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

사용자 인증

개요

dta-wide API는 사용자 인증을 위해 이중 해싱된 비밀번호와 JSON Web Token(JWT) 기반의 인증 체계를 사용합니다. 이 문서는 클라이언트 애플리케이션에서 사용자 인증을 구현하기 위한 가이드라인을 제공합니다.

비밀번호 보안 아키텍처

이중 해싱 방식

dta-wide API는 사용자 비밀번호를 안전하게 처리하기 위해 이중 해싱 방식을 사용합니다. 이 방식은 클라이언트와 서버 모두에서 보안 레이어를 제공하여 비밀번호의 안전성을 크게 향상시킵니다.

  1. 클라이언트 측 해싱(1차):

    • 사용자가 입력한 원본 비밀번호는 클라이언트 앱에서 먼저 해싱됩니다.
    • 앱 내부의 비밀 키(appSecret)와 디바이스 고유 식별자(deviceUUID)를 솔트로 사용하여 SHA-256 해싱을 적용합니다.
    • 이렇게 1차 해싱된 비밀번호가 서버로 전송됩니다.
  2. 서버 측 해싱(2차):

    • 서버는 클라이언트에서 이미 해싱된 비밀번호를 받습니다.
    • 서버측 페퍼(pepper)를 추가하여 bcrypt 알고리즘으로 추가 해싱을 적용합니다.
    • 이 최종 해시값이 데이터베이스에 저장됩니다.

이중 해싱의 장점

  • 서버는 원본 비밀번호를 절대 볼 수 없습니다.
  • 네트워크 상에서 원본 비밀번호가 전송되지 않습니다.
  • 서버 데이터베이스가 노출되어도 원본 비밀번호를 복구하기 어렵습니다.
  • 클라이언트와 서버 양쪽에서 솔트를 적용하여 rainbow table 공격에 대한 방어력이 강화됩니다.
  • 디바이스별 솔트를 사용하여 크리덴셜 재사용 공격을 방지합니다.

사용자 인증 흐름

회원가입 흐름

  1. 사용자가 회원가입 폼에 이메일, 비밀번호 등 정보 입력
  2. 클라이언트에서 비밀번호 1차 해싱 (SHA-256 + 클라이언트 솔트)
  3. 해싱된 비밀번호를 서버로 전송
  4. 서버에서 추가 해싱 후 저장 (bcrypt + 서버 페퍼)

로그인 흐름

  1. 사용자가 로그인 폼에 이메일, 비밀번호 입력
  2. 클라이언트에서 비밀번호 1차 해싱
  3. 해싱된 비밀번호를 서버로 전송

인증 요청 예시:

{
"email": "user@example.com",
"passwordHash": "client_side_hashed_password"
}
  1. 서버에서 비밀번호 검증 후 인증 토큰 발급
  2. 클라이언트에서 토큰 안전하게 저장

이메일 인증 흐름

이메일 인증은 회원가입 및 비밀번호 변경 시 사용되는 공통 프로세스입니다.

  1. 사용자가 이메일 인증을 요청
  2. 시스템은 사용자의 이메일로 6자리 OTP 인증 코드 발송
  3. 사용자가 모바일 앱에서 수신된 인증 코드를 입력
  4. 인증 코드가 확인되면 verificationId 발급
  5. 후속 처리 (회원가입 또는 비밀번호 변경)
참고

이메일 인증 코드는 제한된 시간(5분) 동안만 유효하며, 동일 이메일 주소에 대해 10분 내 요청 횟수를 5회로 제한합니다. 또한 인증 코드 검증 시도는 동일 이메일 및 요청 ID에 대해 최대 10회까지만 허용됩니다. 10회 실패 시 해당 인증 코드는 무효화되고, 이메일 주소는 1시간 동안 잠금 상태가 됩니다.

비밀번호 변경 흐름

  1. 사용자가 비밀번호 변경/재설정을 요청
  2. 이메일 인증 흐름 수행
  3. 인증 완료 후 새 비밀번호 입력 화면으로 전환
  4. 사용자가 새 비밀번호 입력
  5. 클라이언트에서 새 비밀번호 1차 해싱 수행
  6. 해싱된 새 비밀번호를 서버로 전송
  7. 서버에서 추가 해싱 후 데이터베이스에 저장
  8. 모든 기존 인증 토큰 무효화

토큰 기반 인증

토큰 구조

JWT 토큰은 다음과 같은 구조로 되어 있습니다:

  1. 헤더 (Header): 토큰 유형과 사용된 알고리즘 정보

    {
    "alg": "HS256",
    "typ": "JWT"
    }
  2. 페이로드 (Payload): 사용자 ID, 권한, 만료 시간 등의 정보

    {
    "sub": "1234567890",
    "name": "홍길동",
    "role": "user",
    "iat": 1516239022,
    "exp": 1516242622
    }
  3. 서명 (Signature): 토큰의 무결성을 보장하는 서명

