1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-12 14:23:32 +00:00

[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 <jsnider@bitwarden.com>
This commit is contained in:
Ike
2025-01-29 07:49:56 -07:00
committed by GitHub
parent 81943cd4f6
commit 60e569ed9d
22 changed files with 447 additions and 95 deletions

View File

@@ -560,6 +560,7 @@ export default class MainBackground {
this.messagingService, this.messagingService,
this.logService, this.logService,
this.globalStateProvider, this.globalStateProvider,
this.singleUserStateProvider,
); );
this.activeUserStateProvider = new DefaultActiveUserStateProvider( this.activeUserStateProvider = new DefaultActiveUserStateProvider(
this.accountService, this.accountService,

View File

@@ -355,6 +355,7 @@ export class ServiceContainer {
this.messagingService, this.messagingService,
this.logService, this.logService,
this.globalStateProvider, this.globalStateProvider,
this.singleUserStateProvider,
); );
this.activeUserStateProvider = new DefaultActiveUserStateProvider( this.activeUserStateProvider = new DefaultActiveUserStateProvider(

View File

@@ -133,12 +133,6 @@ export class Main {
this.mainCryptoFunctionService = new MainCryptoFunctionService(); this.mainCryptoFunctionService = new MainCryptoFunctionService();
this.mainCryptoFunctionService.init(); this.mainCryptoFunctionService.init();
const accountService = new AccountServiceImplementation(
MessageSender.EMPTY,
this.logService,
globalStateProvider,
);
const stateEventRegistrarService = new StateEventRegistrarService( const stateEventRegistrarService = new StateEventRegistrarService(
globalStateProvider, globalStateProvider,
storageServiceProvider, storageServiceProvider,
@@ -150,6 +144,13 @@ export class Main {
this.logService, this.logService,
); );
const accountService = new AccountServiceImplementation(
MessageSender.EMPTY,
this.logService,
globalStateProvider,
singleUserStateProvider,
);
const activeUserStateProvider = new DefaultActiveUserStateProvider( const activeUserStateProvider = new DefaultActiveUserStateProvider(
accountService, accountService,
singleUserStateProvider, singleUserStateProvider,

View File

@@ -9,6 +9,26 @@
</div> </div>
<app-danger-zone> <app-danger-zone>
<ng-container *ngIf="showSetNewDeviceLoginProtection$ | async">
<button
*ngIf="verifyNewDeviceLogin"
type="button"
bitButton
buttonType="danger"
[bitAction]="setNewDeviceLoginProtection"
>
{{ "turnOffNewDeviceLoginProtection" | i18n }}
</button>
<button
*ngIf="!verifyNewDeviceLogin"
type="button"
bitButton
buttonType="secondary"
[bitAction]="setNewDeviceLoginProtection"
>
{{ "turnOnNewDeviceLoginProtection" | i18n }}
</button>
</ng-container>
<button type="button" bitButton buttonType="danger" (click)="deauthorizeSessions()"> <button type="button" bitButton buttonType="danger" (click)="deauthorizeSessions()">
{{ "deauthorizeSessions" | i18n }} {{ "deauthorizeSessions" | i18n }}
</button> </button>
@@ -32,7 +52,6 @@
</button> </button>
</app-danger-zone> </app-danger-zone>
<ng-template #deauthorizeSessionsTemplate></ng-template>
<ng-template #viewUserApiKeyTemplate></ng-template> <ng-template #viewUserApiKeyTemplate></ng-template>
<ng-template #rotateUserApiKeyTemplate></ng-template> <ng-template #rotateUserApiKeyTemplate></ng-template>
</bit-container> </bit-container>

View File

@@ -1,9 +1,15 @@
// FIXME: Update this file to be type safe and remove this and next line import { Component, OnInit, OnDestroy } from "@angular/core";
// @ts-strict-ignore import {
import { Component, OnInit, ViewChild, ViewContainerRef } from "@angular/core"; combineLatest,
import { combineLatest, firstValueFrom, from, lastValueFrom, map, Observable } from "rxjs"; 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 { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; 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 { DeauthorizeSessionsComponent } from "./deauthorize-sessions.component";
import { DeleteAccountDialogComponent } from "./delete-account-dialog.component"; import { DeleteAccountDialogComponent } from "./delete-account-dialog.component";
import { SetAccountVerifyDevicesDialogComponent } from "./set-account-verify-devices-dialog.component";
@Component({ @Component({
selector: "app-account", selector: "app-account",
templateUrl: "account.component.html", templateUrl: "account.component.html",
}) })
export class AccountComponent implements OnInit { export class AccountComponent implements OnInit, OnDestroy {
@ViewChild("deauthorizeSessionsTemplate", { read: ViewContainerRef, static: true }) private destroy$ = new Subject<void>();
deauthModalRef: ViewContainerRef;
showChangeEmail$: Observable<boolean>; showChangeEmail$: Observable<boolean> = new Observable();
showPurgeVault$: Observable<boolean>; showPurgeVault$: Observable<boolean> = new Observable();
showDeleteAccount$: Observable<boolean>; showDeleteAccount$: Observable<boolean> = new Observable();
showSetNewDeviceLoginProtection$: Observable<boolean> = new Observable();
verifyNewDeviceLogin: boolean = true;
constructor( constructor(
private modalService: ModalService, private accountService: AccountService,
private dialogService: DialogService, private dialogService: DialogService,
private userVerificationService: UserVerificationService, private userVerificationService: UserVerificationService,
private configService: ConfigService, private configService: ConfigService,
private organizationService: OrganizationService, private organizationService: OrganizationService,
private accountService: AccountService,
) {} ) {}
async ngOnInit() { async ngOnInit() {
const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
this.showSetNewDeviceLoginProtection$ = this.configService.getFeatureFlag$(
FeatureFlag.NewDeviceVerification,
);
const isAccountDeprovisioningEnabled$ = this.configService.getFeatureFlag$( const isAccountDeprovisioningEnabled$ = this.configService.getFeatureFlag$(
FeatureFlag.AccountDeprovisioning, FeatureFlag.AccountDeprovisioning,
); );
@@ -83,11 +93,17 @@ export class AccountComponent implements OnInit {
!isAccountDeprovisioningEnabled || !userIsManagedByOrganization, !isAccountDeprovisioningEnabled || !userIsManagedByOrganization,
), ),
); );
this.accountService.accountVerifyNewDeviceLogin$
.pipe(takeUntil(this.destroy$))
.subscribe((verifyDevices) => {
this.verifyNewDeviceLogin = verifyDevices;
});
} }
async deauthorizeSessions() { deauthorizeSessions = async () => {
await this.modalService.openViewRef(DeauthorizeSessionsComponent, this.deauthModalRef); const dialogRef = DeauthorizeSessionsComponent.open(this.dialogService);
} await lastValueFrom(dialogRef.closed);
};
purgeVault = async () => { purgeVault = async () => {
const dialogRef = PurgeVaultComponent.open(this.dialogService); const dialogRef = PurgeVaultComponent.open(this.dialogService);
@@ -98,4 +114,14 @@ export class AccountComponent implements OnInit {
const dialogRef = DeleteAccountDialogComponent.open(this.dialogService); const dialogRef = DeleteAccountDialogComponent.open(this.dialogService);
await lastValueFrom(dialogRef.closed); await lastValueFrom(dialogRef.closed);
}; };
setNewDeviceLoginProtection = async () => {
const dialogRef = SetAccountVerifyDevicesDialogComponent.open(this.dialogService);
await lastValueFrom(dialogRef.closed);
};
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
} }

View File

@@ -1,14 +1,6 @@
<h1 bitTypography="h1" class="tw-mt-16 tw-pb-2.5 !tw-text-danger">{{ "dangerZone" | i18n }}</h1> <h1 bitTypography="h1" class="tw-mt-16 tw-pb-2.5 !tw-text-danger">{{ "dangerZone" | i18n }}</h1>
<div class="tw-rounded tw-border tw-border-solid tw-border-danger-600 tw-p-5"> <div class="tw-rounded tw-border tw-border-solid tw-border-danger-600 tw-p-5">
<p>
{{
(accountDeprovisioningEnabled$ | async) && content.children.length === 1
? ("dangerZoneDescSingular" | i18n)
: ("dangerZoneDesc" | i18n)
}}
</p>
<div #content class="tw-flex tw-flex-row tw-gap-2"> <div #content class="tw-flex tw-flex-row tw-gap-2">
<ng-content></ng-content> <ng-content></ng-content>
</div> </div>

View File

@@ -1,38 +1,21 @@
<div class="modal fade" role="dialog" aria-modal="true" aria-labelledby="deAuthTitle"> <form [formGroup]="deauthForm" [bitSubmit]="submit">
<div class="modal-dialog modal-dialog-scrollable" role="document"> <bit-dialog dialogSize="default" [title]="'deauthorizeSessions' | i18n">
<form <ng-container bitDialogContent>
class="modal-content" <p bitTypography="body1">{{ "deauthorizeSessionsDesc" | i18n }}</p>
#form <bit-callout type="warning">{{ "deauthorizeSessionsWarning" | i18n }}</bit-callout>
(ngSubmit)="submit()" <app-user-verification-form-input
[appApiAction]="formPromise" formControlName="verification"
ngNativeValidate name="verification"
> [(invalidSecret)]="invalidSecret"
<div class="modal-header"> ></app-user-verification-form-input>
<h1 class="modal-title" id="deAuthTitle">{{ "deauthorizeSessions" | i18n }}</h1> </ng-container>
<button <ng-container bitDialogFooter>
type="button" <button bitButton bitFormButton type="submit" buttonType="danger">
class="close" {{ "deauthorizeSessions" | i18n }}
data-dismiss="modal" </button>
appA11yTitle="{{ 'close' | i18n }}" <button bitButton bitFormButton type="button" buttonType="secondary" bitDialogClose>
> {{ "close" | i18n }}
<span aria-hidden="true">&times;</span> </button>
</button> </ng-container>
</div> </bit-dialog>
<div class="modal-body"> </form>
<p>{{ "deauthorizeSessionsDesc" | i18n }}</p>
<bit-callout type="warning">{{ "deauthorizeSessionsWarning" | i18n }}</bit-callout>
<app-user-verification [(ngModel)]="masterPassword" ngDefaultControl name="secret">
</app-user-verification>
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-danger btn-submit" [disabled]="form.loading">
<i class="bwi bwi-spinner bwi-spin" title="{{ 'loading' | i18n }}" aria-hidden="true"></i>
<span>{{ "deauthorizeSessions" | i18n }}</span>
</button>
<button type="button" class="btn btn-outline-secondary" data-dismiss="modal">
{{ "close" | i18n }}
</button>
</div>
</form>
</div>
</div>

View File

@@ -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 { Component } from "@angular/core";
import { FormBuilder } from "@angular/forms";
import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; 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 { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { DialogService, ToastService } from "@bitwarden/components";
import { ToastService } from "@bitwarden/components";
@Component({ @Component({
selector: "app-deauthorize-sessions", selector: "app-deauthorize-sessions",
templateUrl: "deauthorize-sessions.component.html", templateUrl: "deauthorize-sessions.component.html",
}) })
export class DeauthorizeSessionsComponent { export class DeauthorizeSessionsComponent {
masterPassword: Verification; deauthForm = this.formBuilder.group({
formPromise: Promise<unknown>; verification: undefined as Verification | undefined,
});
invalidSecret: boolean = false;
constructor( constructor(
private apiService: ApiService, private apiService: ApiService,
private i18nService: I18nService, private i18nService: I18nService,
private platformUtilsService: PlatformUtilsService, private formBuilder: FormBuilder,
private userVerificationService: UserVerificationService, private userVerificationService: UserVerificationService,
private messagingService: MessagingService, private messagingService: MessagingService,
private logService: LogService, private logService: LogService,
private toastService: ToastService, private toastService: ToastService,
) {} ) {}
async submit() { submit = async () => {
try { try {
this.formPromise = this.userVerificationService const verification: Verification = this.deauthForm.value.verification!;
.buildRequest(this.masterPassword) const request = await this.userVerificationService.buildRequest(verification);
.then((request) => this.apiService.postSecurityStamp(request)); await this.apiService.postSecurityStamp(request);
await this.formPromise;
this.toastService.showToast({ this.toastService.showToast({
variant: "success", variant: "success",
title: this.i18nService.t("sessionsDeauthorized"), title: this.i18nService.t("sessionsDeauthorized"),
@@ -44,5 +43,9 @@ export class DeauthorizeSessionsComponent {
} catch (e) { } catch (e) {
this.logService.error(e); this.logService.error(e);
} }
};
static open(dialogService: DialogService) {
return dialogService.open(DeauthorizeSessionsComponent);
} }
} }

View File

@@ -8,7 +8,6 @@ import { AccountApiService } from "@bitwarden/common/auth/abstractions/account-a
import { Verification } from "@bitwarden/common/auth/types/verification"; import { Verification } from "@bitwarden/common/auth/types/verification";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; 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"; import { DialogService, ToastService } from "@bitwarden/components";
@Component({ @Component({
@@ -22,7 +21,6 @@ export class DeleteAccountDialogComponent {
constructor( constructor(
private i18nService: I18nService, private i18nService: I18nService,
private platformUtilsService: PlatformUtilsService,
private formBuilder: FormBuilder, private formBuilder: FormBuilder,
private accountApiService: AccountApiService, private accountApiService: AccountApiService,
private dialogRef: DialogRef, private dialogRef: DialogRef,

View File

@@ -0,0 +1,43 @@
<form [formGroup]="setVerifyDevicesForm" [bitSubmit]="submit">
<bit-dialog dialogSize="default" [title]="'newDeviceLoginProtection' | i18n">
<ng-container bitDialogContent>
<p *ngIf="verifyNewDeviceLogin" bitTypography="body1">
{{ "turnOffNewDeviceLoginProtectionModalDesc" | i18n }}
</p>
<p *ngIf="!verifyNewDeviceLogin" bitTypography="body1">
{{ "turnOnNewDeviceLoginProtectionModalDesc" | i18n }}
</p>
<bit-callout *ngIf="verifyNewDeviceLogin && !has2faConfigured" type="warning">{{
"turnOffNewDeviceLoginProtectionWarning" | i18n
}}</bit-callout>
<app-user-verification-form-input
formControlName="verification"
name="verification"
[(invalidSecret)]="invalidSecret"
></app-user-verification-form-input>
</ng-container>
<ng-container bitDialogFooter>
<button
bitButton
*ngIf="verifyNewDeviceLogin"
bitFormButton
type="submit"
buttonType="danger"
>
{{ "disable" | i18n }}
</button>
<button
bitButton
*ngIf="!verifyNewDeviceLogin"
bitFormButton
type="submit"
buttonType="primary"
>
{{ "enable" | i18n }}
</button>
<button bitButton bitFormButton type="button" buttonType="secondary" bitDialogClose>
{{ "close" | i18n }}
</button>
</ng-container>
</bit-dialog>
</form>

View File

@@ -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<void>();
// 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();
}
}

View File

@@ -1860,12 +1860,6 @@
"dangerZone": { "dangerZone": {
"message": "Danger zone" "message": "Danger zone"
}, },
"dangerZoneDesc": {
"message": "Careful, these actions are not reversible!"
},
"dangerZoneDescSingular": {
"message": "Careful, this action is not reversible!"
},
"deauthorizeSessions": { "deauthorizeSessions": {
"message": "Deauthorize sessions" "message": "Deauthorize sessions"
}, },
@@ -1875,6 +1869,27 @@
"deauthorizeSessionsWarning": { "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." "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": { "sessionsDeauthorized": {
"message": "All sessions deauthorized" "message": "All sessions deauthorized"
}, },

View File

@@ -563,7 +563,7 @@ const safeProviders: SafeProvider[] = [
safeProvider({ safeProvider({
provide: InternalAccountService, provide: InternalAccountService,
useClass: AccountServiceImplementation, useClass: AccountServiceImplementation,
deps: [MessagingServiceAbstraction, LogService, GlobalStateProvider], deps: [MessagingServiceAbstraction, LogService, GlobalStateProvider, SingleUserStateProvider],
}), }),
safeProvider({ safeProvider({
provide: AccountServiceAbstraction, provide: AccountServiceAbstraction,

View File

@@ -35,6 +35,8 @@ export class FakeAccountService implements AccountService {
activeAccountSubject = new ReplaySubject<Account | null>(1); activeAccountSubject = new ReplaySubject<Account | null>(1);
// eslint-disable-next-line rxjs/no-exposed-subjects -- test class // eslint-disable-next-line rxjs/no-exposed-subjects -- test class
accountActivitySubject = new ReplaySubject<Record<UserId, Date>>(1); accountActivitySubject = new ReplaySubject<Record<UserId, Date>>(1);
// eslint-disable-next-line rxjs/no-exposed-subjects -- test class
accountVerifyDevicesSubject = new ReplaySubject<boolean>(1);
private _activeUserId: UserId; private _activeUserId: UserId;
get activeUserId() { get activeUserId() {
return this._activeUserId; return this._activeUserId;
@@ -42,6 +44,7 @@ export class FakeAccountService implements AccountService {
accounts$ = this.accountsSubject.asObservable(); accounts$ = this.accountsSubject.asObservable();
activeAccount$ = this.activeAccountSubject.asObservable(); activeAccount$ = this.activeAccountSubject.asObservable();
accountActivity$ = this.accountActivitySubject.asObservable(); accountActivity$ = this.accountActivitySubject.asObservable();
accountVerifyNewDeviceLogin$ = this.accountVerifyDevicesSubject.asObservable();
get sortedUserIds$() { get sortedUserIds$() {
return this.accountActivity$.pipe( return this.accountActivity$.pipe(
map((activity) => { map((activity) => {
@@ -67,6 +70,11 @@ export class FakeAccountService implements AccountService {
this.activeAccountSubject.next(null); this.activeAccountSubject.next(null);
this.accountActivitySubject.next(accountActivity); this.accountActivitySubject.next(accountActivity);
} }
setAccountVerifyNewDeviceLogin(userId: UserId, verifyNewDeviceLogin: boolean): Promise<void> {
return this.mock.setAccountVerifyNewDeviceLogin(userId, verifyNewDeviceLogin);
}
setAccountActivity(userId: UserId, lastActivity: Date): Promise<void> { setAccountActivity(userId: UserId, lastActivity: Date): Promise<void> {
this.accountActivitySubject.next({ this.accountActivitySubject.next({
...this.accountActivitySubject["_buffer"][0], ...this.accountActivitySubject["_buffer"][0],

View File

@@ -1,6 +1,7 @@
import { RegisterFinishRequest } from "../models/request/registration/register-finish.request"; import { RegisterFinishRequest } from "../models/request/registration/register-finish.request";
import { RegisterSendVerificationEmailRequest } from "../models/request/registration/register-send-verification-email.request"; import { RegisterSendVerificationEmailRequest } from "../models/request/registration/register-send-verification-email.request";
import { RegisterVerificationEmailClickedRequest } from "../models/request/registration/register-verification-email-clicked.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"; import { Verification } from "../types/verification";
export abstract class AccountApiService { export abstract class AccountApiService {
@@ -18,7 +19,7 @@ export abstract class AccountApiService {
* *
* @param request - The request object containing * @param request - The request object containing
* information needed to send the verification email, such as the user's email address. * 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 * information which must be submitted to complete registration or `null` if
* email verification is enabled (users must get the token by clicking a * email verification is enabled (users must get the token by clicking a
* link in the email that will be sent to them). * 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 * @param request - The request object containing the email verification token and the
* user's email address (which is required to validate the token) * 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. * request if the token is invalid for any reason.
*/ */
abstract registerVerificationEmailClicked( abstract registerVerificationEmailClicked(
@@ -50,4 +51,15 @@ export abstract class AccountApiService {
* registration process is successfully completed. * registration process is successfully completed.
*/ */
abstract registerFinish(request: RegisterFinishRequest): Promise<string>; abstract registerFinish(request: RegisterFinishRequest): Promise<string>;
/**
* 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<string>;
} }

View File

@@ -43,6 +43,8 @@ export abstract class AccountService {
* Observable of the last activity time for each account. * Observable of the last activity time for each account.
*/ */
accountActivity$: Observable<Record<UserId, Date>>; accountActivity$: Observable<Record<UserId, Date>>;
/** Observable of the new device login verification property for the account. */
accountVerifyNewDeviceLogin$: Observable<boolean>;
/** Account list in order of descending recency */ /** Account list in order of descending recency */
sortedUserIds$: Observable<UserId[]>; sortedUserIds$: Observable<UserId[]>;
/** Next account that is not the current active account */ /** Next account that is not the current active account */
@@ -73,6 +75,15 @@ export abstract class AccountService {
* @param emailVerified * @param emailVerified
*/ */
abstract setAccountEmailVerified(userId: UserId, emailVerified: boolean): Promise<void>; abstract setAccountEmailVerified(userId: UserId, emailVerified: boolean): Promise<void>;
/**
* updates the `accounts$` observable with the new VerifyNewDeviceLogin property for the account.
* @param userId
* @param VerifyNewDeviceLogin
*/
abstract setAccountVerifyNewDeviceLogin(
userId: UserId,
verifyNewDeviceLogin: boolean,
): Promise<void>;
/** /**
* Updates the `activeAccount$` observable with the new active account. * Updates the `activeAccount$` observable with the new active account.
* @param userId * @param userId

View File

@@ -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;
}

View File

@@ -10,6 +10,7 @@ import { UserVerificationService } from "../abstractions/user-verification/user-
import { RegisterFinishRequest } from "../models/request/registration/register-finish.request"; import { RegisterFinishRequest } from "../models/request/registration/register-finish.request";
import { RegisterSendVerificationEmailRequest } from "../models/request/registration/register-send-verification-email.request"; import { RegisterSendVerificationEmailRequest } from "../models/request/registration/register-send-verification-email.request";
import { RegisterVerificationEmailClickedRequest } from "../models/request/registration/register-verification-email-clicked.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"; import { Verification } from "../types/verification";
export class AccountApiServiceImplementation implements AccountApiService { export class AccountApiServiceImplementation implements AccountApiService {
@@ -102,4 +103,21 @@ export class AccountApiServiceImplementation implements AccountApiService {
throw e; throw e;
} }
} }
async setVerifyDevices(request: SetVerifyDevicesRequest): Promise<string> {
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;
}
}
} }

View File

@@ -7,7 +7,10 @@ import { MockProxy, mock } from "jest-mock-extended";
import { firstValueFrom } from "rxjs"; import { firstValueFrom } from "rxjs";
import { FakeGlobalState } from "../../../spec/fake-state"; 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 { trackEmissions } from "../../../spec/utils";
import { LogService } from "../../platform/abstractions/log.service"; import { LogService } from "../../platform/abstractions/log.service";
import { MessagingService } from "../../platform/abstractions/messaging.service"; import { MessagingService } from "../../platform/abstractions/messaging.service";
@@ -19,6 +22,7 @@ import {
ACCOUNT_ACCOUNTS, ACCOUNT_ACCOUNTS,
ACCOUNT_ACTIVE_ACCOUNT_ID, ACCOUNT_ACTIVE_ACCOUNT_ID,
ACCOUNT_ACTIVITY, ACCOUNT_ACTIVITY,
ACCOUNT_VERIFY_NEW_DEVICE_LOGIN,
AccountServiceImplementation, AccountServiceImplementation,
} from "./account.service"; } from "./account.service";
@@ -66,6 +70,7 @@ describe("accountService", () => {
let messagingService: MockProxy<MessagingService>; let messagingService: MockProxy<MessagingService>;
let logService: MockProxy<LogService>; let logService: MockProxy<LogService>;
let globalStateProvider: FakeGlobalStateProvider; let globalStateProvider: FakeGlobalStateProvider;
let singleUserStateProvider: FakeSingleUserStateProvider;
let sut: AccountServiceImplementation; let sut: AccountServiceImplementation;
let accountsState: FakeGlobalState<Record<UserId, AccountInfo>>; let accountsState: FakeGlobalState<Record<UserId, AccountInfo>>;
let activeAccountIdState: FakeGlobalState<UserId>; let activeAccountIdState: FakeGlobalState<UserId>;
@@ -77,8 +82,14 @@ describe("accountService", () => {
messagingService = mock(); messagingService = mock();
logService = mock(); logService = mock();
globalStateProvider = new FakeGlobalStateProvider(); globalStateProvider = new FakeGlobalStateProvider();
singleUserStateProvider = new FakeSingleUserStateProvider();
sut = new AccountServiceImplementation(messagingService, logService, globalStateProvider); sut = new AccountServiceImplementation(
messagingService,
logService,
globalStateProvider,
singleUserStateProvider,
);
accountsState = globalStateProvider.getFake(ACCOUNT_ACCOUNTS); accountsState = globalStateProvider.getFake(ACCOUNT_ACCOUNTS);
activeAccountIdState = globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID); 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", () => { describe("addAccount", () => {
it("should emit the new account", async () => { it("should emit the new account", async () => {
await sut.addAccount(userId, userInfo); 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", () => { describe("clean", () => {
beforeEach(() => { beforeEach(() => {
accountsState.stateSubject.next({ [userId]: userInfo }); accountsState.stateSubject.next({ [userId]: userInfo });

View File

@@ -7,6 +7,7 @@ import {
shareReplay, shareReplay,
combineLatest, combineLatest,
Observable, Observable,
switchMap,
filter, filter,
timeout, timeout,
of, of,
@@ -26,6 +27,8 @@ import {
GlobalState, GlobalState,
GlobalStateProvider, GlobalStateProvider,
KeyDefinition, KeyDefinition,
SingleUserStateProvider,
UserKeyDefinition,
} from "../../platform/state"; } from "../../platform/state";
import { UserId } from "../../types/guid"; import { UserId } from "../../types/guid";
@@ -45,6 +48,15 @@ export const ACCOUNT_ACTIVITY = KeyDefinition.record<Date, UserId>(ACCOUNT_DISK,
deserializer: (activity) => new Date(activity), deserializer: (activity) => new Date(activity),
}); });
export const ACCOUNT_VERIFY_NEW_DEVICE_LOGIN = new UserKeyDefinition<boolean>(
ACCOUNT_DISK,
"verifyNewDeviceLogin",
{
deserializer: (verifyDevices) => verifyDevices,
clearOn: ["logout"],
},
);
const LOGGED_OUT_INFO: AccountInfo = { const LOGGED_OUT_INFO: AccountInfo = {
email: "", email: "",
emailVerified: false, emailVerified: false,
@@ -76,6 +88,7 @@ export class AccountServiceImplementation implements InternalAccountService {
accounts$: Observable<Record<UserId, AccountInfo>>; accounts$: Observable<Record<UserId, AccountInfo>>;
activeAccount$: Observable<Account | null>; activeAccount$: Observable<Account | null>;
accountActivity$: Observable<Record<UserId, Date>>; accountActivity$: Observable<Record<UserId, Date>>;
accountVerifyNewDeviceLogin$: Observable<boolean>;
sortedUserIds$: Observable<UserId[]>; sortedUserIds$: Observable<UserId[]>;
nextUpAccount$: Observable<Account>; nextUpAccount$: Observable<Account>;
@@ -83,6 +96,7 @@ export class AccountServiceImplementation implements InternalAccountService {
private messagingService: MessagingService, private messagingService: MessagingService,
private logService: LogService, private logService: LogService,
private globalStateProvider: GlobalStateProvider, private globalStateProvider: GlobalStateProvider,
private singleUserStateProvider: SingleUserStateProvider,
) { ) {
this.accountsState = this.globalStateProvider.get(ACCOUNT_ACCOUNTS); this.accountsState = this.globalStateProvider.get(ACCOUNT_ACCOUNTS);
this.activeAccountIdState = this.globalStateProvider.get(ACCOUNT_ACTIVE_ACCOUNT_ID); 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; 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<void> { async addAccount(userId: UserId, accountData: AccountInfo): Promise<void> {
@@ -203,6 +223,20 @@ export class AccountServiceImplementation implements InternalAccountService {
); );
} }
async setAccountVerifyNewDeviceLogin(
userId: UserId,
setVerifyNewDeviceLogin: boolean,
): Promise<void> {
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<void> { async removeAccountActivity(userId: UserId): Promise<void> {
await this.globalStateProvider.get(ACCOUNT_ACTIVITY).update( await this.globalStateProvider.get(ACCOUNT_ACTIVITY).update(
(activity) => { (activity) => {

View File

@@ -21,6 +21,7 @@ export class ProfileResponse extends BaseResponse {
securityStamp: string; securityStamp: string;
forcePasswordReset: boolean; forcePasswordReset: boolean;
usesKeyConnector: boolean; usesKeyConnector: boolean;
verifyDevices: boolean;
organizations: ProfileOrganizationResponse[] = []; organizations: ProfileOrganizationResponse[] = [];
providers: ProfileProviderResponse[] = []; providers: ProfileProviderResponse[] = [];
providerOrganizations: ProfileProviderOrganizationResponse[] = []; providerOrganizations: ProfileProviderOrganizationResponse[] = [];
@@ -42,6 +43,7 @@ export class ProfileResponse extends BaseResponse {
this.securityStamp = this.getResponseProperty("SecurityStamp"); this.securityStamp = this.getResponseProperty("SecurityStamp");
this.forcePasswordReset = this.getResponseProperty("ForcePasswordReset") ?? false; this.forcePasswordReset = this.getResponseProperty("ForcePasswordReset") ?? false;
this.usesKeyConnector = this.getResponseProperty("UsesKeyConnector") ?? false; this.usesKeyConnector = this.getResponseProperty("UsesKeyConnector") ?? false;
this.verifyDevices = this.getResponseProperty("VerifyDevices") ?? true;
const organizations = this.getResponseProperty("Organizations"); const organizations = this.getResponseProperty("Organizations");
if (organizations != null) { if (organizations != null) {

View File

@@ -197,6 +197,7 @@ export class DefaultSyncService extends CoreSyncService {
await this.avatarService.setSyncAvatarColor(response.id, response.avatarColor); await this.avatarService.setSyncAvatarColor(response.id, response.avatarColor);
await this.tokenService.setSecurityStamp(response.securityStamp, response.id); await this.tokenService.setSecurityStamp(response.securityStamp, response.id);
await this.accountService.setAccountEmailVerified(response.id, response.emailVerified); await this.accountService.setAccountEmailVerified(response.id, response.emailVerified);
await this.accountService.setAccountVerifyNewDeviceLogin(response.id, response.verifyDevices);
await this.billingAccountProfileStateService.setHasPremium( await this.billingAccountProfileStateService.setHasPremium(
response.premiumPersonally, response.premiumPersonally,