앱 토큰 구현 가이드
이 문서는 앱 토큰(App Token) 인증 구현에 대한 세부 사항을 설명합니다. 이 문서는 앱 토큰 기술 명세에 정의된 명세를 실제로 구현하기 위한 가이드입니다. 앱 토큰은 앱 인증 시스템 구현의 마지막 단계에서 발급되며, 앱의 무결성 검증이 완료된 후 사용됩니다.
참고: 이 문서에서 사용되는 JWT(JSON Web Token) 및 JWK(JSON Web Key)를 이용한 RS256 서명 및 검증 메커니즘에 대한 상세 기술 설명은 JWK 관리 서비스 구현 가이드 문서를 참조하세요. 이 문서는 앱 토큰에 특화된 구현 내용에 초점을 맞춥니다.
목차
- 개요
- 디바이스 ID 관리
- AppTokenService 구현
- 앱 토큰 생성 프로세스
- 앱 토큰 검증 프로세스
- 앱 토큰 갱신 프로세스
- 앱 인증 시스템과의 통합
- 보안 고려사항
- 환경 변수 설정
- 오류 코드 관리
- 참조 문서
- 변경 이력
참조 문서
- JWK 관리 서비스 구현 가이드: /domains/common/core-domains/auth/technical-docs/implementation-jwk-management
- JWT 인증 구현: /domains/common/core-domains/auth/technical-docs/implementation-jwt-authentication
개요
AppTokenService는 앱 토큰을 사용한 인증 서비스를 제공합니다. 주요 기능은 다음과 같습니다:
- 앱 토큰 생성 (RS256 알고리즘 사용)
- 앱 토큰 검증
- 앱 토큰 갱신
- 디바이스 ID 관리 및 검증 (AppTokenService 자체보다는 DeviceIdentifierService와 연계)
구현 특징:
- 사용자 액세스 토큰과 동일하게 RS256 알고리즘을 사용한 비대칭 키 암호화로 보안성이 높습니다. (상세 메커니즘은 JWK 관리 서비스 구현 가이드 참조)
- 디바이스 ID를 포함하여 특정 디바이스에서만 토큰을 사용할 수 있도록 합니다.
- Redis(
CacheManager)를 사용한 토큰 캐싱으로 검증 성능을 최적화합니다. - 앱 챌린지-응답 인증 이후에만 발급되어 앱의 무결성을 보장합니다.
디바이스 ID 관리
앱 토큰은 특정 디바이스에서만 유효하도록 디바이스 ID와 연결됩니다. 디바이스 ID의 생성(해싱), 검증, 저장, 블랙리스트 관리 등 핵심 로직은 DeviceIdentifierService 에서 담당합니다. 이 서비스는 앱 인증 시스템 구현 가이드 문서에 상세히 설명되어 있습니다.
AppTokenService는 인증 과정에서 DeviceIdentifierService와 협력하여 디바이스 ID의 유효성을 확인하고, 앱 토큰 페이로드에 이 ID를 포함시킵니다.
참고: 아래
AppTokenService코드 예시에서는 설명을 위해 파라미터 등으로deviceIdValue: string형태의 값을 사용합니다. 실제 구현에서는 이 ID 값이DeviceIdentifierService등을 통해 검증된 값이라고 가정합니다.
디바이스 ID 관리 상세 설명 (DeviceIdentifierService 기준)
DeviceIdentifierService는 다음과 같은 주요 기능을 수행합니다:
- 디바이스 정보 수집 및 해싱: 클라이언트로부터 받은 디바이스 정보(UUID, 플랫폼, 버전 등)를 정규화하고 SHA-256으로 해싱하여 고유한 디바이스 ID를 생성합니다. (상세 로직은
DeviceIdentifierService.hashDeviceId참조) - 디바이스 ID 유효성 검증: 클라이언트가 전달한 해시된 디바이스 ID가 올바른 형식(64자 16진수)인지, 그리고 서버에서 계산한 해시 값과 일치하는지 검증합니다. (상세 로직은
DeviceIdentifierService.validateDeviceId참조) - 디바이스 정보 저장 및 관리: 검증된 디바이스 정보를 데이터베이스에 저장하고, 필요시 블랙리스트 상태 등을 관리합니다. (
DeviceIdentifierRepository사용)
AppTokenService는 토큰 생성 또는 검증 시 DeviceIdentifierService를 호출하여 디바이스가 유효하고 블랙리스트에 없는지 등을 확인할 수 있습니다.
AppTokenService 구현
// 파일 경로: libs/feature/auth/src/lib/application/services/app-token.service.ts (예시)
import { Injectable, Logger, InternalServerErrorException, UnauthorizedException, BadRequestException, Inject } from '@nestjs/common';
import { ConfigService } from '@core/config';
import { JwtService } from '@nestjs/jwt'; // HS256 백업 검증 및 기본 JWT 기능
import { EventEmitter2 } from '@nestjs/event-emitter';
import { PrismaService } from 'path/to/prisma.service'; // 실제 경로로 수정
import { CACHE_MANAGER } from '@nestjs/cache-manager'; // CACHE_MANAGER import 추가
import { Cache } from 'cache-manager'; // Cache 타입 import 추가
import { JwkManagementService } from '../../domain/services/jwk-management.service'; // 실제 경로로 수정
import { AppTokenPayload } from '../interfaces/app-token-payload.interface';
import { v4 as uuidv4 } from 'uuid';
import { decode } from 'jsonwebtoken'; // JWT 디코딩용
@Injectable()
export class AppTokenService {
private readonly logger = new Logger(AppTokenService.name);
private readonly appTokenExpiration: number;
private readonly appTokenCacheDbKeyPrefix: string;
private readonly env: string;
private readonly appTokenSecret: string; // HS256 백업 검증용
constructor(
private prisma: PrismaService,
private jwtService: JwtService,
@Inject(CACHE_MANAGER) private cacheManager: Cache, // CACHE_MANAGER 주입 추가
private configService: ConfigService,
private eventEmitter: EventEmitter2,
private jwkManagementService: JwkManagementService
) {
this.appTokenExpiration = this.configService.get<number>('APP_TOKEN_EXPIRATION', 7 * 24 * 60 * 60 * 1000); // 기본 7일
this.appTokenCacheDbKeyPrefix = this.configService.get<string>('CACHE_PREFIX_APP_TOKEN', 'tokens:app:');
this.env = this.configService.get<string>('DEPLOY_ENV', 'dev');
this.appTokenSecret = this.configService.get<string>('APP_TOKEN_SECRET', 'APP-TOKEN-SECRET');
}
// 주요 메서드들 (아래 섹션에서 상세 구현)
async generateAppToken(appId: string, deviceIdValue: string): Promise<{ token: string; secret: string }> { /* ... */ }
async validateAppToken(token: string): Promise<{ isValid: boolean; appId?: string; deviceId?: string; }> { /* ... */ }
async renewAppToken(token: string): Promise<string> { /* ... */ }
async revokeAppToken(tokenId: string): Promise<boolean> { /* 구현 생략 */ }
// 앱 토큰 특화 비공개 메서드
private async storeTokenInDatabase(
tokenId: string,
appId: string,
token: string, // 서명된 JWT 문자열
deviceIdValue: string, // 해시된 디바이스 ID 문자열
expiresAt: Date
): Promise<void> {
try {
await this.prisma.appToken.create({ // 'appToken'은 실제 Prisma 모델 이름으로 가정
data: {
id: tokenId, // 토큰 고유 ID (jti)
appId, // 앱 식별자
token, // 서명된 토큰 문자열 (보안 고려 시 암호화 필요)
deviceId: deviceIdValue, // 디바이스 ID 문자열
issuedAt: new Date(), // 토큰 발급 시간
expiresAt, // 토큰 만료 시간
lastUsedAt: null, // 초기에는 null
status: 'ACTIVE' // 초기 상태 (예: ACTIVE, REVOKED, EXPIRED)
}
});
this.logger.debug(`Stored app token in DB for jti: ${tokenId}`);
} catch (error) {
this.logger.error(`Failed to store app token in DB for jti: ${tokenId}`, error);
// DB 저장 실패 시 토큰 생성 실패로 이어지도록 예외를 다시 throw 할 수 있음
throw new InternalServerErrorException('Failed to store app token metadata');
}
}
private async updateTokenLastUsed(tokenId: string): Promise<void> { /* ... */ }
private async tryVerifyWithSignatureKey(token: string): Promise<AppTokenPayload | null> { /* ... */ }
}
앱 토큰 생성 프로세스
앱 토큰 생성 프로세스는 앱의 ID와 디바이스 정보를 포함한 토큰을 생성하는 과정입니다.
참고: 이 섹션은 앱 토큰 기술 명세 > 앱 토큰 발급 섹션에서 정의된 요구사항을 실제로 구현하는 방법을 설명합니다. 앱 토큰 발급 전, 앱 인증 시스템 구현 과정을 통해 디바이스 ID의 유효성이 검증되어야 합니다.
// 파일 경로: libs/feature/auth/src/lib/application/services/app-token.service.ts (예시)
// In AppTokenService
/**
* 앱 토큰을 생성합니다.
* @param appId 앱 식별자
* @param deviceIdValue 유효성이 확인된 디바이스 ID 문자열
* @returns 생성된 앱 토큰
*
* 이 함수는 [앱 토큰 기술 명세](/domains/common/core-domains/auth/technical-docs/spec-app-token#앱-토큰-발급)에서
* 정의된 앱 토큰 발급 요구사항을 구현합니다.
* 디바이스 ID 유효성 검증은 이 메서드 호출 전에 완료되어야 합니다.
*/
async generateAppToken(appId: string, deviceIdValue: string): Promise<{ token: string; secret: string }> {
try {
// 1. 고유 토큰 ID 생성 (예: UUID)
const tokenId = uuidv4();
// 2. 현재 시간 및 만료 시간 계산
const currentTimeMs = Date.now();
const currentTimeUnix = Math.floor(currentTimeMs / 1000);
const expiresAtUnix = currentTimeUnix + Math.floor(this.appTokenExpiration / 1000);
// 3. 앱 토큰 페이로드 생성
const payload: AppTokenPayload = {
sub: appId, // Subject: 앱 ID
jti: tokenId, // JWT ID: 토큰 고유 식별자
deviceId: deviceIdValue, // 디바이스 식별자 (문자열)
iat: currentTimeUnix, // Issued At: 발급 시간
exp: expiresAtUnix, // Expiration Time: 만료 시간
env: this.env // 환경 정보 (dev/prod 등)
// aud, iss 등 필요한 표준 클레임 추가 가능
};
// 4. 서명에 사용할 최신 JWK 가져오기
const latestJwk = await this.jwkManagementService.getLatestJwkForSigning();
// 5. JwkManagementService를 사용하여 JWT 서명 (RS256)
const token = await this.jwkManagementService.signWithJwk(payload, latestJwk);
// 6. 데이터베이스에 토큰 정보 저장 (앱 토큰 고유 로직)
const expiresAt = new Date(currentTimeMs + this.appTokenExpiration);
await this.storeTokenInDatabase(tokenId, appId, token, deviceIdValue, expiresAt);
// 7. CacheManager를 사용하여 토큰 캐싱 (빠른 검증용)
await this.cacheManager.set(
`${this.appTokenCacheDbKeyPrefix}${tokenId}`,
token,
this.appTokenExpiration // TTL 설정 (밀리초 단위)
);
// 8. 토큰 발급 이벤트 발행
this.eventEmitter.emit('app.token.created', {
tokenId,
appId,
deviceId: deviceIdValue
});
// 환경 변수에서 앱 시크릿 가져오기
const appSecret = this.configService.getRequired<string>('app.security.appSecret');
return { token, secret: appSecret };
} catch (error) {
this.logger.error('Failed to generate app token', error);
throw new InternalServerErrorException('Failed to generate app token');
}
}
앱 토큰 생성 과정 상세 설명
- 토큰 ID 생성: UUID 등을 사용하여 각 토큰의 고유 식별자(
jti)를 생성합니다. - 만료 시간 계산: 설정된 유효 기간 (
appTokenExpiration)에 따라 토큰의 만료 시간(exp)을 계산합니다. - 앱 토큰 페이로드 생성: 앱 ID(
sub), 토큰 ID(jti), 검증된 디바이스 ID 문자열(deviceId), 발급 시간(iat), 만료 시간(exp), 환경 정보(env) 등 앱 토큰에 필요한 정보를 담아 페이로드를 구성합니다. - 서명용 JWK 조회:
JwkManagementService.getLatestJwkForSigning()를 호출하여 서명에 사용할 최신 JWK를 안전하게 가져옵니다. - JWT 서명 (RS256):
JwkManagementService.signWithJwk()를 호출하여 생성된 페이로드를 가져온 JWK의 Private Key를 사용하여 RS256 알고리즘으로 서명합니다. (상세 서명 메커니즘은 JWK 관리 서비스 구현 가이드 참조) - 데이터베이스 저장: 생성된 토큰 정보(ID, 앱 ID, 토큰 문자열, 디바이스 ID, 만료 시간, 상태 등)를
appToken테이블에 저장하여 영구 관리합니다. - 토큰 캐싱: 빠른 검증을 위해
CacheManager를 사용하여 Redis에 토큰 ID를 키로 하여 토큰 문자열을 캐싱합니다. 만료 시간(TTL)을 설정하여 자동으로 삭제되도록 합니다. - 이벤트 발행: 토큰 생성 이벤트를 발행하여 관련 시스템(로깅, 모니터링 등)에 알립니다.
앱 토큰 검증 프로세스
앱 토큰 검증 프로세스는 클라이언트로부터 받은 토큰의 유효성을 확인하는 과정입니다.
참고: 이 섹션은 앱 토큰 기술 명세 > 앱 토큰 검증 섹션에서 정의된 요구사항을 실제로 구현하는 방법을 설명합니다.
// 파일 경로: libs/feature/auth/src/lib/application/services/app-token.service.ts (예시)
// In AppTokenService
/**
* 앱 토큰의 유효성을 검증합니다. (AppTokenService 내 메서드)
* @param token 검증할 앱 토큰 문자열
* @returns 검증 결과 및 토큰 정보 (appId, deviceId)
*/
async validateAppToken(token: string): Promise<{
isValid: boolean;
appId?: string;
deviceId?: string;
}> {
let payload: AppTokenPayload | 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) {
// 유효하지 않은 토큰 형식이거나 kid 없음
return { isValid: false };
}
const kid = decodedToken.header.kid;
// 2. kid로 검증용 JWK 조회
const jwk = await this.jwkManagementService.findJwkForVerification(kid);
// 3. JWK를 사용한 토큰 검증 (RS256)
if (jwk) {
try {
payload = await this.jwkManagementService.verifyWithJwk(token, jwk) as AppTokenPayload;
} catch (error) {
verificationError = error; // JWK 검증 실패 기록
payload = null;
}
} else {
// 해당 kid의 JWK 없음, 백업 검증 시도
this.logger.warn(`No JWK found for kid: ${kid}, attempting backup verification.`);
}
// 4. 백업 검증 (HS256) - JWK 검증 실패 또는 JWK를 찾지 못한 경우 (선택적)
if (!payload) {
payload = await this.tryVerifyWithSignatureKey(token);
if (!payload && verificationError) {
// JWK 검증과 백업 검증 모두 실패 시, 원래 JWK 검증 오류가 더 유용할 수 있음
this.logger.warn(`App token validation failed after JWK error: ${verificationError.message}`);
return { isValid: false };
}
}
// 5. 페이로드 최종 확인
if (!payload) {
// 모든 검증 방법 실패
this.logger.warn('App token validation failed after all attempts.');
return { isValid: false };
}
// 6. 앱 토큰 고유 페이로드 유효성 검증
if (
!payload.sub || // 앱 ID (sub) 확인
!payload.jti || // 토큰 ID (jti) 확인
!payload.deviceId || // 디바이스 ID 확인
(this.env === 'prod' && payload.env !== this.env) // 환경 일치 확인 (prod 환경에서만)
) {
this.logger.warn('App token payload validation failed.', payload);
return { isValid: false }; // 필수 페이로드 누락 또는 불일치
}
// 7. Redis 캐시 확인 (빠른 경로)
const cachedToken = await this.cacheManager.get<string>(`${this.appTokenCacheDbKeyPrefix}${payload.jti}`);
if (cachedToken === token) {
await this.updateTokenLastUsed(payload.jti);
return { isValid: true, appId: payload.sub, deviceId: payload.deviceId };
}
// 8. 데이터베이스(appToken 테이블)에서 토큰 정보 조회
const appTokenRecord = await this.prisma.appToken.findUnique({
where: { id: payload.jti } // 토큰 ID로 조회
});
// 9. DB 기록 유효성 검증
if (!appTokenRecord) {
this.logger.warn(`App token record not found in DB for jti: ${payload.jti}`);
return { isValid: false }; // DB에 해당 토큰 ID 없음
}
if (appTokenRecord.status !== 'ACTIVE') {
this.logger.warn(`App token status is not ACTIVE for jti: ${payload.jti} (Status: ${appTokenRecord.status})`);
return { isValid: false }; // 토큰 상태가 ACTIVE가 아님
}
if (appTokenRecord.deviceId !== payload.deviceId) {
this.logger.error(`Device ID mismatch for jti: ${payload.jti}. Payload: ${payload.deviceId}, DB: ${appTokenRecord.deviceId}`);
return { isValid: false }; // 페이로드의 deviceId와 DB의 deviceId 불일치
}
if (appTokenRecord.expiresAt < new Date()) {
this.logger.warn(`App token expired based on DB record for jti: ${payload.jti}`);
await this.prisma.appToken.update({
where: { id: payload.jti }, data: { status: 'EXPIRED' }
}).catch(err => this.logger.error("Failed to update expired app token status:", err));
return { isValid: false };
}
// 10. 마지막 사용 시간 업데이트
await this.updateTokenLastUsed(payload.jti);
// 11. Redis 캐시 업데이트 (DB 조회 후 캐시 미스였던 경우)
if (!cachedToken) {
await this.cacheManager.set(
`${this.appTokenCacheDbKeyPrefix}${payload.jti}`,
token,
this.appTokenExpiration
).catch(err => this.logger.error("Failed to update app token cache:", err));
}
// 12. 모든 검증 통과
return {
isValid: true,
appId: payload.sub,
deviceId: payload.deviceId
};
} catch (error) {
// 예상치 못한 오류 로깅
this.logger.error('Unexpected error during app token validation:', error);
return { isValid: false };
}
}
// 파일 경로: libs/feature/auth/src/lib/application/services/app-token.service.ts (예시)
// In AppTokenService
/**
* 토큰의 마지막 사용 시간을 업데이트합니다. (AppTokenService 내 비공개 메서드)
*/
private async updateTokenLastUsed(tokenId: string): Promise<void> {
try {
await this.prisma.appToken.update({
where: { id: tokenId },
data: { lastUsedAt: new Date() }
});
} catch (error) {
// DB 업데이트 실패는 로깅하되, 검증 실패로 이어지지 않도록 처리
this.logger.error(`Failed to update last used time for token ${tokenId}:`, error);
}
}
// 파일 경로: libs/feature/auth/src/lib/application/services/app-token.service.ts (예시)
// In AppTokenService
/**
* HS256 백업 검증 로직 (AppTokenService 내 비공개 메서드)
*/
private async tryVerifyWithSignatureKey(token: string): Promise<AppTokenPayload | null> {
try {
const verified = this.jwtService.verify(token, {
secret: this.appTokenSecret,
algorithms: ['HS256']
});
// 운영 환경에서는 HS256 토큰을 허용하지 않을 수 있음
if (this.env === 'prod') {
this.logger.warn('HS256 app token validation attempted in production.');
// return null;
}
// 타입 가드 (AppTokenPayload 형태인지 확인)
if (typeof verified === 'object' && verified !== null && 'sub' in verified && 'jti' in verified && 'deviceId' in verified) {
return verified as AppTokenPayload;
}
return null;
} catch (error) {
// HS256 검증 오류 (만료 포함) 시 null 반환
// this.logger.debug('HS256 app token verification failed:', error.message);
return null;
}
}
앱 토큰 검증 과정 상세 설명
- 토큰 디코딩:
jsonwebtoken.decode로 토큰 헤더의kid를 추출합니다. - JWK 조회:
kid를 사용하여JwkManagementService.findJwkForVerification()로 검증용 JWK를 찾습니다. - JWT 검증 (RS256): JWK가 있으면
JwkManagementService.verifyWithJwk()를 호출하여 RS256 서명 및 기본 클레임(exp 등)을 검증합니다. 실패 시 오류를 기록합니다. - 백업 검증 (HS256): JWK가 없거나 RS256 검증에 실패한 경우,
tryVerifyWithSignatureKey를 호출하여 HS256 백업 검증을 시도합니다 (선택 사항). - 페이로드 확인: 모든 검증 시도 후 유효한 페이로드를 얻지 못하면 실패 처리합니다.
- 앱 토큰 페이로드 검증: 페이로드에 앱 토큰 필수 정보(
sub,jti,deviceId,env)가 올바르게 포함되었는지 확인합니다. - Redis 캐시 확인: Redis에서 토큰 ID(
jti)를 조회하여 캐시 히트 시 성공 처리합니다 (DB 조회 생략). - 데이터베이스 조회: 캐시 미스 시
appToken테이블에서 토큰 레코드를 조회합니다. - DB 레코드 검증: DB 레코드의 존재 여부, 상태(
ACTIVE),deviceId일치 여부, 만료 시간(expiresAt)을 확인합니다. - 사용 기록 업데이트: 검증 통과 시
lastUsedAt필드를 업데이트합니다. - 캐시 업데이트: 캐시 미스 후 DB 검증 성공 시 Redis 캐시를 업데이트합니다.
- 성공 처리: 모든 검증 통과 시
isValid: true와 함께 앱 ID, 디바이스 ID를 반환합니다.
앱 토큰 갱신 프로세스
토큰 갱신 프로세스는 만료되지 않은 유효한 앱 토큰을 사용하여 새로운 앱 토큰을 발급받는 과정입니다. 앱 토큰은 일반적으로 리프레시 개념 없이, 필요시 앱 인증 재수행 후 새로 발급받는 것이 더 일반적일 수 있으나, 여기서는 기존 토큰 기반 갱신 로직을 설명합니다.
참고: 이 섹션은 앱 토큰 기술 명세 > 앱 토큰 갱신 섹션에서 정의된 요구사항을 실제로 구현하는 방법을 설명합니다.
// 파일 경로: libs/feature/auth/src/lib/application/services/app-token.service.ts (예시)
// In AppTokenService
/**
* 기존 앱 토큰을 사용하여 새로운 앱 토큰을 발급합니다. (AppTokenService 내 메서드)
* @param token 유효한 기존 앱 토큰
* @returns 새로 발급된 앱 토큰
*/
async renewAppToken(token: string): Promise<string> {
try {
// 1. 기존 토큰 검증 (만료되지 않고 유효해야 함)
const validationResult = await this.validateAppToken(token);
if (!validationResult.isValid || !validationResult.appId || !validationResult.deviceId) {
// 토큰이 유효하지 않거나, 필수 정보 누락 시 갱신 불가
throw new UnauthorizedException('Invalid or expired app token for renewal');
}
// 2. 토큰 페이로드에서 토큰 ID(jti) 추출
const decodedToken = decode(token, { complete: true });
if (!decodedToken || typeof decodedToken === 'string' || !decodedToken.payload || !(decodedToken.payload as any).jti) {
throw new InternalServerErrorException('Failed to decode token for renewal');
}
const tokenId = (decodedToken.payload as any).jti;
// 3. 기존 토큰 취소 (상태 변경 및 Redis 캐시 제거)
// 중요: 동시성 문제 방지를 위해 트랜잭션 고려 필요할 수 있음
await this.prisma.appToken.update({
where: { id: tokenId },
data: { status: 'REVOKED', updatedAt: new Date() } // 'REVOKED' 또는 'RENEWED' 상태 사용 가능
});
await this.cacheManager.del(`${this.appTokenCacheDbKeyPrefix}${tokenId}`); // 캐시에서도 제거
// 4. 새 앱 토큰 생성 (동일한 앱 ID와 디바이스 ID 사용)
const newToken = await this.generateAppToken(validationResult.appId, validationResult.deviceId);
// 5. 토큰 갱신 이벤트 발행
this.eventEmitter.emit('app.token.renewed', {
oldTokenId: tokenId,
newTokenId: (decode(newToken, { complete: true })?.payload as any)?.jti, // 새 토큰 ID 추출
appId: validationResult.appId,
deviceId: validationResult.deviceId
});
return newToken;
} catch (error) {
// console.error('Failed to renew app token:', error);
if (error instanceof UnauthorizedException || error instanceof InternalServerErrorException) {
throw error;
}
// 기타 예상치 못한 오류
throw new InternalServerErrorException('Failed to renew app token');
}
}
앱 토큰 갱신 과정 상세 설명
- 기존 토큰 검증:
validateAppToken메서드를 호출하여 갱신하려는 토큰이 여전히 유효한지(만료되지 않고, 상태가 ACTIVE이며, DB 정보와 일치하는지 등) 확인합니다. 유효하지 않으면UnauthorizedException을 발생시킵니다. - 토큰 ID 추출: 기존 토큰에서 고유 식별자(
jti)를 추출합니다. - 기존 토큰 취소: 데이터베이스에서 기존 토큰의 상태를
REVOKED(또는RENEWED)로 변경하고, Redis 캐시에서도 해당 토큰을 삭제하여 더 이상 사용될 수 없도록 합니다. (토큰 재사용 공격 방지) - 새 토큰 생성: 기존 토큰에서 얻은 앱 ID와 디바이스 ID 문자열을 사용하여
generateAppToken메서드를 호출, 새로운 앱 토큰을 생성합니다. 새 토큰은 새로운 토큰 ID(jti)와 만료 시간(exp)을 갖게 됩니다. - 이벤트 발행: 토큰 갱신 이벤트를 발행하여 관련 시스템에 알립니다.
앱 인증 시스템과의 통합
앱 토큰 서비스는 앱 인증 시스템 구현에서 구현된 챌린지-응답 메커니즘과 통합됩니다. 통합 과정은 다음과 같습니다:
참고: 앱 토큰 발급 및 검증을 위한 API 인터페이스는 앱 토큰 기술 명세 > API 인터페이스 섹션에서도 정의하고 있습니다.
앱 인증 시스템에서 앱 토큰 발급
앱 인증 시스템의 챌린지-응답 검증이 성공한 후, 앱 토큰이 발급됩니다. 이때, 검증 과정에서 얻은 해시된 디바이스 ID 문자열을 사용합니다.
// 파일 경로: apps/dta-wide-api/src/app/auth/controllers/app-auth.controller.ts (예시)
import { Controller, Post, Body, UnauthorizedException, Inject } from '@nestjs/common';
import { ApiOperation } from '@nestjs/swagger';
import { AppTokenService } from '../services/app-token.service';
import { ChallengeResponseService } from '../services/challenge-response.service'; // 예시
@Controller('auth/app') // 예시 경로
export class AppAuthController {
constructor(
private readonly appTokenService: AppTokenService,
private readonly challengeResponseService: ChallengeResponseService // 예시
) {}
@Post('complete-challenge')
@ApiOperation({ summary: '챌린지 응답 검증 및 앱 토큰 발급' })
async completeChallenge(@Body() responseDto: {
hashedDeviceId: string;
encryptedChallenge: string;
nonce: string;
// appId?: string; // 필요하다면 DTO에 포함
}) {
// 1. 챌린지 응답 검증
const isValid = await this.challengeResponseService.validateResponse(
responseDto.hashedDeviceId,
responseDto.encryptedChallenge,
responseDto.nonce
);
if (!isValid) {
throw new UnauthorizedException('챌린지 검증 실패');
}
// 2. 디바이스 ID 객체 생성 대신 해시된 ID 문자열 사용
const deviceIdValue = responseDto.hashedDeviceId;
// 3. 앱 토큰 발급 (AppTokenService 사용)
// TODO: 실제 앱 식별자(appId)를 컨텍스트나 설정에서 가져와야 함
const appId = 'your-actual-app-id'; // 실제 앱 ID 로직으로 대체
const { token: appToken, secret: appSecret } = await this.appTokenService.generateAppToken(
appId,
deviceIdValue // 해시된 ID 문자열 전달
);
return { appToken, appSecret };
}
}
앱 토큰 검증 가드 구현
API 요청 시 전달된 앱 토큰을 검증하기 위해 NestJS Guard를 사용합니다. 이 가드는 AppTokenService를 사용하여 토큰의 유효성을 확인합니다.
// 파일 경로: apps/dta-wide-api/src/app/auth/guards/app-token.guard.ts (예시)
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException, Inject } from '@nestjs/common';
import { Request } from 'express';
import { AppTokenService } from '../services/app-token.service';
@Injectable()
export class AppTokenGuard implements CanActivate {
constructor(private readonly appTokenService: AppTokenService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request>();
const token = this.extractTokenFromHeader(request);
if (!token) {
throw new UnauthorizedException('App token not provided');
}
const result = await this.appTokenService.validateAppToken(token);
if (!result.isValid) {
throw new UnauthorizedException('Invalid app token');
}
(request as any).appId = result.appId;
(request as any).deviceId = result.deviceId;
return true;
}
private extractTokenFromHeader(request: Request): string | undefined {
const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
}
}
보안 고려사항
앱 토큰 구현 시 고려해야 할 보안 사항은 다음과 같습니다:
참고: 이 섹션은 앱 토큰 기술 명세 > 보안 고려사항 섹션에서 정의된 보안 요구사항을 충족하기 위한 구체적인 구현 방법 및 조치를 설명합니다.
1. 토큰 보안
- 비대칭 키 암호화: RS256 알고리즘 및 JWK 사용. (상세 내용은 JWK 관리 서비스 구현 가이드 참조)
- 토큰 분리: 앱 토큰과 사용자 액세스 토큰은 명확히 분리되어야 합니다.
- 짧은 만료 시간: 앱 토큰의 유효 기간을 적절하게 설정합니다 (예: 7일). 필요시 더 짧게 조정합니다.
- 토큰 취소: 데이터베이스 상태 변경(
status) 및 Redis 캐시 삭제를 통해 특정 토큰을 즉시 무효화할 수 있어야 합니다.
2. 디바이스 보안
- 디바이스 바인딩: 토큰 페이로드에
deviceId를 포함하고, 검증 시 DB에 저장된 값과 비교하여 토큰이 탈취되어 다른 디바이스에서 사용되는 것을 방지합니다. 이 비교 로직은AppTokenService내validateAppToken에서 수행됩니다. - 해시 사용: 디바이스 원본 정보 대신
DeviceIdentifierService를 통해 생성된 해시된deviceId를 사용합니다. - 앱 무결성 검증: 앱 토큰은 앱 인증 시스템 구현의 챌린지-응답과 같은 메커니즘을 통해 앱의 무결성이 확인된 후에만 발급되어야 합니다.
3. 저장소 보안
- 데이터베이스 암호화: 토큰 정보를 저장할 때 데이터베이스 수준의 암호화를 적용합니다.
- Redis 보안: Redis 연결에 TLS를 적용하고, 데이터 지속성을 설정합니다.
- 토큰 상태 관리: 데이터베이스에서 토큰 상태를 추적하여 취소된 토큰을 즉시 무효화합니다.
4. 모니터링 및 감사
- 토큰 사용 추적: 마지막 사용 시간을 기록하여 비정상적인 패턴을 감지합니다.
- 이벤트 로깅: 토큰 생성, 검증, 갱신, 취소 등의 이벤트를 로깅합니다.
- 보안 이벤트 알림: 의심스러운 활동 감지 시 알림을 발송합니다.
5. 통신 보안
- TLS/SSL: 모든 API 통신은 TLS/SSL을 통해 암호화합니다.
- HTTPS 강제: 토큰 관련 모든 엔드포인트는 HTTPS만 허용합니다.
- CORS 설정: 허용된 오리진에서만 API 호출이 가능하도록 설정합니다.
앱 토큰 보안 체크리스트
- RS256 알고리즘 및 JWK를 사용한 토큰 서명/검증 (JWT 가이드 참조)
- 디바이스 ID와 토큰 바인딩 구현 및 검증
- 데이터베이스 내 토큰 상태 관리 (
ACTIVE,REVOKED,EXPIRED) 구현 - Redis 캐싱 전략 및 보안 설정 (TLS, TTL)
- 토큰 사용 감사 로깅 (마지막 사용 시간 등)
- 토큰 취소(무효화) 메커니즘 테스트
- HTTPS 통신 강제 적용
- 오류 메시지에 민감한 정보 노출 방지
- 보안 이벤트(예: deviceId 불일치) 모니터링 및 알림 설정
- 토큰 수명 주기(생성-검증-갱신-만료-취소) 테스트
환경 변수 설정
앱 토큰 서비스 구현에 필요한 환경 변수는 다음과 같습니다:
# 앱 토큰 관련 환경 변수
APP_TOKEN_SECRET=secure_random_token_secret_here # HS256 백업 검증용
APP_TOKEN_EXPIRATION=604800000 # 7일(밀리초): 7 * 24 * 60 * 60 * 1000
CACHE_PREFIX_APP_TOKEN=tokens:app:
DEPLOY_ENV=dev # 또는 prod
APP_SECRET=your_static_app_secret_here # 앱에서 사용할 정적 시크릿
# JWT/JWK 관련 환경 변수는 implementation-jwt-authentication.md 참조
# 예: JWK_DATABASE_URL 등
앱 인증 시스템에서 사용되는 환경 변수와 함께 설정하는 것이 좋습니다. 환경 변수 설정 방법에 대한 자세한 내용은 앱 인증 시스템 구현 가이드를 참조하세요.
오류 코드 관리
앱 토큰 시스템에서 발생하는 다양한 오류는 Auth 도메인의 공통 오류 관리 방식을 따릅니다. 이 방식은 오류 코드와 메시지를 enum으로 중앙 관리하여 일관성과 유지보수성을 높입니다.
참고: 앱 토큰 관련 주요 오류 상황 및 코드는 앱 토큰 기술 명세 > 오류 처리 (개요) 섹션에서도 요약되어 있습니다. 상세한 오류 코드 관리 및 구현은 오류 관리 가이드를 참조하세요.
앱 토큰 시스템과 관련된 주요 오류 코드는 다음과 같습니다:
| HTTP 상태 코드 | 오류 코드 | 메시지 | 설명 |
|---|---|---|---|
| 500 | 2000 | SERVER_ERROR | 서버 내부 오류 |
| 400 | 2009 | INVALID_DEVICE_ID | 디바이스 ID가 유효하지 않습니다 |
| 401 | 2015 | INVALID_APP_TOKEN | 앱 토큰이 유효하지 않거나 만료되었습니다 |
| 401 | 2051 | APP_TOKEN_EXPIRED | 토큰이 만료되었습니다 (JWT 기본 검증 단계) |
| 401 | 2052 | REFRESH_TOKEN_INVALID | (앱 토큰에는 해당 없음) |
| 401 | 2053 | TOKEN_REVOKED | 토큰이 취소되었습니다 (DB 상태 확인) |
변경 이력
| 버전 | 날짜 | 작성자 | 변경 사항 |
|---|---|---|---|
| 0.1.0 | 2025-04-10 | bok@weltcorp.com | 최초 작성 |