Ver3.5 ๐ iOS ๋ณด์๊ณผ Keychain ยท TouchID ยท FaceID์์ฒด ์ธ์ฆ ๊ตฌํ ์์ ์ ๋ณต

๐ iOS ๋ณด์๊ณผ Keychain ยท TouchID ยท FaceID
์์ฒด ์ธ์ฆ ๊ตฌํ ์์ ์ ๋ณต
์ผ ์์งํ ๋งํด์... ์ฑ ๋ง๋ค ๋ ๋ณด์ ํํธ ์ ์ผ ๊ท์ฐฎ์ง ์์? ใ ใ ใ ๊ทผ๋ฐ ์ด๊ฑฐ ์ ๋๋ก ์ ํ๋ฉด ๋์ค์ ์ง์ง ํฐ์ผ ๋จ. ์ฌ์ฉ์ ๋ฐ์ดํฐ ํธ๋ฆฌ๋ฉด ๊ทธ๋ฅ ์ฑ ๋งํ๋ ๊ฑฐ๊ฑฐ๋ . ์ค๋์ iOS ๋ณด์์ ํต์ฌ์ธ Keychain, TouchID, FaceID ์์ฒด ์ธ์ฆ์ Swift๋ก ์ด๋ป๊ฒ ๊ตฌํํ๋์ง ์ฐํ๊ฒ ํํค์ณ๋ณผ๊ฒ. ์ด๊ฑฐ ์๋ฉด ์ง์ง ์ฑ ํ๋ฆฌํฐ๊ฐ ๋ค๋ฅธ ์ฐจ์์ผ๋ก ์ฌ๋ผ๊ฐ ๐
iOS ๋ณด์์ด ์ ์ด๋ ๊ฒ ๊ฐ๋ ฅํ์ง ์๋ ค๋ฉด ์ ํ์ด ์ค๊ณํ ๋ณด์ ๋ ์ด์ด๋ฅผ ์ดํดํด์ผ ํด. ๊ทธ๋ฅ "์ํธํ ์ ๋์ด์์ด์~" ์์ค์ด ์๋๋ผ ํ๋์จ์ด๋ถํฐ ์ํํธ์จ์ด๊น์ง ๊ฒน๊ฒน์ด ์์ธ ๊ตฌ์กฐ์ผ.
๋ณด์ด์ง? ์ ํ์ ์ง์ง ํ๋์จ์ด ๋ ๋ฒจ๋ถํฐ ๋ณด์์ ์ค๊ณํ์ด. ํนํ Secure Enclave๋ ๋ฉ์ธ CPU์ ์์ ํ ๋ถ๋ฆฌ๋ ๋ณด์ ์ ์ฉ ์นฉ์ธ๋ฐ, ์ฌ๊ธฐ์ ์์ฒด์ ๋ณด๊ฐ ์ ์ฅ๋๊ณ ์ฒ๋ฆฌ๋ผ. ๊ฐ๋ฐ์๋ ์ง์ ์ ๊ทผ ๋ชป ํจ. ๊ทธ๋ฅ "์ธ์ฆ ์ฑ๊ณต/์คํจ" ๊ฒฐ๊ณผ๋ง ๋ฐ๋ ๊ฑฐ์ผ.
UserDefaults์ ๋น๋ฐ๋ฒํธ ์ ์ฅํ๋ ์ฌ๋... ์ ๋ฐ ๊ทธ๋ฌ์ง ๋ง ๐ญ UserDefaults๋ ๊ทธ๋ฅ plist ํ์ผ์ ํ๋ฌธ์ผ๋ก ์ ์ฅ๋๊ฑฐ๋ . ํ์ฅ ๊ธฐ๊ธฐ์์ ๋ฐ๋ก ํธ๋ฆผ. Keychain์ด ๋ฐ๋ก ์ด๋ฐ ๋ฏผ๊ฐํ ๋ฐ์ดํฐ๋ฅผ ์์ ํ๊ฒ ์ ์ฅํ๋ ๊ณณ์ด์ผ.
| ๊ฐ๋ | ์ค๋ช |
|---|---|
| kSecClass | ์ ์ฅํ ๋ฐ์ดํฐ ํ์ (๋น๋ฐ๋ฒํธ, ์ธ์ฆ์, ํค ๋ฑ) |
| kSecAttrService | ์ฑ ์๋ณ์ (๋ณดํต Bundle ID ์ฌ์ฉ) |
| kSecAttrAccount | ๊ณ์ ์๋ณ์ (์ฌ์ฉ์๋ช , ์ด๋ฉ์ผ ๋ฑ) |
| kSecValueData | ์ค์ ์ ์ฅํ ๋ฐ์ดํฐ (Data ํ์ ) |
| kSecAttrAccessible | ์ธ์ ์ ๊ทผ ๊ฐ๋ฅํ์ง ์ค์ (์ ๊ธ ํด์ ํ ๋ฑ) |
๋งค๋ฒ SecItemAdd, SecItemCopyMatching ์ด๋ฐ C API ์ง์ ์ฐ๋ฉด ์ฝ๋๊ฐ ๋๋ฌด ์ง์ ๋ถํด์ง. ๊ทธ๋์ ๋ณดํต ๋ํผ ํด๋์ค๋ฅผ ๋ง๋ค์ด์ ์. ์ค๋ฌด์์ ๋ฐ๋ก ์ธ ์ ์๋ ์์ค์ผ๋ก ๋ง๋ค์ด๋ณผ๊ฒ!
import Foundation
import Security
// MARK: - Keychain ์๋ฌ ์ ์
enum KeychainError: Error, LocalizedError {
case duplicateEntry
case itemNotFound
case invalidData
case unexpectedStatus(OSStatus)
var errorDescription: String? {
switch self {
case .duplicateEntry:
return "์ด๋ฏธ ์กด์ฌํ๋ ํญ๋ชฉ์
๋๋ค"
case .itemNotFound:
return "ํญ๋ชฉ์ ์ฐพ์ ์ ์์ต๋๋ค"
case .invalidData:
return "์ ํจํ์ง ์์ ๋ฐ์ดํฐ์
๋๋ค"
case .unexpectedStatus(let status):
return "์์์น ๋ชปํ ์ค๋ฅ: \(status)"
}
}
}
// MARK: - Keychain ์ ๊ทผ ๊ฐ๋ฅ ์์ ์ค์
enum KeychainAccessibility {
/// ๊ธฐ๊ธฐ ์ ๊ธ ํด์ ํ ์ ๊ทผ ๊ฐ๋ฅ (๊ฐ์ฅ ์ผ๋ฐ์ )
case whenUnlocked
/// ๊ธฐ๊ธฐ ์ ๊ธ ํด์ ํ ์ ๊ทผ ๊ฐ๋ฅ + iCloud ๋ฐฑ์
์ ์ธ
case whenUnlockedThisDeviceOnly
/// ์ฒซ ๋ฒ์งธ ์ ๊ธ ํด์ ํ ์ฌ๋ถํ
์ ๊น์ง ์ ๊ทผ ๊ฐ๋ฅ
case afterFirstUnlock
/// ํญ์ ์ ๊ทผ ๊ฐ๋ฅ (๋น์ถ์ฒ)
case always
var value: CFString {
switch self {
case .whenUnlocked:
return kSecAttrAccessibleWhenUnlocked
case .whenUnlockedThisDeviceOnly:
return kSecAttrAccessibleWhenUnlockedThisDeviceOnly
case .afterFirstUnlock:
return kSecAttrAccessibleAfterFirstUnlock
case .always:
return kSecAttrAccessibleAlways
}
}
}
// MARK: - KeychainManager
final class KeychainManager {
static let shared = KeychainManager()
private init() {}
private let service = Bundle.main.bundleIdentifier ?? "com.myapp.keychain"
// MARK: - ์ ์ฅ (Create/Update)
func save(_ value: String,
forKey key: String,
accessibility: KeychainAccessibility = .whenUnlockedThisDeviceOnly) throws {
guard let data = value.data(using: .utf8) else {
throw KeychainError.invalidData
}
// ๋จผ์ ๊ธฐ์กด ํญ๋ชฉ ์ญ์ ์๋ (์
๋ฐ์ดํธ๋ฅผ ์ํด)
try? delete(forKey: key)
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecValueData as String: data,
kSecAttrAccessible as String: accessibility.value
]
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else {
if status == errSecDuplicateItem {
throw KeychainError.duplicateEntry
}
throw KeychainError.unexpectedStatus(status)
}
}
// MARK: - ์ฝ๊ธฐ (Read)
func read(forKey key: String) throws -> String {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess else {
if status == errSecItemNotFound {
throw KeychainError.itemNotFound
}
throw KeychainError.unexpectedStatus(status)
}
guard let data = result as? Data,
let string = String(data: data, encoding: .utf8) else {
throw KeychainError.invalidData
}
return string
}
// MARK: - ์ญ์ (Delete)
func delete(forKey key: String) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key
]
let status = SecItemDelete(query as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw KeychainError.unexpectedStatus(status)
}
}
// MARK: - ์กด์ฌ ์ฌ๋ถ ํ์ธ
func exists(forKey key: String) -> Bool {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecMatchLimit as String: kSecMatchLimitOne
]
let status = SecItemCopyMatching(query as CFDictionary, nil)
return status == errSecSuccess
}
}
// ์ฌ์ฉ ์์ - ์ด๋ ๊ฒ ์ฐ๋ฉด ๋จ!
class AuthService {
private let tokenKey = "auth_token"
private let refreshTokenKey = "refresh_token"
private let keychain = KeychainManager.shared
// ๋ก๊ทธ์ธ ํ ํ ํฐ ์ ์ฅ
func saveTokens(accessToken: String, refreshToken: String) {
do {
try keychain.save(accessToken, forKey: tokenKey)
try keychain.save(refreshToken, forKey: refreshTokenKey)
print("โ
ํ ํฐ ์ ์ฅ ์๋ฃ!")
} catch {
print("โ ํ ํฐ ์ ์ฅ ์คํจ: \(error.localizedDescription)")
}
}
// ์ ์ฅ๋ ํ ํฐ ๊ฐ์ ธ์ค๊ธฐ
func getAccessToken() -> String? {
return try? keychain.read(forKey: tokenKey)
}
// ๋ก๊ทธ์์ ์ ํ ํฐ ์ญ์
func logout() {
try? keychain.delete(forKey: tokenKey)
try? keychain.delete(forKey: refreshTokenKey)
print("๐ ๋ก๊ทธ์์ ์๋ฃ, ํ ํฐ ์ญ์ ๋จ")
}
}
์ด๋? ์๊ฐ๋ณด๋ค ๋ณ๊ฑฐ ์์ง? ใ ใ ๊ทผ๋ฐ ์ด๊ฒ ์ง์ง ์ค์ํ ๊ฑฐ์ผ. ์ด ํจํด ํ๋๋ง ์ ๋๋ก ์จ๋ ์ฑ ๋ณด์ ์์ค์ด ํ ์ฌ๋ผ๊ฐ.
์ด์ ์ง์ง ๊ฟ์ผ ํํธ! ์์ฒด ์ธ์ฆ์ LocalAuthentication ํ๋ ์์ํฌ๋ฅผ ์ฌ์ฉํด. ์ด๋ฆ์ด ์ข ์ด์ํ๊ฒ ๋๊ปด์ง ์ ์๋๋ฐ, "๋ก์ปฌ(๊ธฐ๊ธฐ ๋ด)์์ ์ธ์ฆํ๋ค"๋ ๋ป์ด์ผ. ์๋ฒ ํต์ ์์ด ๊ธฐ๊ธฐ ์์ฒด์์ ์ธ์ฆ์ด ์๋ฃ๋จ.
FaceID๋ฅผ ์ฌ์ฉํ๋ ค๋ฉด Info.plist์ ๋ฐ๋์ ๊ถํ ์ค๋ช ์ ์ถ๊ฐํด์ผ ํด. ์ ํ๋ฉด ์ฑ์ด ํฌ๋์ ๋จ ใ ใ
<!-- Info.plist์ ์ถ๊ฐ -->
<key>NSFaceIDUsageDescription</key>
<string>์์ ํ ๋ก๊ทธ์ธ์ ์ํด Face ID๋ฅผ ์ฌ์ฉํฉ๋๋ค</string>
import LocalAuthentication
import Foundation
// MARK: - ์์ฒด ์ธ์ฆ ํ์
enum BiometricType {
case none
case touchID
case faceID
case opticID // Apple Vision Pro์ฉ (iOS 17+)
var displayName: String {
switch self {
case .none: return "์์ฒด ์ธ์ฆ ์์"
case .touchID: return "Touch ID"
case .faceID: return "Face ID"
case .opticID: return "Optic ID"
}
}
var iconName: String {
switch self {
case .none: return "lock.fill"
case .touchID: return "touchid"
case .faceID: return "faceid"
case .opticID: return "opticid"
}
}
}
// MARK: - ์ธ์ฆ ๊ฒฐ๊ณผ
enum AuthResult {
case success
case failure(AuthError)
}
// MARK: - ์ธ์ฆ ์๋ฌ
enum AuthError: Error, LocalizedError {
case biometryNotAvailable // ์์ฒด ์ธ์ฆ ํ๋์จ์ด ์์
case biometryNotEnrolled // ์์ฒด ์ ๋ณด ๋ฏธ๋ฑ๋ก
case biometryLockout // ๋๋ฌด ๋ง์ ์คํจ๋ก ์ ๊น
case userCancel // ์ฌ์ฉ์๊ฐ ์ทจ์
case userFallback // ๋น๋ฐ๋ฒํธ ์
๋ ฅ ์ ํ
case systemCancel // ์์คํ
์ด ์ทจ์ (์ ํ ์์ ๋ฑ)
case authenticationFailed // ์ธ์ฆ ์คํจ
case unknown(Error) // ๊ธฐํ ์๋ฌ
var errorDescription: String? {
switch self {
case .biometryNotAvailable:
return "์ด ๊ธฐ๊ธฐ๋ ์์ฒด ์ธ์ฆ์ ์ง์ํ์ง ์์ต๋๋ค"
case .biometryNotEnrolled:
return "์์ฒด ์ ๋ณด๊ฐ ๋ฑ๋ก๋์ง ์์์ต๋๋ค. ์ค์ ์์ ๋ฑ๋กํด์ฃผ์ธ์"
case .biometryLockout:
return "๋๋ฌด ๋ง์ ์คํจ๋ก ์์ฒด ์ธ์ฆ์ด ์ ๊ฒผ์ต๋๋ค. ๊ธฐ๊ธฐ ์ํธ๋ฅผ ์ฌ์ฉํด์ฃผ์ธ์"
case .userCancel:
return "์ธ์ฆ์ด ์ทจ์๋์์ต๋๋ค"
case .userFallback:
return "๋น๋ฐ๋ฒํธ ์
๋ ฅ์ ์ ํํ์ต๋๋ค"
case .systemCancel:
return "์์คํ
์ ์ํด ์ธ์ฆ์ด ์ทจ์๋์์ต๋๋ค"
case .authenticationFailed:
return "์ธ์ฆ์ ์คํจํ์ต๋๋ค"
case .unknown(let error):
return "์ ์ ์๋ ์ค๋ฅ: \(error.localizedDescription)"
}
}
}
// MARK: - BiometricAuthManager
@MainActor
final class BiometricAuthManager: ObservableObject {
static let shared = BiometricAuthManager()
private init() {}
// MARK: - ํ์ฌ ๊ธฐ๊ธฐ์ ์์ฒด ์ธ์ฆ ํ์
ํ์ธ
var biometricType: BiometricType {
let context = LAContext()
var error: NSError?
guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics,
error: &error) else {
return .none
}
switch context.biometryType {
case .touchID:
return .touchID
case .faceID:
return .faceID
case .opticID:
return .opticID
case .none:
return .none
@unknown default:
return .none
}
}
// MARK: - ์์ฒด ์ธ์ฆ ๊ฐ๋ฅ ์ฌ๋ถ ํ์ธ
var isBiometricAvailable: Bool {
let context = LAContext()
var error: NSError?
return context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics,
error: &error)
}
// MARK: - ์์ฒด ์ธ์ฆ ์คํ (async/await ๋ฒ์ )
func authenticate(reason: String = "๋ณธ์ธ ํ์ธ์ ์ํด ์ธ์ฆ์ด ํ์ํฉ๋๋ค") async -> AuthResult {
let context = LAContext()
context.localizedCancelTitle = "์ทจ์"
context.localizedFallbackTitle = "๋น๋ฐ๋ฒํธ ์ฌ์ฉ"
// ์์ฒด ์ธ์ฆ ๊ฐ๋ฅ ์ฌ๋ถ ๋จผ์ ์ฒดํฌ
var error: NSError?
guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics,
error: &error) else {
return .failure(mapLAError(error))
}
do {
let success = try await context.evaluatePolicy(
.deviceOwnerAuthenticationWithBiometrics,
localizedReason: reason
)
return success ? .success : .failure(.authenticationFailed)
} catch let laError as LAError {
return .failure(mapLAError(laError as NSError))
} catch {
return .failure(.unknown(error))
}
}
// MARK: - ๊ธฐ๊ธฐ ์ํธ ํฌํจ ์ธ์ฆ (์์ฒด ์คํจ ์ ์ํธ๋ก ํด๋ฐฑ)
func authenticateWithFallback(reason: String = "๋ณธ์ธ ํ์ธ์ด ํ์ํฉ๋๋ค") async -> AuthResult {
let context = LAContext()
context.localizedCancelTitle = "์ทจ์"
var error: NSError?
guard context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) else {
return .failure(mapLAError(error))
}
do {
let success = try await context.evaluatePolicy(
.deviceOwnerAuthentication, // ๊ธฐ๊ธฐ ์ํธ ํฌํจ!
localizedReason: reason
)
return success ? .success : .failure(.authenticationFailed)
} catch let laError as LAError {
return .failure(mapLAError(laError as NSError))
} catch {
return .failure(.unknown(error))
}
}
// MARK: - LAError ๋งคํ
private func mapLAError(_ error: NSError?) -> AuthError {
guard let error = error else { return .authenticationFailed }
switch LAError.Code(rawValue: error.code) {
case .biometryNotAvailable:
return .biometryNotAvailable
case .biometryNotEnrolled:
return .biometryNotEnrolled
case .biometryLockout:
return .biometryLockout
case .userCancel:
return .userCancel
case .userFallback:
return .userFallback
case .systemCancel:
return .systemCancel
case .authenticationFailed:
return .authenticationFailed
default:
return .unknown(error)
}
}
}
.deviceOwnerAuthentication โ ์์ฒด ์ธ์ฆ + ๊ธฐ๊ธฐ ์ํธ ํด๋ฐฑ. ์์ฒด ์คํจ ์ ์๋์ผ๋ก ์ํธ ์ ๋ ฅ์ฐฝ ๋ธ.
๋ฑ ํน ์ฑ์ฒ๋ผ ๋ณด์์ด ์ค์ํ ๊ฒฝ์ฐ์ ์ ์, ์ผ๋ฐ ์ฑ ์ ๊ธ ํด์ ์ ํ์๊ฐ ๋ UX ์ข์!
์ ์ด์ ์ง์ง ํต์ฌ์ด์ผ. Keychain๊ณผ ์์ฒด ์ธ์ฆ์ ์ฐ๋ํ๋ฉด ํจ์ฌ ๊ฐ๋ ฅํ ๋ณด์์ ๊ตฌํํ ์ ์์ด. kSecAccessControl์ ์ฌ์ฉํ๋ฉด "์์ฒด ์ธ์ฆ ์ฑ๊ณต ์์๋ง Keychain ํญ๋ชฉ์ ์ ๊ทผ ๊ฐ๋ฅ"ํ๊ฒ ๋ง๋ค ์ ์๊ฑฐ๋ . ์ด๊ฒ ์ง์ง ํ๋ก ๋ ๋ฒจ ๊ตฌํ์ ใทใท
import Foundation
import Security
import LocalAuthentication
// MARK: - ์์ฒด ์ธ์ฆ ์ฐ๋ Keychain ์ ์ฅ
final class SecureBiometricKeychain {
static let shared = SecureBiometricKeychain()
private init() {}
private let service = Bundle.main.bundleIdentifier ?? "com.myapp"
// MARK: - ์์ฒด ์ธ์ฆ ๋ณดํธ ํญ๋ชฉ ์ ์ฅ
// ์ด ํญ๋ชฉ์ ์์ฒด ์ธ์ฆ ์ฑ๊ณต ์์๋ง ์ฝ์ ์ ์์!
func saveWithBiometricProtection(
value: String,
forKey key: String
) throws {
guard let data = value.data(using: .utf8) else {
throw KeychainError.invalidData
}
// ์์ฒด ์ธ์ฆ ์ ๊ทผ ์ ์ด ์ค์
var error: Unmanaged<CFError>?
guard let accessControl = SecAccessControlCreateWithFlags(
kCFAllocatorDefault,
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
.biometryCurrentSet, // ํ์ฌ ๋ฑ๋ก๋ ์์ฒด ์ ๋ณด๋ก๋ง ์ ๊ทผ ๊ฐ๋ฅ
&error
) else {
throw KeychainError.invalidData
}
// ๊ธฐ์กด ํญ๋ชฉ ์ญ์
let deleteQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key
]
SecItemDelete(deleteQuery as CFDictionary)
// ์ ํญ๋ชฉ ์ ์ฅ
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecValueData as String: data,
kSecAttrAccessControl as String: accessControl
]
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else {
throw KeychainError.unexpectedStatus(status)
}
}
// MARK: - ์์ฒด ์ธ์ฆ ํ ํญ๋ชฉ ์ฝ๊ธฐ
func readWithBiometricAuth(
forKey key: String,
reason: String = "์ ์ฅ๋ ์ ๋ณด์ ์ ๊ทผํ๋ ค๋ฉด ์ธ์ฆ์ด ํ์ํฉ๋๋ค"
) throws -> String {
// LAContext๋ฅผ ์ง์ ์ ๋ฌํด์ ์ธ์ฆ ์ด์ ์ปค์คํฐ๋ง์ด์ง
let context = LAContext()
context.localizedReason = reason
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
kSecUseAuthenticationContext as String: context // ์ธ์ฆ ์ปจํ
์คํธ ์ฃผ์
!
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess else {
if status == errSecItemNotFound {
throw KeychainError.itemNotFound
}
// errSecUserCanceled: ์ฌ์ฉ์๊ฐ ์ทจ์
// errSecAuthFailed: ์ธ์ฆ ์คํจ
throw KeychainError.unexpectedStatus(status)
}
guard let data = result as? Data,
let string = String(data: data, encoding: .utf8) else {
throw KeychainError.invalidData
}
return string
}
}
// MARK: - .biometryCurrentSet vs .biometryAny ์ฐจ์ด์
/*
.biometryCurrentSet:
- ํ์ฌ ๋ฑ๋ก๋ ์์ฒด ์ ๋ณด๋ก๋ง ์ ๊ทผ ๊ฐ๋ฅ
- ์ ์ง๋ฌธ/์ผ๊ตด ์ถ๊ฐํ๋ฉด ๊ธฐ์กด ํญ๋ชฉ ์ ๊ทผ ๋ถ๊ฐ (๋ณด์ ๊ฐํ)
- ๋ฑ
ํน, ๊ฒฐ์ ์ฑ์ ๊ถ์ฅ
.biometryAny:
- ์ด๋ค ์์ฒด ์ ๋ณด๋ก๋ ์ ๊ทผ ๊ฐ๋ฅ
- ์ ์ง๋ฌธ ์ถ๊ฐํด๋ ๊ธฐ์กด ํญ๋ชฉ ์ ๊ทผ ๊ฐ๋ฅ
- ์ผ๋ฐ ์ฑ ์ ๊ธ์ ์ ํฉ
*/
import SwiftUI
import LocalAuthentication
// MARK: - ์ธ์ฆ ์ํ
enum AuthenticationState {
case unauthenticated
case authenticating
case authenticated
case failed(String)
}
// MARK: - ์ธ์ฆ ๋ทฐ๋ชจ๋ธ
@MainActor
class AuthViewModel: ObservableObject {
@Published var authState: AuthenticationState = .unauthenticated
@Published var isAuthenticated = false
private let biometricManager = BiometricAuthManager.shared
private let keychain = KeychainManager.shared
// ์์ฒด ์ธ์ฆ ํ์
(UI์์ ์์ด์ฝ ํ์์ฉ)
var biometricType: BiometricType {
biometricManager.biometricType
}
// MARK: - ์์ฒด ์ธ์ฆ ์คํ
func authenticate() async {
authState = .authenticating
let result = await biometricManager.authenticate(
reason: "์ฑ์ ์ ๊ทผํ๋ ค๋ฉด \(biometricManager.biometricType.displayName) ์ธ์ฆ์ด ํ์ํฉ๋๋ค"
)
switch result {
case .success:
authState = .authenticated
isAuthenticated = true
// ์ธ์ฆ ์ฑ๊ณต ํ Keychain์์ ํ ํฐ ๊ฐ์ ธ์ค๊ธฐ
loadUserData()
case .failure(let error):
switch error {
case .userCancel:
authState = .unauthenticated // ์ทจ์๋ ์๋ฌ ์๋
case .biometryLockout:
// ์ ๊ธ ์ํ๋ฉด ๊ธฐ๊ธฐ ์ํธ๋ก ํด๋ฐฑ
await authenticateWithDevicePasscode()
default:
authState = .failed(error.localizedDescription ?? "์ธ์ฆ ์คํจ")
}
}
}
// MARK: - ๊ธฐ๊ธฐ ์ํธ๋ก ์ธ์ฆ (ํด๋ฐฑ)
func authenticateWithDevicePasscode() async {
let result = await biometricManager.authenticateWithFallback(
reason: "๊ธฐ๊ธฐ ์ํธ๋ก ์ธ์ฆํด์ฃผ์ธ์"
)
switch result {
case .success:
authState = .authenticated
isAuthenticated = true
case .failure(let error):
authState = .failed(error.localizedDescription ?? "์ธ์ฆ ์คํจ")
}
}
// MARK: - ์ธ์ฆ ํ ๋ฐ์ดํฐ ๋ก๋
private func loadUserData() {
if let token = try? keychain.read(forKey: "auth_token") {
print("โ
ํ ํฐ ๋ก๋ ์ฑ๊ณต: \(token.prefix(10))...")
}
}
// MARK: - ๋ก๊ทธ์์
func logout() {
isAuthenticated = false
authState = .unauthenticated
}
}
// MARK: - ์ธ์ฆ ํ๋ฉด ๋ทฐ
struct AuthenticationView: View {
@StateObject private var viewModel = AuthViewModel()
var body: some View {
ZStack {
// ๋ฐฐ๊ฒฝ
LinearGradient(
colors: [Color(hex: "#f0f4ff"), Color(hex: "#f5f0ff")],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.ignoresSafeArea()
if viewModel.isAuthenticated {
// ์ธ์ฆ ์ฑ๊ณต ํ๋ฉด
MainContentView()
.transition(.opacity.combined(with: .scale))
} else {
// ์ธ์ฆ ํ๋ฉด
VStack(spacing: 32) {
Spacer()
// ์ฑ ์์ด์ฝ ์์ญ
ZStack {
Circle()
.fill(Color(hex: "#4a6cf7").opacity(0.1))
.frame(width: 120, height: 120)
Image(systemName: "lock.shield.fill")
.font(.system(size: 50))
.foregroundColor(Color(hex: "#4a6cf7"))
}
VStack(spacing: 8) {
Text("๋ณธ์ธ ํ์ธ")
.font(.title.bold())
.foregroundColor(.primary)
Text("์ฑ์ ์ ๊ทผํ๋ ค๋ฉด ์ธ์ฆ์ด ํ์ํฉ๋๋ค")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
// ์ธ์ฆ ์ํ ํ์
authStateView
Spacer()
// ์ธ์ฆ ๋ฒํผ
VStack(spacing: 12) {
Button(action: {
Task { await viewModel.authenticate() }
}) {
HStack(spacing: 12) {
Image(systemName: viewModel.biometricType.iconName)
.font(.title3)
Text("\(viewModel.biometricType.displayName)๋ก ์ธ์ฆ")
.font(.headline)
}
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
.background(
LinearGradient(
colors: [Color(hex: "#4a6cf7"), Color(hex: "#6a3de8")],
startPoint: .leading,
endPoint: .trailing
)
)
.cornerRadius(14)
}
.disabled(viewModel.authState == .authenticating)
// ๊ธฐ๊ธฐ ์ํธ ์ฌ์ฉ ๋ฒํผ
Button("๊ธฐ๊ธฐ ์ํธ ์ฌ์ฉ") {
Task { await viewModel.authenticateWithDevicePasscode() }
}
.font(.subheadline)
.foregroundColor(Color(hex: "#4a6cf7"))
}
.padding(.horizontal, 32)
.padding(.bottom, 48)
}
.animation(.spring(response: 0.4), value: viewModel.authState)
}
}
}
// MARK: - ์ํ๋ณ ๋ทฐ
@ViewBuilder
private var authStateView: some View {
switch viewModel.authState {
case .authenticating:
ProgressView()
.scaleEffect(1.5)
.tint(Color(hex: "#4a6cf7"))
case .failed(let message):
HStack(spacing: 8) {
Image(systemName: "exclamationmark.circle.fill")
.foregroundColor(.red)
Text(message)
.font(.caption)
.foregroundColor(.red)
}
.padding(.horizontal, 20)
.multilineTextAlignment(.center)
default:
EmptyView()
}
}
}
์ค๋ฌด์์ ์ง์ง ๋ง์ด ์ฐ๋ ํจํด์ด์ผ. ์ฑ ์คํ ์ Keychain์์ ํ ํฐ ๊บผ๋ด์ ์ ํจ์ฑ ๊ฒ์ฌํ๊ณ , ๋ง๋ฃ๋์ผ๋ฉด ์๋ ๊ฐฑ์ ํ๋ ํ๋ก์ฐ!
// MARK: - ํ ํฐ ๋ชจ๋ธ
struct AuthTokens: Codable {
let accessToken: String
let refreshToken: String
let expiresAt: Date
var isExpired: Bool {
return Date() >= expiresAt
}
var isNearExpiry: Bool {
// ๋ง๋ฃ 5๋ถ ์ ๋ถํฐ ๊ฐฑ์ ์๋
return Date() >= expiresAt.addingTimeInterval(-300)
}
}
// MARK: - ํ ํฐ ๋งค๋์
@MainActor
class TokenManager: ObservableObject {
static let shared = TokenManager()
private init() {}
private let keychain = KeychainManager.shared
private let tokenKey = "auth_tokens_v2"
@Published var isLoggedIn = false
// MARK: - ํ ํฐ ์ ์ฅ (JSON ์ง๋ ฌํ)
func saveTokens(_ tokens: AuthTokens) throws {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
let data = try encoder.encode(tokens)
guard let jsonString = String(data: data, encoding: .utf8) else {
throw KeychainError.invalidData
}
try keychain.save(jsonString, forKey: tokenKey)
isLoggedIn = true
}
// MARK: - ํ ํฐ ๋ก๋ ๋ฐ ์ ํจ์ฑ ๊ฒ์ฌ
func loadValidTokens() async throws -> AuthTokens {
guard let jsonString = try? keychain.read(forKey: tokenKey) else {
throw KeychainError.itemNotFound
}
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
guard let data = jsonString.data(using: .utf8),
let tokens = try? decoder.decode(AuthTokens.self, from: data) else {
throw KeychainError.invalidData
}
// ๋ง๋ฃ ์๋ฐ ์ ์๋ ๊ฐฑ์
if tokens.isNearExpiry {
return try await refreshTokens(refreshToken: tokens.refreshToken)
}
// ์์ ๋ง๋ฃ ์ ๋ก๊ทธ์์
if tokens.isExpired {
logout()
throw KeychainError.itemNotFound
}
return tokens
}
// MARK: - ํ ํฐ ๊ฐฑ์
private func refreshTokens(refreshToken: String) async throws -> AuthTokens {
// ์ค์ API ํธ์ถ๋ก ํ ํฐ ๊ฐฑ์
// let newTokens = try await APIClient.shared.refreshToken(refreshToken)
// try saveTokens(newTokens)
// return newTokens
// ์์์ฉ ๋๋ฏธ ๋ฐํ
let newTokens = AuthTokens(
accessToken: "new_access_token",
refreshToken: refreshToken,
expiresAt: Date().addingTimeInterval(3600)
)
try saveTokens(newTokens)
return newTokens
}
// MARK: - ๋ก๊ทธ์์
func logout() {
try? keychain.delete(forKey: tokenKey)
isLoggedIn = false
}
}
์ด๊ฑฐ ์ง์ง ์ค์ํ๋ฐ ๋ง์ด๋ค ๋ชจ๋ฆ. ์ฌ์ฉ์๊ฐ ์ ์ง๋ฌธ์ ์ถ๊ฐํ๊ฑฐ๋ Face ID๋ฅผ ์ฌ๋ฑ๋กํ๋ฉด ๋ณด์ ์ํ์ด ๋ ์ ์์ด. ์ด๊ฑธ ๊ฐ์งํ๋ ๋ฐฉ๋ฒ์ด ์์!
// MARK: - ์์ฒด ์ธ์ฆ ๋ณ๊ฒฝ ๊ฐ์ง
class BiometricChangeDetector {
private let userDefaults = UserDefaults.standard
private let biometricStateKey = "biometric_domain_state"
// MARK: - ํ์ฌ ์์ฒด ์ํ ์ ์ฅ
func saveBiometricState() {
let context = LAContext()
var error: NSError?
guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics,
error: &error) else { return }
// domainState๋ ์์ฒด ์ ๋ณด๊ฐ ๋ณ๊ฒฝ๋ ๋๋ง๋ค ๋ฐ๋๋ Data
if let domainState = context.evaluatedPolicyDomainState {
userDefaults.set(domainState, forKey: biometricStateKey)
}
}
// MARK: - ์์ฒด ์ ๋ณด ๋ณ๊ฒฝ ์ฌ๋ถ ํ์ธ
func hasBiometricChanged() -> Bool {
let context = LAContext()
var error: NSError?
guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics,
error: &error) else { return false }
guard let currentState = context.evaluatedPolicyDomainState,
let savedState = userDefaults.data(forKey: biometricStateKey) else {
// ์ ์ฅ๋ ์ํ ์์ผ๋ฉด ์ฒ์ ์คํ โ ์ ์ฅํ๊ณ ๋ณ๊ฒฝ ์์์ผ๋ก ์ฒ๋ฆฌ
saveBiometricState()
return false
}
// ์ํ๊ฐ ๋ค๋ฅด๋ฉด ์์ฒด ์ ๋ณด ๋ณ๊ฒฝ๋จ!
if currentState != savedState {
print("โ ๏ธ ์์ฒด ์ธ์ฆ ์ ๋ณด๊ฐ ๋ณ๊ฒฝ๋์์ต๋๋ค!")
// ๋ณด์ ์กฐ์น: ์ ์ฅ๋ ๋ฏผ๊ฐ ๋ฐ์ดํฐ ์ญ์ ๋๋ ์ฌ์ธ์ฆ ์๊ตฌ
return true
}
return false
}
// MARK: - ์ฑ ์คํ ์ ์ฒดํฌ ๋ก์ง
func checkAndHandleBiometricChange(onChanged: () -> Void) {
if hasBiometricChanged() {
// ์์ฒด ์ ๋ณด ๋ณ๊ฒฝ ๊ฐ์ง โ ๋ณด์ ์กฐ์น
onChanged()
// ์ ์ํ ์ ์ฅ
saveBiometricState()
}
}
}
// ์ฌ์ฉ ์์
let detector = BiometricChangeDetector()
detector.checkAndHandleBiometricChange {
// ์์ฒด ์ ๋ณด ๋ณ๊ฒฝ๋จ โ ์ฌ๋ก๊ทธ์ธ ์๊ตฌ
print("๐ ๋ณด์์ ์ํด ๋ค์ ๋ก๊ทธ์ธํด์ฃผ์ธ์")
TokenManager.shared.logout()
}
์ค๊ธฐ๊ธฐ ์์ด๋ ์๋ฎฌ๋ ์ดํฐ์์ ์์ฒด ์ธ์ฆ ํ ์คํธ ๊ฐ๋ฅํด! ์ด๊ฑฐ ๋ชจ๋ฅด๋ ์ฌ๋ ์๊ฐ๋ณด๋ค ๋ง๋๋ผ๊ณ ใ ใ
import XCTest
@testable import MyApp
// MARK: - Keychain ํ
์คํธ
final class KeychainManagerTests: XCTestCase {
var sut: KeychainManager!
let testKey = "test_key_\(UUID().uuidString)"
override func setUp() {
super.setUp()
sut = KeychainManager.shared
}
override func tearDown() {
// ํ
์คํธ ํ ์ ๋ฆฌ
try? sut.delete(forKey: testKey)
super.tearDown()
}
// MARK: - ์ ์ฅ ํ
์คํธ
func test_save_shouldSucceed() throws {
// Given
let testValue = "test_password_123"
// When
XCTAssertNoThrow(try sut.save(testValue, forKey: testKey))
// Then
let retrieved = try sut.read(forKey: testKey)
XCTAssertEqual(retrieved, testValue)
}
// MARK: - ์๋ ํค ์ฝ๊ธฐ ํ
์คํธ
func test_read_nonExistentKey_shouldThrowItemNotFound() {
// Given
let nonExistentKey = "non_existent_\(UUID().uuidString)"
// When & Then
XCTAssertThrowsError(try sut.read(forKey: nonExistentKey)) { error in
XCTAssertEqual(error as? KeychainError, KeychainError.itemNotFound)
}
}
// MARK: - ์ญ์ ํ
์คํธ
func test_delete_shouldRemoveItem() throws {
// Given
try sut.save("test_value", forKey: testKey)
XCTAssertTrue(sut.exists(forKey: testKey))
// When
try sut.delete(forKey: testKey)
// Then
XCTAssertFalse(sut.exists(forKey: testKey))
}
// MARK: - ์
๋ฐ์ดํธ ํ
์คํธ
func test_save_existingKey_shouldUpdate() throws {
// Given
let originalValue = "original"
let updatedValue = "updated"
try sut.save(originalValue, forKey: testKey)
// When
try sut.save(updatedValue, forKey: testKey)
// Then
let retrieved = try sut.read(forKey: testKey)
XCTAssertEqual(retrieved, updatedValue)
}
}
์ฌ๋ฅ๋ท์์ iOS ๊ฐ๋ฐ ๊ด๋ จ ์ฌ๋ฅ์ ๊ฑฐ๋ํ๋ค ๋ณด๋ฉด ๋ณด์ ๊ตฌํ ๊ด๋ จ ์๋ขฐ๊ฐ ๊ฝค ๋ง์ด ๋ค์ด์จ๋ค๊ณ ํด. ํด๋ผ์ด์ธํธํํ "๋ณด์ ์ ๋์ด์~" ํ๋ ค๋ฉด ์ด ์ฒดํฌ๋ฆฌ์คํธ๋ ๊ธฐ๋ณธ์ผ๋ก ํต๊ณผํด์ผ ํจ โ
ํ์ฅ๋ ๊ธฐ๊ธฐ์์๋ Keychain ๋ณด์์ด ์ฐํ๋ ์ ์์ด. ์๋ฒฝํ ํ์ฅ ๊ฐ์ง๋ ๋ถ๊ฐ๋ฅํ์ง๋ง, ๊ธฐ๋ณธ์ ์ธ ์ฒดํฌ๋ ํด๋๋ ๊ฒ ์ข์.
// MARK: - ํ์ฅ ๊ฐ์ง (๊ธฐ๋ณธ ์์ค)
struct JailbreakDetector {
static func isJailbroken() -> Bool {
#if targetEnvironment(simulator)
return false // ์๋ฎฌ๋ ์ดํฐ๋ ํญ์ false
#else
return checkSuspiciousFiles() ||
checkSandboxViolation() ||
checkDynamicLibraries()
#endif
}
// ํ์ฅ ๊ด๋ จ ํ์ผ ์กด์ฌ ์ฌ๋ถ
private static func checkSuspiciousFiles() -> Bool {
let suspiciousPaths = [
"/Applications/Cydia.app",
"/Library/MobileSubstrate/MobileSubstrate.dylib",
"/bin/bash",
"/usr/sbin/sshd",
"/etc/apt",
"/private/var/lib/apt/",
"/usr/bin/ssh"
]
return suspiciousPaths.contains { path in
FileManager.default.fileExists(atPath: path)
}
}
// ์๋๋ฐ์ค ์ธ๋ถ ์ฐ๊ธฐ ์๋
private static func checkSandboxViolation() -> Bool {
let testPath = "/private/jailbreak_test_\(UUID().uuidString)"
do {
try "test".write(toFile: testPath, atomically: true, encoding: .utf8)
try FileManager.default.removeItem(atPath: testPath)
return true // ์ฐ๊ธฐ ์ฑ๊ณต = ํ์ฅ๋จ
} catch {
return false // ์ฐ๊ธฐ ์คํจ = ์ ์
}
}
// ์์ฌ์ค๋ฌ์ด ๋์ ๋ผ์ด๋ธ๋ฌ๋ฆฌ ์ฒดํฌ
private static func checkDynamicLibraries() -> Bool {
let suspiciousLibs = ["SubstrateLoader", "CydiaSubstrate", "SSLKillSwitch"]
for i in 0..<_dyld_image_count() {
if let imageName = _dyld_get_image_name(i) {
let name = String(cString: imageName)
if suspiciousLibs.contains(where: { name.contains($0) }) {
return true
}
}
}
return false
}
}
// ์ฌ์ฉ ์์
if JailbreakDetector.isJailbroken() {
// ํ์ฅ ๊ธฐ๊ธฐ์์ ์ฑ ์คํ ์ ํ
print("โ ๏ธ ๋ณด์ ์ํ์ด ๊ฐ์ง๋์์ต๋๋ค")
// ์ฑ ์ข
๋ฃ ๋๋ ๊ธฐ๋ฅ ์ ํ
}
์ด์ ๋ฐฐ์ด ๊ฑธ ๋ค ํฉ์ณ์ ์ค์ ๋ฑ ํน ์ฑ ์์ค์ ์ธ์ฆ ํ๋ก์ฐ๋ฅผ ๋ง๋ค์ด๋ณผ๊ฒ. ์ฌ๋ฅ๋ท์์ ๊ธ์ต ์ฑ ๊ฐ๋ฐ ์๋ขฐ ๋ฐ์์ ๋ ์ด ์ ๋ ๊ตฌํํ๋ฉด ํด๋ผ์ด์ธํธ๊ฐ ์ง์ง ๋ง์กฑํ ๊ฑฐ์ผ ใ ใ
// MARK: - ์์ฑ๋ ๋ณด์ ์ธ์ฆ ๋งค๋์
@MainActor
final class SecurityManager: ObservableObject {
static let shared = SecurityManager()
private init() {
setupInitialState()
}
// MARK: - ์ํ
@Published var isAuthenticated = false
@Published var requiresReauth = false
private let keychain = KeychainManager.shared
private let biometric = BiometricAuthManager.shared
private let changeDetector = BiometricChangeDetector()
private let tokenManager = TokenManager.shared
// ๋ง์ง๋ง ์ธ์ฆ ์๊ฐ (์ธ์
ํ์์์์ฉ)
private var lastAuthTime: Date?
private let sessionTimeout: TimeInterval = 300 // 5๋ถ
// MARK: - ์ด๊ธฐ ์ค์
private func setupInitialState() {
// ํ์ฅ ๊ฐ์ง
if JailbreakDetector.isJailbroken() {
print("๐จ ํ์ฅ ๊ธฐ๊ธฐ ๊ฐ์ง - ๋ณด์ ๊ธฐ๋ฅ ์ ํ")
// ์ค์ ์ฑ์์๋ ์ฌ๊ธฐ์ ์ฑ ์ข
๋ฃ ๋๋ ๊ธฐ๋ฅ ์ ํ
}
// ์์ฒด ์ธ์ฆ ๋ณ๊ฒฝ ๊ฐ์ง
changeDetector.checkAndHandleBiometricChange {
print("โ ๏ธ ์์ฒด ์ธ์ฆ ๋ณ๊ฒฝ ๊ฐ์ง - ์ฌ๋ก๊ทธ์ธ ํ์")
self.tokenManager.logout()
}
}
// MARK: - ์ฑ ์คํ ์ ์ธ์ฆ ํ๋ก์ฐ
func performAppLaunchAuth() async {
// 1. ์ธ์
ํ์์์ ์ฒดํฌ
if let lastAuth = lastAuthTime,
Date().timeIntervalSince(lastAuth) < sessionTimeout {
isAuthenticated = true
return
}
// 2. ์ ์ฅ๋ ํ ํฐ ํ์ธ
guard let _ = try? await tokenManager.loadValidTokens() else {
// ํ ํฐ ์์ โ ๋ก๊ทธ์ธ ํ๋ฉด์ผ๋ก
isAuthenticated = false
return
}
// 3. ์์ฒด ์ธ์ฆ ์คํ
await performBiometricAuth()
}
// MARK: - ์์ฒด ์ธ์ฆ ์คํ
func performBiometricAuth() async {
let result = await biometric.authenticate(
reason: "์ฑ์ ์์ ํ๊ฒ ์ ๊ทผํ๊ธฐ ์ํด ์ธ์ฆ์ด ํ์ํฉ๋๋ค"
)
switch result {
case .success:
isAuthenticated = true
lastAuthTime = Date()
changeDetector.saveBiometricState() // ์ธ์ฆ ์ฑ๊ณต ์ ์ํ ์
๋ฐ์ดํธ
case .failure(let error):
handleAuthFailure(error)
}
}
// MARK: - ์ธ์ฆ ์คํจ ์ฒ๋ฆฌ
private func handleAuthFailure(_ error: AuthError) {
switch error {
case .biometryLockout:
// ์์ฒด ์ ๊ธ โ ๊ธฐ๊ธฐ ์ํธ๋ก ํด๋ฐฑ
Task { await performDevicePasscodeAuth() }
case .biometryNotEnrolled:
// ์์ฒด ๋ฏธ๋ฑ๋ก โ ๊ธฐ๊ธฐ ์ํธ ์ฌ์ฉ
Task { await performDevicePasscodeAuth() }
case .userCancel:
// ์ทจ์ โ ์ธ์ฆ ํ๋ฉด ์ ์ง
isAuthenticated = false
default:
isAuthenticated = false
requiresReauth = true
}
}
// MARK: - ๊ธฐ๊ธฐ ์ํธ ์ธ์ฆ
private func performDevicePasscodeAuth() async {
let result = await biometric.authenticateWithFallback(
reason: "๊ธฐ๊ธฐ ์ํธ๋ก ์ธ์ฆํด์ฃผ์ธ์"
)
switch result {
case .success:
isAuthenticated = true
lastAuthTime = Date()
case .failure:
isAuthenticated = false
}
}
// MARK: - ์ฑ ๋ฐฑ๊ทธ๋ผ์ด๋ ์ง์
์
func handleAppBackground() {
// ๋ฐฑ๊ทธ๋ผ์ด๋ ์ง์
์ ์ธ์ฆ ์ํ ์ ์ง (ํ์์์ ์ ๊น์ง)
// ๋ฏผ๊ฐํ ํ๋ฉด์ ๋ธ๋ฌ ์ฒ๋ฆฌ ํ์
}
// MARK: - ์ฑ ํฌ๊ทธ๋ผ์ด๋ ๋ณต๊ท ์
func handleAppForeground() async {
// ์ธ์
ํ์์์ ์ฒดํฌ
if let lastAuth = lastAuthTime,
Date().timeIntervalSince(lastAuth) >= sessionTimeout {
isAuthenticated = false
await performBiometricAuth()
}
}
// MARK: - ๋ก๊ทธ์์
func logout() {
tokenManager.logout()
isAuthenticated = false
lastAuthTime = nil
}
}
// MARK: - App ๋ ๋ฒจ์์ ์ฌ์ฉ
@main
struct MySecureApp: App {
@StateObject private var securityManager = SecurityManager.shared
@Environment(\.scenePhase) private var scenePhase
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(securityManager)
.onChange(of: scenePhase) { _, newPhase in
switch newPhase {
case .background:
securityManager.handleAppBackground()
case .active:
Task { await securityManager.handleAppForeground() }
default:
break
}
}
.task {
await securityManager.performAppLaunchAuth()
}
}
}
}
// โ
์ฌ๋ฐ๋ฅธ ๋ฐฉ๋ฒ - ๋ฐฑ๊ทธ๋ผ์ด๋์์ Keychain ์ ๊ทผ
func loadTokenAsync() async throws -> String {
return try await Task.detached(priority: .userInitiated) {
try KeychainManager.shared.read(forKey: "auth_token")
}.value
}
// โ ์๋ชป๋ ๋ฐฉ๋ฒ - ๋ฉ์ธ ์ค๋ ๋์์ ์ง์ ์ ๊ทผ
// let token = try KeychainManager.shared.read(forKey: "auth_token")
// โ UI ๋ธ๋กํน ๋ฐ์ ๊ฐ๋ฅ!
| ์์ | ์ค์ | ์ฌ๋ฐ๋ฅธ ๋ฐฉ๋ฒ |
|---|---|---|
| 1์ | UserDefaults์ ํ ํฐ ์ ์ฅ | ๋ฐ๋์ Keychain ์ฌ์ฉ |
| 2์ | Info.plist์ NSFaceIDUsageDescription ๋๋ฝ | FaceID ์ฌ์ฉ ์ ๋ฐ๋์ ์ถ๊ฐ |
| 3์ | ์ธ์ฆ ๊ฒฐ๊ณผ๋ง ๋ฏฟ๊ณ ์๋ฒ ๊ฒ์ฆ ์๋ต | ์๋ฒ์์ ํ ํฐ ์ฌ๊ฒ์ฆ ํ์ |
| 4์ | kSecAttrAccessibleAlways ์ฌ์ฉ | WhenUnlockedThisDeviceOnly ์ฌ์ฉ |
| 5์ | ๋ฉ์ธ ์ค๋ ๋์์ Keychain ๋๊ธฐ ์ ๊ทผ | ๋ฐฑ๊ทธ๋ผ์ด๋ ์ค๋ ๋์์ async ์ฒ๋ฆฌ |
๊ฐ์ฅ ์ธ๊ธฐ ์๋ Keychain ๋ํผ ๋ผ์ด๋ธ๋ฌ๋ฆฌ. ์ง๊ด์ ์ธ API๋ก Keychain ์์ ์ ๋์ ๋๋ฆฌ์ฒ๋ผ ์ฝ๊ฒ ์ฒ๋ฆฌ ๊ฐ๋ฅ. ์ง์ ๊ตฌํ์ด ๋ถ๋ด์ค๋ฌ์ฐ๋ฉด ์ด๊ฑฐ ์ฐ๋ ๊ฒ๋ ์ข์ ์ ํ์ด์ผ.
Square(๋ธ๋ก)์์ ๋ง๋ Keychain ๋ผ์ด๋ธ๋ฌ๋ฆฌ. ์์ฒด ์ธ์ฆ ์ฐ๋์ด ํนํ ์ ๋์ด ์๊ณ , ๊ณต์ Keychain(์ฑ ๊ทธ๋ฃน) ์ง์๋ ํ๋ฅญํด.
LocalAuthentication ๋ํผ. ์์ฒด ์ธ์ฆ๋ง ํ์ํ ๋ ๊ฐ๋จํ๊ฒ ์ธ ์ ์๋ ๊ฒฝ๋ ๋ผ์ด๋ธ๋ฌ๋ฆฌ.
์ ์ค๋ ์ง์ง ๋ง์ ๊ฑธ ๋ค๋ค๋๋ฐ ํต์ฌ๋ง ์ ๋ฆฌํ๋ฉด:
2. LocalAuthentication = TouchID/FaceID ๊ตฌํ์ ๊ณต์ ํ๋ ์์ํฌ. ์์ฒด ๋ฐ์ดํฐ๋ ์ฑ์ ์ ๋ ์ ์ด.
3. Secure Enclave = ํ๋์จ์ด ๋ ๋ฒจ ๋ณด์. ๊ฐ๋ฐ์๊ฐ ์ง์ ๊ฑด๋๋ฆด ์ ์๊ณ ๊ฑด๋๋ฆด ํ์๋ ์์.
4. kSecAccessControl = Keychain + ์์ฒด ์ธ์ฆ ์ฐ๋์ ํต์ฌ. ์์ฒด ์ธ์ฆ ์ฑ๊ณต ์์๋ง Keychain ์ ๊ทผ ๊ฐ๋ฅ.
5. ์์ฒด ๋ณ๊ฒฝ ๊ฐ์ง = evaluatedPolicyDomainState๋ก ์ ์ง๋ฌธ/์ผ๊ตด ์ถ๊ฐ ๊ฐ์ง ๊ฐ๋ฅ.
6. ์๋ฒ ๊ฒ์ฆ ๋ณํ = ํด๋ผ์ด์ธํธ ์ธ์ฆ๋ง ๋ฏฟ์ผ๋ฉด ์ ๋จ. ํญ์ ์๋ฒ์์ ์ฌ๊ฒ์ฆ!
iOS ๋ณด์์ ์ฒ์์ ๋ณต์กํด ๋ณด์ด์ง๋ง, ์ ํ์ด ์ด๋ฏธ ์์ฒญ๋ ์ธํ๋ผ๋ฅผ ๋ง๋ค์ด๋จ์ด. ์ฐ๋ฆฌ๋ ๊ทธ๊ฑธ ์ฌ๋ฐ๋ฅด๊ฒ ์ฌ์ฉํ๊ธฐ๋ง ํ๋ฉด ๋จ. ์ค๋ ๋ฐฐ์ด ํจํด๋ค ์ ์ตํ๋๋ฉด ์ด๋ค ์ฑ์ ๋ง๋ค์ด๋ ๋ณด์ ๊ฑฑ์ ์ ์์ ๊ฑฐ์ผ ๐ช
๊ทธ๋ฆฌ๊ณ ์ด๋ฐ iOS ๋ณด์ ๊ตฌํ ๊ด๋ จ ๋์์ด ํ์ํ๋ค๋ฉด ์ฌ๋ฅ๋ท์์ iOS ๊ฐ๋ฐ ์ ๋ฌธ๊ฐ๋ฅผ ์ฐพ์๋ณด๋ ๊ฒ๋ ์ข์ ๋ฐฉ๋ฒ์ด์ผ. ๋ณด์ ๊ตฌํ์ ํ ๋ฒ ์๋ชป๋๋ฉด ๋์ค์ ๊ณ ์น๊ธฐ ์์ฒญ ํ๋ค๊ฑฐ๋ . ์ฒ์๋ถํฐ ์ ๋๋ก ํ๋ ๊ฒ ์ต๊ณ ์ผ! ๐
๊ด๋ จ ํค์๋
๋๊ธ ์์ฑ
์ด ๊ธ์ ๋ํ ์ฌ๋ฌ๋ถ์ ์๊ฐ์ ๋ค๋ ค์ฃผ์ธ์
๋ก๊ทธ์ธ์ด ํ์ํฉ๋๋ค
๋๊ธ์ ์์ฑํ๋ ค๋ฉด ๋จผ์ ๋ก๊ทธ์ธํด์ฃผ์ธ์.
๋๊ธ 0
์ง์์ธ์ ์ฒ - ์ง์ ์ฌ์ฐ๊ถ ๋ณดํธ ๊ณ ์ง
์ง์ ์ฌ์ฐ๊ถ ๋ณดํธ ๊ณ ์ง
- ์ ์๊ถ ๋ฐ ์์ ๊ถ: ๋ณธ ์ปจํ ์ธ ๋ ์ฌ๋ฅ๋ท์ ๋ ์ AI ๊ธฐ์ ๋ก ์์ฑ๋์์ผ๋ฉฐ, ๋ํ๋ฏผ๊ตญ ์ ์๊ถ๋ฒ ๋ฐ ๊ตญ์ ์ ์๊ถ ํ์ฝ์ ์ํด ๋ณดํธ๋ฉ๋๋ค.
- AI ์์ฑ ์ปจํ ์ธ ์ ๋ฒ์ ์ง์: ๋ณธ AI ์์ฑ ์ปจํ ์ธ ๋ ์ฌ๋ฅ๋ท์ ์ง์ ์ฐฝ์๋ฌผ๋ก ์ธ์ ๋๋ฉฐ, ๊ด๋ จ ๋ฒ๊ท์ ๋ฐ๋ผ ์ ์๊ถ ๋ณดํธ๋ฅผ ๋ฐ์ต๋๋ค.
- ์ฌ์ฉ ์ ํ: ์ฌ๋ฅ๋ท์ ๋ช ์์ ์๋ฉด ๋์ ์์ด ๋ณธ ์ปจํ ์ธ ๋ฅผ ๋ณต์ , ์์ , ๋ฐฐํฌ, ๋๋ ์์ ์ ์ผ๋ก ํ์ฉํ๋ ํ์๋ ์๊ฒฉํ ๊ธ์ง๋ฉ๋๋ค.
- ๋ฐ์ดํฐ ์์ง ๊ธ์ง: ๋ณธ ์ปจํ ์ธ ์ ๋ํ ๋ฌด๋จ ์คํฌ๋ํ, ํฌ๋กค๋ง, ๋ฐ ์๋ํ๋ ๋ฐ์ดํฐ ์์ง์ ๋ฒ์ ์ ์ฌ์ ๋์์ด ๋ฉ๋๋ค.
- AI ํ์ต ์ ํ: ์ฌ๋ฅ๋ท์ AI ์์ฑ ์ปจํ ์ธ ๋ฅผ ํ AI ๋ชจ๋ธ ํ์ต์ ๋ฌด๋จ ์ฌ์ฉํ๋ ํ์๋ ๊ธ์ง๋๋ฉฐ, ์ด๋ ์ง์ ์ฌ์ฐ๊ถ ์นจํด๋ก ๊ฐ์ฃผ๋ฉ๋๋ค.
์์ง ๋๊ธ์ด ์์ต๋๋ค.