Face ID / Touch ID ์์ฒด ์ธ์ฆ ๊ฐ์ด๋. ์ด ๋ฌธ์๋ฅผ ์ฝ๊ณ ์์ฒด ์ธ์ฆ ์ฝ๋๋ฅผ ์์ฑํ ์ ์์ต๋๋ค.
LocalAuthentication์ Face ID, Touch ID, ๊ธฐ๊ธฐ ์ํธ๋ฅผ ํตํ ์ฌ์ฉ์ ์ธ์ฆ์ ์ ๊ณตํ๋ ํ๋ ์์ํฌ์ ๋๋ค.
import LocalAuthentication<key>NSFaceIDUsageDescription</key>
<string>์ฑ ์ ๊ธ ํด์ ๋ฅผ ์ํด Face ID๋ฅผ ์ฌ์ฉํฉ๋๋ค.</string>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
}// ์์ฒด ์ธ์ฆ๋ง
.deviceOwnerAuthenticationWithBiometrics
// ์์ฒด ์ธ์ฆ + ๊ธฐ๊ธฐ ์ํธ (fallback)
.deviceOwnerAuthenticationfunc 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()
}
}
}
}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
}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()
}
}-
Info.plist ํ์
- Face ID ์ฌ์ฉ ์
NSFaceIDUsageDescriptionํ์ - ๋๋ฝ ์ ํฌ๋์
- Face ID ์ฌ์ฉ ์
-
์๋ฌ ์ฒ๋ฆฌ
.userCancel: ์ฌ์ฉ์ ์ทจ์ (์กฐ์ฉํ ์ฒ๋ฆฌ).userFallback: ์ํธ ์ ๋ ฅ ์ ํ.biometryLockout: ๋๋ฌด ๋ง์ ์คํจ (๊ธฐ๊ธฐ ์ํธ ํ์)
-
์์ฒด ์ ๋ณด ๋ณ๊ฒฝ ๊ฐ์ง
// ์ ์ฅ๋ ๊ฐ๊ณผ ๋น๊ต let oldDomainState = loadedDomainState let newDomainState = context.evaluatedPolicyDomainState if oldDomainState != newDomainState { // ์์ฒด ์ ๋ณด๊ฐ ๋ณ๊ฒฝ๋จ (์ง๋ฌธ ์ถ๊ฐ/์ญ์ ๋ฑ) // ์ฌ์ธ์ฆ ์๊ตฌ ๊ฐ๋ฅ }
-
์๋ฎฌ๋ ์ดํฐ ํ ์คํธ
- Features โ Face ID / Touch ID โ Enrolled
- Matching / Non-matching Face/Finger๋ก ํ ์คํธ