1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-15 07:43:35 +00:00

Merge branch 'main' into ps/PM-7846-rust-ipc

This commit is contained in:
Daniel García
2024-07-08 16:58:58 +02:00
46 changed files with 950 additions and 97 deletions

View File

@@ -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."
},

View File

@@ -30,7 +30,7 @@
</form>
<p class="createAccountLink">
{{ "newAroundHere" | i18n }}
<a [routerLink]="registerRoute" (click)="setLoginEmailValues()">{{
<a [routerLink]="registerRoute$ | async" (click)="setLoginEmailValues()">{{
"createAccount" | i18n
}}</a>
</p>

View File

@@ -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<void> {
// 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();

View File

@@ -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);

View File

@@ -48,7 +48,7 @@
</div>
<div class="sub-options">
<p class="no-margin">{{ "newAroundHere" | i18n }}</p>
<button type="button" class="text text-primary" [routerLink]="registerRoute">
<button type="button" class="text text-primary" [routerLink]="registerRoute$ | async">
{{ "createAccount" | i18n }}
</button>
</div>

View File

@@ -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);

View File

@@ -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."
},

View File

@@ -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 } });
}
}
}

View File

@@ -1 +1,2 @@
export * from "./webauthn-login";
export * from "./registration";

View File

@@ -0,0 +1 @@
export * from "./web-registration-finish.service";

View File

@@ -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<CryptoService>;
let accountApiService: MockProxy<AccountApiService>;
let acceptOrgInviteService: MockProxy<AcceptOrganizationInviteService>;
let policyApiService: MockProxy<PolicyApiServiceAbstraction>;
let logService: MockProxy<LogService>;
let policyService: MockProxy<PolicyService>;
beforeEach(() => {
cryptoService = mock<CryptoService>();
accountApiService = mock<AccountApiService>();
acceptOrgInviteService = mock<AcceptOrganizationInviteService>();
policyApiService = mock<PolicyApiServiceAbstraction>();
logService = mock<LogService>();
policyService = mock<PolicyService>();
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,
}),
);
});
});
});

View File

@@ -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<MasterPasswordPolicyOptions | null> {
// 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<RegisterFinishRequest> {
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;
}
}

View File

@@ -29,7 +29,7 @@
<a
bitButton
buttonType="primary"
[routerLink]="registerRoute"
[routerLink]="registerRoute$ | async"
[queryParams]="{ email: email }"
[block]="true"
>

View File

