JWT 인증 구현
이 문서는 JWT(JSON Web Token)를 사용한 사용자 인증 구현에 대한 세부 사항을 설명합니다.
참고: 이 문서에서 사용되는 JWK(JSON Web Key) 관리 및 RS256 서명/검증 메커니즘은 JWK 관리 서비스 구현 가이드에서 중앙 집중적으로 처리됩니다. 이 문서는 사용자 인증(액세스 토큰, 리프레시 토큰)에 특화된 내용에 초점을 맞춥니다.
목차
개요
AuthService는 JWT 토큰을 사용한 사용자 인증 서비스를 제공합니다. 주요 기능은 다음과 같습니다:
- 사용자 로그인 처리 (별도 PasswordAuthService 등 연동)
- 사용자 Access Token과 Refresh Token 쌍 생성 (
generateTokens) - 사용자 Access Token 또는 Refresh Token 개별 생성 (
createJWT- 레거시 또는 특정 용도) - Access Token 검증 (
authJWT) - Refresh Token을 이용한 Access Token 갱신 (
refreshToken)
구현 특징:
- RS256 알고리즘 기반 비대칭 키 암호화 (JWK 사용)는 JWK 관리 서비스를 통해 처리됩니다.
- Access Token에는 사용자 식별자, 역할(Role), 권한(Permission) 등 인가에 필요한 정보를 포함할 수 있습니다. (페이로드는
DtxClaims인터페이스 참조) - Refresh Token은 안전하게 DB에 저장 및 관리되며, 토큰 회전(Rotation) 정책을 지원할 수 있습니다.
- Redis를 사용한 Access Token 캐싱으로 검증 성능을 최적화합니다.
- 환경(dev/prod)에 따른 검증 정책을 적용할 수 있습니다.
AuthService 구현
// 파일 경로: libs/feature/auth/src/lib/application/services/auth.service.ts (예시)
import { Injectable, InternalServerErrorException, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt'; // HS256 백업 검증용
import { EventEmitter2 } from '@nestjs/event-emitter';
import { PrismaService } from '@core/database';
import { RedisService } from '@core/redis';
import { JwkManagementService } from './jwk-management.service'; // JWK 관리 서비스
import { TimeMachineService } from '@dta-wide-mono/supporting/time-machine'; // TimeMachine 서비스 주입
import { AUTH_CONSTANTS } from '../constants/auth.constants'; // 상수 관리 (예시)
import { DtxClaims } from '@shared-contracts/auth/dto'; // 통합된 DtxClaims 인터페이스
import { TokenResponse } from '../interfaces/token-response.interface'; // 반환 타입 인터페이스
import { v4 as uuidv4 } from 'uuid';
import { decode, JsonWebTokenError, TokenExpiredError } from 'jsonwebtoken';
import { Logger } from '@nestjs/common'; // 로깅 추가
@Injectable()
export class AuthService {
private readonly logger = new Logger(AuthService.name); // 로거 인스턴스화
private readonly accessTokenExpirationMs: number;
private readonly refreshTokenExpirationMs: number;
private readonly issuer: string;
private readonly audience: string[];
private readonly userAccessTokenCacheDbKeyPrefix: string; // Redis 캐시 키 접두사
private readonly env: string;
private readonly jwtSignature: string; // HS256 백업 검증용
constructor(
private readonly prisma: PrismaService,
private readonly jwtService: JwtService,
private readonly redisService: RedisService,
private readonly configService: ConfigService,
private readonly eventEmitter: EventEmitter2,
private readonly jwkManagementService: JwkManagementService,
private readonly timeMachineService: TimeMachineService // TimeMachineService 주입
) {
// 설정값 로드 (예시)
this.accessTokenExpirationMs = this.configService.get<number>('auth.jwt.access.expiresInMs', AUTH_CONSTANTS.ACCESS_TOKEN_EXPIRATION);
this.refreshTokenExpirationMs = this.configService.get<number>('auth.jwt.refresh.expiresInMs', AUTH_CONSTANTS.REFRESH_TOKEN_EXPIRATION);
this.issuer = this.configService.get<string>('auth.jwt.issuer', AUTH_CONSTANTS.ISSUER);
this.audience = this.configService.get<string[]>('auth.jwt.audience', AUTH_CONSTANTS.AUDIENCE);
this.userAccessTokenCacheDbKeyPrefix = this.configService.get<string>('cache.prefix.userAccessToken', 'tokens:user:access:');
this.env = this.configService.get<string>('DEPLOY_ENV', 'dev');
this.jwtSignature = this.configService.get<string>('JWT_SECRET', 'TEST-SECRET');
}
// --- 주요 공개 메서드 ---
/**
* Access Token과 Refresh Token 쌍을 생성합니다. (주 사용 방식)
* @param userId 사용자 식별자 (문자열)
* @param deviceId 디바이스 식별자
* @param additionalPayload Access Token에 추가할 페이로드 (예: roles, permissions)
* @returns 생성된 토큰 정보 (TokenResponse)
*/
async generateTokens(
userId: string,
deviceId: string,
additionalPayload?: { roles?: string[]; permissions?: string[]; consents?: string[] } // consents 추가
): Promise<TokenResponse> { /* 구현은 아래 "JWT 토큰 생성 프로세스" 섹션 참조 */ }
/**
* 단일 JWT 토큰(액세스 또는 리프레시)을 생성합니다. (레거시 또는 특정 용도)
* @param accountId 계정 ID (숫자 - 레거시)
* @param userCycleId 사용자 주기 ID (레거시)
* // ... 기타 레거시 파라미터 ...
* @returns 생성된 JWT 문자열
*/
async createJWT(accountId: number, userCycleId: number, /* ... */): Promise<string> { /* 구현은 아래 "JWT 토큰 생성 프로세스" 섹션 참조 */ }
/**
* JWT 토큰(주로 Access Token)을 검증하고 유효한 경우 페이로드를 반환합니다.
* @param token 검증할 토큰 문자열
* @param expectedTokenType 기대하는 토큰 타입 ('ACCESS', 'REFRESH')
* @returns Base64 인코딩된 페이로드 문자열
*/
async authJWT(token: string, expectedTokenType: 'ACCESS' | 'REFRESH'): Promise<string> { /* 구현은 아래 "JWT 토큰 검증 프로세스" 섹션 참조 */ }
/**
* 리프레시 토큰을 사용하여 새로운 액세스 토큰과 (선택적) 리프레시 토큰을 발급합니다.
* @param refreshToken 유효한 리프레시 토큰 문자열
* @param deviceId 디바이스 ID (선택적, 로깅 및 추적용)
* @returns 새로 발급된 토큰 객체 (TokenResponse)
*/
async refreshToken(refreshToken: string, deviceId?: string): Promise<TokenResponse> { /* 구현은 아래 "JWT 토큰 갱신 프로세스" 섹션 참조 */ }
// --- 내부 Helper 및 검증 메서드 ---
private async storeRefreshToken(jti: string, userId: string, deviceId: string, expiresAt: Date): Promise<void> { /* ... */ }
private async cacheAccessToken(userId: string, token: string, ttlMs: number): Promise<void> { /* ... */ }
private async tryVerifyWithSignatureKey(token: string): Promise<DtxClaims | null> { /* ... */ }
private validateTokenPayload(payload: DtxClaims, tokenType: 'ACCESS' | 'REFRESH') { /* ... */ }
private async validateTokenStorage(payload: DtxClaims, token: string, tokenType: 'ACCESS' | 'REFRESH') { /* ... */ }
private async verifyRefreshToken(token: string): Promise<DtxClaims | null> { /* ... */ }
}
// --- 인터페이스 정의 (별도 파일 분리 권장) ---
// DtxClaims 는 공유 DTO/Interface 경로에서 가져오는 것으로 가정 (@shared-contracts/auth/dto 등)
// 예시: libs/feature/auth/src/lib/interfaces/token-response.interface.ts
export interface TokenResponse {
accessToken: string;
refreshToken: string;
expiresIn: number; // Access Token 만료 시간 (초 단위)
}
// 예시: libs/feature/auth/src/lib/constants/auth.constants.ts
export const AUTH_CONSTANTS = {
ACCESS_TOKEN_EXPIRATION: 30 * 60 * 1000, // 30 minutes
REFRESH_TOKEN_EXPIRATION: 14 * 24 * 60 * 60 * 1000, // 14 days
ISSUER: 'dta-wide',
AUDIENCE: ['api://dta-wide'],
TOKEN_TYPE_ACCESS: 'ACCESS', // 타입을 명확한 문자열로 변경
TOKEN_TYPE_REFRESH: 'REFRESH', // 타입을 명확한 문자열로 변경
ROTATE_REFRESH_TOKEN: true, // 리프레시 토큰 회전 여부 기본값
ENV_PROD: 'prod',
ACCOUNT_TYPE_USER: 1, // 사용자 계정 타입 예시 (필요시 Enum 사용)
};
JWT 토큰 생성 프로세스
3.1 토큰 쌍 생성 (generateTokens)
generateTokens 메소드는 사용자 로그인 성공 시 또는 회원가입 완료 시 호출되어, Access Token과 Refresh Token 쌍을 한 번에 생성하는 주된 방식입니다.
// AuthService 내 generateTokens 구현 예시
async generateTokens(
userId: string,
deviceId: string,
additionalPayload?: { roles?: string[]; permissions?: string[]; consents?: string[] } // consents 추가
): Promise<TokenResponse> {
try { // 오류 처리 추가
const currentTimeMs = await this.timeMachineService.getCurrentTimestamp(userId); // 사용자별 가상 시간 고려 가능
const currentTimeUnix = Math.floor(currentTimeMs / 1000);
// 서명에 사용할 최신 JWK 가져오기
const latestJwk = await this.jwkManagementService.getLatestJwkForSigning();
if (!latestJwk) {
this.logger.error('Failed to get active signing key for token generation.');
throw new InternalServerErrorException('Failed to get active signing key.');
}
const commonPayload = {
iss: this.issuer,
aud: this.audience,
iat: currentTimeUnix,
jti: uuidv4(), // 각 토큰마다 고유 JTI 생성
sub: userId, // 사용자 ID
deviceId: deviceId,
};
// 1. Access Token 생성
const accessTokenPayload: DtxClaims = {
...commonPayload,
exp: currentTimeUnix + Math.floor(this.accessTokenExpirationMs / 1000),
type: 'ACCESS',
roles: additionalPayload?.roles, // 역할 정보 포함
permissions: additionalPayload?.permissions, // 권한 정보 포함
consents: additionalPayload?.consents, // 동의 정보 포함
// 필요한 경우 at, uci, ucs 등 추가 (단, Access Token 목적에 맞게 최소화)
};
const accessToken = await this.jwkManagementService.signWithJwk(accessTokenPayload, latestJwk);
// 2. Refresh Token 생성 (민감 정보 제외)
const refreshTokenPayload: DtxClaims = {
...commonPayload,
// jti는 commonPayload에서 가져오므로 AccessToken과 jti가 다름 (수정: jti 새로 생성)
jti: uuidv4(), // Refresh Token 고유 JTI
exp: currentTimeUnix + Math.floor(this.refreshTokenExpirationMs / 1000),
type: 'REFRESH',
// Refresh Token 에는 roles, permissions, consents 등 불필요/민감 정보 미포함
};
const refreshToken = await this.jwkManagementService.signWithJwk(refreshTokenPayload, latestJwk);
// 3. Refresh Token 저장 (DB)
await this.storeRefreshToken(refreshTokenPayload.jti!, userId, deviceId, new Date(refreshTokenPayload.exp * 1000)); // JTI는 필수라고 가정
// 4. Access Token 캐싱 (Redis)
await this.cacheAccessToken(userId, accessToken, this.accessTokenExpirationMs);
return {
accessToken,
refreshToken,
expiresIn: Math.floor(this.accessTokenExpirationMs / 1000),
};
} catch (error) {
this.logger.error(`Failed to generate tokens for user ${userId}`, error);
throw new InternalServerErrorException('Failed to generate tokens');
}
}
// Helper Method: Refresh Token 저장 (upsert 사용)
private async storeRefreshToken(jti: string, userId: string, deviceId: string, expiresAt: Date): Promise<void> {
try {
await this.prisma.refreshToken.upsert({ // 테이블/필드명은 실제 스키마에 맞게 조정
where: { userId_deviceId: { userId: userId, deviceId: deviceId } },
update: {
id: jti, // JTI 업데이트 (주의: id 필드가 jti를 저장한다고 가정)
tokenHash: await bcrypt.hash(jti, 10), // 실제 토큰 대신 JTI 해시 저장 권장
expiresAt: expiresAt,
status: 'ACTIVE',
updatedAt: new Date(),
},
create: {
id: jti,
userId: userId,
deviceId: deviceId,
tokenHash: await bcrypt.hash(jti, 10), // JTI 해시 저장
expiresAt: expiresAt,
createdAt: new Date(),
updatedAt: new Date(),
status: 'ACTIVE',
},
});
} catch (dbError) {
this.logger.error(`Failed to store refresh token ${jti} for user ${userId} in DB`, dbError);
throw new InternalServerErrorException('Failed to store refresh token');
}
}
// Helper Method: Access Token 캐싱
private async cacheAccessToken(userId: string, token: string, ttlMs: number): Promise<void> {
try {
const cacheKey = `${this.userAccessTokenCacheDbKeyPrefix}${userId}`;
await this.redisService.set(cacheKey, token, ttlMs); // TTL 단위 확인 필요 (ms? seconds?)
} catch (cacheError) {
this.logger.error(`Failed to cache access token for user ${userId} in Redis`, cacheError);
// 캐시 오류는 로깅만 처리할 수 있음
}
}
토큰 쌍 생성 과정 상세 설명
- 시간 및 JWK 준비:
TimeMachineService.getCurrentTimestamp()를 호출하여 현재 유효한 시간(Unix Timestamp)을 얻고,JwkManagementService를 통해 서명용 JWK를 조회합니다. - 공통 페이로드 생성:
iss,aud,iat,sub,deviceId등 Access Token과 Refresh Token에 공통적으로 들어갈 기본 클레임을 구성합니다. 주의: 각 토큰별 고유jti는 별도로 생성합니다. - Access Token 생성:
- 공통 페이로드에 Access Token 고유 정보(
exp,type='ACCESS')와additionalPayload로 받은 정보(roles,permissions,consents등)를 추가하여DtxClaims객체를 완성합니다. jwkManagementService.signWithJwk를 호출하여 서명합니다.
- 공통 페이로드에 Access Token 고유 정보(
- Refresh Token 생성:
- 공통 페이로드에 Refresh Token 고유 정보(
exp,type='REFRESH', 새로운jti)를 추가하여DtxClaims객체를 완성합니다. 민감 정보(roles,permissions등)는 포함하지 않습니다. jwkManagementService.signWithJwk를 호출하여 서명합니다.
- 공통 페이로드에 Refresh Token 고유 정보(
- Refresh Token 저장:
storeRefreshToken헬퍼 메소드를 호출하여 Refresh Token 정보(userId, deviceId, JTI 해시값, expiresAt, status 등)를 데이터베이스에 저장(upsert)합니다. 보안상 실제 토큰 값 대신 JTI의 해시값을 저장하는 것을 권장합니다. - Access Token 캐싱:
cacheAccessToken헬퍼 메소드를 호출하여 생성된 Access Token을 Redis에 캐싱합니다. - 결과 반환: 생성된
accessToken,refreshToken, Access Token 만료 시간(expiresIn)을 담은 객체를 반환합니다.
역할(Role)/권한(Permission)/동의(Consent) 정보 처리
generateTokens는 additionalPayload를 통해 이 정보들을 받아 Access Token 페이로드(DtxClaims)의 roles, permissions, consents 클레임에 포함시킵니다. 이 정보는 API Gateway나 리소스 서버에서 토큰 검증 후 인가(Authorization)에 사용됩니다.
// Access Token 페이로드 예시 (DtxClaims 형태)
{
"sub": "user-123",
"jti": "unique-access-jwt-id",
"iat": 1678886400,
"exp": 1678888200,
"iss": "dta-wide",
"aud": ["api://dta-wide"],
"type": "ACCESS",
"deviceId": "device-abc",
"roles": ["ADMIN", "EDITOR"],
"permissions": ["read:data", "write:config"],
"consents": ["MARKETING_EMAIL"]
}
3.2 단일 토큰 생성 (createJWT - 레거시 또는 특정 용도)
createJWT 메소드는 Access Token 또는 Refresh Token 중 하나만 개별적으로 생성해야 할 때 사용될 수 있습니다. 페이로드는 DtxClaims 형식을 따릅니다.
// AuthService 내 createJWT 구현 예시 (DtxClaims 사용)
async createJWT(
accountId: number,
userCycleId: number,
userCycleStatus: number,
tokenType: 'ACCESS' | 'REFRESH',
accountType: number,
bypassAuth?: boolean,
userPermissionIds?: number[], // 필요시 permissions: string[] 로 변환 필요
isOneTimeUse: boolean = false
): Promise<string> {
try {
const userId = accountId.toString(); // 숫자 ID -> 문자열 ID 변환
const currentTimeMs = await this.timeMachineService.getCurrentTimestamp(userId);
const currentTimeUnix = Math.floor(currentTimeMs / 1000);
// 1. 만료 시간 계산
const expirationDuration = tokenType === 'ACCESS'
? this.accessTokenExpirationMs
: this.refreshTokenExpirationMs;
const expiresAtUnix = currentTimeUnix + Math.floor(expirationDuration / 1000);
// 2. JWT 페이로드(DtxClaims) 생성
const payload: DtxClaims = {
sub: userId,
ai: accountId, // 레거시 필드 유지
uci: userCycleId,
ucs: userCycleStatus,
at: accountType,
env: this.env,
type: tokenType,
iss: this.issuer,
aud: this.audience,
exp: expiresAtUnix,
iat: currentTimeUnix,
jti: uuidv4(),
ba: bypassAuth,
// upi: userPermissionIds, // upi 대신 permissions 사용 권장
isOneTimeUse: isOneTimeUse,
// roles, permissions 등은 이 메소드의 컨텍스트에 맞게 추가/제외
};
// 3. 서명용 JWK 가져오기
const latestJwk = await this.jwkManagementService.getLatestJwkForSigning();
if (!latestJwk) {
this.logger.error('Failed to get active signing key for createJWT.');
throw new InternalServerErrorException('Failed to get active signing key.');
}
// 4. JWT 서명 (RS256)
const token = await this.jwkManagementService.signWithJwk(payload, latestJwk);
// 5. 액세스 토큰의 경우 Redis 캐싱
if (tokenType === 'ACCESS' && !isOneTimeUse) {
await this.cacheAccessToken(userId, token, this.accessTokenExpirationMs);
}
// 6. 리프레시 토큰의 경우 DB 저장은 호출 측 책임
return token;
} catch (error) {
this.logger.error(`Failed to create JWT (${tokenType}) for account ${accountId}`, error);
throw new InternalServerErrorException(`Failed to create JWT ${tokenType} token`);
}
}
JWT 토큰 갱신 프로세스
토큰 갱신 프로세스는 만료된 액세스 토큰을 유효한 리프레시 토큰을 이용해 새로 발급받는 과정입니다.
// AuthService 내 refreshToken 구현 예시
async refreshToken(refreshToken: string, deviceId?: string): Promise<TokenResponse> {
try {
// 1. 리프레시 토큰 자체 검증 (서명, 만료, 타입, 페이로드, DB 존재 여부 등)
const decodedPayload = await this.verifyRefreshToken(refreshToken);
if (!decodedPayload) {
throw new UnauthorizedException('Invalid or expired refresh token');
}
// --- 리프레시 토큰 검증 통과 ---
const userId = decodedPayload.sub;
const currentDeviceId = decodedPayload.deviceId; // Refresh Token 페이로드에서 deviceId 추출
if (!currentDeviceId) { // Refresh Token 에 deviceId 가 없는 경우 (오류 또는 이전 버전 호환성)
this.logger.warn(`Device ID not found in refresh token payload for user ${userId}. Using provided deviceId: ${deviceId}`);
// deviceId 파라미터 사용 또는 오류 처리
if (!deviceId) throw new UnauthorizedException('Device ID missing for token refresh');
}
// 2. 새 액세스 토큰 생성
const currentTimeMs = await this.timeMachineService.getCurrentTimestamp(userId);
const currentTimeUnix = Math.floor(currentTimeMs / 1000);
const latestJwk = await this.jwkManagementService.getLatestJwkForSigning();
if (!latestJwk) {
this.logger.error('Failed to get active signing key for token refresh.');
throw new InternalServerErrorException('Failed to get active signing key.');
}
// 역할/권한 정보 필요 시 DB에서 조회
// const userRoles = await this.userService.getUserRoles(userId); // 예시
// const userPermissions = await this.userService.getUserPermissions(userId); // 예시
const accessTokenPayload: DtxClaims = {
sub: userId,
jti: uuidv4(),
iat: currentTimeUnix,
exp: currentTimeUnix + Math.floor(this.accessTokenExpirationMs / 1000),
iss: this.issuer,
aud: this.audience,
type: 'ACCESS',
deviceId: currentDeviceId || deviceId, // Refresh Token 에 없으면 파라미터 값 사용
// roles: userRoles,
// permissions: userPermissions,
// consents: ... // 필요시 DB 조회
// at, uci, ucs 등은 decodedPayload 에서 가져오거나 필요시 재정의
at: decodedPayload.at,
};
const accessToken = await this.jwkManagementService.signWithJwk(accessTokenPayload, latestJwk);
// 3. 리프레시 토큰 회전(Rotation) 정책 확인
let newRefreshToken = refreshToken; // 기본적으로 기존 토큰 유지
const shouldRotateRefreshToken = this.configService.get<boolean>('auth.feature.rotateRefreshToken', AUTH_CONSTANTS.ROTATE_REFRESH_TOKEN);
if (shouldRotateRefreshToken) {
// 3.1. 새 리프레시 토큰 생성
const newRefreshTokenPayload: DtxClaims = {
sub: userId,
jti: uuidv4(),
iat: currentTimeUnix,
exp: currentTimeUnix + Math.floor(this.refreshTokenExpirationMs / 1000),
iss: this.issuer,
aud: this.audience, // Audience 도 포함
type: 'REFRESH',
deviceId: currentDeviceId || deviceId,
// at 등 필요한 최소 정보 포함
at: decodedPayload.at,
};
newRefreshToken = await this.jwkManagementService.signWithJwk(newRefreshTokenPayload, latestJwk);
// 3.2. 데이터베이스에 새 리프레시 토큰 업데이트 (기존 JTI 기반)
const oldTokenJti = decodedPayload.jti;
if (!oldTokenJti) {
this.logger.error(`Missing JTI in verified refresh token payload for user ${userId}`);
throw new InternalServerErrorException('Missing JTI in refresh token');
}
// storeRefreshToken 은 upsert 로직. 회전을 위해서는 기존 JTI 기반 레코드를 찾아 업데이트 또는 비활성화 후 새 레코드 생성 필요.
// 예시: 기존 JTI 로 찾아서 status=REVOKED 후 새 레코드 생성 또는 아래처럼 upsert 변형
await this.storeRefreshToken(newRefreshTokenPayload.jti!, userId, currentDeviceId || deviceId!, new Date(newRefreshTokenPayload.exp * 1000));
// TODO: 기존 JTI 토큰을 DB에서 명시적으로 비활성화(REVOKED)하는 로직 추가 필요
// 3.3. 토큰 갱신 이벤트 발행 (옵션)
this.eventEmitter.emit('auth.token.refreshed', {
userId: userId,
oldTokenJti: oldTokenJti,
newTokenJti: newRefreshTokenPayload.jti,
deviceId: currentDeviceId || deviceId
});
}
// 4. 새 Access Token 캐싱
await this.cacheAccessToken(userId, accessToken, this.accessTokenExpirationMs);
// 5. 결과 반환
return {
accessToken,
refreshToken: newRefreshToken,
expiresIn: Math.floor(this.accessTokenExpirationMs / 1000)
};
} catch (error) {
this.logger.error(`Failed to refresh token for user (inferred from token)`, error);
if (error instanceof UnauthorizedException || error instanceof InternalServerErrorException) {
throw error;
}
throw new InternalServerErrorException('Failed to refresh token');
}
}
/**
* 리프레시 토큰을 검증하고 페이로드를 반환합니다.
* (서명, 만료, 타입 검증 후 DB 조회 및 비교 포함)
* @param token 검증할 리프레시 토큰 문자열
* @returns 유효한 경우 토큰 페이로드(DtxClaims), 아니면 null
*/
private async verifyRefreshToken(token: string): Promise<DtxClaims | null> {
try {
// 1. authJWT를 사용하여 기본적인 JWT 검증 (서명, 만료, 타입 등) 수행
const base64Payload = await this.authJWT(token, 'REFRESH');
// 2. Base64 디코딩하여 페이로드 객체 얻기
const payloadString = Buffer.from(base64Payload, 'base64').toString('utf8');
const payload = JSON.parse(payloadString) as DtxClaims;
// 3. 데이터베이스에서 리프레시 토큰 확인 (JTI 사용)
const jti = payload.jti;
if (!jti) {
this.logger.warn(`Missing jti in refresh token payload.`);
return null;
}
const refreshTokenRecord = await this.prisma.refreshToken.findUnique({
where: { id: jti },
});
// 4. DB 기록 검증
if (!refreshTokenRecord) {
this.logger.warn(`Refresh token record not found in DB for jti: ${jti}`);
return null;
}
// JTI 해시 비교 (DB에 해시 저장 시)
// const isMatch = await bcrypt.compare(jti, refreshTokenRecord.tokenHash);
// if (!isMatch) {
// this.logger.error(`Refresh token JTI hash mismatch for user: ${payload.sub}. DB JTI: ${refreshTokenRecord.id}, Token JTI: ${jti}. Potential token reuse or theft.`);
// return null;
// }
if (refreshTokenRecord.status !== 'ACTIVE') {
this.logger.warn(`Refresh token status is not ACTIVE for jti: ${jti} (Status: ${refreshTokenRecord.status})`);
return null;
}
// deviceId 일치 여부 확인
if (payload.deviceId && refreshTokenRecord.deviceId !== payload.deviceId) {
this.logger.warn(`Refresh token deviceId mismatch for jti: ${jti}. DB: ${refreshTokenRecord.deviceId}, Token: ${payload.deviceId}`);
return null;
}
// 만료 시간 재확인 (DB vs Payload)
if (refreshTokenRecord.expiresAt.getTime() < Date.now()) {
this.logger.warn(`Refresh token expired based on DB record for jti: ${jti}`);
return null;
}
// 5. 모든 검증 통과, 페이로드 반환
return payload;
} catch (error) {
this.logger.debug('Refresh token verification failed:', error.message);
return null;
}
}
토큰 갱신 시 보안 고려사항 (재강조)
- 리프레시 토큰 회전(Rotation): 보안 강화를 위해 갱신 시 리프레시 토큰도 새로 발급하고 이전 토큰은 **DB에서 명시적으로 비활성화(REVOKED)**하는 것이 강력히 권장됩니다.
- 재사용 감지 및 방지: 회전 정책 사용 시, DB에 저장된 JTI 해시와 제시된 토큰의 JTI를 비교하고, DB 레코드의 상태(
status)가 ACTIVE인지 확인하여 재사용/탈취 시도를 감지하고, 해당 사용자의 모든 활성 세션을 무효화하는 조치를 취해야 합니다. - 안전한 저장: 리프레시 토큰 정보는 데이터베이스에 안전하게 저장되어야 합니다. 실제 토큰 값 대신 JTI의 해시값을 저장하는 것이 좋습니다.
- 수명 관리: 리프레시 토큰 유효 기간을 적절하게 설정합니다.
JWT 토큰 검증 프로세스
토큰 검증 프로세스는 클라이언트로부터 받은 토큰(주로 Access Token)의 유효성을 확인하는 과정입니다.
// AuthService 내 authJWT 구현 예시
async authJWT(token: string, expectedTokenType: 'ACCESS' | 'REFRESH'): Promise<string> {
let payload: DtxClaims | null = null; // 타입 단일화
let verificationError: Error | null = null;
try {
// 1. 토큰 디코딩 (헤더의 kid 추출 목적)
const decodedToken = decode(token, { complete: true });
if (!decodedToken || typeof decodedToken === 'string' || !decodedToken.header || !decodedToken.header.kid) {
throw new UnauthorizedException('Invalid token format or missing kid');
}
const kid = decodedToken.header.kid;
// 2. kid로 검증용 JWK 조회
const jwk = await this.jwkManagementService.findJwkForVerification(kid);
const userIdForTimeMachine = (decode(token) as DtxClaims)?.sub; // 검증 전 sub 추출 시도 (TimeMachine 용)
if (!jwk) {
this.logger.warn(`No JWK found for kid: ${kid}, attempting backup verification.`);
} else {
// 3. JwkManagementService를 사용하여 토큰 검증 (RS256)
try {
// verifyWithJwk 내부에서 TimeMachine 시간 기준으로 만료 확인 필요
payload = await this.jwkManagementService.verifyWithJwk(token, jwk, userIdForTimeMachine) as DtxClaims;
} catch (error) {
verificationError = error;
payload = null;
}
}
// 4. 백업 검증 (HS256)
if (!payload && this.jwtSignature) {
payload = await this.tryVerifyWithSignatureKey(token);
if (!payload && verificationError) {
throw verificationError;
}
}
// 5. 페이로드 최종 확인
if (!payload) {
throw new UnauthorizedException('Invalid token: Verification failed');
}
// 6. 페이로드 내용 검증
this.validateTokenPayload(payload, expectedTokenType);
// 7. 토큰 저장소 검증 (Access Token 캐시 확인)
await this.validateTokenStorage(payload, token, expectedTokenType);
// 8. 검증 성공, 페이로드를 Base64로 인코딩하여 반환
return Buffer.from(JSON.stringify(payload)).toString('base64');
} catch (error) {
this.logger.debug(`Token authentication failed for type ${expectedTokenType}:`, error.message);
if (error instanceof UnauthorizedException || error instanceof JsonWebTokenError || error instanceof TokenExpiredError || error instanceof InternalServerErrorException) {
throw error;
}
throw new InternalServerErrorException('Token authentication failed due to an unexpected error');
}
}
// HS256 백업 검증 로직 (DtxClaims 타입 사용)
private async tryVerifyWithSignatureKey(token: string): Promise<DtxClaims | null> {
try {
const verified = this.jwtService.verify(token, {
secret: this.jwtSignature,
algorithms: ['HS256'],
// TimeMachine 시간 적용 필요 시 clockTimestamp 옵션 사용
// clockTimestamp: await this.timeMachineService.getCurrentTimestampSeconds(...)
});
if (this.env === AUTH_CONSTANTS.ENV_PROD) {
this.logger.warn('HS256 token validation attempted in production environment.');
return null;
}
// DtxClaims 타입인지 더 엄격하게 확인
if (typeof verified === 'object' && verified !== null && verified.sub && verified.type) {
return verified as DtxClaims;
}
this.logger.warn('HS256 verification result is not a valid DtxClaims object.');
return null;
} catch (error) {
this.logger.debug('HS256 verification failed:', error.message);
return null;
}
}
// 페이로드 내용 검증 (DtxClaims 사용)
private validateTokenPayload(payload: DtxClaims, expectedTokenType: 'ACCESS' | 'REFRESH') {
if (!payload) {
throw new UnauthorizedException('Invalid token payload (null)');
}
// 표준 클레임 검증 (iss, aud, sub, type, exp, iat)
if (payload.iss !== this.issuer) {
throw new UnauthorizedException('Invalid token issuer');
}
const audiences = Array.isArray(this.audience) ? this.audience : [this.audience];
const tokenAudience = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
if (!tokenAudience.some(aud => audiences.includes(aud as string))) {
throw new UnauthorizedException('Invalid token audience');
}
if (typeof payload.sub !== 'string' || !payload.sub) {
throw new UnauthorizedException('Invalid or missing sub (subject) claim');
}
if (payload.type !== expectedTokenType) {
throw new UnauthorizedException(`Invalid token type: Expected ${expectedTokenType}, got ${payload.type}`);
}
if (typeof payload.exp !== 'number' || typeof payload.iat !== 'number') {
throw new UnauthorizedException('Invalid or missing exp/iat (expiration/issued at) claims');
}
// 만료 시간 검증은 verifyWithJwk 또는 jwtService.verify 에서 처리됨 (TimeMachine 시간 기준)
// 환경 일치 검증 (운영 환경에서만)
if (this.env === AUTH_CONSTANTS.ENV_PROD && payload.env !== this.env) {
throw new UnauthorizedException('Invalid token environment');
}
// 타입별 필수 커스텀 클레임 존재 여부 확인 (예시)
if (expectedTokenType === 'ACCESS') {
if (!payload.deviceId) { // Access Token 에 deviceId 필수 가정
throw new UnauthorizedException('Missing required deviceId claim in access token payload');
}
} else if (expectedTokenType === 'REFRESH') {
if (!payload.jti) { // Refresh Token 에 jti 필수 가정 (DB 조회용)
throw new UnauthorizedException('Missing required jti claim in refresh token payload');
}
if (!payload.deviceId) { // Refresh Token 에 deviceId 필수 가정
throw new UnauthorizedException('Missing required deviceId claim in refresh token payload');
}
}
}
// 토큰 저장소 검증 (Access Token 캐시 확인)
private async validateTokenStorage(payload: DtxClaims, token: string, tokenType: 'ACCESS' | 'REFRESH') {
if (tokenType === 'ACCESS') {
const userId = payload.sub;
const isOneTime = payload.isOneTimeUse;
if (!userId) {
this.logger.warn('Cannot validate token storage: Missing user identifier (sub) in payload.');
throw new UnauthorizedException('Invalid token payload for storage validation');
}
// 사용자 계정이고 일회용 토큰이 아닌 경우 캐시 검증
const accountType = payload.at; // at 필드 필요
if (accountType === AUTH_CONSTANTS.ACCOUNT_TYPE_USER && !isOneTime) {
const cacheKey = `${this.userAccessTokenCacheDbKeyPrefix}${userId}`;
const storedToken = await this.redisService.get(cacheKey);
if (!storedToken) {
this.logger.warn(`Access token not found in cache for user ${userId}. Potential logout or revocation.`);
throw new UnauthorizedException('Access token not found in cache');
}
if (token !== storedToken) {
this.logger.warn(`Access token mismatch with cache for user ${userId}. Possible old token usage.`);
throw new UnauthorizedException('Access token mismatch with cache');
}
}
}
// 리프레시 토큰은 verifyRefreshToken에서 DB 검증 수행
}
토큰 검증 과정 상세 설명 (DtxClaims 기반)
- 토큰 디코딩: 헤더에서
kid를 추출합니다. - JWK 조회:
kid로 검증용 JWK를 찾습니다. - JWT 검증 (RS256): JWK를 사용하여 토큰 서명 및 표준 클레임(만료 시간은 TimeMachine 기준)을 검증합니다. (
verifyWithJwk내부 처리 가정) - 백업 검증 (HS256): JWK 검증 실패 시, 설정된 경우 HS256으로 백업 검증을 시도합니다.
- 페이로드 확인: 유효한 페이로드(
DtxClaims)를 얻지 못하면 실패 처리합니다. - 페이로드 내용 검증:
validateTokenPayload를 호출하여 페이로드(DtxClaims) 내 클레임(타입, 발급자, 대상, 환경, 필수 커스텀 클레임 등)의 유효성을 검증합니다. - 토큰 저장소 검증:
validateTokenStorage를 호출하여 액세스 토큰의 경우 Redis 캐시와 비교하여 유효성을 확인합니다. - 성공 처리: 모든 검증 통과 시, 페이로드(
DtxClaims)를 Base64 인코딩하여 반환합니다.
오류 코드 관리
JWT 인증 시스템에서 발생하는 다양한 오류는 Auth 도메인의 공통 오류 관리 방식을 따릅니다. 이 방식은 오류 코드와 메시지를 enum으로 중앙 관리하여 일관성과 유지보수성을 높입니다.
JWT 인증 시스템과 관련된 주요 오류 코드는 다음과 같습니다:
| 오류 코드 | 메시지 | HTTP 상태 코드 | 설명 |
|---|---|---|---|
| 2000 | SERVER_ERROR | 500 | 서버 내부 오류 |
| 2023 | INVALID_CREDENTIALS | 401 | 이메일 또는 비밀번호가 올바르지 않습니다 |
| 2024 | ACCOUNT_LOCKED | 403 | 계정이 잠겨 있습니다 |
| 2025 | PASSWORD_EXPIRED | 401 | 비밀번호가 만료되었습니다. 비밀번호를 변경해주세요 |
| 2050 | INVALID_APP_TOKEN | 401 | 토큰이 유효하지 않습니다 (서명 오류, 형식 오류 등) |
| 2051 | APP_TOKEN_EXPIRED | 401 | 토큰이 만료되었습니다 |
| 2052 | REFRESH_TOKEN_INVALID | 401 | 리프레시 토큰이 유효하지 않거나 DB에 없습니다 |
| 2053 | TOKEN_REVOKED | 401 | 토큰이 취소(폐기)되었습니다 (예: 로그아웃, 캐시/DB 불일치) |
| 2054 | UNKNOWN_SIGNING_KEY | 401 | 토큰 서명에 사용된 키를 알 수 없습니다 (kid 불일치 등) |
| 2055 | INVALID_TOKEN_PAYLOAD | 401 | 토큰 페이로드 내용(클레임)이 유효하지 않습니다 |
| 2056 | INVALID_TOKEN_TYPE | 401 | 기대하는 토큰 타입(access/refresh)과 일치하지 않습니다 |
| 2057 | INVALID_TOKEN_ENVIRONMENT | 401 | 토큰이 발급된 환경과 현재 환경이 일치하지 않습니다 |
오류 코드 관리 방식과 구현에 대한 자세한 내용은 오류 관리 가이드를 참조하세요.