안녕하세요! JWT 인증 시리즈의 두 번째 글입니다. [1편](링크를 여기에 삽입하세요)에서는 JWT의 기본 개념과 구조, 그리고 작동 원리에 대해 살펴보았습니다. 이번 글에서는 실제로 Node.js와 Express를 사용하여 JWT 인증 시스템을 구현하는 방법을 자세히 알아보겠습니다. TypeScript를 활용하여 타입 안전한 코드를 작성하고, 인증 과정을 미들웨어로 자동화하는 방법도 함께 살펴보겠습니다.
목차
- 개발 환경 설정
- 프로젝트 구조 설계
- 기본 Express 서버 설정
- 사용자 모델 및 데이터베이스 연결
- JWT 인증 구현
- 인증 미들웨어 구현
- 보호된 라우트 구현
- 테스트 및 검증
- 보안 고려사항
- 마무리
개발 환경 설정
먼저 필요한 개발 환경을 설정해 보겠습니다. 우리는 Node.js, TypeScript, Express, 그리고 JWT 관련 패키지를 사용할 것입니다.
프로젝트 초기화
# 프로젝트 디렉토리 생성 및 이동
mkdir jwt-auth-server
cd jwt-auth-server
# npm 초기화
npm init -y
# TypeScript 및 필요한 패키지 설치
npm install express jsonwebtoken bcrypt dotenv cors
npm install -D typescript ts-node @types/node @types/express @types/jsonwebtoken @types/bcrypt @types/cors nodemon
TypeScript 설정
tsconfig.json 파일을 생성하여 TypeScript 설정을 구성합니다:
{
"compilerOptions": {
"target": "es2016",
"module": "commonjs",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"]
}
환경 변수 설정
.env 파일을 생성하여 중요한 설정 값을 관리합니다:
# .env
PORT=3000
JWT_SECRET=your_super_secure_secret_key
JWT_EXPIRES_IN=24h
REFRESH_TOKEN_SECRET=another_super_secure_secret_key
REFRESH_TOKEN_EXPIRES_IN=7d
보안을 위해 .gitignore 파일에 .env를 추가하는 것을 잊지 마세요:
# .gitignore
node_modules
.env
dist
프로젝트 구조 설계
효율적인 코드 관리를 위해 다음과 같은 프로젝트 구조를 설계해 보겠습니다:
jwt-auth-server/
├── src/
│ ├── config/
│ │ └── index.ts # 환경 변수 및 설정
│ ├── controllers/
│ │ └── authController.ts # 인증 관련 컨트롤러
│ ├── middleware/
│ │ └── authMiddleware.ts # JWT 인증 미들웨어
│ ├── models/
│ │ └── userModel.ts # 사용자 모델(인터페이스)
│ ├── routes/
│ │ ├── authRoutes.ts # 인증 관련
│ │ └── userRoutes.ts # 사용자 관련
│ ├── services/
│ │ └── tokenService.ts # 토큰 생성 및 검증
│ ├── utils/
│ │ └── errorHandler.ts # 에러 처리
│ └── app.ts # 메인 애플리케이션
├── tsconfig.json
├── package.json
└── .env
이제 각 파일에 필요한 코드를 작성해 보겠습니다.
기본 Express 서버 설정
먼저 src/config/index.ts 파일에서 환경 변수를 관리합니다:
// src/config/index.ts
import dotenv from 'dotenv';
dotenv.config();
export default {
port: process.env.PORT || 3000,
jwtSecret: process.env.JWT_SECRET || 'fallback_secret_key',
jwtExpiresIn: process.env.JWT_EXPIRES_IN || '24h',
refreshTokenSecret: process.env.REFRESH_TOKEN_SECRET || 'fallback_refresh_secret',
refreshTokenExpiresIn: process.env.REFRESH_TOKEN_EXPIRES_IN || '7d'
};
다음으로 기본 Express 애플리케이션을 설정합니다:
// src/app.ts
import express, { Express } from 'express';
import cors from 'cors';
import config from './config';
import authRoutes from './routes/authRoutes';
import userRoutes from './routes/userRoutes';
const app: Express = express();
// 미들웨어
app.use(cors());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// 라우트
app.use('/api/auth', authRoutes);
app.use('/api/users', userRoutes);
// 기본 라우트
app.get('/', (req, res) => {
res.send('JWT 인증 API 서버가 실행 중입니다.');
});
// 서버 시작
const PORT = config.port;
app.listen(PORT, () => {
console.log(`서버가 포트 ${PORT}에서 실행 중입니다.`);
});
export default app;
사용자 모델 및 데이터베이스 연결
실제 프로덕션 환경에서는 MongoDB, PostgreSQL 등의 데이터베이스를 사용하겠지만, 이 예제에서는 간단하게 메모리 내 배열을 사용하여 사용자 데이터를 관리하겠습니다. 실제 프로젝트에서는 적절한 데이터베이스를 연결하세요.
// src/models/userModel.ts
export interface User {
id: number;
username: string;
email: string;
password: string;
role: 'user' | 'admin';
}
// 메모리 내 사용자 데이터베이스 (실제로는 MongoDB/PostgreSQL 등의 DB를 사용하세요)
export const users: User[] = [
{
id: 1,
username: 'admin',
email: 'admin@example.com',
// 실제로는 해시된 비밀번호를 저장해야 합니다.
password: '$2b$10$ZZZ5bZTS7v.1k9/qY7hpH.6KU4vYxyCVITp/T8WLSQEz9sjEGgJdq', // "admin123"
role: 'admin'
},
{
id: 2,
username: 'user',
email: 'user@example.com',
password: '$2b$10$jV5HQhC5dUVSzOEYAd5N/u.hWH7hJGZ/iQ8jKDjez0xNHMx4VDKFG', // "user123"
role: 'user'
}
];
// 사용자 찾기 함수
export const findUserByEmail = (email: string): User | undefined => {
return users.find(user => user.email === email);
};
export const findUserById = (id: number): User | undefined => {
return users.find(user => user.id === id);
};
JWT 인증 구현
사용자 인증을 위한 토큰 서비스를 구현해 보겠습니다:
// src/services/tokenService.ts
import jwt from 'jsonwebtoken';
import config from '../config';
import { User } from '../models/userModel';
// 토큰에 포함될 사용자 데이터 타입
export interface UserPayload {
id: number;
username: string;
email: string;
role: string;
}
// 액세스 토큰 생성
export const generateAccessToken = (user: User): string => {
const payload: UserPayload = {
id: user.id,
username: user.username,
email: user.email,
role: user.role
};
return jwt.sign(payload, config.jwtSecret, { expiresIn: config.jwtExpiresIn });
};
// 리프레시 토큰 생성
export const generateRefreshToken = (user: User): string => {
const payload = {
id: user.id,
tokenVersion: 1 // 토큰 버전 관리 (무효화에 사용 가능)
};
return jwt.sign(payload, config.refreshTokenSecret, { expiresIn: config.refreshTokenExpiresIn });
};
// 액세스 토큰 검증
export const verifyAccessToken = (token: string): UserPayload | null => {
try {
const decoded = jwt.verify(token, config.jwtSecret) as UserPayload;
return decoded;
} catch (error) {
return null;
}
};
// 리프레시 토큰 검증
export const verifyRefreshToken = (token: string): { id: number; tokenVersion: number } | null => {
try {
const decoded = jwt.verify(token, config.refreshTokenSecret) as { id: number; tokenVersion: number };
return decoded;
} catch (error) {
return null;
}
};
인증 컨트롤러 구현
이제 인증 관련 기능을 처리할 컨트롤러를 구현합니다:
// src/controllers/authController.ts
import { Request, Response } from 'express';
import bcrypt from 'bcrypt';
import { findUserByEmail, User } from '../models/userModel';
import { generateAccessToken, generateRefreshToken } from '../services/tokenService';
// 로그인 컨트롤러
export const login = async (req: Request, res: Response) => {
try {
const { email, password } = req.body;
// 이메일 및 비밀번호 확인
if (!email || !password) {
return res.status(400).json({ message: '이메일과 비밀번호를 모두 입력해주세요.' });
}
// 사용자 찾기
const user = findUserByEmail(email);
if (!user) {
return res.status(401).json({ message: '이메일 또는 비밀번호가 잘못되었습니다.' });
}
// 비밀번호 확인
const isMatch = await bcrypt.compare(password, user.password);
if (!isMatch) {
return res.status(401).json({ message: '이메일 또는 비밀번호가 잘못되었습니다.' });
}
// 토큰 생성
const accessToken = generateAccessToken(user);
const refreshToken = generateRefreshToken(user);
// 응답
res.status(200).json({
message: '로그인 성공',
accessToken,
refreshToken,
user: {
id: user.id,
username: user.username,
email: user.email,
role: user.role
}
});
} catch (error) {
console.error('로그인 에러:', error);
res.status(500).json({ message: '서버 오류가 발생했습니다.' });
}
};
// 새로운 액세스 토큰 발급 컨트롤러
export const refreshToken = (req: Request, res: Response) => {
// 이 부분은 미들웨어에서 처리 후 req.user에 사용자 정보가 저장되어 있다고 가정
const user = req.user as User;
if (!user) {
return res.status(401).json({ message: '인증되지 않은 사용자입니다.' });
}
// 새 액세스 토큰 생성
const accessToken = generateAccessToken(user);
res.status(200).json({
accessToken
});
};
// 테스트용 회원가입 (실제로는 이메일 인증 등의 추가 단계 필요)
export const register = async (req: Request, res: Response) => {
try {
const { username, email, password } = req.body;
// 입력값 확인
if (!username || !email || !password) {
return res.status(400).json({ message: '모든 필드를 입력해주세요.' });
}
// 이메일 중복 확인
if (findUserByEmail(email)) {
return res.status(400).json({ message: '이미 등록된 이메일입니다.' });
}
// 비밀번호 해싱
const salt = await bcrypt.genSalt(10);
const hashedPassword = await bcrypt.hash(password, salt);
// 새 사용자 생성 (실제 DB에 저장 필요)
// 여기서는 간략하게 처리하지만, 실제로는 DB에 저장해야 합니다.
res.status(201).json({ message: '회원가입이 완료되었습니다.' });
} catch (error) {
console.error('회원가입 에러:', error);
res.status(500).json({ message: '서버 오류가 발생했습니다.' });
}
};
인증 미들웨어 구현
이제 보호된 라우트에 접근할 때 JWT를 검증하기 위한 미들웨어를 구현합니다:
// src/middleware/authMiddleware.ts
import { Request, Response, NextFunction } from 'express';
import { verifyAccessToken, verifyRefreshToken } from '../services/tokenService';
import { findUserById, User } from '../models/userModel';
// Request 객체에 user 속성 추가
declare global {
namespace Express {
interface Request {
user?: User;
}
}
}
// 액세스 토큰 검증 미들웨어
export const authenticateToken = (req: Request, res: Response, next: NextFunction) => {
// 헤더에서 Authorization 값 가져오기
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN 형식
if (!token) {
return res.status(401).json({ message: '인증 토큰이 필요합니다.' });
}
// 토큰 검증
const payload = verifyAccessToken(token);
if (!payload) {
return res.status(403).json({ message: '유효하지 않은 토큰입니다.' });
}
// 사용자 정보 조회
const user = findUserById(payload.id);
if (!user) {
return res.status(404).json({ message: '사용자를 찾을 수 없습니다.' });
}
// Request 객체에 사용자 정보 추가
req.user = user;
next();
};
// 리프레시 토큰 검증 미들웨어
export const authenticateRefreshToken = (req: Request, res: Response, next: NextFunction) => {
const { refreshToken } = req.body;
if (!refreshToken) {
return res.status(401).json({ message: '리프레시 토큰이 필요합니다.' });
}
// 리프레시 토큰 검증
const payload = verifyRefreshToken(refreshToken);
if (!payload) {
return res.status(403).json({ message: '유효하지 않은 리프레시 토큰입니다.' });
}
// 사용자 정보 조회
const user = findUserById(payload.id);
if (!user) {
return res.status(404).json({ message: '사용자를 찾을 수 없습니다.' });
}
// Request 객체에 사용자 정보 추가
req.user = user;
next();
};
// 관리자 권한 확인 미들웨어
export const isAdmin = (req: Request, res: Response, next: NextFunction) => {
if (!req.user) {
return res.status(401).json({ message: '인증되지 않은 사용자입니다.' });
}
if (req.user.role !== 'admin') {
return res.status(403).json({ message: '접근 권한이 없습니다.' });
}
next();
};
라우트 구현
이제 인증 관련 라우트를 설정합니다:
// src/routes/authRoutes.ts
import { Router } from 'express';
import { login, refreshToken, register } from '../controllers/authController';
import { authenticateRefreshToken } from '../middleware/authMiddleware';
const router = Router();
// 로그인 라우트
router.post('/login', login);
// 회원가입 라우트
router.post('/register', register);
// 토큰 갱신 라우트
router.post('/refresh-token', authenticateRefreshToken, refreshToken);
export default router;
그리고 사용자 관련 보호된 라우트를 설정합니다:
// src/routes/userRoutes.ts
import { Router } from 'express';
import { authenticateToken, isAdmin } from '../middleware/authMiddleware';
const router = Router();
// 현재 로그인한 사용자 정보 가져오기
router.get('/me', authenticateToken, (req, res) => {
const user = req.user;
if (!user) {
return res.status(404).json({ message: '사용자를 찾을 수 없습니다.' });
}
// 비밀번호는 제외하고 응답
const { password, ...userWithoutPassword } = user;
res.status(200).json(userWithoutPassword);
});
// 모든 사용자 조회 (관리자만 접근 가능)
router.get('/', authenticateToken, isAdmin, (req, res) => {
// 실제로는 DB에서 사용자 목록을 가져와야 합니다.
res.status(200).json({ message: '모든 사용자 목록입니다.' });
});
export default router;
테스트 및 검증
이제 모든 것이 준비되었으니 서버를 실행하고 API를 테스트해 보겠습니다. package.json에 다음 스크립트를 추가합니다:
"scripts": {
"start": "node dist/app.js",
"dev": "nodemon src/app.ts",
"build": "tsc"
}
이제 서버를 실행하고 Postman이나 curl 같은 도구를 사용하여 API를 테스트해 볼 수 있습니다:
로그인 테스트
curl -X POST http://localhost:3000/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email": "user@example.com", "password": "user123"}'
보호된 라우트 접근 테스트
curl -X GET http://localhost:3000/api/users/me \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN_HERE"
토큰 갱신 테스트
curl -X POST http://localhost:3000/api/auth/refresh-token \
-H "Content-Type: application/json" \
-d '{"refreshToken": "YOUR_REFRESH_TOKEN_HERE"}'
추가 개선사항: 토큰 저장 및 자동 갱신
실제 애플리케이션에서는 클라이언트 측에서 토큰을 적절히 저장하고 관리해야 합니다. 다음은 클라이언트 측에서 구현할 수 있는 몇 가지 전략입니다:
토큰 저장 옵션
- localStorage: 가장 간단한 방법이지만 XSS 공격에 취약할 수 있습니다.
// 저장
localStorage.setItem('accessToken', accessToken);
localStorage.setItem('refreshToken', refreshToken);
// 가져오기
const accessToken = localStorage.getItem('accessToken');
- HttpOnly Cookie: XSS 공격으로부터 보호되지만 CSRF 공격에 취약할 수 있습니다. 이를 사용하려면 서버 측 설정이 필요합니다:
// src/controllers/authController.ts의 login 함수 수정
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production', // HTTPS에서만 쿠키 전송
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000 // 7일 (밀리초 단위)
});
- 메모리 변수: 주로 액세스 토큰에 적합합니다. 페이지를 새로고침하면 사라집니다.
토큰 자동 갱신 구현
클라이언트 측에서 Axios 인터셉터를 사용하여 토큰을 자동으로 갱신하는 방법을 구현할 수 있습니다:
import axios from 'axios';
const api = axios.create({
baseURL: 'http://localhost:3000/api'
});
// 요청 인터셉터
api.interceptors.request.use(
(config) => {
const accessToken = localStorage.getItem('accessToken');
if (accessToken) {
config.headers.Authorization = `Bearer ${accessToken}`;
}
return config;
},
(error) => Promise.reject(error)
);
// 응답 인터셉터
api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
// 401 에러이고 재시도하지 않은 경우
if (error.response.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
// 리프레시 토큰으로 새 액세스 토큰 요청
const refreshToken = localStorage.getItem('refreshToken');
const response = await axios.post('http://localhost:3000/api/auth/refresh-token', {
refreshToken
});
// 새 액세스 토큰 저장
const { accessToken } = response.data;
localStorage.setItem('accessToken', accessToken);
// 요청 헤더 업데이트
originalRequest.headers.Authorization = `Bearer ${accessToken}`;
// 원래 요청 재시도
return axios(originalRequest);
} catch (refreshError) {
// 리프레시 토큰도 만료된 경우 로그아웃
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
// 로그인 페이지로 리다이렉트
window.location.href = '/login';
return Promise.reject(refreshError);
}
}
return Promise.reject(error);
}
);
export default api;
보안 고려사항
JWT 인증을 구현할 때 고려해야 할 몇 가지 중요한 보안 사항은 다음과 같습니다:
1. 적절한 토큰 만료 시간 설정
토큰의 만료 시간은 보안과 사용자 경험 사이의 균형을 고려하여 설정해야 합니다:
- 액세스 토큰: 15분~1시간으로 짧게 설정
- 리프레시 토큰: 7일~30일로 더 길게 설정
// 토큰 생성 시 만료 시간 설정
const generateAccessToken = (user: User): string => {
return jwt.sign(
{ id: user.id, username: user.username, role: user.role },
config.jwtSecret,
{ expiresIn: '30m' } // 30분으로 설정
);
};
const generateRefreshToken = (user: User): string => {
return jwt.sign(
{ id: user.id, tokenVersion: 1 },
config.refreshTokenSecret,
{ expiresIn: '7d' } // 7일로 설정
);
};
2. 강력한 서명 알고리즘 사용
가능하면 HS256보다 RS256과 같은 비대칭 알고리즘을 사용하세요. 비대칭 알고리즘은 공개/개인 키 쌍을 사용하여 더 높은 보안을 제공합니다:
// RSA 키 쌍 생성 예시 (실제로는 안전하게 키를 관리해야 함)
import { generateKeyPairSync } from 'crypto';
const { privateKey, publicKey } = generateKeyPairSync('rsa', {
modulusLength: 2048,
publicKeyEncoding: {
type: 'spki',
format: 'pem'
},
privateKeyEncoding: {
type: 'pkcs8',
format: 'pem'
}
});
// RS256 알고리즘으로 토큰 생성
const generateAccessToken = (user: User): string => {
return jwt.sign(
{ id: user.id, username: user.username, role: user.role },
privateKey,
{
algorithm: 'RS256',
expiresIn: '30m'
}
);
};
// 토큰 검증
const verifyAccessToken = (token: string): UserPayload | null => {
try {
const decoded = jwt.verify(token, publicKey, { algorithms: ['RS256'] }) as UserPayload;
return decoded;
} catch (error) {
return null;
}
};
3. 토큰 무효화 메커니즘 구현
JWT는 기본적으로 발급 후 만료될 때까지 유효합니다. 로그아웃 시 토큰을 즉시 무효화하려면 다음과 같은 방법을 고려하세요:
- 토큰 버전 관리: 리프레시 토큰에 버전 번호를 포함시키고 사용자 데이터에 최신 버전을 저장합니다.
// 사용자 모델 확장
interface User {
// 기존 필드들...
tokenVersion: number;
}
// 리프레시 토큰 검증 시 버전 확인
const verifyRefreshToken = async (token: string, user: User): Promise<boolean> => {
try {
const decoded = jwt.verify(token, config.refreshTokenSecret) as { id: number; tokenVersion: number };
// 토큰 버전이 사용자의 현재 버전과 일치하는지 확인
return decoded.id === user.id && decoded.tokenVersion === user.tokenVersion;
} catch (error) {
return false;
}
};
// 로그아웃 시 토큰 버전 증가
const logout = async (userId: number): Promise<void> => {
// 사용자 찾기
const user = await findUserById(userId);
if (user) {
// 토큰 버전 증가
user.tokenVersion += 1;
// 사용자 정보 업데이트 (DB 저장)
}
};
- 토큰 블랙리스트: Redis와 같은 인메모리 데이터베이스를 사용하여 무효화된 토큰을 저장합니다.
// Redis 클라이언트 설정 (예시)
import { createClient } from 'redis';
const redisClient = createClient();
// 토큰 블랙리스트에 추가 (로그아웃 시)
const blacklistToken = async (token: string, expiresIn: number): Promise<void> => {
await redisClient.set(`bl_${token}`, '1');
await redisClient.expire(`bl_${token}`, expiresIn);
};
// 토큰 검증 시 블랙리스트 확인
const verifyAccessToken = async (token: string): Promise<UserPayload | null> => {
try {
// 블랙리스트 확인
const isBlacklisted = await redisClient.get(`bl_${token}`);
if (isBlacklisted) {
return null;
}
// 토큰 검증
const decoded = jwt.verify(token, config.jwtSecret) as UserPayload;
return decoded;
} catch (error) {
return null;
}
};
4. 안전한 토큰 저장 방식 사용
클라이언트 측에서 토큰을 안전하게 저장하는 방법을 고려해야 합니다:
- HttpOnly 쿠키: XSS 공격으로부터 보호할 수 있지만, CSRF 공격에 취약할 수 있습니다.
// 서버 측에서 HttpOnly 쿠키 설정
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production', // HTTPS에서만 전송
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000 // 7일
});
- 액세스 토큰은 메모리에 저장: 리프레시 토큰은 HttpOnly 쿠키에, 액세스 토큰은 JavaScript 변수에 저장하여 XSS와 CSRF 공격 모두에 대응합니다.
// 클라이언트 측 코드 (예: React)
let inMemoryToken: string | null = null;
const login = async (email: string, password: string) => {
const response = await api.post('/auth/login', { email, password });
inMemoryToken = response.data.accessToken;
// refreshToken은 HttpOnly 쿠키로 서버에서 설정됨
};
// API 호출 시 메모리에서 토큰 사용
const fetchData = async () => {
const response = await fetch('/api/data', {
headers: {
'Authorization': `Bearer ${inMemoryToken}`
}
});
return response.json();
};
5. CSRF 방어
CSRF(Cross-Site Request Forgery) 공격을 방지하기 위해 다음과 같은 전략을 사용할 수 있습니다:
- SameSite 쿠키 설정: 현대 브라우저에서 가장 효과적인 CSRF 방어 방법 중 하나입니다.
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: true,
sameSite: 'strict', // 또는 'lax'
maxAge: 7 * 24 * 60 * 60 * 1000
});
- CSRF 토큰 사용: 민감한 작업에는 추가적인 CSRF 토큰을 사용합니다.
// CSRF 토큰 생성
app.get('/csrf-token', (req, res) => {
const csrfToken = crypto.randomBytes(32).toString('hex');
res.cookie('XSRF-TOKEN', csrfToken, {
secure: true,
sameSite: 'strict'
});
res.json({ csrfToken });
});
// CSRF 토큰 검증 미들웨어
const validateCsrfToken = (req: Request, res: Response, next: NextFunction) => {
const csrfCookie = req.cookies['XSRF-TOKEN'];
const csrfHeader = req.headers['x-xsrf-token'];
if (!csrfCookie || !csrfHeader || csrfCookie !== csrfHeader) {
return res.status(403).json({ message: 'CSRF 토큰이 유효하지 않습니다.' });
}
next();
};
// 민감한 작업에 CSRF 검증 적용
app.post('/api/change-password', validateCsrfToken, authenticateToken, (req, res) => {
// 비밀번호 변경 로직
});
6. HTTPS 사용 강제화
모든 인증 관련 통신은 반드시 HTTPS를 통해 이루어져야 합니다:
// HTTPS 리다이렉션 미들웨어
const httpsRedirect = (req: Request, res: Response, next: NextFunction) => {
if (process.env.NODE_ENV === 'production' && !req.secure) {
return res.redirect(`https://${req.headers.host}${req.url}`);
}
next();
};
app.use(httpsRedirect);
7. 토큰 페이로드 최소화
토큰에는 필요한 최소한의 정보만 포함시켜야 합니다:
// 좋은 예: 필요한 정보만 포함
const generateGoodToken = (user: User): string => {
return jwt.sign(
{
sub: user.id, // 사용자 식별자
role: user.role // 권한 정보
},
config.jwtSecret,
{ expiresIn: '30m' }
);
};
// 나쁜 예: 불필요한 정보까지 포함
const generateBadToken = (user: User): string => {
return jwt.sign(
{
id: user.id,
username: user.username,
email: user.email, // 민감한 정보
role: user.role,
preferences: user.preferences, // 불필요한 정보
createdAt: user.createdAt, // 불필요한 정보
lastLogin: user.lastLogin // 불필요한 정보
},
config.jwtSecret,
{ expiresIn: '30m' }
);
};
8. 토큰 탈취 감지 및 방어
이상한 패턴의 토큰 사용을 감지하여 토큰 탈취 가능성에 대응합니다:
// 토큰 사용 로그 기록 및 분석
const tokenUsageLog = async (userId: number, tokenId: string, userAgent: string, ip: string) => {
await db.tokenUsage.create({
userId,
tokenId,
userAgent,
ip,
timestamp: new Date()
});
// 최근 로그 분석
const recentLogs = await db.tokenUsage.findMany({
where: { userId },
orderBy: { timestamp: 'desc' },
take: 10
});
// IP 주소나 사용자 에이전트가 급격히 변경된 경우 감지
const uniqueIps = new Set(recentLogs.map(log => log.ip)).size;
const uniqueAgents = new Set(recentLogs.map(log => log.userAgent)).size;
if (uniqueIps > 3 || uniqueAgents > 3) {
// 의심스러운 활동 감지 - 토큰 강제 만료 또는 계정 잠금 고려
await forceExpireAllTokens(userId);
// 사용자에게 알림 발송
await sendSecurityAlert(userId);
}
};
마무리
이번 글에서는 Node.js와 Express를 활용하여 JWT 인증 시스템을 구현하는 방법을 자세히 살펴보았습니다. 우리는 TypeScript를 사용하여 타입 안전한 코드를 작성했으며, 액세스 토큰과 리프레시 토큰의 활용 방법, 인증 미들웨어 구현, 그리고 보안 고려사항까지 다루었습니다.
지금까지 구현한 JWT 인증 시스템의 주요 특징을 정리해보면 다음과 같습니다:
- 타입 안전성: TypeScript를 활용하여 개발 시점에 많은 오류를 사전에 방지했습니다.
- 이중 토큰 시스템: 짧은 수명의 액세스 토큰과 긴 수명의 리프레시 토큰을 사용하여 보안과 사용자 경험의 균형을 맞추었습니다.
- 미들웨어 기반 인증: Express 미들웨어를 활용하여 보호된 라우트에 대한 접근을 간편하게 제어할 수 있습니다.
- 토큰 자동 갱신: Axios 인터셉터를 사용하여 클라이언트 측에서 토큰을 자동으로 갱신하는 방법을 구현했습니다.
- 권한 기반 접근 제어: 사용자 역할(Role)에 따라 특정 라우트에 대한 접근을 제한할 수 있습니다.
이러한 기본 구현을 바탕으로 다음과 같은 기능들을 추가로 구현해 볼 수 있습니다:
- 비밀번호 재설정 및 이메일 인증: JWT를 활용하여 비밀번호 재설정 링크와 이메일 인증 링크를 안전하게 구현할 수 있습니다.
- 토큰 블랙리스트: 로그아웃 시 토큰을 블랙리스트에 추가하여 토큰이 만료되기 전에 무효화할 수 있습니다.
- 세션 관리: 사용자의 여러 디바이스 로그인을 관리하고, 특정 디바이스에서만 로그아웃할 수 있는 기능을 구현할 수 있습니다.
- 토큰 갱신 최적화: 클라이언트 측에서 토큰 만료 시간을 확인하여 사용자 작업을 방해하지 않는 시점에 토큰을 갱신하는 전략을 구현할 수 있습니다.
JWT 인증은 현대 웹 애플리케이션에서 필수적인 요소이며, 이를 올바르게 구현하면 보안과 사용자 경험을 모두 향상시킬 수 있습니다. 또한 마이크로서비스 아키텍처에서도 서비스 간 인증에 JWT를 활용할 수 있어 확장성 있는 시스템을 구축하는 데 큰 도움이 됩니다.
인공지능(AI)을 활용한 JWT 인증 개선
최근에는 인공지능 기술을 활용하여 JWT 인증 시스템을 더욱 강화하는 방법이 연구되고 있습니다. 예를 들어:
- 행동 기반 인증: 사용자의 타이핑 패턴, 마우스 움직임 등을 분석하여 의심스러운 행동이 감지되면 추가 인증을 요구할 수 있습니다.
// 행동 패턴 분석 예시 (개념적 코드)
interface BehaviorPattern {
typingSpeed: number;
clickPatterns: number[];
navigationFlow: string[];
}
const analyzeBehavior = (currentPattern: BehaviorPattern, storedPatterns: BehaviorPattern[]): number => {
// AI 모델을 사용하여 현재 패턴과 저장된 패턴 간의 유사도 계산
return similarityScore; // 0~1 사이의 값
};
// 인증 미들웨어에 통합
const enhancedAuth = async (req: Request, res: Response, next: NextFunction) => {
// 기본 JWT 인증
const user = await authenticateToken(req);
if (!user) {
return res.status(401).json({ message: '인증이 필요합니다.' });
}
// 행동 패턴 분석 (클라이언트에서 수집된 데이터)
const behaviorData = req.body.behaviorData;
const storedPatterns = await getUserBehaviorPatterns(user.id);
const similarityScore = analyzeBehavior(behaviorData, storedPatterns);
// 의심스러운 행동 감지 시 추가 인증 요구
if (similarityScore < 0.7) {
return res.status(403).json({
message: '추가 인증이 필요합니다.',
requireMFA: true
});
}
next();
};
- 로그 분석을 통한 이상 징후 감지: AI를 활용하여 로그인 패턴을 분석하고 비정상적인 접근을 감지할 수 있습니다.
// 로그인 이벤트 로깅
const logLoginEvent = async (userId: number, metadata: any) => {
await db.loginEvents.create({
userId,
timestamp: new Date(),
ip: metadata.ip,
userAgent: metadata.userAgent,
location: metadata.geoLocation,
deviceId: metadata.deviceId
});
// 비동기적으로 AI 분석 트리거
analyzeLoginPattern(userId).catch(console.error);
};
// AI 기반 로그인 패턴 분석
const analyzeLoginPattern = async (userId: number) => {
const recentEvents = await db.loginEvents.findMany({
where: { userId },
orderBy: { timestamp: 'desc' },
take: 20
});
// AI 모델에 데이터 전송하여 분석
const anomalyScore = await aiService.detectAnomalies(recentEvents);
if (anomalyScore > 0.8) {
// 높은 이상 점수 - 보안 조치 시행
await securityService.lockAccount(userId, 'suspicious_activity');
await notificationService.alertUser(userId, '의심스러운 계정 활동이 감지되었습니다.');
}
};
- 컨텍스트 기반 인증: 사용자의 위치, 시간, 기기 정보 등을 종합적으로 분석하여 인증 강도를 동적으로 조절할 수 있습니다.
// 컨텍스트 기반 인증 레벨 결정
const determineAuthLevel = async (req: Request, user: User): Promise<'low' | 'medium' | 'high'> => {
const context = {
time: new Date(),
ip: req.ip,
location: await geoService.getLocationFromIp(req.ip),
device: req.headers['user-agent'],
requestedResource: req.path
};
// 사용자의 일반적인 패턴 가져오기
const userPatterns = await getUserPatterns(user.id);
// AI 모델을 사용하여 컨텍스트 기반 위험도 평가
const riskScore = await aiService.evaluateRisk(context, userPatterns);
if (riskScore < 0.3) return 'low';
if (riskScore < 0.7) return 'medium';
return 'high';
};
// 인증 미들웨어에 통합
const contextAwareAuth = async (req: Request, res: Response, next: NextFunction) => {
// 기본 JWT 인증
const user = await authenticateToken(req);
if (!user) {
return res.status(401).json({ message: '인증이 필요합니다.' });
}
// 컨텍스트 기반 인증 레벨 결정
const authLevel = await determineAuthLevel(req, user);
// 요청된 리소스의 필요 인증 레벨 확인
const requiredLevel = getResourceAuthLevel(req.path);
// 인증 레벨이 충분한지 확인
if (authLevelValue[authLevel] < authLevelValue[requiredLevel]) {
return res.status(403).json({
message: '추가 인증이 필요합니다.',
requiredLevel,
currentLevel: authLevel
});
}
next();
};
이러한 AI 기술을 JWT 인증 시스템과 결합하면 보안을 크게 강화하면서도 정상적인 사용자에게는 불편함을 최소화할 수 있습니다.
다음 글에서는 프론트엔드(React/Next.js)에서 JWT를 어떻게 관리하고 활용할 수 있는지, 그리고 효과적인 클라이언트 측 인증 상태 관리 방법에 대해 알아보겠습니다.
여러분의 프로젝트에 이 글이 도움이 되었기를 바랍니다. 질문이나 피드백이 있으시면 댓글로 남겨주세요!
참고 자료
'Study > Authentication' 카테고리의 다른 글
| JWT 인증 시리즈 4편: JWT 보안 강화 및 모범 사례 (1) | 2025.04.27 |
|---|---|
| JWT 인증 시리즈 3편: Frontend에서의 JWT 구현 (React/Next.js) (0) | 2025.04.27 |
| JWT 인증 시리즈 1편: JWT 토큰 원리 (0) | 2025.04.27 |