액세스 토큰과 리프레시 토큰

dta-wide API는 두 가지 유형의 토큰을 사용합니다:

액세스 토큰

  • 짧은 수명: 정확히 30분 (DEV 환경에서는 5분)
  • API 호출에 직접 사용하여 보호된 리소스에 접근
  • 헤더에 Authorization: Bearer {token} 형식으로 포함하여 전송
  • 사용자 ID, 권한 및 동의(Consent) 정보를 포함
  • 토큰이 유효한 동안에만 리소스 접근 권한 부여
  • 만료 시 리프레시 토큰을 사용하여 새로운 액세스 토큰 발급 필요

리프레시 토큰

  • 긴 수명: 정확히 14일
  • 액세스 토큰이 만료되었을 때 새로운 액세스 토큰을 획득하는 데만 사용
  • 액세스 토큰보다 더 높은 보안 수준으로 저장해야 함
  • 사용자 비밀번호 변경 또는 보안 위반 감지 시 자동으로 무효화
  • 한 계정은 한 번에 하나의 디바이스에서만 로그인 가능 (멀티 디바이스 동시 접속 불가)
  • 새 디바이스에서 로그인 시 이전 디바이스의 토큰은 자동 무효화
구현 가이드

토큰 관리 전략:

  1. 액세스 토큰 만료 10분 전에 선제적 갱신 구현
  2. 401 Unauthorized 응답 수신 시 리프레시 토큰으로 자동 갱신 시도
  3. 리프레시 토큰 만료 또는 무효 시 사용자에게 재로그인 요청

모바일 앱 구현 가이드

클라이언트 비밀번호 해싱 구현

iOS 구현 (Swift)

import CryptoKit
import Foundation

class PasswordHasher {
// 비밀번호 해싱 함수
static func hashPassword(_ password: String) throws -> String {
// appSecret과 deviceUUID를 솔트로 활용
let appSecret = Config.current.appSecret // 앱 내부에 저장된 비밀 키
let deviceUUID = Config.current.deviceUUID // 디바이스 고유 식별자
let salt = appSecret + deviceUUID

// 비밀번호에 솔트를 더해 SHA-256 해싱
let saltedInput = password + salt
guard let saltedInputData = saltedInput.data(using: .utf8) else {
throw PasswordError.invalidInput
}

// SHA-256 해싱 수행
let hashedData = SHA256.hash(data: saltedInputData)

// Hex 문자열로 반환
return hashedData.compactMap { String(format: "%02x", $0) }.joined()
}
}

// 로그인 요청 시 사용 예시
func login(email: String, password: String) async throws {
// 1. 비밀번호 해싱
let hashedPassword = try PasswordHasher.hashPassword(password)

// 2. 로그인 요청
let loginRequest = LoginRequest(
email: email,
passwordHash: hashedPassword // 해싱된 비밀번호 전송
)

// 3. API 호출
let response = try await apiClient.login(request: loginRequest)

// 4. 응답 처리
if let tokens = response.tokens {
saveTokens(tokens)
} else {
throw AuthError.loginFailed
}
}

Android 구현 (Kotlin)

import java.security.MessageDigest
import android.util.Log

object PasswordHasher {
// 비밀번호 해싱 함수
fun hashPassword(password: String): String {
try {
// appSecret과 deviceUUID를 솔트로 활용
val appSecret = BuildConfig.APP_SECRET // 앱 내부에 저장된 비밀 키
val deviceUUID = Settings.Secure.getString(
context.contentResolver,
Settings.Secure.ANDROID_ID
) ?: UUID.randomUUID().toString()
val salt = appSecret + deviceUUID

// 비밀번호에 솔트를 더해 SHA-256 해싱
val digest = MessageDigest.getInstance("SHA-256")
val saltedPassword = password + salt
val bytes = digest.digest(saltedPassword.toByteArray())

// Hex 문자열로 반환
return bytes.joinToString("") { "%02x".format(it) }
} catch (e: Exception) {
Log.e("PasswordHasher", "Hashing failed", e)
throw e
}
}
}

// 로그인 요청 시 사용 예시
suspend fun login(email: String, password: String) {
try {
// 1. 비밀번호 해싱
val hashedPassword = PasswordHasher.hashPassword(password)

// 2. 로그인 요청
val loginRequest = LoginRequest(
email = email,
passwordHash = hashedPassword // 해싱된 비밀번호 전송
)

// 3. API 호출
val response = apiClient.login(loginRequest)

// 4. 응답 처리
response.tokens?.let {
saveTokens(it)
} ?: throw AuthException("Login failed")
} catch (e: Exception) {
Log.e("Auth", "Login failed", e)
throw e
}
}

서버 측 구현

서버에서는 클라이언트로부터 받은 이미 해싱된 비밀번호에 추가적인 해싱을 적용합니다:

// 서버 측 비밀번호 처리 (의사 코드)
async function rehashPassword(hashedPassword: string): Promise<string> {
// 서버 측 페퍼 추가
const serverPepper = process.env.SERVER_PEPPER;

// bcrypt를 사용하여 추가 해싱 (솔트 라운드: 12)
const finalHash = await bcrypt.hash(hashedPassword + serverPepper, 12);

return finalHash;
}

// 로그인 시 비밀번호 검증
async function verifyPassword(hashedPassword: string, storedHash: string): Promise<boolean> {
// 클라이언트에서 해싱된 비밀번호에 서버 페퍼 추가
const serverPepper = process.env.SERVER_PEPPER;
const pepperedPassword = hashedPassword + serverPepper;

// bcrypt.compare로 비교
return await bcrypt.compare(pepperedPassword, storedHash);
}

안전한 토큰 저장

토큰은 안전한 저장소에 보관해야 합니다:

  • iOS: Keychain Services를 사용하여 저장

    // 토큰 저장
    func saveTokens(accessToken: String, refreshToken: String) {
    let queryAccess: [String: Any] = [
    kSecClass as String: kSecClassGenericPassword,
    kSecAttrAccount as String: "accessToken",
    kSecValueData as String: accessToken.data(using: .utf8)!,
    kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
    ]

    let queryRefresh: [String: Any] = [
    kSecClass as String: kSecClassGenericPassword,
    kSecAttrAccount as String: "refreshToken",
    kSecValueData as String: refreshToken.data(using: .utf8)!,
    kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
    ]

    // 기존 항목 삭제 후 새로 저장
    SecItemDelete(queryAccess as CFDictionary)
    SecItemAdd(queryAccess as CFDictionary, nil)

    SecItemDelete(queryRefresh as CFDictionary)
    SecItemAdd(queryRefresh as CFDictionary, nil)
    }
  • Android: EncryptedSharedPreferences 또는 Android Keystore System을 사용하여 저장

    // 토큰 저장
    fun saveTokens(accessToken: String, refreshToken: String) {
    val masterKey = MasterKey.Builder(context)
    .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
    .build()

    val sharedPreferences = EncryptedSharedPreferences.create(
    context,
    "auth_prefs",
    masterKey,
    EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
    EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
    )

    sharedPreferences.edit {
    putString("access_token", accessToken)
    putString("refresh_token", refreshToken)
    }
    }
주의사항

토큰을 일반 SharedPreferences, UserDefaults 또는 로컬 저장소에 평문으로 저장하지 마세요!

토큰 갱신 전략

  1. 선제적 갱신: 액세스 토큰 만료 전에 미리 갱신

    // iOS 예시
    func shouldRefreshToken() -> Bool {
    guard let accessToken = getAccessToken(),
    let claims = decodeJWT(accessToken) else {
    return true
    }

    // 토큰이 만료 10분 전이면 갱신
    if let expiration = claims["exp"] as? Double {
    let expirationDate = Date(timeIntervalSince1970: expiration)
    let tenMinutesBeforeExpiration = expirationDate.addingTimeInterval(-600)
    return Date() > tenMinutesBeforeExpiration
    }

    return true
    }
  2. 401 응답 시 갱신: API 호출이 401 Unauthorized로 실패한 경우 갱신

    // Android 예시
    suspend fun authenticatedApiCall(apiCall: suspend () -> Response<T>): Response<T> {
    var response = apiCall()

    if (response.code() == 401) {
    // 토큰 갱신 시도
    val refreshResult = authRepository.refreshToken()

    if (refreshResult.isSuccess) {
    // 새 토큰으로 API 재호출
    response = apiCall()
    } else {
    // 리프레시 실패 시 로그아웃 처리
    authRepository.logout()
    throw UnauthorizedException()
    }
    }

    return response
    }

보안 권장사항

  1. HTTPS 사용: 모든 API 통신은 반드시 HTTPS를 통해 이루어져야 합니다.

  2. 토큰 유효성 검사: 서버 측에서는 모든 토큰에 대해 서명 검증, 만료 시간 확인, 발행자 검증 등의 유효성 검사를 수행해야 합니다.

  3. 토큰 탈취 방지:

    • 인증서 피닝(Certificate Pinning) 구현
    • 토큰 저장소 암호화
    • 앱 보안 강화(루팅/탈옥 감지 등)
  4. 세션 관리:

    • 비정상적인 접근 패턴 감지 시 토큰 무효화
    • 사용자 비밀번호 변경 시 모든 토큰 무효화
    • 토큰 사용 감사 로깅
주의사항
  • API 요청 시 항상 이미 해싱된 비밀번호를 passwordHash 필드로 전송해야 합니다.
  • 원본 비밀번호는 절대 네트워크를 통해 전송하지 마세요.
  • 이중 해싱을 사용하더라도 HTTPS는 필수적으로 적용해야 합니다.