본문으로 건너뛰기

JWT 인증 구현

이 문서는 JWT(JSON Web Token)를 사용한 사용자 인증 구현에 대한 세부 사항을 설명합니다.

참고: 이 문서에서 사용되는 JWK(JSON Web Key) 관리 및 RS256 서명/검증 메커니즘은 JWK 관리 서비스 구현 가이드에서 중앙 집중적으로 처리됩니다. 이 문서는 사용자 인증(액세스 토큰, 리프레시 토큰)에 특화된 내용에 초점을 맞춥니다.

목차

  1. 개요
  2. AuthService 구현
  3. JWT 토큰 생성 프로세스
  4. JWT 토큰 갱신 프로세스
  5. JWT 토큰 검증 프로세스
  6. 오류 코드 관리

개요

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);
// 캐시 오류는 로깅만 처리할 수 있음
}
}

토큰 쌍 생성 과정 상세 설명

  1. 시간 및 JWK 준비: TimeMachineService.getCurrentTimestamp()를 호출하여 현재 유효한 시간(Unix Timestamp)을 얻고, JwkManagementService를 통해 서명용 JWK를 조회합니다.
  2. 공통 페이로드 생성: iss, aud, iat, sub, deviceId 등 Access Token과 Refresh Token에 공통적으로 들어갈 기본 클레임을 구성합니다. 주의: 각 토큰별 고유 jti는 별도로 생성합니다.
  3. Access Token 생성:
    • 공통 페이로드에 Access Token 고유 정보(exp, type='ACCESS')와 additionalPayload로 받은 정보(roles, permissions, consents 등)를 추가하여 DtxClaims 객체를 완성합니다.
    • jwkManagementService.signWithJwk를 호출하여 서명합니다.
  4. Refresh Token 생성:
    • 공통 페이로드에 Refresh Token 고유 정보(exp, type='REFRESH', 새로운 jti)를 추가하여 DtxClaims 객체를 완성합니다. 민감 정보(roles, permissions 등)는 포함하지 않습니다.
    • jwkManagementService.signWithJwk를 호출하여 서명합니다.
  5. Refresh Token 저장: storeRefreshToken 헬퍼 메소드를 호출하여 Refresh Token 정보(userId, deviceId, JTI 해시값, expiresAt, status 등)를 데이터베이스에 저장(upsert)합니다. 보안상 실제 토큰 값 대신 JTI의 해시값을 저장하는 것을 권장합니다.
  6. Access Token 캐싱: cacheAccessToken 헬퍼 메소드를 호출하여 생성된 Access Token을 Redis에 캐싱합니다.
  7. 결과 반환: 생성된 accessToken, refreshToken, Access Token 만료 시간(expiresIn)을 담은 객체를 반환합니다.

역할(Role)/권한(Permission)/동의(Consent) 정보 처리

generateTokensadditionalPayload를 통해 이 정보들을 받아 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;
}
}

토큰 갱신 시 보안 고려사항 (재강조)

  1. 리프레시 토큰 회전(Rotation): 보안 강화를 위해 갱신 시 리프레시 토큰도 새로 발급하고 이전 토큰은 **DB에서 명시적으로 비활성화(REVOKED)**하는 것이 강력히 권장됩니다.
  2. 재사용 감지 및 방지: 회전 정책 사용 시, DB에 저장된 JTI 해시와 제시된 토큰의 JTI를 비교하고, DB 레코드의 상태(status)가 ACTIVE인지 확인하여 재사용/탈취 시도를 감지하고, 해당 사용자의 모든 활성 세션을 무효화하는 조치를 취해야 합니다.
  3. 안전한 저장: 리프레시 토큰 정보는 데이터베이스에 안전하게 저장되어야 합니다. 실제 토큰 값 대신 JTI의 해시값을 저장하는 것이 좋습니다.
  4. 수명 관리: 리프레시 토큰 유효 기간을 적절하게 설정합니다.

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 기반)

  1. 토큰 디코딩: 헤더에서 kid를 추출합니다.
  2. JWK 조회: kid로 검증용 JWK를 찾습니다.
  3. JWT 검증 (RS256): JWK를 사용하여 토큰 서명 및 표준 클레임(만료 시간은 TimeMachine 기준)을 검증합니다. (verifyWithJwk 내부 처리 가정)
  4. 백업 검증 (HS256): JWK 검증 실패 시, 설정된 경우 HS256으로 백업 검증을 시도합니다.
  5. 페이로드 확인: 유효한 페이로드(DtxClaims)를 얻지 못하면 실패 처리합니다.
  6. 페이로드 내용 검증: validateTokenPayload를 호출하여 페이로드(DtxClaims) 내 클레임(타입, 발급자, 대상, 환경, 필수 커스텀 클레임 등)의 유효성을 검증합니다.
  7. 토큰 저장소 검증: validateTokenStorage를 호출하여 액세스 토큰의 경우 Redis 캐시와 비교하여 유효성을 확인합니다.
  8. 성공 처리: 모든 검증 통과 시, 페이로드(DtxClaims)를 Base64 인코딩하여 반환합니다.

오류 코드 관리

JWT 인증 시스템에서 발생하는 다양한 오류는 Auth 도메인의 공통 오류 관리 방식을 따릅니다. 이 방식은 오류 코드와 메시지를 enum으로 중앙 관리하여 일관성과 유지보수성을 높입니다.

JWT 인증 시스템과 관련된 주요 오류 코드는 다음과 같습니다:

오류 코드메시지HTTP 상태 코드설명
2000SERVER_ERROR500서버 내부 오류
2023INVALID_CREDENTIALS401이메일 또는 비밀번호가 올바르지 않습니다
2024ACCOUNT_LOCKED403계정이 잠겨 있습니다
2025PASSWORD_EXPIRED401비밀번호가 만료되었습니다. 비밀번호를 변경해주세요
2050INVALID_APP_TOKEN401토큰이 유효하지 않습니다 (서명 오류, 형식 오류 등)
2051APP_TOKEN_EXPIRED401토큰이 만료되었습니다
2052REFRESH_TOKEN_INVALID401리프레시 토큰이 유효하지 않거나 DB에 없습니다
2053TOKEN_REVOKED401토큰이 취소(폐기)되었습니다 (예: 로그아웃, 캐시/DB 불일치)
2054UNKNOWN_SIGNING_KEY401토큰 서명에 사용된 키를 알 수 없습니다 (kid 불일치 등)
2055INVALID_TOKEN_PAYLOAD401토큰 페이로드 내용(클레임)이 유효하지 않습니다
2056INVALID_TOKEN_TYPE401기대하는 토큰 타입(access/refresh)과 일치하지 않습니다
2057INVALID_TOKEN_ENVIRONMENT401토큰이 발급된 환경과 현재 환경이 일치하지 않습니다

오류 코드 관리 방식과 구현에 대한 자세한 내용은 오류 관리 가이드를 참조하세요.