본문으로 건너뛰기
버전: 0.68.0

앱 인증

개요

앱 인증 API는 모바일 앱과 백엔드 서버 간의 안전한 통신을 위한 인증 메커니즘을 제공합니다. 이 API는 Device ID 기반 인증과 시간 기반 OTP 챌린지 인증을 통해 클라이언트 앱의 무결성을 검증합니다.

앱 인증 흐름

앱 인증은 다음과 같은 챌린지-응답 메커니즘으로 진행됩니다:

  1. 클라이언트 앱: 디바이스 정보 및 해당 정보의 해시 생성 후 챌린지 요청
  2. 서버: 챌린지 생성 및 응답 (챌린지, 논스 포함)
  3. 클라이언트 앱: 논스와 챌린지를 결합한 후 서버의 RSA 공개키를 사용해 비대칭 암호화
  4. 서버: 암호화된 챌린지 검증(서버 개인키로 복호화) 및 앱 토큰 발급

비대칭 암호화(RSA)를 사용하면 앱은 서버의 공개키만 알고 있으면 되므로, 앱 내에 민감한 비밀 정보를 저장할 필요가 없어 보안이 강화됩니다. 서버만이 개인키를 보유하여 암호화된 메시지를 해독할 수 있습니다.

앱 인증 토큰 사용 흐름

앱 인증은 사용자 로그인 전에 가장 먼저 수행되어야 하는 단계입니다. 다음과 같은 순서로 진행됩니다:

  1. 앱 보안 인증

    • 디바이스 정보를 이용해 챌린지 요청(/de/v1/auth/app/challenge) 및 응답(/de/v1/auth/app/complete-challenge)을 통해 appToken 발급
    • 앱 인증 후, 사용자 인증 전 API 요청 또는 앱 자체의 인증만 필요한 API 요청에는 Authorization: Bearer {appToken} 헤더를 포함합니다.
  2. 사용자 미인증 상태에서의 API 호출

    • 앱 토큰만으로 접근 가능한 모든 API에 appToken 사용
    • 예시: Access Code 검증, 약관 목록 조회 및 동의, 이메일 인증, 회원가입, 로그인 등
  3. 사용자 인증 후 토큰 사용

    • 로그인 성공 시 accessToken과 refreshToken 발급
    • 이후 사용자 인증이 필요한 API는 Authorization: Bearer {accessToken} 헤더 사용

API 명세

API 상세 명세

앱 인증 API의 상세한 요청/응답 명세는 앱 인증 API 레퍼런스 문서를 참조하세요.

클라이언트 구현 가이드

다음은 클라이언트 구현의 핵심 로직 예시입니다. 실제 코드는 프로젝트의 CryptoHelper 등을 참조하세요.

iOS 구현 예시 (Swift)

다음은 LiveTokenManager를 사용하여 앱 인증 흐름을 구현하는 예시입니다. 실제 암호화 로직은 CryptoHelper 유틸리티를 사용합니다.

import Foundation

// --- LiveTokenManager 앱 토큰 생성 흐름 ---
extension LiveTokenManager {
///![LiveTokenManager+createAppToken](LiveTokenManager_createAppToken_fc.png) // 이미지 경로 확인 필요
func createAppToken() async -> String? {
do {
// (1) Challenge 요청 준비
let challengeReq = try createChallengeReqDto()

// (2) Challenge 요청 API 호출
let challengeResp = try await fetchChallenge(reqDto: challengeReq)

// (3) VerifyApp 요청 준비 (챌린지 암호화 포함)
let verifyAppReq = try createVerifyAppReqDto(challengeReq: challengeReq, challengeResp: challengeResp)

// (4) VerifyApp 요청 API 호출 (챌린지 완료 및 토큰 발급)
let verifyAppResp = try await verifyApp(reqDto: verifyAppReq)

// (5) 앱 토큰 반환 및 저장
return verifyAppResp.appToken

} catch {
// 오류 처리
handleCreateAppTokenError(error: error)
return nil
}
}
}

// MARK: - 챌린지 요청 관련 함수
extension LiveTokenManager {
private func createChallengeReqDto() throws -> ChallengeDto.Request {
// 디바이스 정보 생성 (앱 설정 및 현재 상태 기반)
let deviceId = ChallengeDto.DeviceIdDto(
uuid: Config.current.deviceUUID, // 실제 기기 UUID 사용
platform: "iOS",
version: Config.current.appVersion, // 실제 앱 버전 사용
timestamp: Date.now // 현재 타임스탬프
)

// DeviceIdDto를 JSON 문자열로 직렬화
let jsonData = try jsonEncoder.encode(deviceId)
guard let jsonString = String(data: jsonData, encoding: .utf8) else { throw TokenErrorType.invalidDataFormat }

// JSON 문자열을 SHA-256 해싱 (Hex 인코딩)
// 서버 측 구현과 해싱 방식 일치 확인 필요
let deviceIdHash = try CryptoHelper.generateSHA256Hash(jsonString)

// 챌린지 요청 DTO 반환
return ChallengeDto.Request(deviceId: deviceId, deviceIdHash: deviceIdHash)
}

private func fetchChallenge(reqDto: ChallengeDto.Request) async throws -> ChallengeDto.Response {
// 챌린지 요청 API 엔드포인트 URL 생성
let url = URLBuilder(
host: Config.current.networkHost, // 실제 API 호스트 사용
path: NetworkConstant.Path.Auth.App.challenge // 실제 API 경로 사용
).url

// POST 요청 생성
let urlRequest = createURLRequest(url: url, httpMethod: .POST)

// 요청 본문 인코딩
let encodedReqDto = try jsonEncoder.encode(reqDto)

// 네트워크 요청 실행 및 응답 디코딩
return try await executeURLSession(urlRequest: urlRequest, reqDto: encodedReqDto)
}
}

