mirror of
https://github.com/bitwarden/browser
synced 2025-12-16 16:23:44 +00:00
579 lines
20 KiB
TypeScript
579 lines
20 KiB
TypeScript
import { CommonModule } from "@angular/common";
|
|
import { Component, ElementRef, Input, NgZone, OnDestroy, OnInit, ViewChild } from "@angular/core";
|
|
import { FormBuilder, FormControl, ReactiveFormsModule, Validators } from "@angular/forms";
|
|
import { ActivatedRoute, Router, RouterModule } from "@angular/router";
|
|
import { first, firstValueFrom, of, Subject, switchMap, take, takeUntil } from "rxjs";
|
|
|
|
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
|
import {
|
|
LoginEmailServiceAbstraction,
|
|
LoginStrategyServiceAbstraction,
|
|
PasswordLoginCredentials,
|
|
RegisterRouteService,
|
|
} from "@bitwarden/auth/common";
|
|
import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
|
import { PolicyData } from "@bitwarden/common/admin-console/models/data/policy.data";
|
|
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
|
|
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
|
|
import { DevicesApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices-api.service.abstraction";
|
|
import { CaptchaIFrame } from "@bitwarden/common/auth/captcha-iframe";
|
|
import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
|
|
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
|
|
import { ClientType } from "@bitwarden/common/enums";
|
|
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
|
|
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
|
|
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
|
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
|
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
|
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
|
import { SyncService } from "@bitwarden/common/platform/sync";
|
|
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
|
|
import { UserId } from "@bitwarden/common/types/guid";
|
|
import {
|
|
AsyncActionsModule,
|
|
ButtonModule,
|
|
CheckboxModule,
|
|
FormFieldModule,
|
|
IconButtonModule,
|
|
ToastService,
|
|
} from "@bitwarden/components";
|
|
|
|
import { AnonLayoutWrapperDataService } from "../anon-layout/anon-layout-wrapper-data.service";
|
|
import { WaveIcon } from "../icons";
|
|
|
|
import { LoginComponentService } from "./login-component.service";
|
|
|
|
const BroadcasterSubscriptionId = "LoginComponent";
|
|
|
|
export enum LoginUiState {
|
|
EMAIL_ENTRY = "EmailEntry",
|
|
MASTER_PASSWORD_ENTRY = "MasterPasswordEntry",
|
|
}
|
|
|
|
@Component({
|
|
standalone: true,
|
|
templateUrl: "./login.component.html",
|
|
imports: [
|
|
AsyncActionsModule,
|
|
ButtonModule,
|
|
CheckboxModule,
|
|
CommonModule,
|
|
FormFieldModule,
|
|
IconButtonModule,
|
|
JslibModule,
|
|
ReactiveFormsModule,
|
|
RouterModule,
|
|
],
|
|
})
|
|
export class LoginComponent implements OnInit, OnDestroy {
|
|
@ViewChild("masterPasswordInputRef") masterPasswordInputRef: ElementRef;
|
|
@Input() captchaSiteKey: string = null;
|
|
|
|
private destroy$ = new Subject<void>();
|
|
readonly Icons = { WaveIcon };
|
|
|
|
captcha: CaptchaIFrame;
|
|
captchaToken: string = null;
|
|
clientType: ClientType;
|
|
ClientType = ClientType;
|
|
LoginUiState = LoginUiState;
|
|
registerRoute$ = this.registerRouteService.registerRoute$(); // TODO: remove when email verification flag is removed
|
|
showLoginWithDevice = false;
|
|
validatedEmail = false;
|
|
|
|
formGroup = this.formBuilder.group({
|
|
email: ["", [Validators.required, Validators.email]],
|
|
masterPassword: [
|
|
"",
|
|
[Validators.required, Validators.minLength(Utils.originalMinimumPasswordLength)],
|
|
],
|
|
rememberEmail: [false],
|
|
});
|
|
|
|
get emailFormControl(): FormControl<string> {
|
|
return this.formGroup.controls.email;
|
|
}
|
|
|
|
get loggedEmail(): string {
|
|
return this.formGroup.value.email;
|
|
}
|
|
|
|
get uiState(): LoginUiState {
|
|
return this.validatedEmail ? LoginUiState.MASTER_PASSWORD_ENTRY : LoginUiState.EMAIL_ENTRY;
|
|
}
|
|
|
|
// Web properties
|
|
enforcedPasswordPolicyOptions: MasterPasswordPolicyOptions;
|
|
policies: Policy[];
|
|
showPasswordless = false;
|
|
showResetPasswordAutoEnrollWarning = false;
|
|
|
|
// Desktop properties
|
|
deferFocus: boolean = null; // TODO-rr-bw: why initialize to null instead of false
|
|
|
|
constructor(
|
|
private activatedRoute: ActivatedRoute,
|
|
private anonLayoutWrapperDataService: AnonLayoutWrapperDataService,
|
|
private appIdService: AppIdService,
|
|
private broadcasterService: BroadcasterService,
|
|
private devicesApiService: DevicesApiServiceAbstraction,
|
|
private environmentService: EnvironmentService,
|
|
private formBuilder: FormBuilder,
|
|
private i18nService: I18nService,
|
|
private loginEmailService: LoginEmailServiceAbstraction,
|
|
private loginComponentService: LoginComponentService,
|
|
private loginStrategyService: LoginStrategyServiceAbstraction,
|
|
private messagingService: MessagingService,
|
|
private ngZone: NgZone,
|
|
private passwordStrengthService: PasswordStrengthServiceAbstraction,
|
|
private platformUtilsService: PlatformUtilsService,
|
|
private policyService: InternalPolicyService,
|
|
private registerRouteService: RegisterRouteService,
|
|
private router: Router,
|
|
private syncService: SyncService,
|
|
private toastService: ToastService,
|
|
) {
|
|
this.clientType = this.platformUtilsService.getClientType();
|
|
this.showPasswordless = this.loginComponentService.getShowPasswordlessFlag();
|
|
}
|
|
|
|
async ngOnInit(): Promise<void> {
|
|
if (this.clientType === ClientType.Web) {
|
|
await this.webOnInit();
|
|
}
|
|
|
|
await this.defaultOnInit();
|
|
|
|
if (this.clientType === ClientType.Browser) {
|
|
if (this.showPasswordless) {
|
|
await this.validateEmail();
|
|
}
|
|
}
|
|
|
|
if (this.clientType === ClientType.Desktop) {
|
|
await this.desktopOnInit();
|
|
}
|
|
}
|
|
|
|
ngOnDestroy(): void {
|
|
if (this.clientType === ClientType.Desktop) {
|
|
// TODO-rr-bw: refactor to not use deprecated broadcaster service.
|
|
this.broadcasterService.unsubscribe(BroadcasterSubscriptionId);
|
|
}
|
|
|
|
this.destroy$.next();
|
|
this.destroy$.complete();
|
|
}
|
|
|
|
submit = async (): Promise<void> => {
|
|
if (this.clientType === ClientType.Desktop) {
|
|
if (!this.validatedEmail) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
const { email, masterPassword } = this.formGroup.value;
|
|
|
|
await this.setupCaptcha();
|
|
|
|
/**
|
|
* TODO-rr-bw: Verify the following
|
|
*
|
|
* In the original base login component there is a comment in the submit() method that says:
|
|
* "desktop, browser; This should be removed once all clients use reactive forms"
|
|
*
|
|
* Since we are now using reactive forms for all clients. I removed the Browser/Desktop specific
|
|
* toast error message.
|
|
*
|
|
* I also removed the `showToast` parameter from the submit() method entirely because my
|
|
* understanding is that now all errors will be visually handled by the reactive form via
|
|
* this.formGroup.markAllAsTouched()
|
|
*
|
|
* Therefore below I am simply checking if the form is invalid and returning if so.
|
|
*/
|
|
this.formGroup.markAllAsTouched();
|
|
if (this.formGroup.invalid) {
|
|
return;
|
|
}
|
|
|
|
const credentials = new PasswordLoginCredentials(
|
|
email,
|
|
masterPassword,
|
|
this.captchaToken,
|
|
null,
|
|
);
|
|
|
|
const authResult = await this.loginStrategyService.logIn(credentials);
|
|
|
|
await this.saveEmailSettings();
|
|
await this.handleAuthResult(authResult);
|
|
|
|
if (this.clientType === ClientType.Desktop) {
|
|
if (this.captchaSiteKey) {
|
|
const content = document.getElementById("content") as HTMLDivElement;
|
|
content.setAttribute("style", "width:335px");
|
|
}
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Handles the result of the authentication process.
|
|
*
|
|
* @param authResult
|
|
* @returns A simple `return` statement for each conditional check.
|
|
* If you update this method, do not forget to add a `return`
|
|
* to each if-condition block where necessary to stop code execution.
|
|
*/
|
|
private async handleAuthResult(authResult: AuthResult): Promise<void> {
|
|
if (this.handleCaptchaRequired(authResult)) {
|
|
this.captchaSiteKey = authResult.captchaSiteKey;
|
|
this.captcha.init(authResult.captchaSiteKey);
|
|
return;
|
|
}
|
|
|
|
if (authResult.requiresEncryptionKeyMigration) {
|
|
/* Legacy accounts used the master key to encrypt data.
|
|
Migration is required but only performed on Web. */
|
|
if (this.clientType === ClientType.Web) {
|
|
await this.router.navigate(["migrate-legacy-encryption"]);
|
|
} else {
|
|
this.toastService.showToast({
|
|
variant: "error",
|
|
title: this.i18nService.t("errorOccured"),
|
|
message: this.i18nService.t("encryptionKeyMigrationRequired"),
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (authResult.requiresTwoFactor) {
|
|
await this.router.navigate(["2fa"]);
|
|
return;
|
|
}
|
|
|
|
await this.syncService.fullSync(true);
|
|
|
|
if (authResult.forcePasswordReset != ForceSetPasswordReason.None) {
|
|
this.loginEmailService.clearValues();
|
|
await this.router.navigate(["update-temp-password"]);
|
|
return;
|
|
}
|
|
|
|
// If none of the above cases are true, proceed with login...
|
|
// ...on Web
|
|
if (this.clientType === ClientType.Web) {
|
|
await this.goAfterLogIn(authResult.userId);
|
|
return;
|
|
|
|
// ...on Browser/Desktop
|
|
} else {
|
|
this.loginEmailService.clearValues();
|
|
|
|
if (this.clientType === ClientType.Browser) {
|
|
await this.router.navigate(["/tabs/vault"]);
|
|
} else {
|
|
await this.router.navigate(["vault"]); // Desktop
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
protected async launchSsoBrowserWindow(clientId: "browser" | "desktop"): Promise<void> {
|
|
await this.loginComponentService.launchSsoBrowserWindow(this.loggedEmail, clientId);
|
|
}
|
|
|
|
protected async goAfterLogIn(userId: UserId): Promise<void> {
|
|
const masterPassword = this.formGroup.value.masterPassword;
|
|
|
|
// Check master password against policy
|
|
if (this.enforcedPasswordPolicyOptions != null) {
|
|
const strengthResult = this.passwordStrengthService.getPasswordStrength(
|
|
masterPassword,
|
|
this.formGroup.value.email,
|
|
);
|
|
const masterPasswordScore = strengthResult == null ? null : strengthResult.score;
|
|
|
|
// If invalid, save policies and require update
|
|
if (
|
|
!this.policyService.evaluateMasterPassword(
|
|
masterPasswordScore,
|
|
masterPassword,
|
|
this.enforcedPasswordPolicyOptions,
|
|
)
|
|
) {
|
|
const policiesData: { [id: string]: PolicyData } = {};
|
|
this.policies.map((p) => (policiesData[p.id] = PolicyData.fromPolicy(p)));
|
|
await this.policyService.replace(policiesData, userId);
|
|
await this.router.navigate(["update-password"]);
|
|
return;
|
|
}
|
|
}
|
|
|
|
/* TODO-rr-bw: these two lines are also used at the end of the submit method for
|
|
Browser/Desktop. See if you can consolidate for all 3 clients. */
|
|
this.loginEmailService.clearValues();
|
|
await this.router.navigate(["vault"]);
|
|
}
|
|
|
|
protected showCaptcha(): boolean {
|
|
return !Utils.isNullOrWhitespace(this.captchaSiteKey);
|
|
}
|
|
|
|
protected async startAuthRequestLogin(): Promise<void> {
|
|
this.formGroup.get("masterPassword")?.clearValidators();
|
|
this.formGroup.get("masterPassword")?.updateValueAndValidity();
|
|
|
|
if (!this.formGroup.valid) {
|
|
return;
|
|
}
|
|
|
|
await this.saveEmailSettings();
|
|
await this.router.navigate(["/login-with-device"]);
|
|
}
|
|
|
|
protected async validateEmail(): Promise<void> {
|
|
this.formGroup.controls.email.markAsTouched();
|
|
const emailValid = this.formGroup.controls.email.valid;
|
|
|
|
if (emailValid) {
|
|
this.toggleValidateEmail(true);
|
|
await this.getLoginWithDevice(this.loggedEmail);
|
|
|
|
this.anonLayoutWrapperDataService.setAnonLayoutWrapperData({
|
|
pageTitle: "welcomeBack",
|
|
pageSubtitle: {
|
|
// TODO-rr-bw: add an icon that takes the user back
|
|
subtitle: `(todo: back btn) ${this.loggedEmail}`,
|
|
translate: false,
|
|
},
|
|
pageIcon: this.Icons.WaveIcon,
|
|
});
|
|
}
|
|
}
|
|
|
|
protected toggleValidateEmail(value: boolean): void {
|
|
this.validatedEmail = value;
|
|
|
|
if (!this.validatedEmail) {
|
|
// Reset master password only when going from validated to not validated so that autofill can work properly
|
|
this.formGroup.controls.masterPassword.reset();
|
|
} else {
|
|
// Mark MP as untouched so that, when users enter email and hit enter, the MP field doesn't load with validation errors
|
|
this.formGroup.controls.masterPassword.markAsUntouched();
|
|
|
|
// When email is validated, focus on master password after waiting for input to be rendered
|
|
if (this.ngZone.isStable) {
|
|
this.masterPasswordInputRef?.nativeElement?.focus();
|
|
} else {
|
|
this.ngZone.onStable.pipe(take(1), takeUntil(this.destroy$)).subscribe(() => {
|
|
this.masterPasswordInputRef?.nativeElement?.focus();
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
protected async goToHint(): Promise<void> {
|
|
await this.saveEmailSettings();
|
|
await this.router.navigateByUrl("/hint");
|
|
}
|
|
|
|
protected async goToRegister(): Promise<void> {
|
|
// TODO: remove when email verification flag is removed
|
|
const registerRoute = await firstValueFrom(this.registerRoute$);
|
|
|
|
if (this.emailFormControl.valid) {
|
|
await this.router.navigate([registerRoute], {
|
|
queryParams: { email: this.emailFormControl.value },
|
|
});
|
|
return;
|
|
}
|
|
|
|
await this.router.navigate([registerRoute]);
|
|
}
|
|
|
|
protected async saveEmailSettings(): Promise<void> {
|
|
this.loginEmailService.setLoginEmail(this.formGroup.value.email);
|
|
this.loginEmailService.setRememberEmail(this.formGroup.value.rememberEmail);
|
|
await this.loginEmailService.saveEmailSettings();
|
|
}
|
|
|
|
protected async continue(): Promise<void> {
|
|
await this.validateEmail();
|
|
|
|
if (!this.formGroup.controls.email.valid) {
|
|
this.toastService.showToast({
|
|
variant: "error",
|
|
title: this.i18nService.t("errorOccured"),
|
|
message: this.i18nService.t("invalidEmail"),
|
|
});
|
|
return;
|
|
}
|
|
|
|
this.focusInput();
|
|
}
|
|
|
|
private async getLoginWithDevice(email: string): Promise<void> {
|
|
try {
|
|
const deviceIdentifier = await this.appIdService.getAppId();
|
|
this.showLoginWithDevice = await this.devicesApiService.getKnownDevice(
|
|
email,
|
|
deviceIdentifier,
|
|
);
|
|
} catch (e) {
|
|
this.showLoginWithDevice = false;
|
|
}
|
|
}
|
|
|
|
private async setupCaptcha(): Promise<void> {
|
|
const env = await firstValueFrom(this.environmentService.environment$);
|
|
const webVaultUrl = env.getWebVaultUrl();
|
|
|
|
this.captcha = new CaptchaIFrame(
|
|
window,
|
|
webVaultUrl,
|
|
this.i18nService,
|
|
(token: string) => {
|
|
this.captchaToken = token;
|
|
},
|
|
(error: string) => {
|
|
this.toastService.showToast({
|
|
variant: "error",
|
|
title: this.i18nService.t("errorOccurred"),
|
|
message: error,
|
|
});
|
|
},
|
|
(info: string) => {
|
|
this.toastService.showToast({
|
|
variant: "info",
|
|
title: this.i18nService.t("info"),
|
|
message: info,
|
|
});
|
|
},
|
|
);
|
|
}
|
|
|
|
private handleCaptchaRequired(authResult: AuthResult): boolean {
|
|
return !Utils.isNullOrWhitespace(authResult.captchaSiteKey);
|
|
}
|
|
|
|
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);
|
|
} 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);
|
|
}
|
|
}
|
|
}
|
|
|
|
private focusInput() {
|
|
const email = this.loggedEmail;
|
|
document.getElementById(email == null || email === "" ? "email" : "masterPassword")?.focus();
|
|
}
|
|
|
|
private async defaultOnInit(): Promise<void> {
|
|
let paramEmailIsSet = false;
|
|
|
|
this.activatedRoute?.queryParams
|
|
.pipe(
|
|
switchMap((params) => {
|
|
if (!params) {
|
|
// If no params,loadEmailSettings from state
|
|
return this.loadEmailSettings();
|
|
}
|
|
|
|
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 is no email in the query params, loadEmailSettings from state
|
|
return paramEmailIsSet ? of(null) : this.loadEmailSettings();
|
|
}),
|
|
takeUntil(this.destroy$),
|
|
)
|
|
.subscribe();
|
|
|
|
// 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 webOnInit(): Promise<void> {
|
|
this.activatedRoute.queryParams.pipe(first(), takeUntil(this.destroy$)).subscribe((qParams) => {
|
|
if (qParams.org != null) {
|
|
const route = this.router.createUrlTree(["create-organization"], {
|
|
queryParams: { plan: qParams.org },
|
|
});
|
|
this.loginComponentService.setPreviousUrl(route);
|
|
}
|
|
|
|
/* If there is a parameter called 'sponsorshipToken', they are coming
|
|
from an email for sponsoring a families organization. Therefore set
|
|
the prevousUrl to /setup/families-for-enterprise?token=<paramValue> */
|
|
if (qParams.sponsorshipToken != null) {
|
|
const route = this.router.createUrlTree(["setup/families-for-enterprise"], {
|
|
queryParams: { token: qParams.sponsorshipToken },
|
|
});
|
|
this.loginComponentService.setPreviousUrl(route);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* TODO-rr-bw: Verify the following
|
|
*
|
|
* In the original web login component, this following code is called AFTER the base ngOnInit()
|
|
* runs. Verify that that previous order was not necessary, and that I can place all webOnInit()
|
|
* logic here BEFORE the defaultOnInit() call.
|
|
*/
|
|
// If there's an existing org invite, use it to get the password policies
|
|
const orgPolicies = await this.loginComponentService.getOrgPolicies();
|
|
|
|
this.policies = orgPolicies?.policies;
|
|
this.showResetPasswordAutoEnrollWarning = orgPolicies?.isPolicyAndAutoEnrollEnabled;
|
|
this.enforcedPasswordPolicyOptions = orgPolicies?.enforcedPasswordPolicyOptions;
|
|
}
|
|
|
|
private async desktopOnInit(): Promise<void> {
|
|
await this.getLoginWithDevice(this.loggedEmail);
|
|
|
|
// TODO-rr-bw: 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");
|
|
}
|
|
}
|