앱 인증 시스템 구현
개요
이 문서는 앱 인증 시스템의 구현에 대한 기술적 세부사항을 제공합니다. 상세한 기술 명세는 앱 보안 인증 시스템 기술 명세를 참조하세요.
주요 구성 요소
앱 인증 시스템은 다음 세 가지 주요 구성 요소를 포함합니다:
- 디바이스 ID 처리: 디바이스 고유 식별 및 해싱
- 챌린지-응답 메커니즘: 앱 무결성 검증을 위한 시간 기반 인증 방식
- 앱 토큰 관리: 인증된 앱에 발급되는 토큰 처리
구현 가이드
디바이스 ID 처리
앱 인증 시스템에서는 해시된 디바이스 ID를 값 객체(Value Object)로 다루는 것이 좋습니다. DeviceIdentifier 값 객체의 상세 구현 방법은 DeviceIdentifier 값 객체 구현 가이드 문서를 참조하세요.
이 DeviceIdentifier 값 객체를 사용하는 디바이스 ID 처리 서비스를 다음과 같이 구현합니다:
// 파일 경로: libs/feature/auth/src/lib/domain/services/device-identifier.service.ts
import { Injectable } from '@nestjs/common';
import { createHash } from 'crypto';
import { DeviceIdentifierRepository } from '../repositories/device-identifier.repository';
import { DeviceIdentifier } from '../domain/value-objects/device-identifier';
@Injectable()
export class DeviceIdentifierService {
constructor(
private readonly deviceIdentifierRepository: DeviceIdentifierRepository
) {}
/**
* 디바이스 ID를 해싱합니다.
* @param deviceInfo 디바이스 정보 객체
* @returns 해시된 디바이스 ID
*/
hashDeviceId(deviceInfo: Record<string, any>): string {
const deviceIdString = JSON.stringify(deviceInfo);
return createHash('sha256')
.update(deviceIdString)
.digest('hex');
}
/**
* 디바이스 ID를 검증하고 저장합니다.
* @param hashedDeviceId 해시된 디바이스 ID
* @param deviceInfo 디바이스 정보 객체
* @returns 저장된 디바이스 ID 객체
*/
async validateAndStoreDeviceId(
hashedDeviceId: string,
deviceInfo: Record<string, any>
): Promise<DeviceIdentifier> {
// 유효성 검증
if (!this.validateDeviceId(hashedDeviceId, deviceInfo)) {
throw new Error('유효하지 않은 디바이스 ID입니다.');
}
// 값 객체 생성
const deviceIdentifier = new DeviceIdentifier(hashedDeviceId);
// 이미 존재하는지 확인
const existingDevice = await this.deviceIdentifierRepository.findByHash(hashedDeviceId);
if (!existingDevice) {
// 새 디바이스 정보 저장
await this.deviceIdentifierRepository.save(deviceIdentifier, deviceInfo);
}
return deviceIdentifier;
}
/**
* 디바이스 ID의 유효성을 검증합니다.
* @param hashedDeviceId 해시된 디바이스 ID
* @param deviceInfo 디바이스 정보 객체
* @returns 유효성 여부
*
* 이 메서드는 [앱 보안 인증 시스템 기술 명세](/domains/common/core-domains/auth/technical-docs/spec-app-security-authentication)의
* 4.1 Device ID 검증 섹션에서 설명한 방식을 구현합니다.
*/
private validateDeviceId(
hashedDeviceId: string,
deviceInfo: Record<string, any>
): boolean {
// 1. 해시 형식 검증 (64자 16진수)
if (!/^[0-9a-f]{64}$/i.test(hashedDeviceId)) {
return false;
}
// 2. 필수 필드 검증
if (!deviceInfo.uuid || !deviceInfo.platform || !deviceInfo.version) {
return false;
}
// 3. 해시 일치 검증
const computedHash = this.hashDeviceId(deviceInfo);
return computedHash === hashedDeviceId;
}
/**
* 디바이스 ID를 블랙리스트에 추가합니다.
* @param deviceId 블랙리스트에 추가할 디바이스 ID
* @param reason 블랙리스트에 추가한 이유
*/
async blacklistDevice(deviceId: DeviceIdentifier, reason: string): Promise<void> {
await this.deviceIdentifierRepository.blacklist(deviceId, reason);
}
}
디바이스 ID 리포지토리 구현 (예시: Prisma)
DeviceIdentifierService는 DeviceIdentifierRepository 인터페이스에 의존합니다. 다음은 Prisma를 사용한 리포지토리 구현 예시입니다.
// 파일 경로: libs/feature/auth/src/lib/infrastructure/repositories/prisma-device-identifier.repository.ts
import { Injectable } from '@nestjs/common';
import { PrismaService } from '@core/database';
import { DeviceIdentifier } from '../../domain/value-objects/device-identifier';
import { DeviceIdentifierRepository } from '../../domain/repositories/device-identifier.repository'; // 인터페이스 정의 경로
import { DeviceMapper } from '../mappers/device.mapper'; // DeviceMapper 임포트 (가정)
import { Prisma } from '@prisma/client'; // Prisma 타입 사용
@Injectable()
export class PrismaDeviceIdentifierRepository implements DeviceIdentifierRepository {
// 생성자에서 PrismaService 주입 (DeviceMapper가 @Injectable() 이라면 함께 주입)
constructor(private readonly prisma: PrismaService) {}
/**
* 해시된 디바이스 ID로 디바이스 정보를 조회합니다.
* @param hashedDeviceId 해시된 디바이스 ID
* @returns DeviceIdentifier 객체 또는 null
*/
async findByHash(hashedDeviceId: string): Promise<DeviceIdentifier | null> {
const deviceModel = await this.prisma.device.findUnique({ // Prisma 모델 이름은 스키마에 따라 'device'
where: { hashedId: hashedDeviceId },
});
if (!deviceModel) {
return null;
}
// DeviceMapper를 사용하여 Prisma 모델을 DeviceIdentifier 값 객체로 변환
return DeviceMapper.toValueObject(deviceModel);
}
/**
* 새로운 디바이스 정보를 저장합니다.
* @param deviceIdentifier 디바이스 ID 값 객체
* @param deviceInfo 원본 디바이스 정보
* @returns 저장된 DeviceIdentifier 객체
*/
async save(
deviceIdentifier: DeviceIdentifier,
deviceInfo: Record<string, any>
): Promise<DeviceIdentifier> {
// DeviceMapper를 사용하여 저장할 Prisma 데이터 객체 생성
const createInput: Prisma.DeviceCreateInput = DeviceMapper.toPrismaCreateInput(
deviceIdentifier,
deviceInfo
);
await this.prisma.device.create({ data: createInput });
// 저장 후 다시 DeviceIdentifier 객체 반환 (또는 저장된 Prisma 모델 기반으로 재생성)
return deviceIdentifier;
}
/**
* 특정 디바이스 ID를 블랙리스트 처리합니다.
* @param deviceId 블랙리스트 처리할 디바이스 ID 객체
* @param reason 블랙리스트 사유
*/
async blacklist(deviceId: DeviceIdentifier, reason: string): Promise<void> {
// DeviceMapper를 사용하여 업데이트할 데이터 생성 (필요시)
const updateInput: Prisma.DeviceUpdateInput = {
isBlacklisted: true,
blacklistReason: reason,
blacklistedAt: new Date(),
};
await this.prisma.device.update({
where: { hashedId: deviceId.getValue() },
data: updateInput,
});
}
/**
* 디바이스가 블랙리스트에 있는지 확인합니다.
* @param hashedDeviceId 확인할 디바이스의 해시된 ID
* @returns 블랙리스트 여부
*/
async isBlacklisted(hashedDeviceId: string): Promise<boolean> {
const device = await this.prisma.device.findUnique({
where: { hashedId: hashedDeviceId },
select: { isBlacklisted: true }, // 필요한 필드만 선택
});
// DeviceMapper는 이 경우 필요 없을 수 있음 (단순 boolean 반환)
return device?.isBlacklisted ?? false;
}
}
DeviceMapper 예시 (가정)
위 Repository 구현은 DeviceMapper라는 클래스(또는 객체)가 존재하고 필요한 변환 메서드를 제공한다고 가정합니다. 아래는 DeviceMapper의 간단한 예시입니다.
// 파일 경로: libs/feature/auth/src/lib/infrastructure/mappers/device.mapper.ts (예시)
import { Device as PrismaDevice, Prisma } from '@prisma/client'; // Prisma 모델 타입 import
import { DeviceIdentifier } from '../../domain/value-objects/device-identifier';
export class DeviceMapper {
/**
* Prisma Device 모델을 DeviceIdentifier 값 객체로 변환합니다.
*/
static toValueObject(prismaDevice: PrismaDevice): DeviceIdentifier {
// PrismaDevice 타입이 hashedId 속성을 가지고 있다고 가정
if (!prismaDevice || !prismaDevice.hashedId) {
throw new Error('Invalid PrismaDevice data for mapping to DeviceIdentifier');
}
return new DeviceIdentifier(prismaDevice.hashedId);
}
/**
* DeviceIdentifier와 deviceInfo를 Prisma Device 생성을 위한 입력 데이터로 변환합니다.
*/
static toPrismaCreateInput(
deviceIdentifier: DeviceIdentifier,
deviceInfo: Record<string, any>
): Prisma.DeviceCreateInput {
// deviceInfo에서 필요한 필드 추출 및 검증
const uuid = deviceInfo.uuid as string;
const platform = deviceInfo.platform as string;
const version = deviceInfo.version as string;
if (!uuid || !platform || !version) {
throw new Error('Missing required fields in deviceInfo for creating Device');
}
return {
hashedId: deviceIdentifier.getValue(),
uuid: uuid,
platform: platform,
appVersion: version,
// 기타 필요한 필드 매핑...
// 예: manufacturer: deviceInfo.manufacturer as string | undefined,
// model: deviceInfo.model as string | undefined,
// osVersion: deviceInfo.osVersion as string | undefined,
deviceInfoJson: JSON.stringify(deviceInfo), // 선택: 원본 JSON 저장
isBlacklisted: false, // 기본값
};
}
// 필요에 따라 Prisma 모델을 더 풍부한 도메인 엔티티로 변환하는 메서드 추가 가능
// static toDomainEntity(prismaDevice: PrismaDevice): DeviceEntity { ... }
}
참고: 실제 프로젝트에서는 위 매퍼를 정적 클래스가 아닌
@Injectable()서비스로 만들어 의존성 주입을 통해 사용할 수도 있습니다. 이 경우, 매퍼 서비스를 사용하는 모듈(AuthDomainModule)의providers배열에 등록해야 합니다.
모듈 설정
위의 PrismaDeviceIdentifierRepository를 사용하려면, 해당 리포지토리를 프로바이더로 등록하는 모듈(예: AuthDomainModule)에서 CoreDatabaseModule을 import 해야 합니다. CoreDatabaseModule은 PrismaService를 제공합니다.
// 파일 경로: libs/feature/auth/src/lib/auth.module.ts
import { Module } from '@nestjs/common';
import { CoreDatabaseModule } from '@core/database'; // CoreDatabaseModule 임포트
import { DeviceIdentifierService } from './domain/services/device-identifier.service';
import { PrismaDeviceIdentifierRepository } from './infrastructure/repositories/prisma-device-identifier.repository';
import { DeviceIdentifierRepository } from './domain/repositories/device-identifier.repository'; // 인터페이스
// ... 다른 import들
@Module({
imports: [
CoreDatabaseModule.forRoot(), // PrismaService 제공
// ... 다른 모듈들
],
providers: [
DeviceIdentifierService,
{
provide: DeviceIdentifierRepository, // 인터페이스를 토큰으로 사용
useClass: PrismaDeviceIdentifierRepository, // 실제 구현 클래스 제공
},
// ... 다른 프로바이더들 (ChallengeResponseService, AppTokenService 등)
],
exports: [
DeviceIdentifierService,
// ... 다른 export들
],
})
export class AuthDomainModule {}
챌린지-응답 구현
챌린지-응답 메커니즘을 구현하는 서비스는 다음과 같습니다:
// 파일 경로: libs/feature/auth/src/lib/domain/services/challenge-response.service.ts
import { Injectable } from '@nestjs/common';
import { randomBytes, privateDecrypt, constants } from 'crypto';
import { ConfigService } from '@core/config';
import { CacheService } from '../infrastructure/cache/cache.service';
import * as fs from 'fs';
import * as path from 'path';
@Injectable()
export class ChallengeResponseService {
private readonly CHALLENGE_TTL = 5 * 60; // 5분
private readonly privateKey: string;
constructor(
private readonly configService: ConfigService,
private readonly cacheService: CacheService
) {
// 개인키 로드 - 실제로는 환경 변수나 KMS에서 가져와야 함
const keyPath = this.configService.get<string>('app.security.rsaPrivateKeyPath');
if (keyPath) {
this.privateKey = fs.readFileSync(path.resolve(keyPath), 'utf8');
} else {
this.privateKey = this.configService.getRequired<string>('app.security.rsaPrivateKey');
}
}
/**
* 챌린지를 생성하고 캐싱합니다.
* @param deviceId 디바이스 ID
* @returns 챌린지 정보 객체
*/
async generateChallenge(deviceId: string): Promise<{
challenge: string;
nonce: string;
timestamp: number;
}> {
// 랜덤 챌린지 생성 (32바이트)
const challenge = randomBytes(32).toString('hex');
// 논스 생성 (16바이트)
const nonce = randomBytes(16).toString('hex');
// 현재 타임스탬프
const timestamp = Date.now();
// 캐싱 (Redis)
await this.cacheService.set(
`challenge:${deviceId}:${nonce}`,
{ challenge },
this.CHALLENGE_TTL
);
// 클라이언트에게 전송할 정보
return {
challenge,
nonce,
timestamp
};
}
/**
* 클라이언트로부터 받은 챌린지 응답을 검증합니다.
* @param deviceId 디바이스 ID
* @param encryptedChallenge 암호화된 챌린지
* @param nonce 논스
* @returns 검증 결과
*
* 이 메서드는 [앱 보안 인증 시스템 기술 명세](/domains/common/core-domains/auth/technical-docs/spec-app-security-authentication)의
* 4.2 챌린지 검증 섹션에서 설명한 방식을 구현합니다.
*/
async validateResponse(
deviceId: string,
encryptedChallenge: string,
nonce: string
): Promise<boolean> {
// 캐시에서 원본 챌린지 정보 조회
const cachedData = await this.cacheService.get<{
challenge: string;
}>(`challenge:${deviceId}:${nonce}`);
// 캐시에 없는 경우 (만료 또는 존재하지 않음)
if (!cachedData) {
return false;
}
// 캐시 데이터 사용 후 삭제 (일회성)
await this.cacheService.delete(`challenge:${deviceId}:${nonce}`);
try {
// 클라이언트의 RSA 암호화된 응답 복호화
const decryptedChallenge = this.decryptChallenge(encryptedChallenge);
// 원본 챌린지와 비교
return decryptedChallenge === cachedData.challenge;
} catch (error) {
console.error('Challenge decryption failed:', error.name);
return false;
}
}
/**
* RSA 개인키로 암호화된 챌린지를 복호화합니다.
* @param encryptedChallenge 암호화된 챌린지 (base64 인코딩된 문자열)
* @returns 복호화된 챌린지
*/
private decryptChallenge(encryptedChallenge: string): string {
// Base64 디코딩
const encryptedBuffer = Buffer.from(encryptedChallenge, 'base64');
// RSA 복호화
const decrypted = privateDecrypt(
{
key: this.privateKey,
padding: constants.RSA_PKCS1_PADDING,
},
encryptedBuffer
);
// UTF-8 문자열로 변환
return decrypted.toString('utf8');
}
}
앱 토큰 관리
앱 인증(챌린지-응답)이 성공적으로 완료되면, 서버는 검증된 앱 클라이언트에게 앱 토큰을 발급해야 합니다. 이 앱 토큰의 생성, 검증, 관리 책임은 **AppTokenService**가 담당합니다.
AppTokenService의 상세한 기술 명세, 구현 방법, 데이터베이스 스키마, 보안 고려사항 등은 다음 문서를 참조하세요:
앱 인증 컨트롤러(AppAuthController)의 completeChallenge 메서드에서는 챌린지 응답 검증 후, AppTokenService의 generateAppToken 메서드를 호출하여 최종적으로 앱 토큰을 발급받게 됩니다. 이때 필요한 appId와 검증된 deviceId (문자열)를 파라미터로 전달합니다.
// 파일 경로: apps/dta-wide-api/src/app/auth/controllers/app-auth.controller.ts (일부)
// ... 챌린지 응답 검증 후 ...
// 검증된 해시된 디바이스 ID 문자열 사용
const deviceIdValue = responseDto.hashedDeviceId;
// 앱 토큰 발급 (appId는 실제 앱 식별자로 대체 필요)
const appId = 'your-actual-app-id';
const { token: appToken, secret: appSecret } = await this.appTokenService.generateAppToken(appId, deviceIdValue);
return { appToken, appSecret };
위 코드에서 this.appTokenService는 AppTokenService의 인스턴스를 나타냅니다. 실제 서비스 구현은 implementation-app-token.md 가이드를 따릅니다.
앱/서버 통합 구현
앱 컨트롤러
앱 인증을 처리하는 컨트롤러는 다음과 같이 구현합니다:
// 파일 경로: apps/dta-wide-api/src/app/auth/controllers/app-auth.controller.ts
import { Controller, Post, Body, UnauthorizedException } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import {
DeviceIdentifierService,
ChallengeResponseService,
AppTokenService,
} from '@feature/auth-core';
@ApiTags('앱 인증')
@Controller('auth/app')
export class AppAuthController {
constructor(
private readonly deviceIdentifierService: DeviceIdentifierService,
private readonly challengeResponseService: ChallengeResponseService,
private readonly appTokenService: AppTokenService
) {}
@Post('challenge')
@ApiOperation({ summary: '챌린지 요청' })
async requestChallenge(@Body() requestDto: {
deviceInfo: Record<string, any>;
hashedDeviceId: string;
}) {
try {
// 디바이스 ID 검증 및 저장
const deviceId = await this.deviceIdentifierService.validateAndStoreDeviceId(
requestDto.hashedDeviceId,
requestDto.deviceInfo
);
// 챌린지 생성
const challenge = await this.challengeResponseService.generateChallenge(
deviceId.getValue()
);
return challenge;
} catch (error) {
throw new UnauthorizedException('디바이스 ID 검증 실패');
}
}
@Post('complete-challenge')
@ApiOperation({ summary: '챌린지 응답 검증 및 앱 토큰 발급' })
async completeChallenge(@Body() responseDto: {
hashedDeviceId: string;
encryptedChallenge: string;
nonce: string;
}) {
// 응답 검증
const isValid = await this.challengeResponseService.validateResponse(
responseDto.hashedDeviceId,
responseDto.encryptedChallenge,
responseDto.nonce
);
if (!isValid) {
throw new UnauthorizedException('챌린지 검증 실패');
}
// 디바이스 ID 객체 생성
const deviceId = new DeviceIdentifier(responseDto.hashedDeviceId);
// 앱 토큰 발급 (appId는 실제 앱 식별자로 대체 필요)
// TODO: 이 부분은 요청 컨텍스트나 설정 등에서 실제 앱 ID를 가져오는 로직으로 대체해야 합니다.
const appId = 'your-actual-app-id';
const { token: appToken, secret: appSecret } = await this.appTokenService.generateAppToken(appId, deviceId);
return { appToken, appSecret };
}
}
클라이언트(앱) 구현 예시
이 예시는 iOS 앱에서 앱 인증을 구현하는 방법을 보여줍니다:
import Foundation
extension LiveTokenManager {
///
func createAppToken() async -> String? {
do {
// (1) Challenge
let challengeReq = try createChallengeReqDto()
let challengeResp = try await fetchChallenge(reqDto: challengeReq)
// (2) VerifyApp
let verifyAppReq = try createVerifyAppReqDto(challengeReq: challengeReq, challengeResp: challengeResp)
let verifyAppResp = try await verifyApp(reqDto: verifyAppReq)
return verifyAppResp.appToken
} catch {
handleCreateAppTokenError(error: error)
return nil
}
}
}
// MARK: - Fetch
extension LiveTokenManager {
private func createChallengeReqDto() throws -> ChallengeDto.Request {
let deviceId = ChallengeDto.DeviceIdDto(
uuid: Config.current.deviceUUID,
platform: "iOS",
version: Config.current.appVersion,
timestamp: Date.now
)
let jsonData = try jsonEncoder.encode(deviceId)
guard let jsonString = String(data: jsonData, encoding: .utf8) else { throw TokenErrorType.invalidDataFormat }
// 주의: 서버 구현(hex 인코딩)과 일치하는 해시 생성 및 인코딩 방식(예: hex)을 사용해야 합니다.
let deviceIdHash = try CryptoHelper.generateSHA256Hash(jsonString)
return ChallengeDto.Request(deviceId: deviceId, deviceIdHash: deviceIdHash)
}
private func fetchChallenge(reqDto: ChallengeDto.Request) async throws -> ChallengeDto.Response {
let url = URLBuilder(
host: Config.current.networkHost,
path: NetworkConstant.Path.Auth.App.challenge
).url
let urlRequest = createURLRequest(url: url, httpMethod: .POST)
let encodedReqDto = try jsonEncoder.encode(reqDto)
return try await executeURLSession(urlRequest: urlRequest, reqDto: encodedReqDto)
}
}
// MARK: - Verify
extension LiveTokenManager {
private func createVerifyAppReqDto(
challengeReq: ChallengeDto.Request,
challengeResp: ChallengeDto.Response
) throws -> VerifyAppDto.Request {
let deviceIdHash = challengeReq.deviceIdHash
let nonce = challengeResp.nonce
let challenge = challengeResp.challenge
// RSA로 챌린지 암호화 (새로운 방식)
let encryptedChallenge = try CryptoHelper.encryptRSA(challenge)
return .init(
deviceId: .init(reqDto: challengeReq.deviceId),
deviceIdHash: deviceIdHash,
encryptedChallenge: encryptedChallenge,
nonce: nonce
)
}
private func verifyApp(reqDto: VerifyAppDto.Request) async throws -> VerifyAppDto.Response {
let url = URLBuilder(
host: Config.current.networkHost,
path: "/de/v1/auth/app/complete-challenge" // 경로 수정
).url
let urlRequest = createURLRequest(url: url, httpMethod: .POST)
let encodedReqDto = try jsonEncoder.encode(reqDto)
return try await executeURLSession(urlRequest: urlRequest, reqDto: encodedReqDto)
}
}
// MARK: - Error
extension LiveTokenManager {
private func handleCreateAppTokenError(
apiError: APIErrorDto? = nil,
statusCode: Int = 0,
error: Error? = nil
) {
let errorText = error.map { " | Error: \(String(describing: $0))" } ?? ""
handleError(
errInfo: .init(severity: .critical(.appTokenCreationFailed), apiError: apiError), // CRITICAL!
function: #function,
details: "📱🎫🛜statusCode: \(statusCode)\(errorText)"
)
}
}
이 Swift 구현에서는 LiveTokenManager가 앱 인증의 전체 흐름을 관리합니다:
- 디바이스 정보 제공: UUID, 플랫폼(iOS), 앱 버전, 타임스탬프를 포함한 디바이스 정보 제공
- 챌린지 요청: 서버에 챌린지 요청 전송 및 응답 수신
- RSA 암호화: 앱에 내장된 서버 공개키를 사용하여 챌린지 암호화
- 응답 전송 및 토큰 수신: 암호화된 챌린지를 서버로 전송하고 앱 토큰 수신
코드의 주요 특징:
- 모든 네트워크 통신은 async/await을 사용하여 비동기적으로 처리
- 오류 처리를 위한 전용 메서드 구현
- 타입 안전성을 위한 DTO(Data Transfer Object) 사용
- 암호화 작업에 CryptoHelper 유틸리티 클래스 활용
iOS/Android 클라이언트와 서버 간 RSA 암호화 호환성
클라이언트와 서버 간의 챌린지-응답 메커니즘에서는 RSA 비대칭 암호화를 사용합니다. 이 방식은 클라이언트가 서버의 공개키로 데이터를 암호화하고 서버는 개인키로 복호화하는 방식입니다.
RSA 암호화 데이터 형식
클라이언트에서 서버로 전송되는 암호화된 데이터는 다음과 같은 형식을 갖습니다:
[RSA로 암호화된 데이터]
이 데이터는 Base64로 인코딩되어 문자열 형태로 전송됩니다.
클라이언트 측 구현 (Swift)
iOS 클라이언트에서는 실제 CryptoHelper 클래스를 통해 다음과 같이 구현합니다:
// 파일 경로: dta-wide-app-ios/.../CryptoHelper.swift
import CryptoKit
import Security
import Foundation
enum CryptoHelper {
// ... (ErrorType, 상수 정의 등은 문서 간결성을 위해 생략) ...
// 서버의 공개키
static let serverPublicKey = """
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzHslv+41DFAjvQXw9kFg
f13F5Y8vedQQEBqOHmQMcAw2CQw3JYlKBmNJqsWOTDxg0C1nAZ+od3iJ6LqMfXVa
NdbL4ZnfxQDXlZt0npGxH6JImk7j5h1G6Yh32jOK2L0XVxDuCIyK0QARs0oC9V0C
sSDXxkHZ5BjnLoVwSrUNwgJvF7mYxH1QgEQaF1wr4w2FkEI4qDXWJ1p1SZfdkJ3I
bffCxCu4ZnFGORTBJHbYgfLHjrEe18mj2Fpv+Mh1BU8JU04UOt1CGs5sghXMJpEz
tK1HUZDSnvpGQZlNiRWkFzkGiGwZJIiWiUttqbhQXKGEwGLJh2H4t6RYEbY5dZGb
EQIDAQAB
-----END PUBLIC KEY-----
"""
// SHA256 해시 생성 (Hex 인코딩)
static func generateSHA256Hash(_ input: String, salt: String? = nil) throws -> String {
let saltedInput = if let salt { input + salt } else { input }
guard let saltedInputData = saltedInput.data(using: .utf8) else { throw ErrorType.invalidDataFormat }
let hashedData = SHA256.hash(data: saltedInputData)
// 서버 구현과 일치하도록 hex 문자열로 반환
return hashedData.compactMap { String(format: "%02x", $0) }.joined()
}
// 서버 공개키 로드
static func loadServerPublicKey() throws -> SecKey {
// PEM 형식 키에서 헤더/푸터 제거 및 Base64 디코딩
let publicKeyString = serverPublicKey
.replacingOccurrences(of: "-----BEGIN PUBLIC KEY-----", with: "")
.replacingOccurrences(of: "-----END PUBLIC KEY-----", with: "")
.replacingOccurrences(of: "\n", with: "")
guard let keyData = Data(base64Encoded: publicKeyString) else {
throw ErrorType.publicKeyLoadFailed
}
// X.509 SubjectPublicKeyInfo 형식으로 변환
let attributes: [String: Any] = [
kSecAttrKeyType as String: kSecAttrKeyTypeRSA,
kSecAttrKeyClass as String: kSecAttrKeyClassPublic,
kSecAttrKeySizeInBits as String: 2048
]
guard let publicKey = SecKeyCreateWithData(keyData as CFData, attributes as CFDictionary, nil) else {
throw ErrorType.publicKeyLoadFailed
}
return publicKey
}
// RSA 암호화
static func encryptRSA(_ input: String) throws -> String {
guard let inputData = input.data(using: .utf8) else {
throw ErrorType.invalidDataFormat
}
let publicKey = try loadServerPublicKey()
var error: Unmanaged<CFError>?
guard let encryptedData = SecKeyCreateEncryptedData(
publicKey,
.rsaEncryptionPKCS1,
inputData as CFData,
&error
) as Data? else {
throw error?.takeRetainedValue() as? Error ?? ErrorType.rsaEncryptionFailed
}
return encryptedData.base64EncodedString()
}
}
서버 측 구현 (Node.js/TypeScript)
서버에서는 클라이언트가 보낸 Base64 인코딩된 RSA 암호화 데이터를 받아 처리합니다:
private decryptChallenge(encryptedChallenge: string): string {
try {
// 1. Base64 디코딩
const encryptedBuffer = Buffer.from(encryptedChallenge, 'base64');
// 2. RSA 복호화
const decrypted = privateDecrypt(
{
key: this.privateKey,
padding: constants.RSA_PKCS1_PADDING,
},
encryptedBuffer
);
// 3. UTF-8 문자열로 변환
return decrypted.toString('utf8');
} catch (error) {
// 복호화 실패 시 로깅 (상세 오류는 보안 문제로 제한적으로 로깅)
console.error('Challenge decryption failed:', error.name);
return ''; // 복호화 실패 시 빈 문자열 반환 (인증 실패 처리됨)
}
}
RSA 키 쌍 생성
서버에서 RSA 키 쌍을 안전하게 생성하고 관리하는 방법은 다음과 같습니다:
# 2048비트 RSA 키 쌍 생성
openssl genrsa -out private_key.pem 2048
# 개인키에서 공개키 추출
openssl rsa -in private_key.pem -pubout -out public_key.pem
# 공개키를 클라이언트 앱에 포함시키고, 개인키는 서버에서 안전하게 관리
보안 고려사항
RSA 비대칭 암호화 방식을 사용할 때의 추가 보안 고려사항:
- RSA 키 관리: 개인키는 HSM이나 KMS와 같은 보안 저장소에서 관리
- 암호화 알고리즘: RSA-2048 이상의 강력한 암호화 알고리즘 사용
- 네트워크 보안: 모든 통신은 TLS/SSL을 통해 암호화
- 논스 및 타임스탬프: 모든 요청에 논스와 타임스탬프를 포함시켜 재사용 공격 방지
- 앱 무결성: 추가적인 무결성 검증 메커니즘 고려 (예: SafetyNet, Play Integrity API, App Attest)
환경 변수 설정
앱 인증 시스템에 필요한 다음 환경 변수를 설정해야 합니다:
# 앱 인증 관련 환경 변수
RSA_PRIVATE_KEY_PATH=/path/to/private_key.pem
# 또는 개인키를 직접 환경 변수로 설정 (KMS에서 가져온 경우)
RSA_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----"
APP_TOKEN_SECRET=secure_random_token_secret_here
APP_TOKEN_EXPIRATION=604800000 # 7일(밀리초): 7 * 24 * 60 * 60 * 1000
환경 변수를 설정하는 방법은 다음과 같습니다:
- 로컬 개발 환경:
cloudrun-deploy/secret/**/dta-wide-api.json파일에 추가
{
"RUNTIME_ENV": "cloudrun",
"DEPLOY_ENV": "dev",
// 다른 환경 변수들...
"RSA_PRIVATE_KEY_PATH": "/app/keys/private_key.pem",
"APP_TOKEN_SECRET": "T9XUGfZlqHBzEHdgsp60Od6E/8QlpMYRTx0gKz4kkGA=",
"APP_TOKEN_EXPIRATION": 604800000
}
- 보안 키 생성 방법: 안전한 키를 생성하려면 다음 명령을 사용합니다:
# RSA 키 쌍 생성 (2048비트)
openssl genrsa -out private_key.pem 2048
# 공개키 추출
openssl rsa -in private_key.pem -pubout -out public_key.pem
# 랜덤 토큰 시크릿 생성
openssl rand -base64 32
- CoreConfigModule 설정:
libs/core/config/src/lib/namespaces/app.config.ts파일에 다음과 같이 구성합니다:
/**
* 앱 기본 설정
*/
export const appConfig = registerAs('app', () => ({
name: process.env['APP_NAME'] || 'dta-wide-api',
port: parseInt(process.env['PORT'] || '3000', 10),
host: process.env['HOST'] || 'localhost',
runtimeEnv: process.env['RUNTIME_ENV'] || 'local',
deployEnv: process.env['DEPLOY_ENV'] || 'dev',
serviceName: process.env['SERVICE_NAME'] || 'dta-wide-api',
apiHost: process.env['API_HOST'] || 'http://localhost:3000',
// 앱 보안 관련 설정
security: {
rsaPrivateKeyPath: process.env['RSA_PRIVATE_KEY_PATH'],
rsaPrivateKey: process.env['RSA_PRIVATE_KEY'],
appTokenSecret: process.env['APP_TOKEN_SECRET'],
appTokenExpiration: parseInt(process.env['APP_TOKEN_EXPIRATION'] || '604800000', 10),
}
}));
이러한 설정은 CoreConfigModule의 구성 서비스를 통해 접근할 수 있습니다:
// 코드에서 설정값 사용 예시
import { ConfigService } from '@core/config';
@Injectable()
export class MyService {
constructor(private readonly configService: ConfigService) {
// RSA 개인키 가져오기 (경로 또는 직접 키)
const keyPath = this.configService.get<string>('app.security.rsaPrivateKeyPath');
if (keyPath) {
this.privateKey = fs.readFileSync(path.resolve(keyPath), 'utf8');
} else {
this.privateKey = this.configService.getRequired<string>('app.security.rsaPrivateKey');
}
// 선택적 설정 가져오기 (없으면 기본값 사용)
const tokenExpires = this.configService.get<number>('app.security.appTokenExpiration', 604800000);
}
}
오류 코드 관리
앱 인증 시스템에서 발생하는 다양한 오류는 Auth 도메인의 공통 오류 관리 방식을 따릅니다. 이 방식은 오류 코드와 메시지를 enum으로 중앙 관리하여 일관성과 유지보수성을 높입니다.
앱 인증 시스템과 관련된 주요 오류 코드는 다음과 같습니다:
| HTTP 상태 코드 | 오류 코드 | 메시지 | 설명 |
|---|---|---|---|
| 500 | 2000 | SERVER_ERROR | 서버 내부 오류 |
| 400 | 2009 | INVALID_DEVICE_ID | 디바이스 ID가 유효하지 않습니다 |
| 401 | 2010 | CHALLENGE_VERIFICATION_FAILED | 챌린지 검증에 실패했습니다 |
| 403 | 2011 | DEVICE_BLACKLISTED | 디바이스가 블랙리스트에 등록되어 있습니다 |
| 401 | 2015 | INVALID_APP_TOKEN | 앱 토큰이 유효하지 않거나 만료되었습니다 |
| 400 | 2016 | DEVICE_HASH_MISMATCH | 디바이스 ID 해시가 계산된 값과 일치하지 않습니다 |
| 429 | 2040 | RATE_LIMIT_EXCEEDED | 요청 횟수가 제한을 초과했습니다 |
오류 코드 관리 방식과 구현에 대한 자세한 내용은 오류 관리 가이드를 참조하세요.
테스트 방법
- 단위 테스트: 각 서비스 메서드 독립적 테스트
- 통합 테스트: 완전한 챌린지-응답 흐름 테스트
- 보안 테스트: 다양한 공격 시나리오에 대한 방어 테스트
- 성능 테스트: 높은 부하 조건에서의 응답 시간 테스트
변경 이력
| 버전 | 날짜 | 작성자 | 변경 사항 |
|---|---|---|---|
| 0.1.0 | 2025-03-29 | bok@weltcorp.com | 초기 버전 작성 |
| 0.2.0 | 2025-04-15 | bok@weltcorp.com | 대칭 키 기반 메커니즘에서 RSA 비대칭 암호화 방식으로 변경 |