@@ -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<void> {

View File

@@ -56,7 +56,7 @@
clicking on the link. Mousedown fires before onBlur.
-->
<a
[routerLink]="registerRoute"
[routerLink]="registerRoute$ | async"
[queryParams]="emailFormControl.valid ? { email: emailFormControl.value } : {}"
(mousedown)="goToRegister()"
>{{ "createAccount" | i18n }}</a

View File

@@ -1,7 +1,7 @@
import { Component, NgZone, OnInit } from "@angular/core";
import { FormBuilder } from "@angular/forms";
import { ActivatedRoute, Router } from "@angular/router";
import { takeUntil } from "rxjs";
import { firstValueFrom, takeUntil } from "rxjs";
import { first } from "rxjs/operators";
import { LoginComponent as BaseLoginComponent } from "@bitwarden/angular/auth/components/login.component";
@@ -9,6 +9,7 @@ import { FormValidationErrorsService } from "@bitwarden/angular/platform/abstrac
import {
LoginStrategyServiceAbstraction,
LoginEmailServiceAbstraction,
RegisterRouteService,
} from "@bitwarden/auth/common";
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";
@@ -20,7 +21,6 @@ import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/
import { WebAuthnLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login.service.abstraction";
import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
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";
@@ -68,7 +68,7 @@ export class LoginComponent extends BaseLoginComponent implements OnInit {
loginEmailService: LoginEmailServiceAbstraction,
ssoLoginService: SsoLoginServiceAbstraction,
webAuthnLoginService: WebAuthnLoginServiceAbstraction,
configService: ConfigService,
registerRouteService: RegisterRouteService,
) {
super(
devicesApiService,
@@ -89,7 +89,7 @@ export class LoginComponent extends BaseLoginComponent implements OnInit {
loginEmailService,
ssoLoginService,
webAuthnLoginService,
configService,
registerRouteService,
);
this.onSuccessfulLoginNavigate = this.goAfterLogIn;
this.showPasswordless = flagEnabled("showPasswordless");
@@ -165,14 +165,17 @@ export class LoginComponent extends BaseLoginComponent implements OnInit {
}
async goToRegister() {
// TODO: remove when email verification flag is removed
const registerRoute = await firstValueFrom(this.registerRoute$);
if (this.emailFormControl.valid) {
await this.router.navigate([this.registerRoute], {
await this.router.navigate([registerRoute], {
queryParams: { email: this.emailFormControl.value },
});
return;
}
await this.router.navigate([this.registerRoute]);
await this.router.navigate([registerRoute]);
}
protected override async handleMigrateEncryptionKey(result: AuthResult): Promise<boolean> {

View File

@@ -32,7 +32,7 @@
{{ "logIn" | i18n }}
</a>
<a
[routerLink]="registerRoute"
[routerLink]="registerRoute$ | async"
[queryParams]="{ email: email }"
class="btn btn-primary btn-block ml-2 mt-0"
>

View File

@@ -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<void> {
@@ -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;

View File

@@ -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<void>();
@@ -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<void>;
abstract unauthedHandler(qParams: Params): Promise<void>;
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(),

View File

@@ -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({

View File

@@ -75,7 +75,7 @@
>Bitwarden Send</a
>
{{ "sendAccessTaglineOr" | i18n }}
<a bitLink [routerLink]="registerRoute" target="_blank" rel="noreferrer">{{
<a bitLink [routerLink]="registerRoute$ | async" target="_blank" rel="noreferrer">{{
"sendAccessTaglineSignUp" | i18n
}}</a>
{{ "sendAccessTaglineTryToday" | i18n }}

View File

@@ -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;

View File

@@ -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."
},
@@ -8523,6 +8526,14 @@
},
"noInvoicesToList": {
"message": "There are no invoices to list",
"description": "A paragraph on the Billing History page of the Provider Portal letting users know they can download a CSV report for their invoices that does not include prorations."
"description": "A paragraph on the Billing History page of the Provider Portal letting users know they can download a CSV report for their invoices that does not include prorations."
},
"providerClientVaultPrivacyNotification": {
"message": "Notice: Later this month, client vault privacy will be improved and provider members will no longer have direct access to client vault items. For questions,",
"description": "This will be displayed as part of a larger sentence. The whole sentence reads: 'Notice: Later this month, client vault privacy will be improved and provider members will no longer have direct access to client vault items. For questions, please contact Bitwarden support'."
},
"contactBitwardenSupport": {
"message": "contact Bitwarden support.",
"description": "This will be displayed as part of a larger sentence. The whole sentence reads: 'Notice: Later this month, client vault privacy will be improved and provider members will no longer have direct access to client vault items. For questions, please contact Bitwarden support'. 'Bitwarden' should not be translated"
}
}

View File

@@ -31,7 +31,7 @@
<a
bitButton
buttonType="primary"
[routerLink]="registerRoute"
[routerLink]="registerRoute$ | async"
[queryParams]="{ email: email }"
[block]="true"
>

View File

@@ -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) {

View File

@@ -45,7 +45,26 @@
<app-toggle-width></app-toggle-width>
</ng-container>
</bit-side-nav>
<bit-banner
class="-tw-m-6 tw-flex tw-flex-col tw-pb-6"
(onClose)="(true)"
*ngIf="
(showProviderClientVaultPrivacyWarningBanner$ | async) &&
(providerClientVaultPrivacyBannerService.showBanner$ | async) != false
"
(onClose)="providerClientVaultPrivacyBannerService.hideBanner()"
>
{{ "providerClientVaultPrivacyNotification" | i18n }}
<a
href="https://bitwarden.com/contact/"
bitLink
linkType="contrast"
target="_blank"
rel="noreferrer"
>
{{ "contactBitwardenSupport" | i18n }} </a
>.
</bit-banner>
<app-payment-method-warnings
*ngIf="showPaymentMethodWarningBanners$ | async"
></app-payment-method-warnings>

View File

@@ -10,12 +10,20 @@ import { Provider } from "@bitwarden/common/admin-console/models/domain/provider
import { hasConsolidatedBilling } from "@bitwarden/common/billing/abstractions/provider-billing.service.abstraction";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { IconModule, LayoutComponent, NavigationModule } from "@bitwarden/components";
import {
BannerModule,
IconModule,
LayoutComponent,
LinkModule,
NavigationModule,
} from "@bitwarden/components";
import { ProviderPortalLogo } from "@bitwarden/web-vault/app/admin-console/icons/provider-portal-logo";
import { PaymentMethodWarningsModule } from "@bitwarden/web-vault/app/billing/shared";
import { ProductSwitcherModule } from "@bitwarden/web-vault/app/layouts/product-switcher/product-switcher.module";
import { ToggleWidthComponent } from "@bitwarden/web-vault/app/layouts/toggle-width.component";
import { ProviderClientVaultPrivacyBannerService } from "./services/provider-client-vault-privacy-banner.service";
@Component({
selector: "providers-layout",
templateUrl: "providers-layout.component.html",
@@ -30,6 +38,8 @@ import { ToggleWidthComponent } from "@bitwarden/web-vault/app/layouts/toggle-wi
PaymentMethodWarningsModule,
ToggleWidthComponent,
ProductSwitcherModule,
BannerModule,
LinkModule,
],
})
export class ProvidersLayoutComponent implements OnInit, OnDestroy {
@@ -45,10 +55,15 @@ export class ProvidersLayoutComponent implements OnInit, OnDestroy {
FeatureFlag.ShowPaymentMethodWarningBanners,
);
protected showProviderClientVaultPrivacyWarningBanner$ = this.configService.getFeatureFlag$(
FeatureFlag.ProviderClientVaultPrivacyBanner,
);
constructor(
private route: ActivatedRoute,
private providerService: ProviderService,
private configService: ConfigService,
protected providerClientVaultPrivacyBannerService: ProviderClientVaultPrivacyBannerService,
) {}
ngOnInit() {

View File

@@ -0,0 +1,31 @@
import { Injectable } from "@angular/core";
import {
StateProvider,
AC_BANNERS_DISMISSED_DISK,
UserKeyDefinition,
} from "@bitwarden/common/platform/state";
export const SHOW_BANNER_KEY = new UserKeyDefinition<boolean>(
AC_BANNERS_DISMISSED_DISK,
"showProviderClientVaultPrivacyBanner",
{
deserializer: (b) => b,
clearOn: [],
},
);
/** Displays a banner warning provider users that client organization vaults
* will soon become inaccessible directly. */
@Injectable({ providedIn: "root" })
export class ProviderClientVaultPrivacyBannerService {
private _showBanner = this.stateProvider.getActive(SHOW_BANNER_KEY);
showBanner$ = this._showBanner.state$;
constructor(private stateProvider: StateProvider) {}
async hideBanner() {
await this._showBanner.update(() => false);
}
}

View File

@@ -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<void>();
@@ -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;

View File

@@ -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(

View File

@@ -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";

View File

@@ -65,7 +65,14 @@
<bit-label>{{ "checkForBreaches" | i18n }}</bit-label>
</bit-form-control>
<button type="submit" bitButton bitFormButton buttonType="primary" [block]="true">
<button
type="submit"
bitButton
bitFormButton
buttonType="primary"
[block]="true"
[loading]="loading"
>
{{ buttonText || ("setMasterPassword" | i18n) }}
</button>

View File

@@ -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(),

View File

@@ -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<CryptoService>;
let accountApiService: MockProxy<AccountApiService>;
beforeEach(() => {
cryptoService = mock<CryptoService>();
accountApiService = mock<AccountApiService>();
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
}),
);
});
});
});

View File

@@ -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<MasterPasswordPolicyOptions | null> {
return null;
}
async finishRegistration(
email: string,
passwordInputResult: PasswordInputResult,
emailVerificationToken?: string,
): Promise<string> {
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<RegisterFinishRequest> {
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,
);
}
}

View File

@@ -1 +1,11 @@
<h3>This component will be built in the next phase of email verification work.</h3>
<div class="full-loading-spinner" *ngIf="loading">
<i class="bwi bwi-spinner bwi-spin bwi-3x" aria-hidden="true"></i>
</div>
<auth-input-password
*ngIf="!loading"
[email]="email"
[masterPasswordPolicyOptions]="masterPasswordPolicyOptions"
[loading]="submitting"
(onPasswordFormSubmit)="handlePasswordFormSubmit($event)"
></auth-input-password>

View File

@@ -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<void>();
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();
}
}

View File

@@ -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<MasterPasswordPolicyOptions | null>;
/**
* 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<string>;
}

View File

@@ -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

View File

@@ -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";

View File

@@ -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<string> {
return this.configService.getFeatureFlag$(FeatureFlag.EmailVerification).pipe(
map((emailVerificationEnabled) => {
if (emailVerificationEnabled) {
return "/signup";
} else {
return "/register";
}
}),
);
}
}

View File

@@ -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<null | string>;
/**
* 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<string>;
}

View File

@@ -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,
) {}
}

View File

@@ -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<string> {
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;
}
}
}

View File

@@ -22,6 +22,7 @@ export enum FeatureFlag {
MemberAccessReport = "ac-2059-member-access-report",
EnableTimeThreshold = "PM-5864-dollar-threshold",
GroupsComponentRefactor = "groups-component-refactor",
ProviderClientVaultPrivacyBanner = "ac-2833-provider-client-vault-privacy-banner",
}
export type AllowedFeatureFlagTypes = boolean | number | string;
@@ -54,6 +55,7 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.MemberAccessReport]: FALSE,
[FeatureFlag.EnableTimeThreshold]: FALSE,
[FeatureFlag.GroupsComponentRefactor]: FALSE,
[FeatureFlag.ProviderClientVaultPrivacyBanner]: FALSE,
} satisfies Record<FeatureFlag, AllowedFeatureFlagTypes>;
export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue;

View File

@@ -29,6 +29,9 @@ export const ORGANIZATION_MANAGEMENT_PREFERENCES_DISK = new StateDefinition(
web: "disk-local",
},
);
export const AC_BANNERS_DISMISSED_DISK = new StateDefinition("acBannersDismissed", "disk", {
web: "disk-local",
});
// Billing
export const BILLING_DISK = new StateDefinition("billing", "disk");