From 60e569ed9da98cfc31eaea52ede39f02c14405f3 Mon Sep 17 00:00:00 2001 From: Ike <137194738+ike-kottlowski@users.noreply.github.com> Date: Wed, 29 Jan 2025 07:49:56 -0700 Subject: [PATCH] [PM-15605] Add new device protection opt out (#12880) * feat(newdeviceVerificaiton) : adding component and request model * feat(newDeviceverification) : adding state structure to track verify devices for active user; added API call to server. * feat(newDeviceVerification) : added visual elements for opting out of new device verification. * Fixing tests for account service. fixed DI for account service * Fixing strict lint issues * debt(deauthorizeSessionsModal) : changed modal to dialog. fixed strict typing for the new dialog for deviceVerification. * fixing tests * fixing desktop build DI * changed dialog to standalone fixed names and comments. * Adding tests for AccountService * fix linting * PM-15605 - AccountComp - fix ngOnDestroy erroring as it was incorrectly decorated with removed property. * PM-15605 - SetAccountVerifyDevicesDialogComponent - only show warning about turning off new device verification if user doensn't have 2FA configured per task description --------- Co-authored-by: Jared Snider <116684653+JaredSnider-Bitwarden@users.noreply.github.com> Co-authored-by: Jared Snider --- .../browser/src/background/main.background.ts | 1 + .../service-container/service-container.ts | 1 + apps/desktop/src/main.ts | 13 +- .../settings/account/account.component.html | 21 ++- .../settings/account/account.component.ts | 58 ++++++--- .../account/danger-zone.component.html | 8 -- .../deauthorize-sessions.component.html | 59 +++------ .../account/deauthorize-sessions.component.ts | 27 ++-- .../delete-account-dialog.component.ts | 2 - ...count-verify-devices-dialog.component.html | 43 ++++++ ...account-verify-devices-dialog.component.ts | 122 ++++++++++++++++++ apps/web/src/locales/en/messages.json | 29 ++++- .../src/services/jslib-services.module.ts | 2 +- libs/common/spec/fake-account-service.ts | 8 ++ .../auth/abstractions/account-api.service.ts | 16 ++- .../src/auth/abstractions/account.service.ts | 11 ++ .../request/set-verify-devices.request.ts | 8 ++ .../src/auth/services/account-api.service.ts | 18 +++ .../src/auth/services/account.service.spec.ts | 58 ++++++++- .../src/auth/services/account.service.ts | 34 +++++ .../src/models/response/profile.response.ts | 2 + .../src/platform/sync/default-sync.service.ts | 1 + 22 files changed, 447 insertions(+), 95 deletions(-) create mode 100644 apps/web/src/app/auth/settings/account/set-account-verify-devices-dialog.component.html create mode 100644 apps/web/src/app/auth/settings/account/set-account-verify-devices-dialog.component.ts create mode 100644 libs/common/src/auth/models/request/set-verify-devices.request.ts diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index d711949512..e20f849e4b 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -560,6 +560,7 @@ export default class MainBackground { this.messagingService, this.logService, this.globalStateProvider, + this.singleUserStateProvider, ); this.activeUserStateProvider = new DefaultActiveUserStateProvider( this.accountService, diff --git a/apps/cli/src/service-container/service-container.ts b/apps/cli/src/service-container/service-container.ts index ef07feb9fa..4eda8cd7ab 100644 --- a/apps/cli/src/service-container/service-container.ts +++ b/apps/cli/src/service-container/service-container.ts @@ -355,6 +355,7 @@ export class ServiceContainer { this.messagingService, this.logService, this.globalStateProvider, + this.singleUserStateProvider, ); this.activeUserStateProvider = new DefaultActiveUserStateProvider( diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 01d84a8f76..72e884d286 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -133,12 +133,6 @@ export class Main { this.mainCryptoFunctionService = new MainCryptoFunctionService(); this.mainCryptoFunctionService.init(); - const accountService = new AccountServiceImplementation( - MessageSender.EMPTY, - this.logService, - globalStateProvider, - ); - const stateEventRegistrarService = new StateEventRegistrarService( globalStateProvider, storageServiceProvider, @@ -150,6 +144,13 @@ export class Main { this.logService, ); + const accountService = new AccountServiceImplementation( + MessageSender.EMPTY, + this.logService, + globalStateProvider, + singleUserStateProvider, + ); + const activeUserStateProvider = new DefaultActiveUserStateProvider( accountService, singleUserStateProvider, diff --git a/apps/web/src/app/auth/settings/account/account.component.html b/apps/web/src/app/auth/settings/account/account.component.html index 4055f14219..9f405c6508 100644 --- a/apps/web/src/app/auth/settings/account/account.component.html +++ b/apps/web/src/app/auth/settings/account/account.component.html @@ -9,6 +9,26 @@ + + + + @@ -32,7 +52,6 @@ - diff --git a/apps/web/src/app/auth/settings/account/account.component.ts b/apps/web/src/app/auth/settings/account/account.component.ts index 012fa0ff01..c32e2c375b 100644 --- a/apps/web/src/app/auth/settings/account/account.component.ts +++ b/apps/web/src/app/auth/settings/account/account.component.ts @@ -1,9 +1,15 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { Component, OnInit, ViewChild, ViewContainerRef } from "@angular/core"; -import { combineLatest, firstValueFrom, from, lastValueFrom, map, Observable } from "rxjs"; +import { Component, OnInit, OnDestroy } from "@angular/core"; +import { + combineLatest, + firstValueFrom, + from, + lastValueFrom, + map, + Observable, + Subject, + takeUntil, +} from "rxjs"; -import { ModalService } from "@bitwarden/angular/services/modal.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; @@ -16,31 +22,35 @@ import { PurgeVaultComponent } from "../../../vault/settings/purge-vault.compone import { DeauthorizeSessionsComponent } from "./deauthorize-sessions.component"; import { DeleteAccountDialogComponent } from "./delete-account-dialog.component"; +import { SetAccountVerifyDevicesDialogComponent } from "./set-account-verify-devices-dialog.component"; @Component({ selector: "app-account", templateUrl: "account.component.html", }) -export class AccountComponent implements OnInit { - @ViewChild("deauthorizeSessionsTemplate", { read: ViewContainerRef, static: true }) - deauthModalRef: ViewContainerRef; +export class AccountComponent implements OnInit, OnDestroy { + private destroy$ = new Subject(); - showChangeEmail$: Observable; - showPurgeVault$: Observable; - showDeleteAccount$: Observable; + showChangeEmail$: Observable = new Observable(); + showPurgeVault$: Observable = new Observable(); + showDeleteAccount$: Observable = new Observable(); + showSetNewDeviceLoginProtection$: Observable = new Observable(); + verifyNewDeviceLogin: boolean = true; constructor( - private modalService: ModalService, + private accountService: AccountService, private dialogService: DialogService, private userVerificationService: UserVerificationService, private configService: ConfigService, private organizationService: OrganizationService, - private accountService: AccountService, ) {} async ngOnInit() { const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); + this.showSetNewDeviceLoginProtection$ = this.configService.getFeatureFlag$( + FeatureFlag.NewDeviceVerification, + ); const isAccountDeprovisioningEnabled$ = this.configService.getFeatureFlag$( FeatureFlag.AccountDeprovisioning, ); @@ -83,11 +93,17 @@ export class AccountComponent implements OnInit { !isAccountDeprovisioningEnabled || !userIsManagedByOrganization, ), ); + this.accountService.accountVerifyNewDeviceLogin$ + .pipe(takeUntil(this.destroy$)) + .subscribe((verifyDevices) => { + this.verifyNewDeviceLogin = verifyDevices; + }); } - async deauthorizeSessions() { - await this.modalService.openViewRef(DeauthorizeSessionsComponent, this.deauthModalRef); - } + deauthorizeSessions = async () => { + const dialogRef = DeauthorizeSessionsComponent.open(this.dialogService); + await lastValueFrom(dialogRef.closed); + }; purgeVault = async () => { const dialogRef = PurgeVaultComponent.open(this.dialogService); @@ -98,4 +114,14 @@ export class AccountComponent implements OnInit { const dialogRef = DeleteAccountDialogComponent.open(this.dialogService); await lastValueFrom(dialogRef.closed); }; + + setNewDeviceLoginProtection = async () => { + const dialogRef = SetAccountVerifyDevicesDialogComponent.open(this.dialogService); + await lastValueFrom(dialogRef.closed); + }; + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } } diff --git a/apps/web/src/app/auth/settings/account/danger-zone.component.html b/apps/web/src/app/auth/settings/account/danger-zone.component.html index 1e7c73a3cc..68173e12fd 100644 --- a/apps/web/src/app/auth/settings/account/danger-zone.component.html +++ b/apps/web/src/app/auth/settings/account/danger-zone.component.html @@ -1,14 +1,6 @@

{{ "dangerZone" | i18n }}

-

- {{ - (accountDeprovisioningEnabled$ | async) && content.children.length === 1 - ? ("dangerZoneDescSingular" | i18n) - : ("dangerZoneDesc" | i18n) - }} -

-
diff --git a/apps/web/src/app/auth/settings/account/deauthorize-sessions.component.html b/apps/web/src/app/auth/settings/account/deauthorize-sessions.component.html index 3867e9d1ca..ecadacfeed 100644 --- a/apps/web/src/app/auth/settings/account/deauthorize-sessions.component.html +++ b/apps/web/src/app/auth/settings/account/deauthorize-sessions.component.html @@ -1,38 +1,21 @@ - +
+ + +

{{ "deauthorizeSessionsDesc" | i18n }}

+ {{ "deauthorizeSessionsWarning" | i18n }} + +
+ + + + +
+
diff --git a/apps/web/src/app/auth/settings/account/deauthorize-sessions.component.ts b/apps/web/src/app/auth/settings/account/deauthorize-sessions.component.ts index 57ca0e0ecf..a7c466d4ff 100644 --- a/apps/web/src/app/auth/settings/account/deauthorize-sessions.component.ts +++ b/apps/web/src/app/auth/settings/account/deauthorize-sessions.component.ts @@ -1,6 +1,5 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Component } from "@angular/core"; +import { FormBuilder } from "@angular/forms"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; @@ -8,33 +7,33 @@ import { Verification } from "@bitwarden/common/auth/types/verification"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; -import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { ToastService } from "@bitwarden/components"; +import { DialogService, ToastService } from "@bitwarden/components"; @Component({ selector: "app-deauthorize-sessions", templateUrl: "deauthorize-sessions.component.html", }) export class DeauthorizeSessionsComponent { - masterPassword: Verification; - formPromise: Promise; + deauthForm = this.formBuilder.group({ + verification: undefined as Verification | undefined, + }); + invalidSecret: boolean = false; constructor( private apiService: ApiService, private i18nService: I18nService, - private platformUtilsService: PlatformUtilsService, + private formBuilder: FormBuilder, private userVerificationService: UserVerificationService, private messagingService: MessagingService, private logService: LogService, private toastService: ToastService, ) {} - async submit() { + submit = async () => { try { - this.formPromise = this.userVerificationService - .buildRequest(this.masterPassword) - .then((request) => this.apiService.postSecurityStamp(request)); - await this.formPromise; + const verification: Verification = this.deauthForm.value.verification!; + const request = await this.userVerificationService.buildRequest(verification); + await this.apiService.postSecurityStamp(request); this.toastService.showToast({ variant: "success", title: this.i18nService.t("sessionsDeauthorized"), @@ -44,5 +43,9 @@ export class DeauthorizeSessionsComponent { } catch (e) { this.logService.error(e); } + }; + + static open(dialogService: DialogService) { + return dialogService.open(DeauthorizeSessionsComponent); } } diff --git a/apps/web/src/app/auth/settings/account/delete-account-dialog.component.ts b/apps/web/src/app/auth/settings/account/delete-account-dialog.component.ts index aa5cfa3c1d..64d7dc1b0d 100644 --- a/apps/web/src/app/auth/settings/account/delete-account-dialog.component.ts +++ b/apps/web/src/app/auth/settings/account/delete-account-dialog.component.ts @@ -8,7 +8,6 @@ import { AccountApiService } from "@bitwarden/common/auth/abstractions/account-a import { Verification } from "@bitwarden/common/auth/types/verification"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { DialogService, ToastService } from "@bitwarden/components"; @Component({ @@ -22,7 +21,6 @@ export class DeleteAccountDialogComponent { constructor( private i18nService: I18nService, - private platformUtilsService: PlatformUtilsService, private formBuilder: FormBuilder, private accountApiService: AccountApiService, private dialogRef: DialogRef, diff --git a/apps/web/src/app/auth/settings/account/set-account-verify-devices-dialog.component.html b/apps/web/src/app/auth/settings/account/set-account-verify-devices-dialog.component.html new file mode 100644 index 0000000000..6cd5bbf921 --- /dev/null +++ b/apps/web/src/app/auth/settings/account/set-account-verify-devices-dialog.component.html @@ -0,0 +1,43 @@ +
+ + +

+ {{ "turnOffNewDeviceLoginProtectionModalDesc" | i18n }} +

+

+ {{ "turnOnNewDeviceLoginProtectionModalDesc" | i18n }} +

+ {{ + "turnOffNewDeviceLoginProtectionWarning" | i18n + }} + +
+ + + + + +
+
diff --git a/apps/web/src/app/auth/settings/account/set-account-verify-devices-dialog.component.ts b/apps/web/src/app/auth/settings/account/set-account-verify-devices-dialog.component.ts new file mode 100644 index 0000000000..dc7735d752 --- /dev/null +++ b/apps/web/src/app/auth/settings/account/set-account-verify-devices-dialog.component.ts @@ -0,0 +1,122 @@ +import { DialogRef } from "@angular/cdk/dialog"; +import { CommonModule } from "@angular/common"; +import { Component, OnDestroy, OnInit } from "@angular/core"; +import { FormBuilder, ReactiveFormsModule } from "@angular/forms"; +import { firstValueFrom, Subject, takeUntil } from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { UserVerificationFormInputComponent } from "@bitwarden/auth/angular"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { AccountApiService } from "@bitwarden/common/auth/abstractions/account-api.service"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; +import { SetVerifyDevicesRequest } from "@bitwarden/common/auth/models/request/set-verify-devices.request"; +import { Verification } from "@bitwarden/common/auth/types/verification"; +import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { + AsyncActionsModule, + ButtonModule, + CalloutModule, + DialogModule, + DialogService, + FormFieldModule, + IconButtonModule, + RadioButtonModule, + SelectModule, + ToastService, +} from "@bitwarden/components"; + +@Component({ + templateUrl: "./set-account-verify-devices-dialog.component.html", + standalone: true, + imports: [ + CommonModule, + ReactiveFormsModule, + JslibModule, + FormFieldModule, + AsyncActionsModule, + ButtonModule, + IconButtonModule, + SelectModule, + CalloutModule, + RadioButtonModule, + DialogModule, + UserVerificationFormInputComponent, + ], +}) +export class SetAccountVerifyDevicesDialogComponent implements OnInit, OnDestroy { + // use this subject for all subscriptions to ensure all subscripts are completed + private destroy$ = new Subject(); + // the default for new device verification is true + verifyNewDeviceLogin: boolean = true; + has2faConfigured: boolean = false; + + setVerifyDevicesForm = this.formBuilder.group({ + verification: undefined as Verification | undefined, + }); + invalidSecret: boolean = false; + + constructor( + private i18nService: I18nService, + private formBuilder: FormBuilder, + private accountApiService: AccountApiService, + private accountService: AccountService, + private userVerificationService: UserVerificationService, + private dialogRef: DialogRef, + private toastService: ToastService, + private apiService: ApiService, + ) { + this.accountService.accountVerifyNewDeviceLogin$ + .pipe(takeUntil(this.destroy$)) + .subscribe((verifyDevices: boolean) => { + this.verifyNewDeviceLogin = verifyDevices; + }); + } + + async ngOnInit() { + const twoFactorProviders = await this.apiService.getTwoFactorProviders(); + this.has2faConfigured = twoFactorProviders.data.length > 0; + } + + submit = async () => { + try { + const activeAccount = await firstValueFrom( + this.accountService.activeAccount$.pipe(takeUntil(this.destroy$)), + ); + const verification: Verification = this.setVerifyDevicesForm.value.verification!; + const request: SetVerifyDevicesRequest = await this.userVerificationService.buildRequest( + verification, + SetVerifyDevicesRequest, + ); + // set verify device opposite what is currently is. + request.verifyDevices = !this.verifyNewDeviceLogin; + await this.accountApiService.setVerifyDevices(request); + await this.accountService.setAccountVerifyNewDeviceLogin( + activeAccount!.id, + request.verifyDevices, + ); + this.dialogRef.close(); + this.toastService.showToast({ + variant: "success", + title: "", + message: this.i18nService.t("accountNewDeviceLoginProtectionSaved"), + }); + } catch (e) { + if (e instanceof ErrorResponse && e.statusCode === 400) { + this.invalidSecret = true; + } + throw e; + } + }; + + static open(dialogService: DialogService) { + return dialogService.open(SetAccountVerifyDevicesDialogComponent); + } + + // closes subscription leaks + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } +} diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 9e5b25a4ab..09ea68091c 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -1792,7 +1792,7 @@ }, "requestPending": { "message": "Request pending" - }, + }, "logBackInOthersToo": { "message": "Please log back in. If you are using other Bitwarden applications log out and back in to those as well." }, @@ -1860,12 +1860,6 @@ "dangerZone": { "message": "Danger zone" }, - "dangerZoneDesc": { - "message": "Careful, these actions are not reversible!" - }, - "dangerZoneDescSingular": { - "message": "Careful, this action is not reversible!" - }, "deauthorizeSessions": { "message": "Deauthorize sessions" }, @@ -1875,6 +1869,27 @@ "deauthorizeSessionsWarning": { "message": "Proceeding will also log you out of your current session, requiring you to log back in. You will also be prompted for two-step login again, if set up. Active sessions on other devices may continue to remain active for up to one hour." }, + "newDeviceLoginProtection": { + "message":"New device login" + }, + "turnOffNewDeviceLoginProtection": { + "message":"Turn off new device login protection" + }, + "turnOnNewDeviceLoginProtection": { + "message":"Turn on new device login protection" + }, + "turnOffNewDeviceLoginProtectionModalDesc": { + "message":"Proceed below to turn off the verification emails bitwarden sends when you login from a new device." + }, + "turnOnNewDeviceLoginProtectionModalDesc": { + "message":"Proceed below to have bitwarden send you verification emails when you login from a new device." + }, + "turnOffNewDeviceLoginProtectionWarning": { + "message":"With new device login protection turned off, anyone with your master password can access your account from any device. To protect your account without verification emails, set up two-step login." + }, + "accountNewDeviceLoginProtectionSaved": { + "message": "New device login protection changes saved" + }, "sessionsDeauthorized": { "message": "All sessions deauthorized" }, diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 68b31f8a7d..fb25c6ff67 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -563,7 +563,7 @@ const safeProviders: SafeProvider[] = [ safeProvider({ provide: InternalAccountService, useClass: AccountServiceImplementation, - deps: [MessagingServiceAbstraction, LogService, GlobalStateProvider], + deps: [MessagingServiceAbstraction, LogService, GlobalStateProvider, SingleUserStateProvider], }), safeProvider({ provide: AccountServiceAbstraction, diff --git a/libs/common/spec/fake-account-service.ts b/libs/common/spec/fake-account-service.ts index 05e44d5db1..d45448ce69 100644 --- a/libs/common/spec/fake-account-service.ts +++ b/libs/common/spec/fake-account-service.ts @@ -35,6 +35,8 @@ export class FakeAccountService implements AccountService { activeAccountSubject = new ReplaySubject(1); // eslint-disable-next-line rxjs/no-exposed-subjects -- test class accountActivitySubject = new ReplaySubject>(1); + // eslint-disable-next-line rxjs/no-exposed-subjects -- test class + accountVerifyDevicesSubject = new ReplaySubject(1); private _activeUserId: UserId; get activeUserId() { return this._activeUserId; @@ -42,6 +44,7 @@ export class FakeAccountService implements AccountService { accounts$ = this.accountsSubject.asObservable(); activeAccount$ = this.activeAccountSubject.asObservable(); accountActivity$ = this.accountActivitySubject.asObservable(); + accountVerifyNewDeviceLogin$ = this.accountVerifyDevicesSubject.asObservable(); get sortedUserIds$() { return this.accountActivity$.pipe( map((activity) => { @@ -67,6 +70,11 @@ export class FakeAccountService implements AccountService { this.activeAccountSubject.next(null); this.accountActivitySubject.next(accountActivity); } + + setAccountVerifyNewDeviceLogin(userId: UserId, verifyNewDeviceLogin: boolean): Promise { + return this.mock.setAccountVerifyNewDeviceLogin(userId, verifyNewDeviceLogin); + } + setAccountActivity(userId: UserId, lastActivity: Date): Promise { this.accountActivitySubject.next({ ...this.accountActivitySubject["_buffer"][0], diff --git a/libs/common/src/auth/abstractions/account-api.service.ts b/libs/common/src/auth/abstractions/account-api.service.ts index 78fbb2cf88..61fdd4f9d6 100644 --- a/libs/common/src/auth/abstractions/account-api.service.ts +++ b/libs/common/src/auth/abstractions/account-api.service.ts @@ -1,6 +1,7 @@ import { RegisterFinishRequest } from "../models/request/registration/register-finish.request"; import { RegisterSendVerificationEmailRequest } from "../models/request/registration/register-send-verification-email.request"; import { RegisterVerificationEmailClickedRequest } from "../models/request/registration/register-verification-email-clicked.request"; +import { SetVerifyDevicesRequest } from "../models/request/set-verify-devices.request"; import { Verification } from "../types/verification"; export abstract class AccountApiService { @@ -18,7 +19,7 @@ export abstract class AccountApiService { * * @param request - The request object containing * information needed to send the verification email, such as the user's email address. - * @returns A promise that resolves to a string tokencontaining the user's encrypted + * @returns A promise that resolves to a string token containing the user's encrypted * information which must be submitted to complete registration or `null` if * email verification is enabled (users must get the token by clicking a * link in the email that will be sent to them). @@ -33,7 +34,7 @@ export abstract class AccountApiService { * * @param request - The request object containing the email verification token and the * user's email address (which is required to validate the token) - * @returns A promise that resolves when the event is logged on the server succcessfully or a bad + * @returns A promise that resolves when the event is logged on the server successfully or a bad * request if the token is invalid for any reason. */ abstract registerVerificationEmailClicked( @@ -50,4 +51,15 @@ export abstract class AccountApiService { * registration process is successfully completed. */ abstract registerFinish(request: RegisterFinishRequest): Promise; + + /** + * Sets the [dbo].[User].[VerifyDevices] flag to true or false. + * + * @param request - The request object is a SecretVerificationRequest extension + * that also contains the boolean value that the VerifyDevices property is being + * set to. + * @returns A promise that resolves when the process is successfully completed or + * a bad request if secret verification fails. + */ + abstract setVerifyDevices(request: SetVerifyDevicesRequest): Promise; } diff --git a/libs/common/src/auth/abstractions/account.service.ts b/libs/common/src/auth/abstractions/account.service.ts index 094e005e65..1686eefda0 100644 --- a/libs/common/src/auth/abstractions/account.service.ts +++ b/libs/common/src/auth/abstractions/account.service.ts @@ -43,6 +43,8 @@ export abstract class AccountService { * Observable of the last activity time for each account. */ accountActivity$: Observable>; + /** Observable of the new device login verification property for the account. */ + accountVerifyNewDeviceLogin$: Observable; /** Account list in order of descending recency */ sortedUserIds$: Observable; /** Next account that is not the current active account */ @@ -73,6 +75,15 @@ export abstract class AccountService { * @param emailVerified */ abstract setAccountEmailVerified(userId: UserId, emailVerified: boolean): Promise; + /** + * updates the `accounts$` observable with the new VerifyNewDeviceLogin property for the account. + * @param userId + * @param VerifyNewDeviceLogin + */ + abstract setAccountVerifyNewDeviceLogin( + userId: UserId, + verifyNewDeviceLogin: boolean, + ): Promise; /** * Updates the `activeAccount$` observable with the new active account. * @param userId diff --git a/libs/common/src/auth/models/request/set-verify-devices.request.ts b/libs/common/src/auth/models/request/set-verify-devices.request.ts new file mode 100644 index 0000000000..4835e9f09c --- /dev/null +++ b/libs/common/src/auth/models/request/set-verify-devices.request.ts @@ -0,0 +1,8 @@ +import { SecretVerificationRequest } from "./secret-verification.request"; + +export class SetVerifyDevicesRequest extends SecretVerificationRequest { + /** + * This is the input for a user update that controls [dbo].[Users].[VerifyDevices] + */ + verifyDevices!: boolean; +} diff --git a/libs/common/src/auth/services/account-api.service.ts b/libs/common/src/auth/services/account-api.service.ts index e10b0686f6..0347694c46 100644 --- a/libs/common/src/auth/services/account-api.service.ts +++ b/libs/common/src/auth/services/account-api.service.ts @@ -10,6 +10,7 @@ import { UserVerificationService } from "../abstractions/user-verification/user- import { RegisterFinishRequest } from "../models/request/registration/register-finish.request"; import { RegisterSendVerificationEmailRequest } from "../models/request/registration/register-send-verification-email.request"; import { RegisterVerificationEmailClickedRequest } from "../models/request/registration/register-verification-email-clicked.request"; +import { SetVerifyDevicesRequest } from "../models/request/set-verify-devices.request"; import { Verification } from "../types/verification"; export class AccountApiServiceImplementation implements AccountApiService { @@ -102,4 +103,21 @@ export class AccountApiServiceImplementation implements AccountApiService { throw e; } } + + async setVerifyDevices(request: SetVerifyDevicesRequest): Promise { + try { + const response = await this.apiService.send( + "POST", + "/accounts/verify-devices", + request, + true, + true, + ); + + return response; + } catch (e: unknown) { + this.logService.error(e); + throw e; + } + } } diff --git a/libs/common/src/auth/services/account.service.spec.ts b/libs/common/src/auth/services/account.service.spec.ts index 2028a7e1fc..3fc4700208 100644 --- a/libs/common/src/auth/services/account.service.spec.ts +++ b/libs/common/src/auth/services/account.service.spec.ts @@ -7,7 +7,10 @@ import { MockProxy, mock } from "jest-mock-extended"; import { firstValueFrom } from "rxjs"; import { FakeGlobalState } from "../../../spec/fake-state"; -import { FakeGlobalStateProvider } from "../../../spec/fake-state-provider"; +import { + FakeGlobalStateProvider, + FakeSingleUserStateProvider, +} from "../../../spec/fake-state-provider"; import { trackEmissions } from "../../../spec/utils"; import { LogService } from "../../platform/abstractions/log.service"; import { MessagingService } from "../../platform/abstractions/messaging.service"; @@ -19,6 +22,7 @@ import { ACCOUNT_ACCOUNTS, ACCOUNT_ACTIVE_ACCOUNT_ID, ACCOUNT_ACTIVITY, + ACCOUNT_VERIFY_NEW_DEVICE_LOGIN, AccountServiceImplementation, } from "./account.service"; @@ -66,6 +70,7 @@ describe("accountService", () => { let messagingService: MockProxy; let logService: MockProxy; let globalStateProvider: FakeGlobalStateProvider; + let singleUserStateProvider: FakeSingleUserStateProvider; let sut: AccountServiceImplementation; let accountsState: FakeGlobalState>; let activeAccountIdState: FakeGlobalState; @@ -77,8 +82,14 @@ describe("accountService", () => { messagingService = mock(); logService = mock(); globalStateProvider = new FakeGlobalStateProvider(); + singleUserStateProvider = new FakeSingleUserStateProvider(); - sut = new AccountServiceImplementation(messagingService, logService, globalStateProvider); + sut = new AccountServiceImplementation( + messagingService, + logService, + globalStateProvider, + singleUserStateProvider, + ); accountsState = globalStateProvider.getFake(ACCOUNT_ACCOUNTS); activeAccountIdState = globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID); @@ -128,6 +139,22 @@ describe("accountService", () => { }); }); + describe("accountsVerifyNewDeviceLogin$", () => { + it("returns expected value", async () => { + // Arrange + const expected = true; + // we need to set this state since it is how we initialize the VerifyNewDeviceLogin$ + activeAccountIdState.stateSubject.next(userId); + singleUserStateProvider.getFake(userId, ACCOUNT_VERIFY_NEW_DEVICE_LOGIN).nextState(expected); + + // Act + const result = await firstValueFrom(sut.accountVerifyNewDeviceLogin$); + + // Assert + expect(result).toEqual(expected); + }); + }); + describe("addAccount", () => { it("should emit the new account", async () => { await sut.addAccount(userId, userInfo); @@ -226,6 +253,33 @@ describe("accountService", () => { }); }); + describe("setAccountVerifyNewDeviceLogin", () => { + const initialState = true; + beforeEach(() => { + activeAccountIdState.stateSubject.next(userId); + singleUserStateProvider + .getFake(userId, ACCOUNT_VERIFY_NEW_DEVICE_LOGIN) + .nextState(initialState); + }); + + it("should update the VerifyNewDeviceLogin", async () => { + const expected = false; + expect(await firstValueFrom(sut.accountVerifyNewDeviceLogin$)).toEqual(initialState); + + await sut.setAccountVerifyNewDeviceLogin(userId, expected); + const currentState = await firstValueFrom(sut.accountVerifyNewDeviceLogin$); + + expect(currentState).toEqual(expected); + }); + + it("should NOT update VerifyNewDeviceLogin when userId is null", async () => { + await sut.setAccountVerifyNewDeviceLogin(null, false); + const currentState = await firstValueFrom(sut.accountVerifyNewDeviceLogin$); + + expect(currentState).toEqual(initialState); + }); + }); + describe("clean", () => { beforeEach(() => { accountsState.stateSubject.next({ [userId]: userInfo }); diff --git a/libs/common/src/auth/services/account.service.ts b/libs/common/src/auth/services/account.service.ts index 673d88382f..50ba2455d7 100644 --- a/libs/common/src/auth/services/account.service.ts +++ b/libs/common/src/auth/services/account.service.ts @@ -7,6 +7,7 @@ import { shareReplay, combineLatest, Observable, + switchMap, filter, timeout, of, @@ -26,6 +27,8 @@ import { GlobalState, GlobalStateProvider, KeyDefinition, + SingleUserStateProvider, + UserKeyDefinition, } from "../../platform/state"; import { UserId } from "../../types/guid"; @@ -45,6 +48,15 @@ export const ACCOUNT_ACTIVITY = KeyDefinition.record(ACCOUNT_DISK, deserializer: (activity) => new Date(activity), }); +export const ACCOUNT_VERIFY_NEW_DEVICE_LOGIN = new UserKeyDefinition( + ACCOUNT_DISK, + "verifyNewDeviceLogin", + { + deserializer: (verifyDevices) => verifyDevices, + clearOn: ["logout"], + }, +); + const LOGGED_OUT_INFO: AccountInfo = { email: "", emailVerified: false, @@ -76,6 +88,7 @@ export class AccountServiceImplementation implements InternalAccountService { accounts$: Observable>; activeAccount$: Observable; accountActivity$: Observable>; + accountVerifyNewDeviceLogin$: Observable; sortedUserIds$: Observable; nextUpAccount$: Observable; @@ -83,6 +96,7 @@ export class AccountServiceImplementation implements InternalAccountService { private messagingService: MessagingService, private logService: LogService, private globalStateProvider: GlobalStateProvider, + private singleUserStateProvider: SingleUserStateProvider, ) { this.accountsState = this.globalStateProvider.get(ACCOUNT_ACCOUNTS); this.activeAccountIdState = this.globalStateProvider.get(ACCOUNT_ACTIVE_ACCOUNT_ID); @@ -117,6 +131,12 @@ export class AccountServiceImplementation implements InternalAccountService { return nextId ? { id: nextId, ...accounts[nextId] } : null; }), ); + this.accountVerifyNewDeviceLogin$ = this.activeAccountIdState.state$.pipe( + switchMap( + (userId) => + this.singleUserStateProvider.get(userId, ACCOUNT_VERIFY_NEW_DEVICE_LOGIN).state$, + ), + ); } async addAccount(userId: UserId, accountData: AccountInfo): Promise { @@ -203,6 +223,20 @@ export class AccountServiceImplementation implements InternalAccountService { ); } + async setAccountVerifyNewDeviceLogin( + userId: UserId, + setVerifyNewDeviceLogin: boolean, + ): Promise { + if (!Utils.isGuid(userId)) { + // only store for valid userIds + return; + } + + await this.singleUserStateProvider.get(userId, ACCOUNT_VERIFY_NEW_DEVICE_LOGIN).update(() => { + return setVerifyNewDeviceLogin; + }); + } + async removeAccountActivity(userId: UserId): Promise { await this.globalStateProvider.get(ACCOUNT_ACTIVITY).update( (activity) => { diff --git a/libs/common/src/models/response/profile.response.ts b/libs/common/src/models/response/profile.response.ts index 6b6555fc56..9aee5acbce 100644 --- a/libs/common/src/models/response/profile.response.ts +++ b/libs/common/src/models/response/profile.response.ts @@ -21,6 +21,7 @@ export class ProfileResponse extends BaseResponse { securityStamp: string; forcePasswordReset: boolean; usesKeyConnector: boolean; + verifyDevices: boolean; organizations: ProfileOrganizationResponse[] = []; providers: ProfileProviderResponse[] = []; providerOrganizations: ProfileProviderOrganizationResponse[] = []; @@ -42,6 +43,7 @@ export class ProfileResponse extends BaseResponse { this.securityStamp = this.getResponseProperty("SecurityStamp"); this.forcePasswordReset = this.getResponseProperty("ForcePasswordReset") ?? false; this.usesKeyConnector = this.getResponseProperty("UsesKeyConnector") ?? false; + this.verifyDevices = this.getResponseProperty("VerifyDevices") ?? true; const organizations = this.getResponseProperty("Organizations"); if (organizations != null) { diff --git a/libs/common/src/platform/sync/default-sync.service.ts b/libs/common/src/platform/sync/default-sync.service.ts index 138c7c0331..982be45345 100644 --- a/libs/common/src/platform/sync/default-sync.service.ts +++ b/libs/common/src/platform/sync/default-sync.service.ts @@ -197,6 +197,7 @@ export class DefaultSyncService extends CoreSyncService { await this.avatarService.setSyncAvatarColor(response.id, response.avatarColor); await this.tokenService.setSecurityStamp(response.securityStamp, response.id); await this.accountService.setAccountEmailVerified(response.id, response.emailVerified); + await this.accountService.setAccountVerifyNewDeviceLogin(response.id, response.verifyDevices); await this.billingAccountProfileStateService.setHasPremium( response.premiumPersonally,