토큰 서명하기
Coddy Node.js 여정의 인증과 보안 섹션에 포함된 레슨. 24개 중 15번째.
토큰은 다른 사람이 만들 수 없다면 가치가 있습니다. HS256을 사용하면 jose가 헤더와 페이로드를 서버만 아는 비밀 값과 결합한 뒤 결과를 해시합니다. 그 해시가 서명입니다. 비밀 값이 없다면 일치하는 서명을 만들 수 없습니다.
비밀 값은 환경 변수 process.env.JWT_SECRET에서 가져오며, 코드에서 가져오지 않습니다. 코드는 공유되고, 복사되고, 커밋되기 때문입니다. 이 레슨에서는 JWT_SECRET을 설정해 둡니다. jose는 이 값을 바이트로 원합니다.
const key = new TextEncoder().encode(process.env.INVITE_SECRET);
const invite = await new SignJWT({ room: 'design' })
.setProtectedHeader({ alg: 'HS256' })
.setSubject(guestId)
.setIssuedAt()
.setExpirationTime('2d')
.sign(key);setSubject에는 텍스트를 전달하고, setExpirationTime에는 '30m' 또는 '2d'와 같은 기간을 전달합니다.
챌린지
쉬움전화 앱처럼 API를 호출하는 앱은 브라우저처럼 쿠키를 유지하지 않습니다. 대신 토큰을 제공하세요. 각 요청에 함께 보내는 서명된 JWT입니다. POST /token은 이러한 앱을 위한 로그인입니다.
server.js에서decodeJwt옆에jose의SignJWT를 가져오세요. 경로 위에서 수업에서 보여 준 것처럼process.env.JWT_SECRET을TextEncoder로 바이트로 변환하세요.jose는 바이트로 서명하기 때문입니다. 이 수업에서는JWT_SECRET을 설정해 둡니다.POST /token을 추가하세요. 로그인과 같은 본문{"username": "ada", "password": "correct horse"}을 받고,POST /login과 같은 방식으로 사용자 이름과 비밀번호를 확인합니다. 값이 틀리면 상태 코드 401과{"error": "Wrong username or password"}를 응답하세요.- 값이 맞으면 토큰에 서명하고
{"token": "..."}으로 응답하세요. 토큰은 다음 조건을 충족해야 합니다.- 알고리즘 HS256을 사용하고,
JWT_SECRET바이트로 서명해야 합니다. - 페이로드에
username클레임을 포함해야 합니다. - 사용자의
_id를 텍스트로 주체에 넣어야 합니다.toHexString()을 사용하세요.jose는 텍스트가 아닌 주체를 허용하지 않기 때문입니다. - 생성된 시점으로부터 한 시간 후에 만료되어야 합니다.
이 수업에서 생성되는 토큰은 다음과 같습니다.
eyJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6ImFkYSIsInN1YiI6IjY2ZjFjMmE5ZTRiMGExYjJjM2Q0ZTVmNiIsImlhdCI6MTc5MzM2MTYwMCwiZXhwIjoxNzkzMzY1MjAwfQ.EdCrjAKSjZowcjE3-IjKttBLVK1VKKbL9DPnJDLdgug - 알고리즘 HS256을 사용하고,
직접 해 보기: ‘플레이’를 누르세요. {"username": "ada", "password": "correct horse"}를 사용해 POST /signup을 보내세요. 같은 본문으로 POST /token을 보내면 상태 코드 200과 {"token": "..."}이 반환됩니다. 토큰을 복사한 다음 GET /inspect?token= 뒤에 붙여 보내세요. 다음과 같은 결과가 표시됩니다. {"username": "ada", "sub": "66f1c2a9e4b0a1b2c3d4e5f6", "iat": 1793361600, "exp": 1793365200}: exp는 iat보다 3600초 뒤입니다. {"username": "ada", "password": "wrong horse"}를 사용해 POST /token을 보내면 상태 코드 401이 반환됩니다.
직접 해보기
import express from 'express';
import { decodeJwt } from 'jose';
import { users } from './db.js';
import { hashPassword, verifyPassword } from './passwords.js';
const app = express();
app.use(express.json());
app.post('/signup', async (req, res) => {
const username = String(req.body.username ?? '').trim().toLowerCase();
const password = String(req.body.password ?? '');
if (!username || password.length < 8) {
return res.status(400).json({ error: 'Pick a username and a password of at least 8 characters' });
}
if (await users.findOne({ username })) {
return res.status(409).json({ error: 'That username is taken' });
}
await users.insertOne({ username, passwordHash: await hashPassword(password) });
res.status(201).json({ username });
});
app.post('/login', async (req, res) => {
const username = String(req.body.username ?? '').trim().toLowerCase();
const user = await users.findOne({ username });
if (!user || !(await verifyPassword(String(req.body.password ?? ''), user.passwordHash))) {
return res.status(401).json({ error: 'Wrong username or password' });
}
res.json({ username: user.username });
});
// 토큰에 담긴 내용을 보여줍니다: GET /inspect?token=...
app.get('/inspect', (req, res) => {
try {
res.json(decodeJwt(String(req.query.token ?? '')));
} catch {
res.status(400).json({ error: 'Not a JWT' });
}
});
// 관리자 페이지입니다. 서명을 확인하기 전에는 어떤 클레임도 신뢰할 수 없습니다.
app.get('/admin', (req, res) => {
res.status(403).json({ error: 'Admins only' });
});
app.listen(3000, () => {
console.log('Vault is running on port 3000');
});
인증과 보안의 모든 레슨
직접 연습해 보세요: Express 플레이그라운드