Skip to content

Commit

Permalink
PM-10271: Set up unlock: allow configuring biometrics (#823)
Browse files Browse the repository at this point in the history
  • Loading branch information
matt-livefront committed Aug 13, 2024
1 parent f4e6b07 commit ee8fd70
Show file tree
Hide file tree
Showing 9 changed files with 175 additions and 63 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ class DefaultBiometricsRepository: BiometricsRepository {
}

try await setUserBiometricAuthKey(value: authKey)
try await configureBiometricIntegrity()
try await stateService.setBiometricAuthenticationEnabled(true)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -408,7 +408,7 @@ final class BiometricsRepositoryTests: BitwardenTestCase { // swiftlint:disable:
XCTAssertFalse(result)
}

/// `setBiometricUnlockKey` can remove a user key from the keychain and track the availbility in state.
/// `setBiometricUnlockKey` can remove a user key from the keychain and track the availability in state.
func test_setBiometricUnlockKey_nilValue_success() async throws {
stateService.activeAccount = .fixture()
try? await stateService.setBiometricAuthenticationEnabled(true)
Expand All @@ -424,6 +424,7 @@ final class BiometricsRepositoryTests: BitwardenTestCase { // swiftlint:disable:
waitFor(keychainService.mockStorage.isEmpty)
let result = try XCTUnwrap(stateService.biometricsEnabled["1"])
XCTAssertFalse(result)
XCTAssertNil(stateService.biometricIntegrityStates["1"])
}

/// `setBiometricUnlockKey` throws on a keychain error.
Expand Down Expand Up @@ -488,4 +489,28 @@ final class BiometricsRepositoryTests: BitwardenTestCase { // swiftlint:disable:
XCTAssertTrue(result)
XCTAssertEqual(keychainService.securityType, .biometryCurrentSet)
}

/// `setBiometricUnlockKey` stores the user key and configures biometric integrity.
func test_setBiometricUnlockKey_withValue_success_configuresBiometricIntegrity() async throws {
biometricsService.biometricIntegrityState = "🔒".data(using: .utf8)
keychainService.setResult = .success(())
stateService.activeAccount = .fixture()
stateService.setBiometricAuthenticationEnabledResult = .success(())

try await subject.setBiometricUnlockKey(authKey: "authKey")

XCTAssertEqual(
keychainService.mockStorage[keychainService.formattedKey(
for: .biometrics(
userId: "1"
)
)],
"authKey"
)
XCTAssertEqual(keychainService.securityType, .biometryCurrentSet)
XCTAssertTrue(try XCTUnwrap(stateService.biometricsEnabled["1"]))

let expectedIntegrityState = try XCTUnwrap("🔒".data(using: .utf8)?.base64EncodedString())
XCTAssertEqual(stateService.biometricIntegrityStates["1"], expectedIntegrityState)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ class MockStateService: StateService { // swiftlint:disable:this type_body_lengt
var appLanguage: LanguageOption = .default
var appTheme: AppTheme?
var biometricsEnabled = [String: Bool]()
var biometricIntegrityStates = [String: String?]()
var biometricIntegrityStates = [String: String]()
var capturedUserId: String?
var clearClipboardValues = [String: ClearClipboardValue]()
var clearClipboardResult: Result<Void, Error> = .success(())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
class VaultUnlockSetupProcessor: StateProcessor<VaultUnlockSetupState, VaultUnlockSetupAction, VaultUnlockSetupEffect> {
// MARK: Types

typealias Services = HasBiometricsRepository
typealias Services = HasAuthRepository
& HasBiometricsRepository
& HasErrorReporter

// MARK: Private Properties
Expand Down Expand Up @@ -53,7 +54,14 @@ class VaultUnlockSetupProcessor: StateProcessor<VaultUnlockSetupState, VaultUnlo
// TODO: PM-10270 Skip unlock setup
break
case let .toggleUnlockMethod(unlockMethod, newValue):
state[keyPath: unlockMethod.keyPath] = newValue
switch unlockMethod {
case .biometrics:
Task {
await toggleBiometricUnlock(enabled: newValue)
}
case .pin:
state.isPinUnlockOn = newValue
}
}
}

Expand All @@ -69,4 +77,18 @@ class VaultUnlockSetupProcessor: StateProcessor<VaultUnlockSetupState, VaultUnlo
coordinator.showAlert(.defaultAlert(title: Localizations.anErrorHasOccurred))
}
}

/// Toggles whether unlock with biometrics is enabled.
///
/// - Parameter enabled: Whether to enable unlock with biometrics.
///
private func toggleBiometricUnlock(enabled: Bool) async {
do {
try await services.authRepository.allowBioMetricUnlock(enabled)
state.biometricsStatus = try await services.biometricsRepository.getBiometricUnlockStatus()
} catch {
services.errorReporter.log(error: error)
coordinator.showAlert(.defaultAlert(title: Localizations.anErrorHasOccurred))
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import XCTest
class VaultUnlockSetupProcessorTests: BitwardenTestCase {
// MARK: Properties

var authRepository: MockAuthRepository!
var biometricsRepository: MockBiometricsRepository!
var coordinator: MockCoordinator<AuthRoute, AuthEvent>!
var errorReporter: MockErrorReporter!
Expand All @@ -15,13 +16,15 @@ class VaultUnlockSetupProcessorTests: BitwardenTestCase {
override func setUp() {
super.setUp()

authRepository = MockAuthRepository()
biometricsRepository = MockBiometricsRepository()
coordinator = MockCoordinator()
errorReporter = MockErrorReporter()

subject = VaultUnlockSetupProcessor(
coordinator: coordinator.asAnyCoordinator(),
services: ServiceContainer.withMocks(
authRepository: authRepository,
biometricsRepository: biometricsRepository,
errorReporter: errorReporter
),
Expand All @@ -32,6 +35,7 @@ class VaultUnlockSetupProcessorTests: BitwardenTestCase {
override func tearDown() {
super.tearDown()

authRepository = nil
biometricsRepository = nil
coordinator = nil
errorReporter = nil
Expand All @@ -49,7 +53,7 @@ class VaultUnlockSetupProcessorTests: BitwardenTestCase {
await subject.perform(.loadData)

XCTAssertEqual(subject.state.biometricsStatus, status)
XCTAssertEqual(subject.state.unlockMethods, [.faceID, .pin])
XCTAssertEqual(subject.state.unlockMethods, [.biometrics(.faceID), .pin])
}

/// `perform(_:)` with `.loadData` logs the error and shows an alert if one occurs.
Expand Down Expand Up @@ -85,7 +89,7 @@ class VaultUnlockSetupProcessorTests: BitwardenTestCase {
await subject.perform(.loadData)

XCTAssertEqual(subject.state.biometricsStatus, status)
XCTAssertEqual(subject.state.unlockMethods, [.touchID, .pin])
XCTAssertEqual(subject.state.unlockMethods, [.biometrics(.touchID), .pin])
}

/// `receive(_:)` with `.continueFlow` navigates to autofill setup.
Expand All @@ -102,13 +106,70 @@ class VaultUnlockSetupProcessorTests: BitwardenTestCase {
// TODO: PM-10270 Skip unlock setup
}

/// `receive(_:)` with `.toggleUnlockMethod` updates the Face ID unlock method in the state.
/// `receive(_:)` with `.toggleUnlockMethod` disables biometrics and updates the state.
@MainActor
func test_receive_toggleUnlockMethod_faceID() {
subject.receive(.toggleUnlockMethod(.faceID, newValue: true))
func test_receive_toggleUnlockMethod_biometrics_disable() {
let biometricUnlockStatus = BiometricsUnlockStatus.available(.faceID, enabled: false, hasValidIntegrity: false)
authRepository.allowBiometricUnlockResult = .success(())
biometricsRepository.biometricUnlockStatus = .success(biometricUnlockStatus)
subject.state.biometricsStatus = .available(.faceID, enabled: true, hasValidIntegrity: true)

subject.receive(.toggleUnlockMethod(.biometrics(.faceID), newValue: false))
waitFor { !subject.state.isBiometricUnlockOn }

XCTAssertEqual(authRepository.allowBiometricUnlock, false)
XCTAssertEqual(subject.state.biometricsStatus, biometricUnlockStatus)
XCTAssertFalse(subject.state.isBiometricUnlockOn)
}

/// `receive(_:)` with `.toggleUnlockMethod` logs an error and shows an alert if disabling biometrics fails.
@MainActor
func test_receive_toggleUnlockMethod_biometrics_disable_error() {
let biometricUnlockStatus = BiometricsUnlockStatus.available(.faceID, enabled: true, hasValidIntegrity: true)
authRepository.allowBiometricUnlockResult = .failure(BitwardenTestError.example)
biometricsRepository.biometricUnlockStatus = .success(biometricUnlockStatus)
subject.state.biometricsStatus = biometricUnlockStatus

subject.receive(.toggleUnlockMethod(.biometrics(.faceID), newValue: false))
waitFor { !coordinator.alertShown.isEmpty }

XCTAssertEqual(authRepository.allowBiometricUnlock, false)
XCTAssertEqual(coordinator.alertShown, [.defaultAlert(title: Localizations.anErrorHasOccurred)])
XCTAssertEqual(errorReporter.errors as? [BitwardenTestError], [.example])
XCTAssertEqual(subject.state.biometricsStatus, biometricUnlockStatus)
XCTAssertTrue(subject.state.isBiometricUnlockOn)
}

/// `receive(_:)` with `.toggleUnlockMethod` enables biometrics and updates the state.
@MainActor
func test_receive_toggleUnlockMethod_biometrics_enable() {
let biometricUnlockStatus = BiometricsUnlockStatus.available(.faceID, enabled: true, hasValidIntegrity: true)
authRepository.allowBiometricUnlockResult = .success(())
biometricsRepository.biometricUnlockStatus = .success(biometricUnlockStatus)

subject.receive(.toggleUnlockMethod(.faceID, newValue: false))
subject.receive(.toggleUnlockMethod(.biometrics(.faceID), newValue: true))
waitFor { subject.state.isBiometricUnlockOn }

XCTAssertEqual(authRepository.allowBiometricUnlock, true)
XCTAssertEqual(subject.state.biometricsStatus, biometricUnlockStatus)
XCTAssertTrue(subject.state.isBiometricUnlockOn)
}

/// `receive(_:)` with `.toggleUnlockMethod` logs an error and shows an alert if enabling biometrics fails.
@MainActor
func test_receive_toggleUnlockMethod_biometrics_enable_error() {
let biometricUnlockStatus = BiometricsUnlockStatus.available(.faceID, enabled: false, hasValidIntegrity: false)
authRepository.allowBiometricUnlockResult = .failure(BitwardenTestError.example)
biometricsRepository.biometricUnlockStatus = .success(biometricUnlockStatus)
subject.state.biometricsStatus = biometricUnlockStatus

subject.receive(.toggleUnlockMethod(.biometrics(.faceID), newValue: true))
waitFor { !coordinator.alertShown.isEmpty }

XCTAssertEqual(authRepository.allowBiometricUnlock, true)
XCTAssertEqual(coordinator.alertShown, [.defaultAlert(title: Localizations.anErrorHasOccurred)])
XCTAssertEqual(errorReporter.errors as? [BitwardenTestError], [.example])
XCTAssertEqual(subject.state.biometricsStatus, biometricUnlockStatus)
XCTAssertFalse(subject.state.isBiometricUnlockOn)
}

Expand All @@ -125,10 +186,20 @@ class VaultUnlockSetupProcessorTests: BitwardenTestCase {
/// `receive(_:)` with `.toggleUnlockMethod` updates the touch ID unlock method in the state.
@MainActor
func test_receive_toggleUnlockMethod_touchID() {
subject.receive(.toggleUnlockMethod(.touchID, newValue: true))
subject.state.biometricsStatus = .available(.touchID, enabled: false, hasValidIntegrity: false)
biometricsRepository.biometricUnlockStatus = .success(
.available(.touchID, enabled: true, hasValidIntegrity: true)
)

subject.receive(.toggleUnlockMethod(.biometrics(.touchID), newValue: true))
waitFor { subject.state.isBiometricUnlockOn }
XCTAssertTrue(subject.state.isBiometricUnlockOn)

subject.receive(.toggleUnlockMethod(.touchID, newValue: false))
biometricsRepository.biometricUnlockStatus = .success(
.available(.touchID, enabled: false, hasValidIntegrity: false)
)
subject.receive(.toggleUnlockMethod(.biometrics(.touchID), newValue: false))
waitFor { !subject.state.isBiometricUnlockOn }
XCTAssertFalse(subject.state.isBiometricUnlockOn)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,48 +7,60 @@ struct VaultUnlockSetupState: Equatable {

/// An enumeration of the vault unlock methods.
///
enum UnlockMethod: Int, Equatable, Identifiable {
/// Face ID is used to unlock the vault.
case faceID
enum UnlockMethod: Equatable, Identifiable {
/// Biometrics is used to unlock the vault.
case biometrics(BiometricAuthenticationType)

/// The user's pin code is used to unlock the vault.
case pin

/// Touch ID is used to unlock the vault.
case touchID

/// The accessibility identifier for the UI toggle.
var accessibilityIdentifier: String {
switch self {
case .faceID, .touchID:
case .biometrics:
"UnlockWithBiometricsSwitch"
case .pin:
"UnlockWithPinSwitch"
}
}

/// A key path for getting/setting whether the unlock method is turned on in the state.
var keyPath: WritableKeyPath<VaultUnlockSetupState, Bool> {
/// A key path for getting whether the unlock method is turned on in the state.
var keyPath: KeyPath<VaultUnlockSetupState, Bool> {
switch self {
case .faceID, .touchID:
case .biometrics:
\.isBiometricUnlockOn
case .pin:
\.isPinUnlockOn
}
}

/// A unique identifier for the unlock method.
var id: Int { rawValue }
var id: String {
switch self {
case let .biometrics(type):
switch type {
case .faceID:
"FaceID"
case .touchID:
"TouchID"
}
case .pin:
"PIN"
}
}

/// The localized title of the UI toggle.
var title: String {
switch self {
case .faceID:
Localizations.unlockWith(Localizations.faceID)
case let .biometrics(type):
switch type {
case .faceID:
Localizations.unlockWith(Localizations.faceID)
case .touchID:
Localizations.unlockWith(Localizations.touchID)
}
case .pin:
Localizations.unlockWithPIN
case .touchID:
Localizations.unlockWith(Localizations.touchID)
}
}
}
Expand All @@ -58,30 +70,31 @@ struct VaultUnlockSetupState: Equatable {
/// The biometric auth status for the user.
var biometricsStatus: BiometricsUnlockStatus?

/// Whether biometric unlock (Face ID / Touch ID) is turned on.
var isBiometricUnlockOn = false

/// Whether pin unlock is turned on.
var isPinUnlockOn = false

// MARK: Computed Properties

/// Whether biometric unlock (Face ID / Touch ID) is turned on.
var isBiometricUnlockOn: Bool {
switch biometricsStatus {
case let .available(_, enabled, hasValidIntegrity):
return enabled && hasValidIntegrity
case nil, .notAvailable:
return false
}
}

/// Whether the continue button is enabled.
var isContinueButtonEnabled: Bool {
isBiometricUnlockOn || isPinUnlockOn
}

/// The available unlock methods to show in the UI.
var unlockMethods: [UnlockMethod] {
let biometricsMethod: UnlockMethod? = if case let .available(type, _, _) = biometricsStatus {
switch type {
case .faceID: .faceID
case .touchID: .touchID
}
} else {
nil
guard case let .available(biometricsType, _, _) = biometricsStatus else {
return [.pin]
}

return [biometricsMethod, .pin].compactMap { $0 }
return [.biometrics(biometricsType), .pin]
}
}
Loading

0 comments on commit ee8fd70

Please sign in to comment.