Menu
CoddyTech

토큰 서명하기

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'와 같은 기간을 전달합니다.

challenge icon

챌린지

쉬움

전화 앱처럼 API를 호출하는 앱은 브라우저처럼 쿠키를 유지하지 않습니다. 대신 토큰을 제공하세요. 각 요청에 함께 보내는 서명된 JWT입니다. POST /token은 이러한 앱을 위한 로그인입니다.

  1. server.js에서 decodeJwt 옆에 jose의 SignJWT를 가져오세요. 경로 위에서 수업에서 보여 준 것처럼 process.env.JWT_SECRET을 TextEncoder로 바이트로 변환하세요. jose는 바이트로 서명하기 때문입니다. 이 수업에서는 JWT_SECRET을 설정해 둡니다.
  2. POST /token을 추가하세요. 로그인과 같은 본문 {"username": "ada", "password": "correct horse"}을 받고, POST /login과 같은 방식으로 사용자 이름과 비밀번호를 확인합니다. 값이 틀리면 상태 코드 401과 {"error": "Wrong username or password"}를 응답하세요.
  3. 값이 맞으면 토큰에 서명하고 {"token": "..."}으로 응답하세요. 토큰은 다음 조건을 충족해야 합니다.
    • 알고리즘 HS256을 사용하고, JWT_SECRET 바이트로 서명해야 합니다.
    • 페이로드에 username 클레임을 포함해야 합니다.
    • 사용자의 _id를 텍스트로 주체에 넣어야 합니다. toHexString()을 사용하세요. jose는 텍스트가 아닌 주체를 허용하지 않기 때문입니다.
    • 생성된 시점으로부터 한 시간 후에 만료되어야 합니다.

    이 수업에서 생성되는 토큰은 다음과 같습니다. eyJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6ImFkYSIsInN1YiI6IjY2ZjFjMmE5ZTRiMGExYjJjM2Q0ZTVmNiIsImlhdCI6MTc5MzM2MTYwMCwiZXhwIjoxNzkzMzY1MjAwfQ.EdCrjAKSjZowcjE3-IjKttBLVK1VKKbL9DPnJDLdgug

직접 해 보기: ‘플레이’를 누르세요. {"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 플레이그라운드