diff --git a/AuthenticatorShared/Core/Auth/Services/Biometrics/BiometricsRepository.swift b/AuthenticatorShared/Core/Auth/Services/Biometrics/BiometricsRepository.swift index 21f19d22..99446553 100644 --- a/AuthenticatorShared/Core/Auth/Services/Biometrics/BiometricsRepository.swift +++ b/AuthenticatorShared/Core/Auth/Services/Biometrics/BiometricsRepository.swift @@ -31,7 +31,7 @@ protocol BiometricsRepository: AnyObject { 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. + /// If permissions have not been requested, this request should trigger the system permissions dialog. /// /// - Parameter authKey: An optional `String` representing the user auth key. If nil, Biometric Unlock is disabled. /// @@ -130,7 +130,7 @@ class DefaultBiometricsRepository: BiometricsRepository { } func getUserAuthKey() async throws -> String { - let id = try await stateService.getActiveAccountId() + let id = await stateService.getActiveAccountId() let key = KeychainItem.biometrics(userId: id) do { @@ -176,7 +176,7 @@ 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 id = await stateService.getActiveAccountId() let key = KeychainItem.biometrics(userId: id) do { try await keychainRepository.deleteUserAuthKey(for: key) @@ -204,7 +204,7 @@ extension DefaultBiometricsRepository { /// - Parameter value: The key to be stored. /// private func setUserBiometricAuthKey(value: String) async throws { - let id = try await stateService.getActiveAccountId() + let id = await stateService.getActiveAccountId() let key = KeychainItem.biometrics(userId: id) do { diff --git a/AuthenticatorShared/Core/Platform/Models/Enum/SessionTimeoutValue.swift b/AuthenticatorShared/Core/Platform/Models/Enum/SessionTimeoutValue.swift new file mode 100644 index 00000000..b1d961de --- /dev/null +++ b/AuthenticatorShared/Core/Platform/Models/Enum/SessionTimeoutValue.swift @@ -0,0 +1,116 @@ +// MARK: - SessionTimeoutValue + +/// An enumeration of session timeout values to choose from. +/// +/// Note: This is imported from the PM app, but the `custom` case has been removed. +/// +public enum SessionTimeoutValue: RawRepresentable, CaseIterable, Equatable, Menuable, Sendable { + /// Timeout immediately. + case immediately + + /// Timeout after 1 minute. + case oneMinute + + /// Timeout after 5 minutes. + case fiveMinutes + + /// Timeout after 15 minutes. + case fifteenMinutes + + /// Timeout after 30 minutes. + case thirtyMinutes + + /// Timeout after 1 hour. + case oneHour + + /// Timeout after 4 hours. + case fourHours + + /// Timeout on app restart. + case onAppRestart + + /// Never timeout the session. + case never + + /// All of the cases to show in the menu. + public static let allCases: [Self] = [ + .immediately, + .oneMinute, + .fiveMinutes, + .fifteenMinutes, + .thirtyMinutes, + .oneHour, + .fourHours, + .onAppRestart, + .never, + ] + + /// The localized string representation of a `SessionTimeoutValue`. + var localizedName: String { + switch self { + case .immediately: + Localizations.immediately + case .oneMinute: + Localizations.oneMinute + case .fiveMinutes: + Localizations.fiveMinutes + case .fifteenMinutes: + Localizations.fifteenMinutes + case .thirtyMinutes: + Localizations.thirtyMinutes + case .oneHour: + Localizations.oneHour + case .fourHours: + Localizations.fourHours + case .onAppRestart: + Localizations.onRestart + case .never: + Localizations.never + } + } + + /// The session timeout value in seconds. + var seconds: Int { + rawValue * 60 + } + + /// The session timeout value in minutes. + public var rawValue: Int { + switch self { + case .immediately: 0 + case .oneMinute: 1 + case .fiveMinutes: 5 + case .fifteenMinutes: 15 + case .thirtyMinutes: 30 + case .oneHour: 60 + case .fourHours: 240 + case .onAppRestart: -1 + case .never: -2 + } + } + + public init(rawValue: Int) { + switch rawValue { + case 0: + self = .immediately + case 1: + self = .oneMinute + case 5: + self = .fiveMinutes + case 15: + self = .fifteenMinutes + case 30: + self = .thirtyMinutes + case 60: + self = .oneHour + case 240: + self = .fourHours + case -1: + self = .onAppRestart + case -2: + self = .never + default: + self = .never + } + } +} diff --git a/AuthenticatorShared/Core/Platform/Models/Enum/SessionTimeoutValueTests.swift b/AuthenticatorShared/Core/Platform/Models/Enum/SessionTimeoutValueTests.swift new file mode 100644 index 00000000..5782b981 --- /dev/null +++ b/AuthenticatorShared/Core/Platform/Models/Enum/SessionTimeoutValueTests.swift @@ -0,0 +1,65 @@ +import XCTest + +@testable import AuthenticatorShared + +final class SessionTimeoutValueTests: AuthenticatorTestCase { + // MARK: Tests + + /// `allCases` returns all of the cases in the correct order. + func test_allCases() { + XCTAssertEqual( + SessionTimeoutValue.allCases, + [ + .immediately, + .oneMinute, + .fiveMinutes, + .fifteenMinutes, + .thirtyMinutes, + .oneHour, + .fourHours, + .onAppRestart, + .never, + ] + ) + } + + /// `init` returns the correct case for the given raw value. + func test_initFromRawValue() { + XCTAssertEqual(SessionTimeoutValue.immediately, SessionTimeoutValue(rawValue: 0)) + XCTAssertEqual(SessionTimeoutValue.oneMinute, SessionTimeoutValue(rawValue: 1)) + XCTAssertEqual(SessionTimeoutValue.fiveMinutes, SessionTimeoutValue(rawValue: 5)) + XCTAssertEqual(SessionTimeoutValue.fifteenMinutes, SessionTimeoutValue(rawValue: 15)) + XCTAssertEqual(SessionTimeoutValue.thirtyMinutes, SessionTimeoutValue(rawValue: 30)) + XCTAssertEqual(SessionTimeoutValue.oneHour, SessionTimeoutValue(rawValue: 60)) + XCTAssertEqual(SessionTimeoutValue.fourHours, SessionTimeoutValue(rawValue: 240)) + XCTAssertEqual(SessionTimeoutValue.onAppRestart, SessionTimeoutValue(rawValue: -1)) + XCTAssertEqual(SessionTimeoutValue.never, SessionTimeoutValue(rawValue: -2)) + XCTAssertEqual(SessionTimeoutValue.never, SessionTimeoutValue(rawValue: 12345)) + } + + /// `localizedName` returns the correct values. + func test_localizedName() { + XCTAssertEqual(SessionTimeoutValue.immediately.localizedName, Localizations.immediately) + XCTAssertEqual(SessionTimeoutValue.oneMinute.localizedName, Localizations.oneMinute) + XCTAssertEqual(SessionTimeoutValue.fiveMinutes.localizedName, Localizations.fiveMinutes) + XCTAssertEqual(SessionTimeoutValue.fifteenMinutes.localizedName, Localizations.fifteenMinutes) + XCTAssertEqual(SessionTimeoutValue.thirtyMinutes.localizedName, Localizations.thirtyMinutes) + XCTAssertEqual(SessionTimeoutValue.oneHour.localizedName, Localizations.oneHour) + XCTAssertEqual(SessionTimeoutValue.fourHours.localizedName, Localizations.fourHours) + XCTAssertEqual(SessionTimeoutValue.onAppRestart.localizedName, Localizations.onRestart) + XCTAssertEqual(SessionTimeoutValue.never.localizedName, Localizations.never) + } + + /// `rawValue` returns the correct values. + func test_rawValues() { + XCTAssertEqual(SessionTimeoutValue.immediately.rawValue, 0) + XCTAssertEqual(SessionTimeoutValue.oneMinute.rawValue, 1) + XCTAssertEqual(SessionTimeoutValue.fiveMinutes.rawValue, 5) + XCTAssertEqual(SessionTimeoutValue.fifteenMinutes.rawValue, 15) + XCTAssertEqual(SessionTimeoutValue.thirtyMinutes.rawValue, 30) + XCTAssertEqual(SessionTimeoutValue.oneHour.rawValue, 60) + XCTAssertEqual(SessionTimeoutValue.fourHours.rawValue, 240) + XCTAssertEqual(SessionTimeoutValue.onAppRestart.rawValue, -1) + XCTAssertEqual(SessionTimeoutValue.never.rawValue, -2) + } +} diff --git a/AuthenticatorShared/Core/Platform/Services/StateService.swift b/AuthenticatorShared/Core/Platform/Services/StateService.swift index ab581458..ebd82651 100644 --- a/AuthenticatorShared/Core/Platform/Services/StateService.swift +++ b/AuthenticatorShared/Core/Platform/Services/StateService.swift @@ -17,7 +17,7 @@ protocol StateService: AnyObject { /// /// - Returns: The active user account id. /// - func getActiveAccountId() async throws -> String + func getActiveAccountId() async -> String /// Get the app theme. /// @@ -209,7 +209,7 @@ actor DefaultStateService: StateService { // MARK: Methods - func getActiveAccountId() async throws -> String { + func getActiveAccountId() async -> String { appSettingsStore.localUserId } diff --git a/AuthenticatorShared/Core/Platform/Services/Stores/AppSettingsStore.swift b/AuthenticatorShared/Core/Platform/Services/Stores/AppSettingsStore.swift index ae783a49..3d8aa47e 100644 --- a/AuthenticatorShared/Core/Platform/Services/Stores/AppSettingsStore.swift +++ b/AuthenticatorShared/Core/Platform/Services/Stores/AppSettingsStore.swift @@ -96,6 +96,13 @@ protocol AppSettingsStore: AnyObject { /// func isBiometricAuthenticationEnabled(userId: String) -> Bool + /// The user's last active time within the app. + /// This value is set when the app is backgrounded. + /// + /// - Parameter userId: The user ID associated with the last active time within the app. + /// + func lastActiveTime(userId: String) -> Date? + /// Sets a feature flag value in the app's settings store. /// /// This method updates or removes the value for a specified feature flag in the app's settings store. @@ -163,6 +170,14 @@ protocol AppSettingsStore: AnyObject { /// func setHasSyncedAccount(name: String) + /// Sets the last active time within the app. + /// + /// - Parameters: + /// - date: The current time. + /// - userId: The user ID associated with the last active time within the app. + /// + func setLastActiveTime(_ date: Date?, userId: String) + /// Sets the user's secret encryption key. /// /// - Parameters: @@ -178,11 +193,26 @@ protocol AppSettingsStore: AnyObject { /// - userId: The user ID. /// func setServerConfig(_ config: ServerConfig?, userId: String) + + /// Sets the user's session timeout, in minutes. + /// + /// - Parameters: + /// - key: The session timeout, in minutes. + /// - userId: The user ID associated with the session timeout. + /// + func setVaultTimeout(minutes: Int, userId: String) + + /// Returns the session timeout in minutes. + /// + /// - Parameter userId: The user ID associated with the session timeout. + /// - Returns: The user's session timeout in minutes. + /// + func vaultTimeout(userId: String) -> Int? } // MARK: - DefaultAppSettingsStore -/// A default `AppSetingsStore` which persists app settings in `UserDefaults`. +/// A default `AppSettingsStore` which persists app settings in `UserDefaults`. /// class DefaultAppSettingsStore { // MARK: Properties @@ -306,10 +336,12 @@ extension DefaultAppSettingsStore: AppSettingsStore { case disableWebIcons case hasSeenWelcomeTutorial case hasSyncedAccount(name: String) + case lastActiveTime(userId: String) case migrationVersion case preAuthServerConfig case secretKey(userId: String) case serverConfig(userId: String) + case vaultTimeout(userId: String) /// Returns the key used to store the data under for retrieving it later. var storageKey: String { @@ -339,6 +371,8 @@ extension DefaultAppSettingsStore: AppSettingsStore { key = "hasSeenWelcomeTutorial" case let .hasSyncedAccount(name: name): key = "hasSyncedAccount_\(name)" + case let .lastActiveTime(userId): + key = "lastActiveTime_\(userId)" case .migrationVersion: key = "migrationVersion" case .preAuthServerConfig: @@ -347,6 +381,8 @@ extension DefaultAppSettingsStore: AppSettingsStore { key = "secretKey_\(userId)" case let .serverConfig(userId): key = "serverConfig_\(userId)" + case let .vaultTimeout(userId): + key = "vaultTimeout_\(userId)" } return "bwaPreferencesStorage:\(key)" } @@ -435,6 +471,10 @@ extension DefaultAppSettingsStore: AppSettingsStore { fetch(for: .biometricAuthEnabled(userId: userId)) } + func lastActiveTime(userId: String) -> Date? { + fetch(for: .lastActiveTime(userId: userId)).map { Date(timeIntervalSince1970: $0) } + } + func overrideDebugFeatureFlag(name: String, value: Bool?) { store(value, for: .debugFeatureFlag(name: name)) } @@ -473,6 +513,10 @@ extension DefaultAppSettingsStore: AppSettingsStore { store(true, for: .hasSyncedAccount(name: name.hexSHA256Hash)) } + func setLastActiveTime(_ date: Date?, userId: String) { + store(date?.timeIntervalSince1970, for: .lastActiveTime(userId: userId)) + } + func setSecretKey(_ key: String, userId: String) { store(key, for: .secretKey(userId: userId)) } @@ -480,6 +524,14 @@ extension DefaultAppSettingsStore: AppSettingsStore { func setServerConfig(_ config: ServerConfig?, userId: String) { store(config, for: .serverConfig(userId: userId)) } + + func setVaultTimeout(minutes: Int, userId: String) { + store(minutes, for: .vaultTimeout(userId: userId)) + } + + func vaultTimeout(userId: String) -> Int? { + fetch(for: .vaultTimeout(userId: userId)) + } } /// An enumeration of possible item list cards. diff --git a/AuthenticatorShared/Core/Platform/Services/Stores/AppSettingsStoreTests.swift b/AuthenticatorShared/Core/Platform/Services/Stores/AppSettingsStoreTests.swift index a6a9ba82..4f239878 100644 --- a/AuthenticatorShared/Core/Platform/Services/Stores/AppSettingsStoreTests.swift +++ b/AuthenticatorShared/Core/Platform/Services/Stores/AppSettingsStoreTests.swift @@ -229,6 +229,23 @@ class AppSettingsStoreTests: AuthenticatorTestCase { XCTAssertFalse(subject.isBiometricAuthenticationEnabled(userId: "1")) } + /// `lastActiveTime(userId:)` returns `nil` if there isn't a previously stored value. + func test_lastActiveTime_isInitiallyNil() { + XCTAssertNil(subject.lastActiveTime(userId: "-1")) + } + + /// `lastActiveTime(userId:)` can be used to get the last active time for a user. + func test_lastActiveTime_withValue() { + let date1 = Date(year: 2023, month: 12, day: 1) + let date2 = Date(year: 2023, month: 10, day: 2) + + subject.setLastActiveTime(date1, userId: "1") + subject.setLastActiveTime(date2, userId: "2") + + XCTAssertEqual(subject.lastActiveTime(userId: "1"), date1) + XCTAssertEqual(subject.lastActiveTime(userId: "2"), date2) + } + /// `migrationVersion` returns `0` if there isn't a previously stored value. func test_migrationVersion_isInitiallyZero() { XCTAssertEqual(subject.migrationVersion, 0) @@ -244,4 +261,12 @@ class AppSettingsStoreTests: AuthenticatorTestCase { XCTAssertEqual(userDefaults.integer(forKey: "bwaPreferencesStorage:migrationVersion"), 2) XCTAssertEqual(subject.migrationVersion, 2) } + + /// `.vaultTimeout(userId:)` returns the correct vault timeout value. + func test_vaultTimeout() throws { + subject.setVaultTimeout(minutes: 60, userId: "1") + + XCTAssertEqual(subject.vaultTimeout(userId: "1"), 60) + XCTAssertEqual(userDefaults.double(forKey: "bwaPreferencesStorage:vaultTimeout_1"), 60) + } } diff --git a/AuthenticatorShared/Core/Platform/Services/Stores/TestHelpers/MockAppSettingsStore.swift b/AuthenticatorShared/Core/Platform/Services/Stores/TestHelpers/MockAppSettingsStore.swift index a9d39f2f..52d86b66 100644 --- a/AuthenticatorShared/Core/Platform/Services/Stores/TestHelpers/MockAppSettingsStore.swift +++ b/AuthenticatorShared/Core/Platform/Services/Stores/TestHelpers/MockAppSettingsStore.swift @@ -42,7 +42,7 @@ class MockAppSettingsStore: AppSettingsStore { var serverConfig = [String: ServerConfig]() var timeoutAction = [String: Int]() var twoFactorTokens = [String: String]() - var vaultTimeout = [String: Int?]() + var vaultTimeout = [String: Int]() var unsuccessfulUnlockAttempts = [String: Int]() @@ -62,6 +62,10 @@ class MockAppSettingsStore: AppSettingsStore { featureFlags[name] } + func lastActiveTime(userId: String) -> Date? { + lastActiveTime[userId] + } + func overrideDebugFeatureFlag(name: String, value: Bool?) { overrideDebugFeatureFlagCalled = true featureFlags[name] = value @@ -87,6 +91,10 @@ class MockAppSettingsStore: AppSettingsStore { clearClipboardValues[userId] = clearClipboardValue } + func setLastActiveTime(_ date: Date?, userId: String) { + lastActiveTime[userId] = date + } + func setSecretKey(_ key: String, userId: String) { secretKeys[userId] = key } @@ -94,6 +102,14 @@ class MockAppSettingsStore: AppSettingsStore { func setServerConfig(_ config: ServerConfig?, userId: String) { serverConfig[userId] = config } + + func setVaultTimeout(minutes: Int, userId: String) { + vaultTimeout[userId] = minutes + } + + func vaultTimeout(userId: String) -> Int? { + vaultTimeout[userId] + } } // MARK: Biometrics diff --git a/AuthenticatorShared/Core/Platform/Services/TestHelpers/MockStateService.swift b/AuthenticatorShared/Core/Platform/Services/TestHelpers/MockStateService.swift index 904701cf..f5fcbc0a 100644 --- a/AuthenticatorShared/Core/Platform/Services/TestHelpers/MockStateService.swift +++ b/AuthenticatorShared/Core/Platform/Services/TestHelpers/MockStateService.swift @@ -27,7 +27,7 @@ class MockStateService: StateService { lazy var appThemeSubject = CurrentValueSubject(self.appTheme ?? .default) - func getActiveAccountId() async throws -> String { + func getActiveAccountId() async -> String { "localtest" } diff --git a/AuthenticatorShared/UI/Platform/Application/AppCoordinator.swift b/AuthenticatorShared/UI/Platform/Application/AppCoordinator.swift index ece7b93d..919826b9 100644 --- a/AuthenticatorShared/UI/Platform/Application/AppCoordinator.swift +++ b/AuthenticatorShared/UI/Platform/Application/AppCoordinator.swift @@ -72,6 +72,8 @@ class AppCoordinator: Coordinator, HasRootNavigator { showTutorial() } } + case .vaultTimeout: + showAuth(.vaultUnlock) } } @@ -134,9 +136,9 @@ class AppCoordinator: Coordinator, HasRootNavigator { coordinator.start() coordinator.navigate(to: route) childCoordinator = coordinator - if rootNavigator.isPresenting { - rootNavigator.rootViewController?.dismiss(animated: true) - } + } + if let rootNavigator, rootNavigator.isPresenting { + rootNavigator.rootViewController?.dismiss(animated: true) } } diff --git a/AuthenticatorShared/UI/Platform/Application/AppProcessor.swift b/AuthenticatorShared/UI/Platform/Application/AppProcessor.swift index 115b28ab..0fc6351d 100644 --- a/AuthenticatorShared/UI/Platform/Application/AppProcessor.swift +++ b/AuthenticatorShared/UI/Platform/Application/AppProcessor.swift @@ -35,6 +35,23 @@ public class AppProcessor { UI.initialLanguageCode = services.appSettingsStore.appLocale ?? Locale.current.languageCode UI.applyDefaultAppearances() + + Task { + for await _ in services.notificationCenterService.willEnterForegroundPublisher().values { + let userId = await services.stateService.getActiveAccountId() + + if vaultHasTimedOut(userId: userId) { + await coordinator?.handleEvent(.vaultTimeout) + } + } + } + + Task { + for await _ in services.notificationCenterService.didEnterBackgroundPublisher().values { + let userId = await services.stateService.getActiveAccountId() + services.appSettingsStore.setLastActiveTime(.now, userId: userId) + } + } } // MARK: Methods @@ -80,4 +97,33 @@ public class AppProcessor { coordinator?.navigate(to: .debugMenu) } #endif + + /// Calculate if the user has passed the timeout set for their vault session. + /// + /// - Parameter userId: The active user. + /// - Returns: `true` if the user has timed out and needs to re-authenticate, + /// `false` if they are within their timeout session and can continue without re-authenticating. + /// + private func vaultHasTimedOut(userId: String) -> Bool { + guard let rawValue = services.appSettingsStore.vaultTimeout(userId: userId) else { + return false + } + let vaultTimeout = SessionTimeoutValue(rawValue: rawValue) + + switch vaultTimeout { + case .never, + .onAppRestart: + // For timeouts of `.never` or `.onAppRestart`, timeouts cannot be calculated. + // In these cases, return false. + return false + default: + // Otherwise, calculate a timeout. + guard let lastActiveTime = services.appSettingsStore.lastActiveTime(userId: userId) else { + return true + } + + return services.timeProvider.presentTime.timeIntervalSince(lastActiveTime) + >= TimeInterval(vaultTimeout.seconds) + } + } } diff --git a/AuthenticatorShared/UI/Platform/Application/AppProcessorTests.swift b/AuthenticatorShared/UI/Platform/Application/AppProcessorTests.swift index f747e4e2..dde0cd55 100644 --- a/AuthenticatorShared/UI/Platform/Application/AppProcessorTests.swift +++ b/AuthenticatorShared/UI/Platform/Application/AppProcessorTests.swift @@ -7,9 +7,10 @@ class AppProcessorTests: AuthenticatorTestCase { // MARK: Properties var appModule: MockAppModule! + var appSettingsStore: MockAppSettingsStore! var coordinator: MockCoordinator! var errorReporter: MockErrorReporter! - var router: MockRouter! + var notificationCenter: MockNotificationCenterService! var subject: AppProcessor! var timeProvider: MockTimeProvider! @@ -18,16 +19,20 @@ class AppProcessorTests: AuthenticatorTestCase { override func setUp() { super.setUp() - router = MockRouter(routeForEvent: { _ in .vaultUnlock }) appModule = MockAppModule() + appSettingsStore = MockAppSettingsStore() coordinator = MockCoordinator() errorReporter = MockErrorReporter() + notificationCenter = MockNotificationCenterService() timeProvider = MockTimeProvider(.currentTime) subject = AppProcessor( appModule: appModule, services: ServiceContainer.withMocks( - errorReporter: errorReporter + appSettingsStore: appSettingsStore, + errorReporter: errorReporter, + notificationCenterService: notificationCenter, + timeProvider: timeProvider ) ) subject.coordinator = coordinator.asAnyCoordinator() @@ -37,8 +42,172 @@ class AppProcessorTests: AuthenticatorTestCase { super.tearDown() appModule = nil + appSettingsStore = nil coordinator = nil + errorReporter = nil + notificationCenter = nil subject = nil timeProvider = nil } + + // MARK: Tests + + func test_background_storesLastActive() async throws { + await subject.start(appContext: .mainApp, + navigator: MockRootNavigator(), + window: window) + notificationCenter.didEnterBackgroundSubject.send() + let userId = appSettingsStore.localUserId + + try await waitForAsync { + self.appSettingsStore.lastActiveTime[userId] != nil + } + + XCTAssertNotNil(appSettingsStore.lastActiveTime[userId]) + } + + /// `showDebugMenu` will send the correct route to the coordinator. + @MainActor + func test_showDebugMenu() { + subject.showDebugMenu() + XCTAssertEqual(coordinator.routes.last, .debugMenu) + } + + /// When the timeout is set to `.never`, the `AppProcessor` **never** sends the `.vaultTimeout` event. + @MainActor + func test_vaultTimeout_never() async throws { + let userId = await subject.services.stateService.getActiveAccountId() + appSettingsStore.setLastActiveTime(.now.advanced(by: -3601), userId: userId) + appSettingsStore.setVaultTimeout(minutes: SessionTimeoutValue.never.rawValue, userId: userId) + + var notificationReceived = false + let publisher = notificationCenter.willEnterForegroundPublisher() + .sink { _ in + notificationReceived = true + } + defer { publisher.cancel() } + notificationCenter.willEnterForegroundSubject.send() + + try await waitForAsync { notificationReceived } + XCTAssertTrue(coordinator.events.isEmpty) + } + + /// When the timeout is not set (i.e. `nil`), the `AppProcessor` **does not** send the `.vaultTimeout` event. + @MainActor + func test_vaultTimeout_notSet() async throws { + let userId = await subject.services.stateService.getActiveAccountId() + appSettingsStore.setLastActiveTime(.now.advanced(by: -3601), userId: userId) + + var notificationReceived = false + let publisher = notificationCenter.willEnterForegroundPublisher() + .sink { _ in + notificationReceived = true + } + defer { publisher.cancel() } + notificationCenter.willEnterForegroundSubject.send() + + try await waitForAsync { notificationReceived } + XCTAssertTrue(coordinator.events.isEmpty) + } + + /// When the timeout is set to `.onAppRestart`, the `AppProcessor` does not send the `.vaultTimeout` event. + /// It will be handled instead when the Coordinator starts up. + @MainActor + func test_vaultTimeout_onAppRestart() async throws { + let userId = await subject.services.stateService.getActiveAccountId() + appSettingsStore.setLastActiveTime(.now.advanced(by: -3601), userId: userId) + appSettingsStore.setVaultTimeout(minutes: SessionTimeoutValue.onAppRestart.rawValue, userId: userId) + + var notificationReceived = false + let publisher = notificationCenter.willEnterForegroundPublisher() + .sink { _ in + notificationReceived = true + } + defer { publisher.cancel() } + notificationCenter.willEnterForegroundSubject.send() + + try await waitForAsync { notificationReceived } + XCTAssertTrue(coordinator.events.isEmpty) + } + + /// When the user has no previous `lastActiveTime` stored, the timeout always occurs. + @MainActor + func test_vaultTimeout_oneMinute_noLastActive() async throws { + let userId = await subject.services.stateService.getActiveAccountId() + appSettingsStore.lastActiveTime.removeValue(forKey: userId) + appSettingsStore.setVaultTimeout(minutes: 1, userId: userId) + + var notificationReceived = false + let publisher = notificationCenter.willEnterForegroundPublisher() + .sink { _ in + notificationReceived = true + } + defer { publisher.cancel() } + notificationCenter.willEnterForegroundSubject.send() + + try await waitForAsync { notificationReceived } + try await waitForAsync { !self.coordinator.events.isEmpty } + XCTAssertEqual(coordinator.events.last, .vaultTimeout) + } + + /// When the timeout has not yet passed, the `AppProcessor` does **not** send the `.vaultTimeout` event. + @MainActor + func test_vaultTimeout_oneMinute_notYetTimedOut() async throws { + let userId = await subject.services.stateService.getActiveAccountId() + appSettingsStore.setLastActiveTime(.now, userId: userId) + appSettingsStore.setVaultTimeout(minutes: 1, userId: userId) + + var notificationReceived = false + let publisher = notificationCenter.willEnterForegroundPublisher() + .sink { _ in + notificationReceived = true + } + defer { publisher.cancel() } + notificationCenter.willEnterForegroundSubject.send() + + try await waitForAsync { notificationReceived } + XCTAssertTrue(coordinator.events.isEmpty) + } + + /// When the one minute timeout length has passed, the `AppProcessor` sends the `.vaultTimeout` event. + @MainActor + func test_vaultTimeout_oneMinute_timeout() async throws { + let userId = await subject.services.stateService.getActiveAccountId() + appSettingsStore.setLastActiveTime(.now.advanced(by: -120), userId: userId) + appSettingsStore.setVaultTimeout(minutes: 1, userId: userId) + + var notificationReceived = false + let publisher = notificationCenter.willEnterForegroundPublisher() + .sink { _ in + notificationReceived = true + } + defer { publisher.cancel() } + notificationCenter.willEnterForegroundSubject.send() + + try await waitForAsync { notificationReceived } + try await waitForAsync { !self.coordinator.events.isEmpty } + XCTAssertEqual(coordinator.events.last, .vaultTimeout) + } + + /// When the one hour timeout length has passed, the `AppProcessor` sends the `.vaultTimeout` event. + @MainActor + func test_vaultTimeout_oneHour_timeout() async throws { + let userId = await subject.services.stateService.getActiveAccountId() + appSettingsStore.setLastActiveTime(.now.advanced(by: -3700), userId: userId) + appSettingsStore.setVaultTimeout(minutes: 60, userId: userId) + + notificationCenter.willEnterForegroundSubject.send() + + var notificationReceived = false + let publisher = notificationCenter.willEnterForegroundPublisher() + .sink { _ in + notificationReceived = true + } + defer { publisher.cancel() } + notificationCenter.willEnterForegroundSubject.send() + + try await waitForAsync { notificationReceived } + try await waitForAsync { !self.coordinator.events.isEmpty } + XCTAssertEqual(coordinator.events.last, .vaultTimeout) + } } diff --git a/AuthenticatorShared/UI/Platform/Application/Support/Localizations/en.lproj/Localizable.strings b/AuthenticatorShared/UI/Platform/Application/Support/Localizations/en.lproj/Localizable.strings index 45ac65aa..0098a136 100644 --- a/AuthenticatorShared/UI/Platform/Application/Support/Localizations/en.lproj/Localizable.strings +++ b/AuthenticatorShared/UI/Platform/Application/Support/Localizations/en.lproj/Localizable.strings @@ -142,3 +142,12 @@ "SaveToBitwarden" = "Save to Bitwarden"; "None" = "None"; "UnableToSyncCodesFromTheBitwardenApp" = "Unable to sync codes from the Bitwarden app. Make sure both apps are up-to-date. You can still access your existing codes in the Bitwarden app."; +"FifteenMinutes" = "15 minutes"; +"OneHour" = "1 hour"; +"OneMinute" = "1 minute"; +"FourHours" = "4 hours"; +"Immediately" = "Immediately"; +"VaultTimeout" = "Vault timeout"; +"ThirtyMinutes" = "30 minutes"; +"OnRestart" = "On app restart"; +"SessionTimeout" = "Session timeout"; diff --git a/AuthenticatorShared/UI/Platform/Application/Utilities/AppRoute.swift b/AuthenticatorShared/UI/Platform/Application/Utilities/AppRoute.swift index 33e98c73..77fd4684 100644 --- a/AuthenticatorShared/UI/Platform/Application/Utilities/AppRoute.swift +++ b/AuthenticatorShared/UI/Platform/Application/Utilities/AppRoute.swift @@ -11,4 +11,7 @@ public enum AppRoute: Equatable { public enum AppEvent: Equatable { /// When the app has started. case didStart + + /// When the user returns to the app and their vault timeout has passed. + case vaultTimeout } diff --git a/AuthenticatorShared/UI/Platform/Settings/Settings/SettingsEffect.swift b/AuthenticatorShared/UI/Platform/Settings/Settings/SettingsEffect.swift index 81b73e57..9a566466 100644 --- a/AuthenticatorShared/UI/Platform/Settings/Settings/SettingsEffect.swift +++ b/AuthenticatorShared/UI/Platform/Settings/Settings/SettingsEffect.swift @@ -1,10 +1,13 @@ // MARK: - SettingsEffect /// Effects that can be processed by an `SettingsProcessor`. -enum SettingsEffect { +enum SettingsEffect: Equatable { /// The view appeared so the initial data should be loaded. case loadData + /// The session timeout value was changed. + case sessionTimeoutValueChanged(SessionTimeoutValue) + /// Unlock with Biometrics was toggled. case toggleUnlockWithBiometrics(Bool) } diff --git a/AuthenticatorShared/UI/Platform/Settings/Settings/SettingsProcessor.swift b/AuthenticatorShared/UI/Platform/Settings/Settings/SettingsProcessor.swift index 492e6ded..0770e896 100644 --- a/AuthenticatorShared/UI/Platform/Settings/Settings/SettingsProcessor.swift +++ b/AuthenticatorShared/UI/Platform/Settings/Settings/SettingsProcessor.swift @@ -50,6 +50,18 @@ final class SettingsProcessor: StateProcessor SessionTimeoutValue { + guard biometricsEnabled else { return .never } + + let accountId = services.appSettingsStore.localUserId + if let timeout = services.appSettingsStore.vaultTimeout(userId: accountId) { + return SessionTimeoutValue(rawValue: timeout) + } else { + return .onAppRestart + } + } + /// Sets the user's biometric auth /// /// - Parameter enabled: Whether or not the the user wants biometric auth enabled. @@ -147,6 +177,16 @@ final class SettingsProcessor: StateProcessor! var subject: SettingsProcessor! @@ -20,6 +21,7 @@ class SettingsProcessorTests: AuthenticatorTestCase { application = MockApplication() appSettingsStore = MockAppSettingsStore() authItemRepository = MockAuthenticatorItemRepository() + biometricsRepository = MockBiometricsRepository() configService = MockConfigService() coordinator = MockCoordinator() subject = SettingsProcessor( @@ -28,6 +30,7 @@ class SettingsProcessorTests: AuthenticatorTestCase { application: application, appSettingsStore: appSettingsStore, authenticatorItemRepository: authItemRepository, + biometricsRepository: biometricsRepository, configService: configService ), state: SettingsState() @@ -40,6 +43,7 @@ class SettingsProcessorTests: AuthenticatorTestCase { application = nil appSettingsStore = nil authItemRepository = nil + biometricsRepository = nil configService = nil coordinator = nil subject = nil @@ -100,6 +104,101 @@ class SettingsProcessorTests: AuthenticatorTestCase { XCTAssertTrue(subject.state.shouldShowSyncButton) } + /// Performing `.loadData` sets the session timeout to `.never` if biometrics are disabled. + func test_perform_loadData_vaultTimeout_biometricsDisabled() async throws { + biometricsRepository.biometricUnlockStatus = .success( + .available(.faceID, enabled: false, hasValidIntegrity: true) + ) + appSettingsStore.setVaultTimeout(minutes: 15, userId: appSettingsStore.localUserId) + await subject.perform(.loadData) + XCTAssertEqual(subject.state.sessionTimeoutValue, .never) + } + + /// Performing `.loadData` sets the session timeout correctly when it is set in app settings.. + func test_perform_loadData_vaultTimeout_fifteenMinutes() async throws { + biometricsRepository.biometricUnlockStatus = .success( + .available(.faceID, enabled: true, hasValidIntegrity: true) + ) + appSettingsStore.setVaultTimeout(minutes: 15, userId: appSettingsStore.localUserId) + await subject.perform(.loadData) + XCTAssertEqual(subject.state.sessionTimeoutValue, .fifteenMinutes) + } + + /// Performing `.loadData` sets the session timeout to `.never` when there is no timeout + /// set and biometrics is not available or not enabled. + func test_perform_loadData_vaultTimeout_nil() async throws { + await subject.perform(.loadData) + XCTAssertEqual(subject.state.sessionTimeoutValue, .never) + } + + /// Performing `.loadData` sets the session timeout to `.onAppRestart` when there is no timeout + /// set and biometrics is turned enabled. + func test_perform_loadData_vaultTimeout_nilWithBiometrics() async throws { + biometricsRepository.biometricUnlockStatus = .success( + .available(.faceID, enabled: true, hasValidIntegrity: true) + ) + await subject.perform(.loadData) + XCTAssertEqual(subject.state.sessionTimeoutValue, .onAppRestart) + } + + /// Receiving `.sessionTimeoutValueChanged` when a user has not yet enabled biometrics enables + /// biometrics and sets the value. + /// + func test_perform_sessionTimeoutValueChanged_biometricsDisabled() async throws { + biometricsRepository.biometricUnlockStatus = .success( + .available(.faceID, enabled: true, hasValidIntegrity: true) + ) + subject.state.biometricUnlockStatus = .available(.faceID, enabled: false, hasValidIntegrity: true) + subject.state.sessionTimeoutValue = .never + await subject.perform(.sessionTimeoutValueChanged(.fifteenMinutes)) + + XCTAssertNotNil(biometricsRepository.capturedUserAuthKey) + XCTAssertEqual(appSettingsStore.vaultTimeout(userId: appSettingsStore.localUserId), 15) + XCTAssertEqual(subject.state.sessionTimeoutValue, .fifteenMinutes) + } + + /// Receiving `.sessionTimeoutValueChanged` updates the user's `vaultTimeout` app setting. + func test_perform_sessionTimeoutValueChanged_success() async throws { + biometricsRepository.biometricUnlockStatus = .success( + .available(.faceID, enabled: true, hasValidIntegrity: true) + ) + subject.state.biometricUnlockStatus = .available(.faceID, enabled: true, hasValidIntegrity: true) + subject.state.sessionTimeoutValue = .oneHour + await subject.perform(.sessionTimeoutValueChanged(.fifteenMinutes)) + + XCTAssertEqual(appSettingsStore.vaultTimeout(userId: appSettingsStore.localUserId), 15) + XCTAssertEqual(subject.state.sessionTimeoutValue, .fifteenMinutes) + } + + /// Performing `.toggleUnlockWithBiometrics` with a `false` value disables biometric unlock and resets the + /// session timeout to `.never` + func test_perform_toggleUnlockWithBiometrics_off() async throws { + biometricsRepository.capturedUserAuthKey = "key" + biometricsRepository.biometricUnlockStatus = .success( + .available(.faceID, enabled: true, hasValidIntegrity: true) + ) + subject.state.sessionTimeoutValue = .fifteenMinutes + appSettingsStore.setVaultTimeout(minutes: 15, userId: appSettingsStore.localUserId) + + await subject.perform(.toggleUnlockWithBiometrics(false)) + + XCTAssertNil(biometricsRepository.capturedUserAuthKey) + XCTAssertEqual(subject.state.sessionTimeoutValue, .never) + } + + /// Performing `.toggleUnlockWithBiometrics` with a `true` value enables biometric unlock and defaults the + /// session timeout to `.onAppRestart`. + func test_perform_toggleUnlockWithBiometrics_on() async throws { + biometricsRepository.biometricUnlockStatus = .success( + .available(.faceID, enabled: true, hasValidIntegrity: true) + ) + + await subject.perform(.toggleUnlockWithBiometrics(true)) + + XCTAssertNotNil(biometricsRepository.capturedUserAuthKey) + XCTAssertEqual(subject.state.sessionTimeoutValue, .onAppRestart) + } + /// Receiving `.backupTapped` shows an alert for the backup information. func test_receive_backupTapped() async throws { subject.receive(.backupTapped) diff --git a/AuthenticatorShared/UI/Platform/Settings/Settings/SettingsState.swift b/AuthenticatorShared/UI/Platform/Settings/Settings/SettingsState.swift index bf10fbe0..fd521fd3 100644 --- a/AuthenticatorShared/UI/Platform/Settings/Settings/SettingsState.swift +++ b/AuthenticatorShared/UI/Platform/Settings/Settings/SettingsState.swift @@ -20,6 +20,9 @@ struct SettingsState: Equatable { /// The current default save option. var defaultSaveOption: DefaultSaveOption = .none + /// The current default save option. + var sessionTimeoutValue: SessionTimeoutValue = .never + /// A flag to indicate if we should show the default save option menu. /// Defaults to false, which indicates we should not show the menu. var shouldShowDefaultSaveOption = false diff --git a/AuthenticatorShared/UI/Platform/Settings/Settings/SettingsView.swift b/AuthenticatorShared/UI/Platform/Settings/Settings/SettingsView.swift index da0ebdb8..f52f9db0 100644 --- a/AuthenticatorShared/UI/Platform/Settings/Settings/SettingsView.swift +++ b/AuthenticatorShared/UI/Platform/Settings/Settings/SettingsView.swift @@ -41,8 +41,20 @@ struct SettingsView: View { switch store.state.biometricUnlockStatus { case let .available(type, enabled: enabled, _): SectionView(Localizations.security) { - VStack(spacing: 0) { + VStack(spacing: 8) { biometricUnlockToggle(enabled: enabled, type: type) + SettingsMenuField( + title: Localizations.sessionTimeout, + options: SessionTimeoutValue.allCases, + hasDivider: false, + accessibilityIdentifier: "VaultTimeoutChooser", + selectionAccessibilityID: "SessionTimeoutStatusLabel", + selection: store.bindingAsync( + get: \.sessionTimeoutValue, + perform: SettingsEffect.sessionTimeoutValueChanged + ) + ) + .clipShape(RoundedRectangle(cornerRadius: 10)) } } .padding(.bottom, 32) diff --git a/AuthenticatorShared/UI/Platform/Settings/Settings/SettingsViewTests.swift b/AuthenticatorShared/UI/Platform/Settings/Settings/SettingsViewTests.swift index b9f89f02..1dbddc3b 100644 --- a/AuthenticatorShared/UI/Platform/Settings/Settings/SettingsViewTests.swift +++ b/AuthenticatorShared/UI/Platform/Settings/Settings/SettingsViewTests.swift @@ -42,6 +42,13 @@ class SettingsViewTests: AuthenticatorTestCase { XCTAssertEqual(processor.dispatchedActions.last, .appThemeChanged(.dark)) } + /// Tapping the backup button dispatches the `.backupTapped` action. + func test_backupButton_tap() throws { + let button = try subject.inspect().find(button: Localizations.backup) + try button.tap() + XCTAssertEqual(processor.dispatchedActions.last, .backupTapped) + } + /// Updating the value of the default save option sends the `.defaultSaveOptionChanged()` action. func test_defaultSaveOptionChanged_updateValue() throws { processor.state.shouldShowDefaultSaveOption = true @@ -52,13 +59,6 @@ class SettingsViewTests: AuthenticatorTestCase { XCTAssertEqual(processor.dispatchedActions.last, .defaultSaveChanged(.saveToBitwarden)) } - /// Tapping the backup button dispatches the `.backupTapped` action. - func test_backupButton_tap() throws { - let button = try subject.inspect().find(button: Localizations.backup) - try button.tap() - XCTAssertEqual(processor.dispatchedActions.last, .backupTapped) - } - /// Tapping the export button dispatches the `.exportItemsTapped` action. func test_exportButton_tap() throws { let button = try subject.inspect().find(button: Localizations.export) @@ -80,6 +80,17 @@ class SettingsViewTests: AuthenticatorTestCase { XCTAssertEqual(processor.dispatchedActions.last, .privacyPolicyTapped) } + /// Updating the value of the `sessionTimeoutValue` sends the `.sessionTimeoutValueChanged()` action. + func test_sessionTimeoutValue_updateValue() throws { + processor.state.biometricUnlockStatus = .available(.faceID, enabled: false, hasValidIntegrity: true) + processor.state.sessionTimeoutValue = .never + let menuField = try subject.inspect().find(settingsMenuField: Localizations.sessionTimeout) + try menuField.select(newValue: SessionTimeoutValue.fifteenMinutes) + + waitFor(!processor.effects.isEmpty) + XCTAssertEqual(processor.effects.last, .sessionTimeoutValueChanged(.fifteenMinutes)) + } + /// Tapping the sync with Bitwarden app button dispatches the `.syncWithBitwardenAppTapped` action. func test_syncWithBitwardenButton_tap() throws { processor.state.shouldShowSyncButton = true @@ -110,6 +121,15 @@ class SettingsViewTests: AuthenticatorTestCase { ) } + /// Tests the view renders correctly. + func test_viewRenderWithBiometricsAvailable() { + processor.state.biometricUnlockStatus = .available(.faceID, enabled: false, hasValidIntegrity: true) + assertSnapshots( + of: subject, + as: [.defaultPortrait, .defaultPortraitDark, .defaultPortraitAX5] + ) + } + /// Tests the view renders correctly with the `shouldShowSyncButton` set to `true`. func test_viewRenderWithSyncRow() { processor.state.shouldShowSyncButton = true diff --git a/AuthenticatorShared/UI/Platform/Settings/Settings/__Snapshots__/SettingsViewTests/test_viewRenderWithBiometricsAvailable.1.png b/AuthenticatorShared/UI/Platform/Settings/Settings/__Snapshots__/SettingsViewTests/test_viewRenderWithBiometricsAvailable.1.png new file mode 100644 index 00000000..80b2931a Binary files /dev/null and b/AuthenticatorShared/UI/Platform/Settings/Settings/__Snapshots__/SettingsViewTests/test_viewRenderWithBiometricsAvailable.1.png differ diff --git a/AuthenticatorShared/UI/Platform/Settings/Settings/__Snapshots__/SettingsViewTests/test_viewRenderWithBiometricsAvailable.2.png b/AuthenticatorShared/UI/Platform/Settings/Settings/__Snapshots__/SettingsViewTests/test_viewRenderWithBiometricsAvailable.2.png new file mode 100644 index 00000000..7d90016e Binary files /dev/null and b/AuthenticatorShared/UI/Platform/Settings/Settings/__Snapshots__/SettingsViewTests/test_viewRenderWithBiometricsAvailable.2.png differ diff --git a/AuthenticatorShared/UI/Platform/Settings/Settings/__Snapshots__/SettingsViewTests/test_viewRenderWithBiometricsAvailable.3.png b/AuthenticatorShared/UI/Platform/Settings/Settings/__Snapshots__/SettingsViewTests/test_viewRenderWithBiometricsAvailable.3.png new file mode 100644 index 00000000..df12a00b Binary files /dev/null and b/AuthenticatorShared/UI/Platform/Settings/Settings/__Snapshots__/SettingsViewTests/test_viewRenderWithBiometricsAvailable.3.png differ