// MARK: - 챌린지 완료 관련 함수
extension LiveTokenManager {
private func createVerifyAppReqDto(
challengeReq: ChallengeDto.Request,
challengeResp: ChallengeDto.Response
) throws -> VerifyAppDto.Request {
// 필요한 정보 추출
let deviceIdHash = challengeReq.deviceIdHash
let challenge = challengeResp.challenge
let nonce = challengeResp.nonce
let input = nonce + challenge

// RSA 암호화 수행
let encryptedChallenge = try CryptoHelper.encryptRSA(input)

// 챌린지 완료 요청 DTO 반환
return VerifyAppDto.Request(
deviceIdHash: deviceIdHash,
encryptedChallenge: encryptedChallenge
)
}

private func verifyApp(reqDto: VerifyAppDto.Request) async throws -> VerifyAppDto.Response {
// 챌린지 완료 API 엔드포인트 URL 생성
let url = URLBuilder(
host: Config.current.networkHost, // 실제 API 호스트 사용
path: "/de/v1/auth/app/complete-challenge" // 실제 API 경로 사용 (문서와 일치)
).url

// POST 요청 생성
let urlRequest = createURLRequest(url: url, httpMethod: .POST)

// 요청 본문 인코딩
let encodedReqDto = try jsonEncoder.encode(reqDto)

// 네트워크 요청 실행 및 응답 디코딩 (앱 토큰 포함)
return try await executeURLSession(urlRequest: urlRequest, reqDto: encodedReqDto)
}
}

// MARK: - 오류 처리
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), // 앱 토큰 생성 실패는 심각한 오류로 처리
function: #function,
details: "📱🎫🛜 App Token Creation Failed - statusCode: \(statusCode)\(errorText)"
)
}
}

// --- CryptoHelper (실제 구현 참조) ---
// 파일 경로: dta-wide-app-ios/.../CryptoHelper.swift (가정)

import CryptoKit
import Foundation
import Security // SecKey API 사용을 위해 필요

enum CryptoHelper {
// 상수 정의 (예: PBKDF2 반복 횟수, 키 길이)
static let defaultIterations = 10000
static let defaultKeyLenght = 32 // 256 비트 (AES-256)

// 사용자 정의 오류 타입
enum ErrorType: Error {
case invalidDataFormat
case invalidKeyLength
case encryptionFailed
case decryptionFailed
case hashFailed
case invalidDateFormat
case invalidPublicKey
}

// 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 }

// CryptoKit 사용
let hashedData = SHA256.hash(data: saltedInputData)

// 서버 구현과 일치하도록 hex 문자열로 반환
return hashedData.compactMap { String(format: "%02x", $0) }.joined()
}

// RSA 암호화 (Base64 인코딩된 결과 반환)
static func encryptRSA(_ input: String) throws -> String {
guard let inputData = input.data(using: .utf8) else {
throw ErrorType.invalidDataFormat
}

// 서버의 RSA 공개키를 가져옴
let publicKey = try getRSAPublicKey()

// RSA-OAEP-SHA256 암호화 수행
guard let encryptedData = SecKeyCreateEncryptedData(
publicKey,
.rsaEncryptionOAEPSHA256,
inputData as CFData,
nil
) as? Data else {
throw ErrorType.encryptionFailed
}

// Base64 인코딩하여 반환
return encryptedData.base64EncodedString()
}

// 서버 공개키 가져오기
private static func getRSAPublicKey() throws -> SecKey {
let attributes: [String: Any] = [
kSecAttrKeyType as String: kSecAttrKeyTypeRSA,
kSecAttrKeyClass as String: kSecAttrKeyClassPublic,
kSecAttrKeySizeInBits as String: 2048
]

guard
let keyData = Data(base64Encoded: Config.current.serverPublicKey),
let publicKey = SecKeyCreateWithData(keyData as CFData, attributes as CFDictionary, nil)
else {
throw ErrorType.invalidPublicKey
}

return publicKey
}
}

// Helper extension for JSON serialization (from existing code)
extension Encodable {
func toJsonString() -> String? {
let encoder = JSONEncoder()
// encoder.outputFormatting = .sortedKeys // 필요시 정렬하여 해시 일관성 확보
guard let data = try? encoder.encode(self) else { return nil }
return String(data: data, encoding: .utf8)
}
}

Android 구현 예시 (Kotlin)

