Allow user to lock the app with biometrics (#59)

This commit is contained in:
Katherine Bertelsen 2024-04-24 23:14:35 -05:00 committed by GitHub
parent 63b55d8376
commit a7b8d8cfa1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
36 changed files with 1401 additions and 40 deletions

View File

@ -0,0 +1,11 @@
// MARK: - BiometricAuthenticationType
/// The enumeration biometric authentication types.
///
enum BiometricAuthenticationType: Equatable {
/// FaceID biometric authentication.
case faceID
/// TouchID biometric authentication.
case touchID
}

View File

@ -0,0 +1,26 @@
// MARK: - BiometricAuthorizationType
/// The enumeration of biometric authentication authorization.
///
enum BiometricAuthorizationStatus: Equatable {
/// BiometricAuth access has been authorized or may be authorized pending a system permissions alert.
case authorized(BiometricAuthenticationType)
/// BiometricAuth access has been denied.
case denied(BiometricAuthenticationType)
/// BiometricAuth access denied due to repeated failed attempts.
case lockedOut(BiometricAuthenticationType)
/// No biometric authentication available on the user's device.
case noBiometrics
/// BiometricAuth access has not been determined yet.
case notDetermined
/// The user has not enrolled in BiometricAuth on this device.
case notEnrolled(BiometricAuthenticationType)
/// An unknown error case
case unknownError(String, BiometricAuthenticationType)
}

View File

@ -0,0 +1,216 @@
import BitwardenSdk
import LocalAuthentication
// MARK: - BiometricsStatus
enum BiometricsUnlockStatus: Equatable {
/// Biometric Unlock is available.
case available(BiometricAuthenticationType, enabled: Bool, hasValidIntegrity: Bool)
/// Biometric Unlock is not available.
case notAvailable
// MARK: Computed Properties
/// Whether biometric unlock is both available and enabled.
var isEnabled: Bool {
guard case let .available(_, enabled, _) = self else {
return false
}
return enabled
}
}
// MARK: - BiometricsRepository
/// A protocol for returning the available authentication policies and access controls for the user's device.
///
protocol BiometricsRepository: AnyObject {
/// Configures the device Biometric Integrity state.
/// Should be called following a successful launch when biometric unlock is enabled.
func configureBiometricIntegrity() async throws
/// Sets the biometric unlock preference for the active user.
/// If permissions have not been requested, this request should trigger the system permisisons dialog.
///
/// - Parameter authKey: An optional `String` representing the user auth key. If nil, Biometric Unlock is disabled.
///
func setBiometricUnlockKey(authKey: String?) async throws
/// Returns the status for user BiometricAuthentication.
///
/// - Returns: The a `BiometricAuthorizationStatus`.
///
func getBiometricUnlockStatus() async throws -> BiometricsUnlockStatus
/// Attempts to retrieve a user's auth key with biometrics.
///
func getUserAuthKey() async throws -> String
}
// MARK: - DefaultBiometricsRepository
/// A default implementation of `BiometricsRepository`, which returns the available authentication policies
/// and access controls for the user's device, and logs an error if one occurs
/// while obtaining the device's biometric authentication type.
///
class DefaultBiometricsRepository: BiometricsRepository {
// MARK: Parameters
/// A service used to track device biometry data & status.
var biometricsService: BiometricsService
/// A service used to store the UserAuthKey key/value pair.
var keychainRepository: KeychainRepository
/// A service used to store the Biometric Integrity Source key/value pair.
var stateService: StateService
// MARK: Initialization
/// Initializes the service.
///
/// - Parameters:
/// - biometricsService: The service used to track device biometry data & status.
/// - keychainService: The service used to store the UserAuthKey key/value pair.
/// - stateService: The service used to update user preferences.
///
init(
biometricsService: BiometricsService,
keychainService: KeychainRepository,
stateService: StateService
) {
self.biometricsService = biometricsService
keychainRepository = keychainService
self.stateService = stateService
}
func configureBiometricIntegrity() async throws {
if let state = biometricsService.getBiometricIntegrityState() {
let base64State = state.base64EncodedString()
try await stateService.setBiometricIntegrityState(base64State)
}
}
func setBiometricUnlockKey(authKey: String?) async throws {
guard let authKey,
try await biometricsService.evaluateBiometricPolicy() else {
try await stateService.setBiometricAuthenticationEnabled(false)
try await stateService.setBiometricIntegrityState(nil)
try? await deleteUserAuthKey()
return
}
try await setUserBiometricAuthKey(value: authKey)
try await stateService.setBiometricAuthenticationEnabled(true)
}
func getBiometricUnlockStatus() async throws -> BiometricsUnlockStatus {
let biometryStatus = biometricsService.getBiometricAuthStatus()
if case .lockedOut = biometryStatus {
throw BiometricsServiceError.biometryLocked
}
let hasEnabledBiometricUnlock = try await stateService.getBiometricAuthenticationEnabled()
let hasValidIntegrityState = await isBiometricIntegrityValid()
switch biometryStatus {
case let .authorized(type):
return .available(
type,
enabled: hasEnabledBiometricUnlock,
hasValidIntegrity: hasValidIntegrityState
)
case .denied,
.lockedOut,
.noBiometrics,
.notDetermined,
.notEnrolled,
.unknownError:
return .notAvailable
}
}
func getUserAuthKey() async throws -> String {
let id = try await stateService.getActiveAccountId()
let key = KeychainItem.biometrics(userId: id)
do {
let string = try await keychainRepository.getUserAuthKeyValue(for: key)
guard !string.isEmpty else {
throw BiometricsServiceError.getAuthKeyFailed
}
if let state = biometricsService.getBiometricIntegrityState() {
let base64State = state.base64EncodedString()
try await stateService.setBiometricIntegrityState(base64State)
}
return string
} catch let error as KeychainServiceError {
switch error {
case .accessControlFailed,
.keyNotFound:
throw BiometricsServiceError.getAuthKeyFailed
case let .osStatusError(status):
switch status {
case kLAErrorBiometryLockout:
throw BiometricsServiceError.biometryLocked
case errSecUserCanceled,
kLAErrorAppCancel,
kLAErrorSystemCancel,
kLAErrorUserCancel:
throw BiometricsServiceError.biometryCancelled
case kLAErrorBiometryDisconnected,
kLAErrorUserFallback:
throw BiometricsServiceError.biometryFailed
default:
throw BiometricsServiceError.getAuthKeyFailed
}
}
} catch {
throw BiometricsServiceError.getAuthKeyFailed
}
}
}
// MARK: Private Methods
extension DefaultBiometricsRepository {
/// Attempts to delete the active user's AuthKey from the keychain.
///
private func deleteUserAuthKey() async throws {
let id = try await stateService.getActiveAccountId()
let key = KeychainItem.biometrics(userId: id)
do {
try await keychainRepository.deleteUserAuthKey(for: key)
} catch {
throw BiometricsServiceError.deleteAuthKeyFailed
}
}
/// Checks if the device evaluatedPolicyDomainState matches the data saved to user defaults.
///
/// - Returns: A `Bool` indicating if the stored Data matches the current data.
/// If no data is stored to the device, `true` is returned by default.
///
private func isBiometricIntegrityValid() async -> Bool {
guard let data = biometricsService.getBiometricIntegrityState() else {
// Fallback for devices unable to retrieve integrity state.
return true
}
let integrityString: String? = try? await stateService.getBiometricIntegrityState()
return data.base64EncodedString() == integrityString
}
/// Attempts to save an auth key to the keychain with biometrics.
///
/// - Parameter value: The key to be stored.
///
private func setUserBiometricAuthKey(value: String) async throws {
let id = try await stateService.getActiveAccountId()
let key = KeychainItem.biometrics(userId: id)
do {
try await keychainRepository.setUserAuthKey(for: key, value: value)
} catch {
throw BiometricsServiceError.setAuthKeyFailed
}
}
}

View File

@ -0,0 +1,168 @@
import LocalAuthentication
import OSLog
// MARK: - BiometricsService
/// A protocol for returning the available authentication policies and access controls for the user's device.
///
protocol BiometricsService: AnyObject {
/// Evaluate's the users biometrics policy via `BiometricAuthorizationStatus`
///
/// - Parameter biometricAuthStatus: The status to be checked.
/// If `true`, a system dialog may prompt the user for permissions.
/// - Returns: A `Bool` indicating if the evaluation was successful.
///
func evaluateBiometricPolicy(
_ suppliedContext: LAContext?,
for biometricAuthStatus: BiometricAuthorizationStatus
) async -> Bool
/// Returns the status for device BiometricAuthenticationType.
///
/// - Returns: The `BiometricAuthenticationType`.
///
func getBiometricAuthenticationType(_ suppliedContext: LAContext?) -> BiometricAuthenticationType?
/// Returns the status for user BiometricAuthentication.
///
/// - Returns: The a `BiometricAuthorizationStatus`.
///
func getBiometricAuthStatus() -> BiometricAuthorizationStatus
/// Returns the `Data` for device evaluatedPolicyDomainState.
///
/// - Returns: The `Data` for evaluatedPolicyDomainState.
///
func getBiometricIntegrityState() -> Data?
}
extension BiometricsService {
/// Evaluate's the users biometrics policy via `BiometricAuthorizationStatus`
///
/// - Returns: An evaluated status for the user's biometric authorization.
///
func evaluateBiometricPolicy() async throws -> Bool {
let initialStatus = getBiometricAuthStatus()
return await evaluateBiometricPolicy(nil, for: initialStatus)
}
}
class DefaultBiometricsService: BiometricsService {
func evaluateBiometricPolicy(
_ suppliedContext: LAContext?,
for biometricAuthStatus: BiometricAuthorizationStatus
) async -> Bool {
// First check if the existing status can be evaluated.
guard case .authorized = biometricAuthStatus else {
// If not, return false
return false
}
// Then, evaluate the policy, which prompts the user for FaceID
// or biometrics permissions.
let authContext = suppliedContext ?? LAContext()
do {
let result = try await authContext.evaluatePolicy(
.deviceOwnerAuthenticationWithBiometrics,
localizedReason: Localizations.useBiometricsToUnlock
)
return result
} catch {
Logger.processor.error("Error evaluating biometrics policy: \(error)")
return false
}
}
func getBiometricAuthenticationType(_ suppliedContext: LAContext?) -> BiometricAuthenticationType? {
let authContext = suppliedContext ?? LAContext()
var error: NSError?
guard authContext.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) else {
Logger.processor.error("Error checking biometrics type: \(error)")
return nil
}
switch authContext.biometryType {
case .none,
.opticID:
return .none
case .touchID:
return .touchID
case .faceID:
return .faceID
@unknown default:
return .none
}
}
func getBiometricAuthStatus() -> BiometricAuthorizationStatus {
let context = LAContext()
var error: NSError?
let biometricAuthType = getBiometricAuthenticationType(context)
// Check if the device supports biometric authentication.
if let biometricAuthType,
context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) {
// Biometrics are available and enrolled, permissions are undetermined or granted.
return .authorized(biometricAuthType)
} else {
guard let error else {
return .notDetermined
}
return errorStatus(biometricAuthType: biometricAuthType, error: error)
}
}
func getBiometricIntegrityState() -> Data? {
let context = LAContext()
var error: NSError?
context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error)
return context.evaluatedPolicyDomainState
}
// MARK: Private Methods
/// Derives a BiometricAuthStatus from a supplied error.
///
/// - Parameters:
/// - biometricAuthType: The biometry type.
/// - error: The error to use in constructing an auth status.
/// - Returns: A BiometricAuthStatus.
///
func errorStatus(
biometricAuthType: BiometricAuthenticationType?,
error: Error
) -> BiometricAuthorizationStatus {
guard let biometricAuthType else {
// Biometrics are not available on the device.
Logger.application.log("Biometry is not available.")
return .noBiometrics
}
guard let laError = error as? LAError else {
// A non LAError occured
Logger.application.log("Other error: \(error.localizedDescription)")
return .unknownError(error.localizedDescription, biometricAuthType)
}
// If canEvaluatePolicy returns false, check the error code.
switch laError.code {
case .biometryNotAvailable:
// The user has denied Biometrics permission for this app.
Logger.application.log("Biometric permission denied!")
return .denied(biometricAuthType)
case .biometryNotEnrolled:
// Biometrics are supported but not enrolled.
Logger.application.log("Biometry is supported but not enrolled.")
return .notEnrolled(biometricAuthType)
case .biometryLockout:
// Biometrics are locked out, typically due to too many failed attempts.
Logger.application.log("Biometry is temporarily locked out.")
return .lockedOut(biometricAuthType)
default:
// Other types of errors.
Logger.application.log("Other error: \(laError.localizedDescription)")
return .unknownError(laError.localizedDescription, biometricAuthType)
}
}
}

