diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index cfc6879573..b8c263652e 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -19,6 +19,18 @@ "createAccount": { "message": "Create account" }, + "newToBitwarden": { + "message": "New to Bitwarden?" + }, + "logInWithPasskey": { + "message": "Log in with passkey" + }, + "useSingleSignOn": { + "message": "Use single sign-on" + }, + "welcomeBack": { + "message": "Welcome back" + }, "setAStrongPassword": { "message": "Set a strong password" }, @@ -833,6 +845,9 @@ "logIn": { "message": "Log in" }, + "logInToBitwarden": { + "message": "Log in to Bitwarden" + }, "restartRegistration": { "message": "Restart registration" }, diff --git a/apps/browser/src/auth/popup/login.component.html b/apps/browser/src/auth/popup/login-v1.component.html similarity index 100% rename from apps/browser/src/auth/popup/login.component.html rename to apps/browser/src/auth/popup/login-v1.component.html diff --git a/apps/browser/src/auth/popup/login.component.ts b/apps/browser/src/auth/popup/login-v1.component.ts similarity index 95% rename from apps/browser/src/auth/popup/login.component.ts rename to apps/browser/src/auth/popup/login-v1.component.ts index fd4d9bc547..eee1bcc4d3 100644 --- a/apps/browser/src/auth/popup/login.component.ts +++ b/apps/browser/src/auth/popup/login-v1.component.ts @@ -3,7 +3,7 @@ import { FormBuilder } from "@angular/forms"; import { ActivatedRoute, Router } from "@angular/router"; import { firstValueFrom } from "rxjs"; -import { LoginComponent as BaseLoginComponent } from "@bitwarden/angular/auth/components/login.component"; +import { LoginComponentV1 as BaseLoginComponent } from "@bitwarden/angular/auth/components/login-v1.component"; import { FormValidationErrorsService } from "@bitwarden/angular/platform/abstractions/form-validation-errors.service"; import { LoginStrategyServiceAbstraction, @@ -29,9 +29,9 @@ import { flagEnabled } from "../../platform/flags"; @Component({ selector: "app-login", - templateUrl: "login.component.html", + templateUrl: "login-v1.component.html", }) -export class LoginComponent extends BaseLoginComponent implements OnInit { +export class LoginComponentV1 extends BaseLoginComponent implements OnInit { showPasswordless = false; constructor( devicesApiService: DevicesApiServiceAbstraction, diff --git a/apps/browser/src/auth/popup/login/extension-login-component.service.spec.ts b/apps/browser/src/auth/popup/login/extension-login-component.service.spec.ts new file mode 100644 index 0000000000..a7e2917101 --- /dev/null +++ b/apps/browser/src/auth/popup/login/extension-login-component.service.spec.ts @@ -0,0 +1,85 @@ +import { TestBed } from "@angular/core/testing"; +import { MockProxy, mock } from "jest-mock-extended"; + +import { DefaultLoginComponentService } from "@bitwarden/auth/angular"; +import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; +import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy"; + +import { flagEnabled } from "../../../platform/flags"; +import { BrowserPlatformUtilsService } from "../../../platform/services/platform-utils/browser-platform-utils.service"; +import { ExtensionAnonLayoutWrapperDataService } from "../extension-anon-layout-wrapper/extension-anon-layout-wrapper-data.service"; + +import { ExtensionLoginComponentService } from "./extension-login-component.service"; + +jest.mock("../../../platform/flags", () => ({ + flagEnabled: jest.fn(), +})); + +describe("ExtensionLoginComponentService", () => { + let service: ExtensionLoginComponentService; + let cryptoFunctionService: MockProxy; + let environmentService: MockProxy; + let passwordGenerationService: MockProxy; + let platformUtilsService: MockProxy; + let ssoLoginService: MockProxy; + let extensionAnonLayoutWrapperDataService: MockProxy; + beforeEach(() => { + cryptoFunctionService = mock(); + environmentService = mock(); + passwordGenerationService = mock(); + platformUtilsService = mock(); + ssoLoginService = mock(); + extensionAnonLayoutWrapperDataService = mock(); + TestBed.configureTestingModule({ + providers: [ + { + provide: ExtensionLoginComponentService, + useFactory: () => + new ExtensionLoginComponentService( + cryptoFunctionService, + environmentService, + passwordGenerationService, + platformUtilsService, + ssoLoginService, + extensionAnonLayoutWrapperDataService, + ), + }, + { provide: DefaultLoginComponentService, useExisting: ExtensionLoginComponentService }, + { provide: CryptoFunctionService, useValue: cryptoFunctionService }, + { provide: EnvironmentService, useValue: environmentService }, + { provide: PasswordGenerationServiceAbstraction, useValue: passwordGenerationService }, + { provide: PlatformUtilsService, useValue: platformUtilsService }, + { provide: SsoLoginServiceAbstraction, useValue: ssoLoginService }, + ], + }); + service = TestBed.inject(ExtensionLoginComponentService); + }); + + it("creates the service", () => { + expect(service).toBeTruthy(); + }); + + describe("isLoginViaAuthRequestSupported", () => { + it("returns true if showPasswordless flag is enabled", () => { + (flagEnabled as jest.Mock).mockReturnValue(true); + expect(service.isLoginViaAuthRequestSupported()).toBe(true); + }); + + it("returns false if showPasswordless flag is disabled", () => { + (flagEnabled as jest.Mock).mockReturnValue(false); + expect(service.isLoginViaAuthRequestSupported()).toBeFalsy(); + }); + }); + + describe("showBackButton", () => { + it("sets showBackButton in extensionAnonLayoutWrapperDataService", () => { + service.showBackButton(true); + expect(extensionAnonLayoutWrapperDataService.setAnonLayoutWrapperData).toHaveBeenCalledWith({ + showBackButton: true, + }); + }); + }); +}); diff --git a/apps/browser/src/auth/popup/login/extension-login-component.service.ts b/apps/browser/src/auth/popup/login/extension-login-component.service.ts new file mode 100644 index 0000000000..8630030e8e --- /dev/null +++ b/apps/browser/src/auth/popup/login/extension-login-component.service.ts @@ -0,0 +1,43 @@ +import { Injectable } from "@angular/core"; + +import { DefaultLoginComponentService, LoginComponentService } from "@bitwarden/auth/angular"; +import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; +import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy"; + +import { flagEnabled } from "../../../platform/flags"; +import { ExtensionAnonLayoutWrapperDataService } from "../extension-anon-layout-wrapper/extension-anon-layout-wrapper-data.service"; + +@Injectable() +export class ExtensionLoginComponentService + extends DefaultLoginComponentService + implements LoginComponentService +{ + constructor( + cryptoFunctionService: CryptoFunctionService, + environmentService: EnvironmentService, + passwordGenerationService: PasswordGenerationServiceAbstraction, + platformUtilsService: PlatformUtilsService, + ssoLoginService: SsoLoginServiceAbstraction, + private extensionAnonLayoutWrapperDataService: ExtensionAnonLayoutWrapperDataService, + ) { + super( + cryptoFunctionService, + environmentService, + passwordGenerationService, + platformUtilsService, + ssoLoginService, + ); + this.clientType = this.platformUtilsService.getClientType(); + } + + isLoginViaAuthRequestSupported(): boolean { + return flagEnabled("showPasswordless"); + } + + showBackButton(showBackButton: boolean): void { + this.extensionAnonLayoutWrapperDataService.setAnonLayoutWrapperData({ showBackButton }); + } +} diff --git a/apps/browser/src/popup/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts index a6d91d0187..d53e51e9df 100644 --- a/apps/browser/src/popup/app-routing.module.ts +++ b/apps/browser/src/popup/app-routing.module.ts @@ -1,7 +1,12 @@ import { Injectable, NgModule } from "@angular/core"; import { ActivatedRouteSnapshot, RouteReuseStrategy, RouterModule, Routes } from "@angular/router"; -import { EnvironmentSelectorComponent } from "@bitwarden/angular/auth/components/environment-selector.component"; +import { + EnvironmentSelectorComponent, + EnvironmentSelectorRouteData, + ExtensionDefaultOverlayPosition, +} from "@bitwarden/angular/auth/components/environment-selector.component"; +import { unauthUiRefreshRedirect } from "@bitwarden/angular/auth/functions/unauth-ui-refresh-redirect"; import { unauthUiRefreshSwap } from "@bitwarden/angular/auth/functions/unauth-ui-refresh-route-swap"; import { authGuard, @@ -16,6 +21,8 @@ import { extensionRefreshSwap } from "@bitwarden/angular/utils/extension-refresh import { AnonLayoutWrapperComponent, AnonLayoutWrapperData, + LoginComponent, + LoginSecondaryContentComponent, LockIcon, LockV2Component, PasswordHintComponent, @@ -27,6 +34,7 @@ import { RegistrationUserAddIcon, SetPasswordJitComponent, UserLockIcon, + VaultIcon, } from "@bitwarden/auth/angular"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; @@ -42,8 +50,8 @@ import { HintComponent } from "../auth/popup/hint.component"; import { HomeComponent } from "../auth/popup/home.component"; import { LockComponent } from "../auth/popup/lock.component"; import { LoginDecryptionOptionsComponent } from "../auth/popup/login-decryption-options/login-decryption-options.component"; +import { LoginComponentV1 } from "../auth/popup/login-v1.component"; import { LoginViaAuthRequestComponent } from "../auth/popup/login-via-auth-request.component"; -import { LoginComponent } from "../auth/popup/login.component"; import { RegisterComponent } from "../auth/popup/register.component"; import { RemovePasswordComponent } from "../auth/popup/remove-password.component"; import { SetPasswordComponent } from "../auth/popup/set-password.component"; @@ -155,7 +163,7 @@ const routes: Routes = [ { path: "home", component: HomeComponent, - canActivate: [unauthGuardFn(unauthRouteOverrides)], + canActivate: [unauthGuardFn(unauthRouteOverrides), unauthUiRefreshRedirect("/login")], data: { state: "home" } satisfies RouteDataProperties, }, ...extensionRefreshSwap(Fido2V1Component, Fido2Component, { @@ -163,12 +171,6 @@ const routes: Routes = [ canActivate: [fido2AuthGuard], data: { state: "fido2" } satisfies RouteDataProperties, }), - { - path: "login", - component: LoginComponent, - canActivate: [unauthGuardFn(unauthRouteOverrides)], - data: { state: "login" } satisfies RouteDataProperties, - }, { path: "login-with-device", component: LoginViaAuthRequestComponent, @@ -440,6 +442,47 @@ const routes: Routes = [ path: "", component: EnvironmentSelectorComponent, outlet: "environment-selector", + data: { + overlayPosition: ExtensionDefaultOverlayPosition, + } satisfies EnvironmentSelectorRouteData, + }, + ], + }, + ], + }, + ), + ...unauthUiRefreshSwap( + LoginComponentV1, + ExtensionAnonLayoutWrapperComponent, + { + path: "login", + canActivate: [unauthGuardFn(unauthRouteOverrides)], + data: { state: "login" }, + }, + { + path: "", + children: [ + { + path: "login", + canActivate: [unauthGuardFn(unauthRouteOverrides)], + data: { + pageIcon: VaultIcon, + pageTitle: { + key: "logInToBitwarden", + }, + state: "login", + showAcctSwitcher: true, + } satisfies RouteDataProperties & ExtensionAnonLayoutWrapperData, + children: [ + { path: "", component: LoginComponent }, + { path: "", component: LoginSecondaryContentComponent, outlet: "secondary" }, + { + path: "", + component: EnvironmentSelectorComponent, + outlet: "environment-selector", + data: { + overlayPosition: ExtensionDefaultOverlayPosition, + } satisfies EnvironmentSelectorRouteData, }, ], }, diff --git a/apps/browser/src/popup/app.module.ts b/apps/browser/src/popup/app.module.ts index d5777215b1..7b2d2ba86b 100644 --- a/apps/browser/src/popup/app.module.ts +++ b/apps/browser/src/popup/app.module.ts @@ -25,8 +25,8 @@ import { HintComponent } from "../auth/popup/hint.component"; import { HomeComponent } from "../auth/popup/home.component"; import { LockComponent } from "../auth/popup/lock.component"; import { LoginDecryptionOptionsComponent } from "../auth/popup/login-decryption-options/login-decryption-options.component"; +import { LoginComponentV1 } from "../auth/popup/login-v1.component"; import { LoginViaAuthRequestComponent } from "../auth/popup/login-via-auth-request.component"; -import { LoginComponent } from "../auth/popup/login.component"; import { RegisterComponent } from "../auth/popup/register.component"; import { RemovePasswordComponent } from "../auth/popup/remove-password.component"; import { SetPasswordComponent } from "../auth/popup/set-password.component"; @@ -159,7 +159,7 @@ import "../platform/popup/locales"; HintComponent, HomeComponent, LockComponent, - LoginComponent, + LoginComponentV1, LoginViaAuthRequestComponent, LoginDecryptionOptionsComponent, NotificationsSettingsV1Component, diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index 7b35d1d310..14ebfb4a17 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -18,17 +18,25 @@ import { ENV_ADDITIONAL_REGIONS, } from "@bitwarden/angular/services/injection-tokens"; import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module"; -import { AnonLayoutWrapperDataService, LockComponentService } from "@bitwarden/auth/angular"; -import { LockService, PinServiceAbstraction } from "@bitwarden/auth/common"; +import { + AnonLayoutWrapperDataService, + LoginComponentService, + LockComponentService, +} from "@bitwarden/auth/angular"; +import { LockService, LoginEmailService, PinServiceAbstraction } from "@bitwarden/auth/common"; import { EventCollectionService as EventCollectionServiceAbstraction } from "@bitwarden/common/abstractions/event/event-collection.service"; import { NotificationsService } from "@bitwarden/common/abstractions/notifications.service"; import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; -import { AccountService as AccountServiceAbstraction } from "@bitwarden/common/auth/abstractions/account.service"; +import { + AccountService, + AccountService as AccountServiceAbstraction, +} from "@bitwarden/common/auth/abstractions/account.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { KdfConfigService } from "@bitwarden/common/auth/abstractions/kdf-config.service"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; +import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { AutofillSettingsService, @@ -90,11 +98,13 @@ import { FolderService as FolderServiceAbstraction } from "@bitwarden/common/vau import { TotpService as TotpServiceAbstraction } from "@bitwarden/common/vault/abstractions/totp.service"; import { TotpService } from "@bitwarden/common/vault/services/totp.service"; import { DialogService, ToastService } from "@bitwarden/components"; +import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy"; import { BiometricStateService, BiometricsService, KeyService } from "@bitwarden/key-management"; import { PasswordRepromptService } from "@bitwarden/vault"; import { ForegroundLockService } from "../../auth/popup/accounts/foreground-lock.service"; import { ExtensionAnonLayoutWrapperDataService } from "../../auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper-data.service"; +import { ExtensionLoginComponentService } from "../../auth/popup/login/extension-login-component.service"; import { AutofillService as AutofillServiceAbstraction } from "../../autofill/services/abstractions/autofill.service"; import AutofillService from "../../autofill/services/autofill.service"; import MainBackground from "../../background/main.background"; @@ -573,9 +583,21 @@ const safeProviders: SafeProvider[] = [ }), safeProvider({ provide: AnonLayoutWrapperDataService, - useClass: ExtensionAnonLayoutWrapperDataService, + useExisting: ExtensionAnonLayoutWrapperDataService, deps: [], }), + safeProvider({ + provide: LoginComponentService, + useClass: ExtensionLoginComponentService, + deps: [ + CryptoFunctionService, + EnvironmentService, + PasswordGenerationServiceAbstraction, + PlatformUtilsService, + SsoLoginServiceAbstraction, + ExtensionAnonLayoutWrapperDataService, + ], + }), safeProvider({ provide: LockService, useClass: ForegroundLockService, @@ -586,6 +608,16 @@ const safeProviders: SafeProvider[] = [ useClass: flagEnabled("sdk") ? BrowserSdkClientFactory : NoopSdkClientFactory, deps: [], }), + safeProvider({ + provide: LoginEmailService, + useClass: LoginEmailService, + deps: [AccountService, AuthService, StateProvider], + }), + safeProvider({ + provide: ExtensionAnonLayoutWrapperDataService, + useClass: ExtensionAnonLayoutWrapperDataService, + deps: [], + }), ]; @NgModule({ diff --git a/apps/desktop/src/app/app-routing.module.ts b/apps/desktop/src/app/app-routing.module.ts index 8b8f62047f..f5023cb424 100644 --- a/apps/desktop/src/app/app-routing.module.ts +++ b/apps/desktop/src/app/app-routing.module.ts @@ -1,7 +1,10 @@ import { NgModule } from "@angular/core"; import { RouterModule, Routes } from "@angular/router"; -import { EnvironmentSelectorComponent } from "@bitwarden/angular/auth/components/environment-selector.component"; +import { + DesktopDefaultOverlayPosition, + EnvironmentSelectorComponent, +} from "@bitwarden/angular/auth/components/environment-selector.component"; import { unauthUiRefreshSwap } from "@bitwarden/angular/auth/functions/unauth-ui-refresh-route-swap"; import { authGuard, @@ -15,6 +18,8 @@ import { extensionRefreshRedirect } from "@bitwarden/angular/utils/extension-ref import { AnonLayoutWrapperComponent, AnonLayoutWrapperData, + LoginComponent, + LoginSecondaryContentComponent, LockIcon, LockV2Component, PasswordHintComponent, @@ -26,6 +31,7 @@ import { RegistrationUserAddIcon, SetPasswordJitComponent, UserLockIcon, + VaultIcon, } from "@bitwarden/auth/angular"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; @@ -35,8 +41,8 @@ import { maxAccountsGuardFn } from "../auth/guards/max-accounts.guard"; import { HintComponent } from "../auth/hint.component"; import { LockComponent } from "../auth/lock.component"; import { LoginDecryptionOptionsComponent } from "../auth/login/login-decryption-options/login-decryption-options.component"; +import { LoginComponentV1 } from "../auth/login/login-v1.component"; import { LoginViaAuthRequestComponent } from "../auth/login/login-via-auth-request.component"; -import { LoginComponent } from "../auth/login/login.component"; import { RegisterComponent } from "../auth/register.component"; import { RemovePasswordComponent } from "../auth/remove-password.component"; import { SetPasswordComponent } from "../auth/set-password.component"; @@ -69,11 +75,6 @@ const routes: Routes = [ canActivate: [lockGuard()], canMatch: [extensionRefreshRedirect("/lockV2")], }, - { - path: "login", - component: LoginComponent, - canActivate: [maxAccountsGuardFn()], - }, { path: "login-with-device", component: LoginViaAuthRequestComponent, @@ -163,6 +164,42 @@ const routes: Routes = [ ], }, ), + ...unauthUiRefreshSwap( + LoginComponentV1, + AnonLayoutWrapperComponent, + { + path: "login", + component: LoginComponentV1, + canActivate: [maxAccountsGuardFn()], + }, + { + path: "", + children: [ + { + path: "login", + canActivate: [maxAccountsGuardFn()], + data: { + pageTitle: { + key: "logInToBitwarden", + }, + pageIcon: VaultIcon, + }, + children: [ + { path: "", component: LoginComponent }, + { path: "", component: LoginSecondaryContentComponent, outlet: "secondary" }, + { + path: "", + component: EnvironmentSelectorComponent, + outlet: "environment-selector", + data: { + overlayPosition: DesktopDefaultOverlayPosition, + }, + }, + ], + }, + ], + }, + ), { path: "", component: AnonLayoutWrapperComponent, diff --git a/apps/desktop/src/app/services/services.module.ts b/apps/desktop/src/app/services/services.module.ts index d3a7b7c0a1..a2195fbd5a 100644 --- a/apps/desktop/src/app/services/services.module.ts +++ b/apps/desktop/src/app/services/services.module.ts @@ -19,28 +19,41 @@ import { CLIENT_TYPE, } from "@bitwarden/angular/services/injection-tokens"; import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module"; -import { LockComponentService, SetPasswordJitService } from "@bitwarden/auth/angular"; +import { + LoginComponentService, + SetPasswordJitService, + LockComponentService, +} from "@bitwarden/auth/angular"; import { InternalUserDecryptionOptionsServiceAbstraction, + LoginEmailService, PinServiceAbstraction, } from "@bitwarden/auth/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service"; import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; import { PolicyService as PolicyServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; -import { AccountService as AccountServiceAbstraction } from "@bitwarden/common/auth/abstractions/account.service"; -import { AuthService as AuthServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth.service"; +import { + AccountService, + AccountService as AccountServiceAbstraction, +} from "@bitwarden/common/auth/abstractions/account.service"; +import { + AuthService, + AuthService as AuthServiceAbstraction, +} from "@bitwarden/common/auth/abstractions/auth.service"; import { KdfConfigService, KdfConfigService as KdfConfigServiceAbstraction, } from "@bitwarden/common/auth/abstractions/kdf-config.service"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; +import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service"; import { ClientType } from "@bitwarden/common/enums"; import { ProcessReloadServiceAbstraction } from "@bitwarden/common/key-management/abstractions/process-reload.service"; import { ProcessReloadService } from "@bitwarden/common/key-management/services/process-reload.service"; import { CryptoFunctionService as CryptoFunctionServiceAbstraction } from "@bitwarden/common/platform/abstractions/crypto-function.service"; import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service"; import { KeyGenerationService as KeyGenerationServiceAbstraction } from "@bitwarden/common/platform/abstractions/key-generation.service"; @@ -68,7 +81,7 @@ import { MemoryStorageService as MemoryStorageServiceForStateProviders } from "@ import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service"; import { VaultTimeoutStringType } from "@bitwarden/common/types/vault-timeout.type"; import { CipherService as CipherServiceAbstraction } from "@bitwarden/common/vault/abstractions/cipher.service"; -import { DialogService } from "@bitwarden/components"; +import { DialogService, ToastService } from "@bitwarden/components"; import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy"; import { KeyService, @@ -77,6 +90,7 @@ import { BiometricsService, } from "@bitwarden/key-management"; +import { DesktopLoginComponentService } from "../../auth/login/desktop-login-component.service"; import { DesktopAutofillSettingsService } from "../../autofill/services/desktop-autofill-settings.service"; import { ElectronBiometricsService } from "../../key-management/biometrics/electron-biometrics.service"; import { flagEnabled } from "../../platform/flags"; @@ -315,11 +329,29 @@ const safeProviders: SafeProvider[] = [ InternalUserDecryptionOptionsServiceAbstraction, ], }), + safeProvider({ + provide: LoginComponentService, + useClass: DesktopLoginComponentService, + deps: [ + CryptoFunctionServiceAbstraction, + EnvironmentService, + PasswordGenerationServiceAbstraction, + PlatformUtilsServiceAbstraction, + SsoLoginServiceAbstraction, + I18nServiceAbstraction, + ToastService, + ], + }), safeProvider({ provide: SdkClientFactory, useClass: flagEnabled("sdk") ? DefaultSdkClientFactory : NoopSdkClientFactory, deps: [], }), + safeProvider({ + provide: LoginEmailService, + useClass: LoginEmailService, + deps: [AccountService, AuthService, StateProvider], + }), ]; @NgModule({ diff --git a/apps/desktop/src/auth/login/desktop-login-component.service.spec.ts b/apps/desktop/src/auth/login/desktop-login-component.service.spec.ts new file mode 100644 index 0000000000..6edde35733 --- /dev/null +++ b/apps/desktop/src/auth/login/desktop-login-component.service.spec.ts @@ -0,0 +1,162 @@ +import { TestBed } from "@angular/core/testing"; +import { MockProxy, mock } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { DefaultLoginComponentService } from "@bitwarden/auth/angular"; +import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; +import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service"; +import { + Environment, + EnvironmentService, +} from "@bitwarden/common/platform/abstractions/environment.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { ToastService } from "@bitwarden/components"; +import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy"; + +import { ElectronPlatformUtilsService } from "../../platform/services/electron-platform-utils.service"; + +import { DesktopLoginComponentService } from "./desktop-login-component.service"; + +const defaultIpc = { + platform: { + isAppImage: false, + isSnapStore: false, + isDev: false, + localhostCallbackService: { + openSsoPrompt: jest.fn(), + }, + }, +}; + +(global as any).ipc = defaultIpc; + +describe("DesktopLoginComponentService", () => { + let service: DesktopLoginComponentService; + let cryptoFunctionService: MockProxy; + let environmentService: MockProxy; + let passwordGenerationService: MockProxy; + let platformUtilsService: MockProxy; + let ssoLoginService: MockProxy; + let i18nService: MockProxy; + let toastService: MockProxy; + + let superLaunchSsoBrowserWindowSpy: jest.SpyInstance; + + beforeEach(() => { + cryptoFunctionService = mock(); + environmentService = mock(); + environmentService.environment$ = of({ + getWebVaultUrl: () => "https://webvault.bitwarden.com", + getRegion: () => "US", + getUrls: () => ({}), + isCloud: () => true, + getApiUrl: () => "https://api.bitwarden.com", + } as Environment); + + passwordGenerationService = mock(); + platformUtilsService = mock(); + ssoLoginService = mock(); + i18nService = mock(); + toastService = mock(); + + TestBed.configureTestingModule({ + providers: [ + { + provide: DesktopLoginComponentService, + useFactory: () => + new DesktopLoginComponentService( + cryptoFunctionService, + environmentService, + passwordGenerationService, + platformUtilsService, + ssoLoginService, + i18nService, + toastService, + ), + }, + { provide: DefaultLoginComponentService, useExisting: DesktopLoginComponentService }, + { provide: CryptoFunctionService, useValue: cryptoFunctionService }, + { provide: EnvironmentService, useValue: environmentService }, + { provide: PasswordGenerationServiceAbstraction, useValue: passwordGenerationService }, + { provide: PlatformUtilsService, useValue: platformUtilsService }, + { provide: SsoLoginServiceAbstraction, useValue: ssoLoginService }, + { provide: I18nService, useValue: i18nService }, + { provide: ToastService, useValue: toastService }, + ], + }); + + service = TestBed.inject(DesktopLoginComponentService); + + superLaunchSsoBrowserWindowSpy = jest.spyOn( + DefaultLoginComponentService.prototype, + "launchSsoBrowserWindow", + ); + }); + + afterEach(() => { + // Restore the original ipc object after each test + (global as any).ipc = { ...defaultIpc }; + + jest.clearAllMocks(); + }); + + it("creates the service", () => { + expect(service).toBeTruthy(); + }); + + describe("launchSsoBrowserWindow", () => { + // Array of all permutations of isAppImage, isSnapStore, and isDev + const permutations = [ + [true, false, false], // Case 1: isAppImage true + [false, true, false], // Case 2: isSnapStore true + [false, false, true], // Case 3: isDev true + [true, true, false], // Case 4: isAppImage and isSnapStore true + [true, false, true], // Case 5: isAppImage and isDev true + [false, true, true], // Case 6: isSnapStore and isDev true + [true, true, true], // Case 7: all true + [false, false, false], // Case 8: all false + ]; + + permutations.forEach(([isAppImage, isSnapStore, isDev]) => { + it(`executes correct logic for isAppImage=${isAppImage}, isSnapStore=${isSnapStore}, isDev=${isDev}`, async () => { + (global as any).ipc.platform.isAppImage = isAppImage; + (global as any).ipc.platform.isSnapStore = isSnapStore; + (global as any).ipc.platform.isDev = isDev; + + const email = "user@example.com"; + const clientId = "desktop"; + const codeChallenge = "testCodeChallenge"; + const codeVerifier = "testCodeVerifier"; + const state = "testState"; + const codeVerifierHash = new Uint8Array(64); + + passwordGenerationService.generatePassword.mockResolvedValueOnce(state); + passwordGenerationService.generatePassword.mockResolvedValueOnce(codeVerifier); + cryptoFunctionService.hash.mockResolvedValueOnce(codeVerifierHash); + jest.spyOn(Utils, "fromBufferToUrlB64").mockReturnValue(codeChallenge); + + await service.launchSsoBrowserWindow(email, clientId); + + if (isAppImage || isSnapStore || isDev) { + expect(superLaunchSsoBrowserWindowSpy).not.toHaveBeenCalled(); + + // Assert that the standard logic is executed + expect(ssoLoginService.setSsoEmail).toHaveBeenCalledWith(email); + expect(passwordGenerationService.generatePassword).toHaveBeenCalledTimes(2); + expect(cryptoFunctionService.hash).toHaveBeenCalledWith(codeVerifier, "sha256"); + expect(ssoLoginService.setSsoState).toHaveBeenCalledWith(state); + expect(ssoLoginService.setCodeVerifier).toHaveBeenCalledWith(codeVerifier); + expect(ipc.platform.localhostCallbackService.openSsoPrompt).toHaveBeenCalledWith( + codeChallenge, + state, + ); + } else { + // If all values are false, expect the super method to be called + expect(superLaunchSsoBrowserWindowSpy).toHaveBeenCalledWith(email, clientId); + } + }); + }); + }); +}); diff --git a/apps/desktop/src/auth/login/desktop-login-component.service.ts b/apps/desktop/src/auth/login/desktop-login-component.service.ts new file mode 100644 index 0000000000..c9b01c5624 --- /dev/null +++ b/apps/desktop/src/auth/login/desktop-login-component.service.ts @@ -0,0 +1,78 @@ +import { Injectable } from "@angular/core"; + +import { DefaultLoginComponentService, LoginComponentService } from "@bitwarden/auth/angular"; +import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; +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 { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { ToastService } from "@bitwarden/components"; +import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy"; + +@Injectable() +export class DesktopLoginComponentService + extends DefaultLoginComponentService + implements LoginComponentService +{ + constructor( + protected cryptoFunctionService: CryptoFunctionService, + protected environmentService: EnvironmentService, + protected passwordGenerationService: PasswordGenerationServiceAbstraction, + protected platformUtilsService: PlatformUtilsService, + protected ssoLoginService: SsoLoginServiceAbstraction, + protected i18nService: I18nService, + protected toastService: ToastService, + ) { + super( + cryptoFunctionService, + environmentService, + passwordGenerationService, + platformUtilsService, + ssoLoginService, + ); + this.clientType = this.platformUtilsService.getClientType(); + } + + override async launchSsoBrowserWindow(email: string, clientId: "desktop"): Promise { + if (!ipc.platform.isAppImage && !ipc.platform.isSnapStore && !ipc.platform.isDev) { + return super.launchSsoBrowserWindow(email, clientId); + } + + // Save email for SSO + await this.ssoLoginService.setSsoEmail(email); + + // Generate SSO params + const passwordOptions: any = { + type: "password", + length: 64, + uppercase: true, + lowercase: true, + numbers: true, + special: false, + }; + + const state = await this.passwordGenerationService.generatePassword(passwordOptions); + const codeVerifier = await this.passwordGenerationService.generatePassword(passwordOptions); + const codeVerifierHash = await this.cryptoFunctionService.hash(codeVerifier, "sha256"); + const codeChallenge = Utils.fromBufferToUrlB64(codeVerifierHash); + + // Save SSO params + await this.ssoLoginService.setSsoState(state); + await this.ssoLoginService.setCodeVerifier(codeVerifier); + + try { + await ipc.platform.localhostCallbackService.openSsoPrompt(codeChallenge, state); + } catch (err) { + this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("errorOccured"), + message: this.i18nService.t("ssoError"), + }); + } + } + + isLoginViaAuthRequestSupported(): boolean { + return true; + } +} diff --git a/apps/desktop/src/auth/login/login.component.html b/apps/desktop/src/auth/login/login-v1.component.html similarity index 100% rename from apps/desktop/src/auth/login/login.component.html rename to apps/desktop/src/auth/login/login-v1.component.html diff --git a/apps/desktop/src/auth/login/login.component.ts b/apps/desktop/src/auth/login/login-v1.component.ts similarity index 97% rename from apps/desktop/src/auth/login/login.component.ts rename to apps/desktop/src/auth/login/login-v1.component.ts index 6ba143421c..6eb069d9bc 100644 --- a/apps/desktop/src/auth/login/login.component.ts +++ b/apps/desktop/src/auth/login/login-v1.component.ts @@ -3,7 +3,7 @@ import { FormBuilder } from "@angular/forms"; import { ActivatedRoute, Router } from "@angular/router"; import { Subject, takeUntil } from "rxjs"; -import { LoginComponent as BaseLoginComponent } from "@bitwarden/angular/auth/components/login.component"; +import { LoginComponentV1 as BaseLoginComponent } from "@bitwarden/angular/auth/components/login-v1.component"; import { FormValidationErrorsService } from "@bitwarden/angular/platform/abstractions/form-validation-errors.service"; import { ModalService } from "@bitwarden/angular/services/modal.service"; import { @@ -34,9 +34,9 @@ const BroadcasterSubscriptionId = "LoginComponent"; @Component({ selector: "app-login", - templateUrl: "login.component.html", + templateUrl: "login-v1.component.html", }) -export class LoginComponent extends BaseLoginComponent implements OnInit, OnDestroy { +export class LoginComponentV1 extends BaseLoginComponent implements OnInit, OnDestroy { @ViewChild("environment", { read: ViewContainerRef, static: true }) environmentModal: ViewContainerRef; diff --git a/apps/desktop/src/auth/login/login.module.ts b/apps/desktop/src/auth/login/login.module.ts index 1b926e57af..c0b330bf2d 100644 --- a/apps/desktop/src/auth/login/login.module.ts +++ b/apps/desktop/src/auth/login/login.module.ts @@ -6,17 +6,17 @@ import { EnvironmentSelectorComponent } from "@bitwarden/angular/auth/components import { SharedModule } from "../../app/shared/shared.module"; import { LoginDecryptionOptionsComponent } from "./login-decryption-options/login-decryption-options.component"; +import { LoginComponentV1 } from "./login-v1.component"; import { LoginViaAuthRequestComponent } from "./login-via-auth-request.component"; -import { LoginComponent } from "./login.component"; @NgModule({ imports: [SharedModule, RouterModule], declarations: [ - LoginComponent, + LoginComponentV1, LoginViaAuthRequestComponent, EnvironmentSelectorComponent, LoginDecryptionOptionsComponent, ], - exports: [LoginComponent, LoginViaAuthRequestComponent], + exports: [LoginComponentV1, LoginViaAuthRequestComponent], }) export class LoginModule {} diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index a654bf7408..64109a052c 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -60,6 +60,9 @@ } } }, + "welcomeBack": { + "message": "Welcome back" + }, "moveToOrgDesc": { "message": "Choose an organization that you wish to move this item to. Moving to an organization transfers ownership of the item to that organization. You will no longer be the direct owner of this item once it has been moved." }, @@ -554,6 +557,9 @@ "createAccount": { "message": "Create account" }, + "newToBitwarden": { + "message": "New to Bitwarden?" + }, "setAStrongPassword": { "message": "Set a strong password" }, @@ -563,6 +569,18 @@ "logIn": { "message": "Log in" }, + "logInToBitwarden": { + "message": "Log in to Bitwarden" + }, + "logInWithPasskey": { + "message": "Log in with passkey" + }, + "loginWithDevice": { + "message": "Log in with device" + }, + "useSingleSignOn": { + "message": "Use single sign-on" + }, "submit": { "message": "Submit" }, diff --git a/apps/web/src/app/auth/core/services/index.ts b/apps/web/src/app/auth/core/services/index.ts index 9e433b87f3..a2e674c2a9 100644 --- a/apps/web/src/app/auth/core/services/index.ts +++ b/apps/web/src/app/auth/core/services/index.ts @@ -1,3 +1,4 @@ +export * from "./login"; export * from "./webauthn-login"; export * from "./set-password-jit"; export * from "./registration"; diff --git a/apps/web/src/app/auth/core/services/login/index.ts b/apps/web/src/app/auth/core/services/login/index.ts new file mode 100644 index 0000000000..73ad7fbd28 --- /dev/null +++ b/apps/web/src/app/auth/core/services/login/index.ts @@ -0,0 +1 @@ +export * from "./web-login-component.service"; diff --git a/apps/web/src/app/auth/core/services/login/web-login-component.service.spec.ts b/apps/web/src/app/auth/core/services/login/web-login-component.service.spec.ts new file mode 100644 index 0000000000..2802b87c3e --- /dev/null +++ b/apps/web/src/app/auth/core/services/login/web-login-component.service.spec.ts @@ -0,0 +1,155 @@ +import { TestBed } from "@angular/core/testing"; +import { MockProxy, mock } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { DefaultLoginComponentService } from "@bitwarden/auth/angular"; +import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; +import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options"; +import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; +import { ResetPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/reset-password-policy-options"; +import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; +import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy"; + +import { RouterService } from "../../../../../../../../apps/web/src/app/core"; +import { flagEnabled } from "../../../../../utils/flags"; +import { AcceptOrganizationInviteService } from "../../../organization-invite/accept-organization.service"; + +import { WebLoginComponentService } from "./web-login-component.service"; + +jest.mock("../../../../../utils/flags", () => ({ + flagEnabled: jest.fn(), +})); + +describe("WebLoginComponentService", () => { + let service: WebLoginComponentService; + let acceptOrganizationInviteService: MockProxy; + let logService: MockProxy; + let policyApiService: MockProxy; + let internalPolicyService: MockProxy; + let routerService: MockProxy; + let cryptoFunctionService: MockProxy; + let environmentService: MockProxy; + let passwordGenerationService: MockProxy; + let platformUtilsService: MockProxy; + let ssoLoginService: MockProxy; + + beforeEach(() => { + acceptOrganizationInviteService = mock(); + logService = mock(); + policyApiService = mock(); + internalPolicyService = mock(); + routerService = mock(); + cryptoFunctionService = mock(); + environmentService = mock(); + passwordGenerationService = mock(); + platformUtilsService = mock(); + ssoLoginService = mock(); + + TestBed.configureTestingModule({ + providers: [ + WebLoginComponentService, + { provide: DefaultLoginComponentService, useClass: WebLoginComponentService }, + { provide: AcceptOrganizationInviteService, useValue: acceptOrganizationInviteService }, + { provide: LogService, useValue: logService }, + { provide: PolicyApiServiceAbstraction, useValue: policyApiService }, + { provide: InternalPolicyService, useValue: internalPolicyService }, + { provide: RouterService, useValue: routerService }, + { provide: CryptoFunctionService, useValue: cryptoFunctionService }, + { provide: EnvironmentService, useValue: environmentService }, + { provide: PasswordGenerationServiceAbstraction, useValue: passwordGenerationService }, + { provide: PlatformUtilsService, useValue: platformUtilsService }, + { provide: SsoLoginServiceAbstraction, useValue: ssoLoginService }, + ], + }); + service = TestBed.inject(WebLoginComponentService); + }); + + it("creates the service", () => { + expect(service).toBeTruthy(); + }); + + describe("isLoginViaAuthRequestSupported", () => { + it("returns true if showPasswordless flag is enabled", () => { + (flagEnabled as jest.Mock).mockReturnValue(true); + expect(service.isLoginViaAuthRequestSupported()).toBe(true); + }); + + it("returns false if showPasswordless flag is disabled", () => { + (flagEnabled as jest.Mock).mockReturnValue(false); + expect(service.isLoginViaAuthRequestSupported()).toBeFalsy(); + }); + }); + + describe("getOrgPolicies", () => { + it("returns undefined if organization invite is null", async () => { + acceptOrganizationInviteService.getOrganizationInvite.mockResolvedValue(null); + const result = await service.getOrgPolicies(); + expect(result).toBeUndefined(); + }); + + it("logs an error if getPoliciesByToken throws an error", async () => { + const error = new Error("Test error"); + acceptOrganizationInviteService.getOrganizationInvite.mockResolvedValue({ + organizationId: "org-id", + token: "token", + email: "email", + organizationUserId: "org-user-id", + initOrganization: false, + orgSsoIdentifier: "sso-id", + orgUserHasExistingUser: false, + organizationName: "org-name", + }); + policyApiService.getPoliciesByToken.mockRejectedValue(error); + await service.getOrgPolicies(); + expect(logService.error).toHaveBeenCalledWith(error); + }); + + it.each([ + [false, false], // autoEnrollEnabled, resetPasswordPolicyEnabled + [true, true], // autoEnrollEnabled, resetPasswordPolicyEnabled + ])( + "returns policies successfully with autoEnrollEnabled=%s and resetPasswordPolicyEnabled=%s", + async (autoEnrollEnabled, resetPasswordPolicyEnabled) => { + const policies: Policy[] = [new Policy()]; + const masterPasswordPolicyOptions = new MasterPasswordPolicyOptions(); + const resetPasswordPolicyOptions = new ResetPasswordPolicyOptions(); + resetPasswordPolicyOptions.autoEnrollEnabled = autoEnrollEnabled; + + acceptOrganizationInviteService.getOrganizationInvite.mockResolvedValue({ + organizationId: "org-id", + token: "token", + email: "email", + organizationUserId: "org-user-id", + initOrganization: false, + orgSsoIdentifier: "sso-id", + orgUserHasExistingUser: false, + organizationName: "org-name", + }); + policyApiService.getPoliciesByToken.mockResolvedValue(policies); + + internalPolicyService.getResetPasswordPolicyOptions.mockReturnValue([ + resetPasswordPolicyOptions, + resetPasswordPolicyEnabled, + ]); + + internalPolicyService.masterPasswordPolicyOptions$.mockReturnValue( + of(masterPasswordPolicyOptions), + ); + + const result = await service.getOrgPolicies(); + + expect(result).toEqual({ + policies: policies, + isPolicyAndAutoEnrollEnabled: + resetPasswordPolicyEnabled && resetPasswordPolicyOptions.autoEnrollEnabled, + enforcedPasswordPolicyOptions: masterPasswordPolicyOptions, + }); + }, + ); + }); +}); diff --git a/apps/web/src/app/auth/core/services/login/web-login-component.service.ts b/apps/web/src/app/auth/core/services/login/web-login-component.service.ts new file mode 100644 index 0000000000..30950ae13b --- /dev/null +++ b/apps/web/src/app/auth/core/services/login/web-login-component.service.ts @@ -0,0 +1,94 @@ +import { Injectable } from "@angular/core"; +import { firstValueFrom } from "rxjs"; + +import { + DefaultLoginComponentService, + LoginComponentService, + PasswordPolicies, +} from "@bitwarden/auth/angular"; +import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; +import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; +import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; +import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy"; + +import { flagEnabled } from "../../../../../utils/flags"; +import { RouterService } from "../../../../core/router.service"; +import { AcceptOrganizationInviteService } from "../../../organization-invite/accept-organization.service"; + +@Injectable() +export class WebLoginComponentService + extends DefaultLoginComponentService + implements LoginComponentService +{ + constructor( + protected acceptOrganizationInviteService: AcceptOrganizationInviteService, + protected logService: LogService, + protected policyApiService: PolicyApiServiceAbstraction, + protected policyService: InternalPolicyService, + protected routerService: RouterService, + cryptoFunctionService: CryptoFunctionService, + environmentService: EnvironmentService, + passwordGenerationService: PasswordGenerationServiceAbstraction, + platformUtilsService: PlatformUtilsService, + ssoLoginService: SsoLoginServiceAbstraction, + ) { + super( + cryptoFunctionService, + environmentService, + passwordGenerationService, + platformUtilsService, + ssoLoginService, + ); + this.clientType = this.platformUtilsService.getClientType(); + } + + isLoginViaAuthRequestSupported(): boolean { + return flagEnabled("showPasswordless"); + } + + async getOrgPolicies(): Promise { + const orgInvite = await this.acceptOrganizationInviteService.getOrganizationInvite(); + + if (orgInvite != null) { + let policies: Policy[]; + + try { + policies = await this.policyApiService.getPoliciesByToken( + orgInvite.organizationId, + orgInvite.token, + orgInvite.email, + orgInvite.organizationUserId, + ); + } catch (e) { + this.logService.error(e); + } + + if (policies == null) { + return; + } + + const resetPasswordPolicy = this.policyService.getResetPasswordPolicyOptions( + policies, + orgInvite.organizationId, + ); + + const isPolicyAndAutoEnrollEnabled = + resetPasswordPolicy[1] && resetPasswordPolicy[0].autoEnrollEnabled; + + const enforcedPasswordPolicyOptions = await firstValueFrom( + this.policyService.masterPasswordPolicyOptions$(policies), + ); + + return { + policies, + isPolicyAndAutoEnrollEnabled, + enforcedPasswordPolicyOptions, + }; + } + } +} diff --git a/apps/web/src/app/auth/login/login.component.html b/apps/web/src/app/auth/login/login-v1.component.html similarity index 98% rename from apps/web/src/app/auth/login/login.component.html rename to apps/web/src/app/auth/login/login-v1.component.html index d26e81b35b..4f8ea93bbd 100644 --- a/apps/web/src/app/auth/login/login.component.html +++ b/apps/web/src/app/auth/login/login-v1.component.html @@ -40,7 +40,7 @@ routerLink="/login-with-passkey" (mousedown)="$event.preventDefault()" > - {{ "loginWithPasskey" | i18n }} + {{ "logInWithPasskey" | i18n }} diff --git a/apps/web/src/app/auth/login/login.component.ts b/apps/web/src/app/auth/login/login-v1.component.ts similarity index 93% rename from apps/web/src/app/auth/login/login.component.ts rename to apps/web/src/app/auth/login/login-v1.component.ts index 1422a7c123..f556c5b65c 100644 --- a/apps/web/src/app/auth/login/login.component.ts +++ b/apps/web/src/app/auth/login/login-v1.component.ts @@ -4,7 +4,7 @@ import { ActivatedRoute, Router } from "@angular/router"; import { firstValueFrom, takeUntil } from "rxjs"; import { first } from "rxjs/operators"; -import { LoginComponent as BaseLoginComponent } from "@bitwarden/angular/auth/components/login.component"; +import { LoginComponentV1 as BaseLoginComponent } from "@bitwarden/angular/auth/components/login-v1.component"; import { FormValidationErrorsService } from "@bitwarden/angular/platform/abstractions/form-validation-errors.service"; import { LoginStrategyServiceAbstraction, @@ -39,14 +39,15 @@ import { OrganizationInvite } from "../organization-invite/organization-invite"; @Component({ selector: "app-login", - templateUrl: "login.component.html", + templateUrl: "login-v1.component.html", }) // eslint-disable-next-line rxjs-angular/prefer-takeuntil -export class LoginComponent extends BaseLoginComponent implements OnInit { +export class LoginComponentV1 extends BaseLoginComponent implements OnInit { showResetPasswordAutoEnrollWarning = false; enforcedPasswordPolicyOptions: MasterPasswordPolicyOptions; policies: Policy[]; showPasswordless = false; + constructor( private acceptOrganizationInviteService: AcceptOrganizationInviteService, devicesApiService: DevicesApiServiceAbstraction, @@ -99,6 +100,7 @@ export class LoginComponent extends BaseLoginComponent implements OnInit { this.onSuccessfulLoginNavigate = this.goAfterLogIn; this.showPasswordless = flagEnabled("showPasswordless"); } + submitForm = async (showToast = true) => { return await this.submitFormHelper(showToast); }; @@ -106,9 +108,11 @@ export class LoginComponent extends BaseLoginComponent implements OnInit { private async submitFormHelper(showToast: boolean) { await super.submit(showToast); } + async ngOnInit() { // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe this.route.queryParams.pipe(first()).subscribe(async (qParams) => { + // If there is an query parameter called 'org', set previousUrl to `/create-organization?org=paramValue` if (qParams.org != null) { const route = this.router.createUrlTree(["create-organization"], { queryParams: { plan: qParams.org }, @@ -116,13 +120,18 @@ export class LoginComponent extends BaseLoginComponent implements OnInit { this.routerService.setPreviousUrl(route.toString()); } - // Are they coming from an email for sponsoring a families organization + /** + * If there is a query parameter called 'sponsorshipToken', that means they are coming + * from an email for sponsoring a families organization. If so, then 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.routerService.setPreviousUrl(route.toString()); } + await super.ngOnInit(); }); @@ -206,10 +215,12 @@ export class LoginComponent extends BaseLoginComponent implements OnInit { if (this.policies == null) { return; } + const resetPasswordPolicy = this.policyService.getResetPasswordPolicyOptions( this.policies, invite.organizationId, ); + // Set to true if policy enabled and auto-enroll enabled this.showResetPasswordAutoEnrollWarning = resetPasswordPolicy[1] && resetPasswordPolicy[0].autoEnrollEnabled; diff --git a/apps/web/src/app/auth/login/login.module.ts b/apps/web/src/app/auth/login/login.module.ts index ee3af10109..53e921d7d2 100644 --- a/apps/web/src/app/auth/login/login.module.ts +++ b/apps/web/src/app/auth/login/login.module.ts @@ -5,20 +5,20 @@ import { CheckboxModule } from "@bitwarden/components"; import { SharedModule } from "../../../app/shared"; import { LoginDecryptionOptionsComponent } from "./login-decryption-options/login-decryption-options.component"; +import { LoginComponentV1 } from "./login-v1.component"; import { LoginViaAuthRequestComponent } from "./login-via-auth-request.component"; import { LoginViaWebAuthnComponent } from "./login-via-webauthn/login-via-webauthn.component"; -import { LoginComponent } from "./login.component"; @NgModule({ imports: [SharedModule, CheckboxModule], declarations: [ - LoginComponent, + LoginComponentV1, LoginViaAuthRequestComponent, LoginDecryptionOptionsComponent, LoginViaWebAuthnComponent, ], exports: [ - LoginComponent, + LoginComponentV1, LoginViaAuthRequestComponent, LoginDecryptionOptionsComponent, LoginViaWebAuthnComponent, diff --git a/apps/web/src/app/core/core.module.ts b/apps/web/src/app/core/core.module.ts index bcf3ce6ec6..d1fa3f8ba8 100644 --- a/apps/web/src/app/core/core.module.ts +++ b/apps/web/src/app/core/core.module.ts @@ -27,21 +27,31 @@ import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services. import { ModalService as ModalServiceAbstraction } from "@bitwarden/angular/services/modal.service"; import { RegistrationFinishService as RegistrationFinishServiceAbstraction, + LoginComponentService, LockComponentService, SetPasswordJitService, } from "@bitwarden/auth/angular"; -import { InternalUserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common"; +import { + InternalUserDecryptionOptionsServiceAbstraction, + LoginEmailService, +} from "@bitwarden/auth/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; -import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { + InternalPolicyService, + PolicyService, +} from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { AccountApiService as AccountApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/account-api.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { KdfConfigService } from "@bitwarden/common/auth/abstractions/kdf-config.service"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; +import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; import { ClientType } from "@bitwarden/common/enums"; 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 { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; import { EnvironmentService, @@ -50,7 +60,7 @@ import { import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { PlatformUtilsService as PlatformUtilsServiceAbstraction } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { SdkClientFactory } from "@bitwarden/common/platform/abstractions/sdk/sdk-client-factory"; import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service"; import { ThemeType } from "@bitwarden/common/platform/enums"; @@ -71,6 +81,7 @@ import { ThemeStateService, } from "@bitwarden/common/platform/theming/theme-state.service"; import { VaultTimeout, VaultTimeoutStringType } from "@bitwarden/common/types/vault-timeout.type"; +import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy"; import { KeyService as KeyServiceAbstraction, BiometricsService } from "@bitwarden/key-management"; import { flagEnabled } from "../../utils/flags"; @@ -78,6 +89,7 @@ import { PolicyListService } from "../admin-console/core/policy-list.service"; import { WebSetPasswordJitService, WebRegistrationFinishService, + WebLoginComponentService, WebLockComponentService, } from "../auth"; import { AcceptOrganizationInviteService } from "../auth/organization-invite/accept-organization.service"; @@ -109,8 +121,8 @@ const safeProviders: SafeProvider[] = [ safeProvider(PolicyListService), safeProvider({ provide: DEFAULT_VAULT_TIMEOUT, - deps: [PlatformUtilsServiceAbstraction], - useFactory: (platformUtilsService: PlatformUtilsServiceAbstraction): VaultTimeout => + deps: [PlatformUtilsService], + useFactory: (platformUtilsService: PlatformUtilsService): VaultTimeout => platformUtilsService.isDev() ? VaultTimeoutStringType.Never : 15, }), safeProvider({ @@ -148,7 +160,7 @@ const safeProviders: SafeProvider[] = [ deps: [], }), safeProvider({ - provide: PlatformUtilsServiceAbstraction, + provide: PlatformUtilsService, useClass: WebPlatformUtilsService, useAngularDecorators: true, }), @@ -243,6 +255,22 @@ const safeProviders: SafeProvider[] = [ useClass: DefaultAppIdService, deps: [OBSERVABLE_DISK_LOCAL_STORAGE, LogService], }), + safeProvider({ + provide: LoginComponentService, + useClass: WebLoginComponentService, + deps: [ + AcceptOrganizationInviteService, + LogService, + PolicyApiServiceAbstraction, + InternalPolicyService, + RouterService, + CryptoFunctionService, + EnvironmentService, + PasswordGenerationServiceAbstraction, + PlatformUtilsService, + SsoLoginServiceAbstraction, + ], + }), safeProvider({ provide: CollectionAdminService, useClass: DefaultCollectionAdminService, @@ -253,6 +281,11 @@ const safeProviders: SafeProvider[] = [ useClass: flagEnabled("sdk") ? WebSdkClientFactory : NoopSdkClientFactory, deps: [], }), + safeProvider({ + provide: LoginEmailService, + useClass: LoginEmailService, + deps: [AccountService, AuthService, StateProvider], + }), ]; @NgModule({ diff --git a/apps/web/src/app/oss-routing.module.ts b/apps/web/src/app/oss-routing.module.ts index 8822800b36..9f36df175f 100644 --- a/apps/web/src/app/oss-routing.module.ts +++ b/apps/web/src/app/oss-routing.module.ts @@ -22,12 +22,15 @@ import { RegistrationStartSecondaryComponentData, SetPasswordJitComponent, RegistrationLinkExpiredComponent, + LoginComponent, + LoginSecondaryContentComponent, LockV2Component, LockIcon, UserLockIcon, RegistrationUserAddIcon, RegistrationLockAltIcon, RegistrationExpiredLinkIcon, + VaultIcon, } from "@bitwarden/auth/angular"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; @@ -43,9 +46,9 @@ import { deepLinkGuard } from "./auth/guards/deep-link.guard"; import { HintComponent } from "./auth/hint.component"; import { LockComponent } from "./auth/lock.component"; import { LoginDecryptionOptionsComponent } from "./auth/login/login-decryption-options/login-decryption-options.component"; +import { LoginComponentV1 } from "./auth/login/login-v1.component"; import { LoginViaAuthRequestComponent } from "./auth/login/login-via-auth-request.component"; import { LoginViaWebAuthnComponent } from "./auth/login/login-via-webauthn/login-via-webauthn.component"; -import { LoginComponent } from "./auth/login/login.component"; import { AcceptOrganizationComponent } from "./auth/organization-invite/accept-organization.component"; import { RecoverDeleteComponent } from "./auth/recover-delete.component"; import { RecoverTwoFactorComponent } from "./auth/recover-two-factor.component"; @@ -180,6 +183,56 @@ const routes: Routes = [ }, ], }, + ...unauthUiRefreshSwap( + AnonLayoutWrapperComponent, + AnonLayoutWrapperComponent, + { + path: "login", + canActivate: [unauthGuardFn()], + children: [ + { + path: "", + component: LoginComponentV1, + }, + { + path: "", + component: EnvironmentSelectorComponent, + outlet: "environment-selector", + }, + ], + data: { + pageTitle: { + key: "logIn", + }, + }, + }, + { + path: "login", + canActivate: [unauthGuardFn()], + data: { + pageTitle: { + key: "logInToBitwarden", + }, + pageIcon: VaultIcon, + } satisfies RouteDataProperties & AnonLayoutWrapperData, + children: [ + { + path: "", + component: LoginComponent, + }, + { + path: "", + component: LoginSecondaryContentComponent, + outlet: "secondary", + }, + { + path: "", + component: EnvironmentSelectorComponent, + outlet: "environment-selector", + }, + ], + }, + ), ...unauthUiRefreshSwap( AnonLayoutWrapperComponent, AnonLayoutWrapperComponent, diff --git a/apps/web/src/connectors/sso.ts b/apps/web/src/connectors/sso.ts index e049c64e5d..44ead1dc18 100644 --- a/apps/web/src/connectors/sso.ts +++ b/apps/web/src/connectors/sso.ts @@ -6,10 +6,11 @@ window.addEventListener("load", () => { const code = getQsParam("code"); const state = getQsParam("state"); const lastpass = getQsParam("lp"); + const clientId = getQsParam("clientId"); if (lastpass === "1") { initiateBrowserSso(code, state, true); - } else if (state != null && state.includes(":clientId=browser")) { + } else if (state != null && clientId == "browser") { initiateBrowserSso(code, state, false); } else { window.location.href = window.location.origin + "/#/sso?code=" + code + "&state=" + state; diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 07d94892ad..470852dce4 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -939,9 +939,15 @@ "useADifferentLogInMethod": { "message": "Use a different log in method" }, - "loginWithPasskey": { + "logInWithPasskey": { "message": "Log in with passkey" }, + "useSingleSignOn": { + "message": "Use single sign-on" + }, + "welcomeBack": { + "message": "Welcome back" + }, "invalidPasskeyPleaseTryAgain": { "message": "Invalid Passkey. Please try again." }, @@ -1023,6 +1029,9 @@ "createAccount": { "message": "Create account" }, + "newToBitwarden": { + "message": "New to Bitwarden?" + }, "setAStrongPassword": { "message": "Set a strong password" }, @@ -1038,6 +1047,9 @@ "logIn": { "message": "Log in" }, + "logInToBitwarden": { + "message": "Log in to Bitwarden" + }, "verifyIdentity": { "message": "Verify your Identity" }, diff --git a/libs/angular/src/auth/components/environment-selector.component.ts b/libs/angular/src/auth/components/environment-selector.component.ts index 9e811d02af..706ecf7a81 100644 --- a/libs/angular/src/auth/components/environment-selector.component.ts +++ b/libs/angular/src/auth/components/environment-selector.component.ts @@ -1,8 +1,8 @@ import { animate, state, style, transition, trigger } from "@angular/animations"; import { ConnectedPosition } from "@angular/cdk/overlay"; -import { Component, EventEmitter, Output } from "@angular/core"; -import { Router } from "@angular/router"; -import { Observable, map } from "rxjs"; +import { Component, EventEmitter, Output, Input, OnInit, OnDestroy } from "@angular/core"; +import { Router, ActivatedRoute } from "@angular/router"; +import { Observable, map, Subject, takeUntil } from "rxjs"; import { EnvironmentService, @@ -10,6 +10,27 @@ import { RegionConfig, } from "@bitwarden/common/platform/abstractions/environment.service"; +export const ExtensionDefaultOverlayPosition: ConnectedPosition[] = [ + { + originX: "start", + originY: "top", + overlayX: "start", + overlayY: "bottom", + }, +]; +export const DesktopDefaultOverlayPosition: ConnectedPosition[] = [ + { + originX: "start", + originY: "top", + overlayX: "start", + overlayY: "bottom", + }, +]; + +export interface EnvironmentSelectorRouteData { + overlayPosition?: ConnectedPosition[]; +} + @Component({ selector: "environment-selector", templateUrl: "environment-selector.component.html", @@ -34,11 +55,9 @@ import { ]), ], }) -export class EnvironmentSelectorComponent { +export class EnvironmentSelectorComponent implements OnInit, OnDestroy { @Output() onOpenSelfHostedSettings = new EventEmitter(); - protected isOpen = false; - protected ServerEnvironmentType = Region; - protected overlayPosition: ConnectedPosition[] = [ + @Input() overlayPosition: ConnectedPosition[] = [ { originX: "start", originY: "bottom", @@ -47,6 +66,8 @@ export class EnvironmentSelectorComponent { }, ]; + protected isOpen = false; + protected ServerEnvironmentType = Region; protected availableRegions = this.environmentService.availableRegions(); protected selectedRegion$: Observable = this.environmentService.environment$.pipe( @@ -54,11 +75,27 @@ export class EnvironmentSelectorComponent { map((r) => this.availableRegions.find((ar) => ar.key === r)), ); + private destroy$ = new Subject(); + constructor( protected environmentService: EnvironmentService, protected router: Router, + private route: ActivatedRoute, ) {} + ngOnInit() { + this.route.data.pipe(takeUntil(this.destroy$)).subscribe((data) => { + if (data && data["overlayPosition"]) { + this.overlayPosition = data["overlayPosition"]; + } + }); + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } + async toggle(option: Region) { this.isOpen = !this.isOpen; if (option === null) { diff --git a/libs/angular/src/auth/components/login.component.ts b/libs/angular/src/auth/components/login-v1.component.ts similarity index 99% rename from libs/angular/src/auth/components/login.component.ts rename to libs/angular/src/auth/components/login-v1.component.ts index 1c8f4f656e..3114519189 100644 --- a/libs/angular/src/auth/components/login.component.ts +++ b/libs/angular/src/auth/components/login-v1.component.ts @@ -35,15 +35,17 @@ import { import { CaptchaProtectedComponent } from "./captcha-protected.component"; @Directive() -export class LoginComponent extends CaptchaProtectedComponent implements OnInit, OnDestroy { +export class LoginComponentV1 extends CaptchaProtectedComponent implements OnInit, OnDestroy { @ViewChild("masterPasswordInput", { static: true }) masterPasswordInput: ElementRef; showPassword = false; formPromise: Promise; + onSuccessfulLogin: () => Promise; onSuccessfulLoginNavigate: (userId: UserId) => Promise; onSuccessfulLoginTwoFactorNavigate: () => Promise; onSuccessfulLoginForceResetNavigate: () => Promise; + showLoginWithDevice: boolean; validatedEmail = false; paramEmailSet = false; @@ -208,6 +210,7 @@ export class LoginComponent extends CaptchaProtectedComponent implements OnInit, // eslint-disable-next-line @typescript-eslint/no-floating-promises this.onSuccessfulLogin(); } + if (this.onSuccessfulLoginNavigate != 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 @@ -292,6 +295,7 @@ export class LoginComponent extends CaptchaProtectedComponent implements OnInit, async validateEmail() { this.formGroup.controls.email.markAsTouched(); const emailValid = this.formGroup.get("email").valid; + if (emailValid) { this.toggleValidateEmail(true); await this.getLoginWithDevice(this.loggedEmail); @@ -346,8 +350,7 @@ export class LoginComponent extends CaptchaProtectedComponent implements OnInit, await this.loginEmailService.saveEmailSettings(); } - // Legacy accounts used the master key to encrypt data. Migration is required - // but only performed on web + // Legacy accounts used the master key to encrypt data. Migration is required but only performed on web protected async handleMigrateEncryptionKey(result: AuthResult): Promise { if (!result.requiresEncryptionKeyMigration) { return false; diff --git a/libs/angular/src/auth/functions/unauth-ui-refresh-redirect.spec.ts b/libs/angular/src/auth/functions/unauth-ui-refresh-redirect.spec.ts new file mode 100644 index 0000000000..5fc904a5b9 --- /dev/null +++ b/libs/angular/src/auth/functions/unauth-ui-refresh-redirect.spec.ts @@ -0,0 +1,55 @@ +import { TestBed } from "@angular/core/testing"; +import { Router, UrlTree } from "@angular/router"; +import { mock, MockProxy } from "jest-mock-extended"; + +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; + +import { unauthUiRefreshRedirect } from "./unauth-ui-refresh-redirect"; + +describe("unauthUiRefreshRedirect", () => { + let configService: MockProxy; + let router: MockProxy; + + beforeEach(() => { + configService = mock(); + router = mock(); + + TestBed.configureTestingModule({ + providers: [ + { provide: ConfigService, useValue: configService }, + { provide: Router, useValue: router }, + ], + }); + }); + + it("returns true when UnauthenticatedExtensionUIRefresh flag is disabled", async () => { + configService.getFeatureFlag.mockResolvedValue(false); + + const result = await TestBed.runInInjectionContext(() => + unauthUiRefreshRedirect("/redirect")(), + ); + + expect(result).toBe(true); + expect(configService.getFeatureFlag).toHaveBeenCalledWith( + FeatureFlag.UnauthenticatedExtensionUIRefresh, + ); + expect(router.parseUrl).not.toHaveBeenCalled(); + }); + + it("returns UrlTree when UnauthenticatedExtensionUIRefresh flag is enabled", async () => { + const mockUrlTree = mock(); + configService.getFeatureFlag.mockResolvedValue(true); + router.parseUrl.mockReturnValue(mockUrlTree); + + const result = await TestBed.runInInjectionContext(() => + unauthUiRefreshRedirect("/redirect")(), + ); + + expect(result).toBe(mockUrlTree); + expect(configService.getFeatureFlag).toHaveBeenCalledWith( + FeatureFlag.UnauthenticatedExtensionUIRefresh, + ); + expect(router.parseUrl).toHaveBeenCalledWith("/redirect"); + }); +}); diff --git a/libs/angular/src/auth/functions/unauth-ui-refresh-redirect.ts b/libs/angular/src/auth/functions/unauth-ui-refresh-redirect.ts new file mode 100644 index 0000000000..22ed23273b --- /dev/null +++ b/libs/angular/src/auth/functions/unauth-ui-refresh-redirect.ts @@ -0,0 +1,24 @@ +import { inject } from "@angular/core"; +import { UrlTree, Router } from "@angular/router"; + +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; + +/** + * Helper function to redirect to a new URL based on the UnauthenticatedExtensionUIRefresh feature flag. + * @param redirectUrl - The URL to redirect to if the UnauthenticatedExtensionUIRefresh flag is enabled. + */ +export function unauthUiRefreshRedirect(redirectUrl: string): () => Promise { + return async () => { + const configService = inject(ConfigService); + const router = inject(Router); + const shouldRedirect = await configService.getFeatureFlag( + FeatureFlag.UnauthenticatedExtensionUIRefresh, + ); + if (shouldRedirect) { + return router.parseUrl(redirectUrl); + } else { + return true; + } + }; +} diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index b7c4a02dec..444fb55dee 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -14,6 +14,8 @@ import { DefaultRegistrationFinishService, AnonLayoutWrapperDataService, DefaultAnonLayoutWrapperDataService, + LoginComponentService, + DefaultLoginComponentService, } from "@bitwarden/auth/angular"; import { AuthRequestServiceAbstraction, @@ -1334,6 +1336,17 @@ const safeProviders: SafeProvider[] = [ useExisting: NoopViewCacheService, deps: [], }), + safeProvider({ + provide: LoginComponentService, + useClass: DefaultLoginComponentService, + deps: [ + CryptoFunctionServiceAbstraction, + EnvironmentService, + PasswordGenerationServiceAbstraction, + PlatformUtilsServiceAbstraction, + SsoLoginServiceAbstraction, + ], + }), safeProvider({ provide: SdkService, useClass: DefaultSdkService, diff --git a/libs/auth/src/angular/icons/index.ts b/libs/auth/src/angular/icons/index.ts index 26e668b784..70460a7aea 100644 --- a/libs/auth/src/angular/icons/index.ts +++ b/libs/auth/src/angular/icons/index.ts @@ -1,8 +1,11 @@ export * from "./bitwarden-logo.icon"; export * from "./bitwarden-shield.icon"; export * from "./lock.icon"; +export * from "./registration-check-email.icon"; export * from "./user-lock.icon"; export * from "./user-verification-biometrics-fingerprint.icon"; +export * from "./wave.icon"; +export * from "./vault.icon"; export * from "./registration-user-add.icon"; export * from "./registration-lock-alt.icon"; export * from "./registration-expired-link.icon"; diff --git a/libs/auth/src/angular/icons/vault.icon.ts b/libs/auth/src/angular/icons/vault.icon.ts new file mode 100644 index 0000000000..e23944ab7d --- /dev/null +++ b/libs/auth/src/angular/icons/vault.icon.ts @@ -0,0 +1,23 @@ +import { svgIcon } from "@bitwarden/components"; + +export const VaultIcon = svgIcon` + + + + + + + + + + + + + + + + + + + +`; diff --git a/libs/auth/src/angular/icons/wave.icon.ts b/libs/auth/src/angular/icons/wave.icon.ts new file mode 100644 index 0000000000..3e4483c1e0 --- /dev/null +++ b/libs/auth/src/angular/icons/wave.icon.ts @@ -0,0 +1,34 @@ +import { svgIcon } from "@bitwarden/components"; + +export const WaveIcon = svgIcon` + + + + + + + +`; diff --git a/libs/auth/src/angular/index.ts b/libs/auth/src/angular/index.ts index 6de473c33e..ef77a7ab5a 100644 --- a/libs/auth/src/angular/index.ts +++ b/libs/auth/src/angular/index.ts @@ -2,9 +2,6 @@ * This barrel file should only contain Angular exports */ -// icons -export * from "./icons"; - // anon layout export * from "./anon-layout/anon-layout.component"; export * from "./anon-layout/anon-layout-wrapper.component"; @@ -14,15 +11,33 @@ export * from "./anon-layout/default-anon-layout-wrapper-data.service"; // fingerprint dialog export * from "./fingerprint-dialog/fingerprint-dialog.component"; +// icons +export * from "./icons"; + +// input password +export * from "./input-password/input-password.component"; +export * from "./input-password/password-input-result"; + +// login +export * from "./login/login.component"; +export * from "./login/login-secondary-content.component"; +export * from "./login/login-component.service"; +export * from "./login/default-login-component.service"; + // password callout export * from "./password-callout/password-callout.component"; // password hint export * from "./password-hint/password-hint.component"; -// input password -export * from "./input-password/input-password.component"; -export * from "./input-password/password-input-result"; +// registration +export * from "./registration/registration-start/registration-start.component"; +export * from "./registration/registration-finish/registration-finish.component"; +export * from "./registration/registration-link-expired/registration-link-expired.component"; +export * from "./registration/registration-start/registration-start-secondary.component"; +export * from "./registration/registration-env-selector/registration-env-selector.component"; +export * from "./registration/registration-finish/registration-finish.service"; +export * from "./registration/registration-finish/default-registration-finish.service"; // set password (JIT user) export * from "./set-password-jit/set-password-jit.component"; @@ -34,15 +49,6 @@ export * from "./user-verification/user-verification-dialog.component"; export * from "./user-verification/user-verification-dialog.types"; export * from "./user-verification/user-verification-form-input.component"; -// registration -export * from "./registration/registration-start/registration-start.component"; -export * from "./registration/registration-finish/registration-finish.component"; -export * from "./registration/registration-link-expired/registration-link-expired.component"; -export * from "./registration/registration-start/registration-start-secondary.component"; -export * from "./registration/registration-env-selector/registration-env-selector.component"; -export * from "./registration/registration-finish/registration-finish.service"; -export * from "./registration/registration-finish/default-registration-finish.service"; - // lock export * from "./lock/lock.component"; export * from "./lock/lock-component.service"; diff --git a/libs/auth/src/angular/login/default-login-component.service.spec.ts b/libs/auth/src/angular/login/default-login-component.service.spec.ts new file mode 100644 index 0000000000..2b565ea67b --- /dev/null +++ b/libs/auth/src/angular/login/default-login-component.service.spec.ts @@ -0,0 +1,124 @@ +import { mock, MockProxy } from "jest-mock-extended"; +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 { + EnvironmentService, + Environment, +} 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"; +import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy"; + +import { DefaultLoginComponentService } from "./default-login-component.service"; + +jest.mock("@bitwarden/common/platform/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"); +jest.mock("@bitwarden/generator-legacy"); + +describe("DefaultLoginComponentService", () => { + let service: DefaultLoginComponentService; + let cryptoFunctionService: MockProxy; + let environmentService: MockProxy; + let platformUtilsService: MockProxy; + let ssoLoginService: MockProxy; + let passwordGenerationService: MockProxy; + + beforeEach(() => { + cryptoFunctionService = mock(); + environmentService = mock(); + platformUtilsService = mock(); + ssoLoginService = mock(); + passwordGenerationService = mock(); + + environmentService.environment$ = of({ + getWebVaultUrl: () => "https://webvault.bitwarden.com", + getRegion: () => "US", + getUrls: () => ({}), + isCloud: () => true, + getApiUrl: () => "https://api.bitwarden.com", + } as Environment); + + service = new DefaultLoginComponentService( + cryptoFunctionService, + environmentService, + passwordGenerationService, + platformUtilsService, + ssoLoginService, + ); + }); + + it("creates without error", () => { + expect(service).toBeTruthy(); + }); + + describe("getOrgPolicies", () => { + it("returns null", async () => { + const result = await service.getOrgPolicies(); + expect(result).toBeNull(); + }); + }); + + describe("isLoginViaAuthRequestSupported", () => { + it("returns false by default", () => { + expect(service.isLoginViaAuthRequestSupported()).toBe(false); + }); + }); + + describe("isLoginWithPasskeySupported", () => { + it("returns true when clientType is Web", () => { + service["clientType"] = ClientType.Web; + expect(service.isLoginWithPasskeySupported()).toBe(true); + }); + + it("returns false when clientType is not Web", () => { + service["clientType"] = ClientType.Desktop; + expect(service.isLoginWithPasskeySupported()).toBe(false); + }); + }); + + describe("launchSsoBrowserWindow", () => { + const email = "test@bitwarden.com"; + const state = "testState"; + const codeVerifier = "testCodeVerifier"; + const codeChallenge = "testCodeChallenge"; + const baseUrl = "https://webvault.bitwarden.com/#/sso"; + + beforeEach(() => { + passwordGenerationService.generatePassword.mockResolvedValueOnce(state); + passwordGenerationService.generatePassword.mockResolvedValueOnce(codeVerifier); + jest.spyOn(Utils, "fromBufferToUrlB64").mockReturnValue(codeChallenge); + }); + + it.each([ + { + clientType: ClientType.Browser, + clientId: "browser", + expectedRedirectUri: "https://webvault.bitwarden.com/sso-connector.html", + }, + { + clientType: ClientType.Desktop, + clientId: "desktop", + expectedRedirectUri: "bitwarden://sso-callback", + }, + ])( + "launches SSO browser window with correct URL for $clientId client", + async ({ clientType, clientId, expectedRedirectUri }) => { + service["clientType"] = clientType; + + await service.launchSsoBrowserWindow(email, clientId as "browser" | "desktop"); + + const expectedUrl = `${baseUrl}?clientId=${clientId}&redirectUri=${encodeURIComponent(expectedRedirectUri)}&state=${state}&codeChallenge=${codeChallenge}&email=${encodeURIComponent(email)}`; + + expect(ssoLoginService.setSsoEmail).toHaveBeenCalledWith(email); + expect(ssoLoginService.setSsoState).toHaveBeenCalledWith(state); + expect(ssoLoginService.setCodeVerifier).toHaveBeenCalledWith(codeVerifier); + expect(platformUtilsService.launchUri).toHaveBeenCalledWith(expectedUrl); + }, + ); + }); +}); diff --git a/libs/auth/src/angular/login/default-login-component.service.ts b/libs/auth/src/angular/login/default-login-component.service.ts new file mode 100644 index 0000000000..30ab55cc0e --- /dev/null +++ b/libs/auth/src/angular/login/default-login-component.service.ts @@ -0,0 +1,94 @@ +import { firstValueFrom } from "rxjs"; + +import { LoginComponentService, PasswordPolicies } 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 { 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"; +import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy"; + +export class DefaultLoginComponentService implements LoginComponentService { + protected clientType: ClientType; + + constructor( + protected cryptoFunctionService: CryptoFunctionService, + protected environmentService: EnvironmentService, + // TODO: refactor to not use deprecated service + protected passwordGenerationService: PasswordGenerationServiceAbstraction, + protected platformUtilsService: PlatformUtilsService, + protected ssoLoginService: SsoLoginServiceAbstraction, + ) {} + + async getOrgPolicies(): Promise { + return null; + } + + isLoginViaAuthRequestSupported(): boolean { + return false; + } + + isLoginWithPasskeySupported(): boolean { + return this.clientType === ClientType.Web; + } + + async launchSsoBrowserWindow( + email: string, + clientId: "browser" | "desktop", + ): Promise { + // Save email for SSO + await this.ssoLoginService.setSsoEmail(email); + + // Generate SSO params + const passwordOptions: any = { + type: "password", + length: 64, + uppercase: true, + lowercase: true, + numbers: true, + special: false, + }; + + const state = await this.passwordGenerationService.generatePassword(passwordOptions); + + const codeVerifier = await this.passwordGenerationService.generatePassword(passwordOptions); + const codeVerifierHash = await this.cryptoFunctionService.hash(codeVerifier, "sha256"); + const codeChallenge = Utils.fromBufferToUrlB64(codeVerifierHash); + + // Save SSO params + await this.ssoLoginService.setSsoState(state); + await this.ssoLoginService.setCodeVerifier(codeVerifier); + + // Build URL + const env = await firstValueFrom(this.environmentService.environment$); + const webVaultUrl = env.getWebVaultUrl(); + + const redirectUri = + clientId === "browser" + ? webVaultUrl + "/sso-connector.html" // Browser + : "bitwarden://sso-callback"; // Desktop + + // Launch browser window with URL + this.platformUtilsService.launchUri( + webVaultUrl + + "/#/sso?clientId=" + + clientId + + "&redirectUri=" + + encodeURIComponent(redirectUri) + + "&state=" + + state + + "&codeChallenge=" + + codeChallenge + + "&email=" + + encodeURIComponent(email), + ); + } + + /** + * No-op implementation of showBackButton + */ + showBackButton(showBackButton: boolean): void { + return; + } +} diff --git a/libs/auth/src/angular/login/login-component.service.ts b/libs/auth/src/angular/login/login-component.service.ts new file mode 100644 index 0000000000..213e09e352 --- /dev/null +++ b/libs/auth/src/angular/login/login-component.service.ts @@ -0,0 +1,46 @@ +import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options"; +import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; + +export interface PasswordPolicies { + policies: Policy[]; + isPolicyAndAutoEnrollEnabled: boolean; + enforcedPasswordPolicyOptions: MasterPasswordPolicyOptions; +} + +/** + * The `LoginComponentService` allows the single libs/auth `LoginComponent` to + * delegate all client-specific functionality to client-specific service + * implementations of `LoginComponentService`. + * + * The `LoginComponentService` should not be confused with the + * `LoginStrategyService`, which is used to determine the login strategy and + * performs the core login logic. + */ +export abstract class LoginComponentService { + /** + * Gets the organization policies if there is an organization invite. + * - Used by: Web + */ + getOrgPolicies: () => Promise; + + /** + * Indicates whether login with device (auth request) is supported on the given client + */ + isLoginViaAuthRequestSupported: () => boolean; + + /** + * Indicates whether login with passkey is supported on the given client + */ + isLoginWithPasskeySupported: () => boolean; + + /** + * Launches the SSO flow in a new browser window. + * - Used by: Browser, Desktop + */ + launchSsoBrowserWindow: (email: string, clientId: "browser" | "desktop") => Promise; + + /** + * Shows the back button. + */ + showBackButton: (showBackButton: boolean) => void; +} diff --git a/libs/auth/src/angular/login/login-secondary-content.component.ts b/libs/auth/src/angular/login/login-secondary-content.component.ts new file mode 100644 index 0000000000..abc772b6c1 --- /dev/null +++ b/libs/auth/src/angular/login/login-secondary-content.component.ts @@ -0,0 +1,24 @@ +import { CommonModule } from "@angular/common"; +import { Component, inject } from "@angular/core"; +import { RouterModule } from "@angular/router"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { RegisterRouteService } from "@bitwarden/auth/common"; +import { LinkModule } from "@bitwarden/components"; + +@Component({ + standalone: true, + imports: [CommonModule, JslibModule, LinkModule, RouterModule], + template: ` +
+ {{ "newToBitwarden" | i18n }} + {{ "createAccount" | i18n }} +
+ `, +}) +export class LoginSecondaryContentComponent { + registerRouteService = inject(RegisterRouteService); + + // TODO: remove when email verification flag is removed + protected registerRoute$ = this.registerRouteService.registerRoute$(); +} diff --git a/libs/auth/src/angular/login/login.component.html b/libs/auth/src/angular/login/login.component.html new file mode 100644 index 0000000000..888b2e8639 --- /dev/null +++ b/libs/auth/src/angular/login/login.component.html @@ -0,0 +1,144 @@ + + +
+ + + + {{ "emailAddress" | i18n }} + + + + + + + {{ "rememberEmail" | i18n }} + + +
+ + + +
{{ "or" | i18n }}
+ + + + + + {{ "logInWithPasskey" | i18n }} + + + + + + + + {{ "useSingleSignOn" | i18n }} + + + + + +
+
+ + + + + {{ "masterPass" | i18n }} + + + + + + + {{ "getMasterPasswordHint" | i18n }} + + + + + +
+ + + + + +
{{ "or" | i18n }}
+ + +
+ + + + + +
+
+
diff --git a/libs/auth/src/angular/login/login.component.ts b/libs/auth/src/angular/login/login.component.ts new file mode 100644 index 0000000000..ad17a0a97a --- /dev/null +++ b/libs/auth/src/angular/login/login.component.ts @@ -0,0 +1,561 @@ +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 { firstValueFrom, Subject, 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 { 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 { 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 { Utils } from "@bitwarden/common/platform/misc/utils"; +import { SyncService } from "@bitwarden/common/platform/sync"; +import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength"; +import { + AsyncActionsModule, + ButtonModule, + CheckboxModule, + FormFieldModule, + IconButtonModule, + LinkModule, + ToastService, +} from "@bitwarden/components"; + +import { AnonLayoutWrapperDataService } from "../anon-layout/anon-layout-wrapper-data.service"; +import { VaultIcon, 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, + LinkModule, + JslibModule, + ReactiveFormsModule, + RouterModule, + ], +}) +export class LoginComponent implements OnInit, OnDestroy { + @ViewChild("masterPasswordInputRef") masterPasswordInputRef: ElementRef; + @Input() captchaSiteKey: string = null; + + private destroy$ = new Subject(); + private enforcedMasterPasswordOptions: MasterPasswordPolicyOptions = undefined; + readonly Icons = { WaveIcon, VaultIcon }; + + captcha: CaptchaIFrame; + captchaToken: string = null; + clientType: ClientType; + ClientType = ClientType; + LoginUiState = LoginUiState; + registerRoute$ = this.registerRouteService.registerRoute$(); // TODO: remove when email verification flag is removed + isKnownDevice = false; + loginUiState: LoginUiState = LoginUiState.EMAIL_ENTRY; + + formGroup = this.formBuilder.group( + { + email: ["", [Validators.required, Validators.email]], + masterPassword: [ + "", + [Validators.required, Validators.minLength(Utils.originalMinimumPasswordLength)], + ], + rememberEmail: [false], + }, + { updateOn: "submit" }, + ); + + get emailFormControl(): FormControl { + return this.formGroup.controls.email; + } + + /** + * LoginViaAuthRequestSupported is a boolean that determines if we show the Login with device button. + * An AuthRequest is the mechanism that allows users to login to the client via a device that is already logged in. + */ + loginViaAuthRequestSupported = false; + + // Web properties + enforcedPasswordPolicyOptions: MasterPasswordPolicyOptions; + policies: Policy[]; + showResetPasswordAutoEnrollWarning = false; + + // Desktop properties + deferFocus: boolean | null = null; + + 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, + private logService: LogService, + ) { + this.clientType = this.platformUtilsService.getClientType(); + this.loginViaAuthRequestSupported = this.loginComponentService.isLoginViaAuthRequestSupported(); + } + + async ngOnInit(): Promise { + await this.defaultOnInit(); + + if (this.clientType === ClientType.Desktop) { + await this.desktopOnInit(); + } + } + + ngOnDestroy(): void { + if (this.clientType === ClientType.Desktop) { + // TODO: refactor to not use deprecated broadcaster service. + this.broadcasterService.unsubscribe(BroadcasterSubscriptionId); + } + + this.destroy$.next(); + this.destroy$.complete(); + } + + submit = async (): Promise => { + if (this.clientType === ClientType.Desktop) { + if (this.loginUiState !== LoginUiState.MASTER_PASSWORD_ENTRY) { + return; + } + } + + const { email, masterPassword } = this.formGroup.value; + + await this.setupCaptcha(); + + 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 { + 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... + await this.evaluatePassword(); + + this.loginEmailService.clearValues(); + + if (this.clientType === ClientType.Browser) { + await this.router.navigate(["/tabs/vault"]); + } else { + await this.router.navigate(["vault"]); + } + } + + protected async launchSsoBrowserWindow(clientId: "browser" | "desktop"): Promise { + await this.loginComponentService.launchSsoBrowserWindow(this.emailFormControl.value, clientId); + } + + protected async evaluatePassword(): Promise { + try { + // If we do not have any saved policies, attempt to load them from the service + if (this.enforcedMasterPasswordOptions == undefined) { + this.enforcedMasterPasswordOptions = await firstValueFrom( + this.policyService.masterPasswordPolicyOptions$(), + ); + } + + if (this.requirePasswordChange()) { + await this.router.navigate(["update-password"]); + return; + } + } catch (e) { + // Do not prevent unlock if there is an error evaluating policies + this.logService.error(e); + } + } + + /** + * Checks if the master password meets the enforced policy requirements + * If not, returns false + */ + private requirePasswordChange(): boolean { + if ( + this.enforcedMasterPasswordOptions == undefined || + !this.enforcedMasterPasswordOptions.enforceOnLogin + ) { + return false; + } + + const masterPassword = this.formGroup.controls.masterPassword.value; + + const passwordStrength = this.passwordStrengthService.getPasswordStrength( + masterPassword, + this.formGroup.value.email, + )?.score; + + return !this.policyService.evaluateMasterPassword( + passwordStrength, + masterPassword, + this.enforcedMasterPasswordOptions, + ); + } + + protected showCaptcha(): boolean { + return !Utils.isNullOrWhitespace(this.captchaSiteKey); + } + + protected async startAuthRequestLogin(): Promise { + 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 { + this.formGroup.controls.email.markAsTouched(); + return this.formGroup.controls.email.valid; + } + + protected async toggleLoginUiState(value: LoginUiState): Promise { + this.loginUiState = value; + + if (this.loginUiState === LoginUiState.EMAIL_ENTRY) { + this.loginComponentService.showBackButton(false); + + this.anonLayoutWrapperDataService.setAnonLayoutWrapperData({ + pageTitle: { key: "logInToBitwarden" }, + pageIcon: this.Icons.VaultIcon, + pageSubtitle: null, // remove subtitle when going back to email entry + }); + + // Reset master password only when going from validated to not validated so that autofill can work properly + this.formGroup.controls.masterPassword.reset(); + + if (this.loginViaAuthRequestSupported) { + // Reset known device state when going back to email entry if it is supported + this.isKnownDevice = false; + } + } else if (this.loginUiState === LoginUiState.MASTER_PASSWORD_ENTRY) { + this.loginComponentService.showBackButton(true); + this.anonLayoutWrapperDataService.setAnonLayoutWrapperData({ + pageTitle: { key: "welcomeBack" }, + pageSubtitle: this.emailFormControl.value, + pageIcon: this.Icons.WaveIcon, + }); + + // 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(); + }); + } + + if (this.loginViaAuthRequestSupported) { + await this.getKnownDevice(this.emailFormControl.value); + } + } + } + + /** + * Set the email value from the input field. + * @param event The event object from the input field. + */ + onEmailBlur(event: Event) { + const emailInput = event.target as HTMLInputElement; + this.formGroup.controls.email.setValue(emailInput.value); + // Call setLoginEmail so that the email is pre-populated when navigating to the "enter password" screen. + this.loginEmailService.setLoginEmail(this.formGroup.value.email); + } + + isLoginWithPasskeySupported() { + return this.loginComponentService.isLoginWithPasskeySupported(); + } + + protected async goToHint(): Promise { + await this.saveEmailSettings(); + await this.router.navigateByUrl("/hint"); + } + + protected async goToRegister(): Promise { + // 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 { + await this.loginEmailService.setLoginEmail(this.formGroup.value.email); + this.loginEmailService.setRememberEmail(this.formGroup.value.rememberEmail); + await this.loginEmailService.saveEmailSettings(); + } + + protected async continue(): Promise { + if (await this.validateEmail()) { + await this.toggleLoginUiState(LoginUiState.MASTER_PASSWORD_ENTRY); + } + } + + /** + * Call to check if the device is known. + * Known means that the user has logged in with this device before. + * @param email - The user's email + */ + private async getKnownDevice(email: string): Promise { + try { + const deviceIdentifier = await this.appIdService.getAppId(); + this.isKnownDevice = await this.devicesApiService.getKnownDevice(email, deviceIdentifier); + } catch (e) { + this.isKnownDevice = false; + } + } + + private async setupCaptcha(): Promise { + 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 { + // 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() { + document + .getElementById( + this.emailFormControl.value == null || this.emailFormControl.value === "" + ? "email" + : "masterPassword", + ) + ?.focus(); + } + + private async defaultOnInit(): Promise { + // 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; + + 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(); + } + + if (this.loginViaAuthRequestSupported) { + 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 { + // 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. + */ + protected shouldShowBackButton(): boolean { + return ( + this.loginUiState === LoginUiState.MASTER_PASSWORD_ENTRY && + this.clientType !== ClientType.Browser + ); + } +} diff --git a/libs/auth/src/angular/registration/registration-start/registration-start.component.ts b/libs/auth/src/angular/registration/registration-start/registration-start.component.ts index 258f811ec8..5ce606b5ec 100644 --- a/libs/auth/src/angular/registration/registration-start/registration-start.component.ts +++ b/libs/auth/src/angular/registration/registration-start/registration-start.component.ts @@ -18,6 +18,7 @@ import { LinkModule, } from "@bitwarden/components"; +import { LoginEmailService } from "../../../common"; import { AnonLayoutWrapperDataService } from "../../anon-layout/anon-layout-wrapper-data.service"; import { RegistrationUserAddIcon } from "../../icons"; import { RegistrationCheckEmailIcon } from "../../icons/registration-check-email.icon"; @@ -89,6 +90,7 @@ export class RegistrationStartComponent implements OnInit, OnDestroy { private platformUtilsService: PlatformUtilsService, private accountApiService: AccountApiService, private router: Router, + private loginEmailService: LoginEmailService, private anonLayoutWrapperDataService: AnonLayoutWrapperDataService, ) { this.isSelfHost = platformUtilsService.isSelfHost(); @@ -99,6 +101,15 @@ export class RegistrationStartComponent implements OnInit, OnDestroy { this.registrationStartStateChange.emit(this.state); this.listenForQueryParamChanges(); + + /** + * If the user has a login email, set the email field to the login email. + */ + this.loginEmailService.loginEmail$.pipe(takeUntil(this.destroy$)).subscribe((email) => { + if (email) { + this.formGroup.patchValue({ email }); + } + }); } private listenForQueryParamChanges() { diff --git a/libs/auth/src/angular/registration/registration-start/registration-start.stories.ts b/libs/auth/src/angular/registration/registration-start/registration-start.stories.ts index f7f6185280..fa3ad2ae2b 100644 --- a/libs/auth/src/angular/registration/registration-start/registration-start.stories.ts +++ b/libs/auth/src/angular/registration/registration-start/registration-start.stories.ts @@ -4,7 +4,7 @@ import { BrowserAnimationsModule } from "@angular/platform-browser/animations"; import { ActivatedRoute, Params } from "@angular/router"; import { RouterTestingModule } from "@angular/router/testing"; import { Meta, StoryObj, applicationConfig, moduleMetadata } from "@storybook/angular"; -import { of } from "rxjs"; +import { of, BehaviorSubject } from "rxjs"; import { AccountApiService } from "@bitwarden/common/auth/abstractions/account-api.service"; import { ClientType } from "@bitwarden/common/enums"; @@ -30,6 +30,7 @@ import { // FIXME: remove `/apps` import from `/libs` // eslint-disable-next-line import/no-restricted-paths import { PreloadedEnglishI18nModule } from "../../../../../../apps/web/src/app/core/tests"; +import { LoginEmailService } from "../../../common"; import { AnonLayoutWrapperDataService } from "../../anon-layout/anon-layout-wrapper-data.service"; import { AnonLayoutWrapperData } from "../../anon-layout/anon-layout-wrapper.component"; @@ -45,6 +46,7 @@ const decorators = (options: { queryParams?: Params; clientType?: ClientType; defaultRegion?: Region; + initialLoginEmail?: string; }) => { return [ moduleMetadata({ @@ -90,6 +92,12 @@ const decorators = (options: { getClientType: () => options.clientType || ClientType.Web, } as Partial, }, + { + provide: LoginEmailService, + useValue: { + loginEmail$: new BehaviorSubject(options.initialLoginEmail || null), + } as Partial, + }, { provide: AnonLayoutWrapperDataService, useValue: { @@ -159,6 +167,21 @@ export const WebUSRegionQueryParamsExample: Story = { }), }; +export const WebUSRegionWithInitialLoginEmailExample: Story = { + render: (args) => ({ + props: args, + template: ` + + `, + }), + decorators: decorators({ + clientType: ClientType.Web, + queryParams: {}, + defaultRegion: Region.US, + initialLoginEmail: "example@bitwarden.com", + }), +}; + export const DesktopUSRegionExample: Story = { render: (args) => ({ props: args,