[PM-7330] Create SetPasswordComponent (email verification) (#9810)

* setup SetPassword component

* accept query params

* add InputPasswordComponent to template

* add route

* add dynamic translation with org name

* feature flag route

* setup onInit

* add set password logic

* move to libs

* remove comments

* update AuthGuard routing

* use ToastService

* replace deprecated methods

* replace orgId input with policy input

* use getter for msg instead of ngOnInit

* cleanup

* refactor to use services

* more refactoring of service

* address browser routing and translations

* add desktop service

* simplify queryParam handler

* remove ngOnDestroy

* small edits

* use inject()

* add jsdocs

* create basic tests

* add success toasts on successfuly set password

* add tests

* update feature-flag

* move model to service

* refactor client services to override setPassword()

* add error handling to setPassword()

* move auto enroll logic to service

* update tests

* fix test

* adjust padding on password-callout list

* revert refactor of auto enroll logic

* refactor keyPair generation to own method

* update page title and button text

* update pageSubtitle and translations

* fix test
This commit is contained in:
rr-bw 2024-07-25 07:42:32 -07:00 committed by GitHub
parent c4c949c15a
commit 9355a9bb43
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 796 additions and 21 deletions

View File

@ -13,6 +13,9 @@
"loginOrCreateNewAccount": {
"message": "Log in or create a new account to access your secure vault."
},
"inviteAccepted": {
"message": "Invitation accepted"
},
"createAccount": {
"message": "Create account"
},
@ -68,6 +71,12 @@
"masterPassHint": {
"message": "Master password hint (optional)"
},
"joinOrganization": {
"message": "Join organization"
},
"finishJoiningThisOrganizationBySettingAMasterPassword": {
"message": "Finish joining this organization by setting a master password."
},
"tab": {
"message": "Tab"
},

View File

@ -16,6 +16,7 @@ import {
RegistrationStartComponent,
RegistrationStartSecondaryComponent,
RegistrationStartSecondaryComponentData,
SetPasswordJitComponent,
} from "@bitwarden/auth/angular";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
@ -409,6 +410,15 @@ const routes: Routes = [
},
],
},
{
path: "set-password-jit",
canActivate: [canAccessFeature(FeatureFlag.EmailVerification)],
component: SetPasswordJitComponent,
data: {
pageTitle: "joinOrganization",
pageSubtitle: "finishJoiningThisOrganizationBySettingAMasterPassword",
} satisfies AnonLayoutWrapperData,
},
],
},
{

View File

@ -16,6 +16,7 @@ import {
RegistrationStartComponent,
RegistrationStartSecondaryComponent,
RegistrationStartSecondaryComponentData,
SetPasswordJitComponent,
} from "@bitwarden/auth/angular";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
@ -149,6 +150,15 @@ const routes: Routes = [
},
],
},
{
path: "set-password-jit",
canActivate: [canAccessFeature(FeatureFlag.EmailVerification)],
component: SetPasswordJitComponent,
data: {
pageTitle: "joinOrganization",
pageSubtitle: "finishJoiningThisOrganizationBySettingAMasterPassword",
} satisfies AnonLayoutWrapperData,
},
],
},
];

View File

@ -0,0 +1,21 @@
import { inject } from "@angular/core";
import {
DefaultSetPasswordJitService,
SetPasswordCredentials,
SetPasswordJitService,
} from "@bitwarden/auth/angular";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
export class DesktopSetPasswordJitService
extends DefaultSetPasswordJitService
implements SetPasswordJitService
{
messagingService = inject(MessagingService);
override async setPassword(credentials: SetPasswordCredentials) {
await super.setPassword(credentials);
this.messagingService.send("redrawMenu");
}
}

View File

