[PM-23300] feat: Store session values to keychain so they can be shared between app and autofill (#2239)

This commit is contained in:
Katherine Bertelsen 2026-01-30 11:51:08 -06:00 committed by GitHub
parent 1ba8503849
commit 317bb15961
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 1140 additions and 525 deletions

View File

@ -480,6 +480,9 @@ class DefaultAuthRepository {
/// The service used by the application to manage trust device information.
private let trustDeviceService: TrustDeviceService
/// The service used by the application to manage user session state.
private let userSessionStateService: UserSessionStateService
/// The service used by the application to manage vault access.
private let vaultTimeoutService: VaultTimeoutService
@ -507,6 +510,7 @@ class DefaultAuthRepository {
/// - policyService: The service used by the application to manage the policy.
/// - stateService: The service used by the application to manage account state.
/// - trustDeviceService: The service used by the application to manage trust device information.
/// - userSessionStateService: The service used by the application to manage user session state.
/// - vaultTimeoutService: The service used by the application to manage vault access.
///
init(
@ -528,6 +532,7 @@ class DefaultAuthRepository {
policyService: PolicyService,
stateService: StateService,
trustDeviceService: TrustDeviceService,
userSessionStateService: UserSessionStateService,
vaultTimeoutService: VaultTimeoutService,
) {
self.accountAPIService = accountAPIService
@ -548,6 +553,7 @@ class DefaultAuthRepository {
self.policyService = policyService
self.stateService = stateService
self.trustDeviceService = trustDeviceService
self.userSessionStateService = userSessionStateService
self.vaultTimeoutService = vaultTimeoutService
}
}
@ -1124,7 +1130,7 @@ extension DefaultAuthRepository: AuthRepository {
private func profileItem(from account: Account) async -> ProfileSwitcherItem {
let isLocked = await (try? isLocked(userId: account.profile.userId)) ?? true
let isAuthenticated = await (try? stateService.isAuthenticated(userId: account.profile.userId)) == true
let hasNeverLock = await (try? stateService.getVaultTimeout(userId: account.profile.userId)) == .never
let hasNeverLock = await (try? userSessionStateService.getVaultTimeout(userId: account.profile.userId)) == .never
let isManuallyLocked = await (try? stateService.getManuallyLockedAccount(
userId: account.profile.userId,
)) == true

View File

@ -29,8 +29,9 @@ class AuthRepositoryTests: BitwardenTestCase { // swiftlint:disable:this type_bo
var policyService: MockPolicyService!
var subject: DefaultAuthRepository!
var stateService: MockStateService!
var vaultTimeoutService: MockVaultTimeoutService!
var trustDeviceService: MockTrustDeviceService!
var userSessionStateService: MockUserSessionStateService!
var vaultTimeoutService: MockVaultTimeoutService!
let anneAccount = Account
.fixture(
@ -110,8 +111,12 @@ class AuthRepositoryTests: BitwardenTestCase { // swiftlint:disable:this type_bo
policyService = MockPolicyService()
stateService = MockStateService()
trustDeviceService = MockTrustDeviceService()
userSessionStateService = MockUserSessionStateService()
vaultTimeoutService = MockVaultTimeoutService()
userSessionStateService.getVaultTimeoutReturnValue = .fifteenMinutes
userSessionStateService.getUnsuccessfulUnlockAttemptsReturnValue = 0
subject = DefaultAuthRepository(
accountAPIService: accountAPIService,
appContextHelper: appContextHelper,
@ -131,6 +136,7 @@ class AuthRepositoryTests: BitwardenTestCase { // swiftlint:disable:this type_bo
policyService: policyService,
stateService: stateService,
trustDeviceService: trustDeviceService,
userSessionStateService: userSessionStateService,
vaultTimeoutService: vaultTimeoutService,
)
}
@ -694,7 +700,7 @@ class AuthRepositoryTests: BitwardenTestCase { // swiftlint:disable:this type_bo
stateService.accounts = [anneAccount]
stateService.activeAccount = anneAccount
stateService.timeoutAction = [anneAccount.profile.userId: .logout]
stateService.vaultTimeout = [anneAccount.profile.userId: .onAppRestart]
userSessionStateService.getVaultTimeoutReturnValue = .onAppRestart
vaultTimeoutService.shouldSessionTimeout[anneAccount.profile.userId] = true
stateService.isAuthenticated[anneAccount.profile.userId] = true
@ -713,7 +719,7 @@ class AuthRepositoryTests: BitwardenTestCase { // swiftlint:disable:this type_bo
stateService.accounts = [anneAccount]
stateService.activeAccount = anneAccount
stateService.timeoutAction = [anneAccount.profile.userId: .logout]
stateService.vaultTimeout = [anneAccount.profile.userId: .onAppRestart]
userSessionStateService.getVaultTimeoutReturnValue = .onAppRestart
vaultTimeoutService.shouldSessionTimeout[anneAccount.profile.userId] = false
stateService.isAuthenticated[anneAccount.profile.userId] = true
@ -964,13 +970,17 @@ class AuthRepositoryTests: BitwardenTestCase { // swiftlint:disable:this type_bo
shortEmail.profile.userId: false,
shortName.profile.userId: true,
]
stateService.vaultTimeout = [
anneAccount.profile.userId: .never,
beeAccount.profile.userId: .never,
empty.profile.userId: .never,
shortEmail.profile.userId: .never,
shortName.profile.userId: .fifteenMinutes,
]
userSessionStateService.getVaultTimeoutClosure = { [weak self] userId in
guard let self else { return .fourHours }
switch userId {
case anneAccount.profile.userId: return .never
case beeAccount.profile.userId: return .never
case empty.profile.userId: return .never
case shortEmail.profile.userId: return .never
case shortName.profile.userId: return .fifteenMinutes
default: return .fourHours
}
}
stateService.manuallyLockedAccounts = [
anneAccount.profile.userId: true,
beeAccount.profile.userId: false,
@ -2459,8 +2469,7 @@ class AuthRepositoryTests: BitwardenTestCase { // swiftlint:disable:this type_bo
// `unlockVaultWithPassword(_:)` unlocks the vault with the user's password and checks if the
// user's KDF settings need to be updated. If updating the user's KDF fails, an error is logged
// but vault unlock still succeeds.
func test_unlockVaultWithPassword_checksForKdfUpdate_error() async throws {
// swiftlint:disable:previous function_body_length
func test_unlockVaultWithPassword_checksForKdfUpdate_error() async throws { // swiftlint:disable:this function_body_length line_length
let account = Account.fixture(profile: .fixture(
kdfIterations: 100_000,
userDecryptionOptions: UserDecryptionOptions(

View File

@ -0,0 +1,187 @@
// swiftlint:disable:this file_name
import BitwardenKit
import XCTest
@testable import BitwardenShared
// MARK: - KeychainRepositoryUserSessionTests
final class KeychainRepositoryUserSessionTests: BitwardenTestCase {
// MARK: Properties
var appSettingsStore: MockAppSettingsStore!
var keychainService: MockKeychainService!
var subject: DefaultKeychainRepository!
// MARK: Setup & Teardown
override func setUp() {
super.setUp()
appSettingsStore = MockAppSettingsStore()
keychainService = MockKeychainService()
subject = DefaultKeychainRepository(
appIdService: AppIdService(
appSettingStore: appSettingsStore,
),
keychainService: keychainService,
)
}
override func tearDown() {
super.tearDown()
appSettingsStore = nil
keychainService = nil
subject = nil
}
// MARK: Tests - Last Active Time
/// `getLastActiveTime(userId:)` returns the stored last active time.
///
func test_getLastActiveTime() async throws {
keychainService.setSearchResultData(string: "1234567890")
let lastActiveTime = try await subject.getLastActiveTime(userId: "1")
XCTAssertEqual(lastActiveTime, Date(timeIntervalSince1970: 1_234_567_890))
}
/// `getLastActiveTime(userId:)` throws an error if one occurs.
///
func test_getLastActiveTime_error() async {
let error = KeychainServiceError.keyNotFound(KeychainItem.lastActiveTime(userId: "1"))
keychainService.searchResult = .failure(error)
await assertAsyncThrows(error: error) {
_ = try await subject.getLastActiveTime(userId: "1")
}
}
/// `setLastActiveTime(_:userId:)` stores the last active time with correct attributes.
///
func test_setLastActiveTime() async throws {
keychainService.accessControlResult = .success(
SecAccessControlCreateWithFlags(
nil,
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
[],
nil,
)!,
)
keychainService.setSearchResultData(string: "1234567890")
try await subject.setLastActiveTime(Date(timeIntervalSince1970: 1_234_567_890), userId: "1")
let attributes = try XCTUnwrap(keychainService.addAttributes) as Dictionary
try XCTAssertEqual(
String(data: XCTUnwrap(attributes[kSecValueData] as? Data), encoding: .utf8),
"1234567890.0",
)
let protection = try XCTUnwrap(keychainService.accessControlProtection as? String)
XCTAssertEqual(protection, String(kSecAttrAccessibleWhenUnlockedThisDeviceOnly))
}
/// `setLastActiveTime(_:userId:)` throws an error if one occurs.
///
func test_setLastActiveTime_error() async {
let error = KeychainServiceError.accessControlFailed(nil)
keychainService.accessControlResult = .failure(error)
await assertAsyncThrows(error: error) {
try await subject.setLastActiveTime(Date(timeIntervalSince1970: 1_234_567_890), userId: "1")
}
}
// MARK: Tests - Unsuccessful Unlock Attempts
/// `getUnsuccessfulUnlockAttempts(userId:)` returns the stored value of unsuccessful unlock attempts.
func test_getUnsuccessfulUnlockAttempts() async throws {
keychainService.setSearchResultData(string: "4")
let attempts = try await subject.getUnsuccessfulUnlockAttempts(userId: "1")
XCTAssertEqual(attempts, 4)
}
/// `getUnsuccessfulUnlockAttempts(userId:)` throws an error if one occurs.
///
func test_getUnsuccessfulUnlockAttempts_error() async {
let error = KeychainServiceError.keyNotFound(KeychainItem.unsuccessfulUnlockAttempts(userId: "1"))
keychainService.searchResult = .failure(error)
await assertAsyncThrows(error: error) {
_ = try await subject.getUnsuccessfulUnlockAttempts(userId: "1")
}
}
/// `setUnsuccessfulUnlockAttempts(_:userId:)` stores the number of unsuccessful unlock attempts.
///
func test_setUnsuccessfulUnlockAttempts() async throws {
keychainService.accessControlResult = .success(
SecAccessControlCreateWithFlags(
nil,
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
[],
nil,
)!,
)
keychainService.setSearchResultData(string: "2")
try await subject.setUnsuccessfulUnlockAttempts(3, userId: "1")
let attributes = try XCTUnwrap(keychainService.addAttributes) as Dictionary
try XCTAssertEqual(
String(data: XCTUnwrap(attributes[kSecValueData] as? Data), encoding: .utf8),
"3",
)
let protection = try XCTUnwrap(keychainService.accessControlProtection as? String)
XCTAssertEqual(protection, String(kSecAttrAccessibleWhenUnlockedThisDeviceOnly))
}
// MARK: Tests - Vault Timeout
/// `getVaultTimeout(userId:)` returns the stored vault timeout.
///
func test_getVaultTimeout() async throws {
keychainService.setSearchResultData(string: "15")
let vaultTimeout = try await subject.getVaultTimeout(userId: "1")
XCTAssertEqual(vaultTimeout, 15)
}
/// `getVaultTimeout(userId:)` throws an error if one occurs.
///
func test_getVaultTimeout_error() async {
let error = KeychainServiceError.keyNotFound(KeychainItem.vaultTimeout(userId: "1"))
keychainService.searchResult = .failure(error)
await assertAsyncThrows(error: error) {
_ = try await subject.getVaultTimeout(userId: "1")
}
}
/// `setVaultTimeout(_:userId:)` stores the vault timeout with correct attributes.
///
func test_setVaultTimeout() async throws {
keychainService.accessControlResult = .success(
SecAccessControlCreateWithFlags(
nil,
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
[],
nil,
)!,
)
keychainService.setSearchResultData(string: "30")
try await subject.setVaultTimeout(minutes: 30, userId: "1")
let attributes = try XCTUnwrap(keychainService.addAttributes) as Dictionary
try XCTAssertEqual(
String(data: XCTUnwrap(attributes[kSecValueData] as? Data), encoding: .utf8),
"30",
)
let protection = try XCTUnwrap(keychainService.accessControlProtection as? String)
XCTAssertEqual(protection, String(kSecAttrAccessibleWhenUnlockedThisDeviceOnly))
}
/// `setVaultTimeout(_:userId:)` throws an error if one occurs.
///
func test_setVaultTimeout_accessControlError() async {
let error = KeychainServiceError.accessControlFailed(nil)
keychainService.accessControlResult = .failure(error)
await assertAsyncThrows(error: error) {
try await subject.setVaultTimeout(minutes: 30, userId: "1")
}
}
}

View File

@ -18,6 +18,9 @@ enum KeychainItem: Equatable, KeychainStorageKeyPossessing {
/// The keychain item for device key.
case deviceKey(userId: String)
/// The keychain item for a user's last active time.
case lastActiveTime(userId: String)
/// The keychain item for the neverLock user auth key.
case neverLock(userId: String)
@ -27,6 +30,12 @@ enum KeychainItem: Equatable, KeychainStorageKeyPossessing {
/// The keychain item for a user's refresh token.
case refreshToken(userId: String)
/// The keychain item for the number of unsuccessful unlock attempts.
case unsuccessfulUnlockAttempts(userId: String)
/// The keychain item for a user's vault timeout.
case vaultTimeout(userId: String)
/// The `SecAccessControlCreateFlags` level for this keychain item.
/// If `nil`, no extra protection is applied.
///
@ -35,9 +44,12 @@ enum KeychainItem: Equatable, KeychainStorageKeyPossessing {
case .accessToken,
.authenticatorVaultKey,
.deviceKey,
.lastActiveTime,
.neverLock,
.pendingAdminLoginRequest,
.refreshToken:
.refreshToken,
.unsuccessfulUnlockAttempts,
.vaultTimeout:
nil
case .biometrics:
.biometryCurrentSet
@ -49,8 +61,11 @@ enum KeychainItem: Equatable, KeychainStorageKeyPossessing {
switch self {
case .biometrics,
.deviceKey,
.lastActiveTime,
.neverLock,
.pendingAdminLoginRequest:
.pendingAdminLoginRequest,
.unsuccessfulUnlockAttempts,
.vaultTimeout:
kSecAttrAccessibleWhenUnlockedThisDeviceOnly
case .accessToken,
.authenticatorVaultKey,
@ -71,12 +86,18 @@ enum KeychainItem: Equatable, KeychainStorageKeyPossessing {
"userKeyBiometricUnlock_" + id
case let .deviceKey(userId: id):
"deviceKey_" + id
case let .lastActiveTime(userId):
"lastActiveTime_\(userId)"
case let .neverLock(userId: id):
"userKeyAutoUnlock_" + id
case let .pendingAdminLoginRequest(userId):
"pendingAdminLoginRequest_\(userId)"
case let .refreshToken(userId):
"refreshToken_\(userId)"
case let .unsuccessfulUnlockAttempts(userId):
"unsuccessfulUnlockAttempts_\(userId)"
case let .vaultTimeout(userId):
"vaultTimeout_\(userId)"
}
}
}
@ -100,12 +121,6 @@ protocol KeychainRepository: AnyObject {
///
func deleteItems(for userId: String) async throws
/// Attempts to delete the userAuthKey from the keychain.
///
/// - Parameter item: The KeychainItem to be deleted.
///
func deleteUserAuthKey(for item: KeychainItem) async throws
/// Attempts to delete the device key from the keychain.
///
/// - Parameter userId: The user ID associated with the stored device key.
@ -118,6 +133,12 @@ protocol KeychainRepository: AnyObject {
///
func deletePendingAdminLoginRequest(userId: String) async throws
/// 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 access token for a user from the keychain.
///
/// - Parameter userId: The user ID associated with the stored access token.
@ -382,10 +403,13 @@ extension DefaultKeychainRepository {
.authenticatorVaultKey(userId: userId),
.biometrics(userId: userId),
// Exclude `deviceKey` since it is used to log back into an account.
.lastActiveTime(userId: userId),
.neverLock(userId: userId),
// Exclude `pendingAdminLoginRequest` since if a TDE user is logged out before the request
// is approved, the next login for the user will succeed with the pending request.
.refreshToken(userId: userId),
.unsuccessfulUnlockAttempts(userId: userId),
// Exclude `vaultTimeout` since it should be maintained for users who log out and back in regularly.
]
for keychainItem in keychainItems {
try await keychainService.delete(query: keychainQueryValues(for: keychainItem))
@ -477,3 +501,46 @@ extension DefaultKeychainRepository: BiometricsKeychainRepository {
try await setUserAuthKey(for: key, value: value)
}
}
// MARK: UserSessionKeychainRepository
extension DefaultKeychainRepository: UserSessionKeychainRepository {
// MARK: Last Active Time
func getLastActiveTime(userId: String) async throws -> Date? {
let stored = try await getValue(for: .lastActiveTime(userId: userId))
guard let timeInterval = TimeInterval(stored) else {
return nil
}
return Date(timeIntervalSince1970: timeInterval)
}
func setLastActiveTime(_ date: Date?, userId: String) async throws {
let value = date.map { String($0.timeIntervalSince1970) } ?? ""
try await setValue(value, for: .lastActiveTime(userId: userId))
}
// MARK: Unsuccessful Unlock Attempts
func getUnsuccessfulUnlockAttempts(userId: String) async throws -> Int? {
let stored = try await getValue(for: .unsuccessfulUnlockAttempts(userId: userId))
return Int(stored)
}
func setUnsuccessfulUnlockAttempts(_ attempts: Int, userId: String) async throws {
let value = String(attempts)
try await setValue(value, for: .unsuccessfulUnlockAttempts(userId: userId))
}
// MARK: Vault Timeout
func getVaultTimeout(userId: String) async throws -> Int? {
let stored = try await getValue(for: .vaultTimeout(userId: userId))
return Int(stored)
}
func setVaultTimeout(minutes: Int, userId: String) async throws {
let value = String(minutes)
try await setValue(value, for: .vaultTimeout(userId: userId))
}
}

View File

@ -96,6 +96,20 @@ final class KeychainRepositoryTests: BitwardenTestCase { // swiftlint:disable:th
)
}
/// `deleteAuthenticatorVaultKey` deletes the stored Authenticator Vault Key with the correct query values.
///
func test_deleteAuthenticatorVaultKey_success() async throws {
let item = KeychainItem.authenticatorVaultKey(userId: "1")
keychainService.deleteResult = .success(())
let expectedQuery = await subject.keychainQueryValues(for: item)
try await subject.deleteAuthenticatorVaultKey(userId: "1")
XCTAssertEqual(
keychainService.deleteQueries,
[expectedQuery],
)
}
/// `deleteItems(for:)` deletes items for a specific user.
func test_deleteItems_forUserId() async throws {
try await subject.deleteItems(for: "1")
@ -104,8 +118,10 @@ final class KeychainRepositoryTests: BitwardenTestCase { // swiftlint:disable:th
subject.keychainQueryValues(for: .accessToken(userId: "1")),
subject.keychainQueryValues(for: .authenticatorVaultKey(userId: "1")),
subject.keychainQueryValues(for: .biometrics(userId: "1")),
subject.keychainQueryValues(for: .lastActiveTime(userId: "1")),
subject.keychainQueryValues(for: .neverLock(userId: "1")),
subject.keychainQueryValues(for: .refreshToken(userId: "1")),
subject.keychainQueryValues(for: .unsuccessfulUnlockAttempts(userId: "1")),
]
XCTAssertEqual(
@ -128,20 +144,6 @@ final class KeychainRepositoryTests: BitwardenTestCase { // swiftlint:disable:th
)
}
/// `deleteAuthenticatorVaultKey` deletes the stored Authenticator Vault Key with the correct query values.
///
func test_deleteAuthenticatorVaultKey_success() async throws {
let item = KeychainItem.authenticatorVaultKey(userId: "1")
keychainService.deleteResult = .success(())
let expectedQuery = await subject.keychainQueryValues(for: item)
try await subject.deleteAuthenticatorVaultKey(userId: "1")
XCTAssertEqual(
keychainService.deleteQueries,
[expectedQuery],
)
}
/// The service should generate a storage key for a` KeychainItem`.
///
func test_formattedKey_biometrics() async {

View File

@ -7,33 +7,27 @@ class MockKeychainRepository: KeychainRepository {
var appId: String = "mockAppId"
var mockStorage = [String: String]()
var securityType: SecAccessControlCreateFlags?
var deleteAllItemsCalled = false
var deleteAllItemsResult: Result<Void, Error> = .success(())
var deleteItemsForUserIds = [String]()
var deleteItemsForUserResult: Result<Void, Error> = .success(())
var deleteResult: Result<Void, Error> = .success(())
var getResult: Result<String, Error>?
var setResult: Result<Void, Error> = .success(())
var deleteAllItemsCalled = false
var deleteAllItemsResult: Result<Void, Error> = .success(())
var deleteItemsForUserIds = [String]()
var deleteItemsForUserResult: Result<Void, Error> = .success(())
var getAccessTokenResult: Result<String, Error> = .success("ACCESS_TOKEN")
var getAuthenticatorVaultKeyResult: Result<String, Error> = .success("AUTHENTICATOR_VAULT_KEY")
var getDeviceKeyResult: Result<String, Error> = .success("DEVICE_KEY")
var getPendingAdminLoginRequestResult: Result<String, Error> = .success("PENDING_REQUEST")
var getRefreshTokenResult: Result<String, Error> = .success("REFRESH_TOKEN")
var getPendingAdminLoginRequestResult: Result<String, Error> = .success("PENDING_REQUEST")
var setAuthenticatorVaultKeyResult: Result<Void, Error> = .success(())
var setAccessTokenResult: Result<Void, Error> = .success(())
var setDeviceKeyResult: Result<Void, Error> = .success(())
var setRefreshTokenResult: Result<Void, Error> = .success(())
var setPendingAdminLoginRequestResult: Result<Void, Error> = .success(())
var setRefreshTokenResult: Result<Void, Error> = .success(())
func deleteAllItems() async throws {
deleteAllItemsCalled = true

View File

@ -0,0 +1,61 @@
import Foundation
// MARK: UserSessionKeychainRepository
/// A service that provides access to keychain values related to the user session.
///
protocol UserSessionKeychainRepository { // sourcery: AutoMockable
// MARK: Last Active Time
/// Gets the stored last active time for a user from the keychain.
///
/// - Parameters:
/// - userId: The user ID associated with the stored last active time.
/// - Returns: The last active time value.
///
func getLastActiveTime(userId: String) async throws -> Date?
/// Stores the last active time for a user in the keychain.
///
/// - Parameters:
/// - date: The last active time to store.
/// - userId: The user's ID, used to get back the last active time later on.
///
func setLastActiveTime(_ date: Date?, userId: String) async throws
// MARK: Unsuccessful Unlock Attempts
/// Gets the number of unsuccessful attempts to unlock the vault for a user ID.
///
/// - Parameters:
/// - userId: The user ID associated with the unsuccessful unlock attempts.
/// - Returns: The number of unsuccessful attempts to unlock the vault.
///
func getUnsuccessfulUnlockAttempts(userId: String) async throws -> Int?
/// Sets the number of unsuccessful attempts to unlock the vault for a user ID.
///
/// - Parameters:
/// - attempts: The number of unsuccessful unlock attempts.
/// - userId: The user ID associated with the unsuccessful unlock attempts.
///
func setUnsuccessfulUnlockAttempts(_ attempts: Int, userId: String) async throws
// MARK: Vault Timeout
/// Gets the stored vault timeout for a user from the keychain.
///
/// - Parameters:
/// - userId: The user ID associated with the stored vault timeout.
/// - Returns: The vault timeout value.
///
func getVaultTimeout(userId: String) async throws -> Int?
/// Stores the vault timeout for a user in the keychain.
///
/// - Parameters:
/// - minutes: The vault timeout to store, in minutes.
/// - userId: The user's ID, used to get back the vault timeout later on.
///
func setVaultTimeout(minutes: Int, userId: String) async throws
}

View File

@ -40,6 +40,9 @@ class DefaultMigrationService {
/// not one in the app group).
let standardUserDefaults: UserDefaults
/// The service used by the application to manage user session state.
let userSessionStateService: UserSessionStateService
// MARK: Initialization
/// Initialize a `DefaultMigrationService`.
@ -52,6 +55,7 @@ class DefaultMigrationService {
/// - keychainService: The service used to access & store data on the device keychain.
/// - keychainServiceName: The service name associated with the app's keychain items.
/// - standardUserDefaults: The shared UserDefaults instance.
/// - userSessionStateService: The service used by the application to manage user session state.
///
init(
appGroupUserDefaults: UserDefaults = .standard,
@ -61,6 +65,7 @@ class DefaultMigrationService {
keychainService: KeychainService,
keychainServiceName: String = Bundle.main.appIdentifier,
standardUserDefaults: UserDefaults = .standard,
userSessionStateService: UserSessionStateService,
) {
self.appGroupUserDefaults = appGroupUserDefaults
self.appSettingsStore = appSettingsStore
@ -69,6 +74,7 @@ class DefaultMigrationService {
self.keychainService = keychainService
self.keychainServiceName = keychainServiceName
self.standardUserDefaults = standardUserDefaults
self.userSessionStateService = userSessionStateService
}
// MARK: Private
@ -96,7 +102,9 @@ class DefaultMigrationService {
for (accountId, account) in state.accounts {
// Reset date values.
appSettingsStore.setLastActiveTime(nil, userId: accountId)
let lastActiveKey = "bwPreferencesStorage:lastActiveTime_\(accountId)"
appGroupUserDefaults.removeObject(forKey: lastActiveKey)
try await userSessionStateService.setLastActiveTime(nil, userId: accountId)
appSettingsStore.setLastSyncTime(nil, userId: accountId)
appSettingsStore.setNotificationsLastRegistrationDate(nil, userId: accountId)
@ -210,6 +218,42 @@ class DefaultMigrationService {
)
}
}
/// Performs migration 5.
///
/// Notes:
/// - This migrates several fields from the AppSettingsStore into the Keychain:
/// - lastActiveTime
/// - unsuccessfulUnlockAttempts
/// - vaultTimeout
/// - These are items related to the user session, ultimately so they can be used between PM and the extensions.
///
private func performMigration5() async throws {
guard let state = appSettingsStore.state else { return }
for (accountId, _) in state.accounts {
let lastActiveKey = "bwPreferencesStorage:lastActiveTime_\(accountId)"
if let lastActiveString = appGroupUserDefaults.string(forKey: lastActiveKey),
let lastActiveTimeInterval = TimeInterval(lastActiveString) {
let lastActiveTime = Date(timeIntervalSince1970: lastActiveTimeInterval)
try await userSessionStateService.setLastActiveTime(lastActiveTime, userId: accountId)
appGroupUserDefaults.removeObject(forKey: lastActiveKey)
}
let unsuccessfulUnlocksKey = "bwPreferencesStorage:invalidUnlockAttempts_\(accountId)"
if let unsuccessfulUnlocksString = appGroupUserDefaults.string(forKey: unsuccessfulUnlocksKey),
let unsuccessfulUnlocks = Int(unsuccessfulUnlocksString) {
try await userSessionStateService.setUnsuccessfulUnlockAttempts(unsuccessfulUnlocks, userId: accountId)
appGroupUserDefaults.removeObject(forKey: unsuccessfulUnlocksKey)
}
let vaultTimeoutKey = "bwPreferencesStorage:vaultTimeout_\(accountId)"
if let vaultTimeoutString = appGroupUserDefaults.string(forKey: vaultTimeoutKey),
let vaultTimeoutInt = Int(vaultTimeoutString) {
let vaultTimeout = SessionTimeoutValue(rawValue: vaultTimeoutInt)
try await userSessionStateService.setVaultTimeout(vaultTimeout, userId: accountId)
appGroupUserDefaults.removeObject(forKey: vaultTimeoutKey)
}
}
}
}
extension DefaultMigrationService {
@ -220,6 +264,7 @@ extension DefaultMigrationService {
performMigration2,
performMigration3,
performMigration4,
performMigration5,
]
}

View File

@ -15,6 +15,7 @@ class MigrationServiceTests: BitwardenTestCase { // swiftlint:disable:this type_
var keychainService: MockKeychainService!
var standardUserDefaults: UserDefaults!
var subject: DefaultMigrationService!
var userSessionStateService: MockUserSessionStateService!
/// A keychain service name to use during tests to avoid corrupting the app's keychain items.
private let testKeychainServiceName = "com.bitwarden.test"
@ -30,6 +31,7 @@ class MigrationServiceTests: BitwardenTestCase { // swiftlint:disable:this type_
keychainRepository = MockKeychainRepository()
keychainService = MockKeychainService()
standardUserDefaults = UserDefaults(suiteName: "test")
userSessionStateService = MockUserSessionStateService()
for key in appGroupUserDefaults.dictionaryRepresentation().map(\.key) {
appGroupUserDefaults.removeObject(forKey: key)
@ -50,6 +52,7 @@ class MigrationServiceTests: BitwardenTestCase { // swiftlint:disable:this type_
keychainService: keychainService,
keychainServiceName: testKeychainServiceName,
standardUserDefaults: standardUserDefaults,
userSessionStateService: userSessionStateService,
)
}
@ -63,6 +66,7 @@ class MigrationServiceTests: BitwardenTestCase { // swiftlint:disable:this type_
keychainService = nil
standardUserDefaults = nil
subject = nil
userSessionStateService = nil
}
// MARK: Tests
@ -98,6 +102,8 @@ class MigrationServiceTests: BitwardenTestCase { // swiftlint:disable:this type_
XCTAssertEqual(errorReporter.errors as? [KeychainServiceError], [KeychainServiceError.osStatusError(-1)])
}
// MARK: Migration 1 (Tokens to Keychain)
/// `performMigrations()` performs migration 1 and moves the user's tokens to the keychain.
func test_performMigrations_1_withAccounts() async throws {
appSettingsStore.migrationVersion = 0
@ -119,7 +125,6 @@ class MigrationServiceTests: BitwardenTestCase { // swiftlint:disable:this type_
activeUserId: "1",
)
for userId in ["1", "2"] {
appSettingsStore.lastActiveTime[userId] = Date()
appSettingsStore.lastSyncTimeByUserId[userId] = Date()
appSettingsStore.notificationsLastRegistrationDates[userId] = Date()
}
@ -139,11 +144,12 @@ class MigrationServiceTests: BitwardenTestCase { // swiftlint:disable:this type_
try XCTAssertEqual(keychainRepository.getValue(for: .refreshToken(userId: "2")), "REFRESH_TOKEN_2")
for userId in ["1", "2"] {
XCTAssertNil(appSettingsStore.lastActiveTime(userId: userId))
XCTAssertNil(appSettingsStore.lastSyncTime(userId: userId))
XCTAssertNil(appSettingsStore.notificationsLastRegistrationDate(userId: userId))
}
XCTAssertEqual(userSessionStateService.setLastActiveTimeCallsCount, 2)
XCTAssertFalse(keychainRepository.deleteAllItemsCalled)
XCTAssertTrue(errorReporter.isEnabled)
@ -180,6 +186,8 @@ class MigrationServiceTests: BitwardenTestCase { // swiftlint:disable:this type_
XCTAssertTrue(errorReporter.isEnabled)
}
// MARK: Migration 2 (Update Keychain Security)
/// `performMigrations()` for migration 2 migrates keychain data in kSecAttrGeneric to kSecValueData.
func test_performMigrations_2() async throws {
let itemsToAdd: [(account: String, value: String)] = [
@ -230,6 +238,8 @@ class MigrationServiceTests: BitwardenTestCase { // swiftlint:disable:this type_
XCTAssertEqual(appSettingsStore.migrationVersion, 2)
}
// MARK: Migration 3 (Remove MAUI Biometrics Integrity State)
/// `performMigrations()` for migration 3 removes the integrity state values from MAUI.
func test_performMigrations_3() async throws {
appGroupUserDefaults.set(
@ -266,6 +276,8 @@ class MigrationServiceTests: BitwardenTestCase { // swiftlint:disable:this type_
)
}
// MARK: Migration 4 (Remove Native Biometrics Integrity State)
/// `performMigrations()` for migration 4 removes the native integrity state values.
func test_performMigrations_4() async throws {
func newKey(userId: String, extensionName: String?) -> String {
@ -321,4 +333,198 @@ class MigrationServiceTests: BitwardenTestCase { // swiftlint:disable:this type_
))
}
}
}
// MARK: Migration 5 (Migrate Session Data to Keychain)
/// `performMigrations()` for migration 5 migrates lastActiveTime, unsuccessfulUnlockAttempts,
/// and vaultTimeout from UserDefaults to the Keychain.
func test_performMigrations_5() async throws { // swiftlint:disable:this function_body_length
let lastActiveDate1 = Date(timeIntervalSince1970: 1_700_000_000)
let lastActiveDate2 = Date(timeIntervalSince1970: 1_700_100_000)
appGroupUserDefaults.set(
String(lastActiveDate1.timeIntervalSince1970),
forKey: "bwPreferencesStorage:lastActiveTime_1",
)
appGroupUserDefaults.set(
String(lastActiveDate2.timeIntervalSince1970),
forKey: "bwPreferencesStorage:lastActiveTime_2",
)
appGroupUserDefaults.set(
"3",
forKey: "bwPreferencesStorage:invalidUnlockAttempts_1",
)
appGroupUserDefaults.set(
"5",
forKey: "bwPreferencesStorage:invalidUnlockAttempts_2",
)
appGroupUserDefaults.set(
"15",
forKey: "bwPreferencesStorage:vaultTimeout_1",
)
appGroupUserDefaults.set(
"-1",
forKey: "bwPreferencesStorage:vaultTimeout_2",
)
appSettingsStore.state = State(
accounts: [
"1": .fixture(),
"2": .fixture(profile: .fixture(userId: "2")),
],
activeUserId: "1",
)
var setLastActiveTimeValue: [String: Date] = [:]
var setUnsuccessfulUnlockAttemptsValue: [String: Int] = [:]
var setVaultTimeoutValue: [String: SessionTimeoutValue] = [:]
userSessionStateService.setLastActiveTimeClosure = { date, userId in
guard let userId else { return }
setLastActiveTimeValue[userId] = date
}
userSessionStateService.setUnsuccessfulUnlockAttemptsClosure = { attempts, userId in
guard let userId else { return }
setUnsuccessfulUnlockAttemptsValue[userId] = attempts
}
userSessionStateService.setVaultTimeoutClosure = { timeout, userId in
guard let userId else { return }
setVaultTimeoutValue[userId] = timeout
}
try await subject.performMigration(version: 5)
// Verify values were migrated to the UserSessionStateService
XCTAssertEqual(setLastActiveTimeValue["1"], lastActiveDate1)
XCTAssertEqual(setLastActiveTimeValue["2"], lastActiveDate2)
XCTAssertEqual(setUnsuccessfulUnlockAttemptsValue["1"], 3)
XCTAssertEqual(setUnsuccessfulUnlockAttemptsValue["2"], 5)
XCTAssertEqual(setVaultTimeoutValue["1"], .fifteenMinutes)
XCTAssertEqual(setVaultTimeoutValue["2"], .onAppRestart)
// Verify old values were removed from UserDefaults
XCTAssertNil(appGroupUserDefaults.string(forKey: "bwPreferencesStorage:lastActiveTime_1"))
XCTAssertNil(appGroupUserDefaults.string(forKey: "bwPreferencesStorage:lastActiveTime_2"))
XCTAssertNil(appGroupUserDefaults.string(forKey: "bwPreferencesStorage:invalidUnlockAttempts_1"))
XCTAssertNil(appGroupUserDefaults.string(forKey: "bwPreferencesStorage:invalidUnlockAttempts_2"))
XCTAssertNil(appGroupUserDefaults.string(forKey: "bwPreferencesStorage:vaultTimeout_1"))
XCTAssertNil(appGroupUserDefaults.string(forKey: "bwPreferencesStorage:vaultTimeout_2"))
}
/// `performMigrations()` for migration 5 handles missing values gracefully.
func test_performMigrations_5_withMissingValues() async throws {
let lastActiveDate1 = Date(timeIntervalSince1970: 1_700_000_000)
appGroupUserDefaults.set(
String(lastActiveDate1.timeIntervalSince1970),
forKey: "bwPreferencesStorage:lastActiveTime_1",
)
appGroupUserDefaults.set(
"5",
forKey: "bwPreferencesStorage:invalidUnlockAttempts_2",
)
appGroupUserDefaults.set(
"30",
forKey: "bwPreferencesStorage:vaultTimeout_1",
)
appSettingsStore.state = State(
accounts: [
"1": .fixture(),
"2": .fixture(profile: .fixture(userId: "2")),
],
activeUserId: "1",
)
try await subject.performMigration(version: 5)
// Verify only user 1's lastActiveTime was set
XCTAssertEqual(userSessionStateService.setLastActiveTimeCallsCount, 1)
XCTAssertEqual(userSessionStateService.setLastActiveTimeReceivedArguments?.userId, "1")
// Verify only user 2's unsuccessfulUnlockAttempts was set
XCTAssertEqual(userSessionStateService.setUnsuccessfulUnlockAttemptsCallsCount, 1)
XCTAssertEqual(userSessionStateService.setUnsuccessfulUnlockAttemptsReceivedArguments?.userId, "2")
// Verify only user 1's vaultTimeout was set
XCTAssertEqual(userSessionStateService.setVaultTimeoutCallsCount, 1)
XCTAssertEqual(userSessionStateService.setVaultTimeoutReceivedArguments?.userId, "1")
}
/// `performMigrations()` for migration 5 handles invalid data gracefully.
func test_performMigrations_5_withInvalidData() async throws {
appGroupUserDefaults.set(
"not-a-valid-time-interval",
forKey: "bwPreferencesStorage:lastActiveTime_1",
)
appGroupUserDefaults.set(
"not-a-valid-number",
forKey: "bwPreferencesStorage:invalidUnlockAttempts_1",
)
appGroupUserDefaults.set(
"not-a-valid-timeout",
forKey: "bwPreferencesStorage:vaultTimeout_1",
)
appSettingsStore.state = State(
accounts: [
"1": .fixture(),
],
activeUserId: "1",
)
try await subject.performMigration(version: 5)
// Verify invalid values were not migrated
XCTAssertEqual(userSessionStateService.setLastActiveTimeCallsCount, 0)
XCTAssertEqual(userSessionStateService.setUnsuccessfulUnlockAttemptsCallsCount, 0)
XCTAssertEqual(userSessionStateService.setVaultTimeoutCallsCount, 0)
// Verify UserDefaults values remain (since they weren't successfully migrated)
XCTAssertNotNil(appGroupUserDefaults.string(forKey: "bwPreferencesStorage:lastActiveTime_1"))
XCTAssertNotNil(appGroupUserDefaults.string(forKey: "bwPreferencesStorage:invalidUnlockAttempts_1"))
XCTAssertNotNil(appGroupUserDefaults.string(forKey: "bwPreferencesStorage:vaultTimeout_1"))
}
/// `performMigrations()` for migration 5 handles custom vault timeout values.
func test_performMigrations_5_withCustomVaultTimeout() async throws {
appGroupUserDefaults.set(
"120",
forKey: "bwPreferencesStorage:vaultTimeout_1",
)
appSettingsStore.state = State(
accounts: [
"1": .fixture(),
],
activeUserId: "1",
)
try await subject.performMigration(version: 5)
// Verify custom timeout value was migrated correctly
let actual = userSessionStateService.setVaultTimeoutReceivedArguments
XCTAssertEqual(actual?.userId, "1")
XCTAssertEqual(actual?.value, .custom(120))
XCTAssertNil(appGroupUserDefaults.string(forKey: "bwPreferencesStorage:vaultTimeout_1"))
}
/// `performMigrations()` for migration 5 handles no existing accounts.
func test_performMigrations_5_withNoAccounts() async throws {
appSettingsStore.state = nil
try await subject.performMigration(version: 5)
XCTAssertEqual(appSettingsStore.migrationVersion, 5)
XCTAssertEqual(userSessionStateService.setLastActiveTimeCallsCount, 0)
XCTAssertEqual(userSessionStateService.setUnsuccessfulUnlockAttemptsCallsCount, 0)
XCTAssertEqual(userSessionStateService.setVaultTimeoutCallsCount, 0)
}
} // swiftlint:disable:this file_length

View File

@ -190,6 +190,9 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le
/// A factory protocol to create `UserVerificationHelper`s.
let userVerificationHelperFactory: UserVerificationHelperFactory
/// The service used by the application to manage user session state.
let userSessionStateService: UserSessionStateService
/// The repository used by the application to manage vault data for the UI layer.
let vaultRepository: VaultRepository
@ -264,6 +267,8 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le
/// - totpService: The service used by the application to validate TOTP keys and produce TOTP values.
/// - trustDeviceService: The service used to handle device trust.
/// - twoStepLoginService: The service used by the application to generate a two step login URL.
/// - userSessionStateService: The service used by the application to manage user session state.
/// - userVerificationHelperFactory: A factory protocol to create `UserVerificationHelper`s.
/// - vaultRepository: The repository used by the application to manage vault data for the UI layer.
/// - vaultTimeoutService: The service used by the application to manage vault access.
/// - watchService: The service used by the application to connect to and communicate with the watch app.
@ -324,6 +329,7 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le
totpService: TOTPService,
trustDeviceService: TrustDeviceService,
twoStepLoginService: TwoStepLoginService,
userSessionStateService: UserSessionStateService,
userVerificationHelperFactory: UserVerificationHelperFactory,
vaultRepository: VaultRepository,
vaultTimeoutService: VaultTimeoutService,
@ -383,6 +389,7 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le
self.totpService = totpService
self.trustDeviceService = trustDeviceService
self.twoStepLoginService = twoStepLoginService
self.userSessionStateService = userSessionStateService
self.userVerificationHelperFactory = userVerificationHelperFactory
self.vaultRepository = vaultRepository
self.vaultTimeoutService = vaultTimeoutService
@ -440,6 +447,7 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le
dataStore: dataStore,
errorReporter: errorReporter,
keychainRepository: keychainRepository,
userSessionKeychainRepository: keychainRepository,
)
let flightRecorder = DefaultFlightRecorder(
@ -605,6 +613,7 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le
sharedTimeoutService: sharedTimeoutService,
stateService: stateService,
timeProvider: timeProvider,
userSessionStateService: stateService,
)
let reviewPromptService = DefaultReviewPromptService(
@ -628,6 +637,7 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le
stateService: stateService,
syncAPIService: apiService,
timeProvider: timeProvider,
userSessionStateService: stateService,
vaultTimeoutService: vaultTimeoutService,
)
@ -696,6 +706,7 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le
policyService: policyService,
stateService: stateService,
trustDeviceService: trustDeviceService,
userSessionStateService: stateService,
vaultTimeoutService: vaultTimeoutService,
)
@ -711,6 +722,7 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le
errorReporter: errorReporter,
keychainRepository: keychainRepository,
keychainService: keychainService,
userSessionStateService: stateService,
)
let notificationService = DefaultNotificationService(
@ -1002,6 +1014,7 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le
totpService: totpService,
trustDeviceService: trustDeviceService,
twoStepLoginService: twoStepLoginService,
userSessionStateService: stateService,
userVerificationHelperFactory: userVerificationHelperFactory,
vaultRepository: vaultRepository,
vaultTimeoutService: vaultTimeoutService,

View File

@ -56,6 +56,7 @@ typealias Services = HasAPIService
& HasTimeProvider
& HasTrustDeviceService
& HasTwoStepLoginService
& HasUserSessionStateService
& HasUserVerificationHelperFactory
& HasVaultRepository
& HasVaultTimeoutService
@ -318,6 +319,13 @@ protocol HasStateService {
var stateService: StateService { get }
}
/// Protocol for an object that provides a `UserSessionStateService`.
///
protocol HasUserSessionStateService {
/// The service used by the application to manage user session state.
var userSessionStateService: UserSessionStateService { get }
}
/// Protocol for an object that has a `SyncService`.
///
protocol HasSyncService {

View File

@ -0,0 +1,172 @@
// swiftlint:disable:this file_name
import BitwardenKit
import BitwardenKitMocks
import XCTest
@testable import BitwardenShared
@testable import BitwardenSharedMocks
// MARK: - StateServiceUserSessionTests
class StateServiceUserSessionTests: BitwardenTestCase {
// MARK: Properties
var appSettingsStore: MockAppSettingsStore!
var dataStore: DataStore!
var errorReporter: MockErrorReporter!
var keychainRepository: MockKeychainRepository!
var userSessionKeychainRepository: MockUserSessionKeychainRepository!
var subject: DefaultStateService!
// MARK: Setup & Teardown
override func setUp() {
super.setUp()
appSettingsStore = MockAppSettingsStore()
dataStore = DataStore(errorReporter: MockErrorReporter(), storeType: .memory)
errorReporter = MockErrorReporter()
keychainRepository = MockKeychainRepository()
userSessionKeychainRepository = MockUserSessionKeychainRepository()
subject = DefaultStateService(
appSettingsStore: appSettingsStore,
dataStore: dataStore,
errorReporter: errorReporter,
keychainRepository: keychainRepository,
userSessionKeychainRepository: userSessionKeychainRepository,
)
}
override func tearDown() {
super.tearDown()
appSettingsStore = nil
dataStore = nil
errorReporter = nil
keychainRepository = nil
subject = nil
userSessionKeychainRepository = nil
}
// MARK: Last Active Time
/// `getLastActiveTime(userId:)` gets the user's last active time.
func test_getLastActiveTime() async throws {
await subject.addAccount(.fixture(profile: .fixture(userId: "1")))
let date = Date(timeIntervalSince1970: 1_234_567_890)
userSessionKeychainRepository.getLastActiveTimeReturnValue = date
let lastActiveTime = try await subject.getLastActiveTime(userId: "1")
XCTAssertEqual(lastActiveTime, date)
}
/// `setLastActiveTime(userId:)` sets the user's last active time.
func test_setLastActiveTime() async throws {
await subject.addAccount(.fixture(profile: .fixture(userId: "1")))
let date = Date(timeIntervalSince1970: 1_234_567_890)
try await subject.setLastActiveTime(date, userId: "1")
let actual = userSessionKeychainRepository.setLastActiveTimeReceivedArguments
XCTAssertEqual(actual?.userId, "1")
XCTAssertEqual(actual?.date, date)
}
// MARK: Unsuccessful Unlock Attempts
/// `getUnsuccessfulUnlockAttempts(userId:)` gets the unsuccessful unlock attempts for the account.
func test_getUnsuccessfulUnlockAttempts() async throws {
await subject.addAccount(.fixture(profile: .fixture(userId: "1")))
userSessionKeychainRepository.getUnsuccessfulUnlockAttemptsReturnValue = 4
let unsuccessfulUnlockAttempts = try await subject.getUnsuccessfulUnlockAttempts(userId: "1")
XCTAssertEqual(unsuccessfulUnlockAttempts, 4)
}
/// `getUnsuccessfulUnlockAttempts(userId:)` returns `0` if no value is stored.
func test_getUnsuccessfulUnlockAttempts_default() async throws {
let item = KeychainItem.unsuccessfulUnlockAttempts(userId: "1")
let error = KeychainServiceError.keyNotFound(item)
userSessionKeychainRepository.getUnsuccessfulUnlockAttemptsThrowableError = error
await subject.addAccount(.fixture(profile: .fixture(userId: "1")))
let unsuccessfulUnlockAttempts = try await subject.getUnsuccessfulUnlockAttempts(userId: "1")
XCTAssertEqual(unsuccessfulUnlockAttempts, 0)
}
/// `setUnsuccessfulUnlockAttempts(userId:)` sets the unsuccessful unlock attempts for the account.
func test_setUnsuccessfulUnlockAttempts() async throws {
await subject.addAccount(.fixture(profile: .fixture(userId: "1")))
try await subject.setUnsuccessfulUnlockAttempts(3, userId: "1")
let actual = userSessionKeychainRepository.setUnsuccessfulUnlockAttemptsReceivedArguments
XCTAssertEqual(actual?.userId, "1")
XCTAssertEqual(actual?.attempts, 3)
}
// MARK: Vault Timeout
/// `.getVaultTimeout(userId:)` gets the user's vault timeout.
func test_getVaultTimeout() async throws {
await subject.addAccount(.fixture(profile: .fixture(userId: "1")))
try await subject.setVaultTimeout(.custom(20), userId: "1")
let key = userSessionKeychainRepository.setVaultTimeoutReceivedArguments
XCTAssertEqual(key?.minutes, 20)
XCTAssertEqual(key?.userId, "1")
userSessionKeychainRepository.getVaultTimeoutReturnValue = 20
let vaultTimeout = try await subject.getVaultTimeout(userId: "1")
XCTAssertEqual(vaultTimeout, .custom(20))
}
/// `.getVaultTimeout(userId:)` gets the default vault timeout for the user if a value isn't set.
func test_getVaultTimeout_default() async throws {
let item = KeychainItem.vaultTimeout(userId: "1")
userSessionKeychainRepository.getVaultTimeoutThrowableError = KeychainServiceError.keyNotFound(item)
await subject.addAccount(.fixture(profile: .fixture(userId: "1")))
let vaultTimeout = try await subject.getVaultTimeout()
XCTAssertEqual(vaultTimeout, .fifteenMinutes)
}
/// `.getVaultTimeout(userId:)` gets the user's vault timeout when it's set to never lock.
func test_getVaultTimeout_neverLock() async throws {
let item = KeychainItem.vaultTimeout(userId: "1")
userSessionKeychainRepository.getVaultTimeoutThrowableError = KeychainServiceError.keyNotFound(item)
keychainRepository.mockStorage[keychainRepository.formattedKey(for: .neverLock(userId: "1"))] = "NEVER_LOCK_KEY"
await subject.addAccount(.fixture(profile: .fixture(userId: "1")))
let vaultTimeout = try await subject.getVaultTimeout()
XCTAssertEqual(vaultTimeout, .never)
}
/// `getVaultTimeout(userId:)` returns the default timeout if the user has a never lock value
/// stored but the never lock key doesn't exist.
func test_getVaultTimeout_neverLock_missingKey() async throws {
appSettingsStore.vaultTimeout["1"] = -2
await subject.addAccount(.fixture(profile: .fixture(userId: "1")))
let vaultTimeout = try await subject.getVaultTimeout()
XCTAssertEqual(vaultTimeout, .fifteenMinutes)
}
/// `.setVaultTimeout(value:userId:)` sets the vault timeout value for the user.
func test_setVaultTimeout() async throws {
await subject.addAccount(.fixture(profile: .fixture(userId: "1")))
try await subject.setVaultTimeout(.custom(20))
let key = userSessionKeychainRepository.setVaultTimeoutReceivedArguments
XCTAssertEqual(key?.minutes, 20)
XCTAssertEqual(key?.userId, "1")
}
}

View File

@ -224,14 +224,6 @@ protocol StateService: AnyObject {
///
func getIntroCarouselShown() async -> Bool
/// Gets 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.
/// - Returns: The date of the last active time.
///
func getLastActiveTime(userId: String?) async throws -> Date?
/// Gets the time of the last sync for a user.
///
/// - Parameter userId: The user ID associated with the last sync time.
@ -352,14 +344,6 @@ protocol StateService: AnyObject {
///
func getTwoFactorToken(email: String) async -> String?
/// Gets the number of unsuccessful attempts to unlock the vault for a user ID.
///
/// - Parameter userId: The optional user ID associated with the unsuccessful unlock attempts,
/// if `nil` defaults to currently active user.
/// - Returns: The number of unsuccessful attempts to unlock the vault.
///
func getUnsuccessfulUnlockAttempts(userId: String?) async throws -> Int
/// Gets whether a user has a master password.
///
/// - Parameter userId: The user ID of the user to determine whether they have a master password.
@ -388,13 +372,6 @@ protocol StateService: AnyObject {
///
func getUsesKeyConnector(userId: String?) async throws -> Bool
/// Gets the session timeout value.
///
/// - Parameter userId: The user ID for the account.
/// - Returns: The session timeout value.
///
func getVaultTimeout(userId: String?) async throws -> SessionTimeoutValue
/// Whether the user is authenticated.
///
/// - Parameter userId: The user ID to check if they are authenticated.
@ -624,14 +601,6 @@ protocol StateService: AnyObject {
///
func setLearnNewLoginActionCardStatus(_ status: AccountSetupProgress) async
/// 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?) async throws
/// Sets the time of the last sync for a user ID.
///
/// - Parameters:
@ -766,13 +735,6 @@ protocol StateService: AnyObject {
///
func setTwoFactorToken(_ token: String?, email: String) async
/// Sets the number of unsuccessful attempts to unlock the vault for a user ID.
///
/// - Parameter userId: The user ID associated with the unsuccessful unlock attempts.
/// if `nil` defaults to currently active user.
///
func setUnsuccessfulUnlockAttempts(_ attempts: Int, userId: String?) async throws
/// Sets whether the user has a master password.
///
/// - Parameter hasMasterPassword: Whether the user has a master password.
@ -795,14 +757,6 @@ protocol StateService: AnyObject {
///
func setUsesKeyConnector(_ usesKeyConnector: Bool, userId: String?) async throws
/// Sets the session timeout value.
///
/// - Parameters:
/// - value: The value that dictates how many seconds in the future a timeout should occur.
/// - userId: The user ID associated with the timeout value.
///
func setVaultTimeout(value: SessionTimeoutValue, userId: String?) async throws
/// Updates the profile information for a user.
///
/// - Parameters:
@ -1029,15 +983,6 @@ extension StateService {
try await getHasPerformedSyncAfterLogin(userId: nil)
}
/// Gets the user's last active time within the app.
/// This value is set when the app is backgrounded.
///
/// - Returns: The date of the last active time.
///
func getLastActiveTime() async throws -> Date? {
try await getLastActiveTime(userId: nil)
}
/// Gets the time of the last sync for a user.
///
/// - Parameter userId: The user ID associated with the last sync time.
@ -1093,17 +1038,6 @@ extension StateService {
try await getTimeoutAction(userId: nil)
}
/// Sets the number of unsuccessful attempts to unlock the vault for the active account.
///
/// - Returns: The number of unsuccessful unlock attempts for the active account.
///
func getUnsuccessfulUnlockAttempts() async -> Int {
if let attempts = try? await getUnsuccessfulUnlockAttempts(userId: nil) {
return attempts
}
return 0
}
/// Gets whether a user has a master password.
///
/// - Returns: Whether the user has a master password.
@ -1128,14 +1062,6 @@ extension StateService {
try await getUsesKeyConnector(userId: nil)
}
/// Gets the session timeout value.
///
/// - Returns: The session timeout value.
///
func getVaultTimeout() async throws -> SessionTimeoutValue {
try await getVaultTimeout(userId: nil)
}
/// Whether the active user account is authenticated.
///
/// - Returns: Whether the user is authenticated.
@ -1306,14 +1232,6 @@ extension StateService {
try await setHasPerformedSyncAfterLogin(hasBeenPerformed, userId: nil)
}
/// Sets the last active time within the app.
///
/// - Parameter date: The current time.
///
func setLastActiveTime(_ date: Date?) async throws {
try await setLastActiveTime(date, userId: nil)
}
/// Sets the time of the last sync for a user ID.
///
/// - Parameter date: The time of the last sync (as the number of seconds since the Unix epoch).]
@ -1378,14 +1296,6 @@ extension StateService {
try await setTimeoutAction(action: action, userId: nil)
}
/// Sets the number of unsuccessful attempts to unlock the vault for the active account.
///
/// - Parameter attempts: The number of unsuccessful unlock attempts.
///
func setUnsuccessfulUnlockAttempts(_ attempts: Int) async {
try? await setUnsuccessfulUnlockAttempts(attempts, userId: nil)
}
/// Sets the username generation options for the active account.
///
/// - Parameter options: The user's username generation options.
@ -1402,14 +1312,6 @@ extension StateService {
try await setUsesKeyConnector(usesKeyConnector, userId: nil)
}
/// Sets the session timeout value.
///
/// - Parameter value: The value that dictates how many seconds in the future a timeout should occur.
///
func setVaultTimeout(value: SessionTimeoutValue) async throws {
try await setVaultTimeout(value: value, userId: nil)
}
/// Whether the user should do the archive onboarding.
/// - Returns: `true` if they should, `false` otherwise.
func shouldDoArchiveOnboarding() async -> Bool {
@ -1497,6 +1399,9 @@ actor DefaultStateService: StateService, ActiveAccountStateProvider, ConfigState
/// A service used to access data in the keychain.
private let keychainRepository: KeychainRepository
/// A service used to access user session data in the keychain.
private let userSessionKeychainRepository: UserSessionKeychainRepository
/// A subject containing the pending App Intent actions.
private var pendingAppIntentActionsSubject = CurrentValueSubject<[PendingAppIntentAction]?, Never>(nil)
@ -1524,11 +1429,13 @@ actor DefaultStateService: StateService, ActiveAccountStateProvider, ConfigState
dataStore: DataStore,
errorReporter: ErrorReporter,
keychainRepository: KeychainRepository,
userSessionKeychainRepository: UserSessionKeychainRepository,
) {
self.appSettingsStore = appSettingsStore
self.dataStore = dataStore
self.errorReporter = errorReporter
self.keychainRepository = keychainRepository
self.userSessionKeychainRepository = userSessionKeychainRepository
appThemeSubject = CurrentValueSubject(AppTheme(appSettingsStore.appTheme))
showWebIconsSubject = CurrentValueSubject(!appSettingsStore.disableWebIcons)
@ -1731,11 +1638,6 @@ actor DefaultStateService: StateService, ActiveAccountStateProvider, ConfigState
appSettingsStore.introCarouselShown
}
func getLastActiveTime(userId: String?) async throws -> Date? {
let userId = try userId ?? getActiveAccountUserId()
return appSettingsStore.lastActiveTime(userId: userId)
}
func getLastSyncTime(userId: String?) async throws -> Date? {
let userId = try userId ?? getActiveAccountUserId()
return appSettingsStore.lastSyncTime(userId: userId)
@ -1833,11 +1735,6 @@ actor DefaultStateService: StateService, ActiveAccountStateProvider, ConfigState
appSettingsStore.twoFactorToken(email: email)
}
func getUnsuccessfulUnlockAttempts(userId: String?) async throws -> Int {
let userId = try userId ?? getActiveAccountUserId()
return appSettingsStore.unsuccessfulUnlockAttempts(userId: userId)
}
func getUserHasMasterPassword(userId: String?) async throws -> Bool {
try getAccount(userId: userId).profile.userDecryptionOptions?.hasMasterPassword ?? true
}
@ -1858,23 +1755,6 @@ actor DefaultStateService: StateService, ActiveAccountStateProvider, ConfigState
return appSettingsStore.usesKeyConnector(userId: userId)
}
func getVaultTimeout(userId: String?) async throws -> SessionTimeoutValue {
let userId = try getAccount(userId: userId).profile.userId
let userAuthKey = try? await keychainRepository.getUserAuthKeyValue(for: .neverLock(userId: userId))
guard let rawValue = appSettingsStore.vaultTimeout(userId: userId) else {
// If there isn't a stored value, it may be because MAUI stored `nil` for never timeout.
// So if the never lock key exists, set the timeout to never, otherwise to default.
return userAuthKey != nil ? .never : .fifteenMinutes
}
let timeoutValue = SessionTimeoutValue(rawValue: rawValue)
if timeoutValue == .never, userAuthKey == nil {
// If never lock but no key (possibly due to logging out), return the default timeout.
return .fifteenMinutes
}
return timeoutValue
}
func isAuthenticated(userId: String?) async throws -> Bool {
do {
let userId = try getAccount(userId: userId).profile.userId
@ -2101,11 +1981,6 @@ actor DefaultStateService: StateService, ActiveAccountStateProvider, ConfigState
appSettingsStore.learnNewLoginActionCardStatus = status
}
func setLastActiveTime(_ date: Date?, userId: String?) async throws {
let userId = try userId ?? getActiveAccountUserId()
appSettingsStore.setLastActiveTime(date, userId: userId)
}
func setLastSyncTime(_ date: Date?, userId: String?) async throws {
let userId = try userId ?? getActiveAccountUserId()
appSettingsStore.setLastSyncTime(date, userId: userId)
@ -2235,11 +2110,6 @@ actor DefaultStateService: StateService, ActiveAccountStateProvider, ConfigState
appSettingsStore.setTwoFactorToken(token, email: email)
}
func setUnsuccessfulUnlockAttempts(_ attempts: Int, userId: String?) async throws {
let userId = try userId ?? getActiveAccountUserId()
appSettingsStore.setUnsuccessfulUnlockAttempts(attempts, userId: userId)
}
func setUserHasMasterPassword(_ hasMasterPassword: Bool) async throws {
let userId = try getActiveAccountUserId()
var state = appSettingsStore.state ?? State()
@ -2261,11 +2131,6 @@ actor DefaultStateService: StateService, ActiveAccountStateProvider, ConfigState
appSettingsStore.setUsesKeyConnector(usesKeyConnector, userId: userId)
}
func setVaultTimeout(value: SessionTimeoutValue, userId: String?) async throws {
let userId = try userId ?? getActiveAccountUserId()
appSettingsStore.setVaultTimeout(minutes: value.rawValue, userId: userId)
}
func updateProfile(from response: ProfileResponseModel, userId: String) async {
var state = appSettingsStore.state ?? State()
defer { appSettingsStore.state = state }
@ -2424,3 +2289,60 @@ extension DefaultStateService: BiometricsStateService {
appSettingsStore.setBiometricAuthenticationEnabled(isEnabled, for: userId)
}
}
// MARK: User Session
extension DefaultStateService: UserSessionStateService {
// MARK: Last Active Time
func getLastActiveTime(userId: String?) async throws -> Date? {
let userId = try userId ?? getActiveAccountUserId()
return try await userSessionKeychainRepository.getLastActiveTime(userId: userId)
}
func setLastActiveTime(_ date: Date?, userId: String?) async throws {
let userId = try userId ?? getActiveAccountUserId()
try await userSessionKeychainRepository.setLastActiveTime(date, userId: userId)
}
// MARK: Unsuccessful Unlock Attempts
func getUnsuccessfulUnlockAttempts(userId: String?) async throws -> Int {
let userId = try userId ?? getActiveAccountUserId()
let attempts = try? await userSessionKeychainRepository.getUnsuccessfulUnlockAttempts(userId: userId)
return attempts ?? 0
}
func setUnsuccessfulUnlockAttempts(_ attempts: Int, userId: String?) async throws {
let userId = try userId ?? getActiveAccountUserId()
try await userSessionKeychainRepository.setUnsuccessfulUnlockAttempts(attempts, userId: userId)
}
// MARK: Vault Timeout
func getVaultTimeout(userId: String?) async throws -> SessionTimeoutValue {
let userId = try getAccount(userId: userId).profile.userId
let userAuthKey = try? await keychainRepository.getUserAuthKeyValue(for: .neverLock(userId: userId))
guard let rawValue = try? await userSessionKeychainRepository.getVaultTimeout(userId: userId)
else {
// If there isn't a stored value, it may be because MAUI stored `nil` for never timeout.
// So if the never lock key exists, set the timeout to never, otherwise to default.
return userAuthKey != nil ? .never : .fifteenMinutes
}
let timeoutValue = SessionTimeoutValue(rawValue: rawValue)
if timeoutValue == .never, userAuthKey == nil {
// If never lock but no key (possibly due to logging out), return the default timeout.
return .fifteenMinutes
}
return timeoutValue
}
func setVaultTimeout(_ value: SessionTimeoutValue, userId: String?) async throws {
let userId = try userId ?? getActiveAccountUserId()
try await userSessionKeychainRepository.setVaultTimeout(
minutes: value.rawValue,
userId: userId,
)
}
}

View File

@ -14,6 +14,7 @@ class StateServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body
var dataStore: DataStore!
var errorReporter: MockErrorReporter!
var keychainRepository: MockKeychainRepository!
var userSessionKeychainRepository: MockUserSessionKeychainRepository!
var subject: DefaultStateService!
// MARK: Setup & Teardown
@ -25,12 +26,14 @@ class StateServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body
dataStore = DataStore(errorReporter: MockErrorReporter(), storeType: .memory)
errorReporter = MockErrorReporter()
keychainRepository = MockKeychainRepository()
userSessionKeychainRepository = MockUserSessionKeychainRepository()
subject = DefaultStateService(
appSettingsStore: appSettingsStore,
dataStore: dataStore,
errorReporter: errorReporter,
keychainRepository: keychainRepository,
userSessionKeychainRepository: userSessionKeychainRepository,
)
}
@ -42,6 +45,7 @@ class StateServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body
errorReporter = nil
keychainRepository = nil
subject = nil
userSessionKeychainRepository = nil
}
// MARK: Tests
@ -846,19 +850,6 @@ class StateServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body
XCTAssertEqual(learnNewLoginActionCardStatus, .complete)
}
/// `getLastActiveTime(userId:)` gets the user's last active time.
func test_getLastActiveTime() async throws {
await subject.addAccount(.fixture(profile: .fixture(userId: "1")))
try await subject.setLastActiveTime(Date())
let lastActiveTime = try await subject.getLastActiveTime()
XCTAssertEqual(
lastActiveTime!.timeIntervalSince1970,
Date().timeIntervalSince1970,
accuracy: 1.0,
)
}
/// `getLastSyncTime(userId:)` gets the user's last sync time.
func test_getLastSyncTime() async throws {
await subject.addAccount(.fixture(profile: .fixture(userId: "1")))
@ -1089,16 +1080,6 @@ class StateServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body
XCTAssertEqual(value, "yay_you_win!")
}
/// `getUnsuccessfulUnlockAttempts(userId:)` gets the unsuccessful unlock attempts for the account.
func test_getUnsuccessfulUnlockAttempts() async throws {
await subject.addAccount(.fixture(profile: .fixture(userId: "1")))
appSettingsStore.unsuccessfulUnlockAttempts["1"] = 4
let unsuccessfulUnlockAttempts = try await subject.getUnsuccessfulUnlockAttempts(userId: "1")
XCTAssertEqual(unsuccessfulUnlockAttempts, 4)
}
/// `getUserHasMasterPassword(userId:)` gets whether a user has a master password for a user
/// with a master password.
func test_getUserHasMasterPassword() async throws {
@ -1231,46 +1212,7 @@ class StateServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body
XCTAssertTrue(usesKeyConnector)
}
/// `.getVaultTimeout(userId:)` gets the user's vault timeout.
func test_getVaultTimeout() async throws {
await subject.addAccount(.fixture(profile: .fixture(userId: "1")))
try await subject.setVaultTimeout(value: .custom(20), userId: "1")
let vaultTimeout = try await subject.getVaultTimeout(userId: "1")
XCTAssertEqual(vaultTimeout, .custom(20))
}
/// `.getVaultTimeout(userId:)` gets the default vault timeout for the user if a value isn't set.
func test_getVaultTimeout_default() async throws {
appSettingsStore.vaultTimeout["1"] = nil
await subject.addAccount(.fixture(profile: .fixture(userId: "1")))
let vaultTimeout = try await subject.getVaultTimeout()
XCTAssertEqual(vaultTimeout, .fifteenMinutes)
}
/// `.getVaultTimeout(userId:)` gets the user's vault timeout when it's set to never lock.
func test_getVaultTimeout_neverLock() async throws {
appSettingsStore.vaultTimeout["1"] = nil
keychainRepository.mockStorage[keychainRepository.formattedKey(for: .neverLock(userId: "1"))] = "NEVER_LOCK_KEY"
await subject.addAccount(.fixture(profile: .fixture(userId: "1")))
let vaultTimeout = try await subject.getVaultTimeout()
XCTAssertEqual(vaultTimeout, .never)
}
/// `getVaultTimeout(userId:)` returns the default timeout if the user has a never lock value
/// stored but the never lock key doesn't exist.
func test_getVaultTimeout_neverLock_missingKey() async throws {
appSettingsStore.vaultTimeout["1"] = -2
await subject.addAccount(.fixture(profile: .fixture(userId: "1")))
let vaultTimeout = try await subject.getVaultTimeout()
XCTAssertEqual(vaultTimeout, .fifteenMinutes)
}
/// `lastSyncTimePublisher()` returns a publisher for the user's last sync time.
func test_lastSyncTimePublisher() async throws {
@ -2227,19 +2169,6 @@ class StateServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body
XCTAssertFalse(appSettingsStore.hasPerformedSyncAfterLogin["1"]!)
}
/// `setLastActiveTime(userId:)` sets the user's last active time.
func test_setLastActiveTime() async throws {
await subject.addAccount(.fixture(profile: .fixture(userId: "1")))
try await subject.setLastActiveTime(Date())
XCTAssertEqual(
appSettingsStore.lastActiveTime["1"]!.timeIntervalSince1970,
Date().timeIntervalSince1970,
accuracy: 1.0,
)
}
/// `setLearnGeneratorActionCardStatus(_:)` sets the learn generator action card status.
func test_setLearnGeneratorActionCardStatus() async {
await subject.setLearnGeneratorActionCardStatus(.incomplete)
@ -2557,15 +2486,6 @@ class StateServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body
XCTAssertEqual(appSettingsStore.twoFactorToken(email: "winner@email.com"), "yay_you_win!")
}
/// `setUnsuccessfulUnlockAttempts(userId:)` sets the unsuccessful unlock attempts for the account.
func test_setUnsuccessfulUnlockAttempts() async throws {
await subject.addAccount(.fixture(profile: .fixture(userId: "1")))
try await subject.setUnsuccessfulUnlockAttempts(3, userId: "1")
XCTAssertEqual(appSettingsStore.unsuccessfulUnlockAttempts["1"], 3)
}
/// `setUsernameGenerationOptions` sets the username generation options for an account.
func test_setUsernameGenerationOptions() async throws {
let options1 = UsernameGenerationOptions(plusAddressedEmail: "user@bitwarden.com")
@ -2716,15 +2636,6 @@ class StateServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body
XCTAssertEqual(appSettingsStore.timeoutAction["1"], 1)
}
/// `.setVaultTimeout(value:userId:)` sets the vault timeout value for the user.
func test_setVaultTimeout() async throws {
await subject.addAccount(.fixture(profile: .fixture(userId: "1")))
try await subject.setVaultTimeout(value: .custom(20))
XCTAssertEqual(appSettingsStore.vaultTimeout["1"], 20)
}
/// `showWebIconsPublisher()` returns a publisher for the show web icons value.
func test_showWebIconsPublisher() async {
var publishedValues = [Bool]()

View File

@ -195,13 +195,6 @@ protocol AppSettingsStore: AnyObject {
///
func hasPerformedSyncAfterLogin(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?
/// Get the user's Biometric Authentication Preference.
///
/// - Parameter userId: The user ID associated with the biometric authentication preference.
@ -421,14 +414,6 @@ protocol AppSettingsStore: AnyObject {
/// - userId: The user ID associated with the sync after login.
func setHasPerformedSyncAfterLogin(_ hasBeenPerformed: Bool?, userId: 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 time of the last sync for the user ID.
///
/// - Parameters:
@ -539,14 +524,6 @@ protocol AppSettingsStore: AnyObject {
///
func setTwoFactorToken(_ token: String?, email: String)
/// Sets the number of unsuccessful attempts to unlock the vault for a user ID.
///
/// - Parameters:
/// - attempts: The number of unsuccessful unlock attempts.
/// - userId: The user ID associated with the unsuccessful unlock attempts.
///
func setUnsuccessfulUnlockAttempts(_ attempts: Int, userId: String)
/// Sets whether the user uses key connector.
///
/// - Parameters:
@ -555,14 +532,6 @@ protocol AppSettingsStore: AnyObject {
///
func setUsesKeyConnector(_ usesKeyConnector: Bool, 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)
/// Sets the username generation options for a user ID.
///
/// - Parameters:
@ -613,13 +582,6 @@ protocol AppSettingsStore: AnyObject {
///
func usernameGenerationOptions(userId: String) -> UsernameGenerationOptions?
/// Gets the number of unsuccessful attempts to unlock the vault for a user ID.
///
/// - Parameter userId: The user ID associated with the unsuccessful unlock attempts.
/// - Returns: The number of unsuccessful attempts to unlock the vault.
///
func unsuccessfulUnlockAttempts(userId: String) -> Int
/// Gets whether the user uses key connector.
///
/// - Parameter userId: The user ID to check if they use key connector.
@ -627,13 +589,6 @@ protocol AppSettingsStore: AnyObject {
///
func usesKeyConnector(userId: String) -> Bool
/// 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: Publishers
/// A publisher for the active account id
@ -786,7 +741,6 @@ extension DefaultAppSettingsStore: AppSettingsStore, ConfigSettingsStore {
case hasPerformedSyncAfterLogin(userId: String)
case introCarouselShown
case learnNewLoginActionCardStatus
case lastActiveTime(userId: String)
case lastSync(userId: String)
case lastUserShouldConnectToWatch
case learnGeneratorActionCardStatus
@ -811,10 +765,8 @@ extension DefaultAppSettingsStore: AppSettingsStore, ConfigSettingsStore {
case syncToAuthenticator(userId: String)
case state
case twoFactorToken(email: String)
case unsuccessfulUnlockAttempts(userId: String)
case usernameGenerationOptions(userId: String)
case usesKeyConnector(userId: String)
case vaultTimeout(userId: String)
case vaultTimeoutAction(userId: String)
/// Returns the key used to store the data under for retrieving it later.
@ -876,8 +828,6 @@ extension DefaultAppSettingsStore: AppSettingsStore, ConfigSettingsStore {
"introCarouselShown"
case .learnNewLoginActionCardStatus:
"learnNewLoginActionCardStatus"
case let .lastActiveTime(userId):
"lastActiveTime_\(userId)"
case let .lastSync(userId):
"lastSync_\(userId)"
case .learnGeneratorActionCardStatus:
@ -926,14 +876,10 @@ extension DefaultAppSettingsStore: AppSettingsStore, ConfigSettingsStore {
"shouldSyncToAuthenticator_\(userId)"
case let .twoFactorToken(email):
"twoFactorToken_\(email)"
case let .unsuccessfulUnlockAttempts(userId):
"invalidUnlockAttempts_\(userId)"
case let .usernameGenerationOptions(userId):
"usernameGenerationOptions_\(userId)"
case let .usesKeyConnector(userId):
"usesKeyConnector_\(userId)"
case let .vaultTimeout(userId):
"vaultTimeout_\(userId)"
case let .vaultTimeoutAction(userId):
"vaultTimeoutAction_\(userId)"
}
@ -1124,10 +1070,6 @@ extension DefaultAppSettingsStore: AppSettingsStore, ConfigSettingsStore {
fetch(for: .hasPerformedSyncAfterLogin(userId: userId))
}
func lastActiveTime(userId: String) -> Date? {
fetch(for: .lastActiveTime(userId: userId)).map { Date(timeIntervalSince1970: $0) }
}
func isBiometricAuthenticationEnabled(userId: String) -> Bool {
fetch(for: .biometricAuthEnabled(userId: userId))
}
@ -1246,10 +1188,6 @@ extension DefaultAppSettingsStore: AppSettingsStore, ConfigSettingsStore {
store(hasBeenPerformed, for: .hasPerformedSyncAfterLogin(userId: userId))
}
func setLastActiveTime(_ date: Date?, userId: String) {
store(date?.timeIntervalSince1970, for: .lastActiveTime(userId: userId))
}
func setLastSyncTime(_ date: Date?, userId: String) {
store(date?.timeIntervalSince1970, for: .lastSync(userId: userId))
}
@ -1310,10 +1248,6 @@ extension DefaultAppSettingsStore: AppSettingsStore, ConfigSettingsStore {
store(usesKeyConnector, for: .usesKeyConnector(userId: userId))
}
func setVaultTimeout(minutes: Int, userId: String) {
store(minutes, for: .vaultTimeout(userId: userId))
}
func setSiriAndShortcutsAccess(_ siriAndShortcutsAccess: Bool, userId: String) {
store(siriAndShortcutsAccess, for: .siriAndShortcutsAccess(userId: userId))
}
@ -1334,14 +1268,6 @@ extension DefaultAppSettingsStore: AppSettingsStore, ConfigSettingsStore {
fetch(for: .twoFactorToken(email: email))
}
func vaultTimeout(userId: String) -> Int? {
fetch(for: .vaultTimeout(userId: userId))
}
func unsuccessfulUnlockAttempts(userId: String) -> Int {
fetch(for: .unsuccessfulUnlockAttempts(userId: userId))
}
func usernameGenerationOptions(userId: String) -> UsernameGenerationOptions? {
fetch(for: .usernameGenerationOptions(userId: userId))
}
@ -1350,10 +1276,6 @@ extension DefaultAppSettingsStore: AppSettingsStore, ConfigSettingsStore {
fetch(for: .usesKeyConnector(userId: userId))
}
func setUnsuccessfulUnlockAttempts(_ attempts: Int, userId: String) {
store(attempts, for: .unsuccessfulUnlockAttempts(userId: userId))
}
func activeAccountIdPublisher() -> AnyPublisher<String?, Never> {
activeAccountIdSubject.eraseToAnyPublisher()
}

View File

@ -638,23 +638,6 @@ class AppSettingsStoreTests: BitwardenTestCase { // swiftlint:disable:this type_
XCTAssertFalse(userDefaults.bool(forKey: "bwPreferencesStorage:lastUserShouldConnectToWatch"))
}
/// `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)
}
/// `lastSyncTime(userId:)` returns `nil` if there isn't a previously stored value.
func test_lastSyncTime_isInitiallyNil() {
XCTAssertNil(subject.lastSyncTime(userId: "-1"))
@ -1127,23 +1110,6 @@ class AppSettingsStoreTests: BitwardenTestCase { // swiftlint:disable:this type_
)
}
/// `unsuccessfulUnlockAttempts` returns `0` if there isn't a previously stored value.
func test_unsuccessfulUnlockAttempts_isInitially0() {
XCTAssertEqual(0, subject.unsuccessfulUnlockAttempts(userId: "1"))
}
/// `unsuccessfulUnlockAttempts(userId:)`can be used to get the unsuccessful unlock attempts for a user.
func test_unsuccessfulUnlockAttempts_withValue() {
subject.setUnsuccessfulUnlockAttempts(4, userId: "1")
subject.setUnsuccessfulUnlockAttempts(1, userId: "3")
XCTAssertEqual(subject.unsuccessfulUnlockAttempts(userId: "1"), 4)
XCTAssertEqual(subject.unsuccessfulUnlockAttempts(userId: "3"), 1)
XCTAssertEqual(4, userDefaults.integer(forKey: "bwPreferencesStorage:invalidUnlockAttempts_1"))
XCTAssertEqual(1, userDefaults.integer(forKey: "bwPreferencesStorage:invalidUnlockAttempts_3"))
}
/// `usernameGenerationOptions(userId:)` returns `nil` if there isn't a previously stored value.
func test_usernameGenerationOptions_isInitiallyNil() {
XCTAssertNil(subject.usernameGenerationOptions(userId: "-1"))
@ -1316,12 +1282,4 @@ class AppSettingsStoreTests: BitwardenTestCase { // swiftlint:disable:this type_
.logout,
)
}
/// `.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: "bwPreferencesStorage:vaultTimeout_1"), 60)
}
}

View File

@ -53,7 +53,6 @@ class MockStateService: StateService, ActiveAccountStateProvider { // swiftlint:
var isAuthenticated = [String: Bool]()
var isAuthenticatedError: Error?
var isInitialSyncRequiredByUserId = [String: Bool]()
var lastActiveTime = [String: Date]()
var learnGeneratorActionCardStatus: AccountSetupProgress?
var learnNewLoginActionCardStatus: AccountSetupProgress?
var loginRequest: LoginRequestNotification?
@ -98,15 +97,12 @@ class MockStateService: StateService, ActiveAccountStateProvider { // swiftlint:
var setAppRehydrationStateError: Error?
var setBiometricAuthenticationEnabledResult: Result<Void, Error> = .success(())
var setBiometricIntegrityStateError: Error?
var setLastActiveTimeError: Error?
var setVaultTimeoutError: Error?
var settingsBadgeSubject = CurrentValueSubject<SettingsBadgeState, Never>(.fixture())
var shouldTrustDevice = [String: Bool?]()
var syncToAuthenticatorByUserId = [String: Bool]()
var syncToAuthenticatorResult: Result<Void, Error> = .success(())
var syncToAuthenticatorSubject = CurrentValueSubject<(String?, Bool), Never>((nil, false))
var twoFactorTokens = [String: String]()
var unsuccessfulUnlockAttempts = [String: Int]()
var updateProfileResponse: ProfileResponseModel?
var updateProfileUserId: String?
var userHasMasterPassword = [String: Bool]()
@ -114,7 +110,6 @@ class MockStateService: StateService, ActiveAccountStateProvider { // swiftlint:
var userIds = [String]()
var usernameGenerationOptions = [String: UsernameGenerationOptions]()
var usesKeyConnector = [String: Bool]()
var vaultTimeout = [String: SessionTimeoutValue]()
lazy var activeIdSubject = CurrentValueSubject<String?, Never>(self.activeAccount?.profile.userId)
lazy var appThemeSubject = CurrentValueSubject<AppTheme, Never>(self.appTheme ?? .default)
@ -306,11 +301,6 @@ class MockStateService: StateService, ActiveAccountStateProvider { // swiftlint:
learnNewLoginActionCardStatus
}
func getLastActiveTime(userId: String?) async throws -> Date? {
let userId = try unwrapUserId(userId)
return lastActiveTime[userId]
}
func getLastSyncTime(userId: String?) async throws -> Date? {
let userId = try unwrapUserId(userId)
return lastSyncTimeByUserId[userId]
@ -400,11 +390,6 @@ class MockStateService: StateService, ActiveAccountStateProvider { // swiftlint:
twoFactorTokens[email]
}
func getUnsuccessfulUnlockAttempts(userId: String?) async throws -> Int {
let userId = try unwrapUserId(userId)
return unsuccessfulUnlockAttempts[userId] ?? 0
}
func getUserHasMasterPassword(userId: String?) async throws -> Bool {
if let userHasMasterPasswordError { throw userHasMasterPasswordError }
let userId = try unwrapUserId(userId)
@ -425,11 +410,6 @@ class MockStateService: StateService, ActiveAccountStateProvider { // swiftlint:
return usesKeyConnector[userId] ?? false
}
func getVaultTimeout(userId: String?) async throws -> SessionTimeoutValue {
let userId = try unwrapUserId(userId)
return vaultTimeout[userId] ?? .immediately
}
func isAuthenticated(userId: String?) async throws -> Bool {
let userId = try unwrapUserId(userId)
if let isAuthenticatedError { throw isAuthenticatedError }
@ -623,12 +603,6 @@ class MockStateService: StateService, ActiveAccountStateProvider { // swiftlint:
learnNewLoginActionCardStatus = status
}
func setLastActiveTime(_ date: Date?, userId: String?) async throws {
if let setLastActiveTimeError { throw setLastActiveTimeError }
let userId = try unwrapUserId(userId)
lastActiveTime[userId] = date
}
func setLastSyncTime(_ date: Date?, userId: String?) async throws {
let userId = try unwrapUserId(userId)
lastSyncTimeByUserId[userId] = date
@ -748,11 +722,6 @@ class MockStateService: StateService, ActiveAccountStateProvider { // swiftlint:
twoFactorTokens[email] = token
}
func setUnsuccessfulUnlockAttempts(_ attempts: Int, userId: String?) async throws {
let userId = try unwrapUserId(userId)
unsuccessfulUnlockAttempts[userId] = attempts
}
func setUserHasMasterPassword(_ hasMasterPassword: Bool) async throws {
let userId = try unwrapUserId(nil)
userHasMasterPassword[userId] = hasMasterPassword
@ -768,12 +737,6 @@ class MockStateService: StateService, ActiveAccountStateProvider { // swiftlint:
self.usesKeyConnector[userId] = usesKeyConnector
}
func setVaultTimeout(value: SessionTimeoutValue, userId: String?) async throws {
if let setVaultTimeoutError { throw setVaultTimeoutError }
let userId = try unwrapUserId(userId)
vaultTimeout[userId] = value
}
/// Attempts to convert a possible user id into an account, or returns the active account.
///
/// - Parameter userId: If nil, the active account is returned. Otherwise, retrieve an account for the id.

View File

@ -65,6 +65,7 @@ extension ServiceContainer {
totpExpirationManagerFactory: TOTPExpirationManagerFactory = MockTOTPExpirationManagerFactory(),
totpService: TOTPService = MockTOTPService(),
twoStepLoginService: TwoStepLoginService = MockTwoStepLoginService(),
userSessionStateService: UserSessionStateService = MockUserSessionStateService(),
userVerificationHelperFactory: UserVerificationHelperFactory = MockUserVerificationHelperFactory(),
vaultRepository: VaultRepository = MockVaultRepository(),
vaultTimeoutService: VaultTimeoutService = MockVaultTimeoutService(),
@ -139,6 +140,7 @@ extension ServiceContainer {
totpService: totpService,
trustDeviceService: trustDeviceService,
twoStepLoginService: twoStepLoginService,
userSessionStateService: userSessionStateService,
userVerificationHelperFactory: userVerificationHelperFactory,
vaultRepository: vaultRepository,
vaultTimeoutService: vaultTimeoutService,

View File

@ -0,0 +1,121 @@
import BitwardenKit
import Foundation
// MARK: - UserSessionStateService
/// A service that provides state management functionality around user session values.
///
protocol UserSessionStateService { // sourcery: AutoMockable
// MARK: Last Active Time
/// Gets 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.
/// - Returns: The date of the last active time.
///
func getLastActiveTime(userId: String?) async throws -> Date?
/// 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?) async throws
// MARK: Unsuccessful Unlock Attempts
/// Gets the number of unsuccessful attempts to unlock the vault for a user ID.
///
/// - Parameter userId: The optional user ID associated with the unsuccessful unlock attempts,
/// if `nil` defaults to currently active user.
/// - Returns: The number of unsuccessful attempts to unlock the vault.
///
func getUnsuccessfulUnlockAttempts(userId: String?) async throws -> Int
/// Sets the number of unsuccessful attempts to unlock the vault for a user ID.
///
/// - Parameter userId: The user ID associated with the unsuccessful unlock attempts.
/// if `nil` defaults to currently active user.
///
func setUnsuccessfulUnlockAttempts(_ attempts: Int, userId: String?) async throws
// MARK: Vault Timeout
/// Gets the session timeout value.
///
/// - Parameter userId: The user ID for the account.
/// - Returns: The session timeout value.
///
func getVaultTimeout(userId: String?) async throws -> SessionTimeoutValue
/// Sets the session timeout value.
///
/// - Parameters:
/// - value: The value that dictates how many seconds in the future a timeout should occur.
/// - userId: The user ID associated with the timeout value.
///
func setVaultTimeout(_ value: SessionTimeoutValue, userId: String?) async throws
}
/// Convenience functions for the current user.
extension UserSessionStateService {
// MARK: Last Active Time
/// Gets the user's last active time within the app.
/// This value is set when the app is backgrounded.
///
/// - Returns: The date of the last active time.
///
func getLastActiveTime() async throws -> Date? {
try await getLastActiveTime(userId: nil)
}
/// Sets the last active time within the app.
///
/// - Parameter date: The current time.
///
func setLastActiveTime(_ date: Date?) async throws {
try await setLastActiveTime(date, userId: nil)
}
// MARK: Unsuccessful Unlock Attempts
/// Sets the number of unsuccessful attempts to unlock the vault for the active account.
///
/// - Returns: The number of unsuccessful unlock attempts for the active account.
///
func getUnsuccessfulUnlockAttempts() async -> Int {
if let attempts = try? await getUnsuccessfulUnlockAttempts(userId: nil) {
return attempts
}
return 0
}
/// Sets the number of unsuccessful attempts to unlock the vault for the active account.
///
/// - Parameter attempts: The number of unsuccessful unlock attempts.
///
func setUnsuccessfulUnlockAttempts(_ attempts: Int) async {
try? await setUnsuccessfulUnlockAttempts(attempts, userId: nil)
}
// MARK: Vault Timeout
/// Gets the session timeout value.
///
/// - Returns: The session timeout value.
///
func getVaultTimeout() async throws -> SessionTimeoutValue {
try await getVaultTimeout(userId: nil)
}
/// Sets the session timeout value.
///
/// - Parameter value: The value that dictates how many seconds in the future a timeout should occur.
///
func setVaultTimeout(_ value: SessionTimeoutValue) async throws {
try await setVaultTimeout(value, userId: nil)
}
}

View File

@ -177,6 +177,9 @@ class DefaultSyncService: SyncService {
/// The time provider for this service.
private let timeProvider: TimeProvider
/// The service used by the application to manage user session state.
private let userSessionStateService: UserSessionStateService
/// The service used by the application to manage vault access.
private let vaultTimeoutService: VaultTimeoutService
@ -200,6 +203,7 @@ class DefaultSyncService: SyncService {
/// - stateService: The service used by the application to manage account state.
/// - syncAPIService: The API service used to perform sync API requests.
/// - timeProvider: The time provider for this service.
/// - userSessionStateService: The service used by the application to manage user session state.
/// - vaultTimeoutService: The service used by the application to manage vault access.
///
init(
@ -218,6 +222,7 @@ class DefaultSyncService: SyncService {
stateService: StateService,
syncAPIService: SyncAPIService,
timeProvider: TimeProvider,
userSessionStateService: UserSessionStateService,
vaultTimeoutService: VaultTimeoutService,
) {
self.accountAPIService = accountAPIService
@ -235,6 +240,7 @@ class DefaultSyncService: SyncService {
self.stateService = stateService
self.syncAPIService = syncAPIService
self.timeProvider = timeProvider
self.userSessionStateService = userSessionStateService
self.vaultTimeoutService = vaultTimeoutService
}
@ -479,21 +485,17 @@ extension DefaultSyncService {
guard let value = timeoutPolicyValues.timeoutValue?.rawValue else { return }
let timeoutAction = try await stateService.getTimeoutAction()
let timeoutValue = try await stateService.getVaultTimeout()
let timeoutValue = try await userSessionStateService.getVaultTimeout()
// For onAppRestart and never policy types, preserve the user's current timeout value
// as these policy types don't restrict the value itself, only the behavior
if type == SessionTimeoutType.onAppRestart || type == SessionTimeoutType.never {
try await stateService.setVaultTimeout(
value: timeoutValue,
)
try await userSessionStateService.setVaultTimeout(timeoutValue)
} else {
// Only update the user's stored vault timeout value if
// their stored timeout value is > the policy's timeout value.
if timeoutValue.rawValue > value || timeoutValue.rawValue < 0 {
try await stateService.setVaultTimeout(
value: SessionTimeoutValue(rawValue: value),
)
try await userSessionStateService.setVaultTimeout(SessionTimeoutValue(rawValue: value))
}
}

View File

@ -28,6 +28,7 @@ class SyncServiceTests: BitwardenTestCase {
var subject: SyncService!
var syncServiceDelegate: MockSyncServiceDelegate!
var timeProvider: MockTimeProvider!
var userSessionStateService: MockUserSessionStateService!
var vaultTimeoutService: MockVaultTimeoutService!
// MARK: Setup & Teardown
@ -58,8 +59,11 @@ class SyncServiceTests: BitwardenTestCase {
),
),
)
userSessionStateService = MockUserSessionStateService()
vaultTimeoutService = MockVaultTimeoutService()
userSessionStateService.getVaultTimeoutReturnValue = .fifteenMinutes
subject = DefaultSyncService(
accountAPIService: APIService(client: client),
appContextHelper: appContextHelper,
@ -76,6 +80,7 @@ class SyncServiceTests: BitwardenTestCase {
stateService: stateService,
syncAPIService: APIService(client: client),
timeProvider: timeProvider,
userSessionStateService: userSessionStateService,
vaultTimeoutService: vaultTimeoutService,
)
subject.delegate = syncServiceDelegate
@ -100,6 +105,7 @@ class SyncServiceTests: BitwardenTestCase {
subject = nil
syncServiceDelegate = nil
timeProvider = nil
userSessionStateService = nil
vaultTimeoutService = nil
}
@ -275,6 +281,7 @@ class SyncServiceTests: BitwardenTestCase {
func test_checkVaultTimeoutPolicy_actionOnly() async throws {
client.result = .httpSuccess(testData: .syncWithCiphers)
stateService.activeAccount = .fixture()
userSessionStateService.getVaultTimeoutReturnValue = SessionTimeoutValue(rawValue: 15)
policyService.fetchTimeoutPolicyValuesResult = .success(
SessionTimeoutPolicy(
timeoutAction: .logout,
@ -286,7 +293,7 @@ class SyncServiceTests: BitwardenTestCase {
try await subject.fetchSync(forceSync: false)
XCTAssertEqual(stateService.timeoutAction["1"], .logout)
XCTAssertNil(stateService.vaultTimeout["1"])
XCTAssertEqual(userSessionStateService.setVaultTimeoutCallsCount, 0)
}
/// `fetchSync()` updates the user's timeout action and value
@ -294,7 +301,7 @@ class SyncServiceTests: BitwardenTestCase {
func test_checkVaultTimeoutPolicy_actionAndValue() async throws {
client.result = .httpSuccess(testData: .syncWithCiphers)
stateService.activeAccount = .fixture()
stateService.vaultTimeout["1"] = SessionTimeoutValue(rawValue: 120)
userSessionStateService.getVaultTimeoutReturnValue = SessionTimeoutValue(rawValue: 120)
policyService.fetchTimeoutPolicyValuesResult = .success(
SessionTimeoutPolicy(
@ -307,14 +314,17 @@ class SyncServiceTests: BitwardenTestCase {
try await subject.fetchSync(forceSync: false)
XCTAssertEqual(stateService.timeoutAction["1"], .logout)
XCTAssertEqual(stateService.vaultTimeout["1"], SessionTimeoutValue(rawValue: 60))
XCTAssertEqual(
userSessionStateService.setVaultTimeoutReceivedArguments?.value,
SessionTimeoutValue(rawValue: 60),
)
}
/// `fetchSync()` updates the user's timeout action and ignores the time value.
func test_checkVaultTimeoutPolicy_setActionForOnAppRestartType() async throws {
client.result = .httpSuccess(testData: .syncWithCiphers)
stateService.activeAccount = .fixture()
stateService.vaultTimeout["1"] = SessionTimeoutValue(rawValue: 120)
userSessionStateService.getVaultTimeoutReturnValue = SessionTimeoutValue(rawValue: 120)
policyService.fetchTimeoutPolicyValuesResult = .success(
SessionTimeoutPolicy(
@ -327,7 +337,10 @@ class SyncServiceTests: BitwardenTestCase {
try await subject.fetchSync(forceSync: false)
XCTAssertEqual(stateService.timeoutAction["1"], .logout)
XCTAssertEqual(stateService.vaultTimeout["1"], SessionTimeoutValue(rawValue: 120))
XCTAssertEqual(
userSessionStateService.setVaultTimeoutReceivedArguments?.value,
SessionTimeoutValue(rawValue: 120),
)
}
/// `fetchSync()` updates the user's timeout action and value - if the timeout value is set to
@ -335,7 +348,7 @@ class SyncServiceTests: BitwardenTestCase {
func test_checkVaultTimeoutPolicy_valueNever() async throws {
client.result = .httpSuccess(testData: .syncWithCiphers)
stateService.activeAccount = .fixture()
stateService.vaultTimeout["1"] = .never
userSessionStateService.getVaultTimeoutReturnValue = .never
policyService.fetchTimeoutPolicyValuesResult = .success(
SessionTimeoutPolicy(
@ -348,7 +361,10 @@ class SyncServiceTests: BitwardenTestCase {
try await subject.fetchSync(forceSync: false)
XCTAssertEqual(stateService.timeoutAction["1"], .lock)
XCTAssertEqual(stateService.vaultTimeout["1"], SessionTimeoutValue.fifteenMinutes)
XCTAssertEqual(
userSessionStateService.setVaultTimeoutReceivedArguments?.value,
SessionTimeoutValue.fifteenMinutes,
)
}
/// `fetchSync()` performs the sync API request.

View File

@ -138,6 +138,9 @@ class DefaultVaultTimeoutService: VaultTimeoutService {
/// Provides the current time.
private let timeProvider: TimeProvider
/// A service that provides state management functionality around user session values.
private let userSessionStateService: UserSessionStateService
/// A subject containing the user's vault locked status mapped to their user ID.
private let vaultLockStatusSubject = CurrentValueSubject<[String: Bool], Never>([:])
@ -153,6 +156,7 @@ class DefaultVaultTimeoutService: VaultTimeoutService {
/// - sharedTimeoutService: The service that manages account timeout between apps.
/// - stateService: The StateService used by DefaultVaultTimeoutService.
/// - timeProvider: Provides the current time.
/// - userSessionStateService: A service that provides state management functionality around user session values.
///
init(
biometricsRepository: BiometricsRepository,
@ -162,6 +166,7 @@ class DefaultVaultTimeoutService: VaultTimeoutService {
sharedTimeoutService: SharedTimeoutService,
stateService: StateService,
timeProvider: TimeProvider,
userSessionStateService: UserSessionStateService,
) {
self.biometricsRepository = biometricsRepository
self.clientService = clientService
@ -170,6 +175,7 @@ class DefaultVaultTimeoutService: VaultTimeoutService {
self.sharedTimeoutService = sharedTimeoutService
self.stateService = stateService
self.timeProvider = timeProvider
self.userSessionStateService = userSessionStateService
}
// MARK: Methods
@ -184,7 +190,7 @@ class DefaultVaultTimeoutService: VaultTimeoutService {
return isAppRestart
default:
// Otherwise, calculate a timeout.
guard let lastActiveTime = try await stateService.getLastActiveTime(userId: userId)
guard let lastActiveTime = try await userSessionStateService.getLastActiveTime(userId: userId)
else { return true }
return timeProvider.presentTime.timeIntervalSince(lastActiveTime)
@ -245,7 +251,7 @@ class DefaultVaultTimeoutService: VaultTimeoutService {
func setLastActiveTime(userId: String) async throws {
let now = timeProvider.presentTime
try await stateService.setLastActiveTime(now, userId: userId)
try await userSessionStateService.setLastActiveTime(now, userId: userId)
let vaultTimeout = try await sessionTimeoutValue(userId: userId)
try await updateSharedTimeout(
lastActiveTime: now,
@ -255,9 +261,9 @@ class DefaultVaultTimeoutService: VaultTimeoutService {
}
func setVaultTimeout(value: SessionTimeoutValue, userId: String?) async throws {
try await stateService.setVaultTimeout(value: value, userId: userId)
try await userSessionStateService.setVaultTimeout(value, userId: userId)
guard let userId else { return }
let lastActiveTime = try await stateService.getLastActiveTime(userId: userId)
let lastActiveTime = try await userSessionStateService.getLastActiveTime(userId: userId)
try await updateSharedTimeout(
lastActiveTime: lastActiveTime,
timeoutValue: value,
@ -272,7 +278,7 @@ class DefaultVaultTimeoutService: VaultTimeoutService {
}
func sessionTimeoutValue(userId: String?) async throws -> SessionTimeoutValue {
try await stateService.getVaultTimeout(userId: userId)
try await userSessionStateService.getVaultTimeout(userId: userId)
}
func vaultLockStatusPublisher() async -> AnyPublisher<VaultLockStatus?, Never> {

View File

@ -22,6 +22,7 @@ final class VaultTimeoutServiceTests: BitwardenTestCase { // swiftlint:disable:t
var stateService: MockStateService!
var subject: DefaultVaultTimeoutService!
var timeProvider: MockTimeProvider!
var userSessionStateService: MockUserSessionStateService!
// MARK: Setup & Teardown
@ -40,6 +41,10 @@ final class VaultTimeoutServiceTests: BitwardenTestCase { // swiftlint:disable:t
.init(year: 2024, month: 1, day: 1),
),
)
userSessionStateService = MockUserSessionStateService()
userSessionStateService.getVaultTimeoutReturnValue = .fifteenMinutes
userSessionStateService.getUnsuccessfulUnlockAttemptsReturnValue = 0
subject = DefaultVaultTimeoutService(
biometricsRepository: biometricsRepository,
clientService: clientService,
@ -48,6 +53,7 @@ final class VaultTimeoutServiceTests: BitwardenTestCase { // swiftlint:disable:t
sharedTimeoutService: sharedTimeoutService,
stateService: stateService,
timeProvider: timeProvider,
userSessionStateService: userSessionStateService,
)
}
@ -70,31 +76,31 @@ final class VaultTimeoutServiceTests: BitwardenTestCase { // swiftlint:disable:t
func test_hasPassedSessionTimeout() async throws {
let account = Account.fixture()
stateService.activeAccount = account
stateService.vaultTimeout[account.profile.userId] = .fiveMinutes
userSessionStateService.getVaultTimeoutReturnValue = .fiveMinutes
let currentTime = Date(year: 2024, month: 1, day: 2, hour: 6, minute: 0)
timeProvider.timeConfig = .mockTime(currentTime)
// Last active 4 minutes ago, no timeout.
stateService.lastActiveTime[account.profile.userId] = Calendar.current
userSessionStateService.getLastActiveTimeReturnValue = Calendar.current
.date(byAdding: .minute, value: -4, to: currentTime)
var shouldTimeout = try await subject.hasPassedSessionTimeout(userId: account.profile.userId)
XCTAssertFalse(shouldTimeout)
// Last active 5 minutes ago, timeout.
stateService.lastActiveTime[account.profile.userId] = Calendar.current
userSessionStateService.getLastActiveTimeReturnValue = Calendar.current
.date(byAdding: .minute, value: -5, to: currentTime)
shouldTimeout = try await subject.hasPassedSessionTimeout(userId: account.profile.userId)
XCTAssertTrue(shouldTimeout)
// Last active 6 minutes ago, timeout.
stateService.lastActiveTime[account.profile.userId] = Calendar.current
userSessionStateService.getLastActiveTimeReturnValue = Calendar.current
.date(byAdding: .minute, value: -6, to: currentTime)
shouldTimeout = try await subject.hasPassedSessionTimeout(userId: account.profile.userId)
XCTAssertTrue(shouldTimeout)
// Last active in the distant past, timeout.
stateService.lastActiveTime[account.profile.userId] = .distantPast
userSessionStateService.getLastActiveTimeReturnValue = .distantPast
shouldTimeout = try await subject.hasPassedSessionTimeout(userId: account.profile.userId)
XCTAssertTrue(shouldTimeout)
}
@ -103,8 +109,8 @@ final class VaultTimeoutServiceTests: BitwardenTestCase { // swiftlint:disable:t
func test_hasPassedSessionTimeout_appRestart_notRestarting() async throws {
let account = Account.fixture()
stateService.activeAccount = account
stateService.lastActiveTime[account.profile.userId] = .distantPast
stateService.vaultTimeout[account.profile.userId] = .onAppRestart
userSessionStateService.getLastActiveTimeReturnValue = .distantPast
userSessionStateService.getVaultTimeoutReturnValue = .onAppRestart
let shouldTimeout = try await subject.hasPassedSessionTimeout(
userId: account.profile.userId,
@ -117,8 +123,8 @@ final class VaultTimeoutServiceTests: BitwardenTestCase { // swiftlint:disable:t
func test_hasPassedSessionTimeout_appRestart_isRestarting() async throws {
let account = Account.fixture()
stateService.activeAccount = account
stateService.lastActiveTime[account.profile.userId] = .distantPast
stateService.vaultTimeout[account.profile.userId] = .onAppRestart
userSessionStateService.getLastActiveTimeReturnValue = .distantPast
userSessionStateService.getVaultTimeoutReturnValue = .onAppRestart
let shouldTimeout = try await subject.hasPassedSessionTimeout(
userId: account.profile.userId,
@ -131,31 +137,31 @@ final class VaultTimeoutServiceTests: BitwardenTestCase { // swiftlint:disable:t
func test_hasPassedSessionTimeout_custom() async throws {
let account = Account.fixture()
stateService.activeAccount = account
stateService.vaultTimeout[account.profile.userId] = .custom(120)
userSessionStateService.getVaultTimeoutReturnValue = .custom(120)
let currentTime = Date(year: 2024, month: 1, day: 2, hour: 6, minute: 0)
timeProvider.timeConfig = .mockTime(currentTime)
// Last active 119 minutes ago, no timeout.
stateService.lastActiveTime[account.profile.userId] = Calendar.current
userSessionStateService.getLastActiveTimeReturnValue = Calendar.current
.date(byAdding: .minute, value: -119, to: currentTime)
var shouldTimeout = try await subject.hasPassedSessionTimeout(userId: account.profile.userId)
XCTAssertFalse(shouldTimeout)
// Last active 120 minutes ago, timeout.
stateService.lastActiveTime[account.profile.userId] = Calendar.current
userSessionStateService.getLastActiveTimeReturnValue = Calendar.current
.date(byAdding: .minute, value: -120, to: currentTime)
shouldTimeout = try await subject.hasPassedSessionTimeout(userId: account.profile.userId)
XCTAssertTrue(shouldTimeout)
// Last active 121 minutes ago, timeout.
stateService.lastActiveTime[account.profile.userId] = Calendar.current
userSessionStateService.getLastActiveTimeReturnValue = Calendar.current
.date(byAdding: .minute, value: -121, to: currentTime)
shouldTimeout = try await subject.hasPassedSessionTimeout(userId: account.profile.userId)
XCTAssertTrue(shouldTimeout)
// Last active in the distant past, timeout.
stateService.lastActiveTime[account.profile.userId] = .distantPast
userSessionStateService.getLastActiveTimeReturnValue = .distantPast
shouldTimeout = try await subject.hasPassedSessionTimeout(userId: account.profile.userId)
XCTAssertTrue(shouldTimeout)
}
@ -164,7 +170,7 @@ final class VaultTimeoutServiceTests: BitwardenTestCase { // swiftlint:disable:t
func test_hasPassedSessionTimeout_noLastActiveTime() async throws {
let account = Account.fixture()
stateService.activeAccount = account
stateService.vaultTimeout[account.profile.userId] = .fiveMinutes
userSessionStateService.getVaultTimeoutReturnValue = .fiveMinutes
let shouldTimeout = try await subject.hasPassedSessionTimeout(userId: account.profile.userId)
XCTAssertTrue(shouldTimeout)
@ -174,8 +180,8 @@ final class VaultTimeoutServiceTests: BitwardenTestCase { // swiftlint:disable:t
func test_hasPassedSessionTimeout_never() async throws {
let account = Account.fixture()
stateService.activeAccount = account
stateService.lastActiveTime[account.profile.userId] = .distantPast
stateService.vaultTimeout[account.profile.userId] = .never
userSessionStateService.getLastActiveTimeReturnValue = .distantPast
userSessionStateService.getVaultTimeoutReturnValue = .never
let shouldTimeout = try await subject.hasPassedSessionTimeout(userId: account.profile.userId)
XCTAssertFalse(shouldTimeout)
@ -380,7 +386,7 @@ final class VaultTimeoutServiceTests: BitwardenTestCase { // swiftlint:disable:t
stateService.activeAccount = account
try await subject.setLastActiveTime(userId: account.profile.userId)
XCTAssertEqual(
stateService.lastActiveTime[account.profile.userId]!,
userSessionStateService.setLastActiveTimeReceivedArguments?.date,
timeProvider.presentTime,
)
XCTAssertEqual(sharedTimeoutService.clearTimeoutUserIds, ["1"])
@ -390,11 +396,11 @@ final class VaultTimeoutServiceTests: BitwardenTestCase { // swiftlint:disable:t
func test_setLastActiveTime_neverOrOnAppRestart() async throws {
let account = Account.fixture()
stateService.activeAccount = account
stateService.vaultTimeout[account.profile.userId] = .never
userSessionStateService.getVaultTimeoutReturnValue = .never
try await subject.setLastActiveTime(userId: account.profile.userId)
XCTAssertEqual(sharedTimeoutService.clearTimeoutUserIds, ["1"])
stateService.vaultTimeout[account.profile.userId] = .onAppRestart
userSessionStateService.getVaultTimeoutReturnValue = .onAppRestart
try await subject.setLastActiveTime(userId: account.profile.userId)
XCTAssertEqual(sharedTimeoutService.clearTimeoutUserIds, ["1", "1"])
}
@ -403,7 +409,7 @@ final class VaultTimeoutServiceTests: BitwardenTestCase { // swiftlint:disable:t
func test_setLastActiveTime_lock() async throws {
let account = Account.fixture()
stateService.activeAccount = account
stateService.vaultTimeout[account.profile.userId] = .fifteenMinutes
userSessionStateService.getVaultTimeoutReturnValue = .fifteenMinutes
stateService.timeoutAction[account.profile.userId] = .lock
try await subject.setLastActiveTime(userId: account.profile.userId)
@ -414,7 +420,7 @@ final class VaultTimeoutServiceTests: BitwardenTestCase { // swiftlint:disable:t
func test_setLastActiveTime_logout() async throws {
let account = Account.fixture()
stateService.activeAccount = account
stateService.vaultTimeout[account.profile.userId] = .oneMinute
userSessionStateService.getVaultTimeoutReturnValue = .oneMinute
stateService.timeoutAction[account.profile.userId] = .logout
try await subject.setLastActiveTime(userId: account.profile.userId)
@ -426,7 +432,7 @@ final class VaultTimeoutServiceTests: BitwardenTestCase { // swiftlint:disable:t
/// `.setLastActiveTime(userId:)` throws errors.
func test_setLastActive_time_error() async throws {
stateService.setLastActiveTimeError = BitwardenTestError.example
userSessionStateService.setLastActiveTimeThrowableError = BitwardenTestError.example
await assertAsyncThrows(error: BitwardenTestError.example) {
try await subject.setLastActiveTime(userId: "1")
@ -438,7 +444,8 @@ final class VaultTimeoutServiceTests: BitwardenTestCase { // swiftlint:disable:t
let account = Account.fixture()
stateService.activeAccount = account
try await subject.setVaultTimeout(value: .custom(120), userId: account.profile.userId)
XCTAssertEqual(stateService.vaultTimeout[account.profile.userId], .custom(120))
XCTAssertEqual(userSessionStateService.setVaultTimeoutReceivedArguments?.userId, account.profile.userId)
XCTAssertEqual(userSessionStateService.setVaultTimeoutReceivedArguments?.value, .custom(120))
XCTAssertEqual(sharedTimeoutService.clearTimeoutUserIds, ["1"])
}
@ -447,7 +454,8 @@ final class VaultTimeoutServiceTests: BitwardenTestCase { // swiftlint:disable:t
let account = Account.fixture()
stateService.activeAccount = account
try await subject.setVaultTimeout(value: .onAppRestart, userId: account.profile.userId)
XCTAssertEqual(stateService.vaultTimeout[account.profile.userId], .onAppRestart)
XCTAssertEqual(userSessionStateService.setVaultTimeoutReceivedArguments?.userId, account.profile.userId)
XCTAssertEqual(userSessionStateService.setVaultTimeoutReceivedArguments?.value, .onAppRestart)
XCTAssertEqual(sharedTimeoutService.clearTimeoutUserIds, ["1"])
}
@ -456,7 +464,8 @@ final class VaultTimeoutServiceTests: BitwardenTestCase { // swiftlint:disable:t
let account = Account.fixture()
stateService.activeAccount = account
try await subject.setVaultTimeout(value: .never, userId: account.profile.userId)
XCTAssertEqual(stateService.vaultTimeout[account.profile.userId], .never)
XCTAssertEqual(userSessionStateService.setVaultTimeoutReceivedArguments?.userId, account.profile.userId)
XCTAssertEqual(userSessionStateService.setVaultTimeoutReceivedArguments?.value, .never)
XCTAssertEqual(sharedTimeoutService.clearTimeoutUserIds, ["1"])
}
@ -474,7 +483,7 @@ final class VaultTimeoutServiceTests: BitwardenTestCase { // swiftlint:disable:t
func test_setVaultTimeout_logout() async throws {
let account = Account.fixture()
stateService.activeAccount = account
stateService.lastActiveTime[account.profile.userId] = timeProvider.presentTime
userSessionStateService.getLastActiveTimeReturnValue = timeProvider.presentTime
stateService.timeoutAction[account.profile.userId] = .logout
try await subject.setVaultTimeout(value: .oneMinute, userId: account.profile.userId)
@ -486,7 +495,7 @@ final class VaultTimeoutServiceTests: BitwardenTestCase { // swiftlint:disable:t
/// `.setVaultTimeout(value:userId:)` throws errors.
func test_setVaultTimeout_error() async throws {
stateService.setVaultTimeoutError = BitwardenTestError.example
userSessionStateService.setVaultTimeoutThrowableError = BitwardenTestError.example
await assertAsyncThrows(error: BitwardenTestError.example) {
try await subject.setVaultTimeout(value: .fiveMinutes, userId: "1")

View File

@ -68,6 +68,7 @@ final class AuthCoordinator: NSObject, // swiftlint:disable:this type_body_lengt
& HasStateService
& HasSystemDevice
& HasTrustDeviceService
& HasUserSessionStateService
& HasVaultTimeoutService
// MARK: Properties

View File

@ -16,6 +16,7 @@ class VaultUnlockProcessor: StateProcessor<
& HasBiometricsRepository
& HasErrorReporter
& HasStateService
& HasUserSessionStateService
// MARK: Private Properties
@ -129,7 +130,7 @@ class VaultUnlockProcessor: StateProcessor<
private func loadData() async {
state.biometricUnlockStatus = await (try? services.biometricsRepository.getBiometricUnlockStatus())
?? .notAvailable
state.unsuccessfulUnlockAttemptsCount = await services.stateService.getUnsuccessfulUnlockAttempts()
state.unsuccessfulUnlockAttemptsCount = await services.userSessionStateService.getUnsuccessfulUnlockAttempts()
state.isInAppExtension = appExtensionDelegate?.isInAppExtension ?? false
await refreshProfileState()
// If biometric unlock is available and enabled, and the app isn't in the background
@ -152,7 +153,7 @@ class VaultUnlockProcessor: StateProcessor<
private func logoutUser(resetAttempts: Bool = false, userInitiated: Bool) async {
if resetAttempts {
state.unsuccessfulUnlockAttemptsCount = 0
await services.stateService.setUnsuccessfulUnlockAttempts(0)
await services.userSessionStateService.setUnsuccessfulUnlockAttempts(0)
}
await coordinator.handleEvent(
.action(
@ -214,7 +215,7 @@ class VaultUnlockProcessor: StateProcessor<
await coordinator.handleEvent(.didCompleteAuth)
state.unsuccessfulUnlockAttemptsCount = 0
await services.stateService.setUnsuccessfulUnlockAttempts(0)
await services.userSessionStateService.setUnsuccessfulUnlockAttempts(0)
} catch let error as InputValidationError {
coordinator.showAlert(Alert.inputValidationAlert(error: error))
} catch {
@ -224,7 +225,7 @@ class VaultUnlockProcessor: StateProcessor<
)
Logger.processor.error("Error unlocking vault: \(error)")
state.unsuccessfulUnlockAttemptsCount += 1
await services.stateService.setUnsuccessfulUnlockAttempts(state.unsuccessfulUnlockAttemptsCount)
await services.userSessionStateService.setUnsuccessfulUnlockAttempts(state.unsuccessfulUnlockAttemptsCount)
if state.unsuccessfulUnlockAttemptsCount >= 5 {
await logoutUser(resetAttempts: true, userInitiated: true)
return
@ -246,7 +247,7 @@ class VaultUnlockProcessor: StateProcessor<
try await services.authRepository.unlockVaultWithBiometrics()
await coordinator.handleEvent(.didCompleteAuth)
state.unsuccessfulUnlockAttemptsCount = 0
await services.stateService.setUnsuccessfulUnlockAttempts(0)
await services.userSessionStateService.setUnsuccessfulUnlockAttempts(0)
} catch BiometricsServiceError.biometryCancelled {
Logger.processor.error("Biometric unlock cancelled.")
// Do nothing if the user cancels.
@ -284,7 +285,7 @@ class VaultUnlockProcessor: StateProcessor<
))
state.unsuccessfulUnlockAttemptsCount += 1
await services.stateService
await services.userSessionStateService
.setUnsuccessfulUnlockAttempts(state.unsuccessfulUnlockAttemptsCount)
if state.unsuccessfulUnlockAttemptsCount >= 5 {
await logoutUser(resetAttempts: true, userInitiated: true)

View File

@ -15,10 +15,11 @@ class VaultUnlockProcessorTests: BitwardenTestCase { // swiftlint:disable:this t
var application: MockApplication!
var authRepository: MockAuthRepository!
var biometricsRepository: MockBiometricsRepository!
var coordinator: MockCoordinator<AuthRoute, AuthEvent>!
var errorReporter: MockErrorReporter!
var stateService: MockStateService!
var coordinator: MockCoordinator<AuthRoute, AuthEvent>!
var subject: VaultUnlockProcessor!
var userSessionStateService: MockUserSessionStateService!
// MARK: Setup & Teardown
@ -32,6 +33,13 @@ class VaultUnlockProcessorTests: BitwardenTestCase { // swiftlint:disable:this t
coordinator = MockCoordinator()
errorReporter = MockErrorReporter()
stateService = MockStateService()
userSessionStateService = MockUserSessionStateService()
userSessionStateService.getVaultTimeoutReturnValue = .fifteenMinutes
userSessionStateService.getUnsuccessfulUnlockAttemptsReturnValue = 0
userSessionStateService.setUnsuccessfulUnlockAttemptsClosure = { [weak self] attempts, _ in
self?.userSessionStateService.getUnsuccessfulUnlockAttemptsReturnValue = attempts
}
subject = VaultUnlockProcessor(
appExtensionDelegate: appExtensionDelegate,
@ -42,6 +50,7 @@ class VaultUnlockProcessorTests: BitwardenTestCase { // swiftlint:disable:this t
biometricsRepository: biometricsRepository,
errorReporter: errorReporter,
stateService: stateService,
userSessionStateService: userSessionStateService,
),
state: VaultUnlockState(account: .fixture()),
)
@ -58,6 +67,7 @@ class VaultUnlockProcessorTests: BitwardenTestCase { // swiftlint:disable:this t
errorReporter = nil
stateService = nil
subject = nil
userSessionStateService = nil
}
// MARK: Tests
@ -167,6 +177,7 @@ class VaultUnlockProcessorTests: BitwardenTestCase { // swiftlint:disable:this t
biometricsRepository.getBiometricUnlockStatusActiveUser = expectedStatus
authRepository.isPinUnlockAvailableResult = .success(false)
authRepository.hasMasterPasswordResult = .failure(BitwardenTestError.example)
await subject.perform(.appeared)
XCTAssertEqual(subject.state.biometricUnlockStatus, expectedStatus)
@ -233,7 +244,7 @@ class VaultUnlockProcessorTests: BitwardenTestCase { // swiftlint:disable:this t
@MainActor
func test_perform_appeared_unlockAttempts() async {
stateService.activeAccount = .fixture()
await stateService.setUnsuccessfulUnlockAttempts(3)
userSessionStateService.getUnsuccessfulUnlockAttemptsReturnValue = 3
await subject.perform(.appeared)
XCTAssertEqual(3, subject.state.unsuccessfulUnlockAttemptsCount)
@ -484,7 +495,7 @@ class VaultUnlockProcessorTests: BitwardenTestCase { // swiftlint:disable:this t
/// `perform(_:)` with `.unlockVault` displays an alert a maximum of 5 times if the master password was incorrect.
/// After the 5th attempt, it logs the user out.
@MainActor
func test_perform_unlockVault_invalidPassword_logout() async throws { // swiftlint:disable:this function_body_length
func test_perform_unlockVault_invalidPassword_logout() async throws {
subject.state.masterPassword = "password"
stateService.activeAccount = .fixture()
XCTAssertEqual(subject.state.unsuccessfulUnlockAttemptsCount, 0)
@ -497,8 +508,7 @@ class VaultUnlockProcessorTests: BitwardenTestCase { // swiftlint:disable:this t
XCTAssertEqual(alert.title, Localizations.anErrorHasOccurred)
XCTAssertEqual(alert.message, Localizations.invalidMasterPassword)
XCTAssertEqual(subject.state.unsuccessfulUnlockAttemptsCount, 1)
var attemptsInUserDefaults = await stateService.getUnsuccessfulUnlockAttempts()
XCTAssertEqual(attemptsInUserDefaults, 1)
XCTAssertEqual(userSessionStateService.setUnsuccessfulUnlockAttemptsReceivedArguments?.attempts, 1)
await alert.alertActions[0].handler?(alert.alertActions[0], [])
XCTAssertFalse(authRepository.logoutCalled)
XCTAssertNotEqual(coordinator.routes.last, .landing)
@ -506,8 +516,7 @@ class VaultUnlockProcessorTests: BitwardenTestCase { // swiftlint:disable:this t
// 2nd unsuccessful attempts
await subject.perform(.unlockVault)
XCTAssertEqual(subject.state.unsuccessfulUnlockAttemptsCount, 2)
attemptsInUserDefaults = await stateService.getUnsuccessfulUnlockAttempts()
XCTAssertEqual(attemptsInUserDefaults, 2)
XCTAssertEqual(userSessionStateService.setUnsuccessfulUnlockAttemptsReceivedArguments?.attempts, 2)
alert = try XCTUnwrap(coordinator.alertShown.last)
await alert.alertActions[0].handler?(alert.alertActions[0], [])
XCTAssertFalse(authRepository.logoutCalled)
@ -516,8 +525,7 @@ class VaultUnlockProcessorTests: BitwardenTestCase { // swiftlint:disable:this t
// 3rd unsuccessful attempts
await subject.perform(.unlockVault)
XCTAssertEqual(subject.state.unsuccessfulUnlockAttemptsCount, 3)
attemptsInUserDefaults = await stateService.getUnsuccessfulUnlockAttempts()
XCTAssertEqual(attemptsInUserDefaults, 3)
XCTAssertEqual(userSessionStateService.setUnsuccessfulUnlockAttemptsReceivedArguments?.attempts, 3)
alert = try XCTUnwrap(coordinator.alertShown.last)
await alert.alertActions[0].handler?(alert.alertActions[0], [])
XCTAssertFalse(authRepository.logoutCalled)
@ -526,8 +534,7 @@ class VaultUnlockProcessorTests: BitwardenTestCase { // swiftlint:disable:this t
// 4th unsuccessful attempts
await subject.perform(.unlockVault)
XCTAssertEqual(subject.state.unsuccessfulUnlockAttemptsCount, 4)
attemptsInUserDefaults = await stateService.getUnsuccessfulUnlockAttempts()
XCTAssertEqual(attemptsInUserDefaults, 4)
XCTAssertEqual(userSessionStateService.setUnsuccessfulUnlockAttemptsReceivedArguments?.attempts, 4)
alert = try XCTUnwrap(coordinator.alertShown.last)
await alert.alertActions[0].handler?(alert.alertActions[0], [])
XCTAssertFalse(authRepository.logoutCalled)
@ -537,11 +544,9 @@ class VaultUnlockProcessorTests: BitwardenTestCase { // swiftlint:disable:this t
await subject.perform(.unlockVault)
// after 5th unsuccessful attempts, we log user out and reset the count to 0.
XCTAssertEqual(subject.state.unsuccessfulUnlockAttemptsCount, 0)
attemptsInUserDefaults = await stateService.getUnsuccessfulUnlockAttempts()
XCTAssertEqual(attemptsInUserDefaults, 0)
XCTAssertEqual(userSessionStateService.setUnsuccessfulUnlockAttemptsReceivedArguments?.attempts, 0)
await alert.alertActions[0].handler?(alert.alertActions[0], [])
attemptsInUserDefaults = await stateService.getUnsuccessfulUnlockAttempts()
XCTAssertEqual(attemptsInUserDefaults, 0)
XCTAssertEqual(userSessionStateService.setUnsuccessfulUnlockAttemptsReceivedArguments?.attempts, 0)
XCTAssertEqual(
coordinator.events.last,
.action(
@ -550,18 +555,16 @@ class VaultUnlockProcessorTests: BitwardenTestCase { // swiftlint:disable:this t
)
}
/// `perform(_:)` with `.unlockVault` logs error if force logout fails after the 5th unsuccessful attempts.
/// `perform(_:)` with `.unlockVault` logs error if force logout fails after the 5th unsuccessful attempt.
@MainActor
func test_perform_unlockVault_invalidPassword() async throws {
subject.state.masterPassword = "password"
stateService.activeAccount = .fixtureAccountLogin()
subject.state.unsuccessfulUnlockAttemptsCount = 4
await stateService.setUnsuccessfulUnlockAttempts(5)
XCTAssertEqual(subject.state.unsuccessfulUnlockAttemptsCount, 4)
struct VaultUnlockError: Error {}
authRepository.unlockWithPasswordResult = .failure(VaultUnlockError())
// 5th unsuccessful attempts
// 5th unsuccessful attempt
await subject.perform(.unlockVault)
XCTAssertEqual(
@ -580,23 +583,19 @@ class VaultUnlockProcessorTests: BitwardenTestCase { // swiftlint:disable:this t
struct VaultUnlockError: Error {}
authRepository.unlockWithPasswordResult = .failure(VaultUnlockError())
stateService.activeAccount = .fixture()
var attemptsInUserDefaults = await stateService.getUnsuccessfulUnlockAttempts()
XCTAssertEqual(attemptsInUserDefaults, 0)
XCTAssertEqual(userSessionStateService.setUnsuccessfulUnlockAttemptsReceivedArguments?.attempts, nil)
await subject.perform(.unlockVault)
XCTAssertEqual(subject.state.unsuccessfulUnlockAttemptsCount, 1)
attemptsInUserDefaults = await stateService.getUnsuccessfulUnlockAttempts()
XCTAssertEqual(attemptsInUserDefaults, 1)
XCTAssertEqual(userSessionStateService.setUnsuccessfulUnlockAttemptsReceivedArguments?.attempts, 1)
await subject.perform(.unlockVault)
XCTAssertEqual(subject.state.unsuccessfulUnlockAttemptsCount, 2)
attemptsInUserDefaults = await stateService.getUnsuccessfulUnlockAttempts()
XCTAssertEqual(attemptsInUserDefaults, 2)
XCTAssertEqual(userSessionStateService.setUnsuccessfulUnlockAttemptsReceivedArguments?.attempts, 2)
authRepository.unlockWithPasswordResult = .success(())
await subject.perform(.unlockVault)
XCTAssertEqual(subject.state.unsuccessfulUnlockAttemptsCount, 0)
attemptsInUserDefaults = await stateService.getUnsuccessfulUnlockAttempts()
XCTAssertEqual(attemptsInUserDefaults, 0)
XCTAssertEqual(userSessionStateService.setUnsuccessfulUnlockAttemptsReceivedArguments?.attempts, 0)
}
/// `perform(_:)` with `.unlockVaultWithBiometrics` logs the user out if biometrics is locked
@ -677,7 +676,8 @@ class VaultUnlockProcessorTests: BitwardenTestCase { // swiftlint:disable:this t
await subject.perform(.unlockVaultWithBiometrics)
XCTAssertNil(coordinator.routes.last)
XCTAssertEqual(1, subject.state.unsuccessfulUnlockAttemptsCount)
XCTAssertEqual(subject.state.unsuccessfulUnlockAttemptsCount, 1)
XCTAssertEqual(userSessionStateService.setUnsuccessfulUnlockAttemptsReceivedArguments?.attempts, 1)
XCTAssertEqual(errorReporter.errors.count, 1)
XCTAssertEqual(

View File

@ -23,6 +23,7 @@ final class AccountSecurityProcessor: StateProcessor<// swiftlint:disable:this t
& HasStateService
& HasTimeProvider
& HasTwoStepLoginService
& HasUserSessionStateService
// MARK: Private Properties
@ -132,7 +133,7 @@ final class AccountSecurityProcessor: StateProcessor<// swiftlint:disable:this t
return
}
state.sessionTimeoutValue = try await services.stateService.getVaultTimeout()
state.sessionTimeoutValue = try await services.userSessionStateService.getVaultTimeout()
state.sessionTimeoutType = SessionTimeoutType(value: state.sessionTimeoutValue)
} catch {
coordinator.showAlert(.defaultAlert(title: Localizations.anErrorHasOccurred))

View File

@ -22,6 +22,7 @@ class AccountSecurityProcessorTests: BitwardenTestCase { // swiftlint:disable:th
var twoStepLoginService: MockTwoStepLoginService!
var vaultTimeoutService: MockVaultTimeoutService!
var subject: AccountSecurityProcessor!
var userSessionStateService: MockUserSessionStateService!
var vaultUnlockSetupHelper: MockVaultUnlockSetupHelper!
// MARK: Setup & Teardown
@ -39,9 +40,12 @@ class AccountSecurityProcessorTests: BitwardenTestCase { // swiftlint:disable:th
settingsRepository = MockSettingsRepository()
stateService = MockStateService()
twoStepLoginService = MockTwoStepLoginService()
userSessionStateService = MockUserSessionStateService()
vaultTimeoutService = MockVaultTimeoutService()
vaultUnlockSetupHelper = MockVaultUnlockSetupHelper()
userSessionStateService.getVaultTimeoutReturnValue = .fifteenMinutes
subject = AccountSecurityProcessor(
coordinator: coordinator.asAnyCoordinator(),
services: ServiceContainer.withMocks(
@ -53,6 +57,7 @@ class AccountSecurityProcessorTests: BitwardenTestCase { // swiftlint:disable:th
settingsRepository: settingsRepository,
stateService: stateService,
twoStepLoginService: twoStepLoginService,
userSessionStateService: userSessionStateService,
vaultTimeoutService: vaultTimeoutService,
),
state: AccountSecurityState(),
@ -72,6 +77,8 @@ class AccountSecurityProcessorTests: BitwardenTestCase { // swiftlint:disable:th
policyService = nil
settingsRepository = nil
subject = nil
twoStepLoginService = nil
userSessionStateService = nil
vaultUnlockSetupHelper = nil
}
@ -108,6 +115,7 @@ class AccountSecurityProcessorTests: BitwardenTestCase { // swiftlint:disable:th
),
)
stateService.userHasMasterPassword[userId] = true
await subject.perform(.appeared)
XCTAssertTrue(subject.state.isPolicyTimeoutEnabled)
@ -186,6 +194,7 @@ class AccountSecurityProcessorTests: BitwardenTestCase { // swiftlint:disable:th
),
)
stateService.userHasMasterPassword[userId] = true
await subject.perform(.appeared)
XCTAssertTrue(subject.state.isPolicyTimeoutEnabled)

View File

@ -85,6 +85,7 @@ final class SettingsCoordinator: Coordinator, HasStackNavigator { // swiftlint:d
& HasSystemDevice
& HasTimeProvider
& HasTwoStepLoginService
& HasUserSessionStateService
& HasVaultRepository
& HasVaultTimeoutService
& HasWatchService