mirror of
https://github.com/bitwarden/ios.git
synced 2025-12-10 17:46:07 -06:00
Allow user to lock the app with biometrics (#59)
This commit is contained in:
parent
63b55d8376
commit
a7b8d8cfa1
@ -0,0 +1,11 @@
|
||||
// MARK: - BiometricAuthenticationType
|
||||
|
||||
/// The enumeration biometric authentication types.
|
||||
///
|
||||
enum BiometricAuthenticationType: Equatable {
|
||||
/// FaceID biometric authentication.
|
||||
case faceID
|
||||
|
||||
/// TouchID biometric authentication.
|
||||
case touchID
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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))
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 {}
|
||||
|
||||
112
AuthenticatorShared/UI/Auth/AuthCoordinator.swift
Normal file
112
AuthenticatorShared/UI/Auth/AuthCoordinator.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -5,4 +5,7 @@
|
||||
public enum AuthEvent: Equatable {
|
||||
/// When the app starts
|
||||
case didStart
|
||||
|
||||
/// When the user successfully authorized
|
||||
case didCompleteAuth
|
||||
}
|
||||
|
||||
47
AuthenticatorShared/UI/Auth/AuthModule.swift
Normal file
47
AuthenticatorShared/UI/Auth/AuthModule.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
39
AuthenticatorShared/UI/Auth/AuthRouter.swift
Normal file
39
AuthenticatorShared/UI/Auth/AuthRouter.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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?)
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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?
|
||||
}
|
||||
@ -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
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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))"
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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> {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user