mirror of
https://github.com/bitwarden/browser
synced 2025-12-15 15:53:27 +00:00
[AC-1192] Create new device approvals component for TDE (#5548)
* Add feature flag route guard and tests * Add additional test for not showing error toast * Strengthen error toast test with message check * Cleanup leaking test state in platformService mock * Negate if statement to reduce nesting * Update return type to CanActivateFn * Use null check instead of undefined * Introduce interface to support different feature flag types - Switch to observable pattern to access serverConfig$ subject - Add catchError handler to allow navigation in case of unexpected exception - Add additional tests * Add additional test for missing feature flag * Remove subscription to the serverConfig observable Introduce type checking logic to determine the appropriately typed flag getter to use in configService * [AC-1192] Create initial device approvals component and route * [AC-1192] Introduce appIfFeature directive for conditionally rendering content based on feature flags * [AC-1192] Add DeviceApprovals link in Settings navigation * Remove align middle from bitCell directive The bitRow directive supports alignment for the entire row and should be used instead * [AC-1192] Add initial device approvals page template * [AC-1192] Introduce fingerprint pipe * [AC-1192] Create core organization module in bitwarden_license directory * [AC-1192] Add support for new Devices icon to no items component - Add new Devices svg - Make icon property of bit-no-items an Input property * [AC-1192] Introduce organization-auth-request.service.ts with related views/responses * [AC-1192] Display pending requests on device approvals page - Add support for loading spinner and no items component * [AC-1192] Add method to bulk deny auth requests * [AC-1192] Add functionality to deny requests from device approvals page * [AC-1192] Add organizationUserId to pending-auth-request.view.ts * [AC-1192] Add approvePendingRequest method to organization-auth-request.service.ts * [AC-1192] Add logic to approve a device approval request * [AC-1192] Change bitMenuItem directive into a component and implement ButtonLikeAbstraction Update the bitMenuItem to be a component and implement the ButtonLikeAbstraction to support the bitAction directive. * [AC-1192] Update menu items to use bitActions * [AC-1192] Update device approvals description copy * [AC-1192] Revert changes to bitMenuItem directive * [AC-1192] Rework menus to use click handlers - Wrap async actions to catch/log any exceptions, set an in-progress state, and refresh after completion - Show a loading spinner in the header when an action is in progress - Disable all menu items when an action is in progress * [AC-1192] Move Devices icon into admin-console web directory * [AC-1192] bit-no-items formatting * [AC-1192] Update appIfFeature directive to hide content on error * [AC-1192] Remove deprecated providedIn for OrganizationAuthRequestService * [AC-1192] Rename key to encryptedUserKey to be more descriptive * [AC-1192] Cleanup loading/spinner logic on data refresh * [AC-1192] Set middle as the default bitRow.alignContent * [AC-1192] Change default alignRowContent for table story * [AC-1192] Rename userId to fingerprintMaterial to be more general The fingerprint material is not always the userId so this name is more general * [AC-1192] Remove redundant alignContent attribute * [AC-1192] Move fingerprint pipe to platform
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
import { NgModule } from "@angular/core";
|
||||
|
||||
import { OrganizationAuthRequestService } from "./services/auth-requests";
|
||||
|
||||
@NgModule({
|
||||
providers: [OrganizationAuthRequestService],
|
||||
})
|
||||
export class CoreOrganizationModule {}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./core-organization.module";
|
||||
@@ -0,0 +1,8 @@
|
||||
export class AdminAuthRequestUpdateRequest {
|
||||
/**
|
||||
*
|
||||
* @param requestApproved - Whether the request was approved/denied. If true, the key must be provided.
|
||||
* @param encryptedUserKey The user's symmetric key that has been encrypted with a device public key if the request was approved.
|
||||
*/
|
||||
constructor(public requestApproved: boolean, public encryptedUserKey?: string) {}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export class BulkDenyAuthRequestsRequest {
|
||||
private ids: string[];
|
||||
constructor(authRequestIds: string[]) {
|
||||
this.ids = authRequestIds;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./pending-organization-auth-request.response";
|
||||
export * from "./organization-auth-request.service";
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { ListResponse } from "@bitwarden/common/models/response/list.response";
|
||||
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
|
||||
|
||||
import { PendingAuthRequestView } from "../../views/pending-auth-request.view";
|
||||
|
||||
import { AdminAuthRequestUpdateRequest } from "./admin-auth-request-update.request";
|
||||
import { BulkDenyAuthRequestsRequest } from "./bulk-deny-auth-requests.request";
|
||||
import { PendingOrganizationAuthRequestResponse } from "./pending-organization-auth-request.response";
|
||||
|
||||
@Injectable()
|
||||
export class OrganizationAuthRequestService {
|
||||
constructor(private apiService: ApiService) {}
|
||||
|
||||
async listPendingRequests(organizationId: string): Promise<PendingAuthRequestView[]> {
|
||||
const r = await this.apiService.send(
|
||||
"GET",
|
||||
`/organizations/${organizationId}/auth-requests`,
|
||||
null,
|
||||
true,
|
||||
true
|
||||
);
|
||||
|
||||
const listResponse = new ListResponse(r, PendingOrganizationAuthRequestResponse);
|
||||
|
||||
return listResponse.data.map((ar) => PendingAuthRequestView.fromResponse(ar));
|
||||
}
|
||||
|
||||
async denyPendingRequests(organizationId: string, ...requestIds: string[]): Promise<void> {
|
||||
await this.apiService.send(
|
||||
"POST",
|
||||
`/organizations/${organizationId}/auth-requests/deny`,
|
||||
new BulkDenyAuthRequestsRequest(requestIds),
|
||||
true,
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
async approvePendingRequest(
|
||||
organizationId: string,
|
||||
requestId: string,
|
||||
encryptedKey: EncString
|
||||
): Promise<void> {
|
||||
await this.apiService.send(
|
||||
"POST",
|
||||
`/organizations/${organizationId}/auth-requests/${requestId}`,
|
||||
new AdminAuthRequestUpdateRequest(true, encryptedKey.encryptedString),
|
||||
true,
|
||||
false
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
|
||||
|
||||
export class PendingOrganizationAuthRequestResponse extends BaseResponse {
|
||||
id: string;
|
||||
userId: string;
|
||||
organizationUserId: string;
|
||||
email: string;
|
||||
publicKey: string;
|
||||
requestDeviceIdentifier: string;
|
||||
requestDeviceType: string;
|
||||
requestIpAddress: string;
|
||||
creationDate: string;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
this.id = this.getResponseProperty("Id");
|
||||
this.userId = this.getResponseProperty("UserId");
|
||||
this.organizationUserId = this.getResponseProperty("OrganizationUserId");
|
||||
this.email = this.getResponseProperty("Email");
|
||||
this.publicKey = this.getResponseProperty("PublicKey");
|
||||
this.requestDeviceIdentifier = this.getResponseProperty("RequestDeviceIdentifier");
|
||||
this.requestDeviceType = this.getResponseProperty("RequestDeviceType");
|
||||
this.requestIpAddress = this.getResponseProperty("RequestIpAddress");
|
||||
this.creationDate = this.getResponseProperty("CreationDate");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { View } from "@bitwarden/common/models/view/view";
|
||||
|
||||
import { PendingOrganizationAuthRequestResponse } from "../services/auth-requests";
|
||||
|
||||
export class PendingAuthRequestView implements View {
|
||||
id: string;
|
||||
userId: string;
|
||||
organizationUserId: string;
|
||||
email: string;
|
||||
publicKey: string;
|
||||
requestDeviceIdentifier: string;
|
||||
requestDeviceType: string;
|
||||
requestIpAddress: string;
|
||||
creationDate: Date;
|
||||
|
||||
static fromResponse(response: PendingOrganizationAuthRequestResponse): PendingAuthRequestView {
|
||||
const view = Object.assign(new PendingAuthRequestView(), response) as PendingAuthRequestView;
|
||||
|
||||
view.creationDate = new Date(response.creationDate);
|
||||
|
||||
return view;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
<h1>
|
||||
{{ "deviceApprovals" | i18n }}
|
||||
<i
|
||||
class="bwi bwi-spinner bwi-spin text-muted"
|
||||
title="{{ 'loading' | i18n }}"
|
||||
aria-hidden="true"
|
||||
*ngIf="actionInProgress || loading"
|
||||
></i>
|
||||
<span class="sr-only">{{ "loading" | i18n }}</span>
|
||||
</h1>
|
||||
<p>
|
||||
{{ "deviceApprovalsDesc" | i18n }}
|
||||
</p>
|
||||
|
||||
<bit-table [dataSource]="tableDataSource">
|
||||
<ng-container header>
|
||||
<tr>
|
||||
<th bitCell>{{ "member" | i18n }}</th>
|
||||
<th bitCell>{{ "deviceInfo" | i18n }}</th>
|
||||
<th bitCell>{{ "time" | i18n }}</th>
|
||||
<th bitCell class="tw-w-10">
|
||||
<button
|
||||
[bitMenuTriggerFor]="headerMenu"
|
||||
bitIconButton="bwi-ellipsis-v"
|
||||
size="small"
|
||||
type="button"
|
||||
appA11yTitle="{{ 'options' | i18n }}"
|
||||
></button>
|
||||
<bit-menu #headerMenu>
|
||||
<button
|
||||
type="button"
|
||||
bitMenuItem
|
||||
(click)="denyAllRequests()"
|
||||
[disabled]="actionInProgress"
|
||||
>
|
||||
<span class="tw-text-danger">
|
||||
<i class="bwi bwi-fw bwi-close" aria-hidden="true"></i>
|
||||
{{ "denyAllRequests" | i18n }}
|
||||
</span>
|
||||
</button>
|
||||
</bit-menu>
|
||||
</th>
|
||||
</tr>
|
||||
</ng-container>
|
||||
<ng-template body let-rows$>
|
||||
<tr bitRow alignContent="top" *ngFor="let r of rows$ | async">
|
||||
<td bitCell class="tw-flex-col">
|
||||
<div>{{ r.email }}</div>
|
||||
<code class="tw-text-sm">{{ r.publicKey | fingerprint : r.email | async }}</code>
|
||||
</td>
|
||||
<td bitCell class="tw-flex-col">
|
||||
<div>{{ r.requestDeviceType }}</div>
|
||||
<div class="tw-text-sm">{{ r.requestIpAddress }}</div>
|
||||
</td>
|
||||
<td bitCell class="tw-flex-col tw-text-muted">
|
||||
{{ r.creationDate | date : "medium" }}
|
||||
</td>
|
||||
<td bitCell class="tw-align-middle">
|
||||
<button
|
||||
[bitMenuTriggerFor]="rowMenu"
|
||||
bitIconButton="bwi-ellipsis-v"
|
||||
size="small"
|
||||
type="button"
|
||||
appA11yTitle="{{ 'options' | i18n }}"
|
||||
></button>
|
||||
<bit-menu #rowMenu>
|
||||
<button
|
||||
type="button"
|
||||
bitMenuItem
|
||||
(click)="approveRequest(r)"
|
||||
[disabled]="actionInProgress"
|
||||
>
|
||||
<i class="bwi bwi-fw bwi-check" aria-hidden="true"></i>
|
||||
{{ "approveRequest" | i18n }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
bitMenuItem
|
||||
(click)="denyRequest(r.id)"
|
||||
[disabled]="actionInProgress"
|
||||
>
|
||||
<span class="tw-text-danger">
|
||||
<i class="bwi bwi-fw bwi-close" aria-hidden="true"></i>
|
||||
{{ "denyRequest" | i18n }}
|
||||
</span>
|
||||
</button>
|
||||
</bit-menu>
|
||||
</td>
|
||||
</tr>
|
||||
</ng-template>
|
||||
</bit-table>
|
||||
|
||||
<bit-no-items
|
||||
class="tw-text-main"
|
||||
*ngIf="!loading && tableDataSource.data.length == 0"
|
||||
[icon]="Devices"
|
||||
>
|
||||
<ng-container slot="title">{{ "noDeviceRequests" | i18n }}</ng-container>
|
||||
<ng-container slot="description">{{ "noDeviceRequestsDesc" | i18n }}</ng-container>
|
||||
</bit-no-items>
|
||||
@@ -0,0 +1,174 @@
|
||||
import { Component, OnDestroy, OnInit } from "@angular/core";
|
||||
import { ActivatedRoute } from "@angular/router";
|
||||
import { BehaviorSubject, Subject, switchMap, takeUntil, tap } from "rxjs";
|
||||
|
||||
import { OrganizationUserService } from "@bitwarden/common/abstractions/organization-user/organization-user.service";
|
||||
import { OrganizationUserResetPasswordDetailsResponse } from "@bitwarden/common/abstractions/organization-user/responses";
|
||||
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.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 { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { TableDataSource } from "@bitwarden/components";
|
||||
import { Devices } from "@bitwarden/web-vault/app/admin-console/icons";
|
||||
|
||||
import { OrganizationAuthRequestService } from "../../core/services/auth-requests";
|
||||
import { PendingAuthRequestView } from "../../core/views/pending-auth-request.view";
|
||||
|
||||
@Component({
|
||||
selector: "app-org-device-approvals",
|
||||
templateUrl: "./device-approvals.component.html",
|
||||
})
|
||||
export class DeviceApprovalsComponent implements OnInit, OnDestroy {
|
||||
tableDataSource = new TableDataSource<PendingAuthRequestView>();
|
||||
organizationId: string;
|
||||
loading = true;
|
||||
actionInProgress = false;
|
||||
|
||||
protected readonly Devices = Devices;
|
||||
|
||||
private destroy$ = new Subject<void>();
|
||||
private refresh$ = new BehaviorSubject<void>(null);
|
||||
|
||||
constructor(
|
||||
private organizationAuthRequestService: OrganizationAuthRequestService,
|
||||
private organizationUserService: OrganizationUserService,
|
||||
private cryptoService: CryptoService,
|
||||
private route: ActivatedRoute,
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
private i18nService: I18nService,
|
||||
private logService: LogService,
|
||||
private validationService: ValidationService
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
this.route.params
|
||||
.pipe(
|
||||
tap((params) => (this.organizationId = params.organizationId)),
|
||||
switchMap(() =>
|
||||
this.refresh$.pipe(
|
||||
tap(() => (this.loading = true)),
|
||||
switchMap(() =>
|
||||
this.organizationAuthRequestService.listPendingRequests(this.organizationId)
|
||||
)
|
||||
)
|
||||
),
|
||||
takeUntil(this.destroy$)
|
||||
)
|
||||
.subscribe((r) => {
|
||||
this.tableDataSource.data = r;
|
||||
this.loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a copy of the user's symmetric key that has been encrypted with the provided device's public key.
|
||||
* @param devicePublicKey
|
||||
* @param resetPasswordDetails
|
||||
* @private
|
||||
*/
|
||||
private async getEncryptedUserSymKey(
|
||||
devicePublicKey: string,
|
||||
resetPasswordDetails: OrganizationUserResetPasswordDetailsResponse
|
||||
): Promise<EncString> {
|
||||
const encryptedUserSymKey = resetPasswordDetails.resetPasswordKey;
|
||||
const encryptedOrgPrivateKey = resetPasswordDetails.encryptedPrivateKey;
|
||||
const devicePubKey = Utils.fromB64ToArray(devicePublicKey);
|
||||
|
||||
// Decrypt Organization's encrypted Private Key with org key
|
||||
const orgSymKey = await this.cryptoService.getOrgKey(this.organizationId);
|
||||
const decOrgPrivateKey = await this.cryptoService.decryptToBytes(
|
||||
new EncString(encryptedOrgPrivateKey),
|
||||
orgSymKey
|
||||
);
|
||||
|
||||
// Decrypt User's symmetric key with decrypted org private key
|
||||
const decValue = await this.cryptoService.rsaDecrypt(encryptedUserSymKey, decOrgPrivateKey);
|
||||
const userSymKey = new SymmetricCryptoKey(decValue);
|
||||
|
||||
// Re-encrypt User's Symmetric Key with the Device Public Key
|
||||
return await this.cryptoService.rsaEncrypt(userSymKey.key, devicePubKey.buffer);
|
||||
}
|
||||
|
||||
async approveRequest(authRequest: PendingAuthRequestView) {
|
||||
await this.performAsyncAction(async () => {
|
||||
const details = await this.organizationUserService.getOrganizationUserResetPasswordDetails(
|
||||
this.organizationId,
|
||||
authRequest.organizationUserId
|
||||
);
|
||||
|
||||
// The user must be enrolled in account recovery (password reset) in order for the request to be approved.
|
||||
if (details == null || details.resetPasswordKey == null) {
|
||||
this.platformUtilsService.showToast(
|
||||
"error",
|
||||
null,
|
||||
this.i18nService.t("resetPasswordDetailsError")
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const encryptedKey = await this.getEncryptedUserSymKey(authRequest.publicKey, details);
|
||||
|
||||
await this.organizationAuthRequestService.approvePendingRequest(
|
||||
this.organizationId,
|
||||
authRequest.id,
|
||||
encryptedKey
|
||||
);
|
||||
|
||||
this.platformUtilsService.showToast(
|
||||
"success",
|
||||
null,
|
||||
this.i18nService.t("loginRequestApproved")
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async denyRequest(requestId: string) {
|
||||
await this.performAsyncAction(async () => {
|
||||
await this.organizationAuthRequestService.denyPendingRequests(this.organizationId, requestId);
|
||||
this.platformUtilsService.showToast("error", null, this.i18nService.t("loginRequestDenied"));
|
||||
});
|
||||
}
|
||||
|
||||
async denyAllRequests() {
|
||||
if (this.tableDataSource.data.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.performAsyncAction(async () => {
|
||||
await this.organizationAuthRequestService.denyPendingRequests(
|
||||
this.organizationId,
|
||||
...this.tableDataSource.data.map((r) => r.id)
|
||||
);
|
||||
this.platformUtilsService.showToast(
|
||||
"error",
|
||||
null,
|
||||
this.i18nService.t("allLoginRequestsDenied")
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private async performAsyncAction(action: () => Promise<void>) {
|
||||
if (this.actionInProgress) {
|
||||
return;
|
||||
}
|
||||
this.actionInProgress = true;
|
||||
try {
|
||||
await action();
|
||||
this.refresh$.next();
|
||||
} catch (err: unknown) {
|
||||
this.logService.error(err.toString());
|
||||
this.validationService.showError(err);
|
||||
} finally {
|
||||
this.actionInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
}
|
||||
@@ -2,14 +2,17 @@ import { NgModule } from "@angular/core";
|
||||
import { RouterModule, Routes } from "@angular/router";
|
||||
|
||||
import { AuthGuard } from "@bitwarden/angular/auth/guards/auth.guard";
|
||||
import { canAccessFeature } from "@bitwarden/angular/guard/feature-flag.guard";
|
||||
import { canAccessSettingsTab } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { OrganizationPermissionsGuard } from "@bitwarden/web-vault/app/admin-console/organizations/guards/org-permissions.guard";
|
||||
import { OrganizationLayoutComponent } from "@bitwarden/web-vault/app/admin-console/organizations/layouts/organization-layout.component";
|
||||
import { SettingsComponent } from "@bitwarden/web-vault/app/admin-console/organizations/settings/settings.component";
|
||||
|
||||
import { SsoComponent } from "../../auth/sso/sso.component";
|
||||
|
||||
import { DeviceApprovalsComponent } from "./manage/device-approvals/device-approvals.component";
|
||||
import { DomainVerificationComponent } from "./manage/domain-verification/domain-verification.component";
|
||||
import { ScimComponent } from "./manage/scim.component";
|
||||
|
||||
@@ -51,6 +54,18 @@ const routes: Routes = [
|
||||
organizationPermissions: (org: Organization) => org.canManageScim,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "device-approvals",
|
||||
component: DeviceApprovalsComponent,
|
||||
canActivate: [
|
||||
OrganizationPermissionsGuard,
|
||||
canAccessFeature(FeatureFlag.TrustedDeviceEncryption),
|
||||
],
|
||||
data: {
|
||||
organizationPermissions: (org: Organization) => org.canManageUsersPassword,
|
||||
titleId: "deviceApprovals",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
import { NgModule } from "@angular/core";
|
||||
|
||||
import { NoItemsModule } from "@bitwarden/components";
|
||||
import { SharedModule } from "@bitwarden/web-vault/app/shared/shared.module";
|
||||
|
||||
import { SsoComponent } from "../../auth/sso/sso.component";
|
||||
|
||||
import { CoreOrganizationModule } from "./core";
|
||||
import { DeviceApprovalsComponent } from "./manage/device-approvals/device-approvals.component";
|
||||
import { DomainAddEditDialogComponent } from "./manage/domain-verification/domain-add-edit-dialog/domain-add-edit-dialog.component";
|
||||
import { DomainVerificationComponent } from "./manage/domain-verification/domain-verification.component";
|
||||
import { ScimComponent } from "./manage/scim.component";
|
||||
import { OrganizationsRoutingModule } from "./organizations-routing.module";
|
||||
|
||||
@NgModule({
|
||||
imports: [SharedModule, OrganizationsRoutingModule],
|
||||
imports: [SharedModule, CoreOrganizationModule, OrganizationsRoutingModule, NoItemsModule],
|
||||
declarations: [
|
||||
SsoComponent,
|
||||
ScimComponent,
|
||||
DomainVerificationComponent,
|
||||
DomainAddEditDialogComponent,
|
||||
DeviceApprovalsComponent,
|
||||
],
|
||||
})
|
||||
export class OrganizationsModule {}
|
||||
|
||||
Reference in New Issue
Block a user