1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-11 22:03:36 +00:00

Auth/PM-16947 - Web - Device Management - Add Manage Auth Requests support (#12809)

* PM-16947 - JsLibServices - register default DefaultLoginApprovalComponentService

* PM-16947 - DeviceResponse - add interface for DevicePendingAuthRequest

* PM-16947 - Web translations - migrate all LoginApprovalComponent translations from desktop to web

* PM-16947 - LoginApprovalComp - (1) Add loading state (2) Refactor to return proper boolean results (3) Don't create race condition by trying to respond to the close event in the dialog and re-sending responses upon approve or deny click

* PM-16947 - DeviceManagementComponent - added support for approving and denying auth requests.

* PM-16947 - LoginApprovalComp - Add validation error

* PM-16947 - LoginApprovalComponent - remove validation service for now.

* PM-16947 - Re add validation

* PM-16947 - Fix LoginApprovalComponent tests
This commit is contained in:
Jared Snider
2025-01-13 14:39:48 -05:00
committed by GitHub
parent d252337474
commit 1fcdf25bf7
8 changed files with 178 additions and 49 deletions

View File

@@ -48,17 +48,31 @@
<i [class]="getDeviceIcon(row.type)" class="bwi-lg" aria-hidden="true"></i> <i [class]="getDeviceIcon(row.type)" class="bwi-lg" aria-hidden="true"></i>
</div> </div>
<div> <div>
{{ row.displayName }} <ng-container *ngIf="row.hasPendingAuthRequest">
<span *ngIf="row.trusted" class="tw-text-sm tw-text-muted tw-block"> <a bitLink href="#" appStopClick (click)="managePendingAuthRequest(row)">
{{ "trusted" | i18n }} {{ row.displayName }}
</span> </a>
<span class="tw-text-sm tw-text-muted tw-block">
{{ "needsApproval" | i18n }}
</span>
</ng-container>
<ng-container *ngIf="!row.hasPendingAuthRequest">
{{ row.displayName }}
<span
*ngIf="row.trusted && !row.hasPendingAuthRequest"
class="tw-text-sm tw-text-muted tw-block"
>
{{ "trusted" | i18n }}
</span>
</ng-container>
</div> </div>
</td> </td>
<td bitCell> <td bitCell>
<span *ngIf="isCurrentDevice(row)" bitBadge variant="primary">{{ <span *ngIf="isCurrentDevice(row)" bitBadge variant="primary">{{
"currentSession" | i18n "currentSession" | i18n
}}</span> }}</span>
<span *ngIf="hasPendingAuthRequest(row)" bitBadge variant="warning">{{ <span *ngIf="row.hasPendingAuthRequest" bitBadge variant="warning">{{
"requestPending" | i18n "requestPending" | i18n
}}</span> }}</span>
</td> </td>

View File

@@ -1,10 +1,14 @@
import { CommonModule } from "@angular/common"; import { CommonModule } from "@angular/common";
import { Component } from "@angular/core"; import { Component } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { firstValueFrom } from "rxjs"; import { combineLatest, firstValueFrom } from "rxjs";
import { switchMap } from "rxjs/operators";
import { LoginApprovalComponent } from "@bitwarden/auth/angular";
import { DevicesServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices/devices.service.abstraction"; import { DevicesServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices/devices.service.abstraction";
import {
DevicePendingAuthRequest,
DeviceResponse,
} from "@bitwarden/common/auth/abstractions/devices/responses/device.response";
import { DeviceView } from "@bitwarden/common/auth/abstractions/devices/views/device.view"; import { DeviceView } from "@bitwarden/common/auth/abstractions/devices/views/device.view";
import { DeviceType, DeviceTypeMetadata } from "@bitwarden/common/enums"; import { DeviceType, DeviceTypeMetadata } from "@bitwarden/common/enums";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -26,7 +30,8 @@ interface DeviceTableData {
loginStatus: string; loginStatus: string;
firstLogin: Date; firstLogin: Date;
trusted: boolean; trusted: boolean;
devicePendingAuthRequest: object | null; devicePendingAuthRequest: DevicePendingAuthRequest | null;
hasPendingAuthRequest: boolean;
} }
/** /**
@@ -52,28 +57,25 @@ export class DeviceManagementComponent {
private toastService: ToastService, private toastService: ToastService,
private validationService: ValidationService, private validationService: ValidationService,
) { ) {
this.devicesService combineLatest([this.devicesService.getCurrentDevice$(), this.devicesService.getDevices$()])
.getCurrentDevice$() .pipe(takeUntilDestroyed())
.pipe(
takeUntilDestroyed(),
switchMap((currentDevice) => {
this.currentDevice = new DeviceView(currentDevice);
return this.devicesService.getDevices$();
}),
)
.subscribe({ .subscribe({
next: (devices) => { next: ([currentDevice, devices]: [DeviceResponse, Array<DeviceView>]) => {
this.dataSource.data = devices.map((device) => { this.currentDevice = new DeviceView(currentDevice);
this.dataSource.data = devices.map((device: DeviceView): DeviceTableData => {
return { return {
id: device.id, id: device.id,
type: device.type, type: device.type,
displayName: this.getHumanReadableDeviceType(device.type), displayName: this.getHumanReadableDeviceType(device.type),
loginStatus: this.getLoginStatus(device), loginStatus: this.getLoginStatus(device),
devicePendingAuthRequest: device.response.devicePendingAuthRequest,
firstLogin: new Date(device.creationDate), firstLogin: new Date(device.creationDate),
trusted: device.response.isTrusted, trusted: device.response.isTrusted,
devicePendingAuthRequest: device.response.devicePendingAuthRequest,
hasPendingAuthRequest: this.hasPendingAuthRequest(device.response),
}; };
}); });
this.loading = false; this.loading = false;
}, },
error: () => { error: () => {
@@ -176,15 +178,36 @@ export class DeviceManagementComponent {
/** /**
* Check if a device has a pending auth request * Check if a device has a pending auth request
* @param device - The device * @param device - The device response
* @returns True if the device has a pending auth request, false otherwise * @returns True if the device has a pending auth request, false otherwise
*/ */
protected hasPendingAuthRequest(device: DeviceTableData): boolean { private hasPendingAuthRequest(device: DeviceResponse): boolean {
return ( return (
device.devicePendingAuthRequest !== undefined && device.devicePendingAuthRequest !== null device.devicePendingAuthRequest !== undefined && device.devicePendingAuthRequest !== null
); );
} }
/**
* Open a dialog to approve or deny a pending auth request for a device
*/
async managePendingAuthRequest(device: DeviceTableData) {
if (device.devicePendingAuthRequest === undefined || device.devicePendingAuthRequest === null) {
return;
}
const dialogRef = LoginApprovalComponent.open(this.dialogService, {
notificationId: device.devicePendingAuthRequest.id,
});
const result = await firstValueFrom(dialogRef.closed);
if (result !== undefined && typeof result === "boolean") {
// auth request approved or denied so reset
device.devicePendingAuthRequest = null;
device.hasPendingAuthRequest = false;
}
}
/** /**
* Remove a device * Remove a device
* @param device - The device * @param device - The device

View File

@@ -3813,6 +3813,67 @@
"trusted": { "trusted": {
"message": "Trusted" "message": "Trusted"
}, },
"needsApproval": {
"message": "Needs approval"
},
"areYouTryingtoLogin": {
"message": "Are you trying to log in?"
},
"logInAttemptBy": {
"message": "Login attempt by $EMAIL$",
"placeholders": {
"email": {
"content": "$1",
"example": "name@example.com"
}
}
},
"deviceType": {
"message": "Device Type"
},
"ipAddress": {
"message": "IP Address"
},
"confirmLogIn": {
"message": "Confirm login"
},
"denyLogIn": {
"message": "Deny login"
},
"thisRequestIsNoLongerValid": {
"message": "This request is no longer valid."
},
"logInConfirmedForEmailOnDevice": {
"message": "Login confirmed for $EMAIL$ on $DEVICE$",
"placeholders": {
"email": {
"content": "$1",
"example": "name@example.com"
},
"device": {
"content": "$2",
"example": "iOS"
}
}
},
"youDeniedALogInAttemptFromAnotherDevice": {
"message": "You denied a login attempt from another device. If this really was you, try to log in with the device again."
},
"loginRequestHasAlreadyExpired": {
"message": "Login request has already expired."
},
"justNow": {
"message": "Just now"
},
"requestedXMinutesAgo": {
"message": "Requested $MINUTES$ minutes ago",
"placeholders": {
"minutes": {
"content": "$1",
"example": "5"
}
}
},
"creatingAccountOn": { "creatingAccountOn": {
"message": "Creating account on" "message": "Creating account on"
}, },

View File

@@ -20,6 +20,7 @@ import {
DefaultLoginComponentService, DefaultLoginComponentService,
LoginDecryptionOptionsService, LoginDecryptionOptionsService,
DefaultLoginDecryptionOptionsService, DefaultLoginDecryptionOptionsService,
DefaultLoginApprovalComponentService,
} from "@bitwarden/auth/angular"; } from "@bitwarden/auth/angular";
import { import {
AuthRequestServiceAbstraction, AuthRequestServiceAbstraction,
@@ -39,6 +40,7 @@ import {
DefaultAuthRequestApiService, DefaultAuthRequestApiService,
DefaultLoginSuccessHandlerService, DefaultLoginSuccessHandlerService,
LoginSuccessHandlerService, LoginSuccessHandlerService,
LoginApprovalComponentServiceAbstraction,
} from "@bitwarden/auth/common"; } from "@bitwarden/auth/common";
import { ApiService as ApiServiceAbstraction } from "@bitwarden/common/abstractions/api.service"; import { ApiService as ApiServiceAbstraction } from "@bitwarden/common/abstractions/api.service";
import { AuditService as AuditServiceAbstraction } from "@bitwarden/common/abstractions/audit.service"; import { AuditService as AuditServiceAbstraction } from "@bitwarden/common/abstractions/audit.service";
@@ -1405,6 +1407,11 @@ const safeProviders: SafeProvider[] = [
useClass: DefaultAuthRequestApiService, useClass: DefaultAuthRequestApiService,
deps: [ApiServiceAbstraction, LogService], deps: [ApiServiceAbstraction, LogService],
}), }),
safeProvider({
provide: LoginApprovalComponentServiceAbstraction,
useClass: DefaultLoginApprovalComponentService,
deps: [],
}),
safeProvider({ safeProvider({
provide: LoginDecryptionOptionsService, provide: LoginDecryptionOptionsService,
useClass: DefaultLoginDecryptionOptionsService, useClass: DefaultLoginDecryptionOptionsService,

View File

@@ -1,23 +1,31 @@
<bit-dialog> <bit-dialog>
<span bitDialogTitle>{{ "areYouTryingtoLogin" | i18n }}</span> <span bitDialogTitle>{{ "areYouTryingtoLogin" | i18n }}</span>
<ng-container bitDialogContent> <ng-container bitDialogContent>
<h4 class="tw-mb-3">{{ "logInAttemptBy" | i18n: email }}</h4> <ng-container *ngIf="loading">
<div> <div class="tw-flex tw-items-center tw-justify-center" *ngIf="loading">
<b>{{ "fingerprintPhraseHeader" | i18n }}</b> <i class="bwi bwi-spinner bwi-spin bwi-3x" aria-hidden="true"></i>
<p class="tw-text-code">{{ fingerprintPhrase }}</p> </div>
</div> </ng-container>
<div>
<b>{{ "deviceType" | i18n }}</b> <ng-container *ngIf="!loading">
<p>{{ authRequestResponse?.requestDeviceType }}</p> <h4 class="tw-mb-3">{{ "logInAttemptBy" | i18n: email }}</h4>
</div> <div>
<div> <b>{{ "fingerprintPhraseHeader" | i18n }}</b>
<b>{{ "ipAddress" | i18n }}</b> <p class="tw-text-code">{{ fingerprintPhrase }}</p>
<p>{{ authRequestResponse?.requestIpAddress }}</p> </div>
</div> <div>
<div> <b>{{ "deviceType" | i18n }}</b>
<b>{{ "time" | i18n }}</b> <p>{{ authRequestResponse?.requestDeviceType }}</p>
<p>{{ requestTimeText }}</p> </div>
</div> <div>
<b>{{ "ipAddress" | i18n }}</b>
<p>{{ authRequestResponse?.requestIpAddress }}</p>
</div>
<div>
<b>{{ "time" | i18n }}</b>
<p>{{ requestTimeText }}</p>
</div>
</ng-container>
</ng-container> </ng-container>
<ng-container bitDialogFooter> <ng-container bitDialogFooter>
<button <button
@@ -25,7 +33,7 @@
type="button" type="button"
buttonType="primary" buttonType="primary"
[bitAction]="approveLogin" [bitAction]="approveLogin"
[bitDialogClose]="true" [disabled]="loading"
> >
{{ "confirmLogIn" | i18n }} {{ "confirmLogIn" | i18n }}
</button> </button>
@@ -34,7 +42,7 @@
type="button" type="button"
buttonType="secondary" buttonType="secondary"
[bitAction]="denyLogin" [bitAction]="denyLogin"
[bitDialogClose]="true" [disabled]="loading"
> >
{{ "denyLogIn" | i18n }} {{ "denyLogIn" | i18n }}
</button> </button>

View File

@@ -13,6 +13,7 @@ import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
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 { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { UserId } from "@bitwarden/common/types/guid"; import { UserId } from "@bitwarden/common/types/guid";
import { ToastService } from "@bitwarden/components"; import { ToastService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management"; import { KeyService } from "@bitwarden/key-management";
@@ -29,6 +30,7 @@ describe("LoginApprovalComponent", () => {
let i18nService: MockProxy<I18nService>; let i18nService: MockProxy<I18nService>;
let dialogRef: MockProxy<DialogRef>; let dialogRef: MockProxy<DialogRef>;
let toastService: MockProxy<ToastService>; let toastService: MockProxy<ToastService>;
let validationService: MockProxy<ValidationService>;
const testNotificationId = "test-notification-id"; const testNotificationId = "test-notification-id";
const testEmail = "test@bitwarden.com"; const testEmail = "test@bitwarden.com";
@@ -41,6 +43,7 @@ describe("LoginApprovalComponent", () => {
i18nService = mock<I18nService>(); i18nService = mock<I18nService>();
dialogRef = mock<DialogRef>(); dialogRef = mock<DialogRef>();
toastService = mock<ToastService>(); toastService = mock<ToastService>();
validationService = mock<ValidationService>();
accountService.activeAccount$ = of({ accountService.activeAccount$ = of({
email: testEmail, email: testEmail,
@@ -62,6 +65,7 @@ describe("LoginApprovalComponent", () => {
{ provide: KeyService, useValue: mock<KeyService>() }, { provide: KeyService, useValue: mock<KeyService>() },
{ provide: DialogRef, useValue: dialogRef }, { provide: DialogRef, useValue: dialogRef },
{ provide: ToastService, useValue: toastService }, { provide: ToastService, useValue: toastService },
{ provide: ValidationService, useValue: validationService },
{ {
provide: LoginApprovalComponentServiceAbstraction, provide: LoginApprovalComponentServiceAbstraction,
useValue: mock<LoginApprovalComponentServiceAbstraction>(), useValue: mock<LoginApprovalComponentServiceAbstraction>(),

View File

@@ -16,6 +16,7 @@ import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
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 { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { Utils } from "@bitwarden/common/platform/misc/utils"; import { Utils } from "@bitwarden/common/platform/misc/utils";
import { import {
AsyncActionsModule, AsyncActionsModule,
@@ -40,6 +41,8 @@ export interface LoginApprovalDialogParams {
imports: [CommonModule, AsyncActionsModule, ButtonModule, DialogModule, JslibModule], imports: [CommonModule, AsyncActionsModule, ButtonModule, DialogModule, JslibModule],
}) })
export class LoginApprovalComponent implements OnInit, OnDestroy { export class LoginApprovalComponent implements OnInit, OnDestroy {
loading = true;
notificationId: string; notificationId: string;
private destroy$ = new Subject<void>(); private destroy$ = new Subject<void>();
@@ -62,25 +65,25 @@ export class LoginApprovalComponent implements OnInit, OnDestroy {
private dialogRef: DialogRef, private dialogRef: DialogRef,
private toastService: ToastService, private toastService: ToastService,
private loginApprovalComponentService: LoginApprovalComponentService, private loginApprovalComponentService: LoginApprovalComponentService,
private validationService: ValidationService,
) { ) {
this.notificationId = params.notificationId; this.notificationId = params.notificationId;
} }
async ngOnDestroy(): Promise<void> { async ngOnDestroy(): Promise<void> {
clearInterval(this.interval); 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$.next();
this.destroy$.complete(); this.destroy$.complete();
} }
async ngOnInit() { async ngOnInit() {
if (this.notificationId != null) { if (this.notificationId != null) {
this.authRequestResponse = await this.apiService.getAuthRequest(this.notificationId); try {
this.authRequestResponse = await this.apiService.getAuthRequest(this.notificationId);
} catch (error) {
this.validationService.showError(error);
}
const publicKey = Utils.fromB64ToArray(this.authRequestResponse.publicKey); const publicKey = Utils.fromB64ToArray(this.authRequestResponse.publicKey);
this.email = await await firstValueFrom( this.email = await await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.email)), this.accountService.activeAccount$.pipe(map((a) => a?.email)),
@@ -96,6 +99,8 @@ export class LoginApprovalComponent implements OnInit, OnDestroy {
}, RequestTimeUpdate); }, RequestTimeUpdate);
this.loginApprovalComponentService.showLoginRequestedAlertIfWindowNotVisible(this.email); this.loginApprovalComponentService.showLoginRequestedAlertIfWindowNotVisible(this.email);
this.loading = false;
} }
} }
@@ -131,6 +136,8 @@ export class LoginApprovalComponent implements OnInit, OnDestroy {
); );
this.showResultToast(loginResponse); this.showResultToast(loginResponse);
} }
this.dialogRef.close(approve);
} }
showResultToast(loginResponse: AuthRequestResponse) { showResultToast(loginResponse: AuthRequestResponse) {

View File

@@ -1,6 +1,11 @@
import { DeviceType } from "../../../../enums"; import { DeviceType } from "../../../../enums";
import { BaseResponse } from "../../../../models/response/base.response"; import { BaseResponse } from "../../../../models/response/base.response";
export interface DevicePendingAuthRequest {
id: string;
creationDate: string;
}
export class DeviceResponse extends BaseResponse { export class DeviceResponse extends BaseResponse {
id: string; id: string;
userId: string; userId: string;
@@ -10,7 +15,7 @@ export class DeviceResponse extends BaseResponse {
creationDate: string; creationDate: string;
revisionDate: string; revisionDate: string;
isTrusted: boolean; isTrusted: boolean;
devicePendingAuthRequest: { id: string; creationDate: string } | null; devicePendingAuthRequest: DevicePendingAuthRequest | null;
constructor(response: any) { constructor(response: any) {
super(response); super(response);