Skip to content

Commit

Permalink
[BWA-82] Add Configurable Timeout Length (#203)
Browse files Browse the repository at this point in the history
  • Loading branch information
brant-livefront authored Dec 4, 2024
1 parent 5fb5449 commit 66cb740
Show file tree
Hide file tree
Showing 22 changed files with 704 additions and 24 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
4 changes: 2 additions & 2 deletions AuthenticatorShared/Core/Platform/Services/StateService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand Down Expand Up @@ -209,7 +209,7 @@ actor DefaultStateService: StateService {

// MARK: Methods

func getActiveAccountId() async throws -> String {
func getActiveAccountId() async -> String {
appSettingsStore.localUserId
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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:
Expand All @@ -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)"
}
Expand Down Expand Up @@ -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))
}
Expand Down Expand Up @@ -473,13 +513,25 @@ 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))
}

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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
}
}
Loading

0 comments on commit 66cb740

Please sign in to comment.