Skip to content

Commit

Permalink
[PM-8216] Add warning to people who don't have two-factor authenticat…
Browse files Browse the repository at this point in the history
…ion turned on (#1208)
  • Loading branch information
KatherineInCode authored Dec 27, 2024
1 parent 43e1883 commit 2aa6aef
Show file tree
Hide file tree
Showing 64 changed files with 1,971 additions and 4 deletions.
8 changes: 8 additions & 0 deletions BitwardenShared/Core/Platform/Models/Domain/Account.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import Foundation

/// Domain model for a user account.
///
public struct Account: Codable, Equatable, Hashable, Sendable {
Expand Down Expand Up @@ -87,6 +89,9 @@ extension Account {
/// The account's avatar color.
var avatarColor: String?

/// The account's creation date.
var creationDate: Date?

/// The account's email.
var email: String

Expand Down Expand Up @@ -120,6 +125,9 @@ extension Account {
/// The account's security stamp.
var stamp: String?

/// Whether the account has two-factor enabled.
var twoFactorEnabled: Bool?

/// User decryption options for the account.
var userDecryptionOptions: UserDecryptionOptions?

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ extension Account {
extension Account.AccountProfile {
static func fixture(
avatarColor: String? = nil,
creationDate: Date? = nil,
email: String = "[email protected]",
emailVerified: Bool? = true,
forcePasswordResetReason: ForcePasswordResetReason? = nil,
Expand All @@ -91,11 +92,13 @@ extension Account.AccountProfile {
name: String? = nil,
orgIdentifier: String? = nil,
stamp: String? = "stamp",
twoFactorEnabled: Bool? = nil,
userDecryptionOptions: UserDecryptionOptions? = nil,
userId: String = "1"
) -> Account.AccountProfile {
Account.AccountProfile(
avatarColor: avatarColor,
creationDate: creationDate,
email: email,
emailVerified: emailVerified,
forcePasswordResetReason: forcePasswordResetReason,
Expand All @@ -107,6 +110,7 @@ extension Account.AccountProfile {
name: name,
orgIdentifier: orgIdentifier,
stamp: stamp,
twoFactorEnabled: twoFactorEnabled,
userDecryptionOptions: userDecryptionOptions,
userId: userId
)
Expand Down
11 changes: 11 additions & 0 deletions BitwardenShared/Core/Platform/Models/Enum/FeatureFlag.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,15 @@ enum FeatureFlag: String, CaseIterable, Codable {
/// A feature flag for the create account flow.
case nativeCreateAccountFlow = "native-create-account-flow"

/// A feature flag for the notice indicating a user does not have two-factor authentication set up.
/// If true, the user can dismiss the notice temporarily.
case newDeviceVerificationTemporaryDismiss = "new-device-temporary-dismiss"

/// A feature flag for the notice indicating a user does not have two-factor authentication set up.
/// If true, the user can not dismiss the notice, and must set up two-factor authentication.
/// Overrides the temporary flag.
case newDeviceVerificationPermanentDismiss = "new-device-permanent-dismiss"

case sshKeyVaultItem = "ssh-key-vault-item"

/// A feature flag for the refactor on the SSO details endpoint.
Expand Down Expand Up @@ -101,6 +110,8 @@ enum FeatureFlag: String, CaseIterable, Codable {
.importLoginsFlow,
.nativeCarouselFlow,
.nativeCreateAccountFlow,
.newDeviceVerificationPermanentDismiss,
.newDeviceVerificationTemporaryDismiss,
.testLocalFeatureFlag,
.testLocalInitialBoolFlag,
.testLocalInitialIntFlag,
Expand Down
45 changes: 45 additions & 0 deletions BitwardenShared/Core/Platform/Services/StateService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,14 @@ protocol StateService: AnyObject {
///
func getTimeoutAction(userId: String?) async throws -> SessionTimeoutAction

/// Gets the display state of the no-two-factor notice for a user ID.
///
/// - Parameters:
/// - userId: The user ID for the account; defaults to current active user if `nil`.
/// - Returns: The display state.
///
func getTwoFactorNoticeDisplayState(userId: String?) async throws -> TwoFactorNoticeDisplayState

/// Get the two-factor token (non-nil if the user selected the "remember me" option).
///
/// - Parameter email: The user's email address.
Expand Down Expand Up @@ -642,6 +650,14 @@ protocol StateService: AnyObject {
///
func setTimeoutAction(action: SessionTimeoutAction, userId: String?) async throws

/// Sets the user's no-two-factor notice display state for a userID.
///
/// - Parameters:
/// - state: The display state to set.
/// - userId: The user ID associated with the state
///
func setTwoFactorNoticeDisplayState(_ state: TwoFactorNoticeDisplayState, userId: String?) async throws

/// Sets the user's two-factor token.
///
/// - Parameters:
Expand Down Expand Up @@ -937,6 +953,14 @@ extension StateService {
try await getTimeoutAction(userId: nil)
}

/// Gets the display state of the no-two-factor notice for the current user.
///
/// - Returns: The display state.
///
func getTwoFactorNoticeDisplayState() async throws -> TwoFactorNoticeDisplayState {
try await getTwoFactorNoticeDisplayState(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.
Expand Down Expand Up @@ -1155,6 +1179,15 @@ extension StateService {
try await setSyncToAuthenticator(syncToAuthenticator, userId: nil)
}

/// Sets the display state for the no-two-factor notice
///
/// - Parameters:
/// - state: The state to set.
///
func setTwoFactorNoticeDisplayState(state: TwoFactorNoticeDisplayState) async throws {
try await setTwoFactorNoticeDisplayState(state, userId: nil)
}

/// Sets the session timeout action.
///
/// - Parameter action: The action to take when the user's session times out.
Expand Down Expand Up @@ -1545,6 +1578,11 @@ actor DefaultStateService: StateService { // swiftlint:disable:this type_body_le
return timeoutAction
}

func getTwoFactorNoticeDisplayState(userId: String?) async throws -> TwoFactorNoticeDisplayState {
let userId = try userId ?? getActiveAccountUserId()
return appSettingsStore.twoFactorNoticeDisplayState(userId: userId)
}

func getTwoFactorToken(email: String) async -> String? {
appSettingsStore.twoFactorToken(email: email)
}
Expand Down Expand Up @@ -1835,6 +1873,11 @@ actor DefaultStateService: StateService { // swiftlint:disable:this type_body_le
appSettingsStore.setTimeoutAction(key: action, userId: userId)
}

func setTwoFactorNoticeDisplayState(_ state: TwoFactorNoticeDisplayState, userId: String?) async throws {
let userId = try userId ?? getActiveAccountUserId()
appSettingsStore.setTwoFactorNoticeDisplayState(state, userId: userId)
}

func setTwoFactorToken(_ token: String?, email: String) async {
appSettingsStore.setTwoFactorToken(token, email: email)
}
Expand Down Expand Up @@ -1877,10 +1920,12 @@ actor DefaultStateService: StateService { // swiftlint:disable:this type_body_le
guard var profile = state.accounts[userId]?.profile else { return }
profile.hasPremiumPersonally = response.premium
profile.avatarColor = response.avatarColor
profile.creationDate = response.creationDate
profile.email = response.email ?? profile.email
profile.emailVerified = response.emailVerified
profile.name = response.name
profile.stamp = response.securityStamp
profile.twoFactorEnabled = response.twoFactorEnabled

state.accounts[userId]?.profile = profile
}
Expand Down
32 changes: 31 additions & 1 deletion BitwardenShared/Core/Platform/Services/StateServiceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -897,6 +897,24 @@ class StateServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body
XCTAssertEqual(action, .logout)
}

/// `getTwoFactorNoticeDisplayState(userId:)` gets the display state of the two-factor notice for the user.
func test_getTwoFactorNoticeDisplayState() async throws {
appSettingsStore.setTwoFactorNoticeDisplayState(.canAccessEmail, userId: "[email protected]")

let value = try await subject.getTwoFactorNoticeDisplayState(userId: "[email protected]")
XCTAssertEqual(value, .canAccessEmail)
}

/// `getTwoFactorNoticeDisplayState()` gets the display state of the two-factor notice for the current user
/// and throws an error if there is no current user.
func test_getTwoFactorNoticeDisplayState_noId() async throws {
appSettingsStore.setTwoFactorNoticeDisplayState(.canAccessEmail, userId: "1")

await assertAsyncThrows(error: StateServiceError.noActiveAccount) {
_ = try await subject.getTwoFactorNoticeDisplayState()
}
}

/// `getTwoFactorToken(email:)` gets the two-factor code associated with the email.
func test_getTwoFactorToken() async {
appSettingsStore.setTwoFactorToken("yay_you_win!", email: "[email protected]")
Expand Down Expand Up @@ -1976,6 +1994,12 @@ class StateServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body
}
}

/// `setTwoFactorNoticeDisplayState(_:userId:)` sets the display state of the two-factor notice for the user.
func test_setTwoFactorNoticeDisplayState() async throws {
try await subject.setTwoFactorNoticeDisplayState(.hasNotSeen, userId: "[email protected]")
XCTAssertEqual(appSettingsStore.twoFactorNoticeDisplayState(userId: "[email protected]"), .hasNotSeen)
}

/// `setTwoFactorToken(_:email:)` sets the two-factor code for the email.
func test_setTwoFactorToken() async {
await subject.setTwoFactorToken("yay_you_win!", email: "[email protected]")
Expand Down Expand Up @@ -2143,11 +2167,13 @@ class StateServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body
.fixture(
profile: .fixture(
avatarColor: nil,
creationDate: nil,
email: "[email protected]",
emailVerified: false,
hasPremiumPersonally: false,
name: "User",
stamp: "stamp",
twoFactorEnabled: false,
userId: "1"
)
)
Expand All @@ -2156,11 +2182,13 @@ class StateServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body
await subject.updateProfile(
from: .fixture(
avatarColor: "175DDC",
creationDate: Date(year: 2024, month: 12, day: 25),
email: "[email protected]",
emailVerified: true,
name: "Other",
premium: true,
securityStamp: "new stamp"
securityStamp: "new stamp",
twoFactorEnabled: true
),
userId: "1"
)
Expand All @@ -2171,11 +2199,13 @@ class StateServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body
.fixture(
profile: .fixture(
avatarColor: "175DDC",
creationDate: Date(year: 2024, month: 12, day: 25),
email: "[email protected]",
emailVerified: true,
hasPremiumPersonally: true,
name: "Other",
stamp: "new stamp",
twoFactorEnabled: true,
userId: "1"
)
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,14 @@ protocol AppSettingsStore: AnyObject {
///
func setTimeoutAction(key: SessionTimeoutAction, userId: String)

/// Sets the display state for the two-factor notice.
///
/// - Parameters:
/// - state: The display state.
/// - userId: The userID associated with the state.
///
func setTwoFactorNoticeDisplayState(_ state: TwoFactorNoticeDisplayState, userId: String)

/// Sets the two-factor token.
///
/// - Parameters:
Expand All @@ -463,7 +471,7 @@ protocol AppSettingsStore: AnyObject {
/// Sets the number of unsuccessful attempts to unlock the vault for a user ID.
///
/// - Parameters:
/// - attempts: The number of unsuccessful unlock attempts..
/// - attempts: The number of unsuccessful unlock attempts.
/// - userId: The user ID associated with the unsuccessful unlock attempts.
///
func setUnsuccessfulUnlockAttempts(_ attempts: Int, userId: String)
Expand Down Expand Up @@ -513,6 +521,14 @@ protocol AppSettingsStore: AnyObject {
///
func timeoutAction(userId: String) -> Int?

/// Get the display state of the no-two-factor notice for a user ID.
///
/// - Parameters:
/// - userId: The user ID associated with the state.
/// - Returns: The state for the user ID.
///
func twoFactorNoticeDisplayState(userId: String) -> TwoFactorNoticeDisplayState

/// Get the two-factor token associated with a user's email.
///
/// - Parameter email: The user's email.
Expand Down Expand Up @@ -713,6 +729,7 @@ extension DefaultAppSettingsStore: AppSettingsStore {
case shouldTrustDevice(userId: String)
case syncToAuthenticator(userId: String)
case state
case twoFactorNoticeDisplayState(userId: String)
case twoFactorToken(email: String)
case unsuccessfulUnlockAttempts(userId: String)
case usernameGenerationOptions(userId: String)
Expand Down Expand Up @@ -806,6 +823,8 @@ extension DefaultAppSettingsStore: AppSettingsStore {
key = "state"
case let .syncToAuthenticator(userId):
key = "shouldSyncToAuthenticator_\(userId)"
case let .twoFactorNoticeDisplayState(userId):
key = "twoFactorNoticeDisplayState_\(userId)"
case let .twoFactorToken(email):
key = "twoFactorToken_\(email)"
case let .unsuccessfulUnlockAttempts(userId):
Expand Down Expand Up @@ -1115,6 +1134,10 @@ extension DefaultAppSettingsStore: AppSettingsStore {
store(key, for: .vaultTimeoutAction(userId: userId))
}

func setTwoFactorNoticeDisplayState(_ state: TwoFactorNoticeDisplayState, userId: String) {
store(state, for: .twoFactorNoticeDisplayState(userId: userId))
}

func setTwoFactorToken(_ token: String?, email: String) {
store(token, for: .twoFactorToken(email: email))
}
Expand All @@ -1139,6 +1162,10 @@ extension DefaultAppSettingsStore: AppSettingsStore {
fetch(for: .vaultTimeoutAction(userId: userId))
}

func twoFactorNoticeDisplayState(userId: String) -> TwoFactorNoticeDisplayState {
fetch(for: .twoFactorNoticeDisplayState(userId: userId)) ?? .hasNotSeen
}

func twoFactorToken(email: String) -> String? {
fetch(for: .twoFactorToken(email: email))
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -856,6 +856,21 @@ class AppSettingsStoreTests: BitwardenTestCase { // swiftlint:disable:this type_
XCTAssertFalse(userDefaults.bool(forKey: "bwPreferencesStorage:shouldSyncToAuthenticator_2"))
}

/// `twoFactorNoticeDisplayState(userId:)` returns `.hasNotSeen` if there isn't a previously stored value.
func test_twoFactorNoticeDisplayState_isInitiallyNotSeen() {
XCTAssertEqual(subject.twoFactorNoticeDisplayState(userId: "[email protected]"), .hasNotSeen)
}

/// `twoFactorToken(email:)` can be used to get and set the persisted value in user defaults.
func test_twoFactorNoticeDisplayState_withValue() {
let date = Date()
subject.setTwoFactorNoticeDisplayState(.canAccessEmail, userId: "[email protected]")
subject.setTwoFactorNoticeDisplayState(.seen(date), userId: "[email protected]")

XCTAssertEqual(subject.twoFactorNoticeDisplayState(userId: "[email protected]"), .canAccessEmail)
XCTAssertEqual(subject.twoFactorNoticeDisplayState(userId: "[email protected]"), .seen(date))
}

/// `twoFactorToken(email:)` returns `nil` if there isn't a previously stored value.
func test_twoFactorToken_isInitiallyNil() {
XCTAssertNil(subject.twoFactorToken(email: "[email protected]"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ class MockAppSettingsStore: AppSettingsStore { // swiftlint:disable:this type_bo
var shouldTrustDevice = [String: Bool?]()
var syncToAuthenticatorByUserId = [String: Bool]()
var timeoutAction = [String: Int]()
var twoFactorNoticeDisplayState = [String: TwoFactorNoticeDisplayState]()
var twoFactorTokens = [String: String]()
var usesKeyConnector = [String: Bool]()
var vaultTimeout = [String: Int]()
Expand Down Expand Up @@ -279,6 +280,14 @@ class MockAppSettingsStore: AppSettingsStore { // swiftlint:disable:this type_bo
timeoutAction[userId] = key.rawValue
}

func setTwoFactorNoticeDisplayState(_ state: TwoFactorNoticeDisplayState, userId: String) {
twoFactorNoticeDisplayState[userId] = state
}

func twoFactorNoticeDisplayState(userId: String) -> TwoFactorNoticeDisplayState {
twoFactorNoticeDisplayState[userId] ?? .hasNotSeen
}

func setTwoFactorToken(_ token: String?, email: String) {
twoFactorTokens[email] = token
}
Expand Down
Loading

0 comments on commit 2aa6aef

Please sign in to comment.