Skip to content

Latest commit

ย 

History

History
465 lines (388 loc) ยท 12.4 KB

File metadata and controls

465 lines (388 loc) ยท 12.4 KB

LocalAuthentication AI Reference

Face ID / Touch ID ์ƒ์ฒด ์ธ์ฆ ๊ฐ€์ด๋“œ. ์ด ๋ฌธ์„œ๋ฅผ ์ฝ๊ณ  ์ƒ์ฒด ์ธ์ฆ ์ฝ”๋“œ๋ฅผ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

๊ฐœ์š”

LocalAuthentication์€ Face ID, Touch ID, ๊ธฐ๊ธฐ ์•”ํ˜ธ๋ฅผ ํ†ตํ•œ ์‚ฌ์šฉ์ž ์ธ์ฆ์„ ์ œ๊ณตํ•˜๋Š” ํ”„๋ ˆ์ž„์›Œํฌ์ž…๋‹ˆ๋‹ค.

ํ•„์ˆ˜ Import

import LocalAuthentication

ํ”„๋กœ์ ํŠธ ์„ค์ • (Info.plist)

<key>NSFaceIDUsageDescription</key>
<string>์•ฑ ์ž ๊ธˆ ํ•ด์ œ๋ฅผ ์œ„ํ•ด Face ID๋ฅผ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค.</string>

ํ•ต์‹ฌ ๊ตฌ์„ฑ์š”์†Œ

1. LAContext

let context = LAContext()

// ์ƒ์ฒด ์ธ์ฆ ๊ฐ€๋Šฅ ์—ฌ๋ถ€ ํ™•์ธ
var error: NSError?
if context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) {
    // ์ƒ์ฒด ์ธ์ฆ ๊ฐ€๋Šฅ
} else {
    // ๋ถˆ๊ฐ€๋Šฅ (error๋กœ ์ด์œ  ํ™•์ธ)
}

// ์ƒ์ฒด ์ธ์ฆ ํƒ€์ž… ํ™•์ธ
switch context.biometryType {
case .faceID:
    print("Face ID")
case .touchID:
    print("Touch ID")
case .opticID:
    print("Optic ID (Vision Pro)")
case .none:
    print("์ƒ์ฒด ์ธ์ฆ ์—†์Œ")
@unknown default:
    break
}

2. ์ธ์ฆ ์ •์ฑ…

// ์ƒ์ฒด ์ธ์ฆ๋งŒ
.deviceOwnerAuthenticationWithBiometrics

// ์ƒ์ฒด ์ธ์ฆ + ๊ธฐ๊ธฐ ์•”ํ˜ธ (fallback)
.deviceOwnerAuthentication

3. ์ธ์ฆ ์‹คํ–‰

func authenticate() async -> Bool {
    let context = LAContext()
    context.localizedCancelTitle = "์ทจ์†Œ"
    context.localizedFallbackTitle = "์•”ํ˜ธ ์ž…๋ ฅ"  // ๋นˆ ๋ฌธ์ž์—ด์ด๋ฉด ์ˆจ๊น€
    
    do {
        return try await context.evaluatePolicy(
            .deviceOwnerAuthenticationWithBiometrics,
            localizedReason: "์•ฑ ์ž ๊ธˆ์„ ํ•ด์ œํ•ฉ๋‹ˆ๋‹ค"
        )
    } catch {
        return false
    }
}

์ „์ฒด ์ž‘๋™ ์˜ˆ์ œ

import SwiftUI
import LocalAuthentication

// MARK: - Biometric Manager
@Observable
class BiometricManager {
    var isAuthenticated = false
    var biometryType: LABiometryType = .none
    var canUseBiometrics = false
    var error: BiometricError?
    
    init() {
        checkBiometricAvailability()
    }
    
    func checkBiometricAvailability() {
        let context = LAContext()
        var error: NSError?
        
        canUseBiometrics = context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error)
        biometryType = context.biometryType
        
