Skip to content

Commit 2aa6aef

Browse files
[PM-8216] Add warning to people who don't have two-factor authentication turned on (#1208)
1 parent 43e1883 commit 2aa6aef

File tree

64 files changed

+1971
-4
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

64 files changed

+1971
-4
lines changed

BitwardenShared/Core/Platform/Models/Domain/Account.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import Foundation
2+
13
/// Domain model for a user account.
24
///
35
public struct Account: Codable, Equatable, Hashable, Sendable {
@@ -87,6 +89,9 @@ extension Account {
8789
/// The account's avatar color.
8890
var avatarColor: String?
8991

92+
/// The account's creation date.
93+
var creationDate: Date?
94+
9095
/// The account's email.
9196
var email: String
9297

@@ -120,6 +125,9 @@ extension Account {
120125
/// The account's security stamp.
121126
var stamp: String?
122127

128+
/// Whether the account has two-factor enabled.
129+
var twoFactorEnabled: Bool?
130+
123131
/// User decryption options for the account.
124132
var userDecryptionOptions: UserDecryptionOptions?
125133

BitwardenShared/Core/Platform/Models/Domain/Fixtures/Account+Fixtures.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ extension Account {
8080
extension Account.AccountProfile {
8181
static func fixture(
8282
avatarColor: String? = nil,
83+
creationDate: Date? = nil,
8384
email: String = "user@bitwarden.com",
8485
emailVerified: Bool? = true,
8586
forcePasswordResetReason: ForcePasswordResetReason? = nil,
@@ -91,11 +92,13 @@ extension Account.AccountProfile {
9192
name: String? = nil,
9293
orgIdentifier: String? = nil,
9394
stamp: String? = "stamp",
95+
twoFactorEnabled: Bool? = nil,
9496
userDecryptionOptions: UserDecryptionOptions? = nil,
9597
userId: String = "1"
9698
) -> Account.AccountProfile {
9799
Account.AccountProfile(
98100
avatarColor: avatarColor,
101+
creationDate: creationDate,
99102
email: email,
100103
emailVerified: emailVerified,
101104
forcePasswordResetReason: forcePasswordResetReason,
@@ -107,6 +110,7 @@ extension Account.AccountProfile {
107110
name: name,
108111
orgIdentifier: orgIdentifier,
109112
stamp: stamp,
113+
twoFactorEnabled: twoFactorEnabled,
110114
userDecryptionOptions: userDecryptionOptions,
111115
userId: userId
112116
)

BitwardenShared/Core/Platform/Models/Enum/FeatureFlag.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,15 @@ enum FeatureFlag: String, CaseIterable, Codable {
3838
/// A feature flag for the create account flow.
3939
case nativeCreateAccountFlow = "native-create-account-flow"
4040

41+
/// A feature flag for the notice indicating a user does not have two-factor authentication set up.
42+
/// If true, the user can dismiss the notice temporarily.
43+
case newDeviceVerificationTemporaryDismiss = "new-device-temporary-dismiss"
44+
45+
/// A feature flag for the notice indicating a user does not have two-factor authentication set up.
46+
/// If true, the user can not dismiss the notice, and must set up two-factor authentication.
47+
/// Overrides the temporary flag.
48+
case newDeviceVerificationPermanentDismiss = "new-device-permanent-dismiss"
49+
4150
case sshKeyVaultItem = "ssh-key-vault-item"
4251

4352
/// A feature flag for the refactor on the SSO details endpoint.
@@ -101,6 +110,8 @@ enum FeatureFlag: String, CaseIterable, Codable {
101110
.importLoginsFlow,
102111
.nativeCarouselFlow,
103112
.nativeCreateAccountFlow,
113+
.newDeviceVerificationPermanentDismiss,
114+
.newDeviceVerificationTemporaryDismiss,
104115
.testLocalFeatureFlag,
105116
.testLocalInitialBoolFlag,
106117
.testLocalInitialIntFlag,

BitwardenShared/Core/Platform/Services/StateService.swift

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,14 @@ protocol StateService: AnyObject {
307307
///
308308
func getTimeoutAction(userId: String?) async throws -> SessionTimeoutAction
309309

310+
/// Gets the display state of the no-two-factor notice for a user ID.
311+
///
312+
/// - Parameters:
313+
/// - userId: The user ID for the account; defaults to current active user if `nil`.
314+
/// - Returns: The display state.
315+
///
316+
func getTwoFactorNoticeDisplayState(userId: String?) async throws -> TwoFactorNoticeDisplayState
317+
310318
/// Get the two-factor token (non-nil if the user selected the "remember me" option).
311319
///
312320
/// - Parameter email: The user's email address.
@@ -642,6 +650,14 @@ protocol StateService: AnyObject {
642650
///
643651
func setTimeoutAction(action: SessionTimeoutAction, userId: String?) async throws
644652

653+
/// Sets the user's no-two-factor notice display state for a userID.
654+
///
655+
/// - Parameters:
656+
/// - state: The display state to set.
657+
/// - userId: The user ID associated with the state
658+
///
659+
func setTwoFactorNoticeDisplayState(_ state: TwoFactorNoticeDisplayState, userId: String?) async throws
660+
645661
/// Sets the user's two-factor token.
646662
///
647663
/// - Parameters:
@@ -937,6 +953,14 @@ extension StateService {
937953
try await getTimeoutAction(userId: nil)
938954
}
939955

956+
/// Gets the display state of the no-two-factor notice for the current user.
957+
///
958+
/// - Returns: The display state.
959+
///
960+
func getTwoFactorNoticeDisplayState() async throws -> TwoFactorNoticeDisplayState {
961+
try await getTwoFactorNoticeDisplayState(userId: nil)
962+
}
963+
940964
/// Sets the number of unsuccessful attempts to unlock the vault for the active account.
941965
///
942966
/// - Returns: The number of unsuccessful unlock attempts for the active account.
@@ -1155,6 +1179,15 @@ extension StateService {
11551179
try await setSyncToAuthenticator(syncToAuthenticator, userId: nil)
11561180
}
11571181

1182+
/// Sets the display state for the no-two-factor notice
1183+
///
1184+
/// - Parameters:
1185+
/// - state: The state to set.
1186+
///
1187+
func setTwoFactorNoticeDisplayState(state: TwoFactorNoticeDisplayState) async throws {
1188+
try await setTwoFactorNoticeDisplayState(state, userId: nil)
1189+
}
1190+
11581191
/// Sets the session timeout action.
11591192
///
11601193
/// - Parameter action: The action to take when the user's session times out.
@@ -1545,6 +1578,11 @@ actor DefaultStateService: StateService { // swiftlint:disable:this type_body_le
15451578
return timeoutAction
15461579
}
15471580

1581+
func getTwoFactorNoticeDisplayState(userId: String?) async throws -> TwoFactorNoticeDisplayState {
1582+
let userId = try userId ?? getActiveAccountUserId()
1583+
return appSettingsStore.twoFactorNoticeDisplayState(userId: userId)
1584+
}
1585+
15481586
func getTwoFactorToken(email: String) async -> String? {
15491587
appSettingsStore.twoFactorToken(email: email)
15501588
}
@@ -1835,6 +1873,11 @@ actor DefaultStateService: StateService { // swiftlint:disable:this type_body_le
18351873
appSettingsStore.setTimeoutAction(key: action, userId: userId)
18361874
}
18371875

1876+
func setTwoFactorNoticeDisplayState(_ state: TwoFactorNoticeDisplayState, userId: String?) async throws {
1877+
let userId = try userId ?? getActiveAccountUserId()
1878+
appSettingsStore.setTwoFactorNoticeDisplayState(state, userId: userId)
1879+
}
1880+
18381881
func setTwoFactorToken(_ token: String?, email: String) async {
18391882
appSettingsStore.setTwoFactorToken(token, email: email)
18401883
}
@@ -1877,10 +1920,12 @@ actor DefaultStateService: StateService { // swiftlint:disable:this type_body_le
18771920
guard var profile = state.accounts[userId]?.profile else { return }
18781921
profile.hasPremiumPersonally = response.premium
18791922
profile.avatarColor = response.avatarColor
1923+
profile.creationDate = response.creationDate
18801924
profile.email = response.email ?? profile.email
18811925
profile.emailVerified = response.emailVerified
18821926
profile.name = response.name
18831927
profile.stamp = response.securityStamp
1928+
profile.twoFactorEnabled = response.twoFactorEnabled
18841929

18851930
state.accounts[userId]?.profile = profile
18861931
}

BitwardenShared/Core/Platform/Services/StateServiceTests.swift

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -897,6 +897,24 @@ class StateServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body
897897
XCTAssertEqual(action, .logout)
898898
}
899899

900+
/// `getTwoFactorNoticeDisplayState(userId:)` gets the display state of the two-factor notice for the user.
901+
func test_getTwoFactorNoticeDisplayState() async throws {
902+
appSettingsStore.setTwoFactorNoticeDisplayState(.canAccessEmail, userId: "person@example.com")
903+
904+
let value = try await subject.getTwoFactorNoticeDisplayState(userId: "person@example.com")
905+
XCTAssertEqual(value, .canAccessEmail)
906+
}
907+
908+
/// `getTwoFactorNoticeDisplayState()` gets the display state of the two-factor notice for the current user
909+
/// and throws an error if there is no current user.
910+
func test_getTwoFactorNoticeDisplayState_noId() async throws {
911+
appSettingsStore.setTwoFactorNoticeDisplayState(.canAccessEmail, userId: "1")
912+
913+
await assertAsyncThrows(error: StateServiceError.noActiveAccount) {
914+
_ = try await subject.getTwoFactorNoticeDisplayState()
915+
}
916+
}
917+
900918
/// `getTwoFactorToken(email:)` gets the two-factor code associated with the email.
901919
func test_getTwoFactorToken() async {
902920
appSettingsStore.setTwoFactorToken("yay_you_win!", email: "winner@email.com")
@@ -1976,6 +1994,12 @@ class StateServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body
19761994
}
19771995
}
19781996

1997+
/// `setTwoFactorNoticeDisplayState(_:userId:)` sets the display state of the two-factor notice for the user.
1998+
func test_setTwoFactorNoticeDisplayState() async throws {
1999+
try await subject.setTwoFactorNoticeDisplayState(.hasNotSeen, userId: "person1@example.com")
2000+
XCTAssertEqual(appSettingsStore.twoFactorNoticeDisplayState(userId: "person1@example.com"), .hasNotSeen)
2001+
}
2002+
19792003
/// `setTwoFactorToken(_:email:)` sets the two-factor code for the email.
19802004
func test_setTwoFactorToken() async {
19812005
await subject.setTwoFactorToken("yay_you_win!", email: "winner@email.com")
@@ -2143,11 +2167,13 @@ class StateServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body
21432167
.fixture(
21442168
profile: .fixture(
21452169
avatarColor: nil,
2170+
creationDate: nil,
21462171
email: "user@bitwarden.com",
21472172
emailVerified: false,
21482173
hasPremiumPersonally: false,
21492174
name: "User",
21502175
stamp: "stamp",
2176+
twoFactorEnabled: false,
21512177
userId: "1"
21522178
)
21532179
)
@@ -2156,11 +2182,13 @@ class StateServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body
21562182
await subject.updateProfile(
21572183
from: .fixture(
21582184
avatarColor: "175DDC",
2185+
creationDate: Date(year: 2024, month: 12, day: 25),
21592186
email: "other@bitwarden.com",
21602187
emailVerified: true,
21612188
name: "Other",
21622189
premium: true,
2163-
securityStamp: "new stamp"
2190+
securityStamp: "new stamp",
2191+
twoFactorEnabled: true
21642192
),
21652193
userId: "1"
21662194
)
@@ -2171,11 +2199,13 @@ class StateServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body
21712199
.fixture(
21722200
profile: .fixture(
21732201
avatarColor: "175DDC",
2202+
creationDate: Date(year: 2024, month: 12, day: 25),
21742203
email: "other@bitwarden.com",
21752204
emailVerified: true,
21762205
hasPremiumPersonally: true,
21772206
name: "Other",
21782207
stamp: "new stamp",
2208+
twoFactorEnabled: true,
21792209
userId: "1"
21802210
)
21812211
)

BitwardenShared/Core/Platform/Services/Stores/AppSettingsStore.swift

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -452,6 +452,14 @@ protocol AppSettingsStore: AnyObject {
452452
///
453453
func setTimeoutAction(key: SessionTimeoutAction, userId: String)
454454

455+
/// Sets the display state for the two-factor notice.
456+
///
457+
/// - Parameters:
458+
/// - state: The display state.
459+
/// - userId: The userID associated with the state.
460+
///
461+
func setTwoFactorNoticeDisplayState(_ state: TwoFactorNoticeDisplayState, userId: String)
462+
455463
/// Sets the two-factor token.
456464
///
457465
/// - Parameters:
@@ -463,7 +471,7 @@ protocol AppSettingsStore: AnyObject {
463471
/// Sets the number of unsuccessful attempts to unlock the vault for a user ID.
464472
///
465473
/// - Parameters:
466-
/// - attempts: The number of unsuccessful unlock attempts..
474+
/// - attempts: The number of unsuccessful unlock attempts.
467475
/// - userId: The user ID associated with the unsuccessful unlock attempts.
468476
///
469477
func setUnsuccessfulUnlockAttempts(_ attempts: Int, userId: String)
@@ -513,6 +521,14 @@ protocol AppSettingsStore: AnyObject {
513521
///
514522
func timeoutAction(userId: String) -> Int?
515523

524+
/// Get the display state of the no-two-factor notice for a user ID.
525+
///
526+
/// - Parameters:
527+
/// - userId: The user ID associated with the state.
528+
/// - Returns: The state for the user ID.
529+
///
530+
func twoFactorNoticeDisplayState(userId: String) -> TwoFactorNoticeDisplayState
531+
516532
/// Get the two-factor token associated with a user's email.
517533
///
518534
/// - Parameter email: The user's email.
@@ -713,6 +729,7 @@ extension DefaultAppSettingsStore: AppSettingsStore {
713729
case shouldTrustDevice(userId: String)
714730
case syncToAuthenticator(userId: String)
715731
case state
732+
case twoFactorNoticeDisplayState(userId: String)
716733
case twoFactorToken(email: String)
717734
case unsuccessfulUnlockAttempts(userId: String)
718735
case usernameGenerationOptions(userId: String)
@@ -806,6 +823,8 @@ extension DefaultAppSettingsStore: AppSettingsStore {
806823
key = "state"
807824
case let .syncToAuthenticator(userId):
808825
key = "shouldSyncToAuthenticator_\(userId)"
826+
case let .twoFactorNoticeDisplayState(userId):
827+
key = "twoFactorNoticeDisplayState_\(userId)"
809828
case let .twoFactorToken(email):
810829
key = "twoFactorToken_\(email)"
811830
case let .unsuccessfulUnlockAttempts(userId):
@@ -1115,6 +1134,10 @@ extension DefaultAppSettingsStore: AppSettingsStore {
11151134
store(key, for: .vaultTimeoutAction(userId: userId))
11161135
}
11171136

1137+
func setTwoFactorNoticeDisplayState(_ state: TwoFactorNoticeDisplayState, userId: String) {
1138+
store(state, for: .twoFactorNoticeDisplayState(userId: userId))
1139+
}
1140+
11181141
func setTwoFactorToken(_ token: String?, email: String) {
11191142
store(token, for: .twoFactorToken(email: email))
11201143
}
@@ -1139,6 +1162,10 @@ extension DefaultAppSettingsStore: AppSettingsStore {
11391162
fetch(for: .vaultTimeoutAction(userId: userId))
11401163
}
11411164

1165+
func twoFactorNoticeDisplayState(userId: String) -> TwoFactorNoticeDisplayState {
1166+
fetch(for: .twoFactorNoticeDisplayState(userId: userId)) ?? .hasNotSeen
1167+
}
1168+
11421169
func twoFactorToken(email: String) -> String? {
11431170
fetch(for: .twoFactorToken(email: email))
11441171
}

BitwardenShared/Core/Platform/Services/Stores/AppSettingsStoreTests.swift

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -856,6 +856,21 @@ class AppSettingsStoreTests: BitwardenTestCase { // swiftlint:disable:this type_
856856
XCTAssertFalse(userDefaults.bool(forKey: "bwPreferencesStorage:shouldSyncToAuthenticator_2"))
857857
}
858858

859+
/// `twoFactorNoticeDisplayState(userId:)` returns `.hasNotSeen` if there isn't a previously stored value.
860+
func test_twoFactorNoticeDisplayState_isInitiallyNotSeen() {
861+
XCTAssertEqual(subject.twoFactorNoticeDisplayState(userId: "anyone@example.com"), .hasNotSeen)
862+
}
863+
864+
/// `twoFactorToken(email:)` can be used to get and set the persisted value in user defaults.
865+
func test_twoFactorNoticeDisplayState_withValue() {
866+
let date = Date()
867+
subject.setTwoFactorNoticeDisplayState(.canAccessEmail, userId: "person1@example.com")
868+
subject.setTwoFactorNoticeDisplayState(.seen(date), userId: "person2@example.com")
869+
870+
XCTAssertEqual(subject.twoFactorNoticeDisplayState(userId: "person1@example.com"), .canAccessEmail)
871+
XCTAssertEqual(subject.twoFactorNoticeDisplayState(userId: "person2@example.com"), .seen(date))
872+
}
873+
859874
/// `twoFactorToken(email:)` returns `nil` if there isn't a previously stored value.
860875
func test_twoFactorToken_isInitiallyNil() {
861876
XCTAssertNil(subject.twoFactorToken(email: "anything@email.com"))

BitwardenShared/Core/Platform/Services/Stores/TestHelpers/MockAppSettingsStore.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ class MockAppSettingsStore: AppSettingsStore { // swiftlint:disable:this type_bo
4848
var shouldTrustDevice = [String: Bool?]()
4949
var syncToAuthenticatorByUserId = [String: Bool]()
5050
var timeoutAction = [String: Int]()
51+
var twoFactorNoticeDisplayState = [String: TwoFactorNoticeDisplayState]()
5152
var twoFactorTokens = [String: String]()
5253
var usesKeyConnector = [String: Bool]()
5354
var vaultTimeout = [String: Int]()
@@ -279,6 +280,14 @@ class MockAppSettingsStore: AppSettingsStore { // swiftlint:disable:this type_bo
279280
timeoutAction[userId] = key.rawValue
280281
}
281282

283+
func setTwoFactorNoticeDisplayState(_ state: TwoFactorNoticeDisplayState, userId: String) {
284+
twoFactorNoticeDisplayState[userId] = state
285+
}
286+
287+
func twoFactorNoticeDisplayState(userId: String) -> TwoFactorNoticeDisplayState {
288+
twoFactorNoticeDisplayState[userId] ?? .hasNotSeen
289+
}
290+
282291
func setTwoFactorToken(_ token: String?, email: String) {
283292
twoFactorTokens[email] = token
284293
}

0 commit comments

Comments
 (0)