1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-12 06:23:38 +00:00

Merge branch 'main' into auth/pm-9115/implement-view-data-persistence-in-2FA-flows

This commit is contained in:
Alec Rippberger
2025-04-10 18:41:05 -05:00
committed by GitHub
145 changed files with 1502 additions and 642 deletions

View File

@@ -25,11 +25,11 @@ import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth
import { LoginViaAuthRequestView } from "@bitwarden/common/auth/models/view/login-via-auth-request.view";
import { ClientType, HttpStatusCode } from "@bitwarden/common/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
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 { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
@@ -809,11 +809,6 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
}
private async handleSuccessfulLoginNavigation(userId: UserId) {
if (this.flow === Flow.StandardAuthRequest) {
// Only need to set remembered email on standard login with auth req flow
await this.loginEmailService.saveEmailSettings();
}
await this.loginSuccessHandlerService.run(userId);
await this.router.navigate(["vault"]);
}

View File

@@ -3,7 +3,7 @@ import { of } from "rxjs";
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
import { ClientType } from "@bitwarden/common/enums";
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
import {
EnvironmentService,
Environment,
@@ -14,7 +14,7 @@ import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legac
import { DefaultLoginComponentService } from "./default-login-component.service";
jest.mock("@bitwarden/common/platform/abstractions/crypto-function.service");
jest.mock("@bitwarden/common/key-management/crypto/abstractions/crypto-function.service");
jest.mock("@bitwarden/common/platform/abstractions/environment.service");
jest.mock("@bitwarden/common/platform/abstractions/platform-utils.service");
jest.mock("@bitwarden/common/auth/abstractions/sso-login.service.abstraction");

View File

@@ -3,7 +3,7 @@
import { LoginComponentService } from "@bitwarden/auth/angular";
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
import { ClientType } from "@bitwarden/common/enums";
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";

View File

@@ -27,7 +27,12 @@
<!-- Remember Email input -->
<bit-form-control>
<input type="checkbox" formControlName="rememberEmail" bitCheckbox />
<input
type="checkbox"
formControlName="rememberEmail"
(input)="onRememberEmailInput($event)"
bitCheckbox
/>
<bit-label>{{ "rememberEmail" | i18n }}</bit-label>
</bit-form-control>
@@ -39,18 +44,18 @@
<div class="tw-text-center">{{ "or" | i18n }}</div>
<!-- Link to Login with Passkey page -->
<!-- Button to Login with Passkey -->
<ng-container *ngIf="isLoginWithPasskeySupported()">
<a
<button
type="button"
bitButton
block
linkType="primary"
routerLink="/login-with-passkey"
(mousedown)="$event.preventDefault()"
buttonType="secondary"
(click)="handleLoginWithPasskeyClick()"
>
<i class="bwi bwi-passkey tw-mr-1"></i>
{{ "logInWithPasskey" | i18n }}
</a>
</button>
</ng-container>
<!-- Button to Login with SSO -->

View File

@@ -148,6 +148,62 @@ export class LoginComponent implements OnInit, OnDestroy {
this.destroy$.complete();
}
private async defaultOnInit(): Promise<void> {
let paramEmailIsSet = false;
const params = await firstValueFrom(this.activatedRoute.queryParams);
if (params) {
const qParamsEmail = params.email;
// If there is an email in the query params, set that email as the form field value
if (qParamsEmail != null && qParamsEmail.indexOf("@") > -1) {
this.formGroup.controls.email.setValue(qParamsEmail);
paramEmailIsSet = true;
}
}
// If there are no params or no email in the query params, loadEmailSettings from state
if (!paramEmailIsSet) {
await this.loadRememberedEmail();
}
// Check to see if the device is known so that we can show the Login with Device option
if (this.emailFormControl.value) {
await this.getKnownDevice(this.emailFormControl.value);
}
// Backup check to handle unknown case where activatedRoute is not available
// This shouldn't happen under normal circumstances
if (!this.activatedRoute) {
await this.loadRememberedEmail();
}
}
private async desktopOnInit(): Promise<void> {
// TODO: refactor to not use deprecated broadcaster service.
this.broadcasterService.subscribe(BroadcasterSubscriptionId, async (message: any) => {
this.ngZone.run(() => {
switch (message.command) {
case "windowIsFocused":
if (this.deferFocus === null) {
this.deferFocus = !message.windowIsFocused;
if (!this.deferFocus) {
this.focusInput();
}
} else if (this.deferFocus && message.windowIsFocused) {
this.focusInput();
this.deferFocus = false;
}
break;
default:
}
});
});
this.messagingService.send("getWindowIsFocused");
}
submit = async (): Promise<void> => {
if (this.clientType === ClientType.Desktop) {
if (this.loginUiState !== LoginUiState.MASTER_PASSWORD_ENTRY) {
@@ -172,7 +228,6 @@ export class LoginComponent implements OnInit, OnDestroy {
try {
const authResult = await this.loginStrategyService.logIn(credentials);
await this.saveEmailSettings();
await this.handleAuthResult(authResult);
} catch (error) {
this.logService.error(error);
@@ -250,7 +305,6 @@ export class LoginComponent implements OnInit, OnDestroy {
// User logged in successfully so execute side effects
await this.loginSuccessHandlerService.run(authResult.userId);
this.loginEmailService.clearValues();
// Determine where to send the user next
if (authResult.forcePasswordReset != ForceSetPasswordReason.None) {
@@ -288,7 +342,6 @@ export class LoginComponent implements OnInit, OnDestroy {
await this.router.navigate(["vault"]);
}
}
/**
* Checks if the master password meets the enforced policy requirements
* and if the user is required to change their password.
@@ -344,11 +397,10 @@ export class LoginComponent implements OnInit, OnDestroy {
return;
}
await this.saveEmailSettings();
await this.router.navigate(["/login-with-device"]);
}
protected async validateEmail(): Promise<boolean> {
protected async emailIsValid(): Promise<boolean> {
this.formGroup.controls.email.markAsTouched();
this.formGroup.controls.email.updateValueAndValidity({ onlySelf: true, emitEvent: true });
return this.formGroup.controls.email.valid;
@@ -399,37 +451,14 @@ export class LoginComponent implements OnInit, OnDestroy {
}
}
/**
* Set the email value from the input field.
* @param event The event object from the input field.
*/
onEmailInput(event: Event) {
const emailInput = event.target as HTMLInputElement;
this.formGroup.controls.email.setValue(emailInput.value);
this.loginEmailService.setLoginEmail(emailInput.value);
}
isLoginWithPasskeySupported() {
return this.loginComponentService.isLoginWithPasskeySupported();
}
protected async goToHint(): Promise<void> {
await this.saveEmailSettings();
await this.router.navigateByUrl("/hint");
}
protected async saveEmailSettings(): Promise<void> {
const email = this.formGroup.value.email;
if (!email) {
this.logService.error("Email is required to save email settings.");
return;
}
await this.loginEmailService.setLoginEmail(email);
this.loginEmailService.setRememberEmail(this.formGroup.value.rememberEmail ?? false);
await this.loginEmailService.saveEmailSettings();
}
/**
* Continue button clicked (or enter key pressed).
* Adds the login url to the browser's history so that the back button can be used to go back to the email entry state.
@@ -445,13 +474,44 @@ export class LoginComponent implements OnInit, OnDestroy {
* Continue to the master password entry state (only if email is validated)
*/
protected async continue(): Promise<void> {
const isEmailValid = await this.validateEmail();
const isEmailValid = await this.emailIsValid();
if (isEmailValid) {
await this.toggleLoginUiState(LoginUiState.MASTER_PASSWORD_ENTRY);
}
}
/**
* Handle the Login with Passkey button click.
* We need a handler here in order to persist the remember email selection to state before routing.
* @param event - The event object.
*/
async handleLoginWithPasskeyClick() {
await this.router.navigate(["/login-with-passkey"]);
}
/**
* Handle the SSO button click.
* @param event - The event object.
*/
async handleSsoClick() {
// Make sure the email is valid
const isEmailValid = await this.emailIsValid();
if (!isEmailValid) {
return;
}
// Make sure the email is not empty, for type safety
const email = this.formGroup.value.email;
if (!email) {
this.logService.error("Email is required for SSO");
return;
}
// Send the user to SSO, either through routing or through redirecting to the web app
await this.loginComponentService.redirectToSsoLogin(email);
}
/**
* Call to check if the device is known.
* Known means that the user has logged in with this device before.
@@ -473,23 +533,17 @@ export class LoginComponent implements OnInit, OnDestroy {
}
}
private async loadEmailSettings(): Promise<void> {
// Try to load the email from memory first
const email = await firstValueFrom(this.loginEmailService.loginEmail$);
const rememberEmail = this.loginEmailService.getRememberEmail();
if (email) {
this.formGroup.controls.email.setValue(email);
this.formGroup.controls.rememberEmail.setValue(rememberEmail);
/**
* Check to see if the user has remembered an email on the current device.
* If so, set the email in the form field and set rememberEmail to true. If not, set rememberEmail to false.
*/
private async loadRememberedEmail(): Promise<void> {
const storedEmail = await firstValueFrom(this.loginEmailService.rememberedEmail$);
if (storedEmail) {
this.formGroup.controls.email.setValue(storedEmail);
this.formGroup.controls.rememberEmail.setValue(true);
} else {
// If there is no email in memory, check for a storedEmail on disk
const storedEmail = await firstValueFrom(this.loginEmailService.storedEmail$);
if (storedEmail) {
this.formGroup.controls.email.setValue(storedEmail);
// If there is a storedEmail, rememberEmail defaults to true
this.formGroup.controls.rememberEmail.setValue(true);
}
this.formGroup.controls.rememberEmail.setValue(false);
}
}
@@ -503,62 +557,6 @@ export class LoginComponent implements OnInit, OnDestroy {
?.focus();
}
private async defaultOnInit(): Promise<void> {
let paramEmailIsSet = false;
const params = await firstValueFrom(this.activatedRoute.queryParams);
if (params) {
const qParamsEmail = params.email;
// If there is an email in the query params, set that email as the form field value
if (qParamsEmail != null && qParamsEmail.indexOf("@") > -1) {
this.formGroup.controls.email.setValue(qParamsEmail);
paramEmailIsSet = true;
}
}
// If there are no params or no email in the query params, loadEmailSettings from state
if (!paramEmailIsSet) {
await this.loadEmailSettings();
}
// Check to see if the device is known so that we can show the Login with Device option
if (this.emailFormControl.value) {
await this.getKnownDevice(this.emailFormControl.value);
}
// Backup check to handle unknown case where activatedRoute is not available
// This shouldn't happen under normal circumstances
if (!this.activatedRoute) {
await this.loadEmailSettings();
}
}
private async desktopOnInit(): Promise<void> {
// TODO: refactor to not use deprecated broadcaster service.
this.broadcasterService.subscribe(BroadcasterSubscriptionId, async (message: any) => {
this.ngZone.run(() => {
switch (message.command) {
case "windowIsFocused":
if (this.deferFocus === null) {
this.deferFocus = !message.windowIsFocused;
if (!this.deferFocus) {
this.focusInput();
}
} else if (this.deferFocus && message.windowIsFocused) {
this.focusInput();
this.deferFocus = false;
}
break;
default:
}
});
});
this.messagingService.send("getWindowIsFocused");
}
/**
* Helper function to determine if the back button should be shown.
* @returns true if the back button should be shown.
@@ -597,27 +595,46 @@ export class LoginComponent implements OnInit, OnDestroy {
};
/**
* Handle the SSO button click.
* Persist the entered email address and the user's choice to remember it to state.
*/
async handleSsoClick() {
const email = this.formGroup.value.email;
// Make sure the email is valid
const isEmailValid = await this.validateEmail();
if (!isEmailValid) {
return;
private async persistEmailIfValid(): Promise<void> {
if (await this.emailIsValid()) {
const email = this.formGroup.value.email;
const rememberEmail = this.formGroup.value.rememberEmail ?? false;
if (!email) {
return;
}
await this.loginEmailService.setLoginEmail(email);
await this.loginEmailService.setRememberedEmailChoice(email, rememberEmail);
} else {
await this.loginEmailService.clearLoginEmail();
await this.loginEmailService.clearRememberedEmail();
}
}
// Make sure the email is not empty, for type safety
if (!email) {
this.logService.error("Email is required for SSO");
return;
}
/**
* Set the email value from the input field.
* We only update the form controls onSubmit instead of onBlur because we don't want to show validation errors until
* the user submits. This is because currently our validation errors are shown below the input fields, and
* displaying them causes the screen to "jump".
* @param event The event object from the input field.
*/
async onEmailInput(event: Event) {
const emailInput = event.target as HTMLInputElement;
this.formGroup.controls.email.setValue(emailInput.value);
await this.persistEmailIfValid();
}
// Save the email configuration for the login component
await this.saveEmailSettings();
// Send the user to SSO, either through routing or through redirecting to the web app
await this.loginComponentService.redirectToSsoLogin(email);
/**
* Set the Remember Email value from the input field.
* We only update the form controls onSubmit instead of onBlur because we don't want to show validation errors until
* the user submits. This is because currently our validation errors are shown below the input fields, and
* displaying them causes the screen to "jump".
* @param event The event object from the input field.
*/
async onRememberEmailInput(event: Event) {
const rememberEmailInput = event.target as HTMLInputElement;
this.formGroup.controls.rememberEmail.setValue(rememberEmailInput.checked);
await this.persistEmailIfValid();
}
}

View File

@@ -5,10 +5,10 @@ import { Router } from "@angular/router";
import { Subject, takeUntil } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { LoginSuccessHandlerService } from "@bitwarden/auth/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import {
AsyncActionsModule,
ButtonModule,
@@ -17,7 +17,6 @@ import {
LinkModule,
} from "@bitwarden/components";
import { LoginEmailServiceAbstraction } from "../../common/abstractions/login-email.service";
import { LoginStrategyServiceAbstraction } from "../../common/abstractions/login-strategy.service";
/**
@@ -60,8 +59,7 @@ export class NewDeviceVerificationComponent implements OnInit, OnDestroy {
private loginStrategyService: LoginStrategyServiceAbstraction,
private logService: LogService,
private i18nService: I18nService,
private syncService: SyncService,
private loginEmailService: LoginEmailServiceAbstraction,
private loginSuccessHandlerService: LoginSuccessHandlerService,
) {}
async ngOnInit() {
@@ -143,9 +141,7 @@ export class NewDeviceVerificationComponent implements OnInit, OnDestroy {
return;
}
this.loginEmailService.clearValues();
await this.syncService.fullSync(true);
this.loginSuccessHandlerService.run(authResult.userId);
// If verification succeeds, navigate to vault
await this.router.navigate(["/vault"]);

View File

@@ -25,11 +25,11 @@ import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/for
import { SsoPreValidateResponse } from "@bitwarden/common/auth/models/response/sso-pre-validate.response";
import { ClientType, HttpStatusCode } from "@bitwarden/common/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";

View File

@@ -264,19 +264,6 @@ describe("TwoFactorAuthComponent", () => {
);
});
it("calls loginEmailService.clearValues() when login is successful", async () => {
// Arrange
mockLoginStrategyService.logInTwoFactor.mockResolvedValue(new AuthResult());
// spy on loginEmailService.clearValues
const clearValuesSpy = jest.spyOn(mockLoginEmailService, "clearValues");
// Act
await component.submit(token, remember);
// Assert
expect(clearValuesSpy).toHaveBeenCalled();
});
describe("Set Master Password scenarios", () => {
beforeEach(() => {
const authResult = new AuthResult();

View File

@@ -475,7 +475,6 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy {
// User is fully logged in so handle any post login logic before executing navigation
await this.loginSuccessHandlerService.run(authResult.userId);
this.loginEmailService.clearValues();
// Save off the OrgSsoIdentifier for use in the TDE flows
// - TDE login decryption options component