이 프로젝트는 이것저것 적용해서 테스트하기 위한 프로젝트입니다. 이번에는 JWT Refresh Token을 적용하려고 합니다.
이번에는 사용자 인증 시스템의 핵심인 Refresh Token을 적용하는 과정을 단계별로 자세히 설명해드리겠습니다. 단순한 JWT 인증에서 한 단계 더 나아가, 보안성과 사용자 경험을 모두 만족시키는 인증 시스템을 만들어보겠습니다.
일반적인 JWT 토큰 시스템의 딜레마를 먼저 살펴보겠습니다.
구분 | Access Token | Refresh Token | 설명 |
---|---|---|---|
목적 | API 접근 권한 | 토큰 갱신 | Access Token은 실제 데이터 접근, Refresh Token은 새 토큰 발급만 |
수명 | 짧음 (15분~1시간) | 길음 (7~30일) | 보안과 편의성의 균형점 |
저장 위치 | 메모리, localStorage | httpOnly 쿠키 권장 | Refresh Token은 XSS 공격 방어 위해 쿠키 사용 |
전송 빈도 | 모든 API 요청 | 갱신 시에만 | 네트워크 노출 최소화 |
검증 방식 | JWT 서명 검증만 | JWT + DB 검증 | Refresh Token은 서버에서 상태 관리 |
무효화 | 불가능 (만료까지 유효) | 즉시 가능 | 로그아웃 시 DB에서 삭제 |
Access Token과 Refresh Token은 반드시 다른 시크릿 키를 사용해야 합니다.
# .env 파일
JWT_ACCESS_SECRET=
JWT_REFRESH_SECRET=
그런 경우는 많이 없지만, DB가 탈취되어도 원본 토큰을 알 수 없도록 해싱처리했습니다. 그리고 JWT 자체 만료 시간과 별도로 DB에서도 관리하기 위해 별도의 필드를 추가했습니다. 만약에 여러 탭에서 동시 로그인이 가능하게 만든다면 Refresh Token을 위한 별도의 테이블을 만들고 User와 1:N 관계를 맺을 수 있습니다.
// prisma/schema.prisma
model User {
id String @id @default(uuid())
email String @unique
password String
nickname String?
socialId String? @unique
refreshToken String? // bcrypt 해싱된 리프레시 토큰
refreshTokenExpiresAt DateTime? // 리프레시 토큰 만료 시간
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("users")
}
Access Token을 Refresh 용도로 사용하는 것을 방지하기 위해 타입별로 다른 시크릿 키 사용하도록 구현했습니다.
그리고 Refresh Token의 원본 토큰은 클라이언트에게, 해시 처리된 것은 DB에 저장합니다.
// src/services/jwt.service.ts
import jwt from 'jsonwebtoken';
import { prisma } from '../utils/prisma.util';
import bcrypt from 'bcrypt';
export class JwtService {
private accessSecret: string;
private refreshSecret: string;
constructor() {
this.accessSecret = process.env.JWT_ACCESS_SECRET || 'your-accessSecret-key';
this.refreshSecret = process.env.JWT_REFRESH_SECRET || 'your-refreshSecret-key';
}
// 액세스 토큰 생성 (30분)
generateAccessToken(userId: string): string {
const payload = {
id: userId,
type: 'access', // 토큰 타입 명시
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 30 * 60, // 30분
};
return jwt.sign(payload, this.accessSecret);
}
// 리프레시 토큰 생성 및 DB 저장
async generateRefreshToken(userId: string): Promise<string> {
const payload = {
id: userId,
type: 'refresh', // 토큰 타입 명시
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60, // 7일
};
const refreshToken = jwt.sign(payload, this.refreshSecret);
// bcrypt로 토큰 해싱
const tokenHash = await bcrypt.hash(refreshToken, 10);
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + 7);
// DB에 해시형태의 리프레시 토큰 저장
await prisma.user.update({
where: { id: userId },
data: {
refreshToken: tokenHash,
refreshTokenExpiresAt: expiresAt,
},
});
return refreshToken; // 원본 토큰 반환 (클라이언트 전송용)
}
// 토큰 쌍 생성 - 로그인/갱신 시 사용
async generateTokens(userId: string) {
const accessToken = this.generateAccessToken(userId);
const refreshToken = await this.generateRefreshToken(userId);
return {
accessToken,
refreshToken,
};
}
}
// src/controllers/auth.controller.ts
export class AuthController {
constructor(
private authService: AuthService,
private jwtService: JwtService
) {}
// Passport Local Strategy를 사용한 로그인
signInWithPassport = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
passport.authenticate('local', async (error: any, user: any, info: any) => {
if (error) {
return next(error);
}
if (!user) {
const errorResponse: ErrorResponseDTO = {
success: false,
message: info?.message || '로그인에 실패했습니다.',
};
res.status(401).json(errorResponse);
return;
}
try {
// JWT 토큰 생성 (JwtService 통해서)
const { accessToken, refreshToken } = await this.jwtService.generateTokens(user.id);
const { password: _, ...userWithoutPassword } = user;
const successResponse: ApiResponse<AuthResponseDTO> = {
success: true,
message: '로그인이 완료되었습니다.',
data: {
user: userWithoutPassword,
accessToken,
refreshToken,
},
};
res.json(successResponse);
} catch (error) {
next(error);
}
})(req, res, next);
};
}
JWT Service의 verifyRefreshToken() 메서드를 통해 JWT 서명 검증, 토큰 타입 확인, DB 확인, 토큰 해시 비교, 만료 시간 확인 과정을 거칩니다.
// src/middlewares/auth.middleware.ts
// Refresh Token 전용 미들웨어
export const authenticateRefreshToken = async (
req: Request,
res: Response,
next: NextFunction
) => {
try {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({
success: false,
message: 'Refresh Token이 필요합니다.',
});
}
const token = authHeader.substring(7);
const jwtService = new JwtService();
// 복잡한 검증 과정 (JWT + DB + 해시 비교)
const decoded = await jwtService.verifyRefreshToken(token);
(req as AuthenticatedRequest).user = { id: decoded.id };
(req as AuthenticatedRequest).refreshToken = token;
next();
} catch (error) {
return res.status(401).json({
success: false,
message: '토큰 인증에 실패했습니다.',
});
}
};
// 리프레시 토큰 검증 (다층 보안)
async verifyRefreshToken(refreshToken: string): Promise<any> {
try {
// 1. JWT 서명 검증
const decoded = jwt.verify(refreshToken, this.refreshSecret) as any;
// 2. 토큰 타입 확인
if (decoded.type !== 'refresh') {
throw new Error('유효하지 않은 토큰 타입입니다.');
}
// 3. DB에서 사용자와 해시된 토큰 조회
const user = await prisma.user.findUnique({
where: { id: decoded.id },
select: {
id: true,
refreshToken: true,
refreshTokenExpiresAt: true,
},
});
if (!user || !user.refreshToken || !user.refreshTokenExpiresAt) {
throw new Error('유효하지 않은 리프레시 토큰입니다.');
}
// 4. bcrypt로 토큰 해시 비교
const isTokenValid = await bcrypt.compare(refreshToken, user.refreshToken);
if (!isTokenValid) {
throw new Error('유효하지 않은 리프레시 토큰입니다.');
}
// 5. 만료 시간 확인
if (new Date() > user.refreshTokenExpiresAt) {
await this.revokeRefreshToken(user.id);
throw new Error('만료된 리프레시 토큰입니다.');
}
return { id: user.id };
} catch (error) {
throw new Error('유효하지 않은 리프레시 토큰입니다.');
}
}
// 토큰 재발급
refreshToken = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const user = req.user as any;
// 새로운 토큰 쌍 생성 (간단한 재생성)
const tokens = await this.jwtService.generateTokens(user.id);
const successResponse: ApiResponse = {
success: true,
message: '토큰이 갱신되었습니다.',
data: {
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
},
};
res.json(successResponse);
} catch (error) {
if (error instanceof Error) {
const errorResponse: ErrorResponseDTO = {
success: false,
message: error.message,
};
res.status(401).json(errorResponse);
return;
}
next(error);
}
};