1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-15 07:43:35 +00:00

[PM-4816] Create shared LoginApprovalComponent (#11982)

* Stub out dialog

* Genericize LoginApprovalComponent

* update ipc mocks

* Remove changes to account component

* Remove changes to account component

* Remove debug

* Remove test component

* Remove added translations

* Fix failing test

* Run lint and prettier

* Rename LoginApprovalServiceAbstraction to LoginApprovalComponentServiceAbstraction

* Add back missing "isVisible" check before calling loginRequest

* Rename classes to contain "Component" in the name

* Add missing space between "login attempt" and fingerprint phrase

* Require email
This commit is contained in:
Alec Rippberger
2024-11-22 12:55:26 -06:00
committed by GitHub
parent 13d4b6f2a6
commit 02ea368446
12 changed files with 307 additions and 12 deletions

View File

@@ -66,3 +66,7 @@ export * from "./vault-timeout-input/vault-timeout-input.component";
// self hosted environment configuration dialog
export * from "./self-hosted-env-config-dialog/self-hosted-env-config-dialog.component";
// login approval
export * from "./login-approval/login-approval.component";
export * from "./login-approval/default-login-approval-component.service";

View File

@@ -0,0 +1,25 @@
import { TestBed } from "@angular/core/testing";
import { DefaultLoginApprovalComponentService } from "./default-login-approval-component.service";
import { LoginApprovalComponent } from "./login-approval.component";
describe("DefaultLoginApprovalComponentService", () => {
let service: DefaultLoginApprovalComponentService;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [DefaultLoginApprovalComponentService],
});
service = TestBed.inject(DefaultLoginApprovalComponentService);
});
it("is created successfully", () => {
expect(service).toBeTruthy();
});
it("has showLoginRequestedAlertIfWindowNotVisible method that is a no-op", async () => {
const loginApprovalComponent = {} as LoginApprovalComponent;
await service.showLoginRequestedAlertIfWindowNotVisible(loginApprovalComponent.email);
});
});

View File

@@ -0,0 +1,16 @@
import { LoginApprovalComponentServiceAbstraction } from "../../common/abstractions/login-approval-component.service.abstraction";
/**
* Default implementation of the LoginApprovalComponentServiceAbstraction.
*/
export class DefaultLoginApprovalComponentService
implements LoginApprovalComponentServiceAbstraction
{
/**
* No-op implementation of the showLoginRequestedAlertIfWindowNotVisible method.
* @returns
*/
async showLoginRequestedAlertIfWindowNotVisible(email?: string): Promise<void> {
return;
}
}

View File

@@ -0,0 +1,42 @@
<bit-dialog>
<span bitDialogTitle>{{ "areYouTryingtoLogin" | i18n }}</span>
<ng-container bitDialogContent>
<h4 class="tw-mb-3">{{ "logInAttemptBy" | i18n: email }}</h4>
<div>
<b>{{ "fingerprintPhraseHeader" | i18n }}</b>
<p class="tw-text-code">{{ fingerprintPhrase }}</p>
</div>
<div>
<b>{{ "deviceType" | i18n }}</b>
<p>{{ authRequestResponse?.requestDeviceType }}</p>
</div>
<div>
<b>{{ "ipAddress" | i18n }}</b>
<p>{{ authRequestResponse?.requestIpAddress }}</p>
</div>
<div>
<b>{{ "time" | i18n }}</b>
<p>{{ requestTimeText }}</p>
</div>
</ng-container>
<ng-container bitDialogFooter>
<button
bitButton
type="button"
buttonType="primary"
[bitAction]="approveLogin"
[bitDialogClose]="true"
>
{{ "confirmLogIn" | i18n }}
</button>
<button
bitButton
type="button"
buttonType="secondary"
[bitAction]="denyLogin"
[bitDialogClose]="true"
>
{{ "denyLogIn" | i18n }}
</button>
</ng-container>
</bit-dialog>

View File

