๐ŸŒ™

[Swift] iOS ์ƒ์ฒด์ธ์ฆ ๊ถŒํ•œ์€ '๊ฑฐ๋ถ€' ์ƒํƒœ๋ฅผ ๊ฐ์ง€ํ•  ์ˆ˜ ์—†๋‹ค?

yeggrrr๐Ÿผ 2025. 10. 26. 02:26
728x90

 

๊ถŒํ•œ์ด ์žˆ๋Š”๋ฐ ์™œ ์•ˆ๋ผ์š”?


์•ฑ์—์„œ FaceID๋‚˜ TouchID ์ธ์ฆ์„ ์‹œ๋„ํ•  ๋•Œ,
์ผ๋ถ€ ์‚ฌ์šฉ์ž ํ™˜๊ฒฝ์—์„œ OS ํŒ์—…์ด ๋œจ์ง€ ์•Š๊ฑฐ๋‚˜, canEvaluatePolicy๊ฐ€ ์‹คํŒจํ•˜๋Š” ๊ฒฝํ—˜์ด ์žˆ๋‹ค.
ํŠนํžˆ ์•„๋ž˜์™€ ๊ฐ™์€ ์ƒํ™ฉ์ด ํ˜ผ๋™์„ ์ค€๋‹ค.

  • ์‚ฌ์šฉ์ž๊ฐ€ ์ฒ˜์Œ์œผ๋กœ ์ƒ์ฒด ์ธ์ฆ ์„ค์ •์„ ํ•˜์ง€ ์•Š์€ ์ƒํƒœ
  • ์‚ฌ์šฉ์ž๊ฐ€ ์„ค์ •์—์„œ ์ƒ์ฒด ์ธ์ฆ์„ ๊บผ ๋‘” ์ƒํƒœ
  • ์‚ฌ์šฉ์ž๊ฐ€ ๊ถŒํ•œ์„ ๊ฑฐ๋ถ€ํ•œ ์ƒํƒœ
  • ๊ธฐ๊ธฐ ์ž์ฒด์— ์ƒ์ฒด ์ •๋ณด(์ง€๋ฌธ/์–ผ๊ตด)๊ฐ€ ๋“ฑ๋ก๋˜์–ด ์žˆ์ง€ ์•Š์€ ์ƒํƒœ

์ด ์ค‘ ์–ด๋–ค ๊ฒฝ์šฐ๋“  iOS๋Š” ๊ฐœ๋ฐœ์ž์—๊ฒŒ "๊ฑฐ๋ถ€๋จ"์ด๋ผ๋Š” ๋ช…ํ™•ํ•œ ์ƒํƒœ๋ฅผ ์•Œ๋ ค์ฃผ์ง€ ์•Š๋Š”๋‹ค.
๊ฒฐ๊ตญ, ์šฐ๋ฆฌ๋Š” OS ์ •์ฑ… ์•ˆ์—์„œ ์ƒํƒœ๋ฅผ "์ถ”์ •"ํ•ด์•ผ ํ•œ๋‹ค.


Apple์˜ ์„ค๊ณ„ ์ฒ ํ•™๊ณผ ์‹ค์ œ ์ฝ”๋“œ


๐Ÿคฆ๐Ÿปโ€โ™€๏ธ LAContext์˜ ํ•œ๊ณ„

let context = LAContext()
var error: NSError?
context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error)

์œ„ ๋ฉ”์„œ๋“œ๋Š” '์‚ฌ์šฉ ๊ฐ€๋Šฅ ์—ฌ๋ถ€'๋งŒ ์•Œ๋ ค์ฃผ๋ฉฐ, '๊ถŒํ•œ ๊ฑฐ๋ถ€ ์ƒํƒœ์ธ์ง€'๋Š” ์•Œ ์ˆ˜ ์—†๋‹ค.
์ฆ‰, ์•„๋ž˜ ๋‘ ์ƒํ™ฉ์ด ๋™์ผํ•˜๊ฒŒ false๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.

  • ์ƒ์ฒด ์ธ์ฆ ์ž์ฒด๊ฐ€ ๊บผ์ ธ ์žˆ๋Š” ๊ฒฝ์šฐ
  • ์‚ฌ์šฉ์ž๊ฐ€ ๊ถŒํ•œ์„ ๊ฑฐ๋ถ€ํ•œ ๊ฒฝ์šฐ

์ด ๋•Œ๋ฌธ์— ์•ฑ ๋‹จ์—์„œ๋Š” ์•„๋ž˜์™€ ๊ฐ™์ด ๋ถ„๊ธฐ์ฒ˜๋ฆฌ๋ฅผ ํ•ด์•ผํ•œ๋‹ค.

if context.biometryType == .none {
    print("๊ธฐ๊ธฐ์—์„œ ์ƒ์ฒด ์ธ์ฆ์„ ์ง€์›ํ•˜์ง€ ์•Š์Œ")
} 
else if error != nil {
    print("์‚ฌ์šฉ ๋ถˆ๊ฐ€ โ€” ์‹ค์ œ ์›์ธ์€ ๋ถˆ๋ช…ํ™•")
}

 

Apple์ด ์ด๋ ‡๊ฒŒ ๋งŒ๋“  ์ด์œ ?

Apple์€ ๋ณด์•ˆ์ƒ์˜ ์ด์œ ๋กœ '๊ถŒํ•œ ๊ฑฐ๋ถ€'๋ฅผ ์•ฑ์ด ๋ช…ํ™•ํžˆ ๊ฐ์ง€ํ•˜์ง€ ๋ชปํ•˜๋„๋ก ์„ค๊ณ„ํ–ˆ๋‹ค. (healthKit ์ฝ๊ธฐ๊ถŒํ•œ๋„ ์ด๋Ÿผ..๐Ÿคฆ๐Ÿปโ€โ™€๏ธ)
์ด๋Š” ๊ณง '์‚ฌ์šฉ์ž์˜ ๊ฑฐ๋ถ€ ์˜์‚ฌ'๋ฅผ ์•ฑ์ด ๊ฐ„์ ‘์ ์œผ๋กœ ์ถ”์ ํ•˜์ง€ ๋ชปํ•˜๊ฒŒ ํ•˜๊ธฐ ์œ„ํ•จ์ด๋‹ค.

์ฆ‰, '๊ถŒํ•œ์ด ์—†๋‹ค'๋Š” ๊ฒƒ์กฐ์ฐจ ๊ฐœ์ธ์ •๋ณด์— ํ•ด๋‹นํ•  ์ˆ˜ ์žˆ๋‹ค๊ณ  ๋ณด๋Š” ๊ฒƒ ๊ฐ™๋‹ค.
 

๋ฏผ๊ฐ ํŽ˜์ด์ง€ ๋ณดํ˜ธ์šฉ์œผ๋กœ ์ƒ์ฒด์ธ์ฆ ํ™œ์šฉ

1. ์ƒํƒœ๋ณ„ ๋ถ„๊ธฐ์ฒ˜๋ฆฌ

import LocalAuthentication

enum SensitiveAuthRoute {
    case granted                         // ์ ‘๊ทผ ํ—ˆ์šฉ
    case cancelled                       // ์‚ฌ์šฉ์ž/์‹œ์Šคํ…œ ์ทจ์†Œ
    case lockedOut                       // ์ƒ์ฒด ์ž ๊ธˆ (์ผ์‹œ์  ์ฐจ๋‹จ)
    case notAvailable                    // ๊ธฐ๊ธฐ ๋ฏธ์ง€์›
    case notEnrolled                     // ๊ธฐ๊ธฐ ๋“ฑ๋ก(์–ผ๊ตด/์ง€๋ฌธ) ์—†์Œ
    case failed                          // ์ธ์ฆ ์‹คํŒจ(๋ถˆ์ผ์น˜ ๋“ฑ)
    case unknown                         // ๊ตฌ๋ถ„ ๋ถˆ๊ฐ€
}


2. ์ธ์ฆ ๋งค๋‹ˆ์ €
- ๊ถŒํ•œ '๊ฑฐ๋ถ€'๋ฅผ ํŒ์ •ํ•˜๋ ค ํ•˜์ง€ ๋ง๊ณ , OS๊ฐ€ ์ฃผ๋Š” ์„ฑ๊ณต/์‹คํŒจ + ์—๋Ÿฌ์ฝ”๋“œ๋ฅผ UX ๋ผ์šฐํŠธ๋กœ ๋ณ€ํ™˜
- ์ž ๊ธˆ(.biometryLockout) ์‹œ์—๋Š” ์ฆ‰์‹œ ํŒจ์Šค์ฝ”๋“œ ๋Œ€์ฒด(.deviceOwnerAuthentication)๋กœ ์ „ํ™˜

final class SensitiveAuthManager {
    private var lastAttemptAt: Date?
    private let minInterval: TimeInterval = 1.0

    @MainActor
    func requireAuthentication(reason: String = "๋ฏผ๊ฐ ์ •๋ณด ์ ‘๊ทผ์„ ์œ„ํ•ด ๋ณธ์ธ ํ™•์ธ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.") async -> SensitiveAuthRoute {
        // 0) ๊ณผ๋„ํ•œ ์žฌ์š”์ฒญ ๋ฐฉ์ง€
        if let last = lastAttemptAt, Date().timeIntervalSince(last) < minInterval {
            return .cancelled
        }
        lastAttemptAt = Date()

        let ctx = LAContext()
        ctx.localizedFallbackTitle = "์•”ํ˜ธ ์ž…๋ ฅ"

        var err: NSError?
        let canBio = ctx.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &err)

        // 1) ์ธ์ฆ ๊ฐ€๋Šฅ ์—ฌ๋ถ€ ๋ถ„๊ธฐ
        if !canBio {
            if let la = err.map({ LAError(_nsError: $0) }) {
                switch la.code {
                case .biometryNotAvailable:
                    return .notAvailable
                case .biometryNotEnrolled:
                    return .notEnrolled
                case .biometryLockout:
                    return await evaluateWithPasscode(context: ctx, reason: reason)
                default:
                    return .unknown
                }
            }
            return .unknown
        }

        // 2) ์ƒ์ฒด ์ธ์ฆ ์ˆ˜ํ–‰
        let bioResult = await evaluateBiometrics(context: ctx, reason: reason)
        switch bioResult {
        case .granted:
            return .granted
        case .lockedOut:
            return await evaluateWithPasscode(context: ctx, reason: reason)
        case .cancelled, .failed, .unknown:
            return bioResult
        }
    }

    private func evaluateBiometrics(context: LAContext, reason: String) async -> SensitiveAuthRoute {
        await withCheckedContinuation { cont in
            context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics,
                                   localizedReason: reason) { success, error in
                if success { cont.resume(returning: .granted); return }
                guard let e = error as NSError? else { cont.resume(returning: .unknown); return }
                let la = LAError(_nsError: e)
                switch la.code {
                case .userCancel, .systemCancel, .appCancel:
                    cont.resume(returning: .cancelled)
                case .authenticationFailed:
                    cont.resume(returning: .failed)
                case .biometryLockout:
                    cont.resume(returning: .lockedOut)
                default:
                    cont.resume(returning: .unknown)
                }
            }
        }
    }

    private func evaluateWithPasscode(context: LAContext, reason: String) async -> SensitiveAuthRoute {
        await withCheckedContinuation { cont in
            context.evaluatePolicy(.deviceOwnerAuthentication,
                                   localizedReason: reason) { success, error in
                if success { cont.resume(returning: .granted); return }
                guard let e = error as NSError? else { cont.resume(returning: .unknown); return }
                let la = LAError(_nsError: e)
                switch la.code {
                case .userCancel, .systemCancel, .appCancel:
                    cont.resume(returning: .cancelled)
                case .authenticationFailed:
                    cont.resume(returning: .failed)
                default:
                    cont.resume(returning: .unknown)
                }
            }
        }
    }
}


3. ๋ฏผ๊ฐ ํ™”๋ฉด ์ง„์ž… ๊ฐ€์ด๋“œ(UIKit)
- ๋ฏผ๊ฐ ํ™”๋ฉด ์ง„์ž… ์ง์ „์— ํ˜ธ์ถœ
- ๋ผ์šฐํŠธ๋ณ„๋กœ ์„ค์ • ์ด๋™ ์œ ๋„, ์•ฑ PIN ๋Œ€์ฒด, ์ผ์‹œ ์žฌ์‹œ๋„, ์ ‘๊ทผ ์ฐจ๋‹จ ๋“ฑ์„ ๊ฒฐ์ •

@MainActor
func guardSensitiveScreenAccess(presenting: UIViewController, onGranted: () -> Void) async {
    let manager = SensitiveAuthManager()
    switch await manager.requireAuthentication(reason: "๊ฑด๊ฐ• ์ •๋ณด ์—ด๋žŒ์„ ์œ„ํ•ด ๋ณธ์ธ ํ™•์ธ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.") {
    case .granted:
        onGranted()
    case .notEnrolled:
        let alert = UIAlertController(
            title: "์ƒ์ฒด ์ •๋ณด ๋ฏธ๋“ฑ๋ก",
            message: "Face ID/Touch ID๊ฐ€ ๋“ฑ๋ก๋˜์–ด ์žˆ์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ์„ค์ •์—์„œ ๋“ฑ๋ก ํ›„ ๋‹ค์‹œ ์‹œ๋„ํ•˜์„ธ์š”.",
            preferredStyle: .alert
        )
        alert.addAction(UIAlertAction(title: "์ทจ์†Œ", style: .cancel))
        alert.addAction(UIAlertAction(title: "์„ค์ •์œผ๋กœ ์ด๋™", style: .default) { _ in
            if let url = URL(string: UIApplication.openSettingsURLString) {
                UIApplication.shared.open(url)
            }
        })
        presenting.present(alert, animated: true)
    case .notAvailable:
        // ๊ธฐ๊ธฐ ๋ฏธ์ง€์› โ†’ ์•ฑ ์ž์ฒด PIN/ํŒจํ„ด ๋“ฑ ๋Œ€์ฒด ์ธ์ฆ ๋ฃจํŠธ๋กœ ์ „ํ™˜
        presentAppPINFlow(from: presenting)
    case .lockedOut:
        // ๊ณผ๋‹ค ์‹คํŒจ โ†’ ์ผ์ • ์‹œ๊ฐ„ ํ›„ ์žฌ์‹œ๋„ ์•ˆ๋‚ด(๋˜๋Š” ์ด๋ฏธ passcode ๋Œ€์ฒด์—์„œ ์‹คํŒจํ•œ ๊ฒฝ์šฐ)
        showToast("์ž ์‹œ ํ›„ ๋‹ค์‹œ ์‹œ๋„ํ•˜์„ธ์š”.") // UI ๊ตฌ์„ฑ์— ๋งž๊ฒŒ ์ฒ˜๋ฆฌ
    case .cancelled:
        // ์‚ฌ์šฉ์ž๊ฐ€ ์ทจ์†Œ โ†’ ์•„๋ฌด ๋™์ž‘ ์—†์ด ๋ณต๊ท€
        break
    case .failed:
        // ๋ถˆ์ผ์น˜ โ†’ ์žฌ์‹œ๋„/๋Œ€์ฒด์ˆ˜๋‹จ ์ œ์•ˆ
        showToast("์ธ์ฆ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค. ๋‹ค์‹œ ์‹œ๋„ํ•˜๊ฑฐ๋‚˜ ๋‹ค๋ฅธ ์ธ์ฆ ์ˆ˜๋‹จ์„ ์ด์šฉํ•˜์„ธ์š”.")
    case .unknown:
        // ์˜ˆ์™ธ โ†’ ์•ˆ์ „ํ•˜๊ฒŒ ์ฐจ๋‹จ
        showToast("์ธ์ฆ์„ ์ง„ํ–‰ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")
    }
}

 

๋งˆ๋ฌด๋ฆฌ

iOS๋Š” ์ƒ์ฒด์ธ์ฆ์— ๋Œ€ํ•ด ๊ฐœ๋ฐœ์ž๊ฐ€ ์ž„์˜๋กœ '๊ถŒํ•œ ํŒ์—…'์„ ๋„์šธ ์ˆ˜ ์—†๋‹ค.
์‹œ์Šคํ…œ ํŒ์—…์€ evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, โ€ฆ)๋ฅผ ํ˜ธ์ถœํ–ˆ์„ ๋•Œ โ€˜์ตœ์ดˆ 1ํšŒโ€™ ์ž๋™์œผ๋กœ ๋…ธ์ถœ๋œ๋‹ค. (์•ฑ ์‚ญ์ œ ์ „๊นŒ์ง€)
๊ทธ๋ž˜์„œ 'ํ—ˆ์šฉํ•ด๋‹ฌ๋ผ๋Š” ์•ˆ๋‚ด ํŒ์—…'์€ ์•ฑ ์ž์ฒด UI๋กœ ๋จผ์ € ๋ณด์—ฌ์ฃผ๊ณ , ์‚ฌ์šฉ์ž๊ฐ€ ๋™์˜ํ•˜๋ฉด evaluatePolicy๋ฅผ ํ˜ธ์ถœํ•ด์„œ OS ํŒ์—…์„ ์œ ๋„ํ•˜๋Š” ๋ฐฉ์‹์ด ์ •์„์ผ ๊ฒƒ ๊ฐ™๋‹ค. ๋˜ํ•œ, ์•ฑ ๊ธฐ๋Šฅ์— ๋งž๊ฒŒ info.plist์— NSFaceIDUsageDescription(FaceID ์ด์œ  ๋ฌธ๊ตฌ)๋ฅผ ๋ฐ˜๋“œ์‹œ ์ถ”๊ฐ€ํ•ด์•ผํ•œ๋‹ค.

์‚ฌ์‹ค ๊ธฐํš์ƒ '๊ถŒํ•œ ๊ฑฐ๋ถ€/ํ—ˆ์šฉ' ์ƒํƒœ์— ๋”ฐ๋ผ์„œ ์‚ฌ์šฉ์ž์—๊ฒŒ ๊ถŒํ•œ์„ ํ—ˆ์šฉํ•ด๋‹ฌ๋ผ๋Š” ๋ฌธ๊ตฌ๋ฅผ ๋„์›Œ์•ผํ•˜๋Š” ๊ฒฝ์šฐ๊ฐ€ ์•„๋‹ˆ๋ผ๋ฉด ๊ตฌํ˜„์— ์žˆ์–ด์„œ ํฐ ๋ฌธ์ œ๋Š” ์—†์„ ๊ฒƒ ๊ฐ™๋‹ค.
๋งŒ์•ฝ ๊ตฌํ˜„์— ํ•„์š”ํ•˜๋‹ค๋ฉด, '์„ฑ๊ณต / ์‹คํŒจ / ์ธ์ฆ ๋ถˆ๊ฐ€๋Šฅ' ์ด๋ ‡๊ฒŒ 3๊ฐ€์ง€ ๊ฒฝ์šฐ๋กœ ๋‚˜๋ˆ„๊ณ  '์‹คํŒจ'์ธ ๊ฒฝ์šฐ์—๋Š” ์•”ํ˜ธ ๋ฐ PIN ์ž…๋ ฅ ํ˜น์€ ์•ฑ ๋‚ด์— ๊ฐ„ํŽธ๋น„๋ฐ€๋ฒˆํ˜ธ ์ž…๋ ฅ ๊ธฐ๋Šฅ์œผ๋กœ Fallback ์ฒ˜๋ฆฌํ•˜๋ฉด ๋œ๋‹ค. '์ธ์ฆ ๋ถˆ๊ฐ€๋Šฅ'์ธ ๊ฒฝ์šฐ์—๋Š” ๊ถŒํ•œ ๊ฑฐ๋ถ€ ์ƒํƒœ์ผ ์ˆ˜ ์žˆ์œผ๋‹ˆ, ์„ค์ •์ฐฝ์œผ๋กœ ์ด๋™์‹œํ‚ฌ ์ˆ˜ ์žˆ๋Š” ํŒ์—…์„ ๋„์›Œ์ฃผ๊ณ , ๊ถŒํ•œ์„ ํ—ˆ์šฉํ•  ์ˆ˜ ์žˆ๋„๋ก ์œ ๋„ํ•˜๋ฉด ๋œ๋‹ค.

์œ„์™€ ๊ฐ™์€ ๊ตฌํ˜„ ๋ฐฉ์‹์ด ์ •์„์€ ์•„๋‹ˆ์ง€๋งŒ, ์—ฌ๋Ÿฌ ๋ฉ”์ด์ € ์•ฑ๋“ค์„ ๋ฒค์น˜๋งˆํ‚น ํ•ด๋ณด๋‹ˆ ์œ„์™€ ๊ฐ™์ด ์ฒ˜๋ฆฌํ•˜๊ณ  ์žˆ์—ˆ๋‹ค.
์• ๋งคํ•˜๋ฉด ๊ทธ๋ƒฅ ๋ฉ”์ด์ € ์•ฑ๊ณผ ๋™์ผํ•˜๊ฒŒ ๊ตฌํ˜„ํ•˜๋Š”๊ฒŒ ์ข‹์€ ๊ฒƒ ๊ฐ™๋‹ค. ๋ฌด์กฐ๊ฑด ๋”ฐ๋ผํ•˜์ž ๋งˆ์ธ๋“œ๊ฐ€ ์•„๋‹ˆ๋ผ ์‚ฌ์šฉ์ž ์ž…์žฅ์—์„œ๋„ ์ต์ˆ™ํ•œ UX์ผํ…Œ๋‹ˆ :)

728x90