JWT란 무엇인가
Coddy Node.js 여정의 인증과 보안 섹션에 포함된 레슨. 24개 중 14번째.
세션은 모든 요청마다 데이터베이스를 조회해야 합니다. JWT(JSON Web Token)는 필요한 정보를 자체적으로 담고 있으며, 점으로 연결된 세 부분으로 이루어집니다. 각 부분은 문자, 숫자, -, _를 사용해 바이트를 표현하는 방식인 base64url로 작성됩니다. 헤더에는 알고리즘이 지정되고, 페이로드에는 클레임(토큰의 주체를 나타내는 sub, 만료 시점을 나타내는 exp)이 들어가며, 서명은 서버가 토큰을 만들었다는 것을 증명합니다.
Base64url은 인코딩이지 암호화가 아닙니다. 토큰을 가진 사람은 누구나 페이로드를 읽을 수 있습니다.
const payload = token.split('.')[1];
JSON.parse(Buffer.from(payload, 'base64url').toString());
// { sub: '42', name: 'Lin', exp: 1767225600 }JWT 패키지인 jose의 decodeJwt(token)도 같은 작업을 합니다. 서명을 확인하지 않고 페이로드를 읽으며, 텍스트가 JWT가 아니면 오류를 발생시킵니다. 그러므로 페이로드에 비밀을 넣지 말고, decodeJwt가 반환한 클레임을 근거로 어떤 결정도 내리지 마세요. 누구나 원하는 클레임을 작성할 수 있습니다.
챌린지
쉬움이 장에서는 API를 호출하는 앱을 위해 토큰을 사용해 로그인하는 두 번째 방법을 추가합니다. 회원 가입과 로그인이 있지만 세션이나 노트는 없는 더 작은 서버 복사본에서 시작합니다. JWT에는 정보가 담겨 있으며 누구나 그 내용을 읽을 수 있습니다. 토큰에 적힌 내용을 보여 주는 경로를 만들고, 토큰의 주장 내용을 무조건 믿는 관리자 페이지를 닫아 보세요.
server.js에서GET /inspect를 작성하세요. 이 경로는GET /inspect?token=eyJ...와 같이 쿼리 문자열에서 토큰을 읽고, 토큰에 담긴 클레임을 JSON으로 반환합니다. 이미 가져온decodeJwt를 사용해 클레임을 읽으세요. 페이로드가{"sub": "lin", "plan": "free"}인 토큰에 대해 이 경로는 정확히{"sub": "lin", "plan": "free"}를 반환합니다.- 텍스트가 JWT가 아니면
decodeJwt에서 오류가 발생합니다. 오류를 잡아서 상태 코드 400과{"error": "Not a JWT"}를 응답하세요. 예를 들어GET /inspect?token=hello는 400 응답을 받습니다. GET /admin을 살펴보세요. 이 경로는decodeJwt로 토큰을 읽고 토큰에"admin": true라고 적혀 있으면 비밀 정보를 보여 줍니다. 하지만decodeJwt는 서명을 확인하지 않으므로 누구든"admin": true라고 적힌 토큰을 입력할 수 있습니다. 이 서버는 아직 서명을 확인할 수 없습니다. 따라서 토큰이 무엇이든GET /admin은 모든 요청에 상태 코드 403과{"error": "Admins only"}를 응답하도록 하세요. 403 Forbidden은 “이 내용을 볼 수 없습니다”라는 뜻입니다. 4장에서는 실제 관리자만 볼 수 있도록 관리자 페이지를 다시 엽니다.
직접 해 보기: ‘실행’을 누르세요. 요청 표시줄에는 GET /admin?token= 뒤에 다음 토큰이 입력되어 있습니다. eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJtYWxsb3J5IiwiYWRtaW4iOnRydWV9.c2lnbmF0dXJl mallory라는 공격자가 비밀 키 없이 직접 작성한 토큰입니다. 아무것도 바꾸기 전에 요청을 보내 보세요. 시작 코드에서는 {"secret": "The vault key is 42"}를 반환합니다. 코드를 변경한 뒤 다시 ‘실행’을 누르고 같은 요청을 보내 보세요. 상태 코드 403과 {"error": "Admins only"}가 반환됩니다. 같은 토큰 뒤에 GET /inspect?token=을 붙여 보내 보세요. 어떤 키도 없이 읽은 {"sub": "mallory", "admin": true}가 반환됩니다. GET /inspect?token=hello를 보내 보세요. 상태 코드 400과 {"error": "Not a JWT"}가 반환됩니다.
직접 해보기
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) => {
res.status(501).json({ error: 'Not written yet' });
});
// 관리자 페이지.
app.get('/admin', (req, res) => {
const claims = decodeJwt(String(req.query.token ?? ''));
if (claims.admin === true) {
return res.json({ secret: 'The vault key is 42' });
}
res.status(403).json({ error: 'Admins only' });
});
app.listen(3000, () => {
console.log('Vault is running on port 3000');
});
이 레슨에는 짧은 퀴즈가 포함되어 있습니다. 레슨을 시작해 문제를 풀고 진행 상황을 기록하세요.
인증과 보안의 모든 레슨
직접 연습해 보세요: Express 플레이그라운드