@@ -0,0 +1,122 @@
import { DialogRef, DIALOG_DATA } from "@angular/cdk/dialog";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { mock, MockProxy } from "jest-mock-extended";
import { of } from "rxjs";
import {
AuthRequestServiceAbstraction,
LoginApprovalComponentServiceAbstraction,
} from "@bitwarden/auth/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response";
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { UserId } from "@bitwarden/common/types/guid";
import { ToastService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
import { LoginApprovalComponent } from "./login-approval.component";
describe("LoginApprovalComponent", () => {
let component: LoginApprovalComponent;
let fixture: ComponentFixture<LoginApprovalComponent>;
let authRequestService: MockProxy<AuthRequestServiceAbstraction>;
let accountService: MockProxy<AccountService>;
let apiService: MockProxy<ApiService>;
let i18nService: MockProxy<I18nService>;
let dialogRef: MockProxy<DialogRef>;
let toastService: MockProxy<ToastService>;
const testNotificationId = "test-notification-id";
const testEmail = "test@bitwarden.com";
const testPublicKey = "test-public-key";
beforeEach(async () => {
authRequestService = mock<AuthRequestServiceAbstraction>();
accountService = mock<AccountService>();
apiService = mock<ApiService>();
i18nService = mock<I18nService>();
dialogRef = mock<DialogRef>();
toastService = mock<ToastService>();
accountService.activeAccount$ = of({
email: testEmail,
id: "test-user-id" as UserId,
emailVerified: true,
name: null,
});
await TestBed.configureTestingModule({
imports: [LoginApprovalComponent],
providers: [
{ provide: DIALOG_DATA, useValue: { notificationId: testNotificationId } },
{ provide: AuthRequestServiceAbstraction, useValue: authRequestService },
{ provide: AccountService, useValue: accountService },
{ provide: PlatformUtilsService, useValue: mock<PlatformUtilsService>() },
{ provide: I18nService, useValue: i18nService },
{ provide: ApiService, useValue: apiService },
{ provide: AppIdService, useValue: mock<AppIdService>() },
{ provide: KeyService, useValue: mock<KeyService>() },
{ provide: DialogRef, useValue: dialogRef },
{ provide: ToastService, useValue: toastService },
{
provide: LoginApprovalComponentServiceAbstraction,
useValue: mock<LoginApprovalComponentServiceAbstraction>(),
},
],
}).compileComponents();
fixture = TestBed.createComponent(LoginApprovalComponent);
component = fixture.componentInstance;
});
it("creates successfully", () => {
expect(component).toBeTruthy();
});
describe("ngOnInit", () => {
beforeEach(() => {
apiService.getAuthRequest.mockResolvedValue({
publicKey: testPublicKey,
creationDate: new Date().toISOString(),
} as AuthRequestResponse);
authRequestService.getFingerprintPhrase.mockResolvedValue("test-phrase");
});
it("retrieves and sets auth request data", async () => {
await component.ngOnInit();
expect(apiService.getAuthRequest).toHaveBeenCalledWith(testNotificationId);
expect(component.email).toBe(testEmail);
expect(component.fingerprintPhrase).toBeDefined();
});
it("updates time text initially", async () => {
i18nService.t.mockReturnValue("justNow");
await component.ngOnInit();
expect(component.requestTimeText).toBe("justNow");
});
});
describe("denyLogin", () => {
it("denies auth request and shows info toast", async () => {
const response = { requestApproved: false } as AuthRequestResponse;
apiService.getAuthRequest.mockResolvedValue(response);
authRequestService.approveOrDenyAuthRequest.mockResolvedValue(response);
i18nService.t.mockReturnValue("denied message");
await component.denyLogin();
expect(authRequestService.approveOrDenyAuthRequest).toHaveBeenCalledWith(false, response);
expect(toastService.showToast).toHaveBeenCalledWith({
variant: "info",
title: null,
message: "denied message",
});
});
});
});

View File

@@ -0,0 +1,196 @@
import { DIALOG_DATA, DialogRef } from "@angular/cdk/dialog";
import { CommonModule } from "@angular/common";
import { Component, OnInit, OnDestroy, Inject } from "@angular/core";
import { Subject, firstValueFrom, map } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import {
AuthRequestServiceAbstraction,
LoginApprovalComponentServiceAbstraction as LoginApprovalComponentService,
} from "@bitwarden/auth/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response";
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import {
AsyncActionsModule,
ButtonModule,
DialogModule,
DialogService,
ToastService,
} from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
const RequestTimeOut = 60000 * 15; //15 Minutes
const RequestTimeUpdate = 60000 * 5; //5 Minutes
export interface LoginApprovalDialogParams {
notificationId: string;
}
@Component({
selector: "login-approval",
templateUrl: "login-approval.component.html",
standalone: true,
imports: [CommonModule, AsyncActionsModule, ButtonModule, DialogModule, JslibModule],
})
export class LoginApprovalComponent implements OnInit, OnDestroy {
notificationId: string;
private destroy$ = new Subject<void>();
email: string;
fingerprintPhrase: string;
authRequestResponse: AuthRequestResponse;
interval: NodeJS.Timeout;
requestTimeText: string;
constructor(
@Inject(DIALOG_DATA) private params: LoginApprovalDialogParams,
protected authRequestService: AuthRequestServiceAbstraction,
protected accountService: AccountService,
protected platformUtilsService: PlatformUtilsService,
protected i18nService: I18nService,
protected apiService: ApiService,
protected appIdService: AppIdService,
protected keyService: KeyService,
private dialogRef: DialogRef,
private toastService: ToastService,
private loginApprovalComponentService: LoginApprovalComponentService,
) {
this.notificationId = params.notificationId;
}
async ngOnDestroy(): Promise<void> {
clearInterval(this.interval);
const closedWithButton = await firstValueFrom(this.dialogRef.closed);
if (!closedWithButton) {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.retrieveAuthRequestAndRespond(false);
}
this.destroy$.next();
this.destroy$.complete();
}
async ngOnInit() {
if (this.notificationId != null) {
this.authRequestResponse = await this.apiService.getAuthRequest(this.notificationId);
const publicKey = Utils.fromB64ToArray(this.authRequestResponse.publicKey);
this.email = await await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.email)),
);
this.fingerprintPhrase = await this.authRequestService.getFingerprintPhrase(
this.email,
publicKey,
);
this.updateTimeText();
this.interval = setInterval(() => {
this.updateTimeText();
}, RequestTimeUpdate);
this.loginApprovalComponentService.showLoginRequestedAlertIfWindowNotVisible(this.email);
}
}
/**
* Strongly-typed helper to open a LoginApprovalDialog
* @param dialogService Instance of the dialog service that will be used to open the dialog
* @param data Configuration for the dialog
*/
static open(dialogService: DialogService, data: LoginApprovalDialogParams) {
return dialogService.open(LoginApprovalComponent, { data });
}
denyLogin = async () => {
await this.retrieveAuthRequestAndRespond(false);
};
approveLogin = async () => {
await this.retrieveAuthRequestAndRespond(true);
};
private async retrieveAuthRequestAndRespond(approve: boolean) {
this.authRequestResponse = await this.apiService.getAuthRequest(this.notificationId);
if (this.authRequestResponse.requestApproved || this.authRequestResponse.responseDate != null) {
this.toastService.showToast({
variant: "info",
title: null,
message: this.i18nService.t("thisRequestIsNoLongerValid"),
});
} else {
const loginResponse = await this.authRequestService.approveOrDenyAuthRequest(
approve,
this.authRequestResponse,
);
this.showResultToast(loginResponse);
}
}
showResultToast(loginResponse: AuthRequestResponse) {
if (loginResponse.requestApproved) {
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t(
"logInConfirmedForEmailOnDevice",
this.email,
loginResponse.requestDeviceType,
),
});
} else {
this.toastService.showToast({
variant: "info",
title: null,
message: this.i18nService.t("youDeniedALogInAttemptFromAnotherDevice"),
});
}
}
updateTimeText() {
const requestDate = new Date(this.authRequestResponse.creationDate);
const requestDateUTC = Date.UTC(
requestDate.getUTCFullYear(),
requestDate.getUTCMonth(),
requestDate.getDate(),
requestDate.getUTCHours(),
requestDate.getUTCMinutes(),
requestDate.getUTCSeconds(),
requestDate.getUTCMilliseconds(),
);
const dateNow = new Date(Date.now());
const dateNowUTC = Date.UTC(
dateNow.getUTCFullYear(),
dateNow.getUTCMonth(),
dateNow.getDate(),
dateNow.getUTCHours(),
dateNow.getUTCMinutes(),
dateNow.getUTCSeconds(),
dateNow.getUTCMilliseconds(),
);
const diffInMinutes = dateNowUTC - requestDateUTC;
if (diffInMinutes <= RequestTimeUpdate) {
this.requestTimeText = this.i18nService.t("justNow");
} else if (diffInMinutes < RequestTimeOut) {
this.requestTimeText = this.i18nService.t(
"requestedXMinutesAgo",
(diffInMinutes / 60000).toFixed(),
);
} else {
clearInterval(this.interval);
this.dialogRef.close();
this.toastService.showToast({
variant: "info",
title: null,
message: this.i18nService.t("loginRequestHasAlreadyExpired"),
});
}
}
}

View File

@@ -4,3 +4,4 @@ export * from "./login-email.service";
export * from "./login-strategy.service";
export * from "./user-decryption-options.service.abstraction";
export * from "./auth-request.service.abstraction";
export * from "./login-approval-component.service.abstraction";

View File

@@ -0,0 +1,9 @@
/**
* Abstraction for the LoginApprovalComponent service.
*/
export abstract class LoginApprovalComponentServiceAbstraction {
/**
* Shows a login requested alert if the window is not visible.
*/
abstract showLoginRequestedAlertIfWindowNotVisible: (email?: string) => Promise<void>;
}