import com.google.gson.Gson // JSON 직렬화
import java.security.MessageDigest // SHA-256
import java.security.KeyFactory
import java.security.PublicKey
import java.security.spec.X509EncodedKeySpec
import javax.crypto.Cipher
import java.util.Base64 // Base64
import android.util.Log

// --- 챌린지 요청 ---
fun requestChallenge() {
// 1. DeviceInfo 생성
val deviceInfo = DeviceInfo(
uuid = Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID) ?: UUID.randomUUID().toString(),
platform = "Android",
version = BuildConfig.VERSION_NAME,
timestamp = System.currentTimeMillis()
)

// 2. DeviceInfo 직렬화 및 해시 생성 (SHA256 + Hex)
val deviceIdJson = Gson().toJson(deviceInfo)
val deviceIdHash = CryptoHelper.generateSHA256Hash(deviceIdJson)

// 3. 챌린지 요청 DTO 생성
val request = ChallengeRequest(deviceId = deviceInfo, deviceIdHash = deviceIdHash)

// 4. API 호출 (/de/v1/auth/app/challenge)
viewModelScope.launch {
try {
val response = apiClient.getChallenge(request)
// 5. 챌린지 완료 함수 호출
completeChallenge(
challenge = response.challenge,
nonce = response.nonce,
originalDeviceIdHash = deviceIdHash // 해시값 전달
)
} catch (e: Exception) {
Log.e("Auth", "Challenge request failed", e)
}
}
}

// --- 챌린지 완료 ---
fun completeChallenge(challenge: String, nonce: String, originalDeviceIdHash: String) {

// 1. nonce와 challenge 결합
val input = nonce + challenge

// 2. RSA 암호화 수행
val encryptedChallenge = CryptoHelper.encryptRSA(input)

if (encryptedChallenge == null) {
Log.e("Auth", "Failed to encrypt challenge")
return
}

// 3. 챌린지 완료 요청 DTO 생성
val request = CompleteRequest(
deviceIdHash = originalDeviceIdHash,
encryptedChallenge = encryptedChallenge
)

// 4. API 호출 (/de/v1/auth/app/complete-challenge)
viewModelScope.launch {
try {
val response = apiClient.completeChallenge(request)
// 5. 앱 토큰 저장 (예: EncryptedSharedPreferences)
encryptedPrefs.edit {
putString("app_token", response.appToken)
}
Log.d("Auth", "App authentication successful")
proceedToNextStep()
} catch (e: Exception) {
Log.e("Auth", "Challenge completion failed", e)
}
}
}

// --- CryptoHelper (실제 구현 참조) ---
object CryptoHelper {
// SHA256 해시 생성 (Hex 인코딩)
fun generateSHA256Hash(input: String): String {
val bytes = MessageDigest.getInstance("SHA-256").digest(input.toByteArray())
return bytes.joinToString("") { "%02x".format(it) } // Hex 인코딩
}

// RSA 암호화 (Base64 인코딩된 결과 반환)
fun encryptRSA(input: String): String? {
return try {
// 서버의 RSA 공개키 로드
val publicKey = getServerPublicKey()

// RSA 암호화 수행
val cipher = Cipher.getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding")
cipher.init(Cipher.ENCRYPT_MODE, publicKey)
val encryptedBytes = cipher.doFinal(input.toByteArray())

// Base64 인코딩하여 반환
Base64.getEncoder().encodeToString(encryptedBytes)
} catch (e: Exception) {
Log.e("CryptoHelper", "RSA encryption failed", e)
null
}
}

// 서버 공개키 가져오기
private fun getServerPublicKey(): PublicKey {
val base64Key = BuildConfig.SERVER_PUBLIC_KEY
val keyBytes = Base64.getDecoder().decode(base64Key)

val keySpec = X509EncodedKeySpec(keyBytes)
val keyFactory = KeyFactory.getInstance("RSA")
return keyFactory.generatePublic(keySpec)
}
}

보안 고려사항

앱 보안 인증 구현 시 다음 사항을 고려해야 합니다:

  1. 키 관리: 클라이언트에서 생성/사용하는 암호화 키는 안전하게 관리되어야 합니다. (예: iOS Keychain, Android Keystore)
  2. 네트워크 보안: 모든 API 통신은 HTTPS를 사용하고, 인증서 피닝(Certificate Pinning)을 구현하여 중간자 공격을 방지하는 것을 권장합니다.
  3. 런타임 보호: 앱 실행 중 디버깅 방지, 루팅/탈옥 탐지, 코드 난독화 및 무결성 검증 등의 기법을 적용하여 런타임 환경에서의 공격을 방지합니다.
  4. 비대칭 암호화: RSA와 같은 비대칭 암호화를 사용하여 다음과 같은 이점을 얻습니다:
    • 앱은 서버의 공개키만 보유하므로 민감한 비밀 정보를 저장할 필요가 없음
    • 서버만이 개인키를 보유하여 안전하게 메시지를 복호화 가능
    • 앱이 탈옥/루팅되거나 역공학되더라도 통신 데이터를 복호화할 수 없음
    • OAEP 패딩과 같은 안전한 패딩 기법을 사용하여 추가적인 보안 레이어 제공