From 7e2b4d9652d8fb8087ad10c7bef340e7624cbad8 Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Tue, 9 Jul 2024 16:19:04 +0200 Subject: [PATCH] [PM-7084] 2/6: Add shared two-factor-auth orchestrator component, and TOTP two-factor component (#9768) * Add shared two-factor-options component * Add new refactored two-factor-auth component and totp auth componnet behind feature flag * Fix default value for twofactorcomponentrefactor featureflag --- .../auth/popup/two-factor-auth.component.ts | 151 ++++++ apps/browser/src/popup/app-routing.module.ts | 28 +- apps/desktop/src/app/app-routing.module.ts | 21 +- .../src/auth/two-factor-auth.component.ts | 41 ++ .../src/app/auth/two-factor-auth.component.ts | 107 ++++ apps/web/src/app/oss-routing.module.ts | 7 +- ...o-factor-auth-authenticator.component.html | 16 + ...two-factor-auth-authenticator.component.ts | 37 ++ .../two-factor-auth.component.html | 40 ++ .../two-factor-auth.component.spec.ts | 502 ++++++++++++++++++ .../two-factor-auth.component.ts | 394 ++++++++++++++ ...wo-factor-component-refactor-route-swap.ts | 31 ++ libs/common/src/enums/feature-flag.enum.ts | 2 + 13 files changed, 1367 insertions(+), 10 deletions(-) create mode 100644 apps/browser/src/auth/popup/two-factor-auth.component.ts create mode 100644 apps/desktop/src/auth/two-factor-auth.component.ts create mode 100644 apps/web/src/app/auth/two-factor-auth.component.ts create mode 100644 libs/angular/src/auth/components/two-factor-auth/two-factor-auth-authenticator.component.html create mode 100644 libs/angular/src/auth/components/two-factor-auth/two-factor-auth-authenticator.component.ts create mode 100644 libs/angular/src/auth/components/two-factor-auth/two-factor-auth.component.html create mode 100644 libs/angular/src/auth/components/two-factor-auth/two-factor-auth.component.spec.ts create mode 100644 libs/angular/src/auth/components/two-factor-auth/two-factor-auth.component.ts create mode 100644 libs/angular/src/utils/two-factor-component-refactor-route-swap.ts diff --git a/apps/browser/src/auth/popup/two-factor-auth.component.ts b/apps/browser/src/auth/popup/two-factor-auth.component.ts new file mode 100644 index 0000000000..8fe735bd16 --- /dev/null +++ b/apps/browser/src/auth/popup/two-factor-auth.component.ts @@ -0,0 +1,151 @@ +import { CommonModule } from "@angular/common"; +import { Component, Inject, OnInit } from "@angular/core"; +import { FormBuilder, ReactiveFormsModule } from "@angular/forms"; +import { ActivatedRoute, Router, RouterLink } from "@angular/router"; + +import { TwoFactorAuthAuthenticatorComponent } from "@bitwarden/angular/auth/components/two-factor-auth/two-factor-auth-authenticator.component"; +import { TwoFactorAuthComponent as BaseTwoFactorAuthComponent } from "@bitwarden/angular/auth/components/two-factor-auth/two-factor-auth.component"; +import { TwoFactorOptionsComponent } from "@bitwarden/angular/auth/components/two-factor-auth/two-factor-options.component"; +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe"; +import { WINDOW } from "@bitwarden/angular/services/injection-tokens"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; +import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; +import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; +import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.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"; +import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { SyncService } from "@bitwarden/common/platform/sync"; +import { + ButtonModule, + FormFieldModule, + AsyncActionsModule, + CheckboxModule, + DialogModule, + LinkModule, + TypographyModule, + DialogService, +} from "@bitwarden/components"; + +import { + LoginStrategyServiceAbstraction, + LoginEmailServiceAbstraction, + UserDecryptionOptionsServiceAbstraction, +} from "../../../../../libs/auth/src/common/abstractions"; +import { BrowserApi } from "../../platform/browser/browser-api"; +import BrowserPopupUtils from "../../platform/popup/browser-popup-utils"; + +@Component({ + standalone: true, + templateUrl: + "../../../../../libs/angular/src/auth/components/two-factor-auth/two-factor-auth.component.html", + selector: "app-two-factor-auth", + imports: [ + CommonModule, + JslibModule, + DialogModule, + ButtonModule, + LinkModule, + TypographyModule, + ReactiveFormsModule, + FormFieldModule, + AsyncActionsModule, + RouterLink, + CheckboxModule, + TwoFactorOptionsComponent, + TwoFactorAuthAuthenticatorComponent, + ], + providers: [I18nPipe], +}) +export class TwoFactorAuthComponent extends BaseTwoFactorAuthComponent implements OnInit { + constructor( + protected loginStrategyService: LoginStrategyServiceAbstraction, + protected router: Router, + i18nService: I18nService, + platformUtilsService: PlatformUtilsService, + environmentService: EnvironmentService, + dialogService: DialogService, + protected route: ActivatedRoute, + logService: LogService, + protected twoFactorService: TwoFactorService, + loginEmailService: LoginEmailServiceAbstraction, + userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction, + protected ssoLoginService: SsoLoginServiceAbstraction, + protected configService: ConfigService, + masterPasswordService: InternalMasterPasswordServiceAbstraction, + accountService: AccountService, + formBuilder: FormBuilder, + @Inject(WINDOW) protected win: Window, + private syncService: SyncService, + private messagingService: MessagingService, + ) { + super( + loginStrategyService, + router, + i18nService, + platformUtilsService, + environmentService, + dialogService, + route, + logService, + twoFactorService, + loginEmailService, + userDecryptionOptionsService, + ssoLoginService, + configService, + masterPasswordService, + accountService, + formBuilder, + win, + ); + super.onSuccessfulLoginTdeNavigate = async () => { + this.win.close(); + }; + this.onSuccessfulLoginNavigate = this.goAfterLogIn; + } + + async ngOnInit(): Promise { + await super.ngOnInit(); + + if (this.route.snapshot.paramMap.has("webAuthnResponse")) { + // WebAuthn fallback response + this.selectedProviderType = TwoFactorProviderType.WebAuthn; + this.token = this.route.snapshot.paramMap.get("webAuthnResponse"); + super.onSuccessfulLogin = async () => { + // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.syncService.fullSync(true); + this.messagingService.send("reloadPopup"); + window.close(); + }; + this.remember = this.route.snapshot.paramMap.get("remember") === "true"; + await this.submit(); + return; + } + + if (await BrowserPopupUtils.inPopout(this.win)) { + this.selectedProviderType = TwoFactorProviderType.Email; + } + + // WebAuthn prompt appears inside the popup on linux, and requires a larger popup width + // than usual to avoid cutting off the dialog. + if (this.selectedProviderType === TwoFactorProviderType.WebAuthn && (await this.isLinux())) { + document.body.classList.add("linux-webauthn"); + } + } + + async ngOnDestroy() { + if (this.selectedProviderType === TwoFactorProviderType.WebAuthn && (await this.isLinux())) { + document.body.classList.remove("linux-webauthn"); + } + } + + async isLinux() { + return (await BrowserApi.getPlatformInfo()).os === "linux"; + } +} diff --git a/apps/browser/src/popup/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts index 4b28444f9b..ff8dc7eeb6 100644 --- a/apps/browser/src/popup/app-routing.module.ts +++ b/apps/browser/src/popup/app-routing.module.ts @@ -19,6 +19,7 @@ import { } from "@bitwarden/auth/angular"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { twofactorRefactorSwap } from "../../../../libs/angular/src/utils/two-factor-component-refactor-route-swap"; import { fido2AuthGuard } from "../auth/guards/fido2-auth.guard"; import { AccountSwitcherComponent } from "../auth/popup/account-switching/account-switcher.component"; import { EnvironmentComponent } from "../auth/popup/environment.component"; @@ -33,6 +34,7 @@ import { RemovePasswordComponent } from "../auth/popup/remove-password.component import { SetPasswordComponent } from "../auth/popup/set-password.component"; import { AccountSecurityComponent } from "../auth/popup/settings/account-security.component"; import { SsoComponent } from "../auth/popup/sso.component"; +import { TwoFactorAuthComponent } from "../auth/popup/two-factor-auth.component"; import { TwoFactorOptionsComponent } from "../auth/popup/two-factor-options.component"; import { TwoFactorComponent } from "../auth/popup/two-factor.component"; import { UpdateTempPasswordComponent } from "../auth/popup/update-temp-password.component"; @@ -137,12 +139,26 @@ const routes: Routes = [ canActivate: [lockGuard()], data: { state: "lock", doNotSaveUrl: true }, }, - { - path: "2fa", - component: TwoFactorComponent, - canActivate: [unauthGuardFn(unauthRouteOverrides)], - data: { state: "2fa" }, - }, + ...twofactorRefactorSwap( + TwoFactorComponent, + AnonLayoutWrapperComponent, + { + path: "2fa", + canActivate: [unauthGuardFn(unauthRouteOverrides)], + data: { state: "2fa" }, + }, + { + path: "2fa", + canActivate: [unauthGuardFn(unauthRouteOverrides)], + data: { state: "2fa" }, + children: [ + { + path: "", + component: TwoFactorAuthComponent, + }, + ], + }, + ), { path: "2fa-options", component: TwoFactorOptionsComponent, diff --git a/apps/desktop/src/app/app-routing.module.ts b/apps/desktop/src/app/app-routing.module.ts index c7a66f510c..0e3f10345a 100644 --- a/apps/desktop/src/app/app-routing.module.ts +++ b/apps/desktop/src/app/app-routing.module.ts @@ -19,6 +19,7 @@ import { } from "@bitwarden/auth/angular"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { twofactorRefactorSwap } from "../../../../libs/angular/src/utils/two-factor-component-refactor-route-swap"; import { AccessibilityCookieComponent } from "../auth/accessibility-cookie.component"; import { maxAccountsGuardFn } from "../auth/guards/max-accounts.guard"; import { HintComponent } from "../auth/hint.component"; @@ -30,6 +31,7 @@ import { RegisterComponent } from "../auth/register.component"; import { RemovePasswordComponent } from "../auth/remove-password.component"; import { SetPasswordComponent } from "../auth/set-password.component"; import { SsoComponent } from "../auth/sso.component"; +import { TwoFactorAuthComponent } from "../auth/two-factor-auth.component"; import { TwoFactorComponent } from "../auth/two-factor.component"; import { UpdateTempPasswordComponent } from "../auth/update-temp-password.component"; import { VaultComponent } from "../vault/app/vault/vault.component"; @@ -61,7 +63,24 @@ const routes: Routes = [ path: "admin-approval-requested", component: LoginViaAuthRequestComponent, }, - { path: "2fa", component: TwoFactorComponent }, + ...twofactorRefactorSwap( + TwoFactorComponent, + AnonLayoutWrapperComponent, + { + path: "2fa", + }, + { + path: "2fa", + component: AnonLayoutWrapperComponent, + children: [ + { + path: "", + component: TwoFactorAuthComponent, + canActivate: [unauthGuardFn()], + }, + ], + }, + ), { path: "login-initiated", component: LoginDecryptionOptionsComponent, diff --git a/apps/desktop/src/auth/two-factor-auth.component.ts b/apps/desktop/src/auth/two-factor-auth.component.ts new file mode 100644 index 0000000000..3f25987665 --- /dev/null +++ b/apps/desktop/src/auth/two-factor-auth.component.ts @@ -0,0 +1,41 @@ +import { DialogModule } from "@angular/cdk/dialog"; +import { CommonModule } from "@angular/common"; +import { Component } from "@angular/core"; +import { ReactiveFormsModule } from "@angular/forms"; +import { RouterLink } from "@angular/router"; + +import { TwoFactorAuthAuthenticatorComponent } from "../../../../libs/angular/src/auth/components/two-factor-auth/two-factor-auth-authenticator.component"; +import { TwoFactorAuthComponent as BaseTwoFactorAuthComponent } from "../../../../libs/angular/src/auth/components/two-factor-auth/two-factor-auth.component"; +import { TwoFactorOptionsComponent } from "../../../../libs/angular/src/auth/components/two-factor-auth/two-factor-options.component"; +import { JslibModule } from "../../../../libs/angular/src/jslib.module"; +import { AsyncActionsModule } from "../../../../libs/components/src/async-actions"; +import { ButtonModule } from "../../../../libs/components/src/button"; +import { CheckboxModule } from "../../../../libs/components/src/checkbox"; +import { FormFieldModule } from "../../../../libs/components/src/form-field"; +import { LinkModule } from "../../../../libs/components/src/link"; +import { I18nPipe } from "../../../../libs/components/src/shared/i18n.pipe"; +import { TypographyModule } from "../../../../libs/components/src/typography"; + +@Component({ + standalone: true, + templateUrl: + "../../../../libs/angular/src/auth/components/two-factor-auth/two-factor-auth.component.html", + selector: "app-two-factor-auth", + imports: [ + CommonModule, + JslibModule, + DialogModule, + ButtonModule, + LinkModule, + TypographyModule, + ReactiveFormsModule, + FormFieldModule, + AsyncActionsModule, + RouterLink, + CheckboxModule, + TwoFactorOptionsComponent, + TwoFactorAuthAuthenticatorComponent, + ], + providers: [I18nPipe], +}) +export class TwoFactorAuthComponent extends BaseTwoFactorAuthComponent {} diff --git a/apps/web/src/app/auth/two-factor-auth.component.ts b/apps/web/src/app/auth/two-factor-auth.component.ts new file mode 100644 index 0000000000..8860303596 --- /dev/null +++ b/apps/web/src/app/auth/two-factor-auth.component.ts @@ -0,0 +1,107 @@ +import { DialogModule } from "@angular/cdk/dialog"; +import { CommonModule } from "@angular/common"; +import { Component, Inject } from "@angular/core"; +import { FormBuilder, ReactiveFormsModule } from "@angular/forms"; +import { ActivatedRoute, Router, RouterLink } from "@angular/router"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe"; +import { WINDOW } from "@bitwarden/angular/services/injection-tokens"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; +import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; +import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; +import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.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"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { LinkModule, TypographyModule, CheckboxModule, DialogService } from "@bitwarden/components"; + +import { TwoFactorAuthAuthenticatorComponent } from "../../../../../libs/angular/src/auth/components/two-factor-auth/two-factor-auth-authenticator.component"; +import { TwoFactorAuthComponent as BaseTwoFactorAuthComponent } from "../../../../../libs/angular/src/auth/components/two-factor-auth/two-factor-auth.component"; +import { TwoFactorOptionsComponent } from "../../../../../libs/angular/src/auth/components/two-factor-auth/two-factor-options.component"; +import { + LoginStrategyServiceAbstraction, + LoginEmailServiceAbstraction, + UserDecryptionOptionsServiceAbstraction, +} from "../../../../../libs/auth/src/common/abstractions"; +import { AsyncActionsModule } from "../../../../../libs/components/src/async-actions"; +import { ButtonModule } from "../../../../../libs/components/src/button"; +import { FormFieldModule } from "../../../../../libs/components/src/form-field"; + +@Component({ + standalone: true, + templateUrl: + "../../../../../libs/angular/src/auth/components/two-factor-auth/two-factor-auth.component.html", + selector: "app-two-factor-auth", + imports: [ + CommonModule, + JslibModule, + DialogModule, + ButtonModule, + LinkModule, + TypographyModule, + ReactiveFormsModule, + FormFieldModule, + AsyncActionsModule, + RouterLink, + CheckboxModule, + TwoFactorOptionsComponent, + TwoFactorAuthAuthenticatorComponent, + ], + providers: [I18nPipe], +}) +export class TwoFactorAuthComponent extends BaseTwoFactorAuthComponent { + constructor( + protected loginStrategyService: LoginStrategyServiceAbstraction, + protected router: Router, + i18nService: I18nService, + platformUtilsService: PlatformUtilsService, + environmentService: EnvironmentService, + dialogService: DialogService, + protected route: ActivatedRoute, + logService: LogService, + protected twoFactorService: TwoFactorService, + loginEmailService: LoginEmailServiceAbstraction, + userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction, + protected ssoLoginService: SsoLoginServiceAbstraction, + protected configService: ConfigService, + masterPasswordService: InternalMasterPasswordServiceAbstraction, + accountService: AccountService, + formBuilder: FormBuilder, + @Inject(WINDOW) protected win: Window, + ) { + super( + loginStrategyService, + router, + i18nService, + platformUtilsService, + environmentService, + dialogService, + route, + logService, + twoFactorService, + loginEmailService, + userDecryptionOptionsService, + ssoLoginService, + configService, + masterPasswordService, + accountService, + formBuilder, + win, + ); + this.onSuccessfulLoginNavigate = this.goAfterLogIn; + } + + protected override handleMigrateEncryptionKey(result: AuthResult): boolean { + if (!result.requiresEncryptionKeyMigration) { + return false; + } + // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.router.navigate(["migrate-legacy-encryption"]); + return true; + } +} diff --git a/apps/web/src/app/oss-routing.module.ts b/apps/web/src/app/oss-routing.module.ts index 9e769fe063..246f4bdc40 100644 --- a/apps/web/src/app/oss-routing.module.ts +++ b/apps/web/src/app/oss-routing.module.ts @@ -20,6 +20,7 @@ import { } from "@bitwarden/auth/angular"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { twofactorRefactorSwap } from "../../../../libs/angular/src/utils/two-factor-component-refactor-route-swap"; import { flagEnabled, Flags } from "../utils/flags"; import { VerifyRecoverDeleteOrgComponent } from "./admin-console/organizations/manage/verify-recover-delete-org.component"; @@ -46,6 +47,7 @@ import { EmergencyAccessViewComponent } from "./auth/settings/emergency-access/v import { SecurityRoutingModule } from "./auth/settings/security/security-routing.module"; import { SsoComponent } from "./auth/sso.component"; import { TrialInitiationComponent } from "./auth/trial-initiation/trial-initiation.component"; +import { TwoFactorAuthComponent } from "./auth/two-factor-auth.component"; import { TwoFactorComponent } from "./auth/two-factor.component"; import { UpdatePasswordComponent } from "./auth/update-password.component"; import { UpdateTempPasswordComponent } from "./auth/update-temp-password.component"; @@ -248,10 +250,9 @@ const routes: Routes = [ path: "2fa", canActivate: [unauthGuardFn()], children: [ - { + ...twofactorRefactorSwap(TwoFactorComponent, TwoFactorAuthComponent, { path: "", - component: TwoFactorComponent, - }, + }), { path: "", component: EnvironmentSelectorComponent, diff --git a/libs/angular/src/auth/components/two-factor-auth/two-factor-auth-authenticator.component.html b/libs/angular/src/auth/components/two-factor-auth/two-factor-auth-authenticator.component.html new file mode 100644 index 0000000000..e738b1eb8c --- /dev/null +++ b/libs/angular/src/auth/components/two-factor-auth/two-factor-auth-authenticator.component.html @@ -0,0 +1,16 @@ + +

+ {{ "enterVerificationCodeApp" | i18n }} +

+ + {{ "verificationCode" | i18n }} + + +
diff --git a/libs/angular/src/auth/components/two-factor-auth/two-factor-auth-authenticator.component.ts b/libs/angular/src/auth/components/two-factor-auth/two-factor-auth-authenticator.component.ts new file mode 100644 index 0000000000..59359ab873 --- /dev/null +++ b/libs/angular/src/auth/components/two-factor-auth/two-factor-auth-authenticator.component.ts @@ -0,0 +1,37 @@ +import { DialogModule } from "@angular/cdk/dialog"; +import { CommonModule } from "@angular/common"; +import { Component, EventEmitter, Output } from "@angular/core"; +import { ReactiveFormsModule, FormsModule } from "@angular/forms"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe"; +import { + ButtonModule, + LinkModule, + TypographyModule, + FormFieldModule, + AsyncActionsModule, +} from "@bitwarden/components"; + +@Component({ + standalone: true, + selector: "app-two-factor-auth-authenticator", + templateUrl: "two-factor-auth-authenticator.component.html", + imports: [ + CommonModule, + JslibModule, + DialogModule, + ButtonModule, + LinkModule, + TypographyModule, + ReactiveFormsModule, + FormFieldModule, + AsyncActionsModule, + FormsModule, + ], + providers: [I18nPipe], +}) +export class TwoFactorAuthAuthenticatorComponent { + tokenValue: string; + @Output() token = new EventEmitter(); +} diff --git a/libs/angular/src/auth/components/two-factor-auth/two-factor-auth.component.html b/libs/angular/src/auth/components/two-factor-auth/two-factor-auth.component.html new file mode 100644 index 0000000000..af34164c2c --- /dev/null +++ b/libs/angular/src/auth/components/two-factor-auth/two-factor-auth.component.html @@ -0,0 +1,40 @@ +
+
+ + + {{ "rememberMe" | i18n }} + + + +

{{ "noTwoStepProviders" | i18n }}

+

{{ "noTwoStepProviders2" | i18n }}

+
+
+
+ +
+ +
+ + + {{ "cancel" | i18n }} + +
+ +
+
diff --git a/libs/angular/src/auth/components/two-factor-auth/two-factor-auth.component.spec.ts b/libs/angular/src/auth/components/two-factor-auth/two-factor-auth.component.spec.ts new file mode 100644 index 0000000000..04532f7fcb --- /dev/null +++ b/libs/angular/src/auth/components/two-factor-auth/two-factor-auth.component.spec.ts @@ -0,0 +1,502 @@ +import { Component } from "@angular/core"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { ActivatedRoute, convertToParamMap, Router } from "@angular/router"; +import { mock, MockProxy } from "jest-mock-extended"; +import { BehaviorSubject } from "rxjs"; + +// eslint-disable-next-line no-restricted-imports +import { WINDOW } from "@bitwarden/angular/services/injection-tokens"; +import { + LoginStrategyServiceAbstraction, + LoginEmailServiceAbstraction, + FakeKeyConnectorUserDecryptionOption as KeyConnectorUserDecryptionOption, + FakeTrustedDeviceUserDecryptionOption as TrustedDeviceUserDecryptionOption, + FakeUserDecryptionOptions as UserDecryptionOptions, + UserDecryptionOptionsServiceAbstraction, +} from "@bitwarden/auth/common"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; +import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; +import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; +import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result"; +import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; +import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/identity-token/token-two-factor.request"; +import { FakeMasterPasswordService } from "@bitwarden/common/auth/services/master-password/fake-master-password.service"; +import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { + Environment, + 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"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; +import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec"; +import { UserId } from "@bitwarden/common/types/guid"; +import { DialogService } from "@bitwarden/components"; + +import { TwoFactorAuthComponent } from "./two-factor-auth.component"; + +// test component that extends the TwoFactorAuthComponent +@Component({}) +class TestTwoFactorComponent extends TwoFactorAuthComponent {} + +interface TwoFactorComponentProtected { + trustedDeviceEncRoute: string; + changePasswordRoute: string; + forcePasswordResetRoute: string; + successRoute: string; +} + +describe("TwoFactorComponent", () => { + let component: TestTwoFactorComponent; + let _component: TwoFactorComponentProtected; + + let fixture: ComponentFixture; + const userId = "userId" as UserId; + + // Mock Services + let mockLoginStrategyService: MockProxy; + let mockRouter: MockProxy; + let mockI18nService: MockProxy; + let mockApiService: MockProxy; + let mockPlatformUtilsService: MockProxy; + let mockWin: MockProxy; + let mockEnvironmentService: MockProxy; + let mockStateService: MockProxy; + let mockLogService: MockProxy; + let mockTwoFactorService: MockProxy; + let mockAppIdService: MockProxy; + let mockLoginEmailService: MockProxy; + let mockUserDecryptionOptionsService: MockProxy; + let mockSsoLoginService: MockProxy; + let mockConfigService: MockProxy; + let mockMasterPasswordService: FakeMasterPasswordService; + let mockAccountService: FakeAccountService; + let mockDialogService: MockProxy; + + let mockUserDecryptionOpts: { + noMasterPassword: UserDecryptionOptions; + withMasterPassword: UserDecryptionOptions; + withMasterPasswordAndTrustedDevice: UserDecryptionOptions; + withMasterPasswordAndTrustedDeviceWithManageResetPassword: UserDecryptionOptions; + withMasterPasswordAndKeyConnector: UserDecryptionOptions; + noMasterPasswordWithTrustedDevice: UserDecryptionOptions; + noMasterPasswordWithTrustedDeviceWithManageResetPassword: UserDecryptionOptions; + noMasterPasswordWithKeyConnector: UserDecryptionOptions; + }; + + let selectedUserDecryptionOptions: BehaviorSubject; + + beforeEach(() => { + mockLoginStrategyService = mock(); + mockRouter = mock(); + mockI18nService = mock(); + mockApiService = mock(); + mockPlatformUtilsService = mock(); + mockWin = mock(); + const mockEnvironment = mock(); + mockEnvironment.getWebVaultUrl.mockReturnValue("http://example.com"); + mockEnvironmentService = mock(); + mockEnvironmentService.environment$ = new BehaviorSubject(mockEnvironment); + + mockStateService = mock(); + mockLogService = mock(); + mockTwoFactorService = mock(); + mockAppIdService = mock(); + mockLoginEmailService = mock(); + mockUserDecryptionOptionsService = mock(); + mockSsoLoginService = mock(); + mockConfigService = mock(); + mockAccountService = mockAccountServiceWith(userId); + mockMasterPasswordService = new FakeMasterPasswordService(); + mockDialogService = mock(); + + mockUserDecryptionOpts = { + noMasterPassword: new UserDecryptionOptions({ + hasMasterPassword: false, + trustedDeviceOption: undefined, + keyConnectorOption: undefined, + }), + withMasterPassword: new UserDecryptionOptions({ + hasMasterPassword: true, + trustedDeviceOption: undefined, + keyConnectorOption: undefined, + }), + withMasterPasswordAndTrustedDevice: new UserDecryptionOptions({ + hasMasterPassword: true, + trustedDeviceOption: new TrustedDeviceUserDecryptionOption(true, false, false), + keyConnectorOption: undefined, + }), + withMasterPasswordAndTrustedDeviceWithManageResetPassword: new UserDecryptionOptions({ + hasMasterPassword: true, + trustedDeviceOption: new TrustedDeviceUserDecryptionOption(true, false, true), + keyConnectorOption: undefined, + }), + withMasterPasswordAndKeyConnector: new UserDecryptionOptions({ + hasMasterPassword: true, + trustedDeviceOption: undefined, + keyConnectorOption: new KeyConnectorUserDecryptionOption("http://example.com"), + }), + noMasterPasswordWithTrustedDevice: new UserDecryptionOptions({ + hasMasterPassword: false, + trustedDeviceOption: new TrustedDeviceUserDecryptionOption(true, false, false), + keyConnectorOption: undefined, + }), + noMasterPasswordWithTrustedDeviceWithManageResetPassword: new UserDecryptionOptions({ + hasMasterPassword: false, + trustedDeviceOption: new TrustedDeviceUserDecryptionOption(true, false, true), + keyConnectorOption: undefined, + }), + noMasterPasswordWithKeyConnector: new UserDecryptionOptions({ + hasMasterPassword: false, + trustedDeviceOption: undefined, + keyConnectorOption: new KeyConnectorUserDecryptionOption("http://example.com"), + }), + }; + + selectedUserDecryptionOptions = new BehaviorSubject(null); + mockUserDecryptionOptionsService.userDecryptionOptions$ = selectedUserDecryptionOptions; + + TestBed.configureTestingModule({ + declarations: [TestTwoFactorComponent], + providers: [ + { provide: LoginStrategyServiceAbstraction, useValue: mockLoginStrategyService }, + { provide: Router, useValue: mockRouter }, + { provide: I18nService, useValue: mockI18nService }, + { provide: ApiService, useValue: mockApiService }, + { provide: PlatformUtilsService, useValue: mockPlatformUtilsService }, + { provide: WINDOW, useValue: mockWin }, + { provide: EnvironmentService, useValue: mockEnvironmentService }, + { provide: StateService, useValue: mockStateService }, + { + provide: ActivatedRoute, + useValue: { + snapshot: { + // Default to standard 2FA flow - not SSO + 2FA + queryParamMap: convertToParamMap({ sso: "false" }), + }, + }, + }, + { provide: LogService, useValue: mockLogService }, + { provide: TwoFactorService, useValue: mockTwoFactorService }, + { provide: AppIdService, useValue: mockAppIdService }, + { provide: LoginEmailServiceAbstraction, useValue: mockLoginEmailService }, + { + provide: UserDecryptionOptionsServiceAbstraction, + useValue: mockUserDecryptionOptionsService, + }, + { provide: SsoLoginServiceAbstraction, useValue: mockSsoLoginService }, + { provide: ConfigService, useValue: mockConfigService }, + { provide: InternalMasterPasswordServiceAbstraction, useValue: mockMasterPasswordService }, + { provide: AccountService, useValue: mockAccountService }, + { provide: DialogService, useValue: mockDialogService }, + ], + }); + + fixture = TestBed.createComponent(TestTwoFactorComponent); + component = fixture.componentInstance; + _component = component as any; + }); + + afterEach(() => { + // Reset all mocks after each test + jest.resetAllMocks(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); + + // Shared tests + const testChangePasswordOnSuccessfulLogin = () => { + it("navigates to the component's defined change password route when user doesn't have a MP and key connector isn't enabled", async () => { + // Act + await component.submit(); + + // Assert + expect(mockRouter.navigate).toHaveBeenCalledTimes(1); + expect(mockRouter.navigate).toHaveBeenCalledWith([_component.changePasswordRoute], { + queryParams: { + identifier: component.orgIdentifier, + }, + }); + }); + }; + + const testForceResetOnSuccessfulLogin = (reasonString: string) => { + it(`navigates to the component's defined forcePasswordResetRoute route when response.forcePasswordReset is ${reasonString}`, async () => { + // Act + await component.submit(); + + // expect(mockRouter.navigate).toHaveBeenCalledTimes(1); + expect(mockRouter.navigate).toHaveBeenCalledWith([_component.forcePasswordResetRoute], { + queryParams: { + identifier: component.orgIdentifier, + }, + }); + }); + }; + + describe("Standard 2FA scenarios", () => { + describe("submit", () => { + const token = "testToken"; + const remember = false; + const captchaToken = "testCaptchaToken"; + + beforeEach(() => { + component.token = token; + component.remember = remember; + component.captchaToken = captchaToken; + + selectedUserDecryptionOptions.next(mockUserDecryptionOpts.withMasterPassword); + }); + + it("calls authService.logInTwoFactor with correct parameters when form is submitted", async () => { + // Arrange + mockLoginStrategyService.logInTwoFactor.mockResolvedValue(new AuthResult()); + + // Act + await component.submit(); + + // Assert + expect(mockLoginStrategyService.logInTwoFactor).toHaveBeenCalledWith( + new TokenTwoFactorRequest(component.selectedProviderType, token, remember), + captchaToken, + ); + }); + + it("should return when handleCaptchaRequired returns true", async () => { + // Arrange + const captchaSiteKey = "testCaptchaSiteKey"; + const authResult = new AuthResult(); + authResult.captchaSiteKey = captchaSiteKey; + + mockLoginStrategyService.logInTwoFactor.mockResolvedValue(authResult); + + // Note: the any casts are required b/c typescript cant recognize that + // handleCaptureRequired is a method on TwoFactorComponent b/c it is inherited + // from the CaptchaProtectedComponent + const handleCaptchaRequiredSpy = jest + .spyOn(component, "handleCaptchaRequired") + .mockReturnValue(true); + + // Act + const result = await component.submit(); + + // Assert + expect(handleCaptchaRequiredSpy).toHaveBeenCalled(); + expect(result).toBeUndefined(); + }); + + it("calls onSuccessfulLogin when defined", async () => { + // Arrange + component.onSuccessfulLogin = jest.fn().mockResolvedValue(undefined); + mockLoginStrategyService.logInTwoFactor.mockResolvedValue(new AuthResult()); + + // Act + await component.submit(); + + // Assert + expect(component.onSuccessfulLogin).toHaveBeenCalled(); + }); + + 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(); + + // Assert + expect(clearValuesSpy).toHaveBeenCalled(); + }); + + describe("Set Master Password scenarios", () => { + beforeEach(() => { + const authResult = new AuthResult(); + mockLoginStrategyService.logInTwoFactor.mockResolvedValue(authResult); + }); + + describe("Given user needs to set a master password", () => { + beforeEach(() => { + // Only need to test the case where the user has no master password to test the primary change mp flow here + selectedUserDecryptionOptions.next(mockUserDecryptionOpts.noMasterPassword); + }); + + testChangePasswordOnSuccessfulLogin(); + }); + + it("does not navigate to the change password route when the user has key connector even if user has no master password", async () => { + selectedUserDecryptionOptions.next( + mockUserDecryptionOpts.noMasterPasswordWithKeyConnector, + ); + + await component.submit(); + + expect(mockRouter.navigate).not.toHaveBeenCalledWith([_component.changePasswordRoute], { + queryParams: { + identifier: component.orgIdentifier, + }, + }); + }); + }); + + describe("Force Master Password Reset scenarios", () => { + [ + ForceSetPasswordReason.AdminForcePasswordReset, + ForceSetPasswordReason.WeakMasterPassword, + ].forEach((forceResetPasswordReason) => { + const reasonString = ForceSetPasswordReason[forceResetPasswordReason]; + + beforeEach(() => { + // use standard user with MP because this test is not concerned with password reset. + selectedUserDecryptionOptions.next(mockUserDecryptionOpts.withMasterPassword); + + const authResult = new AuthResult(); + authResult.forcePasswordReset = forceResetPasswordReason; + mockLoginStrategyService.logInTwoFactor.mockResolvedValue(authResult); + }); + + testForceResetOnSuccessfulLogin(reasonString); + }); + }); + + it("calls onSuccessfulLoginNavigate when the callback is defined", async () => { + // Arrange + component.onSuccessfulLoginNavigate = jest.fn().mockResolvedValue(undefined); + mockLoginStrategyService.logInTwoFactor.mockResolvedValue(new AuthResult()); + + // Act + await component.submit(); + + // Assert + expect(component.onSuccessfulLoginNavigate).toHaveBeenCalled(); + }); + + it("navigates to the component's defined success route when the login is successful and onSuccessfulLoginNavigate is undefined", async () => { + mockLoginStrategyService.logInTwoFactor.mockResolvedValue(new AuthResult()); + + // Act + await component.submit(); + + // Assert + expect(component.onSuccessfulLoginNavigate).not.toBeDefined(); + + expect(mockRouter.navigate).toHaveBeenCalledTimes(1); + expect(mockRouter.navigate).toHaveBeenCalledWith([_component.successRoute], undefined); + }); + }); + }); + + describe("SSO > 2FA scenarios", () => { + beforeEach(() => { + const mockActivatedRoute = TestBed.inject(ActivatedRoute); + mockActivatedRoute.snapshot.queryParamMap.get = jest.fn().mockReturnValue("true"); + }); + + describe("submit", () => { + const token = "testToken"; + const remember = false; + const captchaToken = "testCaptchaToken"; + + beforeEach(() => { + component.token = token; + component.remember = remember; + component.captchaToken = captchaToken; + }); + + describe("Trusted Device Encryption scenarios", () => { + beforeEach(() => { + mockConfigService.getFeatureFlag.mockResolvedValue(true); + }); + + describe("Given Trusted Device Encryption is enabled and user needs to set a master password", () => { + beforeEach(() => { + selectedUserDecryptionOptions.next( + mockUserDecryptionOpts.noMasterPasswordWithTrustedDeviceWithManageResetPassword, + ); + + const authResult = new AuthResult(); + mockLoginStrategyService.logInTwoFactor.mockResolvedValue(authResult); + }); + + it("navigates to the component's defined trusted device encryption route and sets correct flag when user doesn't have a MP and key connector isn't enabled", async () => { + // Act + await component.submit(); + + // Assert + expect(mockMasterPasswordService.mock.setForceSetPasswordReason).toHaveBeenCalledWith( + ForceSetPasswordReason.TdeUserWithoutPasswordHasPasswordResetPermission, + userId, + ); + + expect(mockRouter.navigate).toHaveBeenCalledTimes(1); + expect(mockRouter.navigate).toHaveBeenCalledWith( + [_component.trustedDeviceEncRoute], + undefined, + ); + }); + }); + + describe("Given Trusted Device Encryption is enabled, user doesn't need to set a MP, and forcePasswordReset is required", () => { + [ + ForceSetPasswordReason.AdminForcePasswordReset, + ForceSetPasswordReason.WeakMasterPassword, + ].forEach((forceResetPasswordReason) => { + const reasonString = ForceSetPasswordReason[forceResetPasswordReason]; + + beforeEach(() => { + // use standard user with MP because this test is not concerned with password reset. + selectedUserDecryptionOptions.next( + mockUserDecryptionOpts.withMasterPasswordAndTrustedDevice, + ); + + const authResult = new AuthResult(); + authResult.forcePasswordReset = forceResetPasswordReason; + mockLoginStrategyService.logInTwoFactor.mockResolvedValue(authResult); + }); + + testForceResetOnSuccessfulLogin(reasonString); + }); + }); + + describe("Given Trusted Device Encryption is enabled, user doesn't need to set a MP, and forcePasswordReset is not required", () => { + let authResult; + beforeEach(() => { + selectedUserDecryptionOptions.next( + mockUserDecryptionOpts.withMasterPasswordAndTrustedDevice, + ); + + authResult = new AuthResult(); + authResult.forcePasswordReset = ForceSetPasswordReason.None; + mockLoginStrategyService.logInTwoFactor.mockResolvedValue(authResult); + }); + + it("navigates to the component's defined trusted device encryption route when login is successful and onSuccessfulLoginTdeNavigate is undefined", async () => { + await component.submit(); + + expect(mockRouter.navigate).toHaveBeenCalledTimes(1); + expect(mockRouter.navigate).toHaveBeenCalledWith( + [_component.trustedDeviceEncRoute], + undefined, + ); + }); + + it("calls onSuccessfulLoginTdeNavigate instead of router.navigate when the callback is defined", async () => { + component.onSuccessfulLoginTdeNavigate = jest.fn().mockResolvedValue(undefined); + + await component.submit(); + + expect(mockRouter.navigate).not.toHaveBeenCalled(); + expect(component.onSuccessfulLoginTdeNavigate).toHaveBeenCalled(); + }); + }); + }); + }); + }); +}); diff --git a/libs/angular/src/auth/components/two-factor-auth/two-factor-auth.component.ts b/libs/angular/src/auth/components/two-factor-auth/two-factor-auth.component.ts new file mode 100644 index 0000000000..4c8e90348a --- /dev/null +++ b/libs/angular/src/auth/components/two-factor-auth/two-factor-auth.component.ts @@ -0,0 +1,394 @@ +import { CommonModule } from "@angular/common"; +import { Component, Inject, OnInit } from "@angular/core"; +import { FormBuilder, ReactiveFormsModule, Validators } from "@angular/forms"; +import { ActivatedRoute, NavigationExtras, Router, RouterLink } from "@angular/router"; +import { Subject, takeUntil, lastValueFrom, first, firstValueFrom } from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe"; +import { WINDOW } from "@bitwarden/angular/services/injection-tokens"; +import { + LoginStrategyServiceAbstraction, + LoginEmailServiceAbstraction, + UserDecryptionOptionsServiceAbstraction, + TrustedDeviceUserDecryptionOption, + UserDecryptionOptions, +} from "@bitwarden/auth/common"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; +import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; +import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; +import { AuthenticationType } from "@bitwarden/common/auth/enums/authentication-type"; +import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type"; +import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result"; +import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; +import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/identity-token/token-two-factor.request"; +import { TwoFactorProviders } from "@bitwarden/common/auth/services/two-factor.service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.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"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { + AsyncActionsModule, + ButtonModule, + DialogService, + FormFieldModule, +} from "@bitwarden/components"; + +import { CaptchaProtectedComponent } from "../captcha-protected.component"; + +import { TwoFactorAuthAuthenticatorComponent } from "./two-factor-auth-authenticator.component"; +import { + TwoFactorOptionsDialogResult, + TwoFactorOptionsComponent, + TwoFactorOptionsDialogResultType, +} from "./two-factor-options.component"; + +@Component({ + standalone: true, + selector: "app-two-factor-auth", + templateUrl: "two-factor-auth.component.html", + imports: [ + CommonModule, + JslibModule, + ReactiveFormsModule, + FormFieldModule, + AsyncActionsModule, + RouterLink, + ButtonModule, + TwoFactorOptionsComponent, + TwoFactorAuthAuthenticatorComponent, + ], + providers: [I18nPipe], +}) +export class TwoFactorAuthComponent extends CaptchaProtectedComponent implements OnInit { + token = ""; + remember = false; + orgIdentifier: string = null; + + providers = TwoFactorProviders; + providerType = TwoFactorProviderType; + selectedProviderType: TwoFactorProviderType = TwoFactorProviderType.Authenticator; + providerData: any; + + formGroup = this.formBuilder.group({ + token: [ + "", + { + validators: [Validators.required], + updateOn: "submit", + }, + ], + remember: [false], + }); + actionButtonText = ""; + title = ""; + formPromise: Promise; + + private destroy$ = new Subject(); + + onSuccessfulLogin: () => Promise; + onSuccessfulLoginNavigate: () => Promise; + + onSuccessfulLoginTde: () => Promise; + onSuccessfulLoginTdeNavigate: () => Promise; + + submitForm = async () => { + await this.submit(); + }; + goAfterLogIn = async () => { + this.loginEmailService.clearValues(); + // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.router.navigate([this.successRoute], { + queryParams: { + identifier: this.orgIdentifier, + }, + }); + }; + + protected loginRoute = "login"; + + protected trustedDeviceEncRoute = "login-initiated"; + protected changePasswordRoute = "set-password"; + protected forcePasswordResetRoute = "update-temp-password"; + protected successRoute = "vault"; + + constructor( + protected loginStrategyService: LoginStrategyServiceAbstraction, + protected router: Router, + i18nService: I18nService, + platformUtilsService: PlatformUtilsService, + environmentService: EnvironmentService, + private dialogService: DialogService, + protected route: ActivatedRoute, + private logService: LogService, + protected twoFactorService: TwoFactorService, + private loginEmailService: LoginEmailServiceAbstraction, + private userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction, + protected ssoLoginService: SsoLoginServiceAbstraction, + protected configService: ConfigService, + private masterPasswordService: InternalMasterPasswordServiceAbstraction, + private accountService: AccountService, + private formBuilder: FormBuilder, + @Inject(WINDOW) protected win: Window, + ) { + super(environmentService, i18nService, platformUtilsService); + } + + async ngOnInit() { + if (!(await this.authing()) || (await this.twoFactorService.getProviders()) == null) { + // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.router.navigate([this.loginRoute]); + return; + } + + // eslint-disable-next-line rxjs-angular/prefer-takeuntil + this.route.queryParams.pipe(first()).subscribe((qParams) => { + if (qParams.identifier != null) { + this.orgIdentifier = qParams.identifier; + } + }); + + if (await this.needsLock()) { + this.successRoute = "lock"; + } + + const webAuthnSupported = this.platformUtilsService.supportsWebAuthn(this.win); + this.selectedProviderType = await this.twoFactorService.getDefaultProvider(webAuthnSupported); + const providerData = await this.twoFactorService.getProviders().then((providers) => { + return providers.get(this.selectedProviderType); + }); + this.providerData = providerData; + await this.updateUIToProviderData(); + + this.actionButtonText = this.i18nService.t("continue"); + this.formGroup.valueChanges.pipe(takeUntil(this.destroy$)).subscribe((value) => { + this.token = value.token; + this.remember = value.remember; + }); + } + + async submit() { + await this.setupCaptcha(); + + if (this.token == null || this.token === "") { + this.platformUtilsService.showToast( + "error", + this.i18nService.t("errorOccurred"), + this.i18nService.t("verificationCodeRequired"), + ); + return; + } + + try { + this.formPromise = this.loginStrategyService.logInTwoFactor( + new TokenTwoFactorRequest(this.selectedProviderType, this.token, this.remember), + this.captchaToken, + ); + const authResult: AuthResult = await this.formPromise; + this.logService.info("Successfully submitted two factor token"); + await this.handleLoginResponse(authResult); + } catch { + this.logService.error("Error submitting two factor token"); + this.platformUtilsService.showToast( + "error", + this.i18nService.t("errorOccurred"), + this.i18nService.t("invalidVerificationCode"), + ); + } + } + + async selectOtherTwofactorMethod() { + const dialogRef = TwoFactorOptionsComponent.open(this.dialogService); + const response: TwoFactorOptionsDialogResultType = await lastValueFrom(dialogRef.closed); + if (response.result === TwoFactorOptionsDialogResult.Provider) { + const providerData = await this.twoFactorService.getProviders().then((providers) => { + return providers.get(response.type); + }); + this.providerData = providerData; + this.selectedProviderType = response.type; + await this.updateUIToProviderData(); + } + } + + protected handleMigrateEncryptionKey(result: AuthResult): boolean { + if (!result.requiresEncryptionKeyMigration) { + return false; + } + // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.router.navigate(["migrate-legacy-encryption"]); + return true; + } + + async updateUIToProviderData() { + if (this.selectedProviderType == null) { + this.title = this.i18nService.t("loginUnavailable"); + return; + } + + this.title = (TwoFactorProviders as any)[this.selectedProviderType].name; + } + + private async handleLoginResponse(authResult: AuthResult) { + if (this.handleCaptchaRequired(authResult)) { + return; + } else if (this.handleMigrateEncryptionKey(authResult)) { + return; + } + + // Save off the OrgSsoIdentifier for use in the TDE flows + // - TDE login decryption options component + // - Browser SSO on extension open + await this.ssoLoginService.setActiveUserOrganizationSsoIdentifier(this.orgIdentifier); + this.loginEmailService.clearValues(); + + // note: this flow affects both TDE & standard users + if (this.isForcePasswordResetRequired(authResult)) { + return await this.handleForcePasswordReset(this.orgIdentifier); + } + + const userDecryptionOpts = await firstValueFrom( + this.userDecryptionOptionsService.userDecryptionOptions$, + ); + + const tdeEnabled = await this.isTrustedDeviceEncEnabled(userDecryptionOpts.trustedDeviceOption); + + if (tdeEnabled) { + return await this.handleTrustedDeviceEncryptionEnabled( + authResult, + this.orgIdentifier, + userDecryptionOpts, + ); + } + + // User must set password if they don't have one and they aren't using either TDE or key connector. + const requireSetPassword = + !userDecryptionOpts.hasMasterPassword && userDecryptionOpts.keyConnectorOption === undefined; + + if (requireSetPassword || authResult.resetMasterPassword) { + // Change implies going no password -> password in this case + return await this.handleChangePasswordRequired(this.orgIdentifier); + } + + return await this.handleSuccessfulLogin(); + } + + private async isTrustedDeviceEncEnabled( + trustedDeviceOption: TrustedDeviceUserDecryptionOption, + ): Promise { + const ssoTo2faFlowActive = this.route.snapshot.queryParamMap.get("sso") === "true"; + + return ssoTo2faFlowActive && trustedDeviceOption !== undefined; + } + + private async handleTrustedDeviceEncryptionEnabled( + authResult: AuthResult, + orgIdentifier: string, + userDecryptionOpts: UserDecryptionOptions, + ): Promise { + // If user doesn't have a MP, but has reset password permission, they must set a MP + if ( + !userDecryptionOpts.hasMasterPassword && + userDecryptionOpts.trustedDeviceOption.hasManageResetPasswordPermission + ) { + // Set flag so that auth guard can redirect to set password screen after decryption (trusted or untrusted device) + // Note: we cannot directly navigate to the set password screen in this scenario as we are in a pre-decryption state, and + // if you try to set a new MP before decrypting, you will invalidate the user's data by making a new user key. + const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id; + await this.masterPasswordService.setForceSetPasswordReason( + ForceSetPasswordReason.TdeUserWithoutPasswordHasPasswordResetPermission, + userId, + ); + } + + if (this.onSuccessfulLoginTde != null) { + // Note: awaiting this will currently cause a hang on desktop & browser as they will wait for a full sync to complete + // before navigating to the success route. + // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.onSuccessfulLoginTde(); + } + + // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.navigateViaCallbackOrRoute( + this.onSuccessfulLoginTdeNavigate, + // Navigate to TDE page (if user was on trusted device and TDE has decrypted + // their user key, the login-initiated guard will redirect them to the vault) + [this.trustedDeviceEncRoute], + ); + } + + private async handleChangePasswordRequired(orgIdentifier: string) { + await this.router.navigate([this.changePasswordRoute], { + queryParams: { + identifier: orgIdentifier, + }, + }); + } + + /** + * Determines if a user needs to reset their password based on certain conditions. + * Users can be forced to reset their password via an admin or org policy disallowing weak passwords. + * Note: this is different from the SSO component login flow as a user can + * login with MP and then have to pass 2FA to finish login and we can actually + * evaluate if they have a weak password at that time. + * + * @param {AuthResult} authResult - The authentication result. + * @returns {boolean} Returns true if a password reset is required, false otherwise. + */ + private isForcePasswordResetRequired(authResult: AuthResult): boolean { + const forceResetReasons = [ + ForceSetPasswordReason.AdminForcePasswordReset, + ForceSetPasswordReason.WeakMasterPassword, + ]; + + return forceResetReasons.includes(authResult.forcePasswordReset); + } + + private async handleForcePasswordReset(orgIdentifier: string) { + // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.router.navigate([this.forcePasswordResetRoute], { + queryParams: { + identifier: orgIdentifier, + }, + }); + } + + private async handleSuccessfulLogin() { + if (this.onSuccessfulLogin != null) { + // Note: awaiting this will currently cause a hang on desktop & browser as they will wait for a full sync to complete + // before navigating to the success route. + // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.onSuccessfulLogin(); + } + await this.navigateViaCallbackOrRoute(this.onSuccessfulLoginNavigate, [this.successRoute]); + } + + private async navigateViaCallbackOrRoute( + callback: () => Promise, + commands: unknown[], + extras?: NavigationExtras, + ): Promise { + if (callback) { + await callback(); + } else { + await this.router.navigate(commands, extras); + } + } + + private async authing(): Promise { + return (await firstValueFrom(this.loginStrategyService.currentAuthType$)) !== null; + } + + private async needsLock(): Promise { + const authType = await firstValueFrom(this.loginStrategyService.currentAuthType$); + return authType == AuthenticationType.Sso || authType == AuthenticationType.UserApiKey; + } +} diff --git a/libs/angular/src/utils/two-factor-component-refactor-route-swap.ts b/libs/angular/src/utils/two-factor-component-refactor-route-swap.ts new file mode 100644 index 0000000000..8b57a3eb94 --- /dev/null +++ b/libs/angular/src/utils/two-factor-component-refactor-route-swap.ts @@ -0,0 +1,31 @@ +import { Type, inject } from "@angular/core"; +import { Route, Routes } from "@angular/router"; + +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; + +import { componentRouteSwap } from "./component-route-swap"; +/** + * Helper function to swap between two components based on the TwoFactorComponentRefactor feature flag. + * @param defaultComponent - The current non-refactored component to render. + * @param refreshedComponent - The new refactored component to render. + * @param defaultOptions - The options to apply to the default component and the refactored component, if alt options are not provided. + * @param altOptions - The options to apply to the refactored component. + */ +export function twofactorRefactorSwap( + defaultComponent: Type, + refreshedComponent: Type, + defaultOptions: Route, + altOptions?: Route, +): Routes { + return componentRouteSwap( + defaultComponent, + refreshedComponent, + async () => { + const configService = inject(ConfigService); + return configService.getFeatureFlag(FeatureFlag.TwoFactorComponentRefactor); + }, + defaultOptions, + altOptions, + ); +} diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 25c4c492e9..7c0327f9ff 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -20,6 +20,7 @@ export enum FeatureFlag { EmailVerification = "email-verification", InlineMenuFieldQualification = "inline-menu-field-qualification", MemberAccessReport = "ac-2059-member-access-report", + TwoFactorComponentRefactor = "two-factor-component-refactor", EnableTimeThreshold = "PM-5864-dollar-threshold", GroupsComponentRefactor = "groups-component-refactor", ProviderClientVaultPrivacyBanner = "ac-2833-provider-client-vault-privacy-banner", @@ -53,6 +54,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.EmailVerification]: FALSE, [FeatureFlag.InlineMenuFieldQualification]: FALSE, [FeatureFlag.MemberAccessReport]: FALSE, + [FeatureFlag.TwoFactorComponentRefactor]: FALSE, [FeatureFlag.EnableTimeThreshold]: FALSE, [FeatureFlag.GroupsComponentRefactor]: FALSE, [FeatureFlag.ProviderClientVaultPrivacyBanner]: FALSE,