        if let error {
            self.error = mapError(error)
        }
    }
    
    func authenticate() async {
        let context = LAContext()
        context.localizedCancelTitle = "์ทจ์†Œ"
        context.localizedFallbackTitle = "์•”ํ˜ธ ์‚ฌ์šฉ"
        
        // ์ด์ „ ์ธ์ฆ ๋ฌดํšจํ™” (์„ ํƒ)
        context.invalidate()
        
        do {
            let success = try await context.evaluatePolicy(
                .deviceOwnerAuthenticationWithBiometrics,
                localizedReason: biometryReason
            )
            
            await MainActor.run {
                isAuthenticated = success
                error = nil
            }
        } catch let authError as LAError {
            await MainActor.run {
                isAuthenticated = false
                error = mapLAError(authError)
            }
        } catch {
            await MainActor.run {
                isAuthenticated = false
                self.error = .unknown
            }
        }
    }
    
    func authenticateWithPasscode() async {
        let context = LAContext()
        
        do {
            let success = try await context.evaluatePolicy(
                .deviceOwnerAuthentication,  // ์•”ํ˜ธ fallback ํฌํ•จ
                localizedReason: "์•ฑ ์ž ๊ธˆ์„ ํ•ด์ œํ•ฉ๋‹ˆ๋‹ค"
            )
            
            await MainActor.run {
                isAuthenticated = success
            }
        } catch {
            await MainActor.run {
                isAuthenticated = false
            }
        }
    }
    
    func lock() {
        isAuthenticated = false
    }
    
    // MARK: - Helpers
    private var biometryReason: String {
        switch biometryType {
        case .faceID:
            return "Face ID๋กœ ์•ฑ ์ž ๊ธˆ์„ ํ•ด์ œํ•ฉ๋‹ˆ๋‹ค"
        case .touchID:
            return "Touch ID๋กœ ์•ฑ ์ž ๊ธˆ์„ ํ•ด์ œํ•ฉ๋‹ˆ๋‹ค"
        case .opticID:
            return "Optic ID๋กœ ์•ฑ ์ž ๊ธˆ์„ ํ•ด์ œํ•ฉ๋‹ˆ๋‹ค"
        default:
            return "์•ฑ ์ž ๊ธˆ์„ ํ•ด์ œํ•ฉ๋‹ˆ๋‹ค"
        }
    }
    
    private func mapError(_ error: NSError) -> BiometricError {
        switch error.code {
        case LAError.biometryNotAvailable.rawValue:
            return .notAvailable
        case LAError.biometryNotEnrolled.rawValue:
            return .notEnrolled
        case LAError.biometryLockout.rawValue:
            return .lockout
        default:
            return .unknown
        }
    }
    
    private func mapLAError(_ error: LAError) -> BiometricError {
        switch error.code {
        case .userCancel:
            return .userCancelled
        case .userFallback:
            return .userFallback
        case .authenticationFailed:
            return .authenticationFailed
        case .biometryLockout:
            return .lockout
        default:
            return .unknown
        }
    }
}

enum BiometricError: Error, LocalizedError {
    case notAvailable
    case notEnrolled
    case lockout
    case userCancelled
    case userFallback
    case authenticationFailed
    case unknown
    
    var errorDescription: String? {
        switch self {
        case .notAvailable:
            return "์ƒ์ฒด ์ธ์ฆ์„ ์‚ฌ์šฉํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค"
        case .notEnrolled:
            return "์ƒ์ฒด ์ธ์ฆ์ด ์„ค์ •๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค"
        case .lockout:
            return "๋„ˆ๋ฌด ๋งŽ์€ ์‹œ๋„๋กœ ์ž ๊ฒผ์Šต๋‹ˆ๋‹ค. ๊ธฐ๊ธฐ ์•”ํ˜ธ๋ฅผ ์‚ฌ์šฉํ•˜์„ธ์š”"
        case .userCancelled:
            return "์‚ฌ์šฉ์ž๊ฐ€ ์ทจ์†Œํ–ˆ์Šต๋‹ˆ๋‹ค"
        case .userFallback:
            return "์•”ํ˜ธ ์ž…๋ ฅ์„ ์„ ํƒํ–ˆ์Šต๋‹ˆ๋‹ค"
        case .authenticationFailed:
            return "์ธ์ฆ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค"
        case .unknown:
            return "์•Œ ์ˆ˜ ์—†๋Š” ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค"
        }
    }
}

