From 226af09aac3b3dc8cf665f36a86e416f5dd9127f Mon Sep 17 00:00:00 2001 From: Jared Snider <116684653+JaredSnider-Bitwarden@users.noreply.github.com> Date: Mon, 8 Jul 2024 10:40:23 -0400 Subject: [PATCH] Auth/PM-7321 - Registration with Email Verification - Registration Finish Component Implementation (#9653) * PM-7321 - Temp add input password * PM-7321 - update input password based on latest PR changes to test. * PM-7321 - Progress on testing input password component + RegistrationFinishComponent checks * PM-7321 - more progress on registration finish. * PM-7321 - Wire up RegistrationFinishRequest model + AccountApiService abstraction + implementation changes for new method. * PM-7321 - WIP Registration Finish - wiring up request building and API call on submit. * PM-7321 - WIP registratin finish * PM-7321 - WIP on creating registration-finish service + web override to add org invite handling * PM-7321 - (1) Move web-registration-finish svc to web (2) Wire up exports (3) wire up RegistrationFinishComponent to call registration finish service * PM-7321 - Get CLI building * PM-7321 - Move all finish registration service and content to registration-finish feature folder. * PM-7321 - Fix RegistrationFinishService config * PM-7321 - RegistrationFinishComponent- handlePasswordFormSubmit - error handling WIP * PM-7321 - InputPasswordComp - Update to accept masterPasswordPolicyOptions as input instead of retrieving it as parent components in different scenarios will need to retrieve the policies differently (e.g., orgInvite token in registration vs direct call via org id post SSO on set password) * PM-7321 - Registration Finish - Add web specific logic for retrieving master password policies and passing them into the input password component. * PM-7321 - Registration Start - Send email via query param to registration finish page so it can create masterKey * PM-7321 - InputPassword comp - (1) Add loading input (2) Add email validation to submit logic. * PM-7321 - Registration Finish - Add submitting state and pass into input password so that the rest of the registration process keeps the child form disabled. * PM-7321 - Registration Finish - use validation service for error handling. * PM-7321 - All register routes must be dynamic and change if the feature flag changes. * PM-7321 - Test registration finish services. * PM-7321 - RegisterRouteService - Add comment documenting why the service exists. * PM-7321 - Add missing input password translations to browser & desktop * PM-7321 - WebRegistrationFinishSvc - apply PR feedback --- apps/browser/src/_locales/en/messages.json | 15 ++ .../src/auth/popup/home.component.html | 2 +- apps/browser/src/auth/popup/home.component.ts | 17 +- .../browser/src/auth/popup/login.component.ts | 6 +- .../src/auth/login/login.component.html | 2 +- .../desktop/src/auth/login/login.component.ts | 6 +- apps/desktop/src/locales/en/messages.json | 15 ++ .../accept-family-sponsorship.component.ts | 7 +- apps/web/src/app/auth/core/services/index.ts | 1 + .../auth/core/services/registration/index.ts | 1 + .../web-registration-finish.service.spec.ts | 241 ++++++++++++++++++ .../web-registration-finish.service.ts | 94 +++++++ .../accept/accept-emergency.component.html | 2 +- .../accept/accept-emergency.component.ts | 6 +- .../src/app/auth/login/login.component.html | 2 +- .../web/src/app/auth/login/login.component.ts | 15 +- .../accept-organization.component.html | 2 +- .../accept-organization.component.ts | 16 +- .../src/app/common/base.accept.component.ts | 16 +- apps/web/src/app/core/core.module.ts | 19 ++ .../src/app/tools/send/access.component.html | 2 +- .../src/app/tools/send/access.component.ts | 14 +- apps/web/src/locales/en/messages.json | 3 + .../manage/accept-provider.component.html | 2 +- .../manage/accept-provider.component.ts | 6 +- .../src/auth/components/login.component.ts | 16 +- .../src/services/jslib-services.module.ts | 15 ++ libs/auth/src/angular/index.ts | 5 + .../input-password.component.html | 9 +- .../input-password.component.ts | 5 + ...efault-registration-finish.service.spec.ts | 112 ++++++++ .../default-registration-finish.service.ts | 72 ++++++ .../registration-finish.component.html | 12 +- .../registration-finish.component.ts | 96 ++++++- .../registration-finish.service.ts | 25 ++ .../registration-start.component.ts | 4 +- libs/auth/src/common/services/index.ts | 1 + .../common/services/register-route.service.ts | 21 ++ .../auth/abstractions/account-api.service.ts | 12 + .../registration/register-finish.request.ts | 25 ++ .../src/auth/services/account-api.service.ts | 21 ++ 41 files changed, 869 insertions(+), 94 deletions(-) create mode 100644 apps/web/src/app/auth/core/services/registration/index.ts create mode 100644 apps/web/src/app/auth/core/services/registration/web-registration-finish.service.spec.ts create mode 100644 apps/web/src/app/auth/core/services/registration/web-registration-finish.service.ts create mode 100644 libs/auth/src/angular/registration/registration-finish/default-registration-finish.service.spec.ts create mode 100644 libs/auth/src/angular/registration/registration-finish/default-registration-finish.service.ts create mode 100644 libs/auth/src/angular/registration/registration-finish/registration-finish.service.ts create mode 100644 libs/auth/src/common/services/register-route.service.ts create mode 100644 libs/common/src/auth/models/request/registration/register-finish.request.ts diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 4030e6c94e8..64dd8d236ed 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -556,6 +556,18 @@ "security": { "message": "Security" }, + "confirmMasterPassword": { + "message": "Confirm master password" + }, + "masterPassword": { + "message": "Master password" + }, + "masterPassImportant": { + "message": "Your master password cannot be recovered if you forget it!" + }, + "masterPassHintLabel": { + "message": "Master password hint" + }, "errorOccurred": { "message": "An error has occurred" }, @@ -2164,6 +2176,9 @@ "emailVerificationRequired": { "message": "Email verification required" }, + "emailVerifiedV2": { + "message": "Email verified" + }, "emailVerificationRequiredDesc": { "message": "You must verify your email to use this feature. You can verify your email in the web vault." }, diff --git a/apps/browser/src/auth/popup/home.component.html b/apps/browser/src/auth/popup/home.component.html index 35371948de9..ed395797961 100644 --- a/apps/browser/src/auth/popup/home.component.html +++ b/apps/browser/src/auth/popup/home.component.html @@ -30,7 +30,7 @@ diff --git a/apps/browser/src/auth/popup/home.component.ts b/apps/browser/src/auth/popup/home.component.ts index e647dfd05b9..e553648d791 100644 --- a/apps/browser/src/auth/popup/home.component.ts +++ b/apps/browser/src/auth/popup/home.component.ts @@ -4,9 +4,7 @@ import { Router } from "@angular/router"; import { Subject, firstValueFrom, takeUntil } from "rxjs"; import { EnvironmentSelectorComponent } from "@bitwarden/angular/auth/components/environment-selector.component"; -import { LoginEmailServiceAbstraction } from "@bitwarden/auth/common"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { LoginEmailServiceAbstraction, RegisterRouteService } from "@bitwarden/auth/common"; 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"; @@ -29,7 +27,7 @@ export class HomeComponent implements OnInit, OnDestroy { }); // TODO: remove when email verification flag is removed - registerRoute = "/register"; + registerRoute$ = this.registerRouteService.registerRoute$(); constructor( protected platformUtilsService: PlatformUtilsService, @@ -39,19 +37,10 @@ export class HomeComponent implements OnInit, OnDestroy { private environmentService: EnvironmentService, private loginEmailService: LoginEmailServiceAbstraction, private accountSwitcherService: AccountSwitcherService, - private configService: ConfigService, + private registerRouteService: RegisterRouteService, ) {} async ngOnInit(): Promise { - // TODO: remove when email verification flag is removed - const emailVerification = await this.configService.getFeatureFlag( - FeatureFlag.EmailVerification, - ); - - if (emailVerification) { - this.registerRoute = "/signup"; - } - const email = this.loginEmailService.getEmail(); const rememberEmail = this.loginEmailService.getRememberEmail(); diff --git a/apps/browser/src/auth/popup/login.component.ts b/apps/browser/src/auth/popup/login.component.ts index 760db66c311..0d6c4c0f4a0 100644 --- a/apps/browser/src/auth/popup/login.component.ts +++ b/apps/browser/src/auth/popup/login.component.ts @@ -8,12 +8,12 @@ import { FormValidationErrorsService } from "@bitwarden/angular/platform/abstrac import { LoginStrategyServiceAbstraction, LoginEmailServiceAbstraction, + RegisterRouteService, } from "@bitwarden/auth/common"; import { DevicesApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices-api.service.abstraction"; import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; import { WebAuthnLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login.service.abstraction"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -52,7 +52,7 @@ export class LoginComponent extends BaseLoginComponent { loginEmailService: LoginEmailServiceAbstraction, ssoLoginService: SsoLoginServiceAbstraction, webAuthnLoginService: WebAuthnLoginServiceAbstraction, - configService: ConfigService, + registerRouteService: RegisterRouteService, ) { super( devicesApiService, @@ -73,7 +73,7 @@ export class LoginComponent extends BaseLoginComponent { loginEmailService, ssoLoginService, webAuthnLoginService, - configService, + registerRouteService, ); super.onSuccessfulLogin = async () => { await syncService.fullSync(true); diff --git a/apps/desktop/src/auth/login/login.component.html b/apps/desktop/src/auth/login/login.component.html index d9983220745..e7f01accab5 100644 --- a/apps/desktop/src/auth/login/login.component.html +++ b/apps/desktop/src/auth/login/login.component.html @@ -48,7 +48,7 @@

{{ "newAroundHere" | i18n }}

-
diff --git a/apps/desktop/src/auth/login/login.component.ts b/apps/desktop/src/auth/login/login.component.ts index d37e9961b2f..827f7f5287e 100644 --- a/apps/desktop/src/auth/login/login.component.ts +++ b/apps/desktop/src/auth/login/login.component.ts @@ -9,13 +9,13 @@ import { ModalService } from "@bitwarden/angular/services/modal.service"; import { LoginStrategyServiceAbstraction, LoginEmailServiceAbstraction, + RegisterRouteService, } from "@bitwarden/auth/common"; import { DevicesApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices-api.service.abstraction"; import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; import { WebAuthnLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login.service.abstraction"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -72,7 +72,7 @@ export class LoginComponent extends BaseLoginComponent implements OnDestroy { loginEmailService: LoginEmailServiceAbstraction, ssoLoginService: SsoLoginServiceAbstraction, webAuthnLoginService: WebAuthnLoginServiceAbstraction, - configService: ConfigService, + registerRouteService: RegisterRouteService, ) { super( devicesApiService, @@ -93,7 +93,7 @@ export class LoginComponent extends BaseLoginComponent implements OnDestroy { loginEmailService, ssoLoginService, webAuthnLoginService, - configService, + registerRouteService, ); super.onSuccessfulLogin = () => { return syncService.fullSync(true); diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index 4781042413d..51543394827 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -539,6 +539,18 @@ } } }, + "masterPassword": { + "message": "Master password" + }, + "masterPassImportant": { + "message": "Your master password cannot be recovered if you forget it!" + }, + "confirmMasterPassword": { + "message": "Confirm master password" + }, + "masterPassHintLabel": { + "message": "Master password hint" + }, "settings": { "message": "Settings" }, @@ -1955,6 +1967,9 @@ "emailVerificationRequired": { "message": "Email verification required" }, + "emailVerifiedV2": { + "message": "Email verified" + }, "emailVerificationRequiredDesc": { "message": "You must verify your email to use this feature." }, diff --git a/apps/web/src/app/admin-console/organizations/sponsorships/accept-family-sponsorship.component.ts b/apps/web/src/app/admin-console/organizations/sponsorships/accept-family-sponsorship.component.ts index e5733e262f6..d390f5c3605 100644 --- a/apps/web/src/app/admin-console/organizations/sponsorships/accept-family-sponsorship.component.ts +++ b/apps/web/src/app/admin-console/organizations/sponsorships/accept-family-sponsorship.component.ts @@ -1,5 +1,6 @@ import { Component } from "@angular/core"; import { Params } from "@angular/router"; +import { firstValueFrom } from "rxjs"; import { BaseAcceptComponent } from "../../../common/base.accept.component"; @@ -25,9 +26,9 @@ export class AcceptFamilySponsorshipComponent extends BaseAcceptComponent { // eslint-disable-next-line @typescript-eslint/no-floating-promises this.router.navigate(["/login"], { queryParams: { email: qParams.email } }); } else { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.router.navigate([this.registerRoute], { queryParams: { email: qParams.email } }); + // TODO: remove when email verification flag is removed + const registerRoute = await firstValueFrom(this.registerRoute$); + await this.router.navigate([registerRoute], { queryParams: { email: qParams.email } }); } } } diff --git a/apps/web/src/app/auth/core/services/index.ts b/apps/web/src/app/auth/core/services/index.ts index 4ef20f4b97d..6a28efcbaad 100644 --- a/apps/web/src/app/auth/core/services/index.ts +++ b/apps/web/src/app/auth/core/services/index.ts @@ -1 +1,2 @@ export * from "./webauthn-login"; +export * from "./registration"; diff --git a/apps/web/src/app/auth/core/services/registration/index.ts b/apps/web/src/app/auth/core/services/registration/index.ts new file mode 100644 index 00000000000..6d5565e7e30 --- /dev/null +++ b/apps/web/src/app/auth/core/services/registration/index.ts @@ -0,0 +1 @@ +export * from "./web-registration-finish.service"; diff --git a/apps/web/src/app/auth/core/services/registration/web-registration-finish.service.spec.ts b/apps/web/src/app/auth/core/services/registration/web-registration-finish.service.spec.ts new file mode 100644 index 00000000000..999e603ef6a --- /dev/null +++ b/apps/web/src/app/auth/core/services/registration/web-registration-finish.service.spec.ts @@ -0,0 +1,241 @@ +import { MockProxy, mock } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { PasswordInputResult } from "@bitwarden/auth/angular"; +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 { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options"; +import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; +import { AccountApiService } from "@bitwarden/common/auth/abstractions/account-api.service"; +import { DEFAULT_KDF_CONFIG } from "@bitwarden/common/auth/models/domain/kdf-config"; +import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; +import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { CsprngArray } from "@bitwarden/common/types/csprng"; +import { MasterKey, UserKey } from "@bitwarden/common/types/key"; + +import { AcceptOrganizationInviteService } from "../../../organization-invite/accept-organization.service"; +import { OrganizationInvite } from "../../../organization-invite/organization-invite"; + +import { WebRegistrationFinishService } from "./web-registration-finish.service"; + +describe("DefaultRegistrationFinishService", () => { + let service: WebRegistrationFinishService; + + let cryptoService: MockProxy; + let accountApiService: MockProxy; + let acceptOrgInviteService: MockProxy; + let policyApiService: MockProxy; + let logService: MockProxy; + let policyService: MockProxy; + + beforeEach(() => { + cryptoService = mock(); + accountApiService = mock(); + acceptOrgInviteService = mock(); + policyApiService = mock(); + logService = mock(); + policyService = mock(); + + service = new WebRegistrationFinishService( + cryptoService, + accountApiService, + acceptOrgInviteService, + policyApiService, + logService, + policyService, + ); + }); + + it("instantiates", () => { + expect(service).not.toBeFalsy(); + }); + + describe("getMasterPasswordPolicyOptsFromOrgInvite()", () => { + let orgInvite: OrganizationInvite | null; + + beforeEach(() => { + orgInvite = new OrganizationInvite(); + orgInvite.organizationId = "organizationId"; + orgInvite.organizationUserId = "organizationUserId"; + orgInvite.token = "orgInviteToken"; + orgInvite.email = "email"; + }); + + it("returns null when the org invite is null", async () => { + acceptOrgInviteService.getOrganizationInvite.mockResolvedValue(null); + + const result = await service.getMasterPasswordPolicyOptsFromOrgInvite(); + + expect(result).toBeNull(); + expect(acceptOrgInviteService.getOrganizationInvite).toHaveBeenCalled(); + }); + + it("returns null when the policies are null", async () => { + acceptOrgInviteService.getOrganizationInvite.mockResolvedValue(orgInvite); + policyApiService.getPoliciesByToken.mockResolvedValue(null); + + const result = await service.getMasterPasswordPolicyOptsFromOrgInvite(); + + expect(result).toBeNull(); + expect(acceptOrgInviteService.getOrganizationInvite).toHaveBeenCalled(); + expect(policyApiService.getPoliciesByToken).toHaveBeenCalledWith( + orgInvite.organizationId, + orgInvite.token, + orgInvite.email, + orgInvite.organizationUserId, + ); + }); + + it("logs an error and returns null when policies cannot be fetched", async () => { + acceptOrgInviteService.getOrganizationInvite.mockResolvedValue(orgInvite); + policyApiService.getPoliciesByToken.mockRejectedValue(new Error("error")); + + const result = await service.getMasterPasswordPolicyOptsFromOrgInvite(); + + expect(result).toBeNull(); + expect(acceptOrgInviteService.getOrganizationInvite).toHaveBeenCalled(); + expect(policyApiService.getPoliciesByToken).toHaveBeenCalledWith( + orgInvite.organizationId, + orgInvite.token, + orgInvite.email, + orgInvite.organizationUserId, + ); + expect(logService.error).toHaveBeenCalled(); + }); + + it("returns the master password policy options from the organization invite when it exists", async () => { + const masterPasswordPolicies = [new Policy()]; + const masterPasswordPolicyOptions = new MasterPasswordPolicyOptions(); + + acceptOrgInviteService.getOrganizationInvite.mockResolvedValue(orgInvite); + policyApiService.getPoliciesByToken.mockResolvedValue(masterPasswordPolicies); + policyService.masterPasswordPolicyOptions$.mockReturnValue(of(masterPasswordPolicyOptions)); + + const result = await service.getMasterPasswordPolicyOptsFromOrgInvite(); + + expect(result).toEqual(masterPasswordPolicyOptions); + expect(acceptOrgInviteService.getOrganizationInvite).toHaveBeenCalled(); + expect(policyApiService.getPoliciesByToken).toHaveBeenCalledWith( + orgInvite.organizationId, + orgInvite.token, + orgInvite.email, + orgInvite.organizationUserId, + ); + }); + }); + + describe("finishRegistration()", () => { + let email: string; + let emailVerificationToken: string; + let masterKey: MasterKey; + let passwordInputResult: PasswordInputResult; + let userKey: UserKey; + let userKeyEncString: EncString; + let userKeyPair: [string, EncString]; + let capchaBypassToken: string; + + let orgInvite: OrganizationInvite; + + beforeEach(() => { + email = "test@email.com"; + emailVerificationToken = "emailVerificationToken"; + masterKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as MasterKey; + passwordInputResult = { + masterKey: masterKey, + masterKeyHash: "masterKeyHash", + kdfConfig: DEFAULT_KDF_CONFIG, + hint: "hint", + }; + + userKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as UserKey; + userKeyEncString = new EncString("userKeyEncrypted"); + + userKeyPair = ["publicKey", new EncString("privateKey")]; + capchaBypassToken = "capchaBypassToken"; + + orgInvite = new OrganizationInvite(); + orgInvite.organizationUserId = "organizationUserId"; + orgInvite.token = "orgInviteToken"; + }); + + it("throws an error if the user key cannot be created", async () => { + cryptoService.makeUserKey.mockResolvedValue([null, null]); + + await expect(service.finishRegistration(email, passwordInputResult)).rejects.toThrow( + "User key could not be created", + ); + }); + + it("registers the user and returns a captcha bypass token when given valid email verification input", async () => { + cryptoService.makeUserKey.mockResolvedValue([userKey, userKeyEncString]); + cryptoService.makeKeyPair.mockResolvedValue(userKeyPair); + accountApiService.registerFinish.mockResolvedValue(capchaBypassToken); + acceptOrgInviteService.getOrganizationInvite.mockResolvedValue(null); + + const result = await service.finishRegistration( + email, + passwordInputResult, + emailVerificationToken, + ); + + expect(result).toEqual(capchaBypassToken); + + expect(cryptoService.makeUserKey).toHaveBeenCalledWith(masterKey); + expect(cryptoService.makeKeyPair).toHaveBeenCalledWith(userKey); + expect(accountApiService.registerFinish).toHaveBeenCalledWith( + expect.objectContaining({ + email, + emailVerificationToken: emailVerificationToken, + masterPasswordHash: passwordInputResult.masterKeyHash, + masterPasswordHint: passwordInputResult.hint, + userSymmetricKey: userKeyEncString.encryptedString, + userAsymmetricKeys: { + publicKey: userKeyPair[0], + encryptedPrivateKey: userKeyPair[1].encryptedString, + }, + kdf: passwordInputResult.kdfConfig.kdfType, + kdfIterations: passwordInputResult.kdfConfig.iterations, + kdfMemory: undefined, + kdfParallelism: undefined, + orgInviteToken: undefined, + organizationUserId: undefined, + }), + ); + }); + + it("it registers the user and returns a captcha bypass token when given an org invite", async () => { + cryptoService.makeUserKey.mockResolvedValue([userKey, userKeyEncString]); + cryptoService.makeKeyPair.mockResolvedValue(userKeyPair); + accountApiService.registerFinish.mockResolvedValue(capchaBypassToken); + acceptOrgInviteService.getOrganizationInvite.mockResolvedValue(orgInvite); + + const result = await service.finishRegistration(email, passwordInputResult); + + expect(result).toEqual(capchaBypassToken); + + expect(cryptoService.makeUserKey).toHaveBeenCalledWith(masterKey); + expect(cryptoService.makeKeyPair).toHaveBeenCalledWith(userKey); + expect(accountApiService.registerFinish).toHaveBeenCalledWith( + expect.objectContaining({ + email, + emailVerificationToken: undefined, + masterPasswordHash: passwordInputResult.masterKeyHash, + masterPasswordHint: passwordInputResult.hint, + userSymmetricKey: userKeyEncString.encryptedString, + userAsymmetricKeys: { + publicKey: userKeyPair[0], + encryptedPrivateKey: userKeyPair[1].encryptedString, + }, + kdf: passwordInputResult.kdfConfig.kdfType, + kdfIterations: passwordInputResult.kdfConfig.iterations, + kdfMemory: undefined, + kdfParallelism: undefined, + orgInviteToken: orgInvite.token, + organizationUserId: orgInvite.organizationUserId, + }), + ); + }); + }); +}); diff --git a/apps/web/src/app/auth/core/services/registration/web-registration-finish.service.ts b/apps/web/src/app/auth/core/services/registration/web-registration-finish.service.ts new file mode 100644 index 00000000000..b54fd79a947 --- /dev/null +++ b/apps/web/src/app/auth/core/services/registration/web-registration-finish.service.ts @@ -0,0 +1,94 @@ +import { firstValueFrom } from "rxjs"; + +import { + DefaultRegistrationFinishService, + PasswordInputResult, + RegistrationFinishService, +} from "@bitwarden/auth/angular"; +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 { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options"; +import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; +import { AccountApiService } from "@bitwarden/common/auth/abstractions/account-api.service"; +import { RegisterFinishRequest } from "@bitwarden/common/auth/models/request/registration/register-finish.request"; +import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { EncryptedString, EncString } from "@bitwarden/common/platform/models/domain/enc-string"; + +import { AcceptOrganizationInviteService } from "../../../organization-invite/accept-organization.service"; + +export class WebRegistrationFinishService + extends DefaultRegistrationFinishService + implements RegistrationFinishService +{ + constructor( + protected cryptoService: CryptoService, + protected accountApiService: AccountApiService, + private acceptOrgInviteService: AcceptOrganizationInviteService, + private policyApiService: PolicyApiServiceAbstraction, + private logService: LogService, + private policyService: PolicyService, + ) { + super(cryptoService, accountApiService); + } + + override async getMasterPasswordPolicyOptsFromOrgInvite(): Promise { + // If there's a deep linked org invite, use it to get the password policies + const orgInvite = await this.acceptOrgInviteService.getOrganizationInvite(); + + if (orgInvite == null) { + return null; + } + + let policies: Policy[] | null = null; + try { + policies = await this.policyApiService.getPoliciesByToken( + orgInvite.organizationId, + orgInvite.token, + orgInvite.email, + orgInvite.organizationUserId, + ); + } catch (e) { + this.logService.error(e); + } + + if (policies == null) { + return null; + } + + const masterPasswordPolicyOpts: MasterPasswordPolicyOptions = await firstValueFrom( + this.policyService.masterPasswordPolicyOptions$(policies), + ); + + return masterPasswordPolicyOpts; + } + + // Note: the org invite token and email verification are mutually exclusive. Only one will be present. + override async buildRegisterRequest( + email: string, + emailVerificationToken: string, + passwordInputResult: PasswordInputResult, + encryptedUserKey: EncryptedString, + userAsymmetricKeys: [string, EncString], + ): Promise { + const registerRequest = await super.buildRegisterRequest( + email, + emailVerificationToken, + passwordInputResult, + encryptedUserKey, + userAsymmetricKeys, + ); + + // web specific logic + // Org invites are deep linked. Non-existent accounts are redirected to the register page. + // Org user id and token are included here only for validation and two factor purposes. + const orgInvite = await this.acceptOrgInviteService.getOrganizationInvite(); + if (orgInvite != null) { + registerRequest.organizationUserId = orgInvite.organizationUserId; + registerRequest.orgInviteToken = orgInvite.token; + } + // Invite is accepted after login (on deep link redirect). + + return registerRequest; + } +} diff --git a/apps/web/src/app/auth/emergency-access/accept/accept-emergency.component.html b/apps/web/src/app/auth/emergency-access/accept/accept-emergency.component.html index 11491bd5560..315df6f2c83 100644 --- a/apps/web/src/app/auth/emergency-access/accept/accept-emergency.component.html +++ b/apps/web/src/app/auth/emergency-access/accept/accept-emergency.component.html @@ -29,7 +29,7 @@ diff --git a/apps/web/src/app/auth/emergency-access/accept/accept-emergency.component.ts b/apps/web/src/app/auth/emergency-access/accept/accept-emergency.component.ts index 378726b8407..d5ca41c42c5 100644 --- a/apps/web/src/app/auth/emergency-access/accept/accept-emergency.component.ts +++ b/apps/web/src/app/auth/emergency-access/accept/accept-emergency.component.ts @@ -1,8 +1,8 @@ import { Component } from "@angular/core"; import { ActivatedRoute, Params, Router } from "@angular/router"; +import { RegisterRouteService } from "@bitwarden/auth/common"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; @@ -29,10 +29,10 @@ export class AcceptEmergencyComponent extends BaseAcceptComponent { i18nService: I18nService, route: ActivatedRoute, authService: AuthService, - configService: ConfigService, + registerRouteService: RegisterRouteService, private emergencyAccessService: EmergencyAccessService, ) { - super(router, platformUtilsService, i18nService, route, authService, configService); + super(router, platformUtilsService, i18nService, route, authService, registerRouteService); } async authedHandler(qParams: Params): Promise { diff --git a/apps/web/src/app/auth/login/login.component.html b/apps/web/src/app/auth/login/login.component.html index f54153be6ae..1ee2c8dd84b 100644 --- a/apps/web/src/app/auth/login/login.component.html +++ b/apps/web/src/app/auth/login/login.component.html @@ -56,7 +56,7 @@ clicking on the link. Mousedown fires before onBlur. --> {{ "createAccount" | i18n }} { diff --git a/apps/web/src/app/auth/organization-invite/accept-organization.component.html b/apps/web/src/app/auth/organization-invite/accept-organization.component.html index 6e88d03ffae..04258e7a46a 100644 --- a/apps/web/src/app/auth/organization-invite/accept-organization.component.html +++ b/apps/web/src/app/auth/organization-invite/accept-organization.component.html @@ -32,7 +32,7 @@ {{ "logIn" | i18n }} diff --git a/apps/web/src/app/auth/organization-invite/accept-organization.component.ts b/apps/web/src/app/auth/organization-invite/accept-organization.component.ts index 7326c5a5b56..6013688df22 100644 --- a/apps/web/src/app/auth/organization-invite/accept-organization.component.ts +++ b/apps/web/src/app/auth/organization-invite/accept-organization.component.ts @@ -1,8 +1,9 @@ import { Component } from "@angular/core"; import { ActivatedRoute, Params, Router } from "@angular/router"; +import { firstValueFrom } from "rxjs"; +import { RegisterRouteService } from "@bitwarden/auth/common"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; @@ -24,10 +25,10 @@ export class AcceptOrganizationComponent extends BaseAcceptComponent { i18nService: I18nService, route: ActivatedRoute, authService: AuthService, - configService: ConfigService, + registerRouteService: RegisterRouteService, private acceptOrganizationInviteService: AcceptOrganizationInviteService, ) { - super(router, platformUtilsService, i18nService, route, authService, configService); + super(router, platformUtilsService, i18nService, route, authService, registerRouteService); } async authedHandler(qParams: Params): Promise { @@ -91,22 +92,23 @@ export class AcceptOrganizationComponent extends BaseAcceptComponent { // TODO: update logic when email verification flag is removed let queryParams: Params; - if (this.registerRoute === "/register") { + let registerRoute = await firstValueFrom(this.registerRoute$); + if (registerRoute === "/register") { queryParams = { fromOrgInvite: "true", email: invite.email, }; - } else if (this.registerRoute === "/signup") { + } else if (registerRoute === "/signup") { // We have to override the base component route b/c it is correct for other components // that extend the base accept comp. We don't need users to complete email verification // if they are coming directly from an emailed org invite. - this.registerRoute = "/finish-signup"; + registerRoute = "/finish-signup"; queryParams = { email: invite.email, }; } - await this.router.navigate([this.registerRoute], { + await this.router.navigate([registerRoute], { queryParams: queryParams, }); return; diff --git a/apps/web/src/app/common/base.accept.component.ts b/apps/web/src/app/common/base.accept.component.ts index 32ba232b088..4b35eb811c9 100644 --- a/apps/web/src/app/common/base.accept.component.ts +++ b/apps/web/src/app/common/base.accept.component.ts @@ -3,10 +3,9 @@ import { ActivatedRoute, Params, Router } from "@angular/router"; import { Subject, firstValueFrom } from "rxjs"; import { first, switchMap, takeUntil } from "rxjs/operators"; +import { RegisterRouteService } from "@bitwarden/auth/common"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; @@ -22,7 +21,7 @@ export abstract class BaseAcceptComponent implements OnInit { protected failedMessage = "inviteAcceptFailed"; // TODO: remove when email verification flag is removed - registerRoute = "/register"; + registerRoute$ = this.registerRouteService.registerRoute$(); private destroy$ = new Subject(); @@ -32,22 +31,13 @@ export abstract class BaseAcceptComponent implements OnInit { protected i18nService: I18nService, protected route: ActivatedRoute, protected authService: AuthService, - private configService: ConfigService, + protected registerRouteService: RegisterRouteService, ) {} abstract authedHandler(qParams: Params): Promise; abstract unauthedHandler(qParams: Params): Promise; async ngOnInit() { - // TODO: remove when email verification flag is removed - const emailVerification = await this.configService.getFeatureFlag( - FeatureFlag.EmailVerification, - ); - - if (emailVerification) { - this.registerRoute = "/signup"; - } - this.route.queryParams .pipe( first(), diff --git a/apps/web/src/app/core/core.module.ts b/apps/web/src/app/core/core.module.ts index a7578d0ae22..7dae8c07e54 100644 --- a/apps/web/src/app/core/core.module.ts +++ b/apps/web/src/app/core/core.module.ts @@ -18,8 +18,13 @@ import { } from "@bitwarden/angular/services/injection-tokens"; import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module"; import { ModalService as ModalServiceAbstraction } from "@bitwarden/angular/services/modal.service"; +import { RegistrationFinishService as RegistrationFinishServiceAbstraction } from "@bitwarden/auth/angular"; +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 { AccountApiService as AccountApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/account-api.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { ClientType } from "@bitwarden/common/enums"; +import { CryptoService as CryptoServiceAbstraction } from "@bitwarden/common/platform/abstractions/crypto.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"; @@ -45,6 +50,8 @@ import { import { VaultTimeout, VaultTimeoutStringType } from "@bitwarden/common/types/vault-timeout.type"; import { PolicyListService } from "../admin-console/core/policy-list.service"; +import { WebRegistrationFinishService } from "../auth"; +import { AcceptOrganizationInviteService } from "../auth/organization-invite/accept-organization.service"; import { HtmlStorageService } from "../core/html-storage.service"; import { I18nService } from "../core/i18n.service"; import { WebEnvironmentService } from "../platform/web-environment.service"; @@ -171,6 +178,18 @@ const safeProviders: SafeProvider[] = [ provide: CLIENT_TYPE, useValue: ClientType.Web, }), + safeProvider({ + provide: RegistrationFinishServiceAbstraction, + useClass: WebRegistrationFinishService, + deps: [ + CryptoServiceAbstraction, + AccountApiServiceAbstraction, + AcceptOrganizationInviteService, + PolicyApiServiceAbstraction, + LogService, + PolicyService, + ], + }), ]; @NgModule({ diff --git a/apps/web/src/app/tools/send/access.component.html b/apps/web/src/app/tools/send/access.component.html index 60f172c481c..d1a6f4d42d2 100644 --- a/apps/web/src/app/tools/send/access.component.html +++ b/apps/web/src/app/tools/send/access.component.html @@ -75,7 +75,7 @@ >Bitwarden Send {{ "sendAccessTaglineOr" | i18n }} - {{ + {{ "sendAccessTaglineSignUp" | i18n }} {{ "sendAccessTaglineTryToday" | i18n }} diff --git a/apps/web/src/app/tools/send/access.component.ts b/apps/web/src/app/tools/send/access.component.ts index cd6850c7856..f553542a619 100644 --- a/apps/web/src/app/tools/send/access.component.ts +++ b/apps/web/src/app/tools/send/access.component.ts @@ -2,7 +2,7 @@ import { Component, OnInit } from "@angular/core"; import { FormBuilder } from "@angular/forms"; import { ActivatedRoute } from "@angular/router"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { RegisterRouteService } from "@bitwarden/auth/common"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service"; @@ -56,7 +56,7 @@ export class AccessComponent implements OnInit { protected formGroup = this.formBuilder.group({}); // TODO: remove when email verification flag is removed - registerRoute = "/register"; + registerRoute$ = this.registerRouteService.registerRoute$(); private id: string; private key: string; @@ -69,6 +69,7 @@ export class AccessComponent implements OnInit { private toastService: ToastService, private i18nService: I18nService, private configService: ConfigService, + private registerRouteService: RegisterRouteService, protected formBuilder: FormBuilder, ) {} @@ -87,15 +88,6 @@ export class AccessComponent implements OnInit { } async ngOnInit() { - // TODO: remove when email verification flag is removed - const emailVerification = await this.configService.getFeatureFlag( - FeatureFlag.EmailVerification, - ); - - if (emailVerification) { - this.registerRoute = "/signup"; - } - // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe this.route.params.subscribe(async (params) => { this.id = params.sendId; diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index f03b003ba2b..37ebe626410 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -3399,6 +3399,9 @@ "emailVerified": { "message": "Account email verified" }, + "emailVerifiedV2": { + "message": "Email verified" + }, "emailVerifiedFailed": { "message": "Unable to verify your email. Try sending a new verification email." }, diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/accept-provider.component.html b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/accept-provider.component.html index a3323b6c2c1..8e24bb79391 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/accept-provider.component.html +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/accept-provider.component.html @@ -31,7 +31,7 @@ diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/accept-provider.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/accept-provider.component.ts index f2396139525..30effa880e7 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/accept-provider.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/accept-provider.component.ts @@ -1,10 +1,10 @@ import { Component } from "@angular/core"; import { ActivatedRoute, Params, Router } from "@angular/router"; +import { RegisterRouteService } from "@bitwarden/auth/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { ProviderUserAcceptRequest } from "@bitwarden/common/admin-console/models/request/provider/provider-user-accept.request"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { BaseAcceptComponent } from "@bitwarden/web-vault/app/common/base.accept.component"; @@ -27,9 +27,9 @@ export class AcceptProviderComponent extends BaseAcceptComponent { authService: AuthService, private apiService: ApiService, platformUtilService: PlatformUtilsService, - configService: ConfigService, + registerRouteService: RegisterRouteService, ) { - super(router, platformUtilService, i18nService, route, authService, configService); + super(router, platformUtilService, i18nService, route, authService, registerRouteService); } async authedHandler(qParams: Params) { diff --git a/libs/angular/src/auth/components/login.component.ts b/libs/angular/src/auth/components/login.component.ts index 0d3c3a87071..dc2b8a43deb 100644 --- a/libs/angular/src/auth/components/login.component.ts +++ b/libs/angular/src/auth/components/login.component.ts @@ -8,15 +8,14 @@ import { LoginStrategyServiceAbstraction, LoginEmailServiceAbstraction, PasswordLoginCredentials, + RegisterRouteService, } from "@bitwarden/auth/common"; import { DevicesApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices-api.service.abstraction"; import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; import { WebAuthnLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login.service.abstraction"; import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result"; import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -63,7 +62,7 @@ export class LoginComponent extends CaptchaProtectedComponent implements OnInit, protected twoFactorRoute = "2fa"; protected successRoute = "vault"; // TODO: remove when email verification flag is removed - protected registerRoute = "/register"; + protected registerRoute$ = this.registerRouteService.registerRoute$(); protected forcePasswordResetRoute = "update-temp-password"; protected destroy$ = new Subject(); @@ -91,21 +90,12 @@ export class LoginComponent extends CaptchaProtectedComponent implements OnInit, protected loginEmailService: LoginEmailServiceAbstraction, protected ssoLoginService: SsoLoginServiceAbstraction, protected webAuthnLoginService: WebAuthnLoginServiceAbstraction, - protected configService: ConfigService, + protected registerRouteService: RegisterRouteService, ) { super(environmentService, i18nService, platformUtilsService); } async ngOnInit() { - // TODO: remove when email verification flag is removed - const emailVerification = await this.configService.getFeatureFlag( - FeatureFlag.EmailVerification, - ); - - if (emailVerification) { - this.registerRoute = "/signup"; - } - this.route?.queryParams.pipe(takeUntil(this.destroy$)).subscribe((params) => { if (!params) { return; diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 880488cfcfb..b956acb7637 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -1,6 +1,10 @@ import { ErrorHandler, LOCALE_ID, NgModule } from "@angular/core"; import { Subject } from "rxjs"; +import { + RegistrationFinishService as RegistrationFinishServiceAbstraction, + DefaultRegistrationFinishService, +} from "@bitwarden/auth/angular"; import { AuthRequestServiceAbstraction, AuthRequestService, @@ -14,6 +18,7 @@ import { UserDecryptionOptionsService, UserDecryptionOptionsServiceAbstraction, LogoutReason, + RegisterRouteService, } from "@bitwarden/auth/common"; import { ApiService as ApiServiceAbstraction } from "@bitwarden/common/abstractions/api.service"; import { AuditService as AuditServiceAbstraction } from "@bitwarden/common/abstractions/audit.service"; @@ -1221,6 +1226,16 @@ const safeProviders: SafeProvider[] = [ useClass: StripeService, deps: [LogService], }), + safeProvider({ + provide: RegisterRouteService, + useClass: RegisterRouteService, + deps: [ConfigService], + }), + safeProvider({ + provide: RegistrationFinishServiceAbstraction, + useClass: DefaultRegistrationFinishService, + deps: [CryptoServiceAbstraction, AccountApiServiceAbstraction], + }), ]; function encryptServiceFactory( diff --git a/libs/auth/src/angular/index.ts b/libs/auth/src/angular/index.ts index cf7f7e2d49a..dec1d7c08b4 100644 --- a/libs/auth/src/angular/index.ts +++ b/libs/auth/src/angular/index.ts @@ -22,3 +22,8 @@ export * from "./registration/registration-start/registration-start.component"; export * from "./registration/registration-finish/registration-finish.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"; + +// input password +export * from "./input-password/password-input-result"; diff --git a/libs/auth/src/angular/input-password/input-password.component.html b/libs/auth/src/angular/input-password/input-password.component.html index f2ad810e7bb..5cfcf671655 100644 --- a/libs/auth/src/angular/input-password/input-password.component.html +++ b/libs/auth/src/angular/input-password/input-password.component.html @@ -65,7 +65,14 @@ {{ "checkForBreaches" | i18n }} - diff --git a/libs/auth/src/angular/input-password/input-password.component.ts b/libs/auth/src/angular/input-password/input-password.component.ts index a8220595481..ed77e17da1a 100644 --- a/libs/auth/src/angular/input-password/input-password.component.ts +++ b/libs/auth/src/angular/input-password/input-password.component.ts @@ -49,6 +49,7 @@ export class InputPasswordComponent implements OnInit { @Input({ required: true }) email: string; @Input() protected buttonText: string; @Input() masterPasswordPolicyOptions: MasterPasswordPolicyOptions | null = null; + @Input() loading: boolean = false; private minHintLength = 0; protected maxHintLength = 50; @@ -163,6 +164,10 @@ export class InputPasswordComponent implements OnInit { // Create and hash new master key const kdfConfig = DEFAULT_KDF_CONFIG; + if (this.email == null) { + throw new Error("Email is required to create master key."); + } + const masterKey = await this.cryptoService.makeMasterKey( password, this.email.trim().toLowerCase(), diff --git a/libs/auth/src/angular/registration/registration-finish/default-registration-finish.service.spec.ts b/libs/auth/src/angular/registration/registration-finish/default-registration-finish.service.spec.ts new file mode 100644 index 00000000000..94eccfce2f3 --- /dev/null +++ b/libs/auth/src/angular/registration/registration-finish/default-registration-finish.service.spec.ts @@ -0,0 +1,112 @@ +import { MockProxy, mock } from "jest-mock-extended"; + +import { AccountApiService } from "@bitwarden/common/auth/abstractions/account-api.service"; +import { DEFAULT_KDF_CONFIG } from "@bitwarden/common/auth/models/domain/kdf-config"; +import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; +import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; +import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { CsprngArray } from "@bitwarden/common/types/csprng"; +import { MasterKey, UserKey } from "@bitwarden/common/types/key"; + +import { PasswordInputResult } from "../../input-password/password-input-result"; + +import { DefaultRegistrationFinishService } from "./default-registration-finish.service"; + +describe("DefaultRegistrationFinishService", () => { + let service: DefaultRegistrationFinishService; + + let cryptoService: MockProxy; + let accountApiService: MockProxy; + + beforeEach(() => { + cryptoService = mock(); + accountApiService = mock(); + + service = new DefaultRegistrationFinishService(cryptoService, accountApiService); + }); + + it("instantiates", () => { + expect(service).not.toBeFalsy(); + }); + + describe("getMasterPasswordPolicyOptsFromOrgInvite()", () => { + it("returns null", async () => { + const result = await service.getMasterPasswordPolicyOptsFromOrgInvite(); + + expect(result).toBeNull(); + }); + }); + + describe("finishRegistration()", () => { + let email: string; + let emailVerificationToken: string; + let masterKey: MasterKey; + let passwordInputResult: PasswordInputResult; + let userKey: UserKey; + let userKeyEncString: EncString; + let userKeyPair: [string, EncString]; + let capchaBypassToken: string; + + beforeEach(() => { + email = "test@email.com"; + emailVerificationToken = "emailVerificationToken"; + masterKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as MasterKey; + passwordInputResult = { + masterKey: masterKey, + masterKeyHash: "masterKeyHash", + kdfConfig: DEFAULT_KDF_CONFIG, + hint: "hint", + }; + + userKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as UserKey; + userKeyEncString = new EncString("userKeyEncrypted"); + + userKeyPair = ["publicKey", new EncString("privateKey")]; + capchaBypassToken = "capchaBypassToken"; + }); + + it("throws an error if the user key cannot be created", async () => { + cryptoService.makeUserKey.mockResolvedValue([null, null]); + + await expect(service.finishRegistration(email, passwordInputResult)).rejects.toThrow( + "User key could not be created", + ); + }); + + it("registers the user and returns a captcha bypass token when given valid email verification input", async () => { + cryptoService.makeUserKey.mockResolvedValue([userKey, userKeyEncString]); + cryptoService.makeKeyPair.mockResolvedValue(userKeyPair); + accountApiService.registerFinish.mockResolvedValue(capchaBypassToken); + + const result = await service.finishRegistration( + email, + passwordInputResult, + emailVerificationToken, + ); + + expect(result).toEqual(capchaBypassToken); + + expect(cryptoService.makeUserKey).toHaveBeenCalledWith(masterKey); + expect(cryptoService.makeKeyPair).toHaveBeenCalledWith(userKey); + expect(accountApiService.registerFinish).toHaveBeenCalledWith( + expect.objectContaining({ + email, + emailVerificationToken: emailVerificationToken, + masterPasswordHash: passwordInputResult.masterKeyHash, + masterPasswordHint: passwordInputResult.hint, + userSymmetricKey: userKeyEncString.encryptedString, + userAsymmetricKeys: { + publicKey: userKeyPair[0], + encryptedPrivateKey: userKeyPair[1].encryptedString, + }, + kdf: passwordInputResult.kdfConfig.kdfType, + kdfIterations: passwordInputResult.kdfConfig.iterations, + kdfMemory: undefined, + kdfParallelism: undefined, + orgInviteToken: undefined, // OrgInvite only handled in web + organizationUserId: undefined, // OrgInvite only handled in web + }), + ); + }); + }); +}); diff --git a/libs/auth/src/angular/registration/registration-finish/default-registration-finish.service.ts b/libs/auth/src/angular/registration/registration-finish/default-registration-finish.service.ts new file mode 100644 index 00000000000..85faf871447 --- /dev/null +++ b/libs/auth/src/angular/registration/registration-finish/default-registration-finish.service.ts @@ -0,0 +1,72 @@ +import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options"; +import { AccountApiService } from "@bitwarden/common/auth/abstractions/account-api.service"; +import { RegisterFinishRequest } from "@bitwarden/common/auth/models/request/registration/register-finish.request"; +import { KeysRequest } from "@bitwarden/common/models/request/keys.request"; +import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; +import { EncryptedString, EncString } from "@bitwarden/common/platform/models/domain/enc-string"; + +import { PasswordInputResult } from "../../input-password/password-input-result"; + +import { RegistrationFinishService } from "./registration-finish.service"; + +export class DefaultRegistrationFinishService implements RegistrationFinishService { + constructor( + protected cryptoService: CryptoService, + protected accountApiService: AccountApiService, + ) {} + + getMasterPasswordPolicyOptsFromOrgInvite(): Promise { + return null; + } + + async finishRegistration( + email: string, + passwordInputResult: PasswordInputResult, + emailVerificationToken?: string, + ): Promise { + const [newUserKey, newEncUserKey] = await this.cryptoService.makeUserKey( + passwordInputResult.masterKey, + ); + + if (!newUserKey || !newEncUserKey) { + throw new Error("User key could not be created"); + } + const userAsymmetricKeys = await this.cryptoService.makeKeyPair(newUserKey); + + const registerRequest = await this.buildRegisterRequest( + email, + emailVerificationToken, + passwordInputResult, + newEncUserKey.encryptedString, + userAsymmetricKeys, + ); + + const capchaBypassToken = await this.accountApiService.registerFinish(registerRequest); + + return capchaBypassToken; + } + + protected async buildRegisterRequest( + email: string, + emailVerificationToken: string, + passwordInputResult: PasswordInputResult, + encryptedUserKey: EncryptedString, + userAsymmetricKeys: [string, EncString], + ): Promise { + const userAsymmetricKeysRequest = new KeysRequest( + userAsymmetricKeys[0], + userAsymmetricKeys[1].encryptedString, + ); + + return new RegisterFinishRequest( + email, + emailVerificationToken, + passwordInputResult.masterKeyHash, + passwordInputResult.hint, + encryptedUserKey, + userAsymmetricKeysRequest, + passwordInputResult.kdfConfig.kdfType, + passwordInputResult.kdfConfig.iterations, + ); + } +} diff --git a/libs/auth/src/angular/registration/registration-finish/registration-finish.component.html b/libs/auth/src/angular/registration/registration-finish/registration-finish.component.html index 2bf6b6fc59d..217a7745ebf 100644 --- a/libs/auth/src/angular/registration/registration-finish/registration-finish.component.html +++ b/libs/auth/src/angular/registration/registration-finish/registration-finish.component.html @@ -1 +1,11 @@ -

This component will be built in the next phase of email verification work.

+
+ +
+ + diff --git a/libs/auth/src/angular/registration/registration-finish/registration-finish.component.ts b/libs/auth/src/angular/registration/registration-finish/registration-finish.component.ts index fb344c6e1ad..43a8951318e 100644 --- a/libs/auth/src/angular/registration/registration-finish/registration-finish.component.ts +++ b/libs/auth/src/angular/registration/registration-finish/registration-finish.component.ts @@ -1,15 +1,101 @@ import { CommonModule } from "@angular/common"; -import { Component } from "@angular/core"; -import { RouterModule } from "@angular/router"; +import { Component, OnDestroy, OnInit } from "@angular/core"; +import { ActivatedRoute, Params, Router, RouterModule } from "@angular/router"; +import { Subject, takeUntil } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; +import { ToastService } from "@bitwarden/components"; + +import { InputPasswordComponent } from "../../input-password/input-password.component"; +import { PasswordInputResult } from "../../input-password/password-input-result"; + +import { RegistrationFinishService } from "./registration-finish.service"; @Component({ standalone: true, selector: "auth-registration-finish", templateUrl: "./registration-finish.component.html", - imports: [CommonModule, JslibModule, RouterModule], + imports: [CommonModule, JslibModule, RouterModule, InputPasswordComponent], }) -export class RegistrationFinishComponent { - constructor() {} +export class RegistrationFinishComponent implements OnInit, OnDestroy { + private destroy$ = new Subject(); + + loading = true; + submitting = false; + email: string; + + // Note: this token is the email verification token. It is always supplied as a query param, but + // it either comes from the email verification email or, if email verification is disabled server side + // via global settings, it comes directly from the registration-start component directly. + emailVerificationToken: string; + + masterPasswordPolicyOptions: MasterPasswordPolicyOptions | null = null; + + constructor( + private activatedRoute: ActivatedRoute, + private router: Router, + private toastService: ToastService, + private i18nService: I18nService, + private registrationFinishService: RegistrationFinishService, + private validationService: ValidationService, + ) {} + + async ngOnInit() { + this.listenForQueryParamChanges(); + this.masterPasswordPolicyOptions = + await this.registrationFinishService.getMasterPasswordPolicyOptsFromOrgInvite(); + this.loading = false; + } + + private listenForQueryParamChanges() { + this.activatedRoute.queryParams.pipe(takeUntil(this.destroy$)).subscribe((qParams: Params) => { + if (qParams.email != null && qParams.email.indexOf("@") > -1) { + this.email = qParams.email; + } + + if (qParams.token != null) { + this.emailVerificationToken = qParams.token; + } + + if (qParams.fromEmail && qParams.fromEmail === "true") { + this.toastService.showToast({ + title: null, + message: this.i18nService.t("emailVerifiedV2"), + variant: "success", + }); + } + }); + } + + async handlePasswordFormSubmit(passwordInputResult: PasswordInputResult) { + this.submitting = true; + try { + await this.registrationFinishService.finishRegistration( + this.email, + passwordInputResult, + this.emailVerificationToken, + ); + } catch (e) { + this.validationService.showError(e); + this.submitting = false; + return; + } + + this.toastService.showToast({ + variant: "success", + title: null, + message: this.i18nService.t("newAccountCreated"), + }); + + this.submitting = false; + await this.router.navigate(["/login"], { queryParams: { email: this.email } }); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } } diff --git a/libs/auth/src/angular/registration/registration-finish/registration-finish.service.ts b/libs/auth/src/angular/registration/registration-finish/registration-finish.service.ts new file mode 100644 index 00000000000..63e23182f60 --- /dev/null +++ b/libs/auth/src/angular/registration/registration-finish/registration-finish.service.ts @@ -0,0 +1,25 @@ +import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options"; + +import { PasswordInputResult } from "../../input-password/password-input-result"; + +export abstract class RegistrationFinishService { + /** + * Gets the master password policy options from an organization invite if it exits. + * Organization invites can currently only be accepted on the web. + */ + abstract getMasterPasswordPolicyOptsFromOrgInvite(): Promise; + + /** + * Finishes the registration process by creating a new user account. + * + * @param email The email address of the user. + * @param passwordInputResult The password input result. + * @param emailVerificationToken The optional email verification token. Not present in org invite scenarios. + * Returns a promise which resolves to the captcha bypass token string upon a successful account creation. + */ + abstract finishRegistration( + email: string, + passwordInputResult: PasswordInputResult, + emailVerificationToken?: string, + ): Promise; +} 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 4a45589ee1b..00baeb71072 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 @@ -138,7 +138,9 @@ export class RegistrationStartComponent implements OnInit, OnDestroy { if (typeof result === "string") { // we received a token, so the env doesn't support email verification // send the user directly to the finish registration page with the token as a query param - await this.router.navigate(["/finish-signup"], { queryParams: { token: result } }); + await this.router.navigate(["/finish-signup"], { + queryParams: { token: result, email: this.email.value }, + }); } // Result is null, so email verification is required diff --git a/libs/auth/src/common/services/index.ts b/libs/auth/src/common/services/index.ts index c544d303f17..eb4ec39ce7b 100644 --- a/libs/auth/src/common/services/index.ts +++ b/libs/auth/src/common/services/index.ts @@ -3,3 +3,4 @@ export * from "./login-email/login-email.service"; export * from "./login-strategies/login-strategy.service"; export * from "./user-decryption-options/user-decryption-options.service"; export * from "./auth-request/auth-request.service"; +export * from "./register-route.service"; diff --git a/libs/auth/src/common/services/register-route.service.ts b/libs/auth/src/common/services/register-route.service.ts new file mode 100644 index 00000000000..5bc09db699e --- /dev/null +++ b/libs/auth/src/common/services/register-route.service.ts @@ -0,0 +1,21 @@ +import { Observable, map } from "rxjs"; + +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; + +// This is a temporary service to determine the correct route to use for registration based on the email verification feature flag. +export class RegisterRouteService { + constructor(private configService: ConfigService) {} + + registerRoute$(): Observable { + return this.configService.getFeatureFlag$(FeatureFlag.EmailVerification).pipe( + map((emailVerificationEnabled) => { + if (emailVerificationEnabled) { + return "/signup"; + } else { + return "/register"; + } + }), + ); + } +} diff --git a/libs/common/src/auth/abstractions/account-api.service.ts b/libs/common/src/auth/abstractions/account-api.service.ts index cc63589400a..f1f1c5471e0 100644 --- a/libs/common/src/auth/abstractions/account-api.service.ts +++ b/libs/common/src/auth/abstractions/account-api.service.ts @@ -1,3 +1,4 @@ +import { RegisterFinishRequest } from "../models/request/registration/register-finish.request"; import { RegisterSendVerificationEmailRequest } from "../models/request/registration/register-send-verification-email.request"; import { Verification } from "../types/verification"; @@ -24,4 +25,15 @@ export abstract class AccountApiService { abstract registerSendVerificationEmail( request: RegisterSendVerificationEmailRequest, ): Promise; + + /** + * Completes the registration process. + * + * @param request - The request object containing the user's email verification token, + * the email, hashed MP, newly created user key, and new asymmetric user key pair along + * with the KDF information used during the process. + * @returns A promise that resolves to a string captcha bypass token when the + * registration process is successfully completed. + */ + abstract registerFinish(request: RegisterFinishRequest): Promise; } diff --git a/libs/common/src/auth/models/request/registration/register-finish.request.ts b/libs/common/src/auth/models/request/registration/register-finish.request.ts new file mode 100644 index 00000000000..22275fb228e --- /dev/null +++ b/libs/common/src/auth/models/request/registration/register-finish.request.ts @@ -0,0 +1,25 @@ +import { KeysRequest } from "../../../../models/request/keys.request"; +import { KdfType } from "../../../../platform/enums"; +import { EncryptedString } from "../../../../platform/models/domain/enc-string"; + +export class RegisterFinishRequest { + constructor( + public email: string, + public emailVerificationToken: string, + + public masterPasswordHash: string, + public masterPasswordHint: string, + + public userSymmetricKey: EncryptedString, + public userAsymmetricKeys: KeysRequest, + + public kdf: KdfType, + public kdfIterations: number, + public kdfMemory?: number, + public kdfParallelism?: number, + + // Org Invite data (only applies on web) + public organizationUserId?: string, + public orgInviteToken?: string, + ) {} +} diff --git a/libs/common/src/auth/services/account-api.service.ts b/libs/common/src/auth/services/account-api.service.ts index 0573786d2ad..194e63bea6c 100644 --- a/libs/common/src/auth/services/account-api.service.ts +++ b/libs/common/src/auth/services/account-api.service.ts @@ -7,6 +7,7 @@ import { LogService } from "../../platform/abstractions/log.service"; import { AccountApiService } from "../abstractions/account-api.service"; import { InternalAccountService } from "../abstractions/account.service"; import { UserVerificationService } from "../abstractions/user-verification/user-verification.service.abstraction"; +import { RegisterFinishRequest } from "../models/request/registration/register-finish.request"; import { RegisterSendVerificationEmailRequest } from "../models/request/registration/register-send-verification-email.request"; import { Verification } from "../types/verification"; @@ -58,4 +59,24 @@ export class AccountApiServiceImplementation implements AccountApiService { throw e; } } + + async registerFinish(request: RegisterFinishRequest): Promise { + const env = await firstValueFrom(this.environmentService.environment$); + + try { + const response = await this.apiService.send( + "POST", + "/accounts/register/finish", + request, + false, + true, + env.getIdentityUrl(), + ); + + return response; + } catch (e: unknown) { + this.logService.error(e); + throw e; + } + } }