View File

@ -0,0 +1,29 @@
// MARK: - BiometricsServiceError
/// An error thrown by a BiometricsRepository.
///
enum BiometricsServiceError: Error, Equatable {
/// An error when the user, app, or system cancels a biometric unlock
///
case biometryCancelled
/// An error for when biometry fails for a benign reason.
///
case biometryFailed
/// An error for when the user has passed the maximum failed attempts at biometric unlock.
///
case biometryLocked
/// An error for when deleting an auth key from the keychain fails.
///
case deleteAuthKeyFailed
/// An error for when retrieving an auth key from the keychain fails.
///
case getAuthKeyFailed
/// An error for when saving an auth key to the keychain fails.
///
case setAuthKeyFailed
}

View File

@ -0,0 +1,29 @@
@testable import AuthenticatorShared
class MockBiometricsRepository: BiometricsRepository {
var biometricUnlockStatus: Result<BiometricsUnlockStatus, Error> = .success(.notAvailable)
var capturedUserAuthKey: String?
var didConfigureBiometricIntegrity = false
var didDeleteKey: Bool = false
var getUserAuthKeyResult: Result<String, Error> = .success("UserAuthKey")
var setBiometricUnlockKeyError: Error?
func configureBiometricIntegrity() async {
didConfigureBiometricIntegrity = true
}
func getBiometricUnlockStatus() async throws -> BiometricsUnlockStatus {
try biometricUnlockStatus.get()
}
func getUserAuthKey() async throws -> String {
try getUserAuthKeyResult.get()
}
func setBiometricUnlockKey(authKey: String?) async throws {
capturedUserAuthKey = authKey
if let setBiometricUnlockKeyError {
throw setBiometricUnlockKeyError
}
}
}

