mirror of
https://github.com/bitwarden/ios.git
synced 2026-02-04 02:14:09 -06:00
[PM-23300] feat: Store session values to keychain so they can be shared between app and autofill (#2239)
This commit is contained in:
parent
1ba8503849
commit
317bb15961
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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,
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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]()
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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> {
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -68,6 +68,7 @@ final class AuthCoordinator: NSObject, // swiftlint:disable:this type_body_lengt
|
||||
& HasStateService
|
||||
& HasSystemDevice
|
||||
& HasTrustDeviceService
|
||||
& HasUserSessionStateService
|
||||
& HasVaultTimeoutService
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -85,6 +85,7 @@ final class SettingsCoordinator: Coordinator, HasStackNavigator { // swiftlint:d
|
||||
& HasSystemDevice
|
||||
& HasTimeProvider
|
||||
& HasTwoStepLoginService
|
||||
& HasUserSessionStateService
|
||||
& HasVaultRepository
|
||||
& HasVaultTimeoutService
|
||||
& HasWatchService
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user