mirror of
https://github.com/bitwarden/browser
synced 2026-02-10 05:30:01 +00:00
Merge branch 'auth/pm-9115/implement-view-data-persistence-in-2FA-flows' of https://github.com/bitwarden/clients into auth/pm-9115/implement-view-data-persistence-in-2FA-flows
This commit is contained in:
@@ -16,7 +16,7 @@ export class SetPinComponent implements OnInit {
|
||||
showMasterPasswordOnClientRestartOption = true;
|
||||
|
||||
setPinForm = this.formBuilder.group({
|
||||
pin: ["", [Validators.required]],
|
||||
pin: ["", [Validators.required, Validators.minLength(4)]],
|
||||
requireMasterPasswordOnClientRestart: true,
|
||||
});
|
||||
|
||||
@@ -37,24 +37,26 @@ export class SetPinComponent implements OnInit {
|
||||
}
|
||||
|
||||
submit = async () => {
|
||||
const pin = this.setPinForm.get("pin").value;
|
||||
const pinFormControl = this.setPinForm.controls.pin;
|
||||
const requireMasterPasswordOnClientRestart = this.setPinForm.get(
|
||||
"requireMasterPasswordOnClientRestart",
|
||||
).value;
|
||||
|
||||
if (Utils.isNullOrWhitespace(pin)) {
|
||||
this.dialogRef.close(false);
|
||||
if (Utils.isNullOrWhitespace(pinFormControl.value) || pinFormControl.invalid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
|
||||
const userKey = await this.keyService.getUserKey();
|
||||
|
||||
const userKeyEncryptedPin = await this.pinService.createUserKeyEncryptedPin(pin, userKey);
|
||||
const userKeyEncryptedPin = await this.pinService.createUserKeyEncryptedPin(
|
||||
pinFormControl.value,
|
||||
userKey,
|
||||
);
|
||||
await this.pinService.setUserKeyEncryptedPin(userKeyEncryptedPin, userId);
|
||||
|
||||
const pinKeyEncryptedUserKey = await this.pinService.createPinKeyEncryptedUserKey(
|
||||
pin,
|
||||
pinFormControl.value,
|
||||
userKey,
|
||||
userId,
|
||||
);
|
||||
|
||||
@@ -9,7 +9,7 @@ type Deserializer<T> = {
|
||||
* @param jsonValue The JSON object representation of your state.
|
||||
* @returns The fully typed version of your state.
|
||||
*/
|
||||
readonly deserializer?: (jsonValue: Jsonify<T>) => T;
|
||||
readonly deserializer?: (jsonValue: Jsonify<T>) => T | null;
|
||||
};
|
||||
|
||||
type BaseCacheOptions<T> = {
|
||||
|
||||
@@ -1475,7 +1475,15 @@ const safeProviders: SafeProvider[] = [
|
||||
safeProvider({
|
||||
provide: TaskService,
|
||||
useClass: DefaultTaskService,
|
||||
deps: [StateProvider, ApiServiceAbstraction, OrganizationServiceAbstraction, ConfigService],
|
||||
deps: [
|
||||
StateProvider,
|
||||
ApiServiceAbstraction,
|
||||
OrganizationServiceAbstraction,
|
||||
ConfigService,
|
||||
AuthServiceAbstraction,
|
||||
NotificationsService,
|
||||
MessageListener,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: EndUserNotificationService,
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
# Authentication Flows Documentation
|
||||
|
||||
## Standard Auth Request Flows
|
||||
|
||||
### Flow 1: Unauthed user requests approval from device; Approving device has a masterKey in memory
|
||||
|
||||
1. Unauthed user clicks "Login with device"
|
||||
2. Navigates to /login-with-device which creates a StandardAuthRequest
|
||||
3. Receives approval from a device with authRequestPublicKey(masterKey)
|
||||
4. Decrypts masterKey
|
||||
5. Decrypts userKey
|
||||
6. Proceeds to vault
|
||||
|
||||
### Flow 2: Unauthed user requests approval from device; Approving device does NOT have a masterKey in memory
|
||||
|
||||
1. Unauthed user clicks "Login with device"
|
||||
2. Navigates to /login-with-device which creates a StandardAuthRequest
|
||||
3. Receives approval from a device with authRequestPublicKey(userKey)
|
||||
4. Decrypts userKey
|
||||
5. Proceeds to vault
|
||||
|
||||
**Note:** This flow is an uncommon scenario and relates to TDE off-boarding. The following describes how a user could
|
||||
get into this flow:
|
||||
|
||||
1. An SSO TD user logs into a device via an Admin auth request approval, therefore this device does NOT have a masterKey
|
||||
in memory
|
||||
2. The org admin:
|
||||
- Changes the member decryption options from "Trusted devices" to "Master password" AND
|
||||
- Turns off the "Require single sign-on authentication" policy
|
||||
3. On another device, the user clicks "Login with device", which they can do because the org no longer requires SSO
|
||||
4. The user approves from the device they had previously logged into with SSO TD, which does NOT have a masterKey in
|
||||
memory
|
||||
|
||||
### Flow 3: Authed SSO TD user requests approval from device; Approving device has a masterKey in memory
|
||||
|
||||
1. SSO TD user authenticates via SSO
|
||||
2. Navigates to /login-initiated
|
||||
3. Clicks "Approve from your other device"
|
||||
4. Navigates to /login-with-device which creates a StandardAuthRequest
|
||||
5. Receives approval from device with authRequestPublicKey(masterKey)
|
||||
6. Decrypts masterKey
|
||||
7. Decrypts userKey
|
||||
8. Establishes trust (if required)
|
||||
9. Proceeds to vault
|
||||
|
||||
### Flow 4: Authed SSO TD user requests approval from device; Approving device does NOT have a masterKey in memory
|
||||
|
||||
1. SSO TD user authenticates via SSO
|
||||
2. Navigates to /login-initiated
|
||||
3. Clicks "Approve from your other device"
|
||||
4. Navigates to /login-with-device which creates a StandardAuthRequest
|
||||
5. Receives approval from device with authRequestPublicKey(userKey)
|
||||
6. Decrypts userKey
|
||||
7. Establishes trust (if required)
|
||||
8. Proceeds to vault
|
||||
|
||||
## Admin Auth Request Flow
|
||||
|
||||
### Flow: Authed SSO TD user requests admin approval
|
||||
|
||||
1. SSO TD user authenticates via SSO
|
||||
2. Navigates to /login-initiated
|
||||
3. Clicks "Request admin approval"
|
||||
4. Navigates to /admin-approval-requested which creates an AdminAuthRequest
|
||||
5. Receives approval from device with authRequestPublicKey(userKey)
|
||||
6. Decrypts userKey
|
||||
7. Establishes trust (if required)
|
||||
8. Proceeds to vault
|
||||
|
||||
**Note:** TDE users are required to be enrolled in admin account recovery, which gives the admin access to the user's
|
||||
userKey. This is how admins are able to send over the authRequestPublicKey(userKey) to the user to allow them to unlock.
|
||||
|
||||
## Summary Table
|
||||
|
||||
| Flow | Auth Status | Clicks Button [active route] | Navigates to | Approving device has masterKey in memory\* |
|
||||
| --------------- | ----------- | --------------------------------------------------- | ------------------------- | ------------------------------------------------- |
|
||||
| Standard Flow 1 | unauthed | "Login with device" [/login] | /login-with-device | yes |
|
||||
| Standard Flow 2 | unauthed | "Login with device" [/login] | /login-with-device | no |
|
||||
| Standard Flow 3 | authed | "Approve from your other device" [/login-initiated] | /login-with-device | yes |
|
||||
| Standard Flow 4 | authed | "Approve from your other device" [/login-initiated] | /login-with-device | no |
|
||||
| Admin Flow | authed | "Request admin approval" [/login-initiated] | /admin-approval-requested | NA - admin requests always send encrypted userKey |
|
||||
|
||||
**Note:** The phrase "in memory" here is important. It is possible for a user to have a master password for their
|
||||
account, but not have a masterKey IN MEMORY for a specific device. For example, if a user registers an account with a
|
||||
master password, then joins an SSO TD org, then logs in to a device via SSO and admin auth request, they are now logged
|
||||
into that device but that device does not have masterKey IN MEMORY.
|
||||
|
||||
## State Management
|
||||
|
||||
### View Cache
|
||||
|
||||
The component uses `LoginViaAuthRequestCacheService` to manage persistent state across extension close and reopen.
|
||||
This cache stores:
|
||||
|
||||
- Auth Request ID
|
||||
- Private Key
|
||||
- Access Code
|
||||
|
||||
The cache is used to:
|
||||
|
||||
1. Preserve authentication state during extension close
|
||||
2. Allow resumption of pending auth requests
|
||||
3. Enable processing of approved requests after extension close and reopen.
|
||||
|
||||
### Component State Variables
|
||||
|
||||
Key state variables maintained during the authentication process:
|
||||
|
||||
#### Authentication Keys
|
||||
|
||||
```
|
||||
private authRequestKeyPair: {
|
||||
publicKey: Uint8Array | undefined;
|
||||
privateKey: Uint8Array | undefined;
|
||||
} | undefined
|
||||
```
|
||||
|
||||
- Stores the RSA key pair used for secure communication
|
||||
- Generated during auth request initialization
|
||||
- Required for decrypting approved auth responses
|
||||
|
||||
#### Access Code
|
||||
|
||||
```
|
||||
private accessCode: string | undefined
|
||||
```
|
||||
|
||||
- 25-character generated password
|
||||
- Used for retrieving auth responses when user is not authenticated
|
||||
- Required for standard auth flows
|
||||
|
||||
#### Authentication Status
|
||||
|
||||
```
|
||||
private authStatus: AuthenticationStatus | undefined
|
||||
```
|
||||
|
||||
- Tracks whether user is authenticated via SSO
|
||||
- Determines available flows and API endpoints
|
||||
- Affects navigation paths (`/login` vs `/login-initiated`)
|
||||
|
||||
#### Flow Control
|
||||
|
||||
```
|
||||
protected flow = Flow.StandardAuthRequest
|
||||
```
|
||||
|
||||
- Determines current authentication flow (Standard vs Admin)
|
||||
- Affects UI rendering and request handling
|
||||
- Set based on route and authentication state
|
||||
|
||||
### State Flow Examples
|
||||
|
||||
#### Standard Auth Request Cache Flow
|
||||
|
||||
1. User initiates login with device
|
||||
2. Component generates auth request and keys
|
||||
3. Cache service stores:
|
||||
```
|
||||
cacheLoginView(
|
||||
authRequestResponse.id,
|
||||
authRequestKeyPair.privateKey,
|
||||
accessCode
|
||||
)
|
||||
```
|
||||
4. On page refresh/revisit:
|
||||
- Component retrieves cached view
|
||||
- Reestablishes connection using cached credentials
|
||||
- Continues monitoring for approval
|
||||
|
||||
#### Admin Auth Request State Flow
|
||||
|
||||
1. User requests admin approval
|
||||
2. Component stores admin request in `AuthRequestService`:
|
||||
```
|
||||
setAdminAuthRequest(
|
||||
new AdminAuthRequestStorable({
|
||||
id: authRequestResponse.id,
|
||||
privateKey: authRequestKeyPair.privateKey
|
||||
}),
|
||||
userId
|
||||
)
|
||||
```
|
||||
3. On subsequent visits:
|
||||
- Component checks for existing admin requests
|
||||
- Either resumes monitoring or starts new request
|
||||
- Clears state after successful approval
|
||||
|
||||
### State Cleanup
|
||||
|
||||
State cleanup occurs in several scenarios:
|
||||
|
||||
- Component destruction (`ngOnDestroy`)
|
||||
- Successful authentication
|
||||
- Request denial or timeout
|
||||
- Manual navigation away
|
||||
|
||||
Key cleanup actions:
|
||||
|
||||
1. Hub connection termination
|
||||
2. Cache clearance
|
||||
3. Admin request state removal
|
||||
4. Key pair disposal
|
||||
@@ -1,57 +1,65 @@
|
||||
<div class="tw-text-center">
|
||||
<ng-container *ngIf="flow === Flow.StandardAuthRequest">
|
||||
<p *ngIf="clientType !== ClientType.Web">
|
||||
{{ "notificationSentDevicePart1" | i18n }}
|
||||
<a
|
||||
bitLink
|
||||
linkType="primary"
|
||||
class="tw-cursor-pointer"
|
||||
[href]="deviceManagementUrl"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>{{ "notificationSentDeviceAnchor" | i18n }}</a
|
||||
>. {{ "notificationSentDevicePart2" | i18n }}
|
||||
</p>
|
||||
<p *ngIf="clientType === ClientType.Web">
|
||||
{{ "notificationSentDeviceComplete" | i18n }}
|
||||
</p>
|
||||
<ng-container *ngIf="loading">
|
||||
<div class="tw-flex tw-items-center tw-justify-center">
|
||||
<i class="bwi bwi-spinner bwi-spin bwi-3x" aria-hidden="true"></i>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<div class="tw-font-semibold">{{ "fingerprintPhraseHeader" | i18n }}</div>
|
||||
<code class="tw-text-code">{{ fingerprintPhrase }}</code>
|
||||
<ng-container *ngIf="!loading">
|
||||
<div class="tw-text-center">
|
||||
<ng-container *ngIf="flow === Flow.StandardAuthRequest">
|
||||
<p *ngIf="clientType !== ClientType.Web">
|
||||
{{ "notificationSentDevicePart1" | i18n }}
|
||||
<a
|
||||
bitLink
|
||||
linkType="primary"
|
||||
class="tw-cursor-pointer"
|
||||
[href]="deviceManagementUrl"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>{{ "notificationSentDeviceAnchor" | i18n }}</a
|
||||
>. {{ "notificationSentDevicePart2" | i18n }}
|
||||
</p>
|
||||
<p *ngIf="clientType === ClientType.Web">
|
||||
{{ "notificationSentDeviceComplete" | i18n }}
|
||||
</p>
|
||||
|
||||
<button
|
||||
*ngIf="showResendNotification"
|
||||
type="button"
|
||||
bitButton
|
||||
block
|
||||
buttonType="secondary"
|
||||
class="tw-mt-4"
|
||||
(click)="startStandardAuthRequestLogin(true)"
|
||||
>
|
||||
{{ "resendNotification" | i18n }}
|
||||
</button>
|
||||
<div class="tw-font-semibold">{{ "fingerprintPhraseHeader" | i18n }}</div>
|
||||
<code class="tw-text-code">{{ fingerprintPhrase }}</code>
|
||||
|
||||
<div *ngIf="clientType !== ClientType.Browser" class="tw-mt-4">
|
||||
<span>{{ "needAnotherOptionV1" | i18n }}</span
|
||||
>
|
||||
<a [routerLink]="backToRoute" bitLink linkType="primary">{{
|
||||
"viewAllLogInOptions" | i18n
|
||||
}}</a>
|
||||
</div>
|
||||
</ng-container>
|
||||
<button
|
||||
*ngIf="showResendNotification"
|
||||
type="button"
|
||||
bitButton
|
||||
block
|
||||
buttonType="secondary"
|
||||
class="tw-mt-4"
|
||||
(click)="handleNewStandardAuthRequestLogin()"
|
||||
>
|
||||
{{ "resendNotification" | i18n }}
|
||||
</button>
|
||||
|
||||
<ng-container *ngIf="flow === Flow.AdminAuthRequest">
|
||||
<p>{{ "youWillBeNotifiedOnceTheRequestIsApproved" | i18n }}</p>
|
||||
<div *ngIf="clientType !== ClientType.Browser" class="tw-mt-4">
|
||||
<span>{{ "needAnotherOptionV1" | i18n }}</span
|
||||
>
|
||||
<a [routerLink]="backToRoute" bitLink linkType="primary">{{
|
||||
"viewAllLogInOptions" | i18n
|
||||
}}</a>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<div class="tw-font-semibold">{{ "fingerprintPhraseHeader" | i18n }}</div>
|
||||
<code class="tw-text-code">{{ fingerprintPhrase }}</code>
|
||||
<ng-container *ngIf="flow === Flow.AdminAuthRequest">
|
||||
<p>{{ "youWillBeNotifiedOnceTheRequestIsApproved" | i18n }}</p>
|
||||
|
||||
<div class="tw-mt-4">
|
||||
<span>{{ "troubleLoggingIn" | i18n }}</span
|
||||
>
|
||||
<a [routerLink]="backToRoute" bitLink linkType="primary">{{
|
||||
"viewAllLogInOptions" | i18n
|
||||
}}</a>
|
||||
</div>
|
||||
</ng-container>
|
||||
</div>
|
||||
<div class="tw-font-semibold">{{ "fingerprintPhraseHeader" | i18n }}</div>
|
||||
<code class="tw-text-code">{{ fingerprintPhrase }}</code>
|
||||
|
||||
<div class="tw-mt-4">
|
||||
<span>{{ "troubleLoggingIn" | i18n }}</span
|
||||
>
|
||||
<a [routerLink]="backToRoute" bitLink linkType="primary">{{
|
||||
"viewAllLogInOptions" | i18n
|
||||
}}</a>
|
||||
</div>
|
||||
</ng-container>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
@@ -62,12 +62,13 @@ const matchOptions: IsActiveMatchOptions = {
|
||||
providers: [{ provide: LoginViaAuthRequestCacheService }],
|
||||
})
|
||||
export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
|
||||
private authRequest: AuthRequest | undefined = undefined;
|
||||
private authRequestKeyPair:
|
||||
| { publicKey: Uint8Array | undefined; privateKey: Uint8Array | undefined }
|
||||
| undefined = undefined;
|
||||
private accessCode: string | undefined = undefined;
|
||||
private authStatus: AuthenticationStatus | undefined = undefined;
|
||||
private showResendNotificationTimeoutSeconds = 12;
|
||||
protected loading = true;
|
||||
|
||||
protected backToRoute = "/login";
|
||||
protected clientType: ClientType;
|
||||
@@ -110,13 +111,14 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
|
||||
this.authRequestService.authRequestPushNotification$
|
||||
.pipe(takeUntilDestroyed())
|
||||
.subscribe((requestId) => {
|
||||
this.verifyAndHandleApprovedAuthReq(requestId).catch((e: Error) => {
|
||||
this.loading = true;
|
||||
this.handleExistingAuthRequestLogin(requestId).catch((e: Error) => {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: this.i18nService.t("error"),
|
||||
message: e.message,
|
||||
});
|
||||
|
||||
this.loading = false;
|
||||
this.logService.error("Failed to use approved auth request: " + e.message);
|
||||
});
|
||||
});
|
||||
@@ -149,24 +151,12 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
|
||||
} else {
|
||||
await this.initStandardAuthRequestFlow();
|
||||
}
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
private async initAdminAuthRequestFlow(): Promise<void> {
|
||||
this.flow = Flow.AdminAuthRequest;
|
||||
|
||||
// Get email from state for admin auth requests because it is available and also
|
||||
// prevents it from being lost on refresh as the loginEmailService email does not persist.
|
||||
this.email = await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(map((a) => a?.email)),
|
||||
);
|
||||
|
||||
if (!this.email) {
|
||||
await this.handleMissingEmail();
|
||||
return;
|
||||
}
|
||||
|
||||
// We only allow a single admin approval request to be active at a time
|
||||
// so we must check state to see if we have an existing one or not
|
||||
const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
|
||||
if (!userId) {
|
||||
this.logService.error(
|
||||
@@ -175,12 +165,13 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
const existingAdminAuthRequest = await this.authRequestService.getAdminAuthRequest(userId);
|
||||
// [Admin Request Flow State Management] Check cached auth request
|
||||
const existingAdminAuthRequest = await this.reloadCachedAdminAuthRequest(userId);
|
||||
|
||||
if (existingAdminAuthRequest) {
|
||||
await this.handleExistingAdminAuthRequest(existingAdminAuthRequest, userId);
|
||||
await this.handleExistingAdminAuthRequestLogin(existingAdminAuthRequest, userId);
|
||||
} else {
|
||||
await this.startAdminAuthRequestLogin();
|
||||
await this.handleNewAdminAuthRequestLogin();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,7 +185,24 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.startStandardAuthRequestLogin();
|
||||
// [Standard Flow State Management] Check cached auth request
|
||||
const cachedAuthRequest: LoginViaAuthRequestView | null =
|
||||
this.loginViaAuthRequestCacheService.getCachedLoginViaAuthRequestView();
|
||||
|
||||
if (cachedAuthRequest) {
|
||||
this.logService.info("Found cached auth request.");
|
||||
if (!cachedAuthRequest.id) {
|
||||
this.logService.error(
|
||||
"No id on the cached auth request when in the standard auth request flow.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.reloadCachedStandardAuthRequest(cachedAuthRequest);
|
||||
await this.handleExistingAuthRequestLogin(cachedAuthRequest.id);
|
||||
} else {
|
||||
await this.handleNewStandardAuthRequestLogin();
|
||||
}
|
||||
}
|
||||
|
||||
private async handleMissingEmail(): Promise<void> {
|
||||
@@ -212,11 +220,17 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
|
||||
this.loginViaAuthRequestCacheService.clearCacheLoginView();
|
||||
}
|
||||
|
||||
private async startAdminAuthRequestLogin(): Promise<void> {
|
||||
private async handleNewAdminAuthRequestLogin(): Promise<void> {
|
||||
try {
|
||||
await this.buildAuthRequest(AuthRequestType.AdminApproval);
|
||||
if (!this.email) {
|
||||
this.logService.error("No email when starting admin auth request login.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.authRequest) {
|
||||
// At this point we know there is no
|
||||
const authRequest = await this.buildAuthRequest(this.email, AuthRequestType.AdminApproval);
|
||||
|
||||
if (!authRequest) {
|
||||
this.logService.error("Auth request failed to build.");
|
||||
return;
|
||||
}
|
||||
@@ -226,9 +240,9 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
const authRequestResponse = await this.authRequestApiService.postAdminAuthRequest(
|
||||
this.authRequest as AuthRequest,
|
||||
);
|
||||
const authRequestResponse =
|
||||
await this.authRequestApiService.postAdminAuthRequest(authRequest);
|
||||
|
||||
const adminAuthReqStorable = new AdminAuthRequestStorable({
|
||||
id: authRequestResponse.id,
|
||||
privateKey: this.authRequestKeyPair.privateKey,
|
||||
@@ -253,104 +267,154 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
protected async startStandardAuthRequestLogin(
|
||||
clearCachedRequest: boolean = false,
|
||||
/**
|
||||
* We only allow a single admin approval request to be active at a time
|
||||
* so we can check to see if it's stored in state with the state service
|
||||
* provider.
|
||||
* @param userId
|
||||
* @protected
|
||||
*/
|
||||
protected async reloadCachedAdminAuthRequest(
|
||||
userId: UserId,
|
||||
): Promise<AdminAuthRequestStorable | null> {
|
||||
// Get email from state for admin auth requests because it is available and also
|
||||
// prevents it from being lost on refresh as the loginEmailService email does not persist.
|
||||
this.email = await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(map((a) => a?.email)),
|
||||
);
|
||||
|
||||
if (!this.email) {
|
||||
await this.handleMissingEmail();
|
||||
return null;
|
||||
}
|
||||
|
||||
return await this.authRequestService.getAdminAuthRequest(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores a cached authentication request into the component's state.
|
||||
*
|
||||
* This function checks for the presence of a cached authentication request and,
|
||||
* if available, updates the component's state with the necessary details to
|
||||
* continue processing the request. It ensures that the user's email and the
|
||||
* private key from the cached request are available.
|
||||
*
|
||||
* The private key is converted from Base64 to an ArrayBuffer, and a fingerprint
|
||||
* phrase is derived to verify the request's integrity. The function then sets
|
||||
* the authentication request key pair in the component's state, preparing it
|
||||
* to handle any responses or approvals.
|
||||
*
|
||||
* @param cachedAuthRequest The request to load into the component state
|
||||
* @returns Promise to await for completion
|
||||
*/
|
||||
protected async reloadCachedStandardAuthRequest(
|
||||
cachedAuthRequest: LoginViaAuthRequestView,
|
||||
): Promise<void> {
|
||||
if (cachedAuthRequest) {
|
||||
if (!this.email) {
|
||||
this.logService.error(
|
||||
"Email not defined when trying to reload cached standard auth request.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!cachedAuthRequest.privateKey) {
|
||||
this.logService.error(
|
||||
"No private key on the cached auth request when trying to reload cached standard auth request.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!cachedAuthRequest.accessCode) {
|
||||
this.logService.error(
|
||||
"No access code on the cached auth request when trying to reload cached standard auth request.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const privateKey = Utils.fromB64ToArray(cachedAuthRequest.privateKey);
|
||||
|
||||
// Re-derive the user's fingerprint phrase
|
||||
// It is important to not use the server's public key here as it could have been compromised via MITM
|
||||
const derivedPublicKeyArrayBuffer =
|
||||
await this.cryptoFunctionService.rsaExtractPublicKey(privateKey);
|
||||
|
||||
this.fingerprintPhrase = await this.authRequestService.getFingerprintPhrase(
|
||||
this.email,
|
||||
derivedPublicKeyArrayBuffer,
|
||||
);
|
||||
|
||||
// We don't need the public key for handling the authentication request because
|
||||
// the handleExistingAuthRequestLogin function will receive the public key back
|
||||
// from the looked up auth request, and all we need is to make sure that
|
||||
// we can use the cached private key that is associated with it.
|
||||
this.authRequestKeyPair = {
|
||||
privateKey: privateKey,
|
||||
publicKey: undefined,
|
||||
};
|
||||
|
||||
this.accessCode = cachedAuthRequest.accessCode;
|
||||
}
|
||||
}
|
||||
|
||||
protected async handleNewStandardAuthRequestLogin(): Promise<void> {
|
||||
this.showResendNotification = false;
|
||||
|
||||
if (await this.configService.getFeatureFlag(FeatureFlag.PM9112_DeviceApprovalPersistence)) {
|
||||
// Used for manually refreshing the auth request when clicking the resend auth request
|
||||
// on the ui.
|
||||
if (clearCachedRequest) {
|
||||
this.loginViaAuthRequestCacheService.clearCacheLoginView();
|
||||
try {
|
||||
if (!this.email) {
|
||||
this.logService.error("Email not defined when starting standard auth request login.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const loginAuthRequestView: LoginViaAuthRequestView | null =
|
||||
this.loginViaAuthRequestCacheService.getCachedLoginViaAuthRequestView();
|
||||
const authRequest = await this.buildAuthRequest(
|
||||
this.email,
|
||||
AuthRequestType.AuthenticateAndUnlock,
|
||||
);
|
||||
|
||||
if (!loginAuthRequestView) {
|
||||
await this.buildAuthRequest(AuthRequestType.AuthenticateAndUnlock);
|
||||
|
||||
// I tried several ways to get the IDE/linter to play nice with checking for null values
|
||||
// in less code / more efficiently, but it struggles to identify code paths that
|
||||
// are more complicated than this.
|
||||
if (!this.authRequest) {
|
||||
this.logService.error("AuthRequest failed to initialize from buildAuthRequest.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.fingerprintPhrase) {
|
||||
this.logService.error("FingerprintPhrase failed to initialize from buildAuthRequest.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.authRequestKeyPair) {
|
||||
this.logService.error("KeyPair failed to initialize from buildAuthRequest.");
|
||||
return;
|
||||
}
|
||||
|
||||
const authRequestResponse: AuthRequestResponse =
|
||||
await this.authRequestApiService.postAuthRequest(this.authRequest);
|
||||
|
||||
this.loginViaAuthRequestCacheService.cacheLoginView(
|
||||
this.authRequest,
|
||||
authRequestResponse,
|
||||
this.fingerprintPhrase,
|
||||
this.authRequestKeyPair,
|
||||
);
|
||||
|
||||
if (authRequestResponse.id) {
|
||||
await this.anonymousHubService.createHubConnection(authRequestResponse.id);
|
||||
}
|
||||
} else {
|
||||
// Grab the cached information and store it back in component state.
|
||||
// We don't need the public key for handling the authentication request because
|
||||
// the verifyAndHandleApprovedAuthReq function will receive the public key back
|
||||
// from the looked up auth request and all we need is to make sure that
|
||||
// we can use the cached private key that is associated with it.
|
||||
this.authRequest = loginAuthRequestView.authRequest;
|
||||
this.fingerprintPhrase = loginAuthRequestView.fingerprintPhrase;
|
||||
this.authRequestKeyPair = {
|
||||
privateKey: loginAuthRequestView.privateKey
|
||||
? Utils.fromB64ToArray(loginAuthRequestView.privateKey)
|
||||
: undefined,
|
||||
publicKey: undefined,
|
||||
};
|
||||
|
||||
if (!loginAuthRequestView.authRequestResponse) {
|
||||
this.logService.error("No cached auth request response.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (loginAuthRequestView.authRequestResponse.id) {
|
||||
await this.anonymousHubService.createHubConnection(
|
||||
loginAuthRequestView.authRequestResponse.id,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
this.logService.error(e);
|
||||
// I tried several ways to get the IDE/linter to play nice with checking for null values
|
||||
// in less code / more efficiently, but it struggles to identify code paths that
|
||||
// are more complicated than this.
|
||||
if (!authRequest) {
|
||||
this.logService.error("AuthRequest failed to initialize from buildAuthRequest.");
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await this.buildAuthRequest(AuthRequestType.AuthenticateAndUnlock);
|
||||
|
||||
if (!this.authRequest) {
|
||||
this.logService.error("No auth request found.");
|
||||
if (!this.fingerprintPhrase) {
|
||||
this.logService.error("FingerprintPhrase failed to initialize from buildAuthRequest.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.authRequestKeyPair) {
|
||||
this.logService.error("KeyPair failed to initialize from buildAuthRequest.");
|
||||
return;
|
||||
}
|
||||
|
||||
const authRequestResponse: AuthRequestResponse =
|
||||
await this.authRequestApiService.postAuthRequest(authRequest);
|
||||
|
||||
if (await this.configService.getFeatureFlag(FeatureFlag.PM9112_DeviceApprovalPersistence)) {
|
||||
if (!this.authRequestKeyPair.privateKey) {
|
||||
this.logService.error("No private key when trying to cache the login view.");
|
||||
return;
|
||||
}
|
||||
|
||||
const authRequestResponse = await this.authRequestApiService.postAuthRequest(
|
||||
this.authRequest,
|
||||
);
|
||||
|
||||
if (authRequestResponse.id) {
|
||||
await this.anonymousHubService.createHubConnection(authRequestResponse.id);
|
||||
if (!this.accessCode) {
|
||||
this.logService.error("No access code when trying to cache the login view.");
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
this.logService.error(e);
|
||||
|
||||
this.loginViaAuthRequestCacheService.cacheLoginView(
|
||||
authRequestResponse.id,
|
||||
this.authRequestKeyPair.privateKey,
|
||||
this.accessCode,
|
||||
);
|
||||
}
|
||||
|
||||
if (authRequestResponse.id) {
|
||||
await this.anonymousHubService.createHubConnection(authRequestResponse.id);
|
||||
}
|
||||
} catch (e) {
|
||||
this.logService.error(e);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
@@ -358,7 +422,10 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
|
||||
}, this.showResendNotificationTimeoutSeconds * 1000);
|
||||
}
|
||||
|
||||
private async buildAuthRequest(authRequestType: AuthRequestType): Promise<void> {
|
||||
private async buildAuthRequest(
|
||||
email: string,
|
||||
authRequestType: AuthRequestType,
|
||||
): Promise<AuthRequest> {
|
||||
const authRequestKeyPairArray = await this.cryptoFunctionService.rsaGenerateKeyPair(2048);
|
||||
|
||||
this.authRequestKeyPair = {
|
||||
@@ -369,36 +436,27 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
|
||||
const deviceIdentifier = await this.appIdService.getAppId();
|
||||
|
||||
if (!this.authRequestKeyPair.publicKey) {
|
||||
this.logService.error("AuthRequest public key not set to value in building auth request.");
|
||||
return;
|
||||
const errorMessage = "No public key when building an auth request.";
|
||||
this.logService.error(errorMessage);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
const publicKey = Utils.fromBufferToB64(this.authRequestKeyPair.publicKey);
|
||||
const accessCode = await this.passwordGenerationService.generatePassword({
|
||||
this.fingerprintPhrase = await this.authRequestService.getFingerprintPhrase(
|
||||
email,
|
||||
this.authRequestKeyPair.publicKey,
|
||||
);
|
||||
|
||||
this.accessCode = await this.passwordGenerationService.generatePassword({
|
||||
type: "password",
|
||||
length: 25,
|
||||
});
|
||||
|
||||
if (!this.email) {
|
||||
this.logService.error("Email not defined when building auth request.");
|
||||
return;
|
||||
}
|
||||
const b64PublicKey = Utils.fromBufferToB64(this.authRequestKeyPair.publicKey);
|
||||
|
||||
this.fingerprintPhrase = await this.authRequestService.getFingerprintPhrase(
|
||||
this.email,
|
||||
this.authRequestKeyPair.publicKey,
|
||||
);
|
||||
|
||||
this.authRequest = new AuthRequest(
|
||||
this.email,
|
||||
deviceIdentifier,
|
||||
publicKey,
|
||||
authRequestType,
|
||||
accessCode,
|
||||
);
|
||||
return new AuthRequest(email, deviceIdentifier, b64PublicKey, authRequestType, this.accessCode);
|
||||
}
|
||||
|
||||
private async handleExistingAdminAuthRequest(
|
||||
private async handleExistingAdminAuthRequestLogin(
|
||||
adminAuthRequestStorable: AdminAuthRequestStorable,
|
||||
userId: UserId,
|
||||
): Promise<void> {
|
||||
@@ -414,7 +472,7 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof ErrorResponse && error.statusCode === HttpStatusCode.NotFound) {
|
||||
return await this.handleExistingAdminAuthReqDeletedOrDenied(userId);
|
||||
return await this.clearExistingAdminAuthRequestAndStartNewRequest(userId);
|
||||
}
|
||||
this.logService.error(error);
|
||||
return;
|
||||
@@ -422,28 +480,12 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
|
||||
|
||||
// Request doesn't exist anymore
|
||||
if (!adminAuthRequestResponse) {
|
||||
return await this.handleExistingAdminAuthReqDeletedOrDenied(userId);
|
||||
return await this.clearExistingAdminAuthRequestAndStartNewRequest(userId);
|
||||
}
|
||||
|
||||
// Re-derive the user's fingerprint phrase
|
||||
// It is important to not use the server's public key here as it could have been compromised via MITM
|
||||
const derivedPublicKeyArrayBuffer = await this.cryptoFunctionService.rsaExtractPublicKey(
|
||||
adminAuthRequestStorable.privateKey,
|
||||
);
|
||||
|
||||
if (!this.email) {
|
||||
this.logService.error("Email not defined when handling an existing an admin auth request.");
|
||||
return;
|
||||
}
|
||||
|
||||
this.fingerprintPhrase = await this.authRequestService.getFingerprintPhrase(
|
||||
this.email,
|
||||
derivedPublicKeyArrayBuffer,
|
||||
);
|
||||
|
||||
// Request denied
|
||||
if (adminAuthRequestResponse.isAnswered && !adminAuthRequestResponse.requestApproved) {
|
||||
return await this.handleExistingAdminAuthReqDeletedOrDenied(userId);
|
||||
return await this.clearExistingAdminAuthRequestAndStartNewRequest(userId);
|
||||
}
|
||||
|
||||
// Request approved
|
||||
@@ -455,6 +497,22 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
|
||||
);
|
||||
}
|
||||
|
||||
if (!this.email) {
|
||||
this.logService.error("Email not defined when handling an existing an admin auth request.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Re-derive the user's fingerprint phrase
|
||||
// It is important to not use the server's public key here as it could have been compromised via MITM
|
||||
const derivedPublicKeyArrayBuffer = await this.cryptoFunctionService.rsaExtractPublicKey(
|
||||
adminAuthRequestStorable.privateKey,
|
||||
);
|
||||
|
||||
this.fingerprintPhrase = await this.authRequestService.getFingerprintPhrase(
|
||||
this.email,
|
||||
derivedPublicKeyArrayBuffer,
|
||||
);
|
||||
|
||||
// Request still pending response from admin set keypair and create hub connection
|
||||
// so that any approvals will be received via push notification
|
||||
this.authRequestKeyPair = {
|
||||
@@ -464,117 +522,99 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
|
||||
await this.anonymousHubService.createHubConnection(adminAuthRequestStorable.id);
|
||||
}
|
||||
|
||||
private async verifyAndHandleApprovedAuthReq(requestId: string): Promise<void> {
|
||||
/**
|
||||
* ***********************************
|
||||
* Standard Auth Request Flows
|
||||
* ***********************************
|
||||
*
|
||||
* Flow 1: Unauthed user requests approval from device; Approving device has a masterKey in memory.
|
||||
*
|
||||
* Unauthed user clicks "Login with device" > navigates to /login-with-device which creates a StandardAuthRequest
|
||||
* > receives approval from a device with authRequestPublicKey(masterKey) > decrypts masterKey > decrypts userKey > proceed to vault
|
||||
*
|
||||
* Flow 2: Unauthed user requests approval from device; Approving device does NOT have a masterKey in memory.
|
||||
*
|
||||
* Unauthed user clicks "Login with device" > navigates to /login-with-device which creates a StandardAuthRequest
|
||||
* > receives approval from a device with authRequestPublicKey(userKey) > decrypts userKey > proceeds to vault
|
||||
*
|
||||
* Note: this flow is an uncommon scenario and relates to TDE off-boarding. The following describes how a user could get into this flow:
|
||||
* 1) An SSO TD user logs into a device via an Admin auth request approval, therefore this device does NOT have a masterKey in memory.
|
||||
* 2) The org admin...
|
||||
* (2a) Changes the member decryption options from "Trusted devices" to "Master password" AND
|
||||
* (2b) Turns off the "Require single sign-on authentication" policy
|
||||
* 3) On another device, the user clicks "Login with device", which they can do because the org no longer requires SSO.
|
||||
* 4) The user approves from the device they had previously logged into with SSO TD, which does NOT have a masterKey in memory (see step 1 above).
|
||||
*
|
||||
* Flow 3: Authed SSO TD user requests approval from device; Approving device has a masterKey in memory.
|
||||
*
|
||||
* SSO TD user authenticates via SSO > navigates to /login-initiated > clicks "Approve from your other device"
|
||||
* > navigates to /login-with-device which creates a StandardAuthRequest > receives approval from device with authRequestPublicKey(masterKey)
|
||||
* > decrypts masterKey > decrypts userKey > establishes trust (if required) > proceeds to vault
|
||||
*
|
||||
* Flow 4: Authed SSO TD user requests approval from device; Approving device does NOT have a masterKey in memory.
|
||||
*
|
||||
* SSO TD user authenticates via SSO > navigates to /login-initiated > clicks "Approve from your other device"
|
||||
* > navigates to /login-with-device which creates a StandardAuthRequest > receives approval from device with authRequestPublicKey(userKey)
|
||||
* > decrypts userKey > establishes trust (if required) > proceeds to vault
|
||||
*
|
||||
* ***********************************
|
||||
* Admin Auth Request Flow
|
||||
* ***********************************
|
||||
*
|
||||
* Flow: Authed SSO TD user requests admin approval.
|
||||
*
|
||||
* SSO TD user authenticates via SSO > navigates to /login-initiated > clicks "Request admin approval"
|
||||
* > navigates to /admin-approval-requested which creates an AdminAuthRequest > receives approval from device with authRequestPublicKey(userKey)
|
||||
* > decrypts userKey > establishes trust (if required) > proceeds to vault
|
||||
*
|
||||
* Note: TDE users are required to be enrolled in admin password reset, which gives the admin access to the user's userKey.
|
||||
* This is how admins are able to send over the authRequestPublicKey(userKey) to the user to allow them to unlock.
|
||||
*
|
||||
*
|
||||
* Summary Table
|
||||
* |-------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
* | Flow | Auth Status | Clicks Button [active route] | Navigates to | Approving device has masterKey in memory (see note 1) |
|
||||
* |-------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
* | Standard Flow 1 | unauthed | "Login with device" [/login] | /login-with-device | yes |
|
||||
* | Standard Flow 2 | unauthed | "Login with device" [/login] | /login-with-device | no |
|
||||
* | Standard Flow 3 | authed | "Approve from your other device" [/login-initiated] | /login-with-device | yes |
|
||||
* | Standard Flow 4 | authed | "Approve from your other device" [/login-initiated] | /login-with-device | no |
|
||||
* | Admin Flow | authed | "Request admin approval" [/login-initiated] | /admin-approval-requested | NA - admin requests always send encrypted userKey |
|
||||
* |-------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
* * Note 1: The phrase "in memory" here is important. It is possible for a user to have a master password for their account, but not have a masterKey IN MEMORY for
|
||||
* a specific device. For example, if a user registers an account with a master password, then joins an SSO TD org, then logs in to a device via SSO and
|
||||
* admin auth request, they are now logged into that device but that device does not have masterKey IN MEMORY.
|
||||
*/
|
||||
|
||||
/**
|
||||
* This is used for trying to get the auth request back out of state.
|
||||
* @param requestId
|
||||
* @private
|
||||
*/
|
||||
private async retrieveAuthRequest(requestId: string): Promise<AuthRequestResponse> {
|
||||
let authRequestResponse: AuthRequestResponse | undefined = undefined;
|
||||
try {
|
||||
// There are two cases here, the first being
|
||||
const userHasAuthenticatedViaSSO = this.authStatus === AuthenticationStatus.Locked;
|
||||
|
||||
// Get the response based on whether we've authenticated or not. We need to call a different API method
|
||||
// based on whether we have a token or need to use the accessCode.
|
||||
if (userHasAuthenticatedViaSSO) {
|
||||
// Get the auth request from the server
|
||||
// User is authenticated, therefore the endpoint does not require an access code.
|
||||
const authRequestResponse = await this.authRequestApiService.getAuthRequest(requestId);
|
||||
|
||||
if (authRequestResponse.requestApproved) {
|
||||
// Handles Standard Flows 3-4 and Admin Flow
|
||||
await this.handleAuthenticatedFlows(authRequestResponse);
|
||||
}
|
||||
authRequestResponse = await this.authRequestApiService.getAuthRequest(requestId);
|
||||
} else {
|
||||
if (!this.authRequest) {
|
||||
this.logService.error("No auth request defined when handling approved auth request.");
|
||||
return;
|
||||
if (!this.accessCode) {
|
||||
const errorMessage = "No access code available when handling approved auth request.";
|
||||
this.logService.error(errorMessage);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
// Get the auth request from the server
|
||||
// User is unauthenticated, therefore the endpoint requires an access code for user verification.
|
||||
const authRequestResponse = await this.authRequestApiService.getAuthResponse(
|
||||
authRequestResponse = await this.authRequestApiService.getAuthResponse(
|
||||
requestId,
|
||||
this.authRequest.accessCode,
|
||||
this.accessCode,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
// If the request no longer exists, we treat it as if it's been answered (and denied).
|
||||
if (error instanceof ErrorResponse && error.statusCode === HttpStatusCode.NotFound) {
|
||||
authRequestResponse = undefined;
|
||||
} else {
|
||||
this.logService.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
if (authRequestResponse.requestApproved) {
|
||||
// Handles Standard Flows 1-2
|
||||
await this.handleUnauthenticatedFlows(authRequestResponse, requestId);
|
||||
if (authRequestResponse === undefined) {
|
||||
throw new Error("Auth request response not generated");
|
||||
}
|
||||
|
||||
return authRequestResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if the Auth Request has been approved, deleted or denied, and handles
|
||||
* the response accordingly.
|
||||
* @param requestId The ID of the Auth Request to process
|
||||
* @returns A boolean indicating whether the Auth Request was successfully processed
|
||||
*/
|
||||
private async handleExistingAuthRequestLogin(requestId: string): Promise<void> {
|
||||
this.showResendNotification = false;
|
||||
|
||||
try {
|
||||
const authRequestResponse = await this.retrieveAuthRequest(requestId);
|
||||
|
||||
// Request doesn't exist anymore, so we'll clear the cache and start a new request.
|
||||
if (!authRequestResponse) {
|
||||
return await this.clearExistingStandardAuthRequestAndStartNewRequest();
|
||||
}
|
||||
|
||||
// Request denied, so we'll clear the cache and start a new request.
|
||||
if (authRequestResponse.isAnswered && !authRequestResponse.requestApproved) {
|
||||
return await this.clearExistingStandardAuthRequestAndStartNewRequest();
|
||||
}
|
||||
|
||||
// Request approved, so we'll log the user in.
|
||||
if (authRequestResponse.requestApproved) {
|
||||
const userHasAuthenticatedViaSSO = this.authStatus === AuthenticationStatus.Locked;
|
||||
if (userHasAuthenticatedViaSSO) {
|
||||
// [Standard Flow 3-4] Handle authenticated SSO TD user flows
|
||||
return await this.handleAuthenticatedFlows(authRequestResponse);
|
||||
} else {
|
||||
// [Standard Flow 1-2] Handle unauthenticated user flows
|
||||
return await this.handleUnauthenticatedFlows(authRequestResponse, requestId);
|
||||
}
|
||||
}
|
||||
|
||||
// At this point, we know that the request is still pending, so we'll start a hub connection to listen for a response.
|
||||
await this.anonymousHubService.createHubConnection(requestId);
|
||||
} catch (error) {
|
||||
if (error instanceof ErrorResponse) {
|
||||
await this.router.navigate([this.backToRoute]);
|
||||
this.validationService.showError(error);
|
||||
return;
|
||||
}
|
||||
|
||||
this.logService.error(error);
|
||||
} finally {
|
||||
// Manually clean out the cache to make sure sensitive
|
||||
// data does not persist longer than it needs to.
|
||||
this.loginViaAuthRequestCacheService.clearCacheLoginView();
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
this.showResendNotification = true;
|
||||
}, this.showResendNotificationTimeoutSeconds * 1000);
|
||||
}
|
||||
|
||||
private async handleAuthenticatedFlows(authRequestResponse: AuthRequestResponse) {
|
||||
// [Standard Flow 3-4] Handle authenticated SSO TD user flows
|
||||
const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
|
||||
if (!userId) {
|
||||
this.logService.error(
|
||||
@@ -599,6 +639,7 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
|
||||
authRequestResponse: AuthRequestResponse,
|
||||
requestId: string,
|
||||
) {
|
||||
// [Standard Flow 1-2] Handle unauthenticated user flows
|
||||
const authRequestLoginCredentials = await this.buildAuthRequestLoginCredentials(
|
||||
requestId,
|
||||
authRequestResponse,
|
||||
@@ -609,6 +650,9 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear the cached auth request from state since we're using it to log in.
|
||||
this.loginViaAuthRequestCacheService.clearCacheLoginView();
|
||||
|
||||
// Note: keys are set by AuthRequestLoginStrategy success handling
|
||||
const authResult = await this.loginStrategyService.logIn(authRequestLoginCredentials);
|
||||
|
||||
@@ -621,21 +665,20 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
|
||||
userId: UserId,
|
||||
): Promise<void> {
|
||||
/**
|
||||
* See verifyAndHandleApprovedAuthReq() for flow details.
|
||||
*
|
||||
* [Flow Type Detection]
|
||||
* We determine the type of `key` based on the presence or absence of `masterPasswordHash`:
|
||||
* - If `masterPasswordHash` has a value, we receive the `key` as an authRequestPublicKey(masterKey) [plus we have authRequestPublicKey(masterPasswordHash)]
|
||||
* - If `masterPasswordHash` does not have a value, we receive the `key` as an authRequestPublicKey(userKey)
|
||||
* - If `masterPasswordHash` exists: Standard Flow 1 or 3 (device has masterKey)
|
||||
* - If no `masterPasswordHash`: Standard Flow 2, 4, or Admin Flow (device sends userKey)
|
||||
*/
|
||||
if (authRequestResponse.masterPasswordHash) {
|
||||
// ...in Standard Auth Request Flow 3
|
||||
// [Standard Flow 1 or 3] Device has masterKey
|
||||
await this.authRequestService.setKeysAfterDecryptingSharedMasterKeyAndHash(
|
||||
authRequestResponse,
|
||||
privateKey,
|
||||
userId,
|
||||
);
|
||||
} else {
|
||||
// ...in Standard Auth Request Flow 4 or Admin Auth Request Flow
|
||||
// [Standard Flow 2, 4, or Admin Flow] Device sends userKey
|
||||
await this.authRequestService.setUserKeyAfterDecryptingSharedUserKey(
|
||||
authRequestResponse,
|
||||
privateKey,
|
||||
@@ -643,15 +686,20 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
|
||||
);
|
||||
}
|
||||
|
||||
// [Admin Flow Cleanup] Clear one-time use admin auth request
|
||||
// clear the admin auth request from state so it cannot be used again (it's a one time use)
|
||||
// TODO: this should eventually be enforced via deleting this on the server once it is used
|
||||
await this.authRequestService.clearAdminAuthRequest(userId);
|
||||
|
||||
// [Standard Flow Cleanup] Clear the cached auth request from state
|
||||
this.loginViaAuthRequestCacheService.clearCacheLoginView();
|
||||
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
message: this.i18nService.t("loginApproved"),
|
||||
});
|
||||
|
||||
// [Device Trust] Establish trust if required
|
||||
// Now that we have a decrypted user key in memory, we can check if we
|
||||
// need to establish trust on the current device
|
||||
const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
|
||||
@@ -686,9 +734,9 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.authRequest) {
|
||||
if (!this.accessCode) {
|
||||
this.logService.error(
|
||||
"AuthRequest not defined when building auth request login credentials.",
|
||||
"Access code not defined when building auth request login credentials.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -711,7 +759,7 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
|
||||
|
||||
return new AuthRequestLoginCredentials(
|
||||
this.email,
|
||||
this.authRequest.accessCode,
|
||||
this.accessCode,
|
||||
requestId,
|
||||
null, // no userKey
|
||||
masterKey,
|
||||
@@ -725,7 +773,7 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
|
||||
);
|
||||
return new AuthRequestLoginCredentials(
|
||||
this.email,
|
||||
this.authRequest.accessCode,
|
||||
this.accessCode,
|
||||
requestId,
|
||||
userKey,
|
||||
null, // no masterKey
|
||||
@@ -734,12 +782,20 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
private async handleExistingAdminAuthReqDeletedOrDenied(userId: UserId) {
|
||||
private async clearExistingAdminAuthRequestAndStartNewRequest(userId: UserId) {
|
||||
// clear the admin auth request from state
|
||||
await this.authRequestService.clearAdminAuthRequest(userId);
|
||||
|
||||
// start new auth request
|
||||
await this.startAdminAuthRequestLogin();
|
||||
await this.handleNewAdminAuthRequestLogin();
|
||||
}
|
||||
|
||||
private async clearExistingStandardAuthRequestAndStartNewRequest(): Promise<void> {
|
||||
// clear the auth request from state
|
||||
this.loginViaAuthRequestCacheService.clearCacheLoginView();
|
||||
|
||||
// start new auth request
|
||||
await this.handleNewStandardAuthRequestLogin();
|
||||
}
|
||||
|
||||
private async handlePostLoginNavigation(loginResponse: AuthResult) {
|
||||
|
||||
@@ -2,11 +2,9 @@ import { signal } from "@angular/core";
|
||||
import { TestBed } from "@angular/core/testing";
|
||||
|
||||
import { ViewCacheService } from "@bitwarden/angular/platform/abstractions/view-cache.service";
|
||||
import { AuthRequestType } from "@bitwarden/common/auth/enums/auth-request-type";
|
||||
import { AuthRequest } from "@bitwarden/common/auth/models/request/auth.request";
|
||||
import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response";
|
||||
import { LoginViaAuthRequestView } from "@bitwarden/common/auth/models/view/login-via-auth-request.view";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
|
||||
import { LoginViaAuthRequestCacheService } from "./default-login-via-auth-request-cache.service";
|
||||
|
||||
@@ -39,12 +37,12 @@ describe("LoginViaAuthRequestCache", () => {
|
||||
});
|
||||
|
||||
it("`getCachedLoginViaAuthRequestView` returns the cached data", async () => {
|
||||
cacheSignal.set({ ...buildAuthenticMockAuthView() });
|
||||
cacheSignal.set({ ...buildMockState() });
|
||||
service = testBed.inject(LoginViaAuthRequestCacheService);
|
||||
await service.init();
|
||||
|
||||
expect(service.getCachedLoginViaAuthRequestView()).toEqual({
|
||||
...buildAuthenticMockAuthView(),
|
||||
...buildMockState(),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -54,20 +52,19 @@ describe("LoginViaAuthRequestCache", () => {
|
||||
|
||||
const parameters = buildAuthenticMockAuthView();
|
||||
|
||||
service.cacheLoginView(
|
||||
parameters.authRequest,
|
||||
parameters.authRequestResponse,
|
||||
parameters.fingerprintPhrase,
|
||||
{ publicKey: new Uint8Array(), privateKey: new Uint8Array() },
|
||||
);
|
||||
service.cacheLoginView(parameters.id, parameters.privateKey, parameters.accessCode);
|
||||
|
||||
expect(cacheSignal.set).toHaveBeenCalledWith(parameters);
|
||||
expect(cacheSignal.set).toHaveBeenCalledWith({
|
||||
id: parameters.id,
|
||||
privateKey: Utils.fromBufferToB64(parameters.privateKey),
|
||||
accessCode: parameters.accessCode,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("feature disabled", () => {
|
||||
beforeEach(async () => {
|
||||
cacheSignal.set({ ...buildAuthenticMockAuthView() } as LoginViaAuthRequestView);
|
||||
cacheSignal.set({ ...buildMockState() } as LoginViaAuthRequestView);
|
||||
getFeatureFlag.mockResolvedValue(false);
|
||||
cacheSetMock.mockClear();
|
||||
|
||||
@@ -82,12 +79,7 @@ describe("LoginViaAuthRequestCache", () => {
|
||||
it("does not update the signal value", () => {
|
||||
const params = buildAuthenticMockAuthView();
|
||||
|
||||
service.cacheLoginView(
|
||||
params.authRequest,
|
||||
params.authRequestResponse,
|
||||
params.fingerprintPhrase,
|
||||
{ publicKey: new Uint8Array(), privateKey: new Uint8Array() },
|
||||
);
|
||||
service.cacheLoginView(params.id, params.privateKey, params.accessCode);
|
||||
|
||||
expect(cacheSignal.set).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -95,17 +87,17 @@ describe("LoginViaAuthRequestCache", () => {
|
||||
|
||||
const buildAuthenticMockAuthView = () => {
|
||||
return {
|
||||
fingerprintPhrase: "",
|
||||
privateKey: "",
|
||||
publicKey: "",
|
||||
authRequest: new AuthRequest(
|
||||
"test@gmail.com",
|
||||
"deviceIdentifier",
|
||||
"publicKey",
|
||||
AuthRequestType.Unlock,
|
||||
"accessCode",
|
||||
),
|
||||
authRequestResponse: new AuthRequestResponse({}),
|
||||
id: "testId",
|
||||
privateKey: new Uint8Array(),
|
||||
accessCode: "testAccessCode",
|
||||
};
|
||||
};
|
||||
|
||||
const buildMockState = () => {
|
||||
return {
|
||||
id: "testId",
|
||||
privateKey: Utils.fromBufferToB64(new Uint8Array()),
|
||||
accessCode: "testAccessCode",
|
||||
};
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { inject, Injectable, WritableSignal } from "@angular/core";
|
||||
|
||||
import { ViewCacheService } from "@bitwarden/angular/platform/abstractions/view-cache.service";
|
||||
import { AuthRequest } from "@bitwarden/common/auth/models/request/auth.request";
|
||||
import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response";
|
||||
import { LoginViaAuthRequestView } from "@bitwarden/common/auth/models/view/login-via-auth-request.view";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
@@ -45,12 +43,7 @@ export class LoginViaAuthRequestCacheService {
|
||||
/**
|
||||
* Update the cache with the new LoginView.
|
||||
*/
|
||||
cacheLoginView(
|
||||
authRequest: AuthRequest,
|
||||
authRequestResponse: AuthRequestResponse,
|
||||
fingerprintPhrase: string,
|
||||
keys: { privateKey: Uint8Array | undefined; publicKey: Uint8Array | undefined },
|
||||
): void {
|
||||
cacheLoginView(id: string, privateKey: Uint8Array, accessCode: string): void {
|
||||
if (!this.featureEnabled) {
|
||||
return;
|
||||
}
|
||||
@@ -59,11 +52,9 @@ export class LoginViaAuthRequestCacheService {
|
||||
// data can be properly formed when json-ified. If not done, they are not stored properly and
|
||||
// will not be parsable by the cryptography library after coming out of storage.
|
||||
this.defaultLoginViaAuthRequestCache.set({
|
||||
authRequest,
|
||||
authRequestResponse,
|
||||
fingerprintPhrase,
|
||||
privateKey: keys.privateKey ? Utils.fromBufferToB64(keys.privateKey.buffer) : undefined,
|
||||
publicKey: keys.publicKey ? Utils.fromBufferToB64(keys.publicKey.buffer) : undefined,
|
||||
id: id,
|
||||
privateKey: Utils.fromBufferToB64(privateKey.buffer),
|
||||
accessCode: accessCode,
|
||||
} as LoginViaAuthRequestView);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { AuthRequest } from "@bitwarden/common/auth/models/request/auth.request";
|
||||
import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response";
|
||||
import { View } from "@bitwarden/common/models/view/view";
|
||||
|
||||
export class LoginViaAuthRequestView implements View {
|
||||
authRequest: AuthRequest | undefined = undefined;
|
||||
authRequestResponse: AuthRequestResponse | undefined = undefined;
|
||||
fingerprintPhrase: string | undefined = undefined;
|
||||
id: string | undefined = undefined;
|
||||
accessCode: string | undefined = undefined;
|
||||
privateKey: string | undefined = undefined;
|
||||
publicKey: string | undefined = undefined;
|
||||
|
||||
static fromJSON(obj: Partial<Jsonify<LoginViaAuthRequestView>>): LoginViaAuthRequestView {
|
||||
static fromJSON(obj: Partial<Jsonify<LoginViaAuthRequestView>>): LoginViaAuthRequestView | null {
|
||||
if (obj == null) {
|
||||
return null;
|
||||
}
|
||||
return Object.assign(new LoginViaAuthRequestView(), obj);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,15 +68,18 @@ export class DefaultBillingAccountProfileStateService implements BillingAccountP
|
||||
this.hasPremiumFromAnyOrganization$(userId),
|
||||
]).pipe(
|
||||
concatMap(async ([hasPremiumPersonally, hasPremiumFromOrg]) => {
|
||||
const isCloud = !this.platformUtilsService.isSelfHost();
|
||||
|
||||
let billing = null;
|
||||
if (isCloud) {
|
||||
billing = await this.apiService.getUserBillingHistory();
|
||||
if (hasPremiumPersonally === true || !hasPremiumFromOrg === true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const cloudAndBillingHistory = isCloud && !billing?.hasNoHistory;
|
||||
return hasPremiumPersonally || !hasPremiumFromOrg || cloudAndBillingHistory;
|
||||
const isCloud = !this.platformUtilsService.isSelfHost();
|
||||
|
||||
if (isCloud) {
|
||||
const billing = await this.apiService.getUserBillingHistory();
|
||||
return !billing?.hasNoHistory;
|
||||
}
|
||||
|
||||
return false;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,4 +26,6 @@ export enum NotificationType {
|
||||
SyncOrganizationCollectionSettingChanged = 19,
|
||||
Notification = 20,
|
||||
NotificationStatus = 21,
|
||||
|
||||
PendingSecurityTasks = 22,
|
||||
}
|
||||
|
||||
@@ -105,14 +105,14 @@ export abstract class CoreSyncService implements SyncService {
|
||||
if (remoteFolder != null) {
|
||||
await this.folderService.upsert(new FolderData(remoteFolder), userId);
|
||||
this.messageSender.send("syncedUpsertedFolder", { folderId: notification.id });
|
||||
return this.syncCompleted(true);
|
||||
return this.syncCompleted(true, userId);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
this.logService.error(e);
|
||||
}
|
||||
}
|
||||
return this.syncCompleted(false);
|
||||
return this.syncCompleted(false, userId);
|
||||
}
|
||||
|
||||
async syncDeleteFolder(notification: SyncFolderNotification, userId: UserId): Promise<boolean> {
|
||||
@@ -123,10 +123,10 @@ export abstract class CoreSyncService implements SyncService {
|
||||
if (authStatus >= AuthenticationStatus.Locked) {
|
||||
await this.folderService.delete(notification.id, userId);
|
||||
this.messageSender.send("syncedDeletedFolder", { folderId: notification.id });
|
||||
this.syncCompleted(true);
|
||||
this.syncCompleted(true, userId);
|
||||
return true;
|
||||
}
|
||||
return this.syncCompleted(false);
|
||||
return this.syncCompleted(false, userId);
|
||||
}
|
||||
|
||||
async syncUpsertCipher(
|
||||
@@ -183,18 +183,18 @@ export abstract class CoreSyncService implements SyncService {
|
||||
if (remoteCipher != null) {
|
||||
await this.cipherService.upsert(new CipherData(remoteCipher));
|
||||
this.messageSender.send("syncedUpsertedCipher", { cipherId: notification.id });
|
||||
return this.syncCompleted(true);
|
||||
return this.syncCompleted(true, userId);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (e != null && e.statusCode === 404 && isEdit) {
|
||||
await this.cipherService.delete(notification.id, userId);
|
||||
this.messageSender.send("syncedDeletedCipher", { cipherId: notification.id });
|
||||
return this.syncCompleted(true);
|
||||
return this.syncCompleted(true, userId);
|
||||
}
|
||||
}
|
||||
}
|
||||
return this.syncCompleted(false);
|
||||
return this.syncCompleted(false, userId);
|
||||
}
|
||||
|
||||
async syncDeleteCipher(notification: SyncCipherNotification, userId: UserId): Promise<boolean> {
|
||||
@@ -204,9 +204,9 @@ export abstract class CoreSyncService implements SyncService {
|
||||
if (authStatus >= AuthenticationStatus.Locked) {
|
||||
await this.cipherService.delete(notification.id, userId);
|
||||
this.messageSender.send("syncedDeletedCipher", { cipherId: notification.id });
|
||||
return this.syncCompleted(true);
|
||||
return this.syncCompleted(true, userId);
|
||||
}
|
||||
return this.syncCompleted(false);
|
||||
return this.syncCompleted(false, userId);
|
||||
}
|
||||
|
||||
async syncUpsertSend(notification: SyncSendNotification, isEdit: boolean): Promise<boolean> {
|
||||
@@ -234,14 +234,15 @@ export abstract class CoreSyncService implements SyncService {
|
||||
if (remoteSend != null) {
|
||||
await this.sendService.upsert(new SendData(remoteSend));
|
||||
this.messageSender.send("syncedUpsertedSend", { sendId: notification.id });
|
||||
return this.syncCompleted(true);
|
||||
return this.syncCompleted(true, activeUserId);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
this.logService.error(e);
|
||||
}
|
||||
}
|
||||
return this.syncCompleted(false);
|
||||
// TODO: Update syncCompleted userId when send service allows modification of non-active users
|
||||
return this.syncCompleted(false, undefined);
|
||||
}
|
||||
|
||||
async syncDeleteSend(notification: SyncSendNotification): Promise<boolean> {
|
||||
@@ -249,10 +250,11 @@ export abstract class CoreSyncService implements SyncService {
|
||||
if (await this.stateService.getIsAuthenticated()) {
|
||||
await this.sendService.delete(notification.id);
|
||||
this.messageSender.send("syncedDeletedSend", { sendId: notification.id });
|
||||
this.syncCompleted(true);
|
||||
// TODO: Update syncCompleted userId when send service allows modification of non-active users
|
||||
this.syncCompleted(true, undefined);
|
||||
return true;
|
||||
}
|
||||
return this.syncCompleted(false);
|
||||
return this.syncCompleted(false, undefined);
|
||||
}
|
||||
|
||||
// Helpers
|
||||
@@ -262,9 +264,9 @@ export abstract class CoreSyncService implements SyncService {
|
||||
this.messageSender.send("syncStarted");
|
||||
}
|
||||
|
||||
protected syncCompleted(successfully: boolean): boolean {
|
||||
protected syncCompleted(successfully: boolean, userId: UserId | undefined): boolean {
|
||||
this.syncInProgress = false;
|
||||
this.messageSender.send("syncCompleted", { successfully: successfully });
|
||||
this.messageSender.send("syncCompleted", { successfully: successfully, userId });
|
||||
return successfully;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
import { firstValueFrom, map } from "rxjs";
|
||||
|
||||
import {
|
||||
CollectionService,
|
||||
CollectionData,
|
||||
CollectionDetailsResponse,
|
||||
CollectionService,
|
||||
} from "@bitwarden/admin-console/common";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
|
||||
@@ -107,7 +107,7 @@ export class DefaultSyncService extends CoreSyncService {
|
||||
this.syncStarted();
|
||||
const authStatus = await firstValueFrom(this.authService.authStatusFor$(userId));
|
||||
if (authStatus === AuthenticationStatus.LoggedOut) {
|
||||
return this.syncCompleted(false);
|
||||
return this.syncCompleted(false, userId);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
@@ -116,14 +116,14 @@ export class DefaultSyncService extends CoreSyncService {
|
||||
needsSync = await this.needsSyncing(forceSync);
|
||||
} catch (e) {
|
||||
if (allowThrowOnError) {
|
||||
this.syncCompleted(false);
|
||||
this.syncCompleted(false, userId);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
if (!needsSync) {
|
||||
await this.setLastSync(now, userId);
|
||||
return this.syncCompleted(false);
|
||||
return this.syncCompleted(false, userId);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -139,13 +139,13 @@ export class DefaultSyncService extends CoreSyncService {
|
||||
await this.syncPolicies(response.policies, response.profile.id);
|
||||
|
||||
await this.setLastSync(now, userId);
|
||||
return this.syncCompleted(true);
|
||||
return this.syncCompleted(true, userId);
|
||||
} catch (e) {
|
||||
if (allowThrowOnError) {
|
||||
this.syncCompleted(false);
|
||||
this.syncCompleted(false, userId);
|
||||
throw e;
|
||||
} else {
|
||||
return this.syncCompleted(false);
|
||||
return this.syncCompleted(false, userId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Observable } from "rxjs";
|
||||
import { Observable, Subscription } from "rxjs";
|
||||
|
||||
import { SecurityTaskId, UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
@@ -43,4 +43,9 @@ export abstract class TaskService {
|
||||
* @param userId - The user who is completing the task.
|
||||
*/
|
||||
abstract markAsComplete(taskId: SecurityTaskId, userId: UserId): Promise<void>;
|
||||
|
||||
/**
|
||||
* Creates a subscription for pending security task notifications or completed syncs for unlocked users.
|
||||
*/
|
||||
abstract listenForTaskNotifications(): Subscription;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { BehaviorSubject, firstValueFrom } from "rxjs";
|
||||
import { BehaviorSubject, firstValueFrom, Subject } from "rxjs";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||
import { NotificationType } from "@bitwarden/common/enums";
|
||||
import { NotificationResponse } from "@bitwarden/common/models/response/notification.response";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { Message, MessageListener } from "@bitwarden/common/platform/messaging";
|
||||
import { NotificationsService } from "@bitwarden/common/platform/notifications";
|
||||
import { SecurityTaskId, UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { FakeStateProvider, mockAccountServiceWith } from "../../../../spec";
|
||||
@@ -16,10 +22,13 @@ import { DefaultTaskService } from "./default-task.service";
|
||||
describe("Default task service", () => {
|
||||
let fakeStateProvider: FakeStateProvider;
|
||||
|
||||
const userId = "user-id" as UserId;
|
||||
const mockApiSend = jest.fn();
|
||||
const mockGetAllOrgs$ = jest.fn();
|
||||
const mockGetFeatureFlag$ = jest.fn();
|
||||
|
||||
const mockAuthStatuses$ = new BehaviorSubject<Record<UserId, AuthenticationStatus>>({});
|
||||
const mockNotifications$ = new Subject<readonly [NotificationResponse, UserId]>();
|
||||
const mockMessages$ = new Subject<Message<Record<string, unknown>>>();
|
||||
let service: DefaultTaskService;
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -27,12 +36,15 @@ describe("Default task service", () => {
|
||||
mockGetAllOrgs$.mockClear();
|
||||
mockGetFeatureFlag$.mockClear();
|
||||
|
||||
fakeStateProvider = new FakeStateProvider(mockAccountServiceWith("user-id" as UserId));
|
||||
fakeStateProvider = new FakeStateProvider(mockAccountServiceWith(userId));
|
||||
service = new DefaultTaskService(
|
||||
fakeStateProvider,
|
||||
{ send: mockApiSend } as unknown as ApiService,
|
||||
{ organizations$: mockGetAllOrgs$ } as unknown as OrganizationService,
|
||||
{ getFeatureFlag$: mockGetFeatureFlag$ } as unknown as ConfigService,
|
||||
{ authStatuses$: mockAuthStatuses$.asObservable() } as unknown as AuthService,
|
||||
{ notifications$: mockNotifications$.asObservable() } as unknown as NotificationsService,
|
||||
{ allMessages$: mockMessages$.asObservable() } as unknown as MessageListener,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -257,4 +269,235 @@ describe("Default task service", () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("listenForTaskNotifications()", () => {
|
||||
it("should not subscribe to notifications when there are no unlocked users", () => {
|
||||
mockAuthStatuses$.next({
|
||||
[userId]: AuthenticationStatus.Locked,
|
||||
});
|
||||
|
||||
service.tasksEnabled$ = jest.fn(() => new BehaviorSubject(true));
|
||||
const notificationHelper$ = (service["securityTaskNotifications$"] = jest.fn());
|
||||
const syncCompletedHelper$ = (service["syncCompletedMessage$"] = jest.fn());
|
||||
|
||||
const subscription = service.listenForTaskNotifications();
|
||||
|
||||
expect(notificationHelper$).not.toHaveBeenCalled();
|
||||
expect(syncCompletedHelper$).not.toHaveBeenCalled();
|
||||
subscription.unsubscribe();
|
||||
});
|
||||
|
||||
it("should not subscribe to notifications when no users have tasks enabled", () => {
|
||||
mockAuthStatuses$.next({
|
||||
[userId]: AuthenticationStatus.Unlocked,
|
||||
});
|
||||
|
||||
service.tasksEnabled$ = jest.fn(() => new BehaviorSubject(false));
|
||||
const notificationHelper$ = (service["securityTaskNotifications$"] = jest.fn());
|
||||
const syncCompletedHelper$ = (service["syncCompletedMessage$"] = jest.fn());
|
||||
|
||||
const subscription = service.listenForTaskNotifications();
|
||||
|
||||
expect(notificationHelper$).not.toHaveBeenCalled();
|
||||
expect(syncCompletedHelper$).not.toHaveBeenCalled();
|
||||
subscription.unsubscribe();
|
||||
});
|
||||
|
||||
it("should subscribe to notifications when there are unlocked users with tasks enabled", () => {
|
||||
mockAuthStatuses$.next({
|
||||
[userId]: AuthenticationStatus.Unlocked,
|
||||
});
|
||||
service.tasksEnabled$ = jest.fn(() => new BehaviorSubject(true));
|
||||
|
||||
const notificationHelper$ = (service["securityTaskNotifications$"] = jest.fn());
|
||||
const syncCompletedHelper$ = (service["syncCompletedMessage$"] = jest.fn());
|
||||
|
||||
const subscription = service.listenForTaskNotifications();
|
||||
|
||||
expect(notificationHelper$).toHaveBeenCalled();
|
||||
expect(syncCompletedHelper$).toHaveBeenCalled();
|
||||
subscription.unsubscribe();
|
||||
});
|
||||
|
||||
describe("notification handling", () => {
|
||||
it("should refresh tasks when a notification is received for an allowed user", async () => {
|
||||
mockAuthStatuses$.next({
|
||||
[userId]: AuthenticationStatus.Unlocked,
|
||||
});
|
||||
service.tasksEnabled$ = jest.fn(() => new BehaviorSubject(true));
|
||||
|
||||
const syncCompletedHelper$ = (service["syncCompletedMessage$"] = jest.fn(
|
||||
() => new Subject(),
|
||||
));
|
||||
const refreshTasks = jest.spyOn(service, "refreshTasks");
|
||||
|
||||
const subscription = service.listenForTaskNotifications();
|
||||
|
||||
const notification = {
|
||||
type: NotificationType.PendingSecurityTasks,
|
||||
} as NotificationResponse;
|
||||
mockNotifications$.next([notification, userId]);
|
||||
|
||||
await new Promise(process.nextTick);
|
||||
|
||||
expect(syncCompletedHelper$).toHaveBeenCalled();
|
||||
expect(refreshTasks).toHaveBeenCalledWith(userId);
|
||||
subscription.unsubscribe();
|
||||
});
|
||||
|
||||
it("should ignore notifications for other users", async () => {
|
||||
mockAuthStatuses$.next({
|
||||
[userId]: AuthenticationStatus.Unlocked,
|
||||
});
|
||||
service.tasksEnabled$ = jest.fn(() => new BehaviorSubject(true));
|
||||
|
||||
const syncCompletedHelper$ = (service["syncCompletedMessage$"] = jest.fn(
|
||||
() => new Subject(),
|
||||
));
|
||||
const refreshTasks = jest.spyOn(service, "refreshTasks");
|
||||
|
||||
const subscription = service.listenForTaskNotifications();
|
||||
|
||||
const notification = {
|
||||
type: NotificationType.PendingSecurityTasks,
|
||||
} as NotificationResponse;
|
||||
mockNotifications$.next([notification, "other-user-id" as UserId]);
|
||||
|
||||
await new Promise(process.nextTick);
|
||||
|
||||
expect(syncCompletedHelper$).toHaveBeenCalled();
|
||||
expect(refreshTasks).not.toHaveBeenCalledWith(userId);
|
||||
subscription.unsubscribe();
|
||||
});
|
||||
|
||||
it("should ignore other notifications types", async () => {
|
||||
mockAuthStatuses$.next({
|
||||
[userId]: AuthenticationStatus.Unlocked,
|
||||
});
|
||||
service.tasksEnabled$ = jest.fn(() => new BehaviorSubject(true));
|
||||
|
||||
const syncCompletedHelper$ = (service["syncCompletedMessage$"] = jest.fn(
|
||||
() => new Subject(),
|
||||
));
|
||||
const refreshTasks = jest.spyOn(service, "refreshTasks");
|
||||
|
||||
const subscription = service.listenForTaskNotifications();
|
||||
|
||||
const notification = {
|
||||
type: NotificationType.SyncSettings,
|
||||
} as NotificationResponse;
|
||||
mockNotifications$.next([notification, userId]);
|
||||
|
||||
await new Promise(process.nextTick);
|
||||
|
||||
expect(syncCompletedHelper$).toHaveBeenCalled();
|
||||
expect(refreshTasks).not.toHaveBeenCalledWith(userId);
|
||||
subscription.unsubscribe();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sync completed handling", () => {
|
||||
it("should refresh tasks when a sync completed message is received for an allowed user", async () => {
|
||||
mockAuthStatuses$.next({
|
||||
[userId]: AuthenticationStatus.Unlocked,
|
||||
});
|
||||
service.tasksEnabled$ = jest.fn(() => new BehaviorSubject(true));
|
||||
|
||||
const notificationHelper$ = (service["securityTaskNotifications$"] = jest.fn(
|
||||
() => new Subject(),
|
||||
));
|
||||
const refreshTasks = jest.spyOn(service, "refreshTasks");
|
||||
|
||||
const subscription = service.listenForTaskNotifications();
|
||||
|
||||
mockMessages$.next({
|
||||
command: "syncCompleted",
|
||||
userId,
|
||||
successfully: true,
|
||||
});
|
||||
|
||||
await new Promise(process.nextTick);
|
||||
|
||||
expect(notificationHelper$).toHaveBeenCalled();
|
||||
expect(refreshTasks).toHaveBeenCalledWith(userId);
|
||||
subscription.unsubscribe();
|
||||
});
|
||||
|
||||
it("should ignore non syncCompleted messages", async () => {
|
||||
mockAuthStatuses$.next({
|
||||
[userId]: AuthenticationStatus.Unlocked,
|
||||
});
|
||||
service.tasksEnabled$ = jest.fn(() => new BehaviorSubject(true));
|
||||
|
||||
const notificationHelper$ = (service["securityTaskNotifications$"] = jest.fn(
|
||||
() => new Subject(),
|
||||
));
|
||||
const refreshTasks = jest.spyOn(service, "refreshTasks");
|
||||
|
||||
const subscription = service.listenForTaskNotifications();
|
||||
|
||||
mockMessages$.next({
|
||||
command: "other-command",
|
||||
});
|
||||
|
||||
await new Promise(process.nextTick);
|
||||
|
||||
expect(notificationHelper$).toHaveBeenCalled();
|
||||
expect(refreshTasks).not.toHaveBeenCalledWith(userId);
|
||||
subscription.unsubscribe();
|
||||
});
|
||||
|
||||
it("should ignore failed sync messages", async () => {
|
||||
mockAuthStatuses$.next({
|
||||
[userId]: AuthenticationStatus.Unlocked,
|
||||
});
|
||||
service.tasksEnabled$ = jest.fn(() => new BehaviorSubject(true));
|
||||
|
||||
const notificationHelper$ = (service["securityTaskNotifications$"] = jest.fn(
|
||||
() => new Subject(),
|
||||
));
|
||||
const refreshTasks = jest.spyOn(service, "refreshTasks");
|
||||
|
||||
const subscription = service.listenForTaskNotifications();
|
||||
|
||||
mockMessages$.next({
|
||||
command: "syncCompleted",
|
||||
userId,
|
||||
successfully: false,
|
||||
});
|
||||
|
||||
await new Promise(process.nextTick);
|
||||
|
||||
expect(notificationHelper$).toHaveBeenCalled();
|
||||
expect(refreshTasks).not.toHaveBeenCalledWith(userId);
|
||||
subscription.unsubscribe();
|
||||
});
|
||||
|
||||
it("should ignore sync messages for other users", async () => {
|
||||
mockAuthStatuses$.next({
|
||||
[userId]: AuthenticationStatus.Unlocked,
|
||||
});
|
||||
service.tasksEnabled$ = jest.fn(() => new BehaviorSubject(true));
|
||||
|
||||
const notificationHelper$ = (service["securityTaskNotifications$"] = jest.fn(
|
||||
() => new Subject(),
|
||||
));
|
||||
const refreshTasks = jest.spyOn(service, "refreshTasks");
|
||||
|
||||
const subscription = service.listenForTaskNotifications();
|
||||
|
||||
mockMessages$.next({
|
||||
command: "syncCompleted",
|
||||
userId: "other-user-id" as UserId,
|
||||
successfully: true,
|
||||
});
|
||||
|
||||
await new Promise(process.nextTick);
|
||||
|
||||
expect(notificationHelper$).toHaveBeenCalled();
|
||||
expect(refreshTasks).not.toHaveBeenCalledWith(userId);
|
||||
subscription.unsubscribe();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { combineLatest, map, switchMap } from "rxjs";
|
||||
import { combineLatest, filter, map, merge, Observable, of, Subscription, switchMap } from "rxjs";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||
import { NotificationType } from "@bitwarden/common/enums";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ListResponse } from "@bitwarden/common/models/response/list.response";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { MessageListener } from "@bitwarden/common/platform/messaging";
|
||||
import { NotificationsService } from "@bitwarden/common/platform/notifications";
|
||||
import { StateProvider } from "@bitwarden/common/platform/state";
|
||||
import { SecurityTaskId, UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
@@ -14,12 +19,21 @@ import { SecurityTaskStatus } from "../enums";
|
||||
import { SecurityTask, SecurityTaskData, SecurityTaskResponse } from "../models";
|
||||
import { SECURITY_TASKS } from "../state/security-task.state";
|
||||
|
||||
const getUnlockedUserIds = map<Record<UserId, AuthenticationStatus>, UserId[]>((authStatuses) =>
|
||||
Object.entries(authStatuses ?? {})
|
||||
.filter(([, status]) => status >= AuthenticationStatus.Unlocked)
|
||||
.map(([userId]) => userId as UserId),
|
||||
);
|
||||
|
||||
export class DefaultTaskService implements TaskService {
|
||||
constructor(
|
||||
private stateProvider: StateProvider,
|
||||
private apiService: ApiService,
|
||||
private organizationService: OrganizationService,
|
||||
private configService: ConfigService,
|
||||
private authService: AuthService,
|
||||
private notificationService: NotificationsService,
|
||||
private messageListener: MessageListener,
|
||||
) {}
|
||||
|
||||
tasksEnabled$ = perUserCache$((userId) => {
|
||||
@@ -36,6 +50,7 @@ export class DefaultTaskService implements TaskService {
|
||||
switchMap(async (tasks) => {
|
||||
if (tasks == null) {
|
||||
await this.fetchTasksFromApi(userId);
|
||||
return null;
|
||||
}
|
||||
return tasks;
|
||||
}),
|
||||
@@ -97,4 +112,66 @@ export class DefaultTaskService implements TaskService {
|
||||
): Promise<SecurityTaskData[] | null> {
|
||||
return this.taskState(userId).update(() => tasks);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper observable that filters the list of unlocked user IDs to only those with tasks enabled.
|
||||
* @private
|
||||
*/
|
||||
private getOnlyTaskEnabledUsers = switchMap<UserId[], Observable<UserId[]>>((unlockedUserIds) => {
|
||||
if (unlockedUserIds.length === 0) {
|
||||
return of([]);
|
||||
}
|
||||
|
||||
return combineLatest(
|
||||
unlockedUserIds.map((userId) =>
|
||||
this.tasksEnabled$(userId).pipe(map((enabled) => (enabled ? userId : null))),
|
||||
),
|
||||
).pipe(map((userIds) => userIds.filter((userId) => userId !== null) as UserId[]));
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper observable that emits whenever a security task notification is received for a user in the provided list.
|
||||
* @private
|
||||
*/
|
||||
private securityTaskNotifications$(filterByUserIds: UserId[]) {
|
||||
return this.notificationService.notifications$.pipe(
|
||||
filter(
|
||||
([notification, userId]) =>
|
||||
notification.type === NotificationType.PendingSecurityTasks &&
|
||||
filterByUserIds.includes(userId),
|
||||
),
|
||||
map(([, userId]) => userId),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper observable that emits whenever a sync is completed for a user in the provided list.
|
||||
*/
|
||||
private syncCompletedMessage$(filterByUserIds: UserId[]) {
|
||||
return this.messageListener.allMessages$.pipe(
|
||||
filter((msg) => msg.command === "syncCompleted" && !!msg.successfully && !!msg.userId),
|
||||
map((msg) => msg.userId as UserId),
|
||||
filter((userId) => filterByUserIds.includes(userId)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a subscription for pending security task notifications or completed syncs for unlocked users.
|
||||
*/
|
||||
listenForTaskNotifications(): Subscription {
|
||||
return this.authService.authStatuses$
|
||||
.pipe(
|
||||
getUnlockedUserIds,
|
||||
this.getOnlyTaskEnabledUsers,
|
||||
filter((allowedUserIds) => allowedUserIds.length > 0),
|
||||
switchMap((allowedUserIds) =>
|
||||
merge(
|
||||
this.securityTaskNotifications$(allowedUserIds),
|
||||
this.syncCompletedMessage$(allowedUserIds),
|
||||
),
|
||||
),
|
||||
switchMap((userId) => this.refreshTasks(userId)),
|
||||
)
|
||||
.subscribe();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,14 @@ import { Component, ElementRef, HostBinding, Input } from "@angular/core";
|
||||
|
||||
import { FocusableElement } from "../shared/focusable-element";
|
||||
|
||||
export type BadgeVariant = "primary" | "secondary" | "success" | "danger" | "warning" | "info";
|
||||
export type BadgeVariant =
|
||||
| "primary"
|
||||
| "secondary"
|
||||
| "success"
|
||||
| "danger"
|
||||
| "warning"
|
||||
| "info"
|
||||
| "notification";
|
||||
|
||||
const styles: Record<BadgeVariant, string[]> = {
|
||||
primary: ["tw-bg-primary-100", "tw-border-primary-700", "!tw-text-primary-700"],
|
||||
@@ -14,6 +21,11 @@ const styles: Record<BadgeVariant, string[]> = {
|
||||
danger: ["tw-bg-danger-100", "tw-border-danger-700", "!tw-text-danger-700"],
|
||||
warning: ["tw-bg-warning-100", "tw-border-warning-700", "!tw-text-warning-700"],
|
||||
info: ["tw-bg-info-100", "tw-border-info-700", "!tw-text-info-700"],
|
||||
notification: [
|
||||
"tw-bg-notification-100",
|
||||
"tw-border-notification-600",
|
||||
"!tw-text-notification-600",
|
||||
],
|
||||
};
|
||||
|
||||
const hoverStyles: Record<BadgeVariant, string[]> = {
|
||||
@@ -27,6 +39,11 @@ const hoverStyles: Record<BadgeVariant, string[]> = {
|
||||
danger: ["hover:tw-bg-danger-600", "hover:tw-border-danger-600", "hover:!tw-text-contrast"],
|
||||
warning: ["hover:tw-bg-warning-600", "hover:tw-border-warning-600", "hover:!tw-text-black"],
|
||||
info: ["hover:tw-bg-info-600", "hover:tw-border-info-600", "hover:!tw-text-black"],
|
||||
notification: [
|
||||
"hover:tw-bg-notification-600",
|
||||
"hover:tw-border-notification-600",
|
||||
"hover:!tw-text-contrast",
|
||||
],
|
||||
};
|
||||
|
||||
@Component({
|
||||
|
||||
@@ -36,6 +36,7 @@ export const Variants: Story = {
|
||||
<button class="tw-mx-1" bitBadge variant="danger" [truncate]="truncate">Danger</button>
|
||||
<button class="tw-mx-1" bitBadge variant="warning" [truncate]="truncate">Warning</button>
|
||||
<button class="tw-mx-1" bitBadge variant="info" [truncate]="truncate">Info</button>
|
||||
<button class="tw-mx-1" bitBadge variant="notification" [truncate]="truncate">Notification</button>
|
||||
<br/><br/>
|
||||
<span class="tw-text-main tw-mx-1">Hover</span>
|
||||
<button class="tw-mx-1 tw-test-hover" bitBadge variant="primary" [truncate]="truncate">Primary</button>
|
||||
@@ -44,6 +45,7 @@ export const Variants: Story = {
|
||||
<button class="tw-mx-1 tw-test-hover" bitBadge variant="danger" [truncate]="truncate">Danger</button>
|
||||
<button class="tw-mx-1 tw-test-hover" bitBadge variant="warning" [truncate]="truncate">Warning</button>
|
||||
<button class="tw-mx-1 tw-test-hover" bitBadge variant="info" [truncate]="truncate">Info</button>
|
||||
<button class="tw-mx-1 tw-test-hover" bitBadge variant="notification" [truncate]="truncate">Notification</button>
|
||||
<br/><br/>
|
||||
<span class="tw-text-main tw-mx-1">Focus Visible</span>
|
||||
<button class="tw-mx-1 tw-test-focus-visible" bitBadge variant="primary" [truncate]="truncate">Primary</button>
|
||||
@@ -52,6 +54,7 @@ export const Variants: Story = {
|
||||
<button class="tw-mx-1 tw-test-focus-visible" bitBadge variant="danger" [truncate]="truncate">Danger</button>
|
||||
<button class="tw-mx-1 tw-test-focus-visible" bitBadge variant="warning" [truncate]="truncate">Warning</button>
|
||||
<button class="tw-mx-1 tw-test-focus-visible" bitBadge variant="info" [truncate]="truncate">Info</button>
|
||||
<button class="tw-mx-1 tw-test-focus-visible" bitBadge variant="notification" [truncate]="truncate">Notification</button>
|
||||
<br/><br/>
|
||||
<span class="tw-text-main tw-mx-1">Disabled</span>
|
||||
<button disabled class="tw-mx-1" bitBadge variant="primary" [truncate]="truncate">Primary</button>
|
||||
@@ -60,6 +63,7 @@ export const Variants: Story = {
|
||||
<button disabled class="tw-mx-1" bitBadge variant="danger" [truncate]="truncate">Danger</button>
|
||||
<button disabled class="tw-mx-1" bitBadge variant="warning" [truncate]="truncate">Warning</button>
|
||||
<button disabled class="tw-mx-1" bitBadge variant="info" [truncate]="truncate">Info</button>
|
||||
<button disabled class="tw-mx-1" bitBadge variant="notification" [truncate]="truncate">Notification</button>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
@@ -112,6 +116,13 @@ export const Info: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const Notification: Story = {
|
||||
...Primary,
|
||||
args: {
|
||||
variant: "notification",
|
||||
},
|
||||
};
|
||||
|
||||
export const Truncated: Story = {
|
||||
...Primary,
|
||||
args: {
|
||||
|
||||
@@ -165,7 +165,12 @@
|
||||
</ng-container>
|
||||
-->
|
||||
<ng-container
|
||||
*ngIf="format === 'chromecsv' || format === 'operacsv' || format === 'vivaldicsv'"
|
||||
*ngIf="
|
||||
format === 'chromecsv' ||
|
||||
format === 'operacsv' ||
|
||||
format === 'vivaldicsv' ||
|
||||
format === 'edgecsv'
|
||||
"
|
||||
>
|
||||
<span *ngIf="format !== 'chromecsv'">
|
||||
The process is exactly the same as importing from Google Chrome.
|
||||
|
||||
@@ -46,6 +46,7 @@ export const regularImportOptions = [
|
||||
{ id: "ascendocsv", name: "Ascendo DataVault (csv)" },
|
||||
{ id: "meldiumcsv", name: "Meldium (csv)" },
|
||||
{ id: "passkeepcsv", name: "PassKeep (csv)" },
|
||||
{ id: "edgecsv", name: "Edge (csv)" },
|
||||
{ id: "operacsv", name: "Opera (csv)" },
|
||||
{ id: "vivaldicsv", name: "Vivaldi (csv)" },
|
||||
{ id: "gnomejson", name: "GNOME Passwords and Keys/Seahorse (json)" },
|
||||
|
||||
@@ -239,6 +239,7 @@ export class ImportService implements ImportServiceAbstraction {
|
||||
return new PadlockCsvImporter();
|
||||
case "keepass2xml":
|
||||
return new KeePass2XmlImporter();
|
||||
case "edgecsv":
|
||||
case "chromecsv":
|
||||
case "operacsv":
|
||||
case "vivaldicsv":
|
||||
|
||||
@@ -4,3 +4,6 @@
|
||||
|
||||
export { LockComponent } from "./lock/components/lock.component";
|
||||
export { LockComponentService, UnlockOptions } from "./lock/services/lock-component.service";
|
||||
export { KeyRotationTrustInfoComponent } from "./key-rotation/key-rotation-trust-info.component";
|
||||
export { AccountRecoveryTrustComponent } from "./trust/account-recovery-trust.component";
|
||||
export { EmergencyAccessTrustComponent } from "./trust/emergency-access-trust.component";
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<bit-dialog dialogSize="large">
|
||||
<span bitDialogTitle>
|
||||
<strong> {{ "userkeyRotationDisclaimerTitle" | i18n }} </strong>
|
||||
</span>
|
||||
<span bitDialogContent>
|
||||
{{ "userkeyRotationDisclaimerDescription" | i18n }}
|
||||
<ul class="tw-mt-2 tw-mb-0 tw-pl-4">
|
||||
<li *ngIf="params.orgName != null">
|
||||
{{ "userkeyRotationDisclaimerAccountRecoveryOrgsText" | i18n: params.orgName }}
|
||||
</li>
|
||||
<li *ngIf="params.numberOfEmergencyAccessUsers > 0">
|
||||
{{
|
||||
"userkeyRotationDisclaimerEmergencyAccessText" | i18n: params.numberOfEmergencyAccessUsers
|
||||
}}
|
||||
</li>
|
||||
</ul>
|
||||
</span>
|
||||
<ng-container bitDialogFooter>
|
||||
<a bitButton target="_blank" rel="noreferrer" buttonType="primary" (click)="submit()">
|
||||
{{ "continue" | i18n }}
|
||||
</a>
|
||||
<button bitButton type="button" buttonType="secondary" bitDialogClose>
|
||||
{{ "close" | i18n }}
|
||||
</button>
|
||||
</ng-container>
|
||||
</bit-dialog>
|
||||
@@ -0,0 +1,58 @@
|
||||
import { DialogRef, DIALOG_DATA } from "@angular/cdk/dialog";
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, Inject } from "@angular/core";
|
||||
import { FormsModule, ReactiveFormsModule } from "@angular/forms";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import {
|
||||
AsyncActionsModule,
|
||||
ButtonModule,
|
||||
DialogModule,
|
||||
DialogService,
|
||||
} from "@bitwarden/components";
|
||||
|
||||
type KeyRotationTrustDialogData = {
|
||||
orgName?: string;
|
||||
numberOfEmergencyAccessUsers: number;
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: "key-rotation-trust-info",
|
||||
templateUrl: "key-rotation-trust-info.component.html",
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
JslibModule,
|
||||
DialogModule,
|
||||
ButtonModule,
|
||||
ReactiveFormsModule,
|
||||
AsyncActionsModule,
|
||||
FormsModule,
|
||||
],
|
||||
})
|
||||
export class KeyRotationTrustInfoComponent {
|
||||
constructor(
|
||||
@Inject(DIALOG_DATA) protected params: KeyRotationTrustDialogData,
|
||||
private logService: LogService,
|
||||
private dialogRef: DialogRef<boolean>,
|
||||
) {}
|
||||
|
||||
async submit() {
|
||||
try {
|
||||
this.dialogRef.close(true);
|
||||
} catch (e) {
|
||||
this.logService.error(e);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Strongly typed helper to open a KeyRotationTrustComponent
|
||||
* @param dialogService Instance of the dialog service that will be used to open the dialog
|
||||
* @param data The data to pass to the dialog
|
||||
*/
|
||||
static open(dialogService: DialogService, data: KeyRotationTrustDialogData) {
|
||||
return dialogService.open<boolean, KeyRotationTrustDialogData>(KeyRotationTrustInfoComponent, {
|
||||
data,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<bit-dialog
|
||||
dialogSize="large"
|
||||
[loading]="loading"
|
||||
[title]="'trustOrganization' | i18n"
|
||||
[subtitle]="params.name"
|
||||
>
|
||||
<ng-container bitDialogContent>
|
||||
<bit-callout type="warning">{{ "orgTrustWarning" | i18n }}</bit-callout>
|
||||
<p bitTypography="body1">
|
||||
{{ "fingerprintPhrase" | i18n }} <code>{{ fingerprint }}</code>
|
||||
</p>
|
||||
</ng-container>
|
||||
<ng-container bitDialogFooter>
|
||||
<button buttonType="primary" bitButton bitFormButton type="button" (click)="submit()">
|
||||
<span>{{ "trust" | i18n }}</span>
|
||||
</button>
|
||||
<button bitButton bitFormButton buttonType="secondary" type="button" bitDialogClose>
|
||||
{{ "doNotTrust" | i18n }}
|
||||
</button>
|
||||
</ng-container>
|
||||
</bit-dialog>
|
||||
@@ -0,0 +1,94 @@
|
||||
import { DialogRef, DIALOG_DATA } from "@angular/cdk/dialog";
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, OnInit, Inject } from "@angular/core";
|
||||
import { FormBuilder, FormsModule, ReactiveFormsModule } from "@angular/forms";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import {
|
||||
AsyncActionsModule,
|
||||
ButtonModule,
|
||||
CalloutModule,
|
||||
DialogModule,
|
||||
DialogService,
|
||||
FormFieldModule,
|
||||
LinkModule,
|
||||
TypographyModule,
|
||||
} from "@bitwarden/components";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
|
||||
type AccountRecoveryTrustDialogData = {
|
||||
/** display name of the user */
|
||||
name: string;
|
||||
/** org id */
|
||||
orgId: string;
|
||||
/** org public key */
|
||||
publicKey: Uint8Array;
|
||||
};
|
||||
@Component({
|
||||
selector: "account-recovery-trust",
|
||||
templateUrl: "account-recovery-trust.component.html",
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
JslibModule,
|
||||
DialogModule,
|
||||
ButtonModule,
|
||||
LinkModule,
|
||||
TypographyModule,
|
||||
ReactiveFormsModule,
|
||||
FormFieldModule,
|
||||
AsyncActionsModule,
|
||||
FormsModule,
|
||||
CalloutModule,
|
||||
],
|
||||
})
|
||||
export class AccountRecoveryTrustComponent implements OnInit {
|
||||
loading = true;
|
||||
fingerprint: string = "";
|
||||
confirmForm = this.formBuilder.group({});
|
||||
|
||||
constructor(
|
||||
@Inject(DIALOG_DATA) protected params: AccountRecoveryTrustDialogData,
|
||||
private formBuilder: FormBuilder,
|
||||
private keyService: KeyService,
|
||||
private logService: LogService,
|
||||
private dialogRef: DialogRef<boolean>,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
try {
|
||||
const fingerprint = await this.keyService.getFingerprint(
|
||||
this.params.orgId,
|
||||
this.params.publicKey,
|
||||
);
|
||||
if (fingerprint != null) {
|
||||
this.fingerprint = fingerprint.join("-");
|
||||
}
|
||||
} catch (e) {
|
||||
this.logService.error(e);
|
||||
}
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
async submit() {
|
||||
if (this.loading) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.dialogRef.close(true);
|
||||
}
|
||||
/**
|
||||
* Strongly typed helper to open a AccountRecoveryTrustComponent
|
||||
* @param dialogService Instance of the dialog service that will be used to open the dialog
|
||||
* @param data The data to pass to the dialog
|
||||
*/
|
||||
static open(dialogService: DialogService, data: AccountRecoveryTrustDialogData) {
|
||||
return dialogService.open<boolean, AccountRecoveryTrustDialogData>(
|
||||
AccountRecoveryTrustComponent,
|
||||
{
|
||||
data,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<bit-dialog
|
||||
dialogSize="large"
|
||||
[loading]="loading"
|
||||
[title]="'trustUser' | i18n"
|
||||
[subtitle]="params.name"
|
||||
>
|
||||
<ng-container bitDialogContent>
|
||||
<bit-callout type="warning">{{ "emergencyAccessTrustWarning" | i18n }}</bit-callout>
|
||||
<p bitTypography="body1">
|
||||
{{ "fingerprintEnsureIntegrityVerify" | i18n }}
|
||||
<a
|
||||
bitLink
|
||||
href="https://bitwarden.com/help/fingerprint-phrase/"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
{{ "learnMore" | i18n }}</a
|
||||
>
|
||||
</p>
|
||||
<p bitTypography="body1">
|
||||
<code>{{ fingerprint }}</code>
|
||||
</p>
|
||||
</ng-container>
|
||||
<ng-container bitDialogFooter>
|
||||
<button buttonType="primary" bitButton bitFormButton type="button" (click)="submit()">
|
||||
<span>{{ "trust" | i18n }}</span>
|
||||
</button>
|
||||
<button bitButton bitFormButton buttonType="secondary" type="button" bitDialogClose>
|
||||
{{ "doNotTrust" | i18n }}
|
||||
</button>
|
||||
</ng-container>
|
||||
</bit-dialog>
|
||||
@@ -0,0 +1,94 @@
|
||||
import { DialogRef, DIALOG_DATA } from "@angular/cdk/dialog";
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, OnInit, Inject } from "@angular/core";
|
||||
import { FormBuilder, FormsModule, ReactiveFormsModule } from "@angular/forms";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import {
|
||||
AsyncActionsModule,
|
||||
ButtonModule,
|
||||
CalloutModule,
|
||||
DialogModule,
|
||||
DialogService,
|
||||
FormFieldModule,
|
||||
LinkModule,
|
||||
TypographyModule,
|
||||
} from "@bitwarden/components";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
|
||||
type EmergencyAccessTrustDialogData = {
|
||||
/** display name of the user */
|
||||
name: string;
|
||||
/** userid of the user */
|
||||
userId: string;
|
||||
/** user public key */
|
||||
publicKey: Uint8Array;
|
||||
};
|
||||
@Component({
|
||||
selector: "emergency-access-trust",
|
||||
templateUrl: "emergency-access-trust.component.html",
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
JslibModule,
|
||||
DialogModule,
|
||||
ButtonModule,
|
||||
LinkModule,
|
||||
TypographyModule,
|
||||
ReactiveFormsModule,
|
||||
FormFieldModule,
|
||||
AsyncActionsModule,
|
||||
FormsModule,
|
||||
CalloutModule,
|
||||
],
|
||||
})
|
||||
export class EmergencyAccessTrustComponent implements OnInit {
|
||||
loading = true;
|
||||
fingerprint: string = "";
|
||||
confirmForm = this.formBuilder.group({});
|
||||
|
||||
constructor(
|
||||
@Inject(DIALOG_DATA) protected params: EmergencyAccessTrustDialogData,
|
||||
private formBuilder: FormBuilder,
|
||||
private keyService: KeyService,
|
||||
private logService: LogService,
|
||||
private dialogRef: DialogRef<boolean, EmergencyAccessTrustComponent>,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
try {
|
||||
const fingerprint = await this.keyService.getFingerprint(
|
||||
this.params.userId,
|
||||
this.params.publicKey,
|
||||
);
|
||||
if (fingerprint != null) {
|
||||
this.fingerprint = fingerprint.join("-");
|
||||
}
|
||||
} catch (e) {
|
||||
this.logService.error(e);
|
||||
}
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
async submit() {
|
||||
if (this.loading) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.dialogRef.close(true);
|
||||
}
|
||||
/**
|
||||
* Strongly typed helper to open a EmergencyAccessTrustComponent
|
||||
* @param dialogService Instance of the dialog service that will be used to open the dialog
|
||||
* @param data The data to pass to the dialog
|
||||
*/
|
||||
static open(dialogService: DialogService, data: EmergencyAccessTrustDialogData) {
|
||||
return dialogService.open<boolean, EmergencyAccessTrustDialogData>(
|
||||
EmergencyAccessTrustComponent,
|
||||
{
|
||||
data,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -337,6 +337,17 @@ export abstract class KeyService {
|
||||
userId: UserId,
|
||||
): Observable<{ privateKey: UserPrivateKey; publicKey: UserPublicKey } | null>;
|
||||
|
||||
/**
|
||||
* Gets an observable stream of the given users decrypted private key and public key, guaranteed to be consistent.
|
||||
* Will emit null if the user doesn't have a userkey to decrypt the encrypted private key, or null if the user doesn't have a private key
|
||||
* at all.
|
||||
*
|
||||
* @param userId The user id of the user to get the data for.
|
||||
*/
|
||||
abstract userEncryptionKeyPair$(
|
||||
userId: UserId,
|
||||
): Observable<{ privateKey: UserPrivateKey; publicKey: UserPublicKey } | null>;
|
||||
|
||||
/**
|
||||
* Generates a fingerprint phrase for the user based on their public key
|
||||
*
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { UserKey } from "@bitwarden/common/types/key";
|
||||
|
||||
/**
|
||||
* Constructs key rotation requests for key recovery encryption of the userkey.
|
||||
* @typeparam TRequest A request model that contains the newly encrypted userkey must have an id property
|
||||
*/
|
||||
export interface UserKeyRotationKeyRecoveryProvider<
|
||||
TRequest extends { id: string } | { organizationId: string },
|
||||
TPublicKeyData,
|
||||
> {
|
||||
/**
|
||||
* Get the public keys for this recovery method from the server.
|
||||
* WARNING these are NOT trusted, and need to either be manually trusted by the user, or compared against
|
||||
* a signed trust database for the user. THE SERVER CAN SPOOF THESE.
|
||||
*/
|
||||
getPublicKeys(userId: UserId): Promise<TPublicKeyData[]>;
|
||||
|
||||
/**
|
||||
* Provides re-encrypted data for the user key rotation process
|
||||
* @param newUserKey The new user key
|
||||
* @param trustedPublicKeys The public keys that the user trusted
|
||||
* @param userId The owner of the data, useful for fetching data
|
||||
* @returns A list of data that has been re-encrypted with the new user key
|
||||
*/
|
||||
getRotatedData(
|
||||
newUserKey: UserKey,
|
||||
trustedPublicKeys: Uint8Array[],
|
||||
userId: UserId,
|
||||
): Promise<TRequest[]>;
|
||||
}
|
||||
@@ -10,6 +10,7 @@ export * from "./biometrics/biometric.state";
|
||||
export { CipherDecryptionKeys, KeyService } from "./abstractions/key.service";
|
||||
export { DefaultKeyService } from "./key.service";
|
||||
export { UserKeyRotationDataProvider } from "./abstractions/user-key-rotation-data-provider.abstraction";
|
||||
export { UserKeyRotationKeyRecoveryProvider } from "./abstractions/user-key-rotation-key-recovery-provider.abstraction";
|
||||
export {
|
||||
PBKDF2KdfConfig,
|
||||
Argon2KdfConfig,
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
[title]="''"
|
||||
>
|
||||
<i class="bwi bwi-exclamation-triangle tw-text-warning" aria-hidden="true"></i>
|
||||
<a bitLink href="#" (click)="launchChangePassword()">
|
||||
<a bitLink href="#" appStopClick (click)="launchChangePassword()">
|
||||
{{ "changeAtRiskPassword" | i18n }}
|
||||
<i class="bwi bwi-external-link tw-ml-1" aria-hidden="true"></i>
|
||||
</a>
|
||||
|
||||
@@ -90,7 +90,7 @@
|
||||
></button>
|
||||
</bit-form-field>
|
||||
<bit-hint *ngIf="hadPendingChangePasswordTask">
|
||||
<a bitLink href="#" (click)="launchChangePasswordEvent()">
|
||||
<a bitLink href="#" appStopClick (click)="launchChangePasswordEvent()">
|
||||
{{ "changeAtRiskPassword" | i18n }}
|
||||
<i class="bwi bwi-external-link tw-ml-1" aria-hidden="true"></i>
|
||||
</a>
|
||||
|
||||
Reference in New Issue
Block a user