View File

@ -0,0 +1,29 @@
import LocalAuthentication
@testable import AuthenticatorShared
class MockBiometricsService: BiometricsService {
var biometricAuthenticationType: BiometricAuthenticationType?
var biometricAuthStatus: BiometricAuthorizationStatus = .notDetermined
var biometricIntegrityState: Data?
var evaluationResult: Bool = true
func evaluateBiometricPolicy(
_ suppliedContext: LAContext?,
for biometricAuthStatus: BiometricAuthorizationStatus
) async -> Bool {
evaluationResult
}
func getBiometricAuthenticationType(_ suppliedContext: LAContext?) -> BiometricAuthenticationType? {
biometricAuthenticationType
}
func getBiometricAuthStatus() -> BiometricAuthorizationStatus {
biometricAuthStatus
}
func getBiometricIntegrityState() -> Data? {
biometricIntegrityState
}
}

View File

@ -3,6 +3,9 @@ import Foundation
// MARK: - KeychainItem
enum KeychainItem: Equatable {
/// The keychain item for biometrics protected user auth key.
case biometrics(userId: String)
/// The keychain item for a user's encryption secret key.
case secretKey(userId: String)
@ -11,6 +14,8 @@ enum KeychainItem: Equatable {
///
var protection: SecAccessControlCreateFlags? {
switch self {
case .biometrics:
.biometryCurrentSet
case .secretKey:
nil
}
@ -20,6 +25,8 @@ enum KeychainItem: Equatable {
///
var unformattedKey: String {
switch self {
case let .biometrics(userId: id):
"biometric_key_" + id
case let .secretKey(userId):
"secretKey_\(userId)"
}
@ -29,6 +36,12 @@ enum KeychainItem: Equatable {
// MARK: - KeychainRepository
protocol KeychainRepository: AnyObject {
/// Attempts to delete the userAuthKey from the keychain.
///
/// - Parameter item: The KeychainItem to be deleted.
///
func deleteUserAuthKey(for item: KeychainItem) async throws
/// Gets the stored secret key for a user from the keychain.
///
/// - Parameters:
@ -37,6 +50,13 @@ protocol KeychainRepository: AnyObject {
///
func getSecretKey(userId: String) async throws -> String
/// Gets a user auth key value.
///
/// - Parameter item: The storage key of the user auth key.
/// - Returns: A string representing the user auth key.
///
func getUserAuthKeyValue(for item: KeychainItem) async throws -> String
/// Stores the secret key for a user in the keychain
///
/// - Parameters:
@ -44,6 +64,14 @@ protocol KeychainRepository: AnyObject {
/// - userId: The user's ID
///
func setSecretKey(_ value: String, userId: String) async throws
/// Sets a user auth key/value pair.
///
/// - Parameters:
/// - item: The storage key for this auth key.
/// - value: A `String` representing the user auth key.
///
func setUserAuthKey(for item: KeychainItem, value: String) async throws
}
extension KeychainRepository {
@ -191,11 +219,25 @@ class DefaultKeychainRepository: KeychainRepository {
}
extension DefaultKeychainRepository {
func deleteUserAuthKey(for item: KeychainItem) async throws {
try await keychainService.delete(
query: keychainQueryValues(for: item)
)
}
func getSecretKey(userId: String) async throws -> String {
try await getValue(for: .secretKey(userId: userId))
}
func getUserAuthKeyValue(for item: KeychainItem) async throws -> String {
try await getValue(for: item)
}
func setSecretKey(_ value: String, userId: String) async throws {
try await setValue(value, for: .secretKey(userId: userId))
}
func setUserAuthKey(for item: KeychainItem, value: String) async throws {
try await setValue(value, for: item)
}
}

View File

@ -69,4 +69,11 @@ class MockKeychainRepository: KeychainRepository {
try setSecretKeyResult.get()
mockStorage[formattedKey(for: .secretKey(userId: userId))] = value
}
func setUserAuthKey(for item: KeychainItem, value: String) async throws {
let formattedKey = formattedKey(for: item)
securityType = item.protection
try setResult.get()
mockStorage[formattedKey] = value
}
}

View File

@ -24,6 +24,12 @@ public class ServiceContainer: Services {
/// The service used for managing items
let authenticatorItemRepository: AuthenticatorItemRepository
/// The repository to manage biometric unlock policies and access controls the user.
let biometricsRepository: BiometricsRepository
/// The service used to obtain device biometrics status & data.
let biometricsService: BiometricsService
/// The service used by the application to manage camera use.
let cameraService: CameraService
@ -62,6 +68,9 @@ public class ServiceContainer: Services {
/// - application: The application instance.
/// - appSettingsStore: The service for persisting app settings
/// - authenticatorItemRepository: The service to manage items
/// - biometricsRepository: The repository to manage biometric unlock policies
/// and access controls for the user.
/// - biometricsService: The service used to obtain device biometrics status & data.
/// - cameraService: The service used by the application to manage camera use.
/// - clientService: The service used by the application to handle encryption and decryption tasks.
/// - cryptographyService: The service used by the application to encrypt and decrypt items
@ -77,6 +86,8 @@ public class ServiceContainer: Services {
application: Application?,
appSettingsStore: AppSettingsStore,
authenticatorItemRepository: AuthenticatorItemRepository,
biometricsRepository: BiometricsRepository,
biometricsService: BiometricsService,
cameraService: CameraService,
cryptographyService: CryptographyService,
clientService: ClientService,
@ -91,6 +102,8 @@ public class ServiceContainer: Services {
self.application = application
self.appSettingsStore = appSettingsStore
self.authenticatorItemRepository = authenticatorItemRepository
self.biometricsRepository = biometricsRepository
self.biometricsService = biometricsService
self.cameraService = cameraService
self.clientService = clientService
self.cryptographyService = cryptographyService
@ -118,7 +131,7 @@ public class ServiceContainer: Services {
)
let appIdService = AppIdService(appSettingStore: appSettingsStore)
let biometricsService = DefaultBiometricsService()
let cameraService = DefaultCameraService()
let clientService = DefaultClientService()
let dataStore = DataStore(errorReporter: errorReporter)
@ -136,6 +149,12 @@ public class ServiceContainer: Services {
let timeProvider = CurrentTime()
let biometricsRepository = DefaultBiometricsRepository(
biometricsService: biometricsService,
keychainService: keychainRepository,
stateService: stateService
)
let cryptographyKeyService = CryptographyKeyService(
stateService: stateService
)
@ -179,6 +198,8 @@ public class ServiceContainer: Services {
application: application,
appSettingsStore: appSettingsStore,
authenticatorItemRepository: authenticatorItemRepository,
biometricsRepository: biometricsRepository,
biometricsService: biometricsService,
cameraService: cameraService,
cryptographyService: cryptographyService,
clientService: clientService,

View File

@ -2,6 +2,7 @@ import BitwardenSdk
/// The services provided by the `ServiceContainer`.
typealias Services = HasAuthenticatorItemRepository
& HasBiometricsRepository
& HasCameraService
& HasCryptographyService
& HasErrorReporter
@ -18,6 +19,13 @@ protocol HasAuthenticatorItemRepository {
var authenticatorItemRepository: AuthenticatorItemRepository { get }
}
/// Protocol for obtaining the device's biometric authentication type.
///
protocol HasBiometricsRepository {
/// The repository used to obtain the available authentication policies and access controls for the user's device.
var biometricsRepository: BiometricsRepository { get }
}
/// Protocol for an object that provides a `CameraService`.
///
protocol HasCameraService {

View File

@ -13,12 +13,32 @@ protocol StateService: AnyObject {
///
var hasSeenWelcomeTutorial: Bool { get set }
/// Gets the active account id.
///
/// - Returns: The active user account id.
///
func getActiveAccountId() async throws -> String
/// Get the app theme.
///
/// - Returns: The app theme.
///
func getAppTheme() async -> AppTheme
/// Get the active user's Biometric Authentication Preference.
///
/// - Returns: A `Bool` indicating the user's preference for using biometric authentication.
/// If `true`, the device should attempt biometric authentication for authorization events.
/// If `false`, the device should not attempt biometric authentication for authorization events.
///
func getBiometricAuthenticationEnabled() async throws -> Bool
/// Gets the BiometricIntegrityState for the active user.
///
/// - Returns: An optional base64 string encoding of the BiometricIntegrityState `Data` as last stored for the user.
///
func getBiometricIntegrityState() async throws -> String?
/// Gets the clear clipboard value for an account.
///
/// - Parameter userId: The user ID associated with the clear clipboard value. Defaults to the active
@ -45,6 +65,20 @@ protocol StateService: AnyObject {
///
func setAppTheme(_ appTheme: AppTheme) async
/// Sets the user's Biometric Authentication Preference.
///
/// - Parameter isEnabled: A `Bool` indicating the user's preference for using biometric authentication.
/// If `true`, the device should attempt biometric authentication for authorization events.
/// If `false`, the device should not attempt biometric authentication for authorization events.
///
func setBiometricAuthenticationEnabled(_ isEnabled: Bool?) async throws
/// Sets the BiometricIntegrityState for the active user.
///
/// - Parameter base64State: A base64 string encoding of the BiometricIntegrityState `Data`.
///
func setBiometricIntegrityState(_ base64State: String?) async throws
/// Sets the clear clipboard value for an account.
///
/// - Parameters:
@ -81,6 +115,24 @@ protocol StateService: AnyObject {
func showWebIconsPublisher() async -> AnyPublisher<Bool, Never>
}
// MARK: - StateServiceError
/// The errors thrown from a `StateService`.
///
enum StateServiceError: Error {
/// There are no known accounts.
case noAccounts
/// There isn't an active account.
case noActiveAccount
/// The user has no pin protected user key.
case noPinProtectedUserKey
/// The user has no user key.
case noEncUserKey
}
// MARK: - DefaultStateService
/// A default implementation of `StateService`.
@ -134,6 +186,10 @@ actor DefaultStateService: StateService {
// MARK: Methods
func getActiveAccountId() async throws -> String {
appSettingsStore.localUserId
}
func getAppTheme() async -> AppTheme {
AppTheme(appSettingsStore.appTheme)
}
@ -192,3 +248,27 @@ actor DefaultStateService: StateService {
appSettingsStore.localUserId
}
}
// MARK: Biometrics
extension DefaultStateService {
func getBiometricAuthenticationEnabled() async throws -> Bool {
let userId = try getActiveAccountUserId()
return appSettingsStore.isBiometricAuthenticationEnabled(userId: userId)
}
func getBiometricIntegrityState() async throws -> String? {
let userId = try getActiveAccountUserId()
return appSettingsStore.biometricIntegrityState(userId: userId)
}
func setBiometricAuthenticationEnabled(_ isEnabled: Bool?) async throws {
let userId = try getActiveAccountUserId()
appSettingsStore.setBiometricAuthenticationEnabled(isEnabled, for: userId)
}
func setBiometricIntegrityState(_ base64State: String?) async throws {
let userId = try getActiveAccountUserId()
appSettingsStore.setBiometricIntegrityState(base64State, userId: userId)
}
}

View File

@ -27,6 +27,14 @@ protocol AppSettingsStore: AnyObject {
/// The app's last data migration version.
var migrationVersion: Int { get set }
/// The system biometric integrity state `Data`, base64 encoded.
///
/// - Parameter userId: The user ID associated with the Biometric Integrity State.
/// - Returns: A base64 encoded `String`
/// representing the last known Biometric Integrity State `Data` for the userID.
///
func biometricIntegrityState(userId: String) -> String?
/// Gets the time after which the clipboard should be cleared.
///
/// - Parameter userId: The user ID associated with the clipboard clearing time.
@ -35,6 +43,16 @@ protocol AppSettingsStore: AnyObject {
///
func clearClipboardValue(userId: String) -> ClearClipboardValue
/// Get the user's Biometric Authentication Preference.
///
/// - Parameter userId: The user ID associated with the biometric authentication preference.
///
/// - Returns: A `Bool` indicating the user's preference for using biometric authentication.
/// If `true`, the device should attempt biometric authentication for authorization events.
/// If `false`, the device should not attempt biometric authentication for authorization events.
///
func isBiometricAuthenticationEnabled(userId: String) -> Bool
/// Gets the user's secret encryption key.
///
/// - Parameters:
@ -42,6 +60,24 @@ protocol AppSettingsStore: AnyObject {
///
func secretKey(userId: String) -> String?
/// Sets the user's Biometric Authentication Preference.
///
/// - Parameters:
/// - isEnabled: A `Bool` indicating the user's preference for using biometric authentication.
/// If `true`, the device should attempt biometric authentication for authorization events.
/// If `false`, the device should not attempt biometric authentication for authorization events.
/// - userId: The user ID associated with the biometric authentication preference.
///
func setBiometricAuthenticationEnabled(_ isEnabled: Bool?, for userId: String)
/// Sets a biometric integrity state `Data` as a base64 encoded `String`.
///
/// - Parameters:
/// - base64EncodedIntegrityState: The biometric integrity state `Data`, encoded as a base64 `String`.
/// - userId: The user ID associated with the Biometric Integrity State.
///
func setBiometricIntegrityState(_ base64EncodedIntegrityState: String?, userId: String)
/// Sets the time after which the clipboard should be cleared.
///
/// - Parameters:
@ -178,6 +214,8 @@ extension DefaultAppSettingsStore: AppSettingsStore {
case appId
case appLocale
case appTheme
case biometricAuthEnabled(userId: String)
case biometricIntegrityState(userId: String, bundleId: String)
case clearClipboardValue(userId: String)
case disableWebIcons
case hasSeenWelcomeTutorial
@ -194,6 +232,10 @@ extension DefaultAppSettingsStore: AppSettingsStore {
key = "appLocale"
case .appTheme:
key = "theme"
case let .biometricAuthEnabled(userId):
key = "biometricUnlock_\(userId)"
case let .biometricIntegrityState(userId, bundleId):
key = "biometricIntegritySource_\(userId)_\(bundleId)"
case let .clearClipboardValue(userId):
key = "clearClipboard_\(userId)"
case .disableWebIcons:
@ -239,6 +281,15 @@ extension DefaultAppSettingsStore: AppSettingsStore {
set { store(newValue, for: .migrationVersion) }
}
func biometricIntegrityState(userId: String) -> String? {
fetch(
for: .biometricIntegrityState(
userId: userId,
bundleId: bundleId
)
)
}
func clearClipboardValue(userId: String) -> ClearClipboardValue {
if let rawValue: Int = fetch(for: .clearClipboardValue(userId: userId)),
let value = ClearClipboardValue(rawValue: rawValue) {
@ -247,10 +298,28 @@ extension DefaultAppSettingsStore: AppSettingsStore {
return .never
}
func isBiometricAuthenticationEnabled(userId: String) -> Bool {
fetch(for: .biometricAuthEnabled(userId: userId))
}
func secretKey(userId: String) -> String? {
fetch(for: .secretKey(userId: userId))
}
func setBiometricAuthenticationEnabled(_ isEnabled: Bool?, for userId: String) {
store(isEnabled, for: .biometricAuthEnabled(userId: userId))
}
func setBiometricIntegrityState(_ base64EncodedIntegrityState: String?, userId: String) {
store(
base64EncodedIntegrityState,
for: .biometricIntegrityState(
userId: userId,
bundleId: bundleId
)
)
}
func setClearClipboardValue(_ clearClipboardValue: ClearClipboardValue?, userId: String) {
store(clearClipboardValue?.rawValue, for: .clearClipboardValue(userId: userId))
}

View File

@ -54,3 +54,31 @@ class MockAppSettingsStore: AppSettingsStore {
secretKeys[userId] = key
}
}
// MARK: Biometrics
extension MockAppSettingsStore {
func isBiometricAuthenticationEnabled(userId: String) -> Bool {
(biometricAuthenticationEnabled[userId] ?? false) ?? false
}
func biometricIntegrityState(userId: String) -> String? {
biometricIntegrityStates[userId] ?? nil
}
func setBiometricAuthenticationEnabled(_ isEnabled: Bool?, for userId: String) {
guard isEnabled != nil else {
biometricAuthenticationEnabled.removeValue(forKey: userId)
return
}
biometricAuthenticationEnabled[userId] = isEnabled
}
func setBiometricIntegrityState(_ base64EncodedIntegrityState: String?, userId: String) {
guard let base64EncodedIntegrityState else {
biometricIntegrityStates.removeValue(forKey: userId)
return
}
biometricIntegrityStates[userId] = base64EncodedIntegrityState
}
}

View File

@ -8,17 +8,27 @@ class MockStateService: StateService {
var appLanguage: LanguageOption = .default
var hasSeenWelcomeTutorial: Bool = false
var appTheme: AppTheme?
var biometricsEnabled = [String: Bool]()
var biometricIntegrityStates = [String: String?]()
var clearClipboardValues = [String: ClearClipboardValue]()
var clearClipboardResult: Result<Void, Error> = .success(())
var getBiometricAuthenticationEnabledResult: Result<Void, Error> = .success(())
var getBiometricIntegrityStateError: Error?
var getSecretKeyResult: Result<String, Error> = .success("qwerty")
var setSecretKeyResult: Result<Void, Error> = .success(())
var secretKeyValues = [String: String]()
var setBiometricAuthenticationEnabledResult: Result<Void, Error> = .success(())
var setBiometricIntegrityStateError: Error?
var setSecretKeyResult: Result<Void, Error> = .success(())
var timeProvider = MockTimeProvider(.currentTime)
var showWebIcons = true
var showWebIconsSubject = CurrentValueSubject<Bool, Never>(true)
lazy var appThemeSubject = CurrentValueSubject<AppTheme, Never>(self.appTheme ?? .default)
func getActiveAccountId() async throws -> String {
"localtest"
}
func getAppTheme() async -> AppTheme {
appTheme ?? .default
}
@ -57,7 +67,7 @@ class MockStateService: StateService {
func setSecretKey(_ key: String, userId: String?) async throws {
try setSecretKeyResult.get()
secretKeyValues[userId ?? "local"] = key
secretKeyValues[userId ?? "localtest"] = key
}
func showWebIconsPublisher() async -> AnyPublisher<Bool, Never> {
@ -76,3 +86,31 @@ class MockStateService: StateService {
}
}
}
// MARK: Biometrics
extension MockStateService {
func getBiometricAuthenticationEnabled() async throws -> Bool {
try getBiometricAuthenticationEnabledResult.get()
return biometricsEnabled["localtest"] ?? false
}
func getBiometricIntegrityState() async throws -> String? {
if let getBiometricIntegrityStateError {
throw getBiometricIntegrityStateError
}
return biometricIntegrityStates["localtest"] ?? nil
}
func setBiometricAuthenticationEnabled(_ isEnabled: Bool?) async throws {
try setBiometricAuthenticationEnabledResult.get()
biometricsEnabled["localtest"] = isEnabled
}
func setBiometricIntegrityState(_ base64EncodedState: String?) async throws {
if let setBiometricIntegrityStateError {
throw setBiometricIntegrityStateError
}
biometricIntegrityStates["localtest"] = base64EncodedState
}
}

View File

@ -8,6 +8,8 @@ extension ServiceContainer {
application: Application? = nil,
appSettingsStore: AppSettingsStore = MockAppSettingsStore(),
authenticatorItemRepository: AuthenticatorItemRepository = MockAuthenticatorItemRepository(),
biometricsRepository: BiometricsRepository = MockBiometricsRepository(),
biometricsService: BiometricsService = MockBiometricsService(),
cameraService: CameraService = MockCameraService(),
clientService: ClientService = MockClientService(),
cryptographyService: CryptographyService = MockCryptographyService(),
@ -23,6 +25,8 @@ extension ServiceContainer {
application: application,
appSettingsStore: appSettingsStore,
authenticatorItemRepository: authenticatorItemRepository,
biometricsRepository: biometricsRepository,
biometricsService: biometricsService,
cameraService: cameraService,
cryptographyService: cryptographyService,
clientService: clientService,

View File

@ -1,27 +1,5 @@
// MARK: AuthAction
// MARK: - AuthAction
/// An action that may require routing to a new Auth screen.
///
public enum AuthAction: Equatable {
/// When the app should lock an account.
///
/// - Parameter userId: The user Id of the account.
///
case lockVault(userId: String?)
/// When the app should logout an account vault.
///
/// - Parameters:
/// - userId: The user Id of the selected account. Defaults to the active user id if nil.
/// - userInitiated: Did a user action trigger the logout.
///
case logout(userId: String?, userInitiated: Bool)
/// When the app requests an account switch.
///
/// - Parameters:
/// - isAutomatic: Did the system trigger the account switch?
/// - userId: The user Id of the selected account.
///
case switchAccount(isAutomatic: Bool, userId: String)
}
public enum AuthAction: Equatable {}

View File

@ -0,0 +1,112 @@
import OSLog
import SwiftUI
// MARK: - AuthCoordinatorDelegate
/// An object that is signaled when specific circumstances in the auth flow have been encountered.
///
@MainActor
protocol AuthCoordinatorDelegate: AnyObject {
/// Called when the auth flow has been completed.
///
func didCompleteAuth()
}
// MARK: - AuthCoordinator
/// A coordinator that manages navigation in the authentication flow.
///
final class AuthCoordinator: NSObject, Coordinator, HasStackNavigator, HasRouter {
// MARK: Types
typealias Router = AnyRouter<AuthEvent, AuthRoute>
typealias Services = HasBiometricsRepository
& HasErrorReporter
// MARK: Properties
/// The delegate for this coordinator. Used to signal when auth has been completed. This should
/// be used by the coordinator to communicate to its parent coordinator when auth completes and
/// the auth flow should be dismissed.
private weak var delegate: (any AuthCoordinatorDelegate)?
/// The root navigator used to display this coordinator's interface.
weak var rootNavigator: (any RootNavigator)?
/// The router used by this coordinator.
var router: AnyRouter<AuthEvent, AuthRoute>
/// The services used by this coordinator.
let services: Services
/// The stack navigator that is managed by this coordinator.
private(set) weak var stackNavigator: StackNavigator?
// MARK: Initialization
/// Creates a new `AuthCoordinator`.
///
/// - Parameters:
/// - delegate: The delegate for this coordinator. Used to signal when auth has been completed.
/// - rootNavigator: The root navigator used to display this coordinator's interface.
/// - router: The router used by this coordinator to handle events.
/// - services: The services used by this coordinator.
/// - stackNavigator: The stack navigator that is managed by this coordinator.
///
init(
delegate: AuthCoordinatorDelegate,
rootNavigator: RootNavigator,
router: AnyRouter<AuthEvent, AuthRoute>,
services: Services,
stackNavigator: StackNavigator
) {
self.delegate = delegate
self.rootNavigator = rootNavigator
self.router = router
self.services = services
self.stackNavigator = stackNavigator
}
// MARK: Methods
func navigate(to route: AuthRoute, context: AnyObject?) {
switch route {
case .complete:
if stackNavigator?.isPresenting == true {
stackNavigator?.dismiss {
self.delegate?.didCompleteAuth()
}
} else {
delegate?.didCompleteAuth()
}
case .vaultUnlock:
showVaultUnlock()
}
}
func start() {
navigate(to: .vaultUnlock)
}
// MARK: Private Methods
/// Shows the vault unlock view.
///
/// - Parameters:
/// - account: The active account.
/// - animated: Whether to animate the transition.
/// - attemptAutmaticBiometricUnlock: Whether to the processor should attempt a biometric unlock on appear.
/// - didSwitchAccountAutomatically: A flag indicating if the active account was switched automatically.
///
private func showVaultUnlock() {
let processor = VaultUnlockProcessor(
coordinator: asAnyCoordinator(),
services: services,
state: VaultUnlockState()
)
processor.shouldAttemptAutomaticBiometricUnlock = true
let view = VaultUnlockView(store: Store(processor: processor))
stackNavigator?.replace(view, animated: true)
}
}

View File

@ -5,4 +5,7 @@
public enum AuthEvent: Equatable {
/// When the app starts
case didStart
/// When the user successfully authorized
case didCompleteAuth
}

View File

@ -0,0 +1,47 @@
// MARK: - AuthModule
/// An object that builds coordinators for the auth flow.
@MainActor
protocol AuthModule {
/// Initializes a coordinator for navigating between `AuthRoute`s.
///
/// - Parameters:
/// - delegate: The delegate for this coordinator.
/// - rootNavigator: The root navigator used to display this coordinator's interface.
/// - stackNavigator: The stack navigator that will be used to navigate between routes.
/// - Returns: A coordinator that can navigate to `AuthRoute`s.
///
func makeAuthCoordinator(
delegate: AuthCoordinatorDelegate,
rootNavigator: RootNavigator,
stackNavigator: StackNavigator
) -> AnyCoordinator<AuthRoute, AuthEvent>
/// Initializes a router for converting AuthEvents into AuthRoutes.
///
/// - Returns: A router that can convert `AuthEvent`s into `AuthRoute`s.
///
func makeAuthRouter() -> AnyRouter<AuthEvent, AuthRoute>
}
// MARK: - DefaultAppModule
extension DefaultAppModule: AuthModule {
func makeAuthCoordinator(
delegate: AuthCoordinatorDelegate,
rootNavigator: RootNavigator,
stackNavigator: StackNavigator
) -> AnyCoordinator<AuthRoute, AuthEvent> {
AuthCoordinator(
delegate: delegate,
rootNavigator: rootNavigator,
router: makeAuthRouter(),
services: services,
stackNavigator: stackNavigator
).asAnyCoordinator()
}
func makeAuthRouter() -> AnyRouter<AuthEvent, AuthRoute> {
AuthRouter(services: services).asAnyRouter()
}
}

View File

@ -6,5 +6,7 @@ import Foundation
public enum AuthRoute: Equatable {
/// Dismisses the auth flow.
case complete
case onboarding
/// A route to the unlock screen.
case vaultUnlock
}

View File

@ -0,0 +1,39 @@
import Foundation
// MARK: - AuthRouter
/// An object for converting `AuthEvent` to an `AuthRoute`.
///
final class AuthRouter: NSObject, Router {
// MARK: Types
typealias Services = HasErrorReporter
/// The services used by this router.
let services: Services
// MARK: Initialization
/// Creates a new `AuthRouter`.
///
/// - Parameter services: The services used by this router.
///
/// - Parameters:
init(services: Services) {
self.services = services
}
/// Prepare the coordinator asynchronously for a redirected `AuthRoute` based on current state.
///
/// - Parameter route: The proposed `AuthRoute`.
/// - Returns: Either the supplied route or a new route if the coordinator state demands a different route.
///
func handleAndRoute(_ event: AuthEvent) async -> AuthRoute {
switch event {
case .didCompleteAuth:
.complete
case .didStart:
.vaultUnlock
}
}
}

View File

@ -0,0 +1,8 @@
// MARK: - VaultUnlockAction
/// Synchronous actions that can be handled by a `VaultUnlockProcessor`.
///
enum VaultUnlockAction: Equatable {
/// The toast was shown or hidden.
case toastShown(Toast?)
}

View File

@ -0,0 +1,11 @@
// MARK: - VaultUnlockEffect
/// Asynchronous effects that can be handled by a `VaultUnlockProcessor`.
///
enum VaultUnlockEffect: Equatable {
/// The unlock view appeared.
case appeared
/// The button to unlock with biometrics was pressed.
case unlockWithBiometrics
}

View File

@ -0,0 +1,108 @@
import OSLog
// MARK: - VaultUnlockProcessor
/// The processor used to manage state and handle actions for the unlock screen.
///
class VaultUnlockProcessor: StateProcessor<
VaultUnlockState,
VaultUnlockAction,
VaultUnlockEffect
> {
// MARK: Types
typealias Services = HasBiometricsRepository
& HasErrorReporter
// MARK: Private Properties
/// The `Coordinator` that handles navigation.
private var coordinator: AnyCoordinator<AuthRoute, AuthEvent>
/// A flag indicating if the processor should attempt automatic biometric unlock
var shouldAttemptAutomaticBiometricUnlock = true
/// The services used by this processor.
private var services: Services
// MARK: Initialization
/// Initialize a `VaultUnlockProcessor`.
///
/// - Parameters:
/// - coordinator: The coordinator that handles navigation.
/// - services: The services used by this processor.
/// - state: The initial state of the processor.
///
init(
coordinator: AnyCoordinator<AuthRoute, AuthEvent>,
services: Services,
state: VaultUnlockState
) {
self.coordinator = coordinator
self.services = services
super.init(state: state)
}
// MARK: Methods
override func perform(_ effect: VaultUnlockEffect) async {
switch effect {
case .appeared:
await loadData()
case .unlockWithBiometrics:
await unlockWithBiometrics()
}
}
override func receive(_ action: VaultUnlockAction) {
switch action {
case let .toastShown(toast):
state.toast = toast
}
}
// MARK: Private Methods
/// Loads the async state data for the view
///
private func loadData() async {
state.biometricUnlockStatus = await (try? services.biometricsRepository.getBiometricUnlockStatus())
?? .notAvailable
// If biometric unlock is available, enabled,
// and the user's biometric integrity state is valid;
// attempt to unlock the vault with biometrics once.
if case .available(_, true, true) = state.biometricUnlockStatus,
shouldAttemptAutomaticBiometricUnlock {
shouldAttemptAutomaticBiometricUnlock = false
await unlockWithBiometrics()
}
}
/// Attempts to unlock the vault with the user's biometrics
///
private func unlockWithBiometrics() async {
let status = try? await services.biometricsRepository.getBiometricUnlockStatus()
guard case let .available(_, enabled: enabled, hasValidIntegrity) = status,
enabled,
hasValidIntegrity else {
await loadData()
return
}
do {
let key = try await services.biometricsRepository.getUserAuthKey()
await coordinator.handleEvent(.didCompleteAuth)
} catch let error as BiometricsServiceError {
Logger.processor.error("BiometricsServiceError unlocking vault with biometrics: \(error)")
if case .biometryCancelled = error {
// Do nothing if the user cancels.
return
}
await loadData()
} catch {
Logger.processor.error("Error unlocking vault with biometrics: \(error)")
await loadData()
}
}
}

View File

@ -0,0 +1,13 @@
// MARK: - VaultUnlockState
/// An object that defines the current state of a `VaultUnlockView`.
///
struct VaultUnlockState: Equatable {
// MARK: Properties
/// The biometric auth status for the user.
var biometricUnlockStatus: BiometricsUnlockStatus = .notAvailable
/// A toast message to show in the view.
var toast: Toast?
}

View File

@ -0,0 +1,74 @@
import SwiftUI
// MARK: - VaultUnlockView
/// A view that allows a user to use biometrics before viewing their items
///
struct VaultUnlockView: View {
// MARK: Properties
/// The `Store` for this view.
@ObservedObject var store: Store<VaultUnlockState, VaultUnlockAction, VaultUnlockEffect>
var body: some View {
content
.task {
await store.perform(.appeared)
}
.toast(store.binding(
get: \.toast,
send: VaultUnlockAction.toastShown
))
}
private var content: some View {
VStack(spacing: 48) {
Image(decorative: Asset.Images.logo)
biometricAuthButton
}
.padding(16)
}
/// A button to trigger a biometric auth unlock.
@ViewBuilder private var biometricAuthButton: some View {
if case let .available(biometryType, true, true) = store.state.biometricUnlockStatus {
AsyncButton {
Task { await store.perform(.unlockWithBiometrics) }
} label: {
biometricUnlockText(biometryType)
}
.buttonStyle(.primary(shouldFillWidth: true))
}
}
private func biometricUnlockText(_ biometryType: BiometricAuthenticationType) -> some View {
switch biometryType {
case .faceID:
Text(Localizations.useFaceIDToUnlock)
case .touchID:
Text(Localizations.useFingerprintToUnlock)
}
}
}
// MARK: - Previews
#if DEBUG
#Preview("Unlock") {
NavigationView {
VaultUnlockView(
store: Store(
processor: StateProcessor(
state: VaultUnlockState(
biometricUnlockStatus: .available(
.faceID,
enabled: true,
hasValidIntegrity: true
)
)
)
)
)
}
}
#endif

View File

@ -10,7 +10,8 @@ class AppCoordinator: Coordinator, HasRootNavigator {
// MARK: Types
/// The types of modules used by this coordinator.
typealias Module = ItemListModule
typealias Module = AuthModule
& ItemListModule
& TabModule
& TutorialModule
@ -61,9 +62,14 @@ class AppCoordinator: Coordinator, HasRootNavigator {
func handleEvent(_ event: AppEvent, context: AnyObject?) async {
switch event {
case .didStart:
showTab(route: .itemList(.list))
if !services.stateService.hasSeenWelcomeTutorial {
showTutorial()
let isEnabled = (try? await services.biometricsRepository.getBiometricUnlockStatus().isEnabled) ?? false
if isEnabled {
showAuth(.vaultUnlock)
} else {
showTab(route: .itemList(.list))
if !services.stateService.hasSeenWelcomeTutorial {
showTutorial()
}
}
}
}
@ -82,6 +88,29 @@ class AppCoordinator: Coordinator, HasRootNavigator {
// MARK: Private Methods
/// Shows the auth route.
///
/// - Parameter route: The auth route to show.
///
private func showAuth(_ authRoute: AuthRoute) {
if let coordinator = childCoordinator as? AnyCoordinator<AuthRoute, AuthEvent> {
coordinator.navigate(to: authRoute)
} else {
guard let rootNavigator else { return }
let navigationController = UINavigationController()
let coordinator = module.makeAuthCoordinator(
delegate: self,
rootNavigator: rootNavigator,
stackNavigator: navigationController
)
coordinator.start()
navigationController.modalPresentationStyle = .overFullScreen
navigationController.isNavigationBarHidden = true
rootNavigator.rootViewController?.present(navigationController, animated: false)
}
}
/// Shows the tab route.
///
/// - Parameter route: The tab route to show.
@ -100,6 +129,9 @@ class AppCoordinator: Coordinator, HasRootNavigator {
coordinator.start()
coordinator.navigate(to: route)
childCoordinator = coordinator
if rootNavigator.isPresenting {
rootNavigator.rootViewController?.dismiss(animated: true)
}
}
}
@ -116,3 +148,11 @@ class AppCoordinator: Coordinator, HasRootNavigator {
rootNavigator?.rootViewController?.present(navigationController, animated: false)
}
}
// MARK: - AuthCoordinatorDelegate
extension AppCoordinator: AuthCoordinatorDelegate {
func didCompleteAuth() {
showTab(route: .itemList(.list))
}
}

View File

@ -18,7 +18,7 @@ class AppProcessorTests: AuthenticatorTestCase {
override func setUp() {
super.setUp()
router = MockRouter(routeForEvent: { _ in .onboarding })
router = MockRouter(routeForEvent: { _ in .vaultUnlock })
appModule = MockAppModule()
coordinator = MockCoordinator()
errorReporter = MockErrorReporter()

View File

@ -15,7 +15,7 @@ class AnyRouterTests: AuthenticatorTestCase {
override func setUp() {
super.setUp()
router = MockRouter(routeForEvent: { _ in .onboarding })
router = MockRouter(routeForEvent: { _ in .vaultUnlock })
subject = router.asAnyRouter()
}
@ -31,7 +31,7 @@ class AnyRouterTests: AuthenticatorTestCase {
func test_handleAndRoute() async {
var didStart = false
router.routeForEvent = { event in
guard case .didStart = event else { return .onboarding }
guard case .didStart = event else { return .vaultUnlock }
didStart = true
return .complete
}

View File

@ -4,4 +4,7 @@
enum SettingsEffect {
/// The view appeared so the initial data should be loaded.
case loadData
/// Unlock with Biometrics was toggled.
case toggleUnlockWithBiometrics(Bool)
}

View File

@ -1,3 +1,5 @@
import OSLog
// MARK: - SettingsProcessor
/// The processor used to manage state and handle actions for the settings screen.
@ -5,7 +7,8 @@
final class SettingsProcessor: StateProcessor<SettingsState, SettingsAction, SettingsEffect> {
// MARK: Types
typealias Services = HasErrorReporter
typealias Services = HasBiometricsRepository
& HasErrorReporter
& HasExportItemsService
& HasPasteboardService
& HasStateService
@ -42,8 +45,9 @@ final class SettingsProcessor: StateProcessor<SettingsState, SettingsAction, Set
override func perform(_ effect: SettingsEffect) async {
switch effect {
case .loadData:
state.currentLanguage = services.stateService.appLanguage
state.appTheme = await services.stateService.getAppTheme()
await loadData()
case let .toggleUnlockWithBiometrics(isOn):
await setBiometricAuth(isOn)
}
}
@ -85,6 +89,45 @@ final class SettingsProcessor: StateProcessor<SettingsState, SettingsAction, Set
services.pasteboardService.copy(text)
state.toast = Toast(text: Localizations.valueHasBeenCopied(Localizations.appInfo))
}
/// Loads the state of the user's biometric unlock preferences.
///
/// - Returns: The `BiometricsUnlockStatus` for the user.
///
private func loadBiometricUnlockPreference() async -> BiometricsUnlockStatus {
do {
let biometricsStatus = try await services.biometricsRepository.getBiometricUnlockStatus()
return biometricsStatus
} catch {
Logger.application.debug("Error loading biometric preferences: \(error)")
return .notAvailable
}
}
/// Load any initial data for the view.
private func loadData() async {
state.currentLanguage = services.stateService.appLanguage
state.appTheme = await services.stateService.getAppTheme()
state.biometricUnlockStatus = await loadBiometricUnlockPreference()
}
/// Sets the user's biometric auth
///
/// - Parameter enabled: Whether or not the the user wants biometric auth enabled.
///
private func setBiometricAuth(_ enabled: Bool) async {
do {
try await services.biometricsRepository.setBiometricUnlockKey(authKey: enabled ? "key" : nil)
state.biometricUnlockStatus = try await services.biometricsRepository.getBiometricUnlockStatus()
// Set biometric integrity if needed.
if case .available(_, true, false) = state.biometricUnlockStatus {
try await services.biometricsRepository.configureBiometricIntegrity()
state.biometricUnlockStatus = try await services.biometricsRepository.getBiometricUnlockStatus()
}
} catch {
services.errorReporter.log(error: error)
}
}
}
// MARK: - SelectLanguageDelegate

View File

@ -8,6 +8,9 @@ struct SettingsState: Equatable {
/// The selected app theme.
var appTheme: AppTheme = .default
/// The biometric auth status for the user.
var biometricUnlockStatus: BiometricsUnlockStatus = .notAvailable
/// The copyright text.
var copyrightText = "© Bitwarden Inc. 2015-\(Calendar.current.component(.year, from: Date.now))"

View File

@ -35,6 +35,22 @@ struct SettingsView: View {
// MARK: Private views
/// A view for the user's biometrics setting
///
@ViewBuilder private var biometricsSetting: some View {
switch store.state.biometricUnlockStatus {
case let .available(type, enabled: enabled, _):
SectionView(Localizations.security) {
VStack(spacing: 0) {
biometricUnlockToggle(enabled: enabled, type: type)
}
}
.padding(.bottom, 32)
default:
EmptyView()
}
}
/// The chevron shown in the settings list item.
private var chevron: some View {
Image(asset: Asset.Images.rightAngle)
@ -74,6 +90,8 @@ struct SettingsView: View {
/// The settings items.
private var settingsItems: some View {
VStack(spacing: 0) {
biometricsSetting
SectionView(Localizations.vault) {
VStack(spacing: 0) {
SettingsListItem(Localizations.export, hasDivider: false) {
@ -143,6 +161,32 @@ struct SettingsView: View {
}
}
/// A toggle for the user's biometric unlock preference.
///
@ViewBuilder
private func biometricUnlockToggle(enabled: Bool, type: BiometricAuthenticationType) -> some View {
let toggleText = biometricsToggleText(type)
Toggle(isOn: store.bindingAsync(
get: { _ in enabled },
perform: SettingsEffect.toggleUnlockWithBiometrics
)) {
Text(toggleText)
}
.padding(.trailing, 3)
.accessibilityIdentifier("UnlockWithBiometricsSwitch")
.accessibilityLabel(toggleText)
.toggleStyle(.bitwarden)
}
private func biometricsToggleText(_ biometryType: BiometricAuthenticationType) -> String {
switch biometryType {
case .faceID:
return Localizations.unlockWith(Localizations.faceID)
case .touchID:
return Localizations.unlockWith(Localizations.touchID)
}
}
/// Returns a `SettingsListItem` configured for an external web link.
///
/// - Parameters:
@ -169,7 +213,19 @@ struct SettingsView: View {
#if DEBUG
#Preview {
NavigationView {
SettingsView(store: Store(processor: StateProcessor(state: SettingsState())))
SettingsView(
store: Store(
processor: StateProcessor(
state: SettingsState(
biometricUnlockStatus: .available(
.faceID,
enabled: false,
hasValidIntegrity: true
)
)
)
)
)
}
}
#endif

View File

@ -11,7 +11,8 @@ final class SettingsCoordinator: Coordinator, HasStackNavigator {
/// The module types required by this coordinator for creating child coordinators.
typealias Module = TutorialModule
typealias Services = HasErrorReporter
typealias Services = HasBiometricsRepository
& HasErrorReporter
& HasExportItemsService
& HasPasteboardService
& HasStateService

View File

@ -4,10 +4,13 @@
class MockAppModule:
AppModule,
AuthModule,
ItemListModule,
TutorialModule,
TabModule {
var appCoordinator = MockCoordinator<AppRoute, AppEvent>()
var authCoordinator = MockCoordinator<AuthRoute, AuthEvent>()
var authRouter = MockRouter<AuthEvent, AuthRoute>(routeForEvent: { _ in .vaultUnlock })
var itemListCoordinator = MockCoordinator<ItemListRoute, ItemListEvent>()
var tabCoordinator = MockCoordinator<TabRoute, Void>()
var tutorialCoordinator = MockCoordinator<TutorialRoute, TutorialEvent>()
@ -19,6 +22,18 @@ class MockAppModule:
appCoordinator.asAnyCoordinator()
}
func makeAuthCoordinator(
delegate _: AuthCoordinatorDelegate,
rootNavigator _: RootNavigator,
stackNavigator _: StackNavigator
) -> AnyCoordinator<AuthRoute, AuthEvent> {
authCoordinator.asAnyCoordinator()
}
func makeAuthRouter() -> AnyRouter<AuthEvent, AuthRoute> {
authRouter.asAnyRouter()
}
func makeItemListCoordinator(
stackNavigator _: AuthenticatorShared.StackNavigator
) -> AnyCoordinator<ItemListRoute, ItemListEvent> {