728x90
반응형
1. JWT란
JWT(JSON Web Token)는 당사자 간에 정보를 JSON 객체로 안전하게 전송하기 위한 개방형 표준(RFC 7519)입니다. 디지털 서명이 되어 있어 검증과 신뢰가 가능합니다.
1.1 JWT 구조
xxxxx.yyyyy.zzzzz
│ │ │
Header.Payload.Signature
Header: 토큰 타입, 알고리즘
Payload: 클레임(데이터)
Signature: 서명
1.2 예시
// Header
{
"alg": "HS256",
"typ": "JWT"
}
// Payload
{
"sub": "1234567890",
"name": "홍길동",
"admin": true,
"iat": 1516239022
}
// Signature
HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret
)
2. 설치 및 기본 사용
npm install jsonwebtoken
2.1 토큰 생성
const jwt = require('jsonwebtoken');
const SECRET = 'your-256-bit-secret';
// 기본 토큰 생성
const token = jwt.sign(
{ userId: 123, email: 'user@example.com' },
SECRET
);
console.log(token);
// eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
// 옵션과 함께 생성
const tokenWithOptions = jwt.sign(
{ userId: 123, email: 'user@example.com' },
SECRET,
{
expiresIn: '1h', // 만료 시간
issuer: 'myapp', // 발급자
audience: 'users', // 대상
subject: '123' // 주제 (사용자 ID)
}
);
2.2 토큰 검증
const jwt = require('jsonwebtoken');
// 동기 검증
try {
const decoded = jwt.verify(token, SECRET);
console.log('검증 성공:', decoded);
} catch (error) {
if (error.name === 'TokenExpiredError') {
console.log('토큰 만료');
} else if (error.name === 'JsonWebTokenError') {
console.log('유효하지 않은 토큰');
}
}
// 비동기 검증
jwt.verify(token, SECRET, (err, decoded) => {
if (err) {
console.log('검증 실패:', err.message);
return;
}
console.log('검증 성공:', decoded);
});
// 옵션과 함께 검증
const decoded = jwt.verify(token, SECRET, {
issuer: 'myapp',
audience: 'users'
});
2.3 토큰 디코딩 (검증 없이)
// 서명 검증 없이 페이로드 확인
const decoded = jwt.decode(token);
console.log(decoded);
// 헤더와 페이로드 모두 확인
const complete = jwt.decode(token, { complete: true });
console.log(complete.header);
console.log(complete.payload);
3. Express와 함께 사용
3.1 인증 시스템
const express = require('express');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcrypt');
const app = express();
app.use(express.json());
const SECRET = process.env.JWT_SECRET || 'your-secret-key';
const users = new Map();
// 회원가입
app.post('/register', async (req, res) => {
const { email, password, name } = req.body;
if (users.has(email)) {
return res.status(400).json({ error: 'Email already exists' });
}
const hashedPassword = await bcrypt.hash(password, 10);
const user = {
id: Date.now().toString(),
email,
password: hashedPassword,
name
};
users.set(email, user);
res.status(201).json({ message: 'User registered' });
});
// 로그인
app.post('/login', async (req, res) => {
const { email, password } = req.body;
const user = users.get(email);
if (!user) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const isValid = await bcrypt.compare(password, user.password);
if (!isValid) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// 토큰 생성
const token = jwt.sign(
{
userId: user.id,
email: user.email,
name: user.name
},
SECRET,
{ expiresIn: '24h' }
);
res.json({ token });
});
// 인증 미들웨어
function authenticate(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'No token provided' });
}
const token = authHeader.slice(7);
try {
const decoded = jwt.verify(token, SECRET);
req.user = decoded;
next();
} catch (error) {
return res.status(401).json({ error: 'Invalid token' });
}
}
// 보호된 라우트
app.get('/profile', authenticate, (req, res) => {
res.json({ user: req.user });
});
app.listen(3000);
반응형
4. 리프레시 토큰
4.1 액세스 토큰 + 리프레시 토큰
const ACCESS_SECRET = 'access-secret';
const REFRESH_SECRET = 'refresh-secret';
const refreshTokens = new Set(); // 실제로는 DB에 저장
// 토큰 쌍 생성
function generateTokens(user) {
const accessToken = jwt.sign(
{ userId: user.id, email: user.email },
ACCESS_SECRET,
{ expiresIn: '15m' }
);
const refreshToken = jwt.sign(
{ userId: user.id },
REFRESH_SECRET,
{ expiresIn: '7d' }
);
refreshTokens.add(refreshToken);
return { accessToken, refreshToken };
}
// 로그인
app.post('/login', async (req, res) => {
// ... 인증 로직
const tokens = generateTokens(user);
res.json(tokens);
});
// 토큰 갱신
app.post('/refresh', (req, res) => {
const { refreshToken } = req.body;
if (!refreshToken || !refreshTokens.has(refreshToken)) {
return res.status(401).json({ error: 'Invalid refresh token' });
}
try {
const decoded = jwt.verify(refreshToken, REFRESH_SECRET);
const user = findUserById(decoded.userId);
// 새 액세스 토큰 발급
const accessToken = jwt.sign(
{ userId: user.id, email: user.email },
ACCESS_SECRET,
{ expiresIn: '15m' }
);
res.json({ accessToken });
} catch (error) {
refreshTokens.delete(refreshToken);
res.status(401).json({ error: 'Invalid refresh token' });
}
});
// 로그아웃
app.post('/logout', (req, res) => {
const { refreshToken } = req.body;
refreshTokens.delete(refreshToken);
res.json({ message: 'Logged out' });
});
// 모든 기기에서 로그아웃
app.post('/logout-all', authenticate, (req, res) => {
// 해당 사용자의 모든 리프레시 토큰 삭제
for (const token of refreshTokens) {
try {
const decoded = jwt.verify(token, REFRESH_SECRET);
if (decoded.userId === req.user.userId) {
refreshTokens.delete(token);
}
} catch (e) {
refreshTokens.delete(token);
}
}
res.json({ message: 'Logged out from all devices' });
});
5. 토큰 블랙리스트
const blacklist = new Set(); // 실제로는 Redis 사용 권장
// 토큰 블랙리스트에 추가
function addToBlacklist(token) {
const decoded = jwt.decode(token);
const exp = decoded.exp * 1000; // 만료 시간
blacklist.add(token);
// 만료 시간이 지나면 자동 삭제
setTimeout(() => {
blacklist.delete(token);
}, exp - Date.now());
}
// 블랙리스트 확인 미들웨어
function checkBlacklist(req, res, next) {
const token = req.headers.authorization?.slice(7);
if (blacklist.has(token)) {
return res.status(401).json({ error: 'Token has been revoked' });
}
next();
}
// 로그아웃
app.post('/logout', authenticate, (req, res) => {
const token = req.headers.authorization.slice(7);
addToBlacklist(token);
res.json({ message: 'Logged out' });
});
// 보호된 라우트에 블랙리스트 확인 추가
app.get('/profile', checkBlacklist, authenticate, (req, res) => {
res.json({ user: req.user });
});
6. RS256 (비대칭 키)
const jwt = require('jsonwebtoken');
const fs = require('fs');
// 키 생성: openssl genrsa -out private.pem 2048
// 공개키 추출: openssl rsa -in private.pem -pubout -out public.pem
const privateKey = fs.readFileSync('private.pem');
const publicKey = fs.readFileSync('public.pem');
// 토큰 생성 (개인키 사용)
const token = jwt.sign(
{ userId: 123 },
privateKey,
{ algorithm: 'RS256', expiresIn: '1h' }
);
// 토큰 검증 (공개키 사용)
const decoded = jwt.verify(token, publicKey, {
algorithms: ['RS256']
});
console.log(decoded);
7. 클레임 (Claims)
7.1 등록된 클레임
const token = jwt.sign(
{
// 등록된 클레임 (예약어)
iss: 'myapp', // 발급자 (issuer)
sub: '1234567890', // 주제 (subject, 보통 사용자 ID)
aud: 'users', // 대상 (audience)
exp: Math.floor(Date.now() / 1000) + 3600, // 만료 시간
nbf: Math.floor(Date.now() / 1000), // 사용 시작 시간
iat: Math.floor(Date.now() / 1000), // 발급 시간
jti: 'unique-token-id' // JWT ID (토큰 고유 식별자)
},
SECRET
);
// 또는 옵션으로 설정
const tokenWithOptions = jwt.sign(
{ data: 'value' },
SECRET,
{
issuer: 'myapp',
subject: '1234567890',
audience: 'users',
expiresIn: '1h',
notBefore: '0',
jwtid: 'unique-id'
}
);
7.2 커스텀 클레임
const token = jwt.sign(
{
// 커스텀 클레임
userId: 123,
email: 'user@example.com',
role: 'admin',
permissions: ['read', 'write', 'delete']
},
SECRET,
{ expiresIn: '1h' }
);
8. 에러 처리
function verifyToken(token) {
try {
return { success: true, data: jwt.verify(token, SECRET) };
} catch (error) {
switch (error.name) {
case 'TokenExpiredError':
return {
success: false,
error: 'TOKEN_EXPIRED',
message: '토큰이 만료되었습니다',
expiredAt: error.expiredAt
};
case 'JsonWebTokenError':
return {
success: false,
error: 'INVALID_TOKEN',
message: '유효하지 않은 토큰입니다'
};
case 'NotBeforeError':
return {
success: false,
error: 'TOKEN_NOT_ACTIVE',
message: '아직 활성화되지 않은 토큰입니다',
date: error.date
};
default:
return {
success: false,
error: 'UNKNOWN_ERROR',
message: '알 수 없는 오류가 발생했습니다'
};
}
}
}
// 사용
const result = verifyToken(token);
if (!result.success) {
console.log('토큰 검증 실패:', result.message);
} else {
console.log('토큰 데이터:', result.data);
}
9. 보안 고려사항
// 1. 환경 변수로 비밀키 관리
const SECRET = process.env.JWT_SECRET;
if (!SECRET) {
throw new Error('JWT_SECRET environment variable is required');
}
// 2. 짧은 만료 시간 사용
const token = jwt.sign(payload, SECRET, { expiresIn: '15m' });
// 3. 민감한 정보 포함 금지
// 나쁜 예
jwt.sign({ password: 'secret123' }, SECRET);
// 좋은 예
jwt.sign({ userId: 123, email: 'user@example.com' }, SECRET);
// 4. HTTPS 사용 (토큰 전송 시)
// 5. 토큰 저장 위치
// - 브라우저: HttpOnly 쿠키 권장
// - 모바일: 보안 저장소
// 6. 알고리즘 명시
jwt.verify(token, SECRET, { algorithms: ['HS256'] });
10. 실전 예시: 완전한 인증 시스템
const express = require('express');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcrypt');
const app = express();
app.use(express.json());
const config = {
accessSecret: process.env.ACCESS_SECRET || 'access-secret',
refreshSecret: process.env.REFRESH_SECRET || 'refresh-secret',
accessExpiresIn: '15m',
refreshExpiresIn: '7d'
};
const users = new Map();
const refreshTokens = new Map();
// 헬퍼 함수
function generateAccessToken(user) {
return jwt.sign(
{ userId: user.id, email: user.email, role: user.role },
config.accessSecret,
{ expiresIn: config.accessExpiresIn }
);
}
function generateRefreshToken(user) {
const token = jwt.sign(
{ userId: user.id, tokenVersion: user.tokenVersion || 0 },
config.refreshSecret,
{ expiresIn: config.refreshExpiresIn }
);
refreshTokens.set(token, user.id);
return token;
}
// 미들웨어
function authenticate(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Access token required' });
}
try {
const token = authHeader.slice(7);
const decoded = jwt.verify(token, config.accessSecret);
req.user = decoded;
next();
} catch (error) {
if (error.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Access token expired' });
}
return res.status(401).json({ error: 'Invalid access token' });
}
}
// 라우트
app.post('/auth/register', async (req, res) => {
const { email, password, name } = req.body;
if (users.has(email)) {
return res.status(400).json({ error: 'Email already registered' });
}
const user = {
id: Date.now().toString(),
email,
password: await bcrypt.hash(password, 10),
name,
role: 'user',
tokenVersion: 0
};
users.set(email, user);
res.status(201).json({ message: 'User registered successfully' });
});
app.post('/auth/login', async (req, res) => {
const { email, password } = req.body;
const user = users.get(email);
if (!user || !(await bcrypt.compare(password, user.password))) {
return res.status(401).json({ error: 'Invalid credentials' });
}
res.json({
accessToken: generateAccessToken(user),
refreshToken: generateRefreshToken(user)
});
});
app.post('/auth/refresh', (req, res) => {
const { refreshToken } = req.body;
if (!refreshToken || !refreshTokens.has(refreshToken)) {
return res.status(401).json({ error: 'Invalid refresh token' });
}
try {
const decoded = jwt.verify(refreshToken, config.refreshSecret);
const user = Array.from(users.values()).find(u => u.id === decoded.userId);
if (!user || user.tokenVersion !== decoded.tokenVersion) {
refreshTokens.delete(refreshToken);
return res.status(401).json({ error: 'Token revoked' });
}
res.json({ accessToken: generateAccessToken(user) });
} catch (error) {
refreshTokens.delete(refreshToken);
res.status(401).json({ error: 'Invalid refresh token' });
}
});
app.post('/auth/logout', (req, res) => {
const { refreshToken } = req.body;
refreshTokens.delete(refreshToken);
res.json({ message: 'Logged out' });
});
app.get('/me', authenticate, (req, res) => {
res.json({ user: req.user });
});
app.listen(3000);
결론
JWT는 상태 비저장 인증을 위한 표준 방식입니다. 헤더, 페이로드, 서명으로 구성되며, jsonwebtoken 패키지로 생성과 검증을 수행합니다. 액세스 토큰은 짧게(15분), 리프레시 토큰은 길게(7일) 설정하여 보안과 편의성을 균형있게 유지합니다. 민감한 정보는 페이로드에 포함하지 않고, 비밀키는 환경 변수로 관리해야 합니다.
728x90
반응형
'Node.js' 카테고리의 다른 글
| Node.js의 Socket.IO 사용법 (0) | 2026.03.14 |
|---|---|
| Node.js의 인증 및 인가(Authentication and Authorization) (0) | 2026.03.13 |
| Node.js의 웹소켓(WebSocket) 사용법 (0) | 2026.03.13 |
| Node.js의 GraphQL API 만들기 (0) | 2026.03.12 |
| Node.js의 RESTful API 만들기 (0) | 2026.03.12 |