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:
Doug
2023-10-27 10:09:12 +01:00
committed by GitHub
parent 2eb8945824
commit 11563c6297
27 changed files with 598 additions and 92 deletions
@@ -43,6 +43,7 @@ class AppLockScreenViewModelTests: XCTestCase {
// Given a valid PIN code.
let pinCode = "2023"
keychainController.pinCodeReturnValue = pinCode
keychainController.containsPINCodeBiometricStateReturnValue = false
// When entering it on the lock screen.
let deferred = deferFulfillment(viewModel.actions) { $0 == .appUnlocked }
@@ -69,6 +70,7 @@ class AppLockScreenViewModelTests: XCTestCase {
// Given an invalid PIN code.
let pinCode = "2024"
keychainController.pinCodeReturnValue = "2023"
keychainController.containsPINCodeBiometricStateReturnValue = false
XCTAssertEqual(context.viewState.numberOfPINAttempts, 0, "The shouldn't be any attempts yet.")
XCTAssertFalse(context.viewState.isSubtitleWarning, "No warning should be shown yet.")
XCTAssertNil(context.alertInfo, "No alert should be shown yet.")
@@ -95,6 +97,7 @@ class AppLockScreenViewModelTests: XCTestCase {
func testForceQuitRequiresLogout() {
// Given an app with a PIN set where the user attempted to unlock 3 times.
keychainController.pinCodeReturnValue = "2023"
keychainController.containsPINCodeBiometricStateReturnValue = false
appSettings.appLockNumberOfPINAttempts = 2
XCTAssertNil(context.alertInfo)
viewModel.context.pinCode = "0000"
@@ -14,12 +14,14 @@
// limitations under the License.
//
import LocalAuthentication
import XCTest
@testable import ElementX
@MainActor
class AppLockServiceTests: XCTestCase {
var keychainController: KeychainController!
var appSettings: AppSettings!
var service: AppLockService!
@@ -28,7 +30,8 @@ class AppLockServiceTests: XCTestCase {
appSettings = AppSettings()
appSettings.appLockFlowEnabled = true
let keychainController = KeychainController(service: .tests, accessGroup: InfoPlistReader.main.keychainAccessGroupIdentifier)
keychainController = KeychainController(service: .tests, accessGroup: InfoPlistReader.main.keychainAccessGroupIdentifier)
keychainController.resetSecrets()
service = AppLockService(keychainController: keychainController, appSettings: appSettings)
service.disable()
@@ -38,6 +41,8 @@ class AppLockServiceTests: XCTestCase {
AppSettings.reset()
}
// MARK: - PIN Code
func testValidPINCode() {
// Given a service that hasn't been enabled.
XCTAssertFalse(service.isEnabled, "The service shouldn't be enabled to begin with.")
@@ -176,6 +181,137 @@ class AppLockServiceTests: XCTestCase {
XCTAssertFalse(service.unlock(with: pinCode), "The initial PIN shouldn't work any more.")
}
// MARK: - Biometric Unlock
func testEnableBiometricUnlock() async {
// Given a service with the PIN code already set.
let context = LAContextMock()
context.biometryTypeValue = .touchID
context.evaluatedPolicyDomainStateValue = "👆".data(using: .utf8)
service = AppLockService(keychainController: keychainController, appSettings: appSettings, context: context)
guard case .success = service.setupPINCode("2023") else {
XCTFail("The PIN should be valid.")
return
}
XCTAssertTrue(service.isEnabled, "The service should be enabled.")
XCTAssertEqual(service.biometryType, .touchID, "The biometry type should be in sync with the mock.")
XCTAssertFalse(service.biometricUnlockEnabled, "Biometric unlock should not be enabled.")
XCTAssertFalse(service.biometricUnlockTrusted, "Biometric unlock should not be trusted.")
// When enabling biometric unlock.
guard case .success = service.enableBiometricUnlock() else {
XCTFail("The biometric lock should enable.")
return
}
context.evaluatePolicyReturnValue = true
// Then the service should be unlockable with biometrics.
XCTAssertEqual(service.biometryType, .touchID, "The biometry type should not change.")
XCTAssertTrue(service.biometricUnlockEnabled, "Biometric unlock should now be enabled.")
XCTAssertTrue(service.biometricUnlockTrusted, "Biometric unlock should now be trusted.")
guard await service.unlockWithBiometrics() else {
XCTFail("The biometric unlock should work.")
return
}
}
func testBiometricUnlockTrust() {
// Given a service with the PIN code already set.
let context = LAContextMock()
context.biometryTypeValue = .touchID
context.evaluatedPolicyDomainStateValue = "👆".data(using: .utf8)
service = AppLockService(keychainController: keychainController, appSettings: appSettings, context: context)
let pinCode = "2023"
guard case .success = service.setupPINCode(pinCode) else {
XCTFail("The PIN should be valid.")
return
}
guard case .success = service.enableBiometricUnlock() else {
XCTFail("The biometric lock should enable.")
return
}
XCTAssertTrue(service.isEnabled, "The service should be enabled.")
XCTAssertEqual(service.biometryType, .touchID, "The biometry type should be in sync with the mock.")
XCTAssertTrue(service.biometricUnlockEnabled, "Biometric unlock should be enabled.")
XCTAssertTrue(service.biometricUnlockTrusted, "Biometric unlock should be trusted.")
// When the user changes biometric data.
context.evaluatedPolicyDomainStateValue = "👈".data(using: .utf8)
// Then biometric lock should remain enabled but untrusted.
XCTAssertTrue(service.isEnabled, "The service should remain enabled.")
XCTAssertEqual(service.biometryType, .touchID, "The biometry type should not change.")
XCTAssertTrue(service.biometricUnlockEnabled, "Biometric unlock should remain enabled.")
XCTAssertFalse(service.biometricUnlockTrusted, "Biometric unlock should no longer be trusted.")
// When the user confirms their PIN code.
XCTAssertTrue(service.unlock(with: pinCode), "The PIN code should be accepted")
// Then the biometric lock should once again be trusted.
XCTAssertTrue(service.isEnabled, "The service should remain enabled.")
XCTAssertEqual(service.biometryType, .touchID, "The biometry type should not change.")
XCTAssertTrue(service.biometricUnlockEnabled, "Biometric unlock should remain enabled.")
XCTAssertTrue(service.biometricUnlockTrusted, "Biometric unlock should once again be trusted.")
}
func testDisableBiometricUnlock() {
// Given a service with the PIN code already set.
let context = LAContextMock()
context.biometryTypeValue = .touchID
context.evaluatedPolicyDomainStateValue = "👆".data(using: .utf8)
service = AppLockService(keychainController: keychainController, appSettings: appSettings, context: context)
guard case .success = service.setupPINCode("2023") else {
XCTFail("The PIN should be valid.")
return
}
guard case .success = service.enableBiometricUnlock() else {
XCTFail("The biometric lock should enable.")
return
}
XCTAssertTrue(service.isEnabled, "The service should be enabled.")
XCTAssertEqual(service.biometryType, .touchID, "The biometry type should be in sync with the mock.")
XCTAssertTrue(service.biometricUnlockEnabled, "Biometric unlock should be enabled.")
XCTAssertTrue(service.biometricUnlockTrusted, "Biometric unlock should be trusted.")
// When disabling biometric unlock.
service.disableBiometricUnlock()
// Then only PIN unlock should remain enabled.
XCTAssertTrue(service.isEnabled, "The service should remain enabled.")
XCTAssertEqual(service.biometryType, .touchID, "The biometry type should not change.")
XCTAssertFalse(service.biometricUnlockEnabled, "Biometric unlock should become disabled.")
XCTAssertFalse(service.biometricUnlockTrusted, "Biometric unlock should no longer be trusted.")
}
func testDisablePINWithBiometricUnlock() {
// Given a service with the PIN code already set.
let context = LAContextMock()
context.biometryTypeValue = .touchID
context.evaluatedPolicyDomainStateValue = "👆".data(using: .utf8)
service = AppLockService(keychainController: keychainController, appSettings: appSettings, context: context)
guard case .success = service.setupPINCode("2023") else {
XCTFail("The PIN should be valid.")
return
}
guard case .success = service.enableBiometricUnlock() else {
XCTFail("The biometric lock should enable.")
return
}
XCTAssertTrue(service.isEnabled, "The service should be enabled.")
XCTAssertTrue(service.biometricUnlockEnabled, "Biometric unlock should be enabled.")
XCTAssertTrue(service.biometricUnlockTrusted, "Biometric unlock should be trusted.")
// When disabling the PIN lock.
service.disable()
// Then both PIN and biometric unlock should be disabled.
XCTAssertFalse(service.isEnabled, "The service should remain enabled.")
XCTAssertFalse(service.biometricUnlockEnabled, "Biometric unlock should become disabled.")
XCTAssertFalse(service.biometricUnlockTrusted, "Biometric unlock should no longer be trusted.")
}
// MARK: - Attempt failures
func testResetAttemptsOnUnlock() {
// Given a service that is enabled and has failed unlock attempts.
let pinCode = "2023"
@@ -184,9 +320,7 @@ class AppLockServiceTests: XCTestCase {
return
}
appSettings.appLockNumberOfPINAttempts = 2
appSettings.appLockNumberOfBiometricAttempts = 2
XCTAssertEqual(appSettings.appLockNumberOfPINAttempts, 2, "The initial conditions should be stored.")
XCTAssertEqual(appSettings.appLockNumberOfBiometricAttempts, 2, "The initial conditions should be stored.")
XCTAssertTrue(service.isEnabled, "The service should be enabled.")
// When unlocking the service
@@ -194,7 +328,6 @@ class AppLockServiceTests: XCTestCase {
// Then the attempts counts should both be reset.
XCTAssertEqual(appSettings.appLockNumberOfPINAttempts, 0, "The PIN attempts should be reset.")
XCTAssertEqual(appSettings.appLockNumberOfBiometricAttempts, 0, "The biometric attempts should be reset.")
}
func testResetAttemptsOnDisable() {
@@ -205,9 +338,7 @@ class AppLockServiceTests: XCTestCase {
return
}
appSettings.appLockNumberOfPINAttempts = 2
appSettings.appLockNumberOfBiometricAttempts = 2
XCTAssertEqual(appSettings.appLockNumberOfPINAttempts, 2, "The initial conditions should be stored.")
XCTAssertEqual(appSettings.appLockNumberOfBiometricAttempts, 2, "The initial conditions should be stored.")
XCTAssertTrue(service.isEnabled, "The service should be enabled.")
// When disabling the service
@@ -216,6 +347,38 @@ class AppLockServiceTests: XCTestCase {
// Then the attempts counts should both be reset.
XCTAssertEqual(appSettings.appLockNumberOfPINAttempts, 0, "The PIN attempts should be reset.")
XCTAssertEqual(appSettings.appLockNumberOfBiometricAttempts, 0, "The biometric attempts should be reset.")
}
}
// MARK: - Mocks
/// A customised context that allows injecting a few mock values but otherwise behaves as expected.
/// It works as the actual context does and won't update the return values of `biometryType` and
/// `evaluatedPolicyDomainStateValue` until either `canEvaluatePolicy` or
/// `evaluatePolicy` have been called.
private class LAContextMock: LAContext {
var biometryTypeValue: LABiometryType!
private var internalBiometryTypeValue: LABiometryType!
override var biometryType: LABiometryType { internalBiometryTypeValue }
var evaluatedPolicyDomainStateValue: Data?
private var internalEvaluatedPolicyDomainStateValue: Data?
override var evaluatedPolicyDomainState: Data? { internalEvaluatedPolicyDomainStateValue }
override func canEvaluatePolicy(_ policy: LAPolicy, error: NSErrorPointer) -> Bool {
let result = super.canEvaluatePolicy(policy, error: error)
updateInternalValues()
return result
}
var evaluatePolicyReturnValue: Bool!
override func evaluatePolicy(_ policy: LAPolicy, localizedReason: String) async throws -> Bool {
updateInternalValues()
return evaluatePolicyReturnValue
}
private func updateInternalValues() {
internalBiometryTypeValue = biometryTypeValue
internalEvaluatedPolicyDomainStateValue = evaluatedPolicyDomainStateValue
}
}
@@ -20,16 +20,18 @@ import XCTest
@MainActor
class AppLockSetupBiometricsScreenViewModelTests: XCTestCase {
var appLockService: AppLockService!
var keychainController: KeychainControllerMock!
var appLockService: AppLockServiceMock!
var viewModel: AppLockSetupBiometricsScreenViewModelProtocol!
var context: AppLockSetupBiometricsScreenViewModelType.Context { viewModel.context }
override func setUp() {
AppSettings.reset()
keychainController = KeychainControllerMock()
appLockService = AppLockService(keychainController: keychainController, appSettings: AppSettings())
appLockService = AppLockServiceMock()
appLockService.underlyingIsEnabled = true
appLockService.underlyingBiometryType = .touchID
appLockService.enableBiometricUnlockReturnValue = .success(())
viewModel = AppLockSetupBiometricsScreenViewModel(appLockService: appLockService)
}
@@ -38,24 +40,22 @@ class AppLockSetupBiometricsScreenViewModelTests: XCTestCase {
}
func testAllow() async throws {
// Given a service that has biometric unlock disabled.
XCTAssertFalse(appLockService.biometricUnlockEnabled)
// When allowing Touch/Face ID.
let deferred = deferFulfillment(viewModel.actions) { $0 == .continue }
context.send(viewAction: .allow)
try await deferred.fulfill()
// Then the service should now have biometric unlock enabled.
XCTAssertTrue(appLockService.biometricUnlockEnabled)
XCTAssertEqual(appLockService.enableBiometricUnlockCallsCount, 1)
}
func testSkip() async throws {
// Given a service that has biometric unlock disabled.
XCTAssertFalse(appLockService.biometricUnlockEnabled)
// When skipping biometrics.
let deferred = deferFulfillment(viewModel.actions) { $0 == .continue }
context.send(viewAction: .skip)
try await deferred.fulfill()
// Then the service should now have biometric unlock enabled.
XCTAssertFalse(appLockService.biometricUnlockEnabled)
XCTAssertEqual(appLockService.enableBiometricUnlockCallsCount, 0)
}
}
@@ -87,6 +87,7 @@ class AppLockSetupPINScreenViewModelTests: XCTestCase {
let pinCode = "2023"
keychainController.pinCodeReturnValue = pinCode
keychainController.containsPINCodeReturnValue = true
keychainController.containsPINCodeBiometricStateReturnValue = false
let deferred = deferFulfillment(viewModel.actions, message: "The screen should be finished.") { $0 == .complete }
context.pinCode = pinCode