@ -18,16 +18,30 @@ import {
CLIENT_TYPE,
} from "@bitwarden/angular/services/injection-tokens";
import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module";
import { SetPasswordJitService } from "@bitwarden/auth/angular";
import {
InternalUserDecryptionOptionsServiceAbstraction,
PinServiceAbstraction,
} from "@bitwarden/auth/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import { OrganizationUserService } from "@bitwarden/common/admin-console/abstractions/organization-user/organization-user.service";
import { PolicyService as PolicyServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { AccountService as AccountServiceAbstraction } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService as AuthServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth.service";
import { KdfConfigService as KdfConfigServiceAbstraction } from "@bitwarden/common/auth/abstractions/kdf-config.service";
import {
KdfConfigService,
KdfConfigService as KdfConfigServiceAbstraction,
} from "@bitwarden/common/auth/abstractions/kdf-config.service";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction";
import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service";
import { ClientType } from "@bitwarden/common/enums";
import { CryptoFunctionService as CryptoFunctionServiceAbstraction } from "@bitwarden/common/platform/abstractions/crypto-function.service";
import { CryptoService as CryptoServiceAbstraction } from "@bitwarden/common/platform/abstractions/crypto.service";
import {
CryptoService,
CryptoService as CryptoServiceAbstraction,
} from "@bitwarden/common/platform/abstractions/crypto.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service";
import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service";
@ -56,7 +70,6 @@ import { CipherService as CipherServiceAbstraction } from "@bitwarden/common/vau
import { DialogService } from "@bitwarden/components";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
import { PinServiceAbstraction } from "../../../../../libs/auth/src/common/abstractions";
import { DesktopAutofillSettingsService } from "../../autofill/services/desktop-autofill-settings.service";
import { DesktopSettingsService } from "../../platform/services/desktop-settings.service";
import { ElectronCryptoService } from "../../platform/services/electron-crypto.service";
@ -77,6 +90,7 @@ import { NativeMessagingService } from "../../services/native-messaging.service"
import { SearchBarService } from "../layout/search/search-bar.service";
import { DesktopFileDownloadService } from "./desktop-file-download.service";
import { DesktopSetPasswordJitService } from "./desktop-set-password-jit.service";
import { InitService } from "./init.service";
import { NativeMessagingManifestService } from "./native-messaging-manifest.service";
import { RendererCryptoFunctionService } from "./renderer-crypto-function.service";
@ -254,6 +268,20 @@ const safeProviders: SafeProvider[] = [
provide: CLIENT_TYPE,
useValue: ClientType.Desktop,
}),
safeProvider({
provide: SetPasswordJitService,
useClass: DesktopSetPasswordJitService,
deps: [
ApiService,
CryptoService,
I18nServiceAbstraction,
KdfConfigService,
InternalMasterPasswordServiceAbstraction,
OrganizationApiServiceAbstraction,
OrganizationUserService,
InternalUserDecryptionOptionsServiceAbstraction,
],
}),
];
@NgModule({

View File

@ -551,6 +551,12 @@
"masterPassHintLabel": {
"message": "Master password hint"
},
"joinOrganization": {
"message": "Join organization"
},
"finishJoiningThisOrganizationBySettingAMasterPassword": {
"message": "Finish joining this organization by setting a master password."
},
"settings": {
"message": "Settings"
},
@ -2093,6 +2099,9 @@
"vaultTimeoutTooLarge": {
"message": "Your vault timeout exceeds the restrictions set by your organization."
},
"inviteAccepted": {
"message": "Invitation accepted"
},
"resetPasswordPolicyAutoEnroll": {
"message": "Automatic enrollment"
},

View File

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

View File

@ -145,6 +145,7 @@ describe("DefaultRegistrationFinishService", () => {
passwordInputResult = {
masterKey: masterKey,
masterKeyHash: "masterKeyHash",
localMasterKeyHash: "localMasterKeyHash",
kdfConfig: DEFAULT_KDF_CONFIG,
hint: "hint",
};

View File

@ -0,0 +1 @@
export * from "./web-set-password-jit.service";

View File

@ -0,0 +1,27 @@
import { inject } from "@angular/core";
import {
DefaultSetPasswordJitService,
SetPasswordCredentials,
SetPasswordJitService,
} from "@bitwarden/auth/angular";
import { RouterService } from "../../../../core/router.service";
import { AcceptOrganizationInviteService } from "../../../organization-invite/accept-organization.service";
export class WebSetPasswordJitService
extends DefaultSetPasswordJitService
implements SetPasswordJitService
{
routerService = inject(RouterService);
acceptOrganizationInviteService = inject(AcceptOrganizationInviteService);
override async setPassword(credentials: SetPasswordCredentials) {
await super.setPassword(credentials);
// SSO JIT accepts org invites when setting their MP, meaning
// we can clear the deep linked url for accepting it.
await this.routerService.getAndClearLoginRedirectUrl();
await this.acceptOrganizationInviteService.clearOrganizationInvitation();
}
}

View File

@ -17,11 +17,20 @@ 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 {
SetPasswordJitService,
RegistrationFinishService as RegistrationFinishServiceAbstraction,
} from "@bitwarden/auth/angular";
import { InternalUserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import { OrganizationUserService } from "@bitwarden/common/admin-console/abstractions/organization-user/organization-user.service";
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 { KdfConfigService } from "@bitwarden/common/auth/abstractions/kdf-config.service";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction";
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";
@ -48,7 +57,7 @@ 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 { WebSetPasswordJitService, 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";
@ -184,6 +193,20 @@ const safeProviders: SafeProvider[] = [
PolicyService,
],
}),
safeProvider({
provide: SetPasswordJitService,
useClass: WebSetPasswordJitService,
deps: [
ApiService,
CryptoServiceAbstraction,
I18nServiceAbstraction,
KdfConfigService,
InternalMasterPasswordServiceAbstraction,
OrganizationApiServiceAbstraction,
OrganizationUserService,
InternalUserDecryptionOptionsServiceAbstraction,
],
}),
];
@NgModule({

View File

@ -17,6 +17,7 @@ import {
RegistrationStartComponent,
RegistrationStartSecondaryComponent,
RegistrationStartSecondaryComponentData,
SetPasswordJitComponent,
LockIcon,
RegistrationLinkExpiredComponent,
} from "@bitwarden/auth/angular";
@ -206,6 +207,15 @@ const routes: Routes = [
},
],
},
{
path: "set-password-jit",
canActivate: [canAccessFeature(FeatureFlag.EmailVerification)],
component: SetPasswordJitComponent,
data: {
pageTitle: "joinOrganization",
pageSubtitle: "finishJoiningThisOrganizationBySettingAMasterPassword",
} satisfies AnonLayoutWrapperData,
},
{
path: "signup-link-expired",
canActivate: [canAccessFeature(FeatureFlag.EmailVerification), unauthGuardFn()],

View File

@ -3471,6 +3471,9 @@
"joinOrganizationDesc": {
"message": "You've been invited to join the organization listed above. To accept the invitation, you need to log in or create a new Bitwarden account."
},
"finishJoiningThisOrganizationBySettingAMasterPassword": {
"message": "Finish joining this organization by setting a master password."
},
"inviteAccepted": {
"message": "Invitation accepted"
},

View File

@ -17,6 +17,7 @@ import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/
import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
import { SsoPreValidateResponse } from "@bitwarden/common/auth/models/response/sso-pre-validate.response";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
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";
@ -319,6 +320,14 @@ export class SsoComponent {
}
private async handleChangePasswordRequired(orgIdentifier: string) {
const emailVerification = await this.configService.getFeatureFlag(
FeatureFlag.EmailVerification,
);
if (emailVerification) {
this.changePasswordRoute = "set-password-jit";
}
await this.navigateViaCallbackOrRoute(
this.onSuccessfulLoginChangePasswordNavigate,
[this.changePasswordRoute],

View File

@ -2,6 +2,8 @@ import { ErrorHandler, LOCALE_ID, NgModule } from "@angular/core";
import { Subject } from "rxjs";
import {
SetPasswordJitService,
DefaultSetPasswordJitService,
RegistrationFinishService as RegistrationFinishServiceAbstraction,
DefaultRegistrationFinishService,
} from "@bitwarden/auth/angular";
@ -1265,6 +1267,20 @@ const safeProviders: SafeProvider[] = [
useClass: StripeService,
deps: [LogService],
}),
safeProvider({
provide: SetPasswordJitService,
useClass: DefaultSetPasswordJitService,
deps: [
ApiServiceAbstraction,
CryptoServiceAbstraction,
I18nServiceAbstraction,
KdfConfigServiceAbstraction,
InternalMasterPasswordServiceAbstraction,
OrganizationApiServiceAbstraction,
OrganizationUserService,
InternalUserDecryptionOptionsServiceAbstraction,
],
}),
safeProvider({
provide: RegisterRouteService,
useClass: RegisterRouteService,

View File

@ -5,13 +5,26 @@
// icons
export * from "./icons";
// anon layout
export * from "./anon-layout/anon-layout.component";
export * from "./anon-layout/anon-layout-wrapper.component";
// fingerprint dialog
export * from "./fingerprint-dialog/fingerprint-dialog.component";
export * from "./input-password/input-password.component";
// password callout
export * from "./password-callout/password-callout.component";
export * from "./vault-timeout-input/vault-timeout-input.component";
// input password
export * from "./input-password/input-password.component";
export * from "./input-password/password-input-result";
// set password (JIT user)
export * from "./set-password-jit/set-password-jit.component";
export * from "./set-password-jit/set-password-jit.service.abstraction";
export * from "./set-password-jit/default-set-password-jit.service";
// user verification
export * from "./user-verification/user-verification-dialog.component";
export * from "./user-verification/user-verification-dialog.types";
@ -25,6 +38,3 @@ export * from "./registration/registration-start/registration-start-secondary.co
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

@ -23,7 +23,7 @@
<bit-hint>
<span class="tw-font-bold">{{ "important" | i18n }} </span>
{{ "masterPassImportant" | i18n }}
{{ minPasswordMsg }}.
{{ minPasswordLengthMsg }}.
</bit-hint>
</bit-form-field>

View File

@ -1,4 +1,4 @@
import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core";
import { Component, EventEmitter, Input, Output } from "@angular/core";
import { ReactiveFormsModule, FormBuilder, Validators } from "@angular/forms";
import { JslibModule } from "@bitwarden/angular/jslib.module";
@ -12,6 +12,7 @@ import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/mod
import { DEFAULT_KDF_CONFIG } from "@bitwarden/common/auth/models/domain/kdf-config";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { HashPurpose } from "@bitwarden/common/platform/enums";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import {
AsyncActionsModule,
@ -48,17 +49,16 @@ import { PasswordInputResult } from "./password-input-result";
JslibModule,
],
})
export class InputPasswordComponent implements OnInit {
export class InputPasswordComponent {
@Output() onPasswordFormSubmit = new EventEmitter<PasswordInputResult>();
@Input({ required: true }) email: string;
@Input() protected buttonText: string;
@Input() buttonText: string;
@Input() masterPasswordPolicyOptions: MasterPasswordPolicyOptions | null = null;
@Input() loading: boolean = false;
private minHintLength = 0;
protected maxHintLength = 50;
protected minPasswordLength = Utils.minimumPasswordLength;
protected minPasswordMsg = "";
protected passwordStrengthScore: PasswordStrengthScore;
@ -103,17 +103,14 @@ export class InputPasswordComponent implements OnInit {
private toastService: ToastService,
) {}
async ngOnInit() {
get minPasswordLengthMsg() {
if (
this.masterPasswordPolicyOptions != null &&
this.masterPasswordPolicyOptions.minLength > 0
) {
this.minPasswordMsg = this.i18nService.t(
"characterMinimum",
this.masterPasswordPolicyOptions.minLength,
);
return this.i18nService.t("characterMinimum", this.masterPasswordPolicyOptions.minLength);
} else {
this.minPasswordMsg = this.i18nService.t("characterMinimum", this.minPasswordLength);
return this.i18nService.t("characterMinimum", this.minPasswordLength);
}
}
@ -181,9 +178,16 @@ export class InputPasswordComponent implements OnInit {
const masterKeyHash = await this.cryptoService.hashMasterKey(password, masterKey);
const localMasterKeyHash = await this.cryptoService.hashMasterKey(
password,
masterKey,
HashPurpose.LocalAuthorization,
);
this.onPasswordFormSubmit.emit({
masterKey,
masterKeyHash,
localMasterKeyHash,
kdfConfig,
hint: this.formGroup.controls.hint.value,
});

View File

@ -4,6 +4,7 @@ import { MasterKey } from "@bitwarden/common/types/key";
export interface PasswordInputResult {
masterKey: MasterKey;
masterKeyHash: string;
localMasterKeyHash: string;
kdfConfig: PBKDF2KdfConfig;
hint: string;
}

View File

@ -1,7 +1,7 @@
<bit-callout>
{{ message | i18n }}
<ul *ngIf="policy" class="tw-mb-0">
<ul *ngIf="policy" class="tw-mb-0 tw-ml-8 tw-ps-0">
<li *ngIf="policy?.minComplexity > 0">
{{ "policyInEffectMinComplexity" | i18n: getPasswordScoreAlertDisplay() }}
</li>

View File

@ -54,6 +54,7 @@ describe("DefaultRegistrationFinishService", () => {
passwordInputResult = {
masterKey: masterKey,
masterKeyHash: "masterKeyHash",
localMasterKeyHash: "localMasterKeyHash",
kdfConfig: DEFAULT_KDF_CONFIG,
hint: "hint",
};

View File

@ -0,0 +1,230 @@
import { MockProxy, mock } from "jest-mock-extended";
import { BehaviorSubject, of } from "rxjs";
import {
FakeUserDecryptionOptions as UserDecryptionOptions,
InternalUserDecryptionOptionsServiceAbstraction,
} from "@bitwarden/auth/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import { OrganizationUserService } from "@bitwarden/common/admin-console/abstractions/organization-user/organization-user.service";
import { OrganizationKeysResponse } from "@bitwarden/common/admin-console/models/response/organization-keys.response";
import { KdfConfigService } from "@bitwarden/common/auth/abstractions/kdf-config.service";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction";
import { DEFAULT_KDF_CONFIG } from "@bitwarden/common/auth/models/domain/kdf-config";
import { SetPasswordRequest } from "@bitwarden/common/auth/models/request/set-password.request";
import { KeysRequest } from "@bitwarden/common/models/request/keys.request";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
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 { UserId } from "@bitwarden/common/types/guid";
import { MasterKey, UserKey } from "@bitwarden/common/types/key";
import { PasswordInputResult } from "../input-password/password-input-result";
import { DefaultSetPasswordJitService } from "./default-set-password-jit.service";
import { SetPasswordCredentials } from "./set-password-jit.service.abstraction";
describe("DefaultSetPasswordJitService", () => {
let sut: DefaultSetPasswordJitService;
let apiService: MockProxy<ApiService>;
let cryptoService: MockProxy<CryptoService>;
let i18nService: MockProxy<I18nService>;
let kdfConfigService: MockProxy<KdfConfigService>;
let masterPasswordService: MockProxy<InternalMasterPasswordServiceAbstraction>;
let organizationApiService: MockProxy<OrganizationApiServiceAbstraction>;
let organizationUserService: MockProxy<OrganizationUserService>;
let userDecryptionOptionsService: MockProxy<InternalUserDecryptionOptionsServiceAbstraction>;
beforeEach(() => {
apiService = mock<ApiService>();
cryptoService = mock<CryptoService>();
i18nService = mock<I18nService>();
kdfConfigService = mock<KdfConfigService>();
masterPasswordService = mock<InternalMasterPasswordServiceAbstraction>();
organizationApiService = mock<OrganizationApiServiceAbstraction>();
organizationUserService = mock<OrganizationUserService>();
userDecryptionOptionsService = mock<InternalUserDecryptionOptionsServiceAbstraction>();
sut = new DefaultSetPasswordJitService(
apiService,
cryptoService,
i18nService,
kdfConfigService,
masterPasswordService,
organizationApiService,
organizationUserService,
userDecryptionOptionsService,
);
});
it("should instantiate the DefaultSetPasswordJitService", () => {
expect(sut).not.toBeFalsy();
});
describe("setPassword", () => {
let masterKey: MasterKey;
let userKey: UserKey;
let userKeyEncString: EncString;
let protectedUserKey: [UserKey, EncString];
let keyPair: [string, EncString];
let keysRequest: KeysRequest;
let organizationKeys: OrganizationKeysResponse;
let orgPublicKey: Uint8Array;
let orgSsoIdentifier: string;
let orgId: string;
let resetPasswordAutoEnroll: boolean;
let userId: UserId;
let passwordInputResult: PasswordInputResult;
let credentials: SetPasswordCredentials;
let userDecryptionOptionsSubject: BehaviorSubject<UserDecryptionOptions>;
let setPasswordRequest: SetPasswordRequest;
beforeEach(() => {
masterKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as MasterKey;
userKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as UserKey;
userKeyEncString = new EncString("userKeyEncrypted");
protectedUserKey = [userKey, userKeyEncString];
keyPair = ["publicKey", new EncString("privateKey")];
keysRequest = new KeysRequest(keyPair[0], keyPair[1].encryptedString);
organizationKeys = {
privateKey: "orgPrivateKey",
publicKey: "orgPublicKey",
} as OrganizationKeysResponse;
orgPublicKey = Utils.fromB64ToArray(organizationKeys.publicKey);
orgSsoIdentifier = "orgSsoIdentifier";
orgId = "orgId";
resetPasswordAutoEnroll = false;
userId = "userId" as UserId;
passwordInputResult = {
masterKey: masterKey,
masterKeyHash: "masterKeyHash",
localMasterKeyHash: "localMasterKeyHash",
hint: "hint",
kdfConfig: DEFAULT_KDF_CONFIG,
};
credentials = {
...passwordInputResult,
orgSsoIdentifier,
orgId,
resetPasswordAutoEnroll,
userId,
};
userDecryptionOptionsSubject = new BehaviorSubject(null);
userDecryptionOptionsService.userDecryptionOptions$ = userDecryptionOptionsSubject;
setPasswordRequest = new SetPasswordRequest(
passwordInputResult.masterKeyHash,
protectedUserKey[1].encryptedString,
passwordInputResult.hint,
orgSsoIdentifier,
keysRequest,
passwordInputResult.kdfConfig.kdfType,
passwordInputResult.kdfConfig.iterations,
);
});
function setupSetPasswordMocks(hasUserKey = true) {
if (!hasUserKey) {
cryptoService.userKey$.mockReturnValue(of(null));
cryptoService.makeUserKey.mockResolvedValue(protectedUserKey);
} else {
cryptoService.userKey$.mockReturnValue(of(userKey));
cryptoService.encryptUserKeyWithMasterKey.mockResolvedValue(protectedUserKey);
}
cryptoService.makeKeyPair.mockResolvedValue(keyPair);
apiService.setPassword.mockResolvedValue(undefined);
masterPasswordService.setForceSetPasswordReason.mockResolvedValue(undefined);
userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: true }));
userDecryptionOptionsService.setUserDecryptionOptions.mockResolvedValue(undefined);
kdfConfigService.setKdfConfig.mockResolvedValue(undefined);
cryptoService.setUserKey.mockResolvedValue(undefined);
cryptoService.setPrivateKey.mockResolvedValue(undefined);
masterPasswordService.setMasterKeyHash.mockResolvedValue(undefined);
}
function setupResetPasswordAutoEnrollMocks(organizationKeysExist = true) {
if (organizationKeysExist) {
organizationApiService.getKeys.mockResolvedValue(organizationKeys);
} else {
organizationApiService.getKeys.mockResolvedValue(null);
return;
}
cryptoService.userKey$.mockReturnValue(of(userKey));
cryptoService.rsaEncrypt.mockResolvedValue(userKeyEncString);
organizationUserService.putOrganizationUserResetPasswordEnrollment.mockResolvedValue(
undefined,
);
}
it("should set password successfully (given a user key)", async () => {
// Arrange
setupSetPasswordMocks();
// Act
await sut.setPassword(credentials);
// Assert
expect(apiService.setPassword).toHaveBeenCalledWith(setPasswordRequest);
});
it("should set password successfully (given no user key)", async () => {
// Arrange
setupSetPasswordMocks(false);
// Act
await sut.setPassword(credentials);
// Assert
expect(apiService.setPassword).toHaveBeenCalledWith(setPasswordRequest);
});
it("should handle reset password auto enroll", async () => {
// Arrange
credentials.resetPasswordAutoEnroll = true;
setupSetPasswordMocks();
setupResetPasswordAutoEnrollMocks();
// Act
await sut.setPassword(credentials);
// Assert
expect(apiService.setPassword).toHaveBeenCalledWith(setPasswordRequest);
expect(organizationApiService.getKeys).toHaveBeenCalledWith(orgId);
expect(cryptoService.rsaEncrypt).toHaveBeenCalledWith(userKey.key, orgPublicKey);
expect(organizationUserService.putOrganizationUserResetPasswordEnrollment).toHaveBeenCalled();
});
it("when handling reset password auto enroll, it should throw an error if organization keys are not found", async () => {
// Arrange
credentials.resetPasswordAutoEnroll = true;
setupSetPasswordMocks();
setupResetPasswordAutoEnrollMocks(false);
// Act and Assert
await expect(sut.setPassword(credentials)).rejects.toThrow();
expect(
organizationUserService.putOrganizationUserResetPasswordEnrollment,
).not.toHaveBeenCalled();
});
});
});

View File

@ -0,0 +1,170 @@
import { firstValueFrom } from "rxjs";
import { InternalUserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import { OrganizationUserService } from "@bitwarden/common/admin-console/abstractions/organization-user/organization-user.service";
import { OrganizationUserResetPasswordEnrollmentRequest } from "@bitwarden/common/admin-console/abstractions/organization-user/requests";
import { KdfConfigService } from "@bitwarden/common/auth/abstractions/kdf-config.service";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
import { PBKDF2KdfConfig } from "@bitwarden/common/auth/models/domain/kdf-config";
import { SetPasswordRequest } from "@bitwarden/common/auth/models/request/set-password.request";
import { KeysRequest } from "@bitwarden/common/models/request/keys.request";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { UserId } from "@bitwarden/common/types/guid";
import { MasterKey, UserKey } from "@bitwarden/common/types/key";
import {
SetPasswordCredentials,
SetPasswordJitService,
} from "./set-password-jit.service.abstraction";
export class DefaultSetPasswordJitService implements SetPasswordJitService {
constructor(
protected apiService: ApiService,
protected cryptoService: CryptoService,
protected i18nService: I18nService,
protected kdfConfigService: KdfConfigService,
protected masterPasswordService: InternalMasterPasswordServiceAbstraction,
protected organizationApiService: OrganizationApiServiceAbstraction,
protected organizationUserService: OrganizationUserService,
protected userDecryptionOptionsService: InternalUserDecryptionOptionsServiceAbstraction,
) {}
async setPassword(credentials: SetPasswordCredentials): Promise<void> {
const {
masterKey,
masterKeyHash,
localMasterKeyHash,
hint,
kdfConfig,
orgSsoIdentifier,
orgId,
resetPasswordAutoEnroll,
userId,
} = credentials;
for (const [key, value] of Object.entries(credentials)) {
if (value == null) {
throw new Error(`${key} not found. Could not set password.`);
}
}
const protectedUserKey = await this.makeProtectedUserKey(masterKey, userId);
if (protectedUserKey == null) {
throw new Error("protectedUserKey not found. Could not set password.");
}
// Since this is an existing JIT provisioned user in a MP encryption org setting first password,
// they will not already have a user asymmetric key pair so we must create it for them.
const [keyPair, keysRequest] = await this.makeKeyPairAndRequest(protectedUserKey);
const request = new SetPasswordRequest(
masterKeyHash,
protectedUserKey[1].encryptedString,
hint,
orgSsoIdentifier,
keysRequest,
kdfConfig.kdfType, // kdfConfig is always DEFAULT_KDF_CONFIG (see InputPasswordComponent)
kdfConfig.iterations,
);
await this.apiService.setPassword(request);
// Clear force set password reason to allow navigation back to vault.
await this.masterPasswordService.setForceSetPasswordReason(ForceSetPasswordReason.None, userId);
// User now has a password so update account decryption options in state
await this.updateAccountDecryptionProperties(masterKey, kdfConfig, protectedUserKey, userId);
await this.cryptoService.setPrivateKey(keyPair[1].encryptedString, userId);
await this.masterPasswordService.setMasterKeyHash(localMasterKeyHash, userId);
if (resetPasswordAutoEnroll) {
await this.handleResetPasswordAutoEnroll(masterKeyHash, orgId, userId);
}
}
private async makeProtectedUserKey(
masterKey: MasterKey,
userId: UserId,
): Promise<[UserKey, EncString]> {
let protectedUserKey: [UserKey, EncString] = null;
const userKey = await firstValueFrom(this.cryptoService.userKey$(userId));
if (userKey == null) {
protectedUserKey = await this.cryptoService.makeUserKey(masterKey);
} else {
protectedUserKey = await this.cryptoService.encryptUserKeyWithMasterKey(masterKey);
}
return protectedUserKey;
}
private async makeKeyPairAndRequest(
protectedUserKey: [UserKey, EncString],
): Promise<[[string, EncString], KeysRequest]> {
const keyPair = await this.cryptoService.makeKeyPair(protectedUserKey[0]);
if (keyPair == null) {
throw new Error("keyPair not found. Could not set password.");
}
const keysRequest = new KeysRequest(keyPair[0], keyPair[1].encryptedString);
return [keyPair, keysRequest];
}
private async updateAccountDecryptionProperties(
masterKey: MasterKey,
kdfConfig: PBKDF2KdfConfig,
protectedUserKey: [UserKey, EncString],
userId: UserId,
) {
const userDecryptionOpts = await firstValueFrom(
this.userDecryptionOptionsService.userDecryptionOptions$,
);
userDecryptionOpts.hasMasterPassword = true;
await this.userDecryptionOptionsService.setUserDecryptionOptions(userDecryptionOpts);
await this.kdfConfigService.setKdfConfig(userId, kdfConfig);
await this.masterPasswordService.setMasterKey(masterKey, userId);
await this.cryptoService.setUserKey(protectedUserKey[0], userId);
}
private async handleResetPasswordAutoEnroll(
masterKeyHash: string,
orgId: string,
userId: UserId,
) {
const organizationKeys = await this.organizationApiService.getKeys(orgId);
if (organizationKeys == null) {
throw new Error(this.i18nService.t("resetPasswordOrgKeysError"));
}
const publicKey = Utils.fromB64ToArray(organizationKeys.publicKey);
// RSA Encrypt user key with organization public key
const userKey = await firstValueFrom(this.cryptoService.userKey$(userId));
if (userKey == null) {
throw new Error("userKey not found. Could not handle reset password auto enroll.");
}
const encryptedUserKey = await this.cryptoService.rsaEncrypt(userKey.key, publicKey);
const resetRequest = new OrganizationUserResetPasswordEnrollmentRequest();
resetRequest.masterPasswordHash = masterKeyHash;
resetRequest.resetPasswordKey = encryptedUserKey.encryptedString;
await this.organizationUserService.putOrganizationUserResetPasswordEnrollment(
orgId,
userId,
resetRequest,
);
}
}

View File

@ -0,0 +1,22 @@
<ng-container *ngIf="syncLoading">
<i class="bwi bwi-spinner bwi-spin tw-mr-2" title="{{ 'loading' | i18n }}" aria-hidden="true"></i>
{{ "loading" | i18n }}
</ng-container>
<ng-container *ngIf="!syncLoading">
<app-callout
type="warning"
title="{{ 'resetPasswordPolicyAutoEnroll' | i18n }}"
*ngIf="resetPasswordAutoEnroll"
>
{{ "resetPasswordAutoEnrollInviteWarning" | i18n }}
</app-callout>
<auth-input-password
[buttonText]="'createAccount' | i18n"
[email]="email"
[loading]="submitting"
[masterPasswordPolicyOptions]="masterPasswordPolicyOptions"
(onPasswordFormSubmit)="handlePasswordFormSubmit($event)"
></auth-input-password>
</ng-container>

View File

@ -0,0 +1,126 @@
import { CommonModule } from "@angular/common";
import { Component, OnInit } from "@angular/core";
import { ActivatedRoute, Router } from "@angular/router";
import { firstValueFrom, map } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { UserId } from "@bitwarden/common/types/guid";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { ToastService } from "../../../../components/src/toast";
import { InputPasswordComponent } from "../input-password/input-password.component";
import { PasswordInputResult } from "../input-password/password-input-result";
import {
SetPasswordCredentials,
SetPasswordJitService,
} from "./set-password-jit.service.abstraction";
@Component({
standalone: true,
selector: "auth-set-password-jit",
templateUrl: "set-password-jit.component.html",
imports: [CommonModule, InputPasswordComponent, JslibModule],
})
export class SetPasswordJitComponent implements OnInit {
protected email: string;
protected masterPasswordPolicyOptions: MasterPasswordPolicyOptions;
protected orgId: string;
protected orgSsoIdentifier: string;
protected resetPasswordAutoEnroll: boolean;
protected submitting = false;
protected syncLoading = true;
protected userId: UserId;
constructor(
private accountService: AccountService,
private activatedRoute: ActivatedRoute,
private i18nService: I18nService,
private organizationApiService: OrganizationApiServiceAbstraction,
private policyApiService: PolicyApiServiceAbstraction,
private router: Router,
private setPasswordJitService: SetPasswordJitService,
private syncService: SyncService,
private toastService: ToastService,
private validationService: ValidationService,
) {}
async ngOnInit() {
this.email = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.email)),
);
await this.syncService.fullSync(true);
this.syncLoading = false;
await this.handleQueryParams();
}
private async handleQueryParams() {
const qParams = await firstValueFrom(this.activatedRoute.queryParams);
if (qParams.identifier != null) {
try {
this.orgSsoIdentifier = qParams.identifier;
const autoEnrollStatus = await this.organizationApiService.getAutoEnrollStatus(
this.orgSsoIdentifier,
);
this.orgId = autoEnrollStatus.id;
this.resetPasswordAutoEnroll = autoEnrollStatus.resetPasswordEnabled;
this.masterPasswordPolicyOptions =
await this.policyApiService.getMasterPasswordPolicyOptsForOrgUser(autoEnrollStatus.id);
} catch {
this.toastService.showToast({
variant: "error",
title: null,
message: this.i18nService.t("errorOccurred"),
});
}
}
}
protected async handlePasswordFormSubmit(passwordInputResult: PasswordInputResult) {
this.submitting = true;
const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
const credentials: SetPasswordCredentials = {
...passwordInputResult,
orgSsoIdentifier: this.orgSsoIdentifier,
orgId: this.orgId,
resetPasswordAutoEnroll: this.resetPasswordAutoEnroll,
userId,
};
try {
await this.setPasswordJitService.setPassword(credentials);
} catch (e) {
this.validationService.showError(e);
this.submitting = false;
return;
}
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t("accountSuccessfullyCreated"),
});
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t("inviteAccepted"),
});
this.submitting = false;
await this.router.navigate(["vault"]);
}
}

View File

@ -0,0 +1,33 @@
import { PBKDF2KdfConfig } from "@bitwarden/common/auth/models/domain/kdf-config";
import { UserId } from "@bitwarden/common/types/guid";
import { MasterKey } from "@bitwarden/common/types/key";
export interface SetPasswordCredentials {
masterKey: MasterKey;
masterKeyHash: string;
localMasterKeyHash: string;
kdfConfig: PBKDF2KdfConfig;
hint: string;
orgSsoIdentifier: string;
orgId: string;
resetPasswordAutoEnroll: boolean;
userId: UserId;
}
/**
* This service handles setting a password for a "just-in-time" provisioned user.
*
* A "just-in-time" (JIT) provisioned user is a user who does not have a registered account at the
* time they first click "Login with SSO". Once they click "Login with SSO" we register the account on
* the fly ("just-in-time").
*/
export abstract class SetPasswordJitService {
/**
* Sets the password for a JIT provisioned user.
*
* @param credentials An object of the credentials needed to set the password for a JIT provisioned user
* @throws If any property on the `credentials` object is null or undefined, or if a protectedUserKey
* or newKeyPair could not be created.
*/
setPassword: (credentials: SetPasswordCredentials) => Promise<void>;
}