1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-09-27 04:03:00 +02:00

[AC-1416] Expose Organization Fingerprint (#5557)

* refactor: change getFingerprint param to fingerprintMaterial, refs PM-1522

* feat: generate and show fingerprint for organization (WIP), refs AC-1416

* feat: update legacy params subscription to best practice (WIP), refs AC-1461

* refactor: update to use reactive forms, refs AC-1416

* refactor: remove boostrap specific classes and update to component library paradigms, refs AC-1416

* refactor: remove boostrap specific classes and update to component library paradigms, refs AC-1416

* refactor: create shared fingerprint component to redude boilerplate for settings fingerprint views, refs AC-1416

* refactor: use grid to emulate col-6 and remove unnecessary theme extensions, refs AC-1416

* refactor: remove negative margin and clean up extra divs, refs AC-1416

* [AC-1431] Add missing UserVerificationModule import (#5555)

* [PM-2238] Add nord and solarize themes (#5491)

* Fix simple configurable dialog stories (#5560)

* chore(deps): update bitwarden/gh-actions digest to 72594be (#5523)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

* refactor: remove extra div leftover from card-body class, refs AC-1416

* refactor: use bitTypography for headers, refs AC-1416

* fix: update crypto service abstraction path, refs AC-1416

* refactor: remove try/catch on handler, remove bootstrap class, update api chaining in observable, refs AC-1416

* fix: replace faulty combineLatest logic, refs AC-1416

* refactor: simplify observable logic again, refs AC-1416

---------

Co-authored-by: Shane Melton <smelton@bitwarden.com>
Co-authored-by: Oscar Hinton <Hinton@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
This commit is contained in:
Vincent Salucci 2023-06-15 21:03:48 -05:00 committed by GitHub
parent 0afbd90a2d
commit 5cd51374d7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 206 additions and 135 deletions

View File

@ -1,95 +1,66 @@
<div class="page-header">
<h1>{{ "organizationInfo" | i18n }}</h1>
</div>
<h1 bitTypography="h1" class="tw-pb-2.5">{{ "organizationInfo" | i18n }}</h1>
<div *ngIf="loading">
<i
class="bwi bwi-spinner bwi-spin text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="sr-only">{{ "loading" | i18n }}</span>
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
</div>
<form
*ngIf="org && !loading"
#form
(ngSubmit)="submit()"
[appApiAction]="formPromise"
ngNativeValidate
>
<div class="row">
<div class="col-6">
<div class="form-group">
<label for="name">{{ "organizationName" | i18n }}</label>
<input
id="name"
class="form-control"
type="text"
name="Name"
[(ngModel)]="org.name"
[disabled]="selfHosted"
/>
</div>
<div class="form-group">
<label for="billingEmail">{{ "billingEmail" | i18n }}</label>
<input
id="billingEmail"
class="form-control"
type="text"
name="BillingEmail"
[(ngModel)]="org.billingEmail"
[disabled]="selfHosted || !canEditSubscription"
/>
</div>
<div class="form-group">
<label for="businessName">{{ "businessName" | i18n }}</label>
<input
id="businessName"
class="form-control"
type="text"
name="BusinessName"
[(ngModel)]="org.businessName"
[disabled]="selfHosted || !canEditSubscription"
/>
</div>
<form *ngIf="org && !loading" #form [bitSubmit]="submit" [formGroup]="formGroup">
<div class="tw-grid tw-grid-cols-2 tw-gap-5">
<div>
<bit-form-field>
<bit-label>{{ "organizationName" | i18n }}</bit-label>
<input bitInput id="orgName" type="text" formControlName="orgName" />
</bit-form-field>
<bit-form-field>
<bit-label>{{ "billingEmail" | i18n }}</bit-label>
<input bitInput id="billingEmail" formControlName="billingEmail" type="email" />
</bit-form-field>
<bit-form-field>
<bit-label>{{ "businessName" | i18n }}</bit-label>
<input bitInput id="businessName" formControlName="businessName" type="text" />
</bit-form-field>
</div>
<div class="col-6">
<div>
<bit-avatar [text]="org.name" [id]="org.id" size="large"></bit-avatar>
<app-account-fingerprint
[fingerprintMaterial]="organizationId"
[publicKeyBuffer]="publicKeyBuffer"
fingerprintLabel="{{ 'yourOrganizationsFingerprint' | i18n }}"
>
</app-account-fingerprint>
</div>
</div>
<button type="submit" buttonType="primary" bitButton [loading]="form.loading">
<button type="submit" bitButton bitFormButton buttonType="primary">
{{ "save" | i18n }}
</button>
</form>
<ng-container *ngIf="canUseApi">
<div class="secondary-header border-0 mb-0">
<h1>{{ "apiKey" | i18n }}</h1>
</div>
<h1 bitTypography="h1" class="tw-mt-16 tw-pb-2.5">{{ "apiKey" | i18n }}</h1>
<p>
{{ "apiKeyDesc" | i18n }}
<a href="https://docs.bitwarden.com" target="_blank" rel="noopener">
{{ "learnMore" | i18n }}
</a>
</p>
<button type="button" class="btn btn-outline-secondary" (click)="viewApiKey()">
<button type="button" bitButton buttonType="secondary" (click)="viewApiKey()">
{{ "viewApiKey" | i18n }}
</button>
<button type="button" class="btn btn-outline-secondary" (click)="rotateApiKey()">
<button type="button" bitButton buttonType="secondary" (click)="rotateApiKey()">
{{ "rotateApiKey" | i18n }}
</button>
</ng-container>
<div class="secondary-header text-danger border-0 mb-0">
<h1>{{ "dangerZone" | i18n }}</h1>
</div>
<div class="card border-danger">
<div class="card-body">
<p>{{ "dangerZoneDesc" | i18n }}</p>
<button type="button" class="btn btn-outline-danger" (click)="deleteOrganization()">
{{ "deleteOrganization" | i18n }}
</button>
<button type="button" class="btn btn-outline-danger" (click)="purgeVault()">
{{ "purgeVault" | i18n }}
</button>
</div>
<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-500 tw-bg-background tw-p-5">
<p>{{ "dangerZoneDesc" | i18n }}</p>
<button type="button" bitButton buttonType="danger" (click)="deleteOrganization()">
{{ "deleteOrganization" | i18n }}
</button>
<button type="button" bitButton buttonType="danger" (click)="purgeVault()">
{{ "purgeVault" | i18n }}
</button>
</div>
<ng-template #purgeOrganizationTemplate></ng-template>
<ng-template #apiKeyTemplate></ng-template>

View File

@ -1,6 +1,7 @@
import { Component, ViewChild, ViewContainerRef } from "@angular/core";
import { FormBuilder, Validators } from "@angular/forms";
import { ActivatedRoute, Router } from "@angular/router";
import { lastValueFrom } from "rxjs";
import { combineLatest, lastValueFrom, Subject, switchMap, takeUntil, from } from "rxjs";
import { DialogServiceAbstraction } from "@bitwarden/angular/services/dialog";
import { ModalService } from "@bitwarden/angular/services/modal.service";
@ -13,6 +14,7 @@ import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.se
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 { Utils } from "@bitwarden/common/platform/misc/utils";
import { ApiKeyComponent } from "../../../settings/api-key.component";
import { PurgeVaultComponent } from "../../../settings/purge-vault.component";
@ -23,7 +25,6 @@ import { DeleteOrganizationDialogResult, openDeleteOrganizationDialog } from "./
selector: "app-org-account",
templateUrl: "account.component.html",
})
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
export class AccountComponent {
@ViewChild("purgeOrganizationTemplate", { read: ViewContainerRef, static: true })
purgeModalRef: ViewContainerRef;
@ -40,7 +41,29 @@ export class AccountComponent {
formPromise: Promise<OrganizationResponse>;
taxFormPromise: Promise<unknown>;
private organizationId: string;
// FormGroup validators taken from server Organization domain object
protected formGroup = this.formBuilder.group({
orgName: this.formBuilder.control(
{ value: "", disabled: true },
{
validators: [Validators.required, Validators.maxLength(50)],
updateOn: "change",
}
),
billingEmail: this.formBuilder.control(
{ value: "", disabled: true },
{ validators: [Validators.required, Validators.email, Validators.maxLength(256)] }
),
businessName: this.formBuilder.control(
{ value: "", disabled: true },
{ validators: [Validators.maxLength(50)] }
),
});
protected organizationId: string;
protected publicKeyBuffer: ArrayBuffer;
private destroy$ = new Subject<void>();
constructor(
private modalService: ModalService,
@ -52,53 +75,88 @@ export class AccountComponent {
private router: Router,
private organizationService: OrganizationService,
private organizationApiService: OrganizationApiServiceAbstraction,
private dialogService: DialogServiceAbstraction
private dialogService: DialogServiceAbstraction,
private formBuilder: FormBuilder
) {}
async ngOnInit() {
this.selfHosted = this.platformUtilsService.isSelfHost();
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
this.route.parent.parent.params.subscribe(async (params) => {
this.organizationId = params.organizationId;
this.canEditSubscription = this.organizationService.get(
this.organizationId
).canEditSubscription;
try {
this.org = await this.organizationApiService.get(this.organizationId);
this.canUseApi = this.org.useApi;
} catch (e) {
this.logService.error(e);
}
});
this.loading = false;
this.route.parent.parent.params
.pipe(
switchMap((params) => {
return combineLatest([
// Organization domain
this.organizationService.get$(params.organizationId),
// OrganizationResponse for form population
from(this.organizationApiService.get(params.organizationId)),
// Organization Public Key
from(this.organizationApiService.getKeys(params.organizationId)),
]);
}),
takeUntil(this.destroy$)
)
.subscribe(([organization, orgResponse, orgKeys]) => {
// Set domain level organization variables
this.organizationId = organization.id;
this.canEditSubscription = organization.canEditSubscription;
this.canUseApi = organization.useApi;
// Org Response
this.org = orgResponse;
// Public Key Buffer for Org Fingerprint Generation
this.publicKeyBuffer = Utils.fromB64ToArray(orgKeys?.publicKey)?.buffer;
// Patch existing values
this.formGroup.patchValue({
orgName: this.org.name,
billingEmail: this.org.billingEmail,
businessName: this.org.businessName,
});
// Update disabled states - reactive forms prefers not using disabled attribute
if (!this.selfHosted) {
this.formGroup.get("orgName").enable();
}
if (!this.selfHosted || this.canEditSubscription) {
this.formGroup.get("billingEmail").enable();
this.formGroup.get("businessName").enable();
}
this.loading = false;
});
}
async submit() {
try {
const request = new OrganizationUpdateRequest();
request.name = this.org.name;
request.businessName = this.org.businessName;
request.billingEmail = this.org.billingEmail;
ngOnDestroy(): void {
// You must first call .next() in order for the notifier to properly close subscriptions using takeUntil
this.destroy$.next();
this.destroy$.complete();
}
// Backfill pub/priv key if necessary
if (!this.org.hasPublicAndPrivateKeys) {
const orgShareKey = await this.cryptoService.getOrgKey(this.organizationId);
const orgKeys = await this.cryptoService.makeKeyPair(orgShareKey);
request.keys = new OrganizationKeysRequest(orgKeys[0], orgKeys[1].encryptedString);
}
this.formPromise = this.organizationApiService.save(this.organizationId, request);
await this.formPromise;
this.platformUtilsService.showToast(
"success",
null,
this.i18nService.t("organizationUpdated")
);
} catch (e) {
this.logService.error(e);
submit = async () => {
this.formGroup.markAllAsTouched();
if (this.formGroup.invalid) {
return;
}
}
const request = new OrganizationUpdateRequest();
request.name = this.formGroup.value.orgName;
request.businessName = this.formGroup.value.businessName;
request.billingEmail = this.formGroup.value.billingEmail;
// Backfill pub/priv key if necessary
if (!this.org.hasPublicAndPrivateKeys) {
const orgShareKey = await this.cryptoService.getOrgKey(this.organizationId);
const orgKeys = await this.cryptoService.makeKeyPair(orgShareKey);
request.keys = new OrganizationKeysRequest(orgKeys[0], orgKeys[1].encryptedString);
}
this.formPromise = this.organizationApiService.save(this.organizationId, request);
await this.formPromise;
this.platformUtilsService.showToast("success", null, this.i18nService.t("organizationUpdated"));
};
async deleteOrganization() {
const dialog = openDeleteOrganizationDialog(this.dialogService, {

View File

@ -1,6 +1,7 @@
import { NgModule } from "@angular/core";
import { LooseComponentsModule, SharedModule } from "../../../shared";
import { AccountFingerprintComponent } from "../../../shared/components/account-fingerprint/account-fingerprint.component";
import { PoliciesModule } from "../../organizations/policies";
import { AccountComponent } from "./account.component";
@ -9,7 +10,13 @@ import { SettingsComponent } from "./settings.component";
import { TwoFactorSetupComponent } from "./two-factor-setup.component";
@NgModule({
imports: [SharedModule, LooseComponentsModule, PoliciesModule, OrganizationSettingsRoutingModule],
imports: [
SharedModule,
LooseComponentsModule,
PoliciesModule,
OrganizationSettingsRoutingModule,
AccountFingerprintComponent,
],
declarations: [SettingsComponent, AccountComponent, TwoFactorSetupComponent],
})
export class OrganizationSettingsModule {}

View File

@ -46,19 +46,11 @@
Customize
</button>
</div>
<hr />
<p *ngIf="fingerprint">
{{ "yourAccountsFingerprint" | i18n }}:
<a
href="https://bitwarden.com/help/fingerprint-phrase/"
target="_blank"
rel="noopener"
appA11yTitle="{{ 'learnMore' | i18n }}"
>
<i class="bwi bwi-question-circle" aria-hidden="true"></i></a
><br />
<code>{{ fingerprint }}</code>
</p>
<app-account-fingerprint
[fingerprintMaterial]="fingerprintMaterial"
fingerprintLabel="{{ 'yourAccountsFingerprint' | i18n }}"
>
</app-account-fingerprint>
</div>
</div>
<button type="submit" class="btn btn-primary btn-submit" [disabled]="form.loading">

View File

@ -3,10 +3,8 @@ import { Subject, takeUntil } from "rxjs";
import { ModalService } from "@bitwarden/angular/services/modal.service";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service";
import { UpdateProfileRequest } from "@bitwarden/common/auth/models/request/update-profile.request";
import { ProfileResponse } from "@bitwarden/common/models/response/profile.response";
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";
@ -21,7 +19,7 @@ import { ChangeAvatarComponent } from "./change-avatar.component";
export class ProfileComponent implements OnInit, OnDestroy {
loading = true;
profile: ProfileResponse;
fingerprint: string;
fingerprintMaterial: string;
formPromise: Promise<any>;
@ViewChild("avatarModalTemplate", { read: ViewContainerRef, static: true })
@ -32,9 +30,7 @@ export class ProfileComponent implements OnInit, OnDestroy {
private apiService: ApiService,
private i18nService: I18nService,
private platformUtilsService: PlatformUtilsService,
private cryptoService: CryptoService,
private logService: LogService,
private keyConnectorService: KeyConnectorService,
private stateService: StateService,
private modalService: ModalService
) {}
@ -42,12 +38,7 @@ export class ProfileComponent implements OnInit, OnDestroy {
async ngOnInit() {
this.profile = await this.apiService.getProfile();
this.loading = false;
const fingerprint = await this.cryptoService.getFingerprint(
await this.stateService.getUserId()
);
if (fingerprint != null) {
this.fingerprint = fingerprint.join("-");
}
this.fingerprintMaterial = await this.stateService.getUserId();
}
async ngOnDestroy() {

View File

@ -0,0 +1,16 @@
<ng-container>
<hr />
<p *ngIf="fingerprint">
{{ fingerprintLabel }}:
<a
href="https://bitwarden.com/help/fingerprint-phrase/"
target="_blank"
rel="noopener"
appA11yTitle="{{ 'learnMore' | i18n }}"
>
<i class="bwi bwi-question-circle" aria-hidden="true"></i
></a>
<br />
<code class="tw-text-code">{{ fingerprint }}</code>
</p>
</ng-container>

View File

@ -0,0 +1,30 @@
import { Component, Input, OnInit } from "@angular/core";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { SharedModule } from "../../shared.module";
@Component({
selector: "app-account-fingerprint",
templateUrl: "account-fingerprint.component.html",
standalone: true,
imports: [SharedModule],
})
export class AccountFingerprintComponent implements OnInit {
@Input() fingerprintMaterial: string;
@Input() publicKeyBuffer: ArrayBuffer;
@Input() fingerprintLabel: string;
protected fingerprint: string;
constructor(private cryptoService: CryptoService) {}
async ngOnInit() {
// TODO - In the future, remove this code and use the fingerprint pipe once merged
const generatedFingerprint = await this.cryptoService.getFingerprint(
this.fingerprintMaterial,
this.publicKeyBuffer
);
this.fingerprint = generatedFingerprint?.join("-") ?? null;
}
}

View File

@ -107,6 +107,7 @@ import { AddEditComponent as OrgAddEditComponent } from "../vault/org-vault/add-
import { AttachmentsComponent as OrgAttachmentsComponent } from "../vault/org-vault/attachments.component";
import { CollectionsComponent as OrgCollectionsComponent } from "../vault/org-vault/collections.component";
import { AccountFingerprintComponent } from "./components/account-fingerprint/account-fingerprint.component";
import { UserVerificationModule } from "./components/user-verification";
import { SharedModule } from "./shared.module";
@ -121,6 +122,7 @@ import { SharedModule } from "./shared.module";
UserVerificationModule,
ChangeKdfModule,
DynamicAvatarComponent,
AccountFingerprintComponent,
],
declarations: [
PremiumBadgeComponent,

View File

@ -6845,6 +6845,10 @@
"updatedTempPassword": {
"message": "User updated a password issued through account recovery."
},
"yourOrganizationsFingerprint": {
"message": "Your organization's fingerprint phrase",
"description": "A 'fingerprint phrase' is a unique word phrase (similar to a passphrase) that a user can use to authenticate their organization's public key with another user, for the purposes of sharing."
},
"deviceApprovals": {
"message": "Device approvals"
},

View File

@ -24,7 +24,7 @@ export abstract class CryptoService {
getEncKey: (key?: SymmetricCryptoKey) => Promise<SymmetricCryptoKey>;
getPublicKey: () => Promise<ArrayBuffer>;
getPrivateKey: () => Promise<ArrayBuffer>;
getFingerprint: (userId: string, publicKey?: ArrayBuffer) => Promise<string[]>;
getFingerprint: (fingerprintMaterial: string, publicKey?: ArrayBuffer) => Promise<string[]>;
getOrgKeys: () => Promise<Map<string, SymmetricCryptoKey>>;
getOrgKey: (orgId: string) => Promise<SymmetricCryptoKey>;
getProviderKey: (providerId: string) => Promise<SymmetricCryptoKey>;

View File

@ -204,7 +204,7 @@ export class CryptoService implements CryptoServiceAbstraction {
return privateKey;
}
async getFingerprint(userId: string, publicKey?: ArrayBuffer): Promise<string[]> {
async getFingerprint(fingerprintMaterial: string, publicKey?: ArrayBuffer): Promise<string[]> {
if (publicKey == null) {
publicKey = await this.getPublicKey();
}
@ -214,7 +214,7 @@ export class CryptoService implements CryptoServiceAbstraction {
const keyFingerprint = await this.cryptoFunctionService.hash(publicKey, "sha256");
const userFingerprint = await this.cryptoFunctionService.hkdfExpand(
keyFingerprint,
userId,
fingerprintMaterial,
32,
"sha256"
);