사용자 인증
개요
dta-wide API는 사용자 인증을 위해 이중 해싱된 비밀번호와 JSON Web Token(JWT) 기반의 인증 체계를 사용합니다. 이 문서는 클라이언트 애플리케이션에서 사용자 인증을 구현하기 위한 가이드라인을 제공합니다.
비밀번호 보안 아키텍처
이중 해싱 방식
dta-wide API는 사용자 비밀번호를 안전하게 처리하기 위해 이중 해싱 방식을 사용합니다. 이 방식은 클라이언트와 서버 모두에서 보안 레이어를 제공하여 비밀번호의 안전성을 크게 향상시킵니다.
-
클라이언트 측 해싱(1차):
- 사용자가 입력한 원본 비밀번호는 클라이언트 앱에서 먼저 해싱됩니다.
- 앱 내부의 비밀 키(appSecret)와 디바이스 고유 식별자(deviceUUID)를 솔트로 사용하여 SHA-256 해싱을 적용합니다.
- 이렇게 1차 해싱된 비밀번호가 서버로 전송됩니다.
-
서버 측 해싱(2차):
- 서버는 클라이언트에서 이미 해싱된 비밀번호를 받습니다.
- 서버측 페퍼(pepper)를 추가하여 bcrypt 알고리즘으로 추가 해싱을 적용합니다.
- 이 최종 해시값이 데이터베이스에 저장됩니다.
이중 해싱의 장점
- 서버는 원본 비밀번호를 절대 볼 수 없습니다.
- 네트워크 상에서 원본 비밀번호가 전송되지 않습니다.
- 서버 데이터베이스가 노출되어도 원본 비밀번호를 복구하기 어렵습니다.
- 클라이언트와 서버 양쪽에서 솔트를 적용하여 rainbow table 공격에 대한 방어력이 강화됩니다.
- 디바이스별 솔트를 사용하여 크리덴셜 재사용 공격을 방지합니다.
사용자 인증 흐름
회원가입 흐름
- 사용자가 회원가입 폼에 이메일, 비밀번호 등 정보 입력
- 클라이언트에서 비밀번호 1차 해싱 (SHA-256 + 클라이언트 솔트)
- 해싱된 비밀번호를 서버로 전송
- 서버에서 추가 해싱 후 저장 (bcrypt + 서버 페퍼)
로그인 흐름
- 사용자가 로그인 폼에 이메일, 비밀번호 입력
- 클라이언트에서 비밀번호 1차 해싱
- 해싱된 비밀번호를 서버로 전송
인증 요청 예시:
{
"email": "user@example.com",
"passwordHash": "client_side_hashed_password"
}
- 서버에서 비밀번호 검증 후 인증 토큰 발급
- 클라이언트에서 토큰 안전하게 저장
이메일 인증 흐름
이메일 인증은 회원가입 및 비밀번호 변경 시 사용되는 공통 프로세스입니다.
- 사용자가 이메일 인증을 요청
- 시스템은 사용자의 이메일로 6자리 OTP 인증 코드 발송
- 사용자가 모바일 앱에서 수신된 인증 코드를 입력
- 인증 코드가 확인되면 verificationId 발급
- 후속 처리 (회원가입 또는 비밀번호 변경)
이메일 인증 코드는 제한된 시간(5분) 동안만 유효하며, 동일 이메일 주소에 대해 10분 내 요청 횟수를 5회로 제한합니다. 또한 인증 코드 검증 시도는 동일 이메일 및 요청 ID에 대해 최대 10회까지만 허용됩니다. 10회 실패 시 해당 인증 코드는 무효화되고, 이메일 주소는 1시간 동안 잠금 상태가 됩니다.
비밀번호 변경 흐름
- 사용자가 비밀번호 변경/재설정을 요청
- 이메일 인증 흐름 수행
- 인증 완료 후 새 비밀번호 입력 화면으로 전환
- 사용자가 새 비밀번호 입력
- 클라이언트에서 새 비밀번호 1차 해싱 수행
- 해싱된 새 비밀번호를 서버로 전송
- 서버에서 추가 해싱 후 데이터베이스에 저장
- 모든 기존 인증 토큰 무효화
토큰 기반 인증
토큰 구조
JWT 토큰은 다음과 같은 구조로 되어 있습니다:
-
헤더 (Header): 토큰 유형과 사용된 알고리즘 정보
{
"alg": "HS256",
"typ": "JWT"
} -
페이로드 (Payload): 사용자 ID, 권한, 만료 시간 등의 정보
{
"sub": "1234567890",
"name": "홍길동",
"role": "user",
"iat": 1516239022,
"exp": 1516242622
} -
서명 (Signature): 토큰의 무결성을 보장하는 서명
액세스 토큰과 리프레시 토큰
dta-wide API는 두 가지 유형의 토큰을 사용합니다:
액세스 토큰
- 짧은 수명: 정확히 30분 (DEV 환경에서는 5분)
- API 호출에 직접 사용하여 보호된 리소스에 접근
- 헤더에
Authorization: Bearer {token}형식으로 포함하여 전송 - 사용자 ID, 권한 및 동의(Consent) 정보를 포함
- 토큰이 유효한 동안에만 리소스 접근 권한 부여
- 만료 시 리프레시 토큰을 사용하여 새로운 액세스 토큰 발급 필요
리프레시 토큰
- 긴 수명: 정확히 14일
- 액세스 토큰이 만료되었을 때 새로운 액세스 토큰을 획득하는 데만 사용
- 액세스 토큰보다 더 높은 보안 수준으로 저장해야 함
- 사용자 비밀번호 변경 또는 보안 위반 감지 시 자동으로 무효화
- 한 계정은 한 번에 하나의 디바이스에서만 로그인 가능 (멀티 디바이스 동시 접속 불가)
- 새 디바이스에서 로그인 시 이전 디바이스의 토큰은 자동 무효화
토큰 관리 전략:
- 액세스 토큰 만료 10분 전에 선제적 갱신 구현
- 401 Unauthorized 응답 수신 시 리프레시 토큰으로 자동 갱신 시도
- 리프레시 토큰 만료 또는 무효 시 사용자에게 재로그인 요청
모바일 앱 구현 가이드
클라이언트 비밀번호 해싱 구현
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 또는 로컬 저장소에 평문으로 저장하지 마세요!
토큰 갱신 전략
-
선제적 갱신: 액세스 토큰 만료 전에 미리 갱신
// 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
} -
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
}
보안 권장사항
-
HTTPS 사용: 모든 API 통신은 반드시 HTTPS를 통해 이루어져야 합니다.
-
토큰 유효성 검사: 서버 측에서는 모든 토큰에 대해 서명 검증, 만료 시간 확인, 발행자 검증 등의 유효성 검사를 수행해야 합니다.
-
토큰 탈취 방지:
- 인증서 피닝(Certificate Pinning) 구현
- 토큰 저장소 암호화
- 앱 보안 강화(루팅/탈옥 감지 등)
-
세션 관리:
- 비정상적인 접근 패턴 감지 시 토큰 무효화
- 사용자 비밀번호 변경 시 모든 토큰 무효화
- 토큰 사용 감사 로깅
- API 요청 시 항상 이미 해싱된 비밀번호를
passwordHash필드로 전송해야 합니다. - 원본 비밀번호는 절대 네트워크를 통해 전송하지 마세요.
- 이중 해싱을 사용하더라도 HTTPS는 필수적으로 적용해야 합니다.