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:
@@ -291,7 +291,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationCoordinatorDelegate,
|
||||
if oldVersion < Version(1, 1, 0) {
|
||||
MXLog.info("Migrating to v1.1.0, signing out the user.")
|
||||
// Version 1.1.0 switched the Rust crypto store to SQLite
|
||||
// There are no migrations in place so we need to reset everything
|
||||
// There are no migrations in place so we need to sign the user out
|
||||
wipeUserData()
|
||||
}
|
||||
|
||||
@@ -308,6 +308,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationCoordinatorDelegate,
|
||||
private func wipeUserData(includingSettings: Bool = false) {
|
||||
if includingSettings {
|
||||
AppSettings.reset()
|
||||
appLockFlowCoordinator.appLockService.disable()
|
||||
}
|
||||
userSessionStore.reset()
|
||||
}
|
||||
|
||||
@@ -133,9 +133,6 @@ final class AppSettings {
|
||||
/// The number of attempts the user has made to unlock the app with a PIN code (resets when unlocked).
|
||||
@UserPreference(key: UserDefaultsKeys.appLockNumberOfPINAttempts, defaultValue: 0, storageType: .userDefaults(store))
|
||||
var appLockNumberOfPINAttempts: Int
|
||||
/// The number of attempts the user has made to unlock the app with Touch/Face ID (resets when unlocked).
|
||||
@UserPreference(key: UserDefaultsKeys.appLockNumberOfBiometricAttempts, defaultValue: 0, storageType: .userDefaults(store))
|
||||
var appLockNumberOfBiometricAttempts: Int
|
||||
|
||||
// MARK: - Authentication
|
||||
|
||||
|
||||
@@ -32,6 +32,8 @@ class AppLockFlowCoordinator: CoordinatorProtocol {
|
||||
let userIndicatorController: UserIndicatorController
|
||||
let navigationCoordinator: NavigationRootCoordinator
|
||||
|
||||
/// A task used to await biometric unlock before showing the PIN screen.
|
||||
@CancellableTask private var unlockTask: Task<Void, Never>?
|
||||
private var cancellables: Set<AnyCancellable> = []
|
||||
|
||||
private let actionsSubject: PassthroughSubject<AppLockFlowCoordinatorAction, Never> = .init()
|
||||
@@ -44,6 +46,9 @@ class AppLockFlowCoordinator: CoordinatorProtocol {
|
||||
self.userIndicatorController = userIndicatorController
|
||||
self.navigationCoordinator = navigationCoordinator
|
||||
|
||||
// Set the initial background state.
|
||||
showPlaceholder()
|
||||
|
||||
appLockService.disabledPublisher
|
||||
.sink { [weak self] _ in
|
||||
// When the service is disabled via a force logout, we need to remove the activity indicator.
|
||||
@@ -71,6 +76,8 @@ class AppLockFlowCoordinator: CoordinatorProtocol {
|
||||
// MARK: - App unlock
|
||||
|
||||
private func applicationDidEnterBackground() {
|
||||
unlockTask = nil
|
||||
|
||||
guard appLockService.isEnabled else { return }
|
||||
|
||||
appLockService.applicationDidEnterBackground()
|
||||
@@ -80,12 +87,33 @@ class AppLockFlowCoordinator: CoordinatorProtocol {
|
||||
private func applicationWillEnterForeground() {
|
||||
guard appLockService.isEnabled else { return }
|
||||
|
||||
if appLockService.computeNeedsUnlock(willEnterForegroundAt: .now) {
|
||||
showUnlockScreen()
|
||||
} else {
|
||||
guard appLockService.computeNeedsUnlock(willEnterForegroundAt: .now) else {
|
||||
// Reveal the app again if within the grace period.
|
||||
actionsSubject.send(.unlockApp)
|
||||
return
|
||||
}
|
||||
|
||||
// Show the relevant unlock mechanism.
|
||||
unlockTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
await startUnlockFlow()
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs the unlock flow, showing Touch ID/Face ID if available, transitioning to PIN unlock if it fails or isn't available.
|
||||
private func startUnlockFlow() async {
|
||||
if appLockService.biometricUnlockEnabled, appLockService.biometricUnlockTrusted {
|
||||
showPlaceholder() // For the unlock background.
|
||||
|
||||
if await appLockService.unlockWithBiometrics() {
|
||||
actionsSubject.send(.unlockApp)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
guard !Task.isCancelled else { return }
|
||||
|
||||
showUnlockScreen()
|
||||
}
|
||||
|
||||
/// Displays the unlock flow with the app's placeholder view to hide obscure the view hierarchy in the app switcher.
|
||||
|
||||
@@ -140,6 +140,9 @@ class AppLockSetupFlowCoordinator: FlowCoordinatorProtocol {
|
||||
showBiometricsPrompt()
|
||||
case (.createPIN, .settings):
|
||||
navigationStackCoordinator.setSheetCoordinator(nil)
|
||||
// The above is fine for change pin, but not create PIN when biometrics are unavailable.
|
||||
// Need to track the two flows differently to call the alternative below.
|
||||
// showSettings()
|
||||
case (.biometricsPrompt, .settings):
|
||||
showSettings()
|
||||
case (.settings, .createPIN):
|
||||
|
||||
@@ -82,6 +82,8 @@ public enum L10n {
|
||||
public static var actionEnable: String { return L10n.tr("Localizable", "action_enable") }
|
||||
/// End poll
|
||||
public static var actionEndPoll: String { return L10n.tr("Localizable", "action_end_poll") }
|
||||
/// Enter PIN
|
||||
public static var actionEnterPin: String { return L10n.tr("Localizable", "action_enter_pin") }
|
||||
/// Forgot password?
|
||||
public static var actionForgotPassword: String { return L10n.tr("Localizable", "action_forgot_password") }
|
||||
/// Forward
|
||||
@@ -676,6 +678,8 @@ public enum L10n {
|
||||
public static var screenAppLockBiometricAuthentication: String { return L10n.tr("Localizable", "screen_app_lock_biometric_authentication") }
|
||||
/// biometric unlock
|
||||
public static var screenAppLockBiometricUnlock: String { return L10n.tr("Localizable", "screen_app_lock_biometric_unlock") }
|
||||
/// Authentication is needed to access your app
|
||||
public static var screenAppLockBiometricUnlockReasonIos: String { return L10n.tr("Localizable", "screen_app_lock_biometric_unlock_reason_ios") }
|
||||
/// Forgot PIN?
|
||||
public static var screenAppLockForgotPin: String { return L10n.tr("Localizable", "screen_app_lock_forgot_pin") }
|
||||
/// Change PIN code
|
||||
|
||||
@@ -137,6 +137,11 @@ class AppLockServiceMock: AppLockServiceProtocol {
|
||||
set(value) { underlyingBiometricUnlockEnabled = value }
|
||||
}
|
||||
var underlyingBiometricUnlockEnabled: Bool!
|
||||
var biometricUnlockTrusted: Bool {
|
||||
get { return underlyingBiometricUnlockTrusted }
|
||||
set(value) { underlyingBiometricUnlockTrusted = value }
|
||||
}
|
||||
var underlyingBiometricUnlockTrusted: Bool!
|
||||
var disabledPublisher: AnyPublisher<Void, Never> {
|
||||
get { return underlyingDisabledPublisher }
|
||||
set(value) { underlyingDisabledPublisher = value }
|
||||
@@ -147,11 +152,6 @@ class AppLockServiceMock: AppLockServiceProtocol {
|
||||
set(value) { underlyingNumberOfPINAttempts = value }
|
||||
}
|
||||
var underlyingNumberOfPINAttempts: AnyPublisher<Int, Never>!
|
||||
var numberOfBiometricAttempts: AnyPublisher<Int, Never> {
|
||||
get { return underlyingNumberOfBiometricAttempts }
|
||||
set(value) { underlyingNumberOfBiometricAttempts = value }
|
||||
}
|
||||
var underlyingNumberOfBiometricAttempts: AnyPublisher<Int, Never>!
|
||||
|
||||
//MARK: - setupPINCode
|
||||
|
||||
@@ -195,6 +195,35 @@ class AppLockServiceMock: AppLockServiceProtocol {
|
||||
return validateReturnValue
|
||||
}
|
||||
}
|
||||
//MARK: - enableBiometricUnlock
|
||||
|
||||
var enableBiometricUnlockCallsCount = 0
|
||||
var enableBiometricUnlockCalled: Bool {
|
||||
return enableBiometricUnlockCallsCount > 0
|
||||
}
|
||||
var enableBiometricUnlockReturnValue: Result<Void, AppLockServiceError>!
|
||||
var enableBiometricUnlockClosure: (() -> Result<Void, AppLockServiceError>)?
|
||||
|
||||
func enableBiometricUnlock() -> Result<Void, AppLockServiceError> {
|
||||
enableBiometricUnlockCallsCount += 1
|
||||
if let enableBiometricUnlockClosure = enableBiometricUnlockClosure {
|
||||
return enableBiometricUnlockClosure()
|
||||
} else {
|
||||
return enableBiometricUnlockReturnValue
|
||||
}
|
||||
}
|
||||
//MARK: - disableBiometricUnlock
|
||||
|
||||
var disableBiometricUnlockCallsCount = 0
|
||||
var disableBiometricUnlockCalled: Bool {
|
||||
return disableBiometricUnlockCallsCount > 0
|
||||
}
|
||||
var disableBiometricUnlockClosure: (() -> Void)?
|
||||
|
||||
func disableBiometricUnlock() {
|
||||
disableBiometricUnlockCallsCount += 1
|
||||
disableBiometricUnlockClosure?()
|
||||
}
|
||||
//MARK: - disable
|
||||
|
||||
var disableCallsCount = 0
|
||||
@@ -268,12 +297,12 @@ class AppLockServiceMock: AppLockServiceProtocol {
|
||||
return unlockWithBiometricsCallsCount > 0
|
||||
}
|
||||
var unlockWithBiometricsReturnValue: Bool!
|
||||
var unlockWithBiometricsClosure: (() -> Bool)?
|
||||
var unlockWithBiometricsClosure: (() async -> Bool)?
|
||||
|
||||
func unlockWithBiometrics() -> Bool {
|
||||
func unlockWithBiometrics() async -> Bool {
|
||||
unlockWithBiometricsCallsCount += 1
|
||||
if let unlockWithBiometricsClosure = unlockWithBiometricsClosure {
|
||||
return unlockWithBiometricsClosure()
|
||||
return await unlockWithBiometricsClosure()
|
||||
} else {
|
||||
return unlockWithBiometricsReturnValue
|
||||
}
|
||||
@@ -906,6 +935,72 @@ class KeychainControllerMock: KeychainControllerProtocol {
|
||||
removePINCodeCallsCount += 1
|
||||
removePINCodeClosure?()
|
||||
}
|
||||
//MARK: - containsPINCodeBiometricState
|
||||
|
||||
var containsPINCodeBiometricStateCallsCount = 0
|
||||
var containsPINCodeBiometricStateCalled: Bool {
|
||||
return containsPINCodeBiometricStateCallsCount > 0
|
||||
}
|
||||
var containsPINCodeBiometricStateReturnValue: Bool!
|
||||
var containsPINCodeBiometricStateClosure: (() -> Bool)?
|
||||
|
||||
func containsPINCodeBiometricState() -> Bool {
|
||||
containsPINCodeBiometricStateCallsCount += 1
|
||||
if let containsPINCodeBiometricStateClosure = containsPINCodeBiometricStateClosure {
|
||||
return containsPINCodeBiometricStateClosure()
|
||||
} else {
|
||||
return containsPINCodeBiometricStateReturnValue
|
||||
}
|
||||
}
|
||||
//MARK: - setPINCodeBiometricState
|
||||
|
||||
var setPINCodeBiometricStateThrowableError: Error?
|
||||
var setPINCodeBiometricStateCallsCount = 0
|
||||
var setPINCodeBiometricStateCalled: Bool {
|
||||
return setPINCodeBiometricStateCallsCount > 0
|
||||
}
|
||||
var setPINCodeBiometricStateReceivedState: Data?
|
||||
var setPINCodeBiometricStateReceivedInvocations: [Data] = []
|
||||
var setPINCodeBiometricStateClosure: ((Data) throws -> Void)?
|
||||
|
||||
func setPINCodeBiometricState(_ state: Data) throws {
|
||||
if let error = setPINCodeBiometricStateThrowableError {
|
||||
throw error
|
||||
}
|
||||
setPINCodeBiometricStateCallsCount += 1
|
||||
setPINCodeBiometricStateReceivedState = state
|
||||
setPINCodeBiometricStateReceivedInvocations.append(state)
|
||||
try setPINCodeBiometricStateClosure?(state)
|
||||
}
|
||||
//MARK: - pinCodeBiometricState
|
||||
|
||||
var pinCodeBiometricStateCallsCount = 0
|
||||
var pinCodeBiometricStateCalled: Bool {
|
||||
return pinCodeBiometricStateCallsCount > 0
|
||||
}
|
||||
var pinCodeBiometricStateReturnValue: Data?
|
||||
var pinCodeBiometricStateClosure: (() -> Data?)?
|
||||
|
||||
func pinCodeBiometricState() -> Data? {
|
||||
pinCodeBiometricStateCallsCount += 1
|
||||
if let pinCodeBiometricStateClosure = pinCodeBiometricStateClosure {
|
||||
return pinCodeBiometricStateClosure()
|
||||
} else {
|
||||
return pinCodeBiometricStateReturnValue
|
||||
}
|
||||
}
|
||||
//MARK: - removePINCodeBiometricState
|
||||
|
||||
var removePINCodeBiometricStateCallsCount = 0
|
||||
var removePINCodeBiometricStateCalled: Bool {
|
||||
return removePINCodeBiometricStateCallsCount > 0
|
||||
}
|
||||
var removePINCodeBiometricStateClosure: (() -> Void)?
|
||||
|
||||
func removePINCodeBiometricState() {
|
||||
removePINCodeBiometricStateCallsCount += 1
|
||||
removePINCodeBiometricStateClosure?()
|
||||
}
|
||||
}
|
||||
class MediaPlayerMock: MediaPlayerProtocol {
|
||||
var mediaSource: MediaSourceProxy?
|
||||
|
||||
@@ -46,11 +46,7 @@ class AppLockScreenViewModel: AppLockScreenViewModelType, AppLockScreenViewModel
|
||||
|
||||
switch viewAction {
|
||||
case .submitPINCode:
|
||||
guard appLockService.unlock(with: state.bindings.pinCode) else {
|
||||
handleInvalidPIN()
|
||||
return
|
||||
}
|
||||
actionsSubject.send(.appUnlocked)
|
||||
submitPINCode()
|
||||
case .clearPINCode:
|
||||
state.bindings.pinCode = ""
|
||||
case .forgotPIN:
|
||||
@@ -60,6 +56,14 @@ class AppLockScreenViewModel: AppLockScreenViewModelType, AppLockScreenViewModel
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func submitPINCode() {
|
||||
guard appLockService.unlock(with: state.bindings.pinCode) else {
|
||||
handleInvalidPIN()
|
||||
return
|
||||
}
|
||||
actionsSubject.send(.appUnlocked)
|
||||
}
|
||||
|
||||
private func handleForgotPIN() {
|
||||
state.bindings.alertInfo = .init(id: .confirmResetPIN,
|
||||
title: L10n.screenAppLockSignoutAlertTitle,
|
||||
|
||||
+27
-6
@@ -39,13 +39,34 @@ class AppLockSetupBiometricsScreenViewModel: AppLockSetupBiometricsScreenViewMod
|
||||
|
||||
switch viewAction {
|
||||
case .allow:
|
||||
MXLog.info("Enable biometric unlock.")
|
||||
appLockService.biometricUnlockEnabled = true
|
||||
actionsSubject.send(.continue)
|
||||
Task { await enableBiometricUnlock() }
|
||||
case .skip:
|
||||
MXLog.info("Disable biometric unlock.")
|
||||
appLockService.biometricUnlockEnabled = false
|
||||
actionsSubject.send(.continue)
|
||||
disableBiometricUnlock()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func enableBiometricUnlock() async {
|
||||
guard case .success = appLockService.enableBiometricUnlock() else {
|
||||
MXLog.error("Enabling biometric unlock failed.")
|
||||
return
|
||||
}
|
||||
MXLog.info("Biometric unlock enabled.")
|
||||
|
||||
// Attempt unlock to trigger Face ID permissions alert.
|
||||
if appLockService.biometryType == .faceID,
|
||||
await !appLockService.unlockWithBiometrics() {
|
||||
MXLog.info("Confirmation failed. Disabling biometric unlock.")
|
||||
appLockService.disableBiometricUnlock()
|
||||
}
|
||||
|
||||
actionsSubject.send(.continue)
|
||||
}
|
||||
|
||||
private func disableBiometricUnlock() {
|
||||
appLockService.disableBiometricUnlock()
|
||||
MXLog.info("Biometric unlock disabled.")
|
||||
actionsSubject.send(.continue)
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -30,8 +30,8 @@ struct AppLockSetupSettingsScreenViewState: BindableState {
|
||||
let biometryType: LABiometryType
|
||||
var bindings: AppLockSetupSettingsScreenViewStateBindings
|
||||
|
||||
var supportsBiometry: Bool { biometryType != .none }
|
||||
var enableBiometryTitle: String { L10n.screenAppLockSetupBiometricUnlockAllowTitle(biometryType.localizedString) }
|
||||
var supportsBiometrics: Bool { biometryType != .none }
|
||||
var enableBiometricsTitle: String { L10n.screenAppLockSetupBiometricUnlockAllowTitle(biometryType.localizedString) }
|
||||
}
|
||||
|
||||
struct AppLockSetupSettingsScreenViewStateBindings {
|
||||
|
||||
+23
-1
@@ -45,12 +45,34 @@ class AppLockSetupSettingsScreenViewModel: AppLockSetupSettingsScreenViewModelTy
|
||||
case .disable:
|
||||
showRemovePINAlert()
|
||||
case .enableBiometricsChanged:
|
||||
appLockService.biometricUnlockEnabled = state.bindings.enableBiometrics
|
||||
Task { await toggleBiometrics() }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func toggleBiometrics() async {
|
||||
if state.bindings.enableBiometrics {
|
||||
guard case .success = appLockService.enableBiometricUnlock() else {
|
||||
MXLog.error("Enabling biometric unlock failed.")
|
||||
state.bindings.enableBiometrics = false
|
||||
return
|
||||
}
|
||||
MXLog.info("Biometric unlock enabled.")
|
||||
|
||||
// Attempt unlock to trigger Face ID permissions alert.
|
||||
if appLockService.biometryType == .faceID,
|
||||
await !appLockService.unlockWithBiometrics() {
|
||||
MXLog.info("Confirmation failed. Disabling biometric unlock.")
|
||||
state.bindings.enableBiometrics = false
|
||||
appLockService.disableBiometricUnlock()
|
||||
}
|
||||
} else {
|
||||
appLockService.disableBiometricUnlock()
|
||||
MXLog.info("Biometric unlock disabled.")
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows a confirmation alert to the user before removing their PIN code.
|
||||
private func showRemovePINAlert() {
|
||||
state.bindings.alertInfo = .init(id: .confirmRemovePINCode,
|
||||
|
||||
+2
-2
@@ -32,9 +32,9 @@ struct AppLockSetupSettingsScreen: View {
|
||||
}
|
||||
}
|
||||
|
||||
if context.viewState.supportsBiometry {
|
||||
if context.viewState.supportsBiometrics {
|
||||
Section {
|
||||
ListRow(label: .plain(title: context.viewState.enableBiometryTitle),
|
||||
ListRow(label: .plain(title: context.viewState.enableBiometricsTitle),
|
||||
kind: .toggle($context.enableBiometrics))
|
||||
.onChange(of: context.enableBiometrics) { _ in
|
||||
context.send(viewAction: .enableBiometricsChanged)
|
||||
|
||||
@@ -22,7 +22,7 @@ extension LABiometryType {
|
||||
var systemSymbol: SFSymbol {
|
||||
switch self {
|
||||
case .none:
|
||||
MXLog.error("Invalid presentation: Biometry not supported.")
|
||||
MXLog.error("Invalid presentation: Biometrics not supported.")
|
||||
return .viewfinder
|
||||
case .touchID:
|
||||
return .touchid
|
||||
@@ -40,7 +40,7 @@ extension LABiometryType {
|
||||
var localizedString: String {
|
||||
switch self {
|
||||
case .none:
|
||||
MXLog.error("Invalid presentation: Biometry not supported.")
|
||||
MXLog.error("Invalid presentation: Biometrics not supported.")
|
||||
return L10n.screenAppLockBiometricUnlock
|
||||
case .touchID:
|
||||
return L10n.commonTouchIdIos
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -38,7 +38,8 @@ class KeychainController: KeychainControllerProtocol {
|
||||
private let mainKeychain: Keychain
|
||||
|
||||
private enum Key: String {
|
||||
case pinCode
|
||||
case appLockPINCode
|
||||
case appLockBiometricState
|
||||
}
|
||||
|
||||
init(service: KeychainControllerService, accessGroup: String) {
|
||||
@@ -129,16 +130,16 @@ class KeychainController: KeychainControllerProtocol {
|
||||
}
|
||||
|
||||
func containsPINCode() throws -> Bool {
|
||||
try mainKeychain.contains(Key.pinCode.rawValue)
|
||||
try mainKeychain.contains(Key.appLockPINCode.rawValue)
|
||||
}
|
||||
|
||||
func setPINCode(_ pinCode: String) throws {
|
||||
try mainKeychain.set(pinCode, key: Key.pinCode.rawValue)
|
||||
try mainKeychain.set(pinCode, key: Key.appLockPINCode.rawValue)
|
||||
}
|
||||
|
||||
func pinCode() -> String? {
|
||||
do {
|
||||
return try mainKeychain.getString(Key.pinCode.rawValue)
|
||||
return try mainKeychain.getString(Key.appLockPINCode.rawValue)
|
||||
} catch {
|
||||
MXLog.error("Failed retrieving the PIN code.")
|
||||
return nil
|
||||
@@ -147,9 +148,39 @@ class KeychainController: KeychainControllerProtocol {
|
||||
|
||||
func removePINCode() {
|
||||
do {
|
||||
try mainKeychain.remove(Key.pinCode.rawValue)
|
||||
try mainKeychain.remove(Key.appLockPINCode.rawValue)
|
||||
} catch {
|
||||
MXLog.error("Failed removing the PIN code.")
|
||||
}
|
||||
}
|
||||
|
||||
func containsPINCodeBiometricState() -> Bool {
|
||||
do {
|
||||
return try mainKeychain.contains(Key.appLockBiometricState.rawValue)
|
||||
} catch {
|
||||
MXLog.error("Failed checking for biometric state.")
|
||||
return false // No need to re-throw the error, we can fall back to the PIN code.
|
||||
}
|
||||
}
|
||||
|
||||
func setPINCodeBiometricState(_ state: Data) throws {
|
||||
try mainKeychain.set(state, key: Key.appLockBiometricState.rawValue)
|
||||
}
|
||||
|
||||
func pinCodeBiometricState() -> Data? {
|
||||
do {
|
||||
return try mainKeychain.getData(Key.appLockBiometricState.rawValue)
|
||||
} catch {
|
||||
MXLog.error("Failed setting the PIN code biometric state.")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func removePINCodeBiometricState() {
|
||||
do {
|
||||
try mainKeychain.remove(Key.appLockBiometricState.rawValue)
|
||||
} catch {
|
||||
MXLog.error("Failed removing the PIN code biometric state.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,4 +44,12 @@ protocol KeychainControllerProtocol: ClientSessionDelegate {
|
||||
func pinCode() -> String?
|
||||
/// Removes the App Lock PIN code.
|
||||
func removePINCode()
|
||||
/// Whether or not PIN code biometric state has been set.
|
||||
func containsPINCodeBiometricState() -> Bool
|
||||
/// Sets the PIN code biometric state for App Lock.
|
||||
func setPINCodeBiometricState(_ state: Data) throws
|
||||
/// The PIN code biometric state required to use Touch/Face ID to unlock the app.
|
||||
func pinCodeBiometricState() -> Data?
|
||||
/// Removes the App Lock PIN code biometric state.
|
||||
func removePINCodeBiometricState()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user