mirror of
https://github.com/bitwarden/browser
synced 2025-12-11 05:43:41 +00:00
fix(device-approval-persistence): [PM-9112] Device Approval Persistence (#13680)
* feat(device-approval-persistence): [PM-9112] Device Approval Persistence - Added in view cache data needed to persist the approval process. Clears after 2 minutes.
This commit is contained in:
committed by
GitHub
parent
4c4019c35f
commit
2e0c991f83
@@ -26,7 +26,7 @@
|
|||||||
block
|
block
|
||||||
buttonType="secondary"
|
buttonType="secondary"
|
||||||
class="tw-mt-4"
|
class="tw-mt-4"
|
||||||
(click)="startStandardAuthRequestLogin()"
|
(click)="startStandardAuthRequestLogin(true)"
|
||||||
>
|
>
|
||||||
{{ "resendNotification" | i18n }}
|
{{ "resendNotification" | i18n }}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
// FIXME: Update this file to be type safe and remove this and next line
|
|
||||||
// @ts-strict-ignore
|
|
||||||
import { CommonModule } from "@angular/common";
|
import { CommonModule } from "@angular/common";
|
||||||
import { Component, OnDestroy, OnInit } from "@angular/core";
|
import { Component, OnDestroy, OnInit } from "@angular/core";
|
||||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||||
@@ -24,10 +22,13 @@ import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
|
|||||||
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
|
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
|
||||||
import { AuthRequest } from "@bitwarden/common/auth/models/request/auth.request";
|
import { AuthRequest } from "@bitwarden/common/auth/models/request/auth.request";
|
||||||
import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response";
|
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 { ClientType, HttpStatusCode } from "@bitwarden/common/enums";
|
import { ClientType, HttpStatusCode } from "@bitwarden/common/enums";
|
||||||
|
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||||
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
|
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
|
||||||
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
|
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
|
||||||
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
|
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
|
||||||
|
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||||
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
|
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
|
||||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
@@ -40,6 +41,7 @@ import { ButtonModule, LinkModule, ToastService } from "@bitwarden/components";
|
|||||||
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
|
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
|
||||||
|
|
||||||
import { AuthRequestApiService } from "../../common/abstractions/auth-request-api.service";
|
import { AuthRequestApiService } from "../../common/abstractions/auth-request-api.service";
|
||||||
|
import { LoginViaAuthRequestCacheService } from "../../common/services/auth-request/default-login-via-auth-request-cache.service";
|
||||||
|
|
||||||
enum Flow {
|
enum Flow {
|
||||||
StandardAuthRequest, // when user clicks "Login with device" from /login or "Approve from your other device" from /login-initiated
|
StandardAuthRequest, // when user clicks "Login with device" from /login or "Approve from your other device" from /login-initiated
|
||||||
@@ -57,23 +59,26 @@ const matchOptions: IsActiveMatchOptions = {
|
|||||||
standalone: true,
|
standalone: true,
|
||||||
templateUrl: "./login-via-auth-request.component.html",
|
templateUrl: "./login-via-auth-request.component.html",
|
||||||
imports: [ButtonModule, CommonModule, JslibModule, LinkModule, RouterModule],
|
imports: [ButtonModule, CommonModule, JslibModule, LinkModule, RouterModule],
|
||||||
|
providers: [{ provide: LoginViaAuthRequestCacheService }],
|
||||||
})
|
})
|
||||||
export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
|
export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
|
||||||
private authRequest: AuthRequest;
|
private authRequest: AuthRequest | undefined = undefined;
|
||||||
private authRequestKeyPair: { publicKey: Uint8Array; privateKey: Uint8Array };
|
private authRequestKeyPair:
|
||||||
private authStatus: AuthenticationStatus;
|
| { publicKey: Uint8Array | undefined; privateKey: Uint8Array | undefined }
|
||||||
|
| undefined = undefined;
|
||||||
|
private authStatus: AuthenticationStatus | undefined = undefined;
|
||||||
private showResendNotificationTimeoutSeconds = 12;
|
private showResendNotificationTimeoutSeconds = 12;
|
||||||
|
|
||||||
protected backToRoute = "/login";
|
protected backToRoute = "/login";
|
||||||
protected clientType: ClientType;
|
protected clientType: ClientType;
|
||||||
protected ClientType = ClientType;
|
protected ClientType = ClientType;
|
||||||
protected email: string;
|
protected email: string | undefined = undefined;
|
||||||
protected fingerprintPhrase: string;
|
protected fingerprintPhrase: string | undefined = undefined;
|
||||||
protected showResendNotification = false;
|
protected showResendNotification = false;
|
||||||
protected Flow = Flow;
|
protected Flow = Flow;
|
||||||
protected flow = Flow.StandardAuthRequest;
|
protected flow = Flow.StandardAuthRequest;
|
||||||
protected webVaultUrl: string;
|
protected webVaultUrl: string | undefined = undefined;
|
||||||
protected deviceManagementUrl: string;
|
protected deviceManagementUrl: string | undefined;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private accountService: AccountService,
|
private accountService: AccountService,
|
||||||
@@ -95,6 +100,8 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
|
|||||||
private toastService: ToastService,
|
private toastService: ToastService,
|
||||||
private validationService: ValidationService,
|
private validationService: ValidationService,
|
||||||
private loginSuccessHandlerService: LoginSuccessHandlerService,
|
private loginSuccessHandlerService: LoginSuccessHandlerService,
|
||||||
|
private loginViaAuthRequestCacheService: LoginViaAuthRequestCacheService,
|
||||||
|
private configService: ConfigService,
|
||||||
) {
|
) {
|
||||||
this.clientType = this.platformUtilsService.getClientType();
|
this.clientType = this.platformUtilsService.getClientType();
|
||||||
|
|
||||||
@@ -124,6 +131,7 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
|
|||||||
async ngOnInit(): Promise<void> {
|
async ngOnInit(): Promise<void> {
|
||||||
// Get the authStatus early because we use it in both flows
|
// Get the authStatus early because we use it in both flows
|
||||||
this.authStatus = await firstValueFrom(this.authService.activeAccountStatus$);
|
this.authStatus = await firstValueFrom(this.authService.activeAccountStatus$);
|
||||||
|
await this.loginViaAuthRequestCacheService.init();
|
||||||
|
|
||||||
const userHasAuthenticatedViaSSO = this.authStatus === AuthenticationStatus.Locked;
|
const userHasAuthenticatedViaSSO = this.authStatus === AuthenticationStatus.Locked;
|
||||||
|
|
||||||
@@ -133,7 +141,7 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The LoginViaAuthRequestComponent handles both the `login-with-device` and
|
* The LoginViaAuthRequestComponent handles both the `login-with-device` and
|
||||||
* the `admin-approval-requested` routes. Therefore we check the route to determine
|
* the `admin-approval-requested` routes. Therefore, we check the route to determine
|
||||||
* which flow to initialize.
|
* which flow to initialize.
|
||||||
*/
|
*/
|
||||||
if (this.router.isActive("admin-approval-requested", matchOptions)) {
|
if (this.router.isActive("admin-approval-requested", matchOptions)) {
|
||||||
@@ -159,7 +167,14 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
// We only allow a single admin approval request to be active at a time
|
// 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
|
// so we must check state to see if we have an existing one or not
|
||||||
const userId = (await firstValueFrom(this.accountService.activeAccount$)).id;
|
const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
|
||||||
|
if (!userId) {
|
||||||
|
this.logService.error(
|
||||||
|
"Not able to get a user id from the account service active account observable.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const existingAdminAuthRequest = await this.authRequestService.getAdminAuthRequest(userId);
|
const existingAdminAuthRequest = await this.authRequestService.getAdminAuthRequest(userId);
|
||||||
|
|
||||||
if (existingAdminAuthRequest) {
|
if (existingAdminAuthRequest) {
|
||||||
@@ -172,7 +187,9 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
|
|||||||
private async initStandardAuthRequestFlow(): Promise<void> {
|
private async initStandardAuthRequestFlow(): Promise<void> {
|
||||||
this.flow = Flow.StandardAuthRequest;
|
this.flow = Flow.StandardAuthRequest;
|
||||||
|
|
||||||
this.email = await firstValueFrom(this.loginEmailService.loginEmail$);
|
this.email = await firstValueFrom(
|
||||||
|
this.accountService.activeAccount$.pipe(map((a) => a?.email)),
|
||||||
|
);
|
||||||
|
|
||||||
if (!this.email) {
|
if (!this.email) {
|
||||||
await this.handleMissingEmail();
|
await this.handleMissingEmail();
|
||||||
@@ -185,7 +202,6 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
|
|||||||
private async handleMissingEmail(): Promise<void> {
|
private async handleMissingEmail(): Promise<void> {
|
||||||
this.toastService.showToast({
|
this.toastService.showToast({
|
||||||
variant: "error",
|
variant: "error",
|
||||||
title: null,
|
|
||||||
message: this.i18nService.t("userEmailMissing"),
|
message: this.i18nService.t("userEmailMissing"),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -194,21 +210,41 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
async ngOnDestroy(): Promise<void> {
|
async ngOnDestroy(): Promise<void> {
|
||||||
await this.anonymousHubService.stopHubConnection();
|
await this.anonymousHubService.stopHubConnection();
|
||||||
|
|
||||||
|
this.loginViaAuthRequestCacheService.clearCacheLoginView();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async startAdminAuthRequestLogin(): Promise<void> {
|
private async startAdminAuthRequestLogin(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await this.buildAuthRequest(AuthRequestType.AdminApproval);
|
await this.buildAuthRequest(AuthRequestType.AdminApproval);
|
||||||
|
|
||||||
|
if (!this.authRequest) {
|
||||||
|
this.logService.error("Auth request failed to build.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.authRequestKeyPair) {
|
||||||
|
this.logService.error("Key pairs failed to initialize from buildAuthRequest.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const authRequestResponse = await this.authRequestApiService.postAdminAuthRequest(
|
const authRequestResponse = await this.authRequestApiService.postAdminAuthRequest(
|
||||||
this.authRequest,
|
this.authRequest as AuthRequest,
|
||||||
);
|
);
|
||||||
const adminAuthReqStorable = new AdminAuthRequestStorable({
|
const adminAuthReqStorable = new AdminAuthRequestStorable({
|
||||||
id: authRequestResponse.id,
|
id: authRequestResponse.id,
|
||||||
privateKey: this.authRequestKeyPair.privateKey,
|
privateKey: this.authRequestKeyPair.privateKey,
|
||||||
});
|
});
|
||||||
|
|
||||||
const userId = (await firstValueFrom(this.accountService.activeAccount$)).id;
|
const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
this.logService.error(
|
||||||
|
"Not able to get a user id from the account service active account observable.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await this.authRequestService.setAdminAuthRequest(adminAuthReqStorable, userId);
|
await this.authRequestService.setAdminAuthRequest(adminAuthReqStorable, userId);
|
||||||
|
|
||||||
if (authRequestResponse.id) {
|
if (authRequestResponse.id) {
|
||||||
@@ -219,21 +255,104 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async startStandardAuthRequestLogin(): Promise<void> {
|
protected async startStandardAuthRequestLogin(
|
||||||
|
clearCachedRequest: boolean = false,
|
||||||
|
): Promise<void> {
|
||||||
this.showResendNotification = false;
|
this.showResendNotification = false;
|
||||||
|
|
||||||
try {
|
if (await this.configService.getFeatureFlag(FeatureFlag.PM9112_DeviceApprovalPersistence)) {
|
||||||
await this.buildAuthRequest(AuthRequestType.AuthenticateAndUnlock);
|
// Used for manually refreshing the auth request when clicking the resend auth request
|
||||||
|
// on the ui.
|
||||||
const authRequestResponse = await this.authRequestApiService.postAuthRequest(
|
if (clearCachedRequest) {
|
||||||
this.authRequest,
|
this.loginViaAuthRequestCacheService.clearCacheLoginView();
|
||||||
);
|
}
|
||||||
|
|
||||||
if (authRequestResponse.id) {
|
try {
|
||||||
await this.anonymousHubService.createHubConnection(authRequestResponse.id);
|
const loginAuthRequestView: LoginViaAuthRequestView | null =
|
||||||
|
this.loginViaAuthRequestCacheService.getCachedLoginViaAuthRequestView();
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
await this.buildAuthRequest(AuthRequestType.AuthenticateAndUnlock);
|
||||||
|
|
||||||
|
if (!this.authRequest) {
|
||||||
|
this.logService.error("No auth request found.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const authRequestResponse = await this.authRequestApiService.postAuthRequest(
|
||||||
|
this.authRequest,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (authRequestResponse.id) {
|
||||||
|
await this.anonymousHubService.createHubConnection(authRequestResponse.id);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.logService.error(e);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
|
||||||
this.logService.error(e);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -250,12 +369,23 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const deviceIdentifier = await this.appIdService.getAppId();
|
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 publicKey = Utils.fromBufferToB64(this.authRequestKeyPair.publicKey);
|
const publicKey = Utils.fromBufferToB64(this.authRequestKeyPair.publicKey);
|
||||||
const accessCode = await this.passwordGenerationService.generatePassword({
|
const accessCode = await this.passwordGenerationService.generatePassword({
|
||||||
type: "password",
|
type: "password",
|
||||||
length: 25,
|
length: 25,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!this.email) {
|
||||||
|
this.logService.error("Email not defined when building auth request.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.fingerprintPhrase = await this.authRequestService.getFingerprintPhrase(
|
this.fingerprintPhrase = await this.authRequestService.getFingerprintPhrase(
|
||||||
this.email,
|
this.email,
|
||||||
this.authRequestKeyPair.publicKey,
|
this.authRequestKeyPair.publicKey,
|
||||||
@@ -288,6 +418,8 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
|
|||||||
if (error instanceof ErrorResponse && error.statusCode === HttpStatusCode.NotFound) {
|
if (error instanceof ErrorResponse && error.statusCode === HttpStatusCode.NotFound) {
|
||||||
return await this.handleExistingAdminAuthReqDeletedOrDenied(userId);
|
return await this.handleExistingAdminAuthReqDeletedOrDenied(userId);
|
||||||
}
|
}
|
||||||
|
this.logService.error(error);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Request doesn't exist anymore
|
// Request doesn't exist anymore
|
||||||
@@ -300,6 +432,12 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
|
|||||||
const derivedPublicKeyArrayBuffer = await this.cryptoFunctionService.rsaExtractPublicKey(
|
const derivedPublicKeyArrayBuffer = await this.cryptoFunctionService.rsaExtractPublicKey(
|
||||||
adminAuthRequestStorable.privateKey,
|
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.fingerprintPhrase = await this.authRequestService.getFingerprintPhrase(
|
||||||
this.email,
|
this.email,
|
||||||
derivedPublicKeyArrayBuffer,
|
derivedPublicKeyArrayBuffer,
|
||||||
@@ -319,9 +457,12 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Request still pending response from admin
|
// Request still pending response from admin set keypair and create hub connection
|
||||||
// set keypair and create hub connection so that any approvals will be received via push notification
|
// so that any approvals will be received via push notification
|
||||||
this.authRequestKeyPair = { privateKey: adminAuthRequestStorable.privateKey, publicKey: null };
|
this.authRequestKeyPair = {
|
||||||
|
privateKey: adminAuthRequestStorable.privateKey,
|
||||||
|
publicKey: undefined,
|
||||||
|
};
|
||||||
await this.anonymousHubService.createHubConnection(adminAuthRequestStorable.id);
|
await this.anonymousHubService.createHubConnection(adminAuthRequestStorable.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -403,6 +544,11 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
|
|||||||
await this.handleAuthenticatedFlows(authRequestResponse);
|
await this.handleAuthenticatedFlows(authRequestResponse);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
if (!this.authRequest) {
|
||||||
|
this.logService.error("No auth request defined when handling approved auth request.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Get the auth request from the server
|
// Get the auth request from the server
|
||||||
// User is unauthenticated, therefore the endpoint requires an access code for user verification.
|
// User is unauthenticated, therefore the endpoint requires an access code for user verification.
|
||||||
const authRequestResponse = await this.authRequestApiService.getAuthResponse(
|
const authRequestResponse = await this.authRequestApiService.getAuthResponse(
|
||||||
@@ -423,11 +569,26 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.logService.error(error);
|
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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleAuthenticatedFlows(authRequestResponse: AuthRequestResponse) {
|
private async handleAuthenticatedFlows(authRequestResponse: AuthRequestResponse) {
|
||||||
const userId = (await firstValueFrom(this.accountService.activeAccount$)).id;
|
const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
|
||||||
|
if (!userId) {
|
||||||
|
this.logService.error(
|
||||||
|
"Not able to get a user id from the account service active account observable.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.authRequestKeyPair || !this.authRequestKeyPair.privateKey) {
|
||||||
|
this.logService.error("No private key set when handling the authenticated flows.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await this.decryptViaApprovedAuthRequest(
|
await this.decryptViaApprovedAuthRequest(
|
||||||
authRequestResponse,
|
authRequestResponse,
|
||||||
@@ -445,6 +606,11 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
|
|||||||
authRequestResponse,
|
authRequestResponse,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!authRequestLoginCredentials) {
|
||||||
|
this.logService.error("Didn't set up auth request login credentials properly.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Note: keys are set by AuthRequestLoginStrategy success handling
|
// Note: keys are set by AuthRequestLoginStrategy success handling
|
||||||
const authResult = await this.loginStrategyService.logIn(authRequestLoginCredentials);
|
const authResult = await this.loginStrategyService.logIn(authRequestLoginCredentials);
|
||||||
|
|
||||||
@@ -463,7 +629,6 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
|
|||||||
* - If `masterPasswordHash` has a value, we receive the `key` as an authRequestPublicKey(masterKey) [plus we have authRequestPublicKey(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` does not have a value, we receive the `key` as an authRequestPublicKey(userKey)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
if (authRequestResponse.masterPasswordHash) {
|
if (authRequestResponse.masterPasswordHash) {
|
||||||
// ...in Standard Auth Request Flow 3
|
// ...in Standard Auth Request Flow 3
|
||||||
await this.authRequestService.setKeysAfterDecryptingSharedMasterKeyAndHash(
|
await this.authRequestService.setKeysAfterDecryptingSharedMasterKeyAndHash(
|
||||||
@@ -486,13 +651,17 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
this.toastService.showToast({
|
this.toastService.showToast({
|
||||||
variant: "success",
|
variant: "success",
|
||||||
title: null,
|
|
||||||
message: this.i18nService.t("loginApproved"),
|
message: this.i18nService.t("loginApproved"),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Now that we have a decrypted user key in memory, we can check if we
|
// Now that we have a decrypted user key in memory, we can check if we
|
||||||
// need to establish trust on the current device
|
// need to establish trust on the current device
|
||||||
const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
|
const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
|
||||||
|
if (!activeAccount) {
|
||||||
|
this.logService.error("No active account defined from the account service.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await this.deviceTrustService.trustDeviceIfRequired(activeAccount.id);
|
await this.deviceTrustService.trustDeviceIfRequired(activeAccount.id);
|
||||||
|
|
||||||
await this.handleSuccessfulLoginNavigation(userId);
|
await this.handleSuccessfulLoginNavigation(userId);
|
||||||
@@ -508,7 +677,24 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
|
|||||||
private async buildAuthRequestLoginCredentials(
|
private async buildAuthRequestLoginCredentials(
|
||||||
requestId: string,
|
requestId: string,
|
||||||
authRequestResponse: AuthRequestResponse,
|
authRequestResponse: AuthRequestResponse,
|
||||||
): Promise<AuthRequestLoginCredentials> {
|
): Promise<AuthRequestLoginCredentials | undefined> {
|
||||||
|
if (!this.authRequestKeyPair || !this.authRequestKeyPair.privateKey) {
|
||||||
|
this.logService.error("No private key set when building auth request login credentials.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.email) {
|
||||||
|
this.logService.error("Email not defined.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.authRequest) {
|
||||||
|
this.logService.error(
|
||||||
|
"AuthRequest not defined when building auth request login credentials.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* See verifyAndHandleApprovedAuthReq() for flow details.
|
* See verifyAndHandleApprovedAuthReq() for flow details.
|
||||||
*
|
*
|
||||||
@@ -516,7 +702,6 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
|
|||||||
* - If `masterPasswordHash` has a value, we receive the `key` as an authRequestPublicKey(masterKey) [plus we have authRequestPublicKey(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` does not have a value, we receive the `key` as an authRequestPublicKey(userKey)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
if (authRequestResponse.masterPasswordHash) {
|
if (authRequestResponse.masterPasswordHash) {
|
||||||
// ...in Standard Auth Request Flow 1
|
// ...in Standard Auth Request Flow 1
|
||||||
const { masterKey, masterKeyHash } =
|
const { masterKey, masterKeyHash } =
|
||||||
|
|||||||
@@ -53,9 +53,9 @@ export class AuthRequestLoginCredentials {
|
|||||||
public email: string,
|
public email: string,
|
||||||
public accessCode: string,
|
public accessCode: string,
|
||||||
public authRequestId: string,
|
public authRequestId: string,
|
||||||
public decryptedUserKey: UserKey,
|
public decryptedUserKey: UserKey | null,
|
||||||
public decryptedMasterKey: MasterKey,
|
public decryptedMasterKey: MasterKey | null,
|
||||||
public decryptedMasterKeyHash: string,
|
public decryptedMasterKeyHash: string | null,
|
||||||
public twoFactor?: TokenTwoFactorRequest,
|
public twoFactor?: TokenTwoFactorRequest,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
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 { LoginViaAuthRequestCacheService } from "./default-login-via-auth-request-cache.service";
|
||||||
|
|
||||||
|
describe("LoginViaAuthRequestCache", () => {
|
||||||
|
let service: LoginViaAuthRequestCacheService;
|
||||||
|
let testBed: TestBed;
|
||||||
|
|
||||||
|
const cacheSignal = signal<LoginViaAuthRequestView | null>(null);
|
||||||
|
const getCacheSignal = jest.fn().mockReturnValue(cacheSignal);
|
||||||
|
const getFeatureFlag = jest.fn().mockResolvedValue(false);
|
||||||
|
const cacheSetMock = jest.spyOn(cacheSignal, "set");
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
getCacheSignal.mockClear();
|
||||||
|
getFeatureFlag.mockClear();
|
||||||
|
cacheSetMock.mockClear();
|
||||||
|
|
||||||
|
testBed = TestBed.configureTestingModule({
|
||||||
|
providers: [
|
||||||
|
{ provide: ViewCacheService, useValue: { signal: getCacheSignal } },
|
||||||
|
{ provide: ConfigService, useValue: { getFeatureFlag } },
|
||||||
|
LoginViaAuthRequestCacheService,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("feature enabled", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
getFeatureFlag.mockResolvedValue(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("`getCachedLoginViaAuthRequestView` returns the cached data", async () => {
|
||||||
|
cacheSignal.set({ ...buildAuthenticMockAuthView() });
|
||||||
|
service = testBed.inject(LoginViaAuthRequestCacheService);
|
||||||
|
await service.init();
|
||||||
|
|
||||||
|
expect(service.getCachedLoginViaAuthRequestView()).toEqual({
|
||||||
|
...buildAuthenticMockAuthView(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates the signal value", async () => {
|
||||||
|
service = testBed.inject(LoginViaAuthRequestCacheService);
|
||||||
|
await service.init();
|
||||||
|
|
||||||
|
const parameters = buildAuthenticMockAuthView();
|
||||||
|
|
||||||
|
service.cacheLoginView(
|
||||||
|
parameters.authRequest,
|
||||||
|
parameters.authRequestResponse,
|
||||||
|
parameters.fingerprintPhrase,
|
||||||
|
{ publicKey: new Uint8Array(), privateKey: new Uint8Array() },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(cacheSignal.set).toHaveBeenCalledWith(parameters);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("feature disabled", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
cacheSignal.set({ ...buildAuthenticMockAuthView() } as LoginViaAuthRequestView);
|
||||||
|
getFeatureFlag.mockResolvedValue(false);
|
||||||
|
cacheSetMock.mockClear();
|
||||||
|
|
||||||
|
service = testBed.inject(LoginViaAuthRequestCacheService);
|
||||||
|
await service.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("`getCachedCipherView` returns null", () => {
|
||||||
|
expect(service.getCachedLoginViaAuthRequestView()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not update the signal value", () => {
|
||||||
|
const params = buildAuthenticMockAuthView();
|
||||||
|
|
||||||
|
service.cacheLoginView(
|
||||||
|
params.authRequest,
|
||||||
|
params.authRequestResponse,
|
||||||
|
params.fingerprintPhrase,
|
||||||
|
{ publicKey: new Uint8Array(), privateKey: new Uint8Array() },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(cacheSignal.set).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const buildAuthenticMockAuthView = () => {
|
||||||
|
return {
|
||||||
|
fingerprintPhrase: "",
|
||||||
|
privateKey: "",
|
||||||
|
publicKey: "",
|
||||||
|
authRequest: new AuthRequest(
|
||||||
|
"test@gmail.com",
|
||||||
|
"deviceIdentifier",
|
||||||
|
"publicKey",
|
||||||
|
AuthRequestType.Unlock,
|
||||||
|
"accessCode",
|
||||||
|
),
|
||||||
|
authRequestResponse: new AuthRequestResponse({}),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
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";
|
||||||
|
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||||
|
|
||||||
|
const LOGIN_VIA_AUTH_CACHE_KEY = "login-via-auth-request-form-cache";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This is a cache service used for the login via auth request component.
|
||||||
|
*
|
||||||
|
* There is sensitive information stored temporarily here. Cache will be cleared
|
||||||
|
* after 2 minutes.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class LoginViaAuthRequestCacheService {
|
||||||
|
private viewCacheService: ViewCacheService = inject(ViewCacheService);
|
||||||
|
private configService: ConfigService = inject(ConfigService);
|
||||||
|
|
||||||
|
/** True when the `PM9112_DeviceApproval` flag is enabled */
|
||||||
|
private featureEnabled: boolean = false;
|
||||||
|
|
||||||
|
private defaultLoginViaAuthRequestCache: WritableSignal<LoginViaAuthRequestView | null> =
|
||||||
|
this.viewCacheService.signal<LoginViaAuthRequestView | null>({
|
||||||
|
key: LOGIN_VIA_AUTH_CACHE_KEY,
|
||||||
|
initialValue: null,
|
||||||
|
deserializer: LoginViaAuthRequestView.fromJSON,
|
||||||
|
});
|
||||||
|
|
||||||
|
constructor() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Must be called once before interacting with the cached data, otherwise methods will be noop.
|
||||||
|
*/
|
||||||
|
async init() {
|
||||||
|
this.featureEnabled = await this.configService.getFeatureFlag(
|
||||||
|
FeatureFlag.PM9112_DeviceApprovalPersistence,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the cache with the new LoginView.
|
||||||
|
*/
|
||||||
|
cacheLoginView(
|
||||||
|
authRequest: AuthRequest,
|
||||||
|
authRequestResponse: AuthRequestResponse,
|
||||||
|
fingerprintPhrase: string,
|
||||||
|
keys: { privateKey: Uint8Array | undefined; publicKey: Uint8Array | undefined },
|
||||||
|
): void {
|
||||||
|
if (!this.featureEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// When the keys get stored they should be converted to a B64 string to ensure
|
||||||
|
// 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,
|
||||||
|
} as LoginViaAuthRequestView);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearCacheLoginView(): void {
|
||||||
|
if (!this.featureEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.defaultLoginViaAuthRequestCache.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the cached LoginViaAuthRequestView when available.
|
||||||
|
*/
|
||||||
|
getCachedLoginViaAuthRequestView(): LoginViaAuthRequestView | null {
|
||||||
|
if (!this.featureEnabled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.defaultLoginViaAuthRequestCache();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
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;
|
||||||
|
privateKey: string | undefined = undefined;
|
||||||
|
publicKey: string | undefined = undefined;
|
||||||
|
|
||||||
|
static fromJSON(obj: Partial<Jsonify<LoginViaAuthRequestView>>): LoginViaAuthRequestView {
|
||||||
|
return Object.assign(new LoginViaAuthRequestView(), obj);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,6 +36,9 @@ export enum FeatureFlag {
|
|||||||
VaultBulkManagementAction = "vault-bulk-management-action",
|
VaultBulkManagementAction = "vault-bulk-management-action",
|
||||||
SecurityTasks = "security-tasks",
|
SecurityTasks = "security-tasks",
|
||||||
|
|
||||||
|
/* Auth */
|
||||||
|
PM9112_DeviceApprovalPersistence = "pm-9112-device-approval-persistence",
|
||||||
|
|
||||||
PM4154_BulkEncryptionService = "PM-4154-bulk-encryption-service",
|
PM4154_BulkEncryptionService = "PM-4154-bulk-encryption-service",
|
||||||
UnauthenticatedExtensionUIRefresh = "unauth-ui-refresh",
|
UnauthenticatedExtensionUIRefresh = "unauth-ui-refresh",
|
||||||
CipherKeyEncryption = "cipher-key-encryption",
|
CipherKeyEncryption = "cipher-key-encryption",
|
||||||
@@ -93,6 +96,9 @@ export const DefaultFeatureFlagValue = {
|
|||||||
[FeatureFlag.VaultBulkManagementAction]: FALSE,
|
[FeatureFlag.VaultBulkManagementAction]: FALSE,
|
||||||
[FeatureFlag.SecurityTasks]: FALSE,
|
[FeatureFlag.SecurityTasks]: FALSE,
|
||||||
|
|
||||||
|
/* Auth */
|
||||||
|
[FeatureFlag.PM9112_DeviceApprovalPersistence]: FALSE,
|
||||||
|
|
||||||
[FeatureFlag.PM4154_BulkEncryptionService]: FALSE,
|
[FeatureFlag.PM4154_BulkEncryptionService]: FALSE,
|
||||||
[FeatureFlag.UnauthenticatedExtensionUIRefresh]: FALSE,
|
[FeatureFlag.UnauthenticatedExtensionUIRefresh]: FALSE,
|
||||||
[FeatureFlag.CipherKeyEncryption]: FALSE,
|
[FeatureFlag.CipherKeyEncryption]: FALSE,
|
||||||
|
|||||||
Reference in New Issue
Block a user