// MARK: - Views
struct LockScreenView: View {
    @State private var biometricManager = BiometricManager()
    
    var body: some View {
        Group {
            if biometricManager.isAuthenticated {
                MainContentView(biometricManager: biometricManager)
            } else {
                AuthenticationView(biometricManager: biometricManager)
            }
        }
    }
}

struct AuthenticationView: View {
    let biometricManager: BiometricManager
    
    var body: some View {
        VStack(spacing: 32) {
            Image(systemName: biometricIcon)
                .font(.system(size: 80))
                .foregroundStyle(.blue)
            
            Text("์•ฑ ์ž ๊ธˆ")
                .font(.title.bold())
            
            Text("๋ณด์•ˆ์„ ์œ„ํ•ด ์ธ์ฆ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค")
                .foregroundStyle(.secondary)
            
            if let error = biometricManager.error {
                Text(error.localizedDescription)
                    .foregroundStyle(.red)
                    .font(.caption)
            }
            
            Spacer()
            
            VStack(spacing: 16) {
                if biometricManager.canUseBiometrics {
                    Button {
                        Task {
                            await biometricManager.authenticate()
                        }
                    } label: {
                        Label(biometricButtonTitle, systemImage: biometricIcon)
                            .frame(maxWidth: .infinity)
                    }
                    .buttonStyle(.borderedProminent)
                    .controlSize(.large)
                }
                
                Button("์•”ํ˜ธ๋กœ ์ž ๊ธˆ ํ•ด์ œ") {
                    Task {
                        await biometricManager.authenticateWithPasscode()
                    }
                }
                .buttonStyle(.bordered)
            }
            .padding(.horizontal, 40)
        }
        .padding()
        .task {
            // ์•ฑ ์‹œ์ž‘ ์‹œ ์ž๋™์œผ๋กœ ์ธ์ฆ ์š”์ฒญ
            if biometricManager.canUseBiometrics {
                await biometricManager.authenticate()
            }
        }
    }
    
    var biometricIcon: String {
        switch biometricManager.biometryType {
        case .faceID: return "faceid"
        case .touchID: return "touchid"
        case .opticID: return "opticid"
        default: return "lock.fill"
        }
    }
    
    var biometricButtonTitle: String {
        switch biometricManager.biometryType {
        case .faceID: return "Face ID๋กœ ์ž ๊ธˆ ํ•ด์ œ"
        case .touchID: return "Touch ID๋กœ ์ž ๊ธˆ ํ•ด์ œ"
        case .opticID: return "Optic ID๋กœ ์ž ๊ธˆ ํ•ด์ œ"
        default: return "์ž ๊ธˆ ํ•ด์ œ"
        }
    }
}

struct MainContentView: View {
    let biometricManager: BiometricManager
    @Environment(\.scenePhase) private var scenePhase
    
    var body: some View {
        NavigationStack {
            List {
                Section("๋ฏผ๊ฐํ•œ ๋ฐ์ดํ„ฐ") {
                    Text("๋น„๋ฐ€๋ฒˆํ˜ธ: โ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ข")
                    Text("์นด๋“œ๋ฒˆํ˜ธ: โ€ขโ€ขโ€ขโ€ข โ€ขโ€ขโ€ขโ€ข โ€ขโ€ขโ€ขโ€ข 1234")
                }
            }
            .navigationTitle("๋ณด์•ˆ ๊ธˆ๊ณ ")
            .toolbar {
                Button("์ž ๊ธˆ") {
                    biometricManager.lock()
                }
            }
        }
        .onChange(of: scenePhase) { _, newPhase in
            // ์•ฑ์ด ๋ฐฑ๊ทธ๋ผ์šด๋“œ๋กœ ๊ฐ€๋ฉด ์ž ๊ธˆ
            if newPhase == .background {
                biometricManager.lock()
            }
        }
    }
}

๊ณ ๊ธ‰ ํŒจํ„ด

1. Keychain๊ณผ ์—ฐ๋™

import Security

func saveToKeychain(data: Data, withBiometricProtection: Bool) throws {
    let access: SecAccessControlCreateFlags = withBiometricProtection 
        ? .biometryCurrentSet 
        : []
    
    guard let accessControl = SecAccessControlCreateWithFlags(
        nil,
        kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
        access,
        nil
    ) else {
        throw KeychainError.accessControlCreationFailed
    }
    
    let query: [String: Any] = [
        kSecClass as String: kSecClassGenericPassword,
        kSecAttrAccount as String: "secureData",
        kSecValueData as String: data,
        kSecAttrAccessControl as String: accessControl
    ]
    
    SecItemDelete(query as CFDictionary)
    let status = SecItemAdd(query as CFDictionary, nil)
    
    guard status == errSecSuccess else {
        throw KeychainError.saveFailed(status)
    }
}

func readFromKeychain() async throws -> Data {
    let context = LAContext()
    context.localizedReason = "์ €์žฅ๋œ ๋ฐ์ดํ„ฐ์— ์ ‘๊ทผํ•ฉ๋‹ˆ๋‹ค"
    
    let query: [String: Any] = [
        kSecClass as String: kSecClassGenericPassword,
        kSecAttrAccount as String: "secureData",
        kSecReturnData as String: true,
        kSecUseAuthenticationContext as String: context
    ]
    
    var result: AnyObject?
    let status = SecItemCopyMatching(query as CFDictionary, &result)
    
    guard status == errSecSuccess, let data = result as? Data else {
        throw KeychainError.readFailed(status)
    }
    
    return data
}

2. ์žฌ์ธ์ฆ ๋ฐฉ์ง€ (์ผ์ • ์‹œ๊ฐ„)

class BiometricManager {
    private var lastAuthTime: Date?
    private let authValidDuration: TimeInterval = 300  // 5๋ถ„
    
    var needsReauthentication: Bool {
        guard let lastAuth = lastAuthTime else { return true }
        return Date().timeIntervalSince(lastAuth) > authValidDuration
    }
    
    func authenticate() async {
        guard needsReauthentication else {
            isAuthenticated = true
            return
        }
        
        // ์‹ค์ œ ์ธ์ฆ ์ˆ˜ํ–‰
        // ...
        
        lastAuthTime = Date()
    }
}

์ฃผ์˜์‚ฌํ•ญ

  1. Info.plist ํ•„์ˆ˜

    • Face ID ์‚ฌ์šฉ ์‹œ NSFaceIDUsageDescription ํ•„์ˆ˜
    • ๋ˆ„๋ฝ ์‹œ ํฌ๋ž˜์‹œ
  2. ์—๋Ÿฌ ์ฒ˜๋ฆฌ

    • .userCancel: ์‚ฌ์šฉ์ž ์ทจ์†Œ (์กฐ์šฉํžˆ ์ฒ˜๋ฆฌ)
    • .userFallback: ์•”ํ˜ธ ์ž…๋ ฅ ์„ ํƒ
    • .biometryLockout: ๋„ˆ๋ฌด ๋งŽ์€ ์‹คํŒจ (๊ธฐ๊ธฐ ์•”ํ˜ธ ํ•„์š”)
  3. ์ƒ์ฒด ์ •๋ณด ๋ณ€๊ฒฝ ๊ฐ์ง€

    // ์ €์žฅ๋œ ๊ฐ’๊ณผ ๋น„๊ต
    let oldDomainState = loadedDomainState
    let newDomainState = context.evaluatedPolicyDomainState
    
    if oldDomainState != newDomainState {
        // ์ƒ์ฒด ์ •๋ณด๊ฐ€ ๋ณ€๊ฒฝ๋จ (์ง€๋ฌธ ์ถ”๊ฐ€/์‚ญ์ œ ๋“ฑ)
        // ์žฌ์ธ์ฆ ์š”๊ตฌ ๊ฐ€๋Šฅ
    }
  4. ์‹œ๋ฎฌ๋ ˆ์ดํ„ฐ ํ…Œ์ŠคํŠธ

    • Features โ†’ Face ID / Touch ID โ†’ Enrolled
    • Matching / Non-matching Face/Finger๋กœ ํ…Œ์ŠคํŠธ