mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-02 21:33:26 +00:00
Add support for Face ID/Touch ID to app lock. (#1966)
* Fix biometrics with low grace period and backgrounding before unlocked. * Trigger permissions alert when enabling Face ID.
This commit is contained in:
@@ -21,9 +21,10 @@ import LocalAuthentication
|
||||
class AppLockService: AppLockServiceProtocol {
|
||||
private let keychainController: KeychainControllerProtocol
|
||||
private let appSettings: AppSettings
|
||||
private let context = LAContext()
|
||||
private let context: LAContext
|
||||
|
||||
private let timer: AppLockTimer
|
||||
private let unlockPolicy: LAPolicy = .deviceOwnerAuthenticationWithBiometrics
|
||||
|
||||
var isMandatory: Bool { appSettings.appLockIsMandatory }
|
||||
|
||||
@@ -38,21 +39,34 @@ class AppLockService: AppLockServiceProtocol {
|
||||
}
|
||||
}
|
||||
|
||||
var biometryType: LABiometryType { context.biometryType }
|
||||
var biometricUnlockEnabled = false // Needs to be stored, not sure if in the keychain or defaults yet.
|
||||
var biometryType: LABiometryType {
|
||||
updateBiometrics()
|
||||
guard context.evaluatedPolicyDomainState != nil else { return .none }
|
||||
return context.biometryType
|
||||
}
|
||||
|
||||
var biometricUnlockEnabled: Bool {
|
||||
keychainController.containsPINCodeBiometricState()
|
||||
}
|
||||
|
||||
var biometricUnlockTrusted: Bool {
|
||||
guard let state = keychainController.pinCodeBiometricState() else { return false }
|
||||
updateBiometrics()
|
||||
return state == context.evaluatedPolicyDomainState
|
||||
}
|
||||
|
||||
var numberOfPINAttempts: AnyPublisher<Int, Never> { appSettings.$appLockNumberOfPINAttempts }
|
||||
var numberOfBiometricAttempts: AnyPublisher<Int, Never> { appSettings.$appLockNumberOfBiometricAttempts }
|
||||
|
||||
private var disabledSubject: PassthroughSubject<Void, Never> = .init()
|
||||
var disabledPublisher: AnyPublisher<Void, Never> { disabledSubject.eraseToAnyPublisher() }
|
||||
|
||||
init(keychainController: KeychainControllerProtocol, appSettings: AppSettings) {
|
||||
init(keychainController: KeychainControllerProtocol, appSettings: AppSettings, context: LAContext = .init()) {
|
||||
self.keychainController = keychainController
|
||||
self.appSettings = appSettings
|
||||
self.context = context
|
||||
timer = AppLockTimer(gracePeriod: appSettings.appLockGracePeriod)
|
||||
|
||||
configureBiometrics()
|
||||
updateBiometrics()
|
||||
}
|
||||
|
||||
func setupPINCode(_ pinCode: String) -> Result<Void, AppLockServiceError> {
|
||||
@@ -74,11 +88,27 @@ class AppLockService: AppLockServiceProtocol {
|
||||
return .success(())
|
||||
}
|
||||
|
||||
func enableBiometricUnlock() -> Result<Void, AppLockServiceError> {
|
||||
guard isEnabled else { return .failure(.pinNotSet) }
|
||||
guard let state = context.evaluatedPolicyDomainState else { return .failure(.biometricUnlockNotSupported) }
|
||||
|
||||
do {
|
||||
try keychainController.setPINCodeBiometricState(state)
|
||||
return .success(())
|
||||
} catch {
|
||||
MXLog.error("Keychain access error: \(error)")
|
||||
return .failure(.keychainError)
|
||||
}
|
||||
}
|
||||
|
||||
func disableBiometricUnlock() {
|
||||
keychainController.removePINCodeBiometricState()
|
||||
}
|
||||
|
||||
func disable() {
|
||||
biometricUnlockEnabled = false
|
||||
keychainController.removePINCode()
|
||||
keychainController.removePINCodeBiometricState()
|
||||
appSettings.appLockNumberOfPINAttempts = 0
|
||||
appSettings.appLockNumberOfBiometricAttempts = 0
|
||||
disabledSubject.send()
|
||||
}
|
||||
|
||||
@@ -96,35 +126,68 @@ class AppLockService: AppLockServiceProtocol {
|
||||
appSettings.appLockNumberOfPINAttempts += 1
|
||||
return false
|
||||
}
|
||||
|
||||
if biometricUnlockEnabled, !biometricUnlockTrusted {
|
||||
MXLog.info("Fixing trust for biometric unlock.")
|
||||
updateBiometrics()
|
||||
_ = enableBiometricUnlock()
|
||||
}
|
||||
|
||||
return completeUnlock()
|
||||
}
|
||||
|
||||
func unlockWithBiometrics() -> Bool {
|
||||
func unlockWithBiometrics() async -> Bool {
|
||||
guard biometryType != .none, biometricUnlockEnabled else {
|
||||
MXLog.warning("\(biometryType) failed.")
|
||||
appSettings.appLockNumberOfBiometricAttempts += 1
|
||||
MXLog.error("Biometric unlock not setup.")
|
||||
return false
|
||||
}
|
||||
|
||||
guard biometricUnlockTrusted else {
|
||||
MXLog.error("Biometrics have changed. PIN should be shown.")
|
||||
return false
|
||||
}
|
||||
|
||||
do {
|
||||
let context = unlockContext()
|
||||
guard try await context.evaluatePolicy(unlockPolicy, localizedReason: L10n.screenAppLockBiometricUnlockReasonIos) else {
|
||||
MXLog.warning("\(context.biometryType) failed without error.")
|
||||
return false
|
||||
}
|
||||
return completeUnlock()
|
||||
} catch {
|
||||
MXLog.error("\(context.biometryType) failed: \(error)")
|
||||
return false
|
||||
}
|
||||
return completeUnlock()
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
/// Queries the context for supported biometrics.
|
||||
private func configureBiometrics() {
|
||||
/// Queries the context for supported biometrics and enrolment state.
|
||||
private func updateBiometrics() {
|
||||
var error: NSError?
|
||||
context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error)
|
||||
context.canEvaluatePolicy(unlockPolicy, error: &error)
|
||||
|
||||
if let error {
|
||||
MXLog.error("Biometrics error: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared logic for completing an unlock via a PIN or biometry.
|
||||
/// Creates a context specifically for unlocking the app. The titles are customised,
|
||||
/// and the fresh context ensures that the user is promoted to unlock based on
|
||||
/// `timer.gracePeriod` rather than any system to defined grace period.
|
||||
private func unlockContext() -> LAContext {
|
||||
// Keep using the injected context for tests etc.
|
||||
guard type(of: context) == LAContext.self else { return context }
|
||||
|
||||
let context = LAContext()
|
||||
context.localizedFallbackTitle = L10n.actionEnterPin
|
||||
return context
|
||||
}
|
||||
|
||||
/// Shared logic for completing an unlock via a PIN or biometrics.
|
||||
private func completeUnlock() -> Bool {
|
||||
timer.registerUnlock()
|
||||
appSettings.appLockNumberOfPINAttempts = 0
|
||||
appSettings.appLockNumberOfBiometricAttempts = 0
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,10 @@ enum AppLockServiceError: Error {
|
||||
case invalidPIN
|
||||
/// The PIN code was rejected as an insecure choice.
|
||||
case weakPIN
|
||||
/// A PIN code hasn't been set yet.
|
||||
case pinNotSet
|
||||
/// Attempting to use biometric unlock when it isn't yet supported on this device.
|
||||
case biometricUnlockNotSupported
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -36,7 +40,10 @@ protocol AppLockServiceProtocol: AnyObject {
|
||||
/// The type of biometric authentication supported by the device.
|
||||
var biometryType: LABiometryType { get }
|
||||
/// Whether or not the user has enabled unlock via TouchID, FaceID or (possibly) OpticID.
|
||||
var biometricUnlockEnabled: Bool { get set }
|
||||
var biometricUnlockEnabled: Bool { get }
|
||||
/// Whether TouchID, FaceID or (possibly) OpticID are trusted, or if the app needs the user
|
||||
/// to re-enter their PIN code to re-enable the feature (i.e. to accept a new face or fingerprint).
|
||||
var biometricUnlockTrusted: Bool { get }
|
||||
|
||||
/// A publisher that advertises when the service has been disabled.
|
||||
var disabledPublisher: AnyPublisher<Void, Never> { get }
|
||||
@@ -45,6 +52,10 @@ protocol AppLockServiceProtocol: AnyObject {
|
||||
func setupPINCode(_ pinCode: String) -> Result<Void, AppLockServiceError>
|
||||
/// Validates the supplied PIN code is long enough, only contains digits and isn't a weak choice.
|
||||
func validate(_ pinCode: String) -> Result<Void, AppLockServiceError>
|
||||
/// Enables the use of Touch ID/Face ID as an alternative to the PIN code.
|
||||
func enableBiometricUnlock() -> Result<Void, AppLockServiceError>
|
||||
/// Disables the use of Touch ID/Face ID as an alternative to the PIN code.
|
||||
func disableBiometricUnlock()
|
||||
/// Disables the App Lock feature, removing the user's stored PIN code.
|
||||
func disable()
|
||||
|
||||
@@ -56,12 +67,12 @@ protocol AppLockServiceProtocol: AnyObject {
|
||||
/// Attempt to unlock the app with the supplied PIN code.
|
||||
func unlock(with pinCode: String) -> Bool
|
||||
/// Attempt to unlock the app using FaceID or TouchID.
|
||||
func unlockWithBiometrics() -> Bool
|
||||
func unlockWithBiometrics() async -> Bool
|
||||
|
||||
/// The number of attempts the user had made to unlock with a PIN code.
|
||||
///
|
||||
/// Note: We don't track the biometric attempts as LAContext does that automatically.
|
||||
var numberOfPINAttempts: AnyPublisher<Int, Never> { get }
|
||||
/// The number of attempts the user has made to unlock with Touch/Face ID.
|
||||
var numberOfBiometricAttempts: AnyPublisher<Int, Never> { get }
|
||||
}
|
||||
|
||||
// sourcery: AutoMockable
|
||||
@@ -73,7 +84,6 @@ extension AppLockServiceMock {
|
||||
mock.isEnabled = pinCode != nil
|
||||
mock.isMandatory = isMandatory
|
||||
mock.numberOfPINAttempts = PassthroughSubject<Int, Never>().eraseToAnyPublisher()
|
||||
mock.numberOfBiometricAttempts = PassthroughSubject<Int, Never>().eraseToAnyPublisher()
|
||||
mock.underlyingBiometryType = biometryType
|
||||
mock.underlyingBiometricUnlockEnabled = biometryType != .none
|
||||
mock.unlockWithClosure = { $0 == pinCode }
|
||||
|
||||
@@ -31,7 +31,7 @@ class AppLockTimer {
|
||||
/// Creates a new timer.
|
||||
/// - Parameter gracePeriod: The amount of time the app should remain unlocked for whilst backgrounded.
|
||||
init(gracePeriod: TimeInterval) {
|
||||
self.gracePeriod = 180
|
||||
self.gracePeriod = gracePeriod
|
||||
}
|
||||
|
||||
/// Signals to the timer to track how long the app will be backgrounded for.
|
||||
|
||||
Reference in New Issue
Block a user