diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index b6a8d1834b4..ce5787c46bd 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -2919,6 +2919,9 @@ "emailVerificationRequiredDesc": { "message": "You must verify your email to use this feature. You can verify your email in the web vault." }, + "masterPasswordSuccessfullySet": { + "message": "Master password successfully set" + }, "updatedMasterPassword": { "message": "Updated master password" }, diff --git a/apps/browser/src/popup/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts index e3574c4e142..f836f5ffac7 100644 --- a/apps/browser/src/popup/app-routing.module.ts +++ b/apps/browser/src/popup/app-routing.module.ts @@ -15,6 +15,8 @@ import { tdeDecryptionRequiredGuard, unauthGuardFn, } from "@bitwarden/angular/auth/guards"; +import { SetInitialPasswordComponent } from "@bitwarden/angular/auth/password-management/set-initial-password/set-initial-password.component"; +import { canAccessFeature } from "@bitwarden/angular/platform/guard/feature-flag.guard"; import { DevicesIcon, LoginComponent, @@ -38,6 +40,7 @@ import { UserLockIcon, VaultIcon, } from "@bitwarden/auth/angular"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { AnonLayoutWrapperComponent, AnonLayoutWrapperData, Icons } from "@bitwarden/components"; import { LockComponent } from "@bitwarden/key-management-ui"; @@ -376,6 +379,14 @@ const routes: Routes = [ }, ], }, + { + path: "set-initial-password", + canActivate: [canAccessFeature(FeatureFlag.PM16117_SetInitialPasswordRefactor), authGuard], + component: SetInitialPasswordComponent, + data: { + elevation: 1, + } satisfies RouteDataProperties, + }, { path: "login", canActivate: [unauthGuardFn(unauthRouteOverrides), IntroCarouselGuard], diff --git a/apps/desktop/src/app/app-routing.module.ts b/apps/desktop/src/app/app-routing.module.ts index d90f3cf0d26..42846878d03 100644 --- a/apps/desktop/src/app/app-routing.module.ts +++ b/apps/desktop/src/app/app-routing.module.ts @@ -14,6 +14,8 @@ import { tdeDecryptionRequiredGuard, unauthGuardFn, } from "@bitwarden/angular/auth/guards"; +import { SetInitialPasswordComponent } from "@bitwarden/angular/auth/password-management/set-initial-password/set-initial-password.component"; +import { canAccessFeature } from "@bitwarden/angular/platform/guard/feature-flag.guard"; import { featureFlaggedRoute } from "@bitwarden/angular/platform/utils/feature-flagged-route"; import { LoginComponent, @@ -315,6 +317,14 @@ const routes: Routes = [ }, } satisfies AnonLayoutWrapperData, }, + { + path: "set-initial-password", + canActivate: [canAccessFeature(FeatureFlag.PM16117_SetInitialPasswordRefactor), authGuard], + component: SetInitialPasswordComponent, + data: { + maxWidth: "lg", + } satisfies AnonLayoutWrapperData, + }, { path: "2fa", canActivate: [unauthGuardFn(), TwoFactorAuthGuard], diff --git a/apps/desktop/src/app/services/services.module.ts b/apps/desktop/src/app/services/services.module.ts index 06c42c5b0bc..0abd810bd18 100644 --- a/apps/desktop/src/app/services/services.module.ts +++ b/apps/desktop/src/app/services/services.module.ts @@ -5,6 +5,7 @@ import { Router } from "@angular/router"; import { Subject, merge } from "rxjs"; import { OrganizationUserApiService } from "@bitwarden/admin-console/common"; +import { SetInitialPasswordService } from "@bitwarden/angular/auth/password-management/set-initial-password/set-initial-password.service.abstraction"; import { SafeProvider, safeProvider } from "@bitwarden/angular/platform/utils/safe-provider"; import { SECURE_STORAGE, @@ -140,6 +141,7 @@ 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"; +import { DesktopSetInitialPasswordService } from "./set-initial-password/desktop-set-initial-password.service"; const RELOAD_CALLBACK = new SafeInjectionToken<() => any>("RELOAD_CALLBACK"); @@ -392,6 +394,23 @@ const safeProviders: SafeProvider[] = [ InternalUserDecryptionOptionsServiceAbstraction, ], }), + safeProvider({ + provide: SetInitialPasswordService, + useClass: DesktopSetInitialPasswordService, + deps: [ + ApiService, + EncryptService, + I18nServiceAbstraction, + KdfConfigService, + KeyService, + MasterPasswordApiService, + InternalMasterPasswordServiceAbstraction, + OrganizationApiServiceAbstraction, + OrganizationUserApiService, + InternalUserDecryptionOptionsServiceAbstraction, + MessagingServiceAbstraction, + ], + }), safeProvider({ provide: SsoUrlService, useClass: SsoUrlService, diff --git a/apps/desktop/src/app/services/set-initial-password/desktop-set-initial-password.service.spec.ts b/apps/desktop/src/app/services/set-initial-password/desktop-set-initial-password.service.spec.ts new file mode 100644 index 00000000000..02438300e94 --- /dev/null +++ b/apps/desktop/src/app/services/set-initial-password/desktop-set-initial-password.service.spec.ts @@ -0,0 +1,177 @@ +import { MockProxy, mock } from "jest-mock-extended"; +import { BehaviorSubject, of } from "rxjs"; + +import { OrganizationUserApiService } from "@bitwarden/admin-console/common"; +import { + SetInitialPasswordCredentials, + SetInitialPasswordService, + SetInitialPasswordUserType, +} from "@bitwarden/angular/auth/password-management/set-initial-password/set-initial-password.service.abstraction"; +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 { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction"; +import { SetPasswordRequest } from "@bitwarden/common/auth/models/request/set-password.request"; +import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; +import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; +import { KeysRequest } from "@bitwarden/common/models/request/keys.request"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.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 { UserId } from "@bitwarden/common/types/guid"; +import { MasterKey, UserKey } from "@bitwarden/common/types/key"; +import { DEFAULT_KDF_CONFIG, KdfConfigService, KeyService } from "@bitwarden/key-management"; + +import { DesktopSetInitialPasswordService } from "./desktop-set-initial-password.service"; + +describe("DesktopSetInitialPasswordService", () => { + let sut: SetInitialPasswordService; + + let apiService: MockProxy; + let encryptService: MockProxy; + let i18nService: MockProxy; + let kdfConfigService: MockProxy; + let keyService: MockProxy; + let masterPasswordApiService: MockProxy; + let masterPasswordService: MockProxy; + let organizationApiService: MockProxy; + let organizationUserApiService: MockProxy; + let userDecryptionOptionsService: MockProxy; + let messagingService: MockProxy; + + beforeEach(() => { + apiService = mock(); + encryptService = mock(); + i18nService = mock(); + kdfConfigService = mock(); + keyService = mock(); + masterPasswordApiService = mock(); + masterPasswordService = mock(); + organizationApiService = mock(); + organizationUserApiService = mock(); + userDecryptionOptionsService = mock(); + messagingService = mock(); + + sut = new DesktopSetInitialPasswordService( + apiService, + encryptService, + i18nService, + kdfConfigService, + keyService, + masterPasswordApiService, + masterPasswordService, + organizationApiService, + organizationUserApiService, + userDecryptionOptionsService, + messagingService, + ); + }); + + it("should instantiate", () => { + expect(sut).not.toBeFalsy(); + }); + + describe("setInitialPassword(...)", () => { + // Mock function parameters + let credentials: SetInitialPasswordCredentials; + let userType: SetInitialPasswordUserType; + let userId: UserId; + + // Mock other function data + let userKey: UserKey; + let userKeyEncString: EncString; + let masterKeyEncryptedUserKey: [UserKey, EncString]; + + let keyPair: [string, EncString]; + let keysRequest: KeysRequest; + + let userDecryptionOptions: UserDecryptionOptions; + let userDecryptionOptionsSubject: BehaviorSubject; + let setPasswordRequest: SetPasswordRequest; + + beforeEach(() => { + // Mock function parameters + credentials = { + newMasterKey: new SymmetricCryptoKey(new Uint8Array(32).buffer as CsprngArray) as MasterKey, + newServerMasterKeyHash: "newServerMasterKeyHash", + newLocalMasterKeyHash: "newLocalMasterKeyHash", + newPasswordHint: "newPasswordHint", + kdfConfig: DEFAULT_KDF_CONFIG, + orgSsoIdentifier: "orgSsoIdentifier", + orgId: "orgId", + resetPasswordAutoEnroll: false, + }; + userId = "userId" as UserId; + userType = SetInitialPasswordUserType.JIT_PROVISIONED_MP_ORG_USER; + + // Mock other function data + userKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as UserKey; + userKeyEncString = new EncString("masterKeyEncryptedUserKey"); + masterKeyEncryptedUserKey = [userKey, userKeyEncString]; + + keyPair = ["publicKey", new EncString("privateKey")]; + keysRequest = new KeysRequest(keyPair[0], keyPair[1].encryptedString); + + userDecryptionOptions = new UserDecryptionOptions({ hasMasterPassword: true }); + userDecryptionOptionsSubject = new BehaviorSubject(userDecryptionOptions); + userDecryptionOptionsService.userDecryptionOptions$ = userDecryptionOptionsSubject; + + setPasswordRequest = new SetPasswordRequest( + credentials.newServerMasterKeyHash, + masterKeyEncryptedUserKey[1].encryptedString, + credentials.newPasswordHint, + credentials.orgSsoIdentifier, + keysRequest, + credentials.kdfConfig.kdfType, + credentials.kdfConfig.iterations, + ); + }); + + function setupMocks() { + // Mock makeMasterKeyEncryptedUserKey() values + keyService.userKey$.mockReturnValue(of(userKey)); + keyService.encryptUserKeyWithMasterKey.mockResolvedValue(masterKeyEncryptedUserKey); + + // Mock keyPair values + keyService.userPrivateKey$.mockReturnValue(of(null)); + keyService.userPublicKey$.mockReturnValue(of(null)); + keyService.makeKeyPair.mockResolvedValue(keyPair); + } + + describe("given the initial password was successfully set", () => { + it("should send a 'redrawMenu' message", async () => { + // Arrange + setupMocks(); + + // Act + await sut.setInitialPassword(credentials, userType, userId); + + // Assert + expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(setPasswordRequest); + expect(messagingService.send).toHaveBeenCalledTimes(1); + expect(messagingService.send).toHaveBeenCalledWith("redrawMenu"); + }); + }); + + describe("given the initial password was NOT successfully set (due to some error in setInitialPassword())", () => { + it("should NOT send a 'redrawMenu' message", async () => { + // Arrange + credentials.newMasterKey = null; // will trigger an error in setInitialPassword() + setupMocks(); + + // Act + const promise = sut.setInitialPassword(credentials, userType, userId); + + // Assert + await expect(promise).rejects.toThrow(); + expect(masterPasswordApiService.setPassword).not.toHaveBeenCalled(); + expect(messagingService.send).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/apps/desktop/src/app/services/set-initial-password/desktop-set-initial-password.service.ts b/apps/desktop/src/app/services/set-initial-password/desktop-set-initial-password.service.ts new file mode 100644 index 00000000000..8de7e73fafe --- /dev/null +++ b/apps/desktop/src/app/services/set-initial-password/desktop-set-initial-password.service.ts @@ -0,0 +1,59 @@ +import { OrganizationUserApiService } from "@bitwarden/admin-console/common"; +import { DefaultSetInitialPasswordService } from "@bitwarden/angular/auth/password-management/set-initial-password/default-set-initial-password.service.implementation"; +import { + SetInitialPasswordCredentials, + SetInitialPasswordService, + SetInitialPasswordUserType, +} from "@bitwarden/angular/auth/password-management/set-initial-password/set-initial-password.service.abstraction"; +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 { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction"; +import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; +import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; +import { UserId } from "@bitwarden/common/types/guid"; +import { KdfConfigService, KeyService } from "@bitwarden/key-management"; + +export class DesktopSetInitialPasswordService + extends DefaultSetInitialPasswordService + implements SetInitialPasswordService +{ + constructor( + protected apiService: ApiService, + protected encryptService: EncryptService, + protected i18nService: I18nService, + protected kdfConfigService: KdfConfigService, + protected keyService: KeyService, + protected masterPasswordApiService: MasterPasswordApiService, + protected masterPasswordService: InternalMasterPasswordServiceAbstraction, + protected organizationApiService: OrganizationApiServiceAbstraction, + protected organizationUserApiService: OrganizationUserApiService, + protected userDecryptionOptionsService: InternalUserDecryptionOptionsServiceAbstraction, + private messagingService: MessagingService, + ) { + super( + apiService, + encryptService, + i18nService, + kdfConfigService, + keyService, + masterPasswordApiService, + masterPasswordService, + organizationApiService, + organizationUserApiService, + userDecryptionOptionsService, + ); + } + + override async setInitialPassword( + credentials: SetInitialPasswordCredentials, + userType: SetInitialPasswordUserType, + userId: UserId, + ) { + await super.setInitialPassword(credentials, userType, userId); + + this.messagingService.send("redrawMenu"); + } +} diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index f67de2d51d7..ac9307c482c 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -2392,6 +2392,9 @@ "passwordConfirmationDesc": { "message": "This action is protected. To continue, please re-enter your master password to verify your identity." }, + "masterPasswordSuccessfullySet": { + "message": "Master password successfully set" + }, "updatedMasterPassword": { "message": "Updated master password" }, diff --git a/apps/web/src/app/auth/core/services/index.ts b/apps/web/src/app/auth/core/services/index.ts index 5539e3b76ea..8c556986225 100644 --- a/apps/web/src/app/auth/core/services/index.ts +++ b/apps/web/src/app/auth/core/services/index.ts @@ -2,6 +2,7 @@ export * from "./change-password"; export * from "./login"; export * from "./login-decryption-options"; export * from "./webauthn-login"; +export * from "./password-management"; export * from "./set-password-jit"; export * from "./registration"; export * from "./two-factor-auth"; diff --git a/apps/web/src/app/auth/core/services/password-management/index.ts b/apps/web/src/app/auth/core/services/password-management/index.ts new file mode 100644 index 00000000000..1444fd024af --- /dev/null +++ b/apps/web/src/app/auth/core/services/password-management/index.ts @@ -0,0 +1 @@ +export * from "./set-initial-password/web-set-initial-password.service"; diff --git a/apps/web/src/app/auth/core/services/password-management/set-initial-password/web-set-initial-password.service.spec.ts b/apps/web/src/app/auth/core/services/password-management/set-initial-password/web-set-initial-password.service.spec.ts new file mode 100644 index 00000000000..b90d0624b3f --- /dev/null +++ b/apps/web/src/app/auth/core/services/password-management/set-initial-password/web-set-initial-password.service.spec.ts @@ -0,0 +1,208 @@ +import { MockProxy, mock } from "jest-mock-extended"; +import { BehaviorSubject, of } from "rxjs"; + +import { OrganizationUserApiService } from "@bitwarden/admin-console/common"; +import { + SetInitialPasswordCredentials, + SetInitialPasswordService, + SetInitialPasswordUserType, +} from "@bitwarden/angular/auth/password-management/set-initial-password/set-initial-password.service.abstraction"; +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 { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction"; +import { SetPasswordRequest } from "@bitwarden/common/auth/models/request/set-password.request"; +import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; +import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; +import { KeysRequest } from "@bitwarden/common/models/request/keys.request"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.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 { UserId } from "@bitwarden/common/types/guid"; +import { MasterKey, UserKey } from "@bitwarden/common/types/key"; +import { DEFAULT_KDF_CONFIG, KdfConfigService, KeyService } from "@bitwarden/key-management"; +import { AcceptOrganizationInviteService } from "@bitwarden/web-vault/app/auth/organization-invite/accept-organization.service"; +import { RouterService } from "@bitwarden/web-vault/app/core"; + +import { WebSetInitialPasswordService } from "./web-set-initial-password.service"; + +describe("WebSetInitialPasswordService", () => { + let sut: SetInitialPasswordService; + + let apiService: MockProxy; + let encryptService: MockProxy; + let i18nService: MockProxy; + let kdfConfigService: MockProxy; + let keyService: MockProxy; + let masterPasswordApiService: MockProxy; + let masterPasswordService: MockProxy; + let organizationApiService: MockProxy; + let organizationUserApiService: MockProxy; + let userDecryptionOptionsService: MockProxy; + let acceptOrganizationInviteService: MockProxy; + let routerService: MockProxy; + + beforeEach(() => { + apiService = mock(); + encryptService = mock(); + i18nService = mock(); + kdfConfigService = mock(); + keyService = mock(); + masterPasswordApiService = mock(); + masterPasswordService = mock(); + organizationApiService = mock(); + organizationUserApiService = mock(); + userDecryptionOptionsService = mock(); + acceptOrganizationInviteService = mock(); + routerService = mock(); + + sut = new WebSetInitialPasswordService( + apiService, + encryptService, + i18nService, + kdfConfigService, + keyService, + masterPasswordApiService, + masterPasswordService, + organizationApiService, + organizationUserApiService, + userDecryptionOptionsService, + acceptOrganizationInviteService, + routerService, + ); + }); + + it("should instantiate", () => { + expect(sut).not.toBeFalsy(); + }); + + describe("setInitialPassword(...)", () => { + // Mock function parameters + let credentials: SetInitialPasswordCredentials; + let userType: SetInitialPasswordUserType; + let userId: UserId; + + // Mock other function data + let userKey: UserKey; + let userKeyEncString: EncString; + let masterKeyEncryptedUserKey: [UserKey, EncString]; + + let keyPair: [string, EncString]; + let keysRequest: KeysRequest; + + let userDecryptionOptions: UserDecryptionOptions; + let userDecryptionOptionsSubject: BehaviorSubject; + let setPasswordRequest: SetPasswordRequest; + + beforeEach(() => { + // Mock function parameters + credentials = { + newMasterKey: new SymmetricCryptoKey(new Uint8Array(32).buffer as CsprngArray) as MasterKey, + newServerMasterKeyHash: "newServerMasterKeyHash", + newLocalMasterKeyHash: "newLocalMasterKeyHash", + newPasswordHint: "newPasswordHint", + kdfConfig: DEFAULT_KDF_CONFIG, + orgSsoIdentifier: "orgSsoIdentifier", + orgId: "orgId", + resetPasswordAutoEnroll: false, + }; + userId = "userId" as UserId; + userType = SetInitialPasswordUserType.JIT_PROVISIONED_MP_ORG_USER; + + // Mock other function data + userKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as UserKey; + userKeyEncString = new EncString("masterKeyEncryptedUserKey"); + masterKeyEncryptedUserKey = [userKey, userKeyEncString]; + + keyPair = ["publicKey", new EncString("privateKey")]; + keysRequest = new KeysRequest(keyPair[0], keyPair[1].encryptedString); + + userDecryptionOptions = new UserDecryptionOptions({ hasMasterPassword: true }); + userDecryptionOptionsSubject = new BehaviorSubject(userDecryptionOptions); + userDecryptionOptionsService.userDecryptionOptions$ = userDecryptionOptionsSubject; + + setPasswordRequest = new SetPasswordRequest( + credentials.newServerMasterKeyHash, + masterKeyEncryptedUserKey[1].encryptedString, + credentials.newPasswordHint, + credentials.orgSsoIdentifier, + keysRequest, + credentials.kdfConfig.kdfType, + credentials.kdfConfig.iterations, + ); + }); + + function setupMocks() { + // Mock makeMasterKeyEncryptedUserKey() values + keyService.userKey$.mockReturnValue(of(userKey)); + keyService.encryptUserKeyWithMasterKey.mockResolvedValue(masterKeyEncryptedUserKey); + + // Mock keyPair values + keyService.userPrivateKey$.mockReturnValue(of(null)); + keyService.userPublicKey$.mockReturnValue(of(null)); + keyService.makeKeyPair.mockResolvedValue(keyPair); + } + + describe("given the initial password was successfully set", () => { + it("should call routerService.getAndClearLoginRedirectUrl()", async () => { + // Arrange + setupMocks(); + + // Act + await sut.setInitialPassword(credentials, userType, userId); + + // Assert + expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(setPasswordRequest); + expect(routerService.getAndClearLoginRedirectUrl).toHaveBeenCalledTimes(1); + }); + + it("should call acceptOrganizationInviteService.clearOrganizationInvitation()", async () => { + // Arrange + setupMocks(); + + // Act + await sut.setInitialPassword(credentials, userType, userId); + + // Assert + expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(setPasswordRequest); + expect(acceptOrganizationInviteService.clearOrganizationInvitation).toHaveBeenCalledTimes( + 1, + ); + }); + }); + + describe("given the initial password was NOT successfully set (due to some error in setInitialPassword())", () => { + it("should NOT call routerService.getAndClearLoginRedirectUrl()", async () => { + // Arrange + credentials.newMasterKey = null; // will trigger an error in setInitialPassword() + setupMocks(); + + // Act + const promise = sut.setInitialPassword(credentials, userType, userId); + + // Assert + await expect(promise).rejects.toThrow(); + expect(masterPasswordApiService.setPassword).not.toHaveBeenCalled(); + expect(routerService.getAndClearLoginRedirectUrl).not.toHaveBeenCalled(); + }); + + it("should NOT call acceptOrganizationInviteService.clearOrganizationInvitation()", async () => { + // Arrange + credentials.newMasterKey = null; // will trigger an error in setInitialPassword() + setupMocks(); + + // Act + const promise = sut.setInitialPassword(credentials, userType, userId); + + // Assert + await expect(promise).rejects.toThrow(); + expect(masterPasswordApiService.setPassword).not.toHaveBeenCalled(); + expect(acceptOrganizationInviteService.clearOrganizationInvitation).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/apps/web/src/app/auth/core/services/password-management/set-initial-password/web-set-initial-password.service.ts b/apps/web/src/app/auth/core/services/password-management/set-initial-password/web-set-initial-password.service.ts new file mode 100644 index 00000000000..41e7e8ad4ab --- /dev/null +++ b/apps/web/src/app/auth/core/services/password-management/set-initial-password/web-set-initial-password.service.ts @@ -0,0 +1,83 @@ +import { OrganizationUserApiService } from "@bitwarden/admin-console/common"; +import { DefaultSetInitialPasswordService } from "@bitwarden/angular/auth/password-management/set-initial-password/default-set-initial-password.service.implementation"; +import { + SetInitialPasswordCredentials, + SetInitialPasswordService, + SetInitialPasswordUserType, +} from "@bitwarden/angular/auth/password-management/set-initial-password/set-initial-password.service.abstraction"; +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 { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction"; +import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; +import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { UserId } from "@bitwarden/common/types/guid"; +import { KdfConfigService, KeyService } from "@bitwarden/key-management"; +import { AcceptOrganizationInviteService } from "@bitwarden/web-vault/app/auth/organization-invite/accept-organization.service"; +import { RouterService } from "@bitwarden/web-vault/app/core"; + +export class WebSetInitialPasswordService + extends DefaultSetInitialPasswordService + implements SetInitialPasswordService +{ + constructor( + protected apiService: ApiService, + protected encryptService: EncryptService, + protected i18nService: I18nService, + protected kdfConfigService: KdfConfigService, + protected keyService: KeyService, + protected masterPasswordApiService: MasterPasswordApiService, + protected masterPasswordService: InternalMasterPasswordServiceAbstraction, + protected organizationApiService: OrganizationApiServiceAbstraction, + protected organizationUserApiService: OrganizationUserApiService, + protected userDecryptionOptionsService: InternalUserDecryptionOptionsServiceAbstraction, + private acceptOrganizationInviteService: AcceptOrganizationInviteService, + private routerService: RouterService, + ) { + super( + apiService, + encryptService, + i18nService, + kdfConfigService, + keyService, + masterPasswordApiService, + masterPasswordService, + organizationApiService, + organizationUserApiService, + userDecryptionOptionsService, + ); + } + + override async setInitialPassword( + credentials: SetInitialPasswordCredentials, + userType: SetInitialPasswordUserType, + userId: UserId, + ) { + await super.setInitialPassword(credentials, userType, userId); + + /** + * TODO: Investigate refactoring the following logic in https://bitwarden.atlassian.net/browse/PM-22615 + * --- + * When a user has been invited to an org, they can be accepted into the org in two different ways: + * + * 1) By clicking the email invite link, which triggers the normal AcceptOrganizationComponent flow + * a. This flow sets an org invite in state + * b. However, if the user does not already have an account AND the org has SSO enabled AND the require + * SSO policy enabled, the AcceptOrganizationComponent will send the user to /sso to accelerate + * the user through the SSO JIT provisioning process (see #2 below) + * + * 2) By logging in via SSO, which triggers the JIT provisioning process + * a. This flow does NOT (itself) set an org invite in state + * b. The set initial password process on the server accepts the user into the org after successfully + * setting the password (see server - SetInitialMasterPasswordCommand.cs) + * + * If a user clicks the email link but gets accelerated through the SSO JIT process (see 1b), + * the SSO JIT process will accept the user into the org upon setting their initial password (see 2b), + * at which point we must remember to clear the deep linked URL used for accepting the org invite, as well + * as clear the org invite itself that was originally set in state by the AcceptOrganizationComponent. + */ + await this.routerService.getAndClearLoginRedirectUrl(); + await this.acceptOrganizationInviteService.clearOrganizationInvitation(); + } +} diff --git a/apps/web/src/app/core/core.module.ts b/apps/web/src/app/core/core.module.ts index 46435981a5e..b6a6ca102d8 100644 --- a/apps/web/src/app/core/core.module.ts +++ b/apps/web/src/app/core/core.module.ts @@ -10,6 +10,7 @@ import { OrganizationUserApiService, CollectionService, } from "@bitwarden/admin-console/common"; +import { SetInitialPasswordService } from "@bitwarden/angular/auth/password-management/set-initial-password/set-initial-password.service.abstraction"; import { SafeProvider, safeProvider } from "@bitwarden/angular/platform/utils/safe-provider"; import { CLIENT_TYPE, @@ -117,6 +118,7 @@ import { WebLoginDecryptionOptionsService, WebTwoFactorAuthDuoComponentService, LinkSsoService, + WebSetInitialPasswordService, } from "../auth"; import { WebSsoComponentService } from "../auth/core/services/login/web-sso-component.service"; import { AcceptOrganizationInviteService } from "../auth/organization-invite/accept-organization.service"; @@ -283,6 +285,24 @@ const safeProviders: SafeProvider[] = [ InternalUserDecryptionOptionsServiceAbstraction, ], }), + safeProvider({ + provide: SetInitialPasswordService, + useClass: WebSetInitialPasswordService, + deps: [ + ApiService, + EncryptService, + I18nServiceAbstraction, + KdfConfigService, + KeyServiceAbstraction, + MasterPasswordApiService, + InternalMasterPasswordServiceAbstraction, + OrganizationApiServiceAbstraction, + OrganizationUserApiService, + InternalUserDecryptionOptionsServiceAbstraction, + AcceptOrganizationInviteService, + RouterService, + ], + }), safeProvider({ provide: AppIdService, useClass: DefaultAppIdService, diff --git a/apps/web/src/app/oss-routing.module.ts b/apps/web/src/app/oss-routing.module.ts index 0733d1ef289..615bb545811 100644 --- a/apps/web/src/app/oss-routing.module.ts +++ b/apps/web/src/app/oss-routing.module.ts @@ -10,6 +10,8 @@ import { unauthGuardFn, activeAuthGuard, } from "@bitwarden/angular/auth/guards"; +import { SetInitialPasswordComponent } from "@bitwarden/angular/auth/password-management/set-initial-password/set-initial-password.component"; +import { canAccessFeature } from "@bitwarden/angular/platform/guard/feature-flag.guard"; import { PasswordHintComponent, RegistrationFinishComponent, @@ -36,6 +38,7 @@ import { NewDeviceVerificationComponent, DeviceVerificationIcon, } from "@bitwarden/auth/angular"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { AnonLayoutWrapperComponent, AnonLayoutWrapperData, Icons } from "@bitwarden/components"; import { LockComponent } from "@bitwarden/key-management-ui"; import { VaultIcons } from "@bitwarden/vault"; @@ -305,6 +308,14 @@ const routes: Routes = [ }, ], }, + { + path: "set-initial-password", + canActivate: [canAccessFeature(FeatureFlag.PM16117_SetInitialPasswordRefactor), authGuard], + component: SetInitialPasswordComponent, + data: { + maxWidth: "lg", + } satisfies AnonLayoutWrapperData, + }, { path: "set-password-jit", component: SetPasswordJitComponent, diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 5c9b02e5287..eed2757eacc 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -6065,6 +6065,9 @@ "add": { "message": "Add" }, + "masterPasswordSuccessfullySet": { + "message": "Master password successfully set" + }, "updatedMasterPassword": { "message": "Master password saved" }, diff --git a/libs/angular/src/auth/guards/auth.guard.ts b/libs/angular/src/auth/guards/auth.guard.ts index f99a91fda34..7b8c21fef62 100644 --- a/libs/angular/src/auth/guards/auth.guard.ts +++ b/libs/angular/src/auth/guards/auth.guard.ts @@ -39,7 +39,32 @@ export const authGuard: CanActivateFn = async ( return false; } - if (authStatus === AuthenticationStatus.Locked) { + const userId = (await firstValueFrom(accountService.activeAccount$)).id; + const forceSetPasswordReason = await firstValueFrom( + masterPasswordService.forceSetPasswordReason$(userId), + ); + + const isSetInitialPasswordFlagOn = await configService.getFeatureFlag( + FeatureFlag.PM16117_SetInitialPasswordRefactor, + ); + const isChangePasswordFlagOn = await configService.getFeatureFlag( + FeatureFlag.PM16117_ChangeExistingPasswordRefactor, + ); + + // User JIT provisioned into a master-password-encryption org + if ( + authStatus === AuthenticationStatus.Locked && + forceSetPasswordReason === ForceSetPasswordReason.SsoNewJitProvisionedUser && + !routerState.url.includes("set-initial-password") && + isSetInitialPasswordFlagOn + ) { + return router.createUrlTree(["/set-initial-password"]); + } + + if ( + authStatus === AuthenticationStatus.Locked && + forceSetPasswordReason !== ForceSetPasswordReason.SsoNewJitProvisionedUser + ) { if (routerState != null) { messagingService.send("lockedUrl", { url: routerState.url }); } @@ -55,18 +80,6 @@ export const authGuard: CanActivateFn = async ( return router.createUrlTree(["/remove-password"]); } - const userId = (await firstValueFrom(accountService.activeAccount$)).id; - const forceSetPasswordReason = await firstValueFrom( - masterPasswordService.forceSetPasswordReason$(userId), - ); - - const isSetInitialPasswordFlagOn = await configService.getFeatureFlag( - FeatureFlag.PM16117_SetInitialPasswordRefactor, - ); - const isChangePasswordFlagOn = await configService.getFeatureFlag( - FeatureFlag.PM16117_ChangeExistingPasswordRefactor, - ); - // TDE org user has "manage account recovery" permission if ( forceSetPasswordReason === diff --git a/libs/angular/src/auth/password-management/set-initial-password/default-set-initial-password.service.implementation.ts b/libs/angular/src/auth/password-management/set-initial-password/default-set-initial-password.service.implementation.ts new file mode 100644 index 00000000000..1c5edb00c8c --- /dev/null +++ b/libs/angular/src/auth/password-management/set-initial-password/default-set-initial-password.service.implementation.ts @@ -0,0 +1,248 @@ +import { firstValueFrom } from "rxjs"; + +// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. +// eslint-disable-next-line no-restricted-imports +import { + OrganizationUserApiService, + OrganizationUserResetPasswordEnrollmentRequest, +} from "@bitwarden/admin-console/common"; +// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. +// eslint-disable-next-line no-restricted-imports +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 { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction"; +import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; +import { SetPasswordRequest } from "@bitwarden/common/auth/models/request/set-password.request"; +import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; +import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; +import { KeysRequest } from "@bitwarden/common/models/request/keys.request"; +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 { KdfConfigService, KeyService, KdfConfig } from "@bitwarden/key-management"; + +import { + SetInitialPasswordService, + SetInitialPasswordCredentials, + SetInitialPasswordUserType, +} from "./set-initial-password.service.abstraction"; + +export class DefaultSetInitialPasswordService implements SetInitialPasswordService { + constructor( + protected apiService: ApiService, + protected encryptService: EncryptService, + protected i18nService: I18nService, + protected kdfConfigService: KdfConfigService, + protected keyService: KeyService, + protected masterPasswordApiService: MasterPasswordApiService, + protected masterPasswordService: InternalMasterPasswordServiceAbstraction, + protected organizationApiService: OrganizationApiServiceAbstraction, + protected organizationUserApiService: OrganizationUserApiService, + protected userDecryptionOptionsService: InternalUserDecryptionOptionsServiceAbstraction, + ) {} + + async setInitialPassword( + credentials: SetInitialPasswordCredentials, + userType: SetInitialPasswordUserType, + userId: UserId, + ): Promise { + const { + newMasterKey, + newServerMasterKeyHash, + newLocalMasterKeyHash, + newPasswordHint, + kdfConfig, + orgSsoIdentifier, + orgId, + resetPasswordAutoEnroll, + } = credentials; + + for (const [key, value] of Object.entries(credentials)) { + if (value == null) { + throw new Error(`${key} not found. Could not set password.`); + } + } + if (userId == null) { + throw new Error("userId not found. Could not set password."); + } + if (userType == null) { + throw new Error("userType not found. Could not set password."); + } + + const masterKeyEncryptedUserKey = await this.makeMasterKeyEncryptedUserKey( + newMasterKey, + userId, + ); + if (masterKeyEncryptedUserKey == null || !masterKeyEncryptedUserKey[1].encryptedString) { + throw new Error("masterKeyEncryptedUserKey not found. Could not set password."); + } + + let keyPair: [string, EncString] | null = null; + let keysRequest: KeysRequest | null = null; + + if (userType === SetInitialPasswordUserType.JIT_PROVISIONED_MP_ORG_USER) { + /** + * A user being JIT provisioned into a MP encryption org does not yet have a user + * asymmetric key pair, so we create it for them here. + * + * Sidenote: + * In the case of a TDE user whose permissions require that they have a MP - that user + * will already have a user asymmetric key pair by this point, so we skip this if-block + * so that we don't create a new key pair for them. + */ + + // Extra safety check (see description on https://github.com/bitwarden/clients/pull/10180): + // In case we have have a local private key and are not sure whether it has been posted to the server, + // we post the local private key instead of generating a new one + const existingUserPrivateKey = (await firstValueFrom( + this.keyService.userPrivateKey$(userId), + )) as Uint8Array; + + const existingUserPublicKey = await firstValueFrom(this.keyService.userPublicKey$(userId)); + + if (existingUserPrivateKey != null && existingUserPublicKey != null) { + const existingUserPublicKeyB64 = Utils.fromBufferToB64(existingUserPublicKey); + + // Existing key pair + keyPair = [ + existingUserPublicKeyB64, + await this.encryptService.wrapDecapsulationKey( + existingUserPrivateKey, + masterKeyEncryptedUserKey[0], + ), + ]; + } else { + // New key pair + keyPair = await this.keyService.makeKeyPair(masterKeyEncryptedUserKey[0]); + } + + if (keyPair == null) { + throw new Error("keyPair not found. Could not set password."); + } + if (!keyPair[1].encryptedString) { + throw new Error("encrypted private key not found. Could not set password."); + } + + keysRequest = new KeysRequest(keyPair[0], keyPair[1].encryptedString); + } + + const request = new SetPasswordRequest( + newServerMasterKeyHash, + masterKeyEncryptedUserKey[1].encryptedString, + newPasswordHint, + orgSsoIdentifier, + keysRequest, + kdfConfig.kdfType, + kdfConfig.iterations, + ); + + await this.masterPasswordApiService.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( + newMasterKey, + kdfConfig, + masterKeyEncryptedUserKey, + userId, + ); + + /** + * Set the private key only for new JIT provisioned users in MP encryption orgs. + * (Existing TDE users will have their private key set on sync or on login.) + */ + if (keyPair != null && userType === SetInitialPasswordUserType.JIT_PROVISIONED_MP_ORG_USER) { + if (!keyPair[1].encryptedString) { + throw new Error("encrypted private key not found. Could not set private key in state."); + } + await this.keyService.setPrivateKey(keyPair[1].encryptedString, userId); + } + + await this.masterPasswordService.setMasterKeyHash(newLocalMasterKeyHash, userId); + + if (resetPasswordAutoEnroll) { + await this.handleResetPasswordAutoEnroll(newServerMasterKeyHash, orgId, userId); + } + } + + private async makeMasterKeyEncryptedUserKey( + masterKey: MasterKey, + userId: UserId, + ): Promise<[UserKey, EncString]> { + let masterKeyEncryptedUserKey: [UserKey, EncString] | null = null; + + const userKey = await firstValueFrom(this.keyService.userKey$(userId)); + + if (userKey == null) { + masterKeyEncryptedUserKey = await this.keyService.makeUserKey(masterKey); + } else { + masterKeyEncryptedUserKey = await this.keyService.encryptUserKeyWithMasterKey(masterKey); + } + + return masterKeyEncryptedUserKey; + } + + private async updateAccountDecryptionProperties( + masterKey: MasterKey, + kdfConfig: KdfConfig, + masterKeyEncryptedUserKey: [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.keyService.setUserKey(masterKeyEncryptedUserKey[0], userId); + } + + private async handleResetPasswordAutoEnroll( + masterKeyHash: string, + orgId: string, + userId: UserId, + ) { + const organizationKeys = await this.organizationApiService.getKeys(orgId); + + if (organizationKeys == null) { + throw new Error( + "Organization keys response is null. Could not handle reset password auto enroll.", + ); + } + + const orgPublicKey = Utils.fromB64ToArray(organizationKeys.publicKey); + const userKey = await firstValueFrom(this.keyService.userKey$(userId)); + + if (userKey == null) { + throw new Error("userKey not found. Could not handle reset password auto enroll."); + } + + // RSA encrypt user key with organization public key + const orgPublicKeyEncryptedUserKey = await this.encryptService.encapsulateKeyUnsigned( + userKey, + orgPublicKey, + ); + + if (orgPublicKeyEncryptedUserKey == null || !orgPublicKeyEncryptedUserKey.encryptedString) { + throw new Error( + "orgPublicKeyEncryptedUserKey not found. Could not handle reset password auto enroll.", + ); + } + + const enrollmentRequest = new OrganizationUserResetPasswordEnrollmentRequest(); + enrollmentRequest.masterPasswordHash = masterKeyHash; + enrollmentRequest.resetPasswordKey = orgPublicKeyEncryptedUserKey.encryptedString; + + await this.organizationUserApiService.putOrganizationUserResetPasswordEnrollment( + orgId, + userId, + enrollmentRequest, + ); + } +} diff --git a/libs/angular/src/auth/password-management/set-initial-password/default-set-initial-password.service.spec.ts b/libs/angular/src/auth/password-management/set-initial-password/default-set-initial-password.service.spec.ts new file mode 100644 index 00000000000..ca4d9adbd67 --- /dev/null +++ b/libs/angular/src/auth/password-management/set-initial-password/default-set-initial-password.service.spec.ts @@ -0,0 +1,633 @@ +import { MockProxy, mock } from "jest-mock-extended"; +import { BehaviorSubject, of } from "rxjs"; + +// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. +// eslint-disable-next-line no-restricted-imports +import { + OrganizationUserApiService, + OrganizationUserResetPasswordEnrollmentRequest, +} from "@bitwarden/admin-console/common"; +// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. +// eslint-disable-next-line no-restricted-imports +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 { OrganizationKeysResponse } from "@bitwarden/common/admin-console/models/response/organization-keys.response"; +import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction"; +import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; +import { SetPasswordRequest } from "@bitwarden/common/auth/models/request/set-password.request"; +import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; +import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; +import { KeysRequest } from "@bitwarden/common/models/request/keys.request"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { EncryptedString, 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, UserPrivateKey, UserPublicKey } from "@bitwarden/common/types/key"; +import { DEFAULT_KDF_CONFIG, KdfConfigService, KeyService } from "@bitwarden/key-management"; + +import { DefaultSetInitialPasswordService } from "./default-set-initial-password.service.implementation"; +import { + SetInitialPasswordCredentials, + SetInitialPasswordService, + SetInitialPasswordUserType, +} from "./set-initial-password.service.abstraction"; + +describe("DefaultSetInitialPasswordService", () => { + let sut: SetInitialPasswordService; + + let apiService: MockProxy; + let encryptService: MockProxy; + let i18nService: MockProxy; + let kdfConfigService: MockProxy; + let keyService: MockProxy; + let masterPasswordApiService: MockProxy; + let masterPasswordService: MockProxy; + let organizationApiService: MockProxy; + let organizationUserApiService: MockProxy; + let userDecryptionOptionsService: MockProxy; + + beforeEach(() => { + apiService = mock(); + encryptService = mock(); + i18nService = mock(); + kdfConfigService = mock(); + keyService = mock(); + masterPasswordApiService = mock(); + masterPasswordService = mock(); + organizationApiService = mock(); + organizationUserApiService = mock(); + userDecryptionOptionsService = mock(); + + sut = new DefaultSetInitialPasswordService( + apiService, + encryptService, + i18nService, + kdfConfigService, + keyService, + masterPasswordApiService, + masterPasswordService, + organizationApiService, + organizationUserApiService, + userDecryptionOptionsService, + ); + }); + + it("should instantiate", () => { + expect(sut).not.toBeFalsy(); + }); + + describe("setInitialPassword(...)", () => { + // Mock function parameters + let credentials: SetInitialPasswordCredentials; + let userType: SetInitialPasswordUserType; + let userId: UserId; + + // Mock other function data + let userKey: UserKey; + let userKeyEncString: EncString; + let masterKeyEncryptedUserKey: [UserKey, EncString]; + + let existingUserPublicKey: UserPublicKey; + let existingUserPrivateKey: UserPrivateKey; + let userKeyEncryptedPrivateKey: EncString; + + let keyPair: [string, EncString]; + let keysRequest: KeysRequest; + + let organizationKeys: OrganizationKeysResponse; + let orgPublicKeyEncryptedUserKey: EncString; + + let userDecryptionOptions: UserDecryptionOptions; + let userDecryptionOptionsSubject: BehaviorSubject; + let setPasswordRequest: SetPasswordRequest; + + let enrollmentRequest: OrganizationUserResetPasswordEnrollmentRequest; + + beforeEach(() => { + // Mock function parameters + credentials = { + newMasterKey: new SymmetricCryptoKey(new Uint8Array(32).buffer as CsprngArray) as MasterKey, + newServerMasterKeyHash: "newServerMasterKeyHash", + newLocalMasterKeyHash: "newLocalMasterKeyHash", + newPasswordHint: "newPasswordHint", + kdfConfig: DEFAULT_KDF_CONFIG, + orgSsoIdentifier: "orgSsoIdentifier", + orgId: "orgId", + resetPasswordAutoEnroll: false, + }; + userId = "userId" as UserId; + userType = SetInitialPasswordUserType.JIT_PROVISIONED_MP_ORG_USER; + + // Mock other function data + userKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as UserKey; + userKeyEncString = new EncString("masterKeyEncryptedUserKey"); + masterKeyEncryptedUserKey = [userKey, userKeyEncString]; + + existingUserPublicKey = Utils.fromB64ToArray("existingUserPublicKey") as UserPublicKey; + existingUserPrivateKey = Utils.fromB64ToArray("existingUserPrivateKey") as UserPrivateKey; + userKeyEncryptedPrivateKey = new EncString("userKeyEncryptedPrivateKey"); + + keyPair = ["publicKey", new EncString("privateKey")]; + keysRequest = new KeysRequest(keyPair[0], keyPair[1].encryptedString); + + organizationKeys = { + privateKey: "orgPrivateKey", + publicKey: "orgPublicKey", + } as OrganizationKeysResponse; + orgPublicKeyEncryptedUserKey = new EncString("orgPublicKeyEncryptedUserKey"); + + userDecryptionOptions = new UserDecryptionOptions({ hasMasterPassword: true }); + userDecryptionOptionsSubject = new BehaviorSubject(userDecryptionOptions); + userDecryptionOptionsService.userDecryptionOptions$ = userDecryptionOptionsSubject; + + setPasswordRequest = new SetPasswordRequest( + credentials.newServerMasterKeyHash, + masterKeyEncryptedUserKey[1].encryptedString, + credentials.newPasswordHint, + credentials.orgSsoIdentifier, + keysRequest, + credentials.kdfConfig.kdfType, + credentials.kdfConfig.iterations, + ); + + enrollmentRequest = new OrganizationUserResetPasswordEnrollmentRequest(); + enrollmentRequest.masterPasswordHash = credentials.newServerMasterKeyHash; + enrollmentRequest.resetPasswordKey = orgPublicKeyEncryptedUserKey.encryptedString; + }); + + interface MockConfig { + userType: SetInitialPasswordUserType; + userHasUserKey: boolean; + userHasLocalKeyPair: boolean; + resetPasswordAutoEnroll: boolean; + } + + const defaultMockConfig: MockConfig = { + userType: SetInitialPasswordUserType.JIT_PROVISIONED_MP_ORG_USER, + userHasUserKey: true, + userHasLocalKeyPair: false, + resetPasswordAutoEnroll: false, + }; + + function setupMocks(config: MockConfig = defaultMockConfig) { + // Mock makeMasterKeyEncryptedUserKey() values + if (config.userHasUserKey) { + keyService.userKey$.mockReturnValue(of(userKey)); + keyService.encryptUserKeyWithMasterKey.mockResolvedValue(masterKeyEncryptedUserKey); + } else { + keyService.userKey$.mockReturnValue(of(null)); + keyService.makeUserKey.mockResolvedValue(masterKeyEncryptedUserKey); + } + + // Mock keyPair values + if (config.userType === SetInitialPasswordUserType.JIT_PROVISIONED_MP_ORG_USER) { + if (config.userHasLocalKeyPair) { + keyService.userPrivateKey$.mockReturnValue(of(existingUserPrivateKey)); + keyService.userPublicKey$.mockReturnValue(of(existingUserPublicKey)); + encryptService.wrapDecapsulationKey.mockResolvedValue(userKeyEncryptedPrivateKey); + } else { + keyService.userPrivateKey$.mockReturnValue(of(null)); + keyService.userPublicKey$.mockReturnValue(of(null)); + keyService.makeKeyPair.mockResolvedValue(keyPair); + } + } + + // Mock handleResetPasswordAutoEnroll() values + if (config.resetPasswordAutoEnroll) { + organizationApiService.getKeys.mockResolvedValue(organizationKeys); + encryptService.encapsulateKeyUnsigned.mockResolvedValue(orgPublicKeyEncryptedUserKey); + keyService.userKey$.mockReturnValue(of(userKey)); + } + } + + describe("general error handling", () => { + [ + "newMasterKey", + "newServerMasterKeyHash", + "newLocalMasterKeyHash", + "newPasswordHint", + "kdfConfig", + "orgSsoIdentifier", + "orgId", + "resetPasswordAutoEnroll", + ].forEach((key) => { + it(`should throw if ${key} is not provided on the SetInitialPasswordCredentials object`, async () => { + // Arrange + const invalidCredentials: SetInitialPasswordCredentials = { + ...credentials, + [key]: null, + }; + + // Act + const promise = sut.setInitialPassword(invalidCredentials, userType, userId); + + // Assert + await expect(promise).rejects.toThrow(`${key} not found. Could not set password.`); + }); + }); + + ["userId", "userType"].forEach((param) => { + it(`should throw if ${param} was not passed in`, async () => { + // Arrange & Act + const promise = sut.setInitialPassword( + credentials, + param === "userType" ? null : userType, + param === "userId" ? null : userId, + ); + + // Assert + await expect(promise).rejects.toThrow(`${param} not found. Could not set password.`); + }); + }); + }); + + describe("given SetInitialPasswordUserType.JIT_PROVISIONED_MP_ORG_USER", () => { + beforeEach(() => { + userType = SetInitialPasswordUserType.JIT_PROVISIONED_MP_ORG_USER; + }); + + describe("given the user has an existing local key pair", () => { + it("should NOT create a brand new key pair for the user", async () => { + // Arrange + setPasswordRequest.keys = { + encryptedPrivateKey: userKeyEncryptedPrivateKey.encryptedString, + publicKey: Utils.fromBufferToB64(existingUserPublicKey), + }; + + setupMocks({ ...defaultMockConfig, userHasLocalKeyPair: true }); + + // Act + await sut.setInitialPassword(credentials, userType, userId); + + // Assert + expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(setPasswordRequest); + expect(keyService.userPrivateKey$).toHaveBeenCalledWith(userId); + expect(keyService.userPublicKey$).toHaveBeenCalledWith(userId); + expect(encryptService.wrapDecapsulationKey).toHaveBeenCalledWith( + existingUserPrivateKey, + masterKeyEncryptedUserKey[0], + ); + expect(keyService.makeKeyPair).not.toHaveBeenCalled(); + }); + }); + + describe("given the user has a userKey", () => { + it("should successfully set an initial password", async () => { + // Arrange + setupMocks(); + + // Act + await sut.setInitialPassword(credentials, userType, userId); + + // Assert + expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(setPasswordRequest); + }); + }); + + describe("given the user does NOT have a userKey", () => { + it("should successfully set an initial password", async () => { + // Arrange + setupMocks({ ...defaultMockConfig, userHasUserKey: false }); + + // Act + await sut.setInitialPassword(credentials, userType, userId); + + // Assert + expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(setPasswordRequest); + }); + }); + + it("should throw if a key pair is not found", async () => { + // Arrange + keyPair = null; + + setupMocks(); + + // Act + const promise = sut.setInitialPassword(credentials, userType, userId); + + // Assert + await expect(promise).rejects.toThrow("keyPair not found. Could not set password."); + expect(masterPasswordApiService.setPassword).not.toHaveBeenCalled(); + }); + + it("should throw if an encrypted private key is not found", async () => { + // Arrange + keyPair[1].encryptedString = "" as EncryptedString; + + setupMocks(); + + // Act + const promise = sut.setInitialPassword(credentials, userType, userId); + + // Assert + await expect(promise).rejects.toThrow( + "encrypted private key not found. Could not set password.", + ); + expect(masterPasswordApiService.setPassword).not.toHaveBeenCalled(); + }); + + describe("given the initial password has been successfully set", () => { + it("should clear the ForceSetPasswordReason by setting it to None", async () => { + // Arrange + setupMocks(); + + // Act + await sut.setInitialPassword(credentials, userType, userId); + + // Assert + expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(setPasswordRequest); + expect(masterPasswordService.setForceSetPasswordReason).toHaveBeenCalledWith( + ForceSetPasswordReason.None, + userId, + ); + }); + + it("should update account decryption properties", async () => { + // Arrange + setupMocks(); + + // Act + await sut.setInitialPassword(credentials, userType, userId); + + // Assert + expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(setPasswordRequest); + expect(userDecryptionOptionsService.setUserDecryptionOptions).toHaveBeenCalledWith( + userDecryptionOptions, + ); + expect(kdfConfigService.setKdfConfig).toHaveBeenCalledWith(userId, credentials.kdfConfig); + expect(masterPasswordService.setMasterKey).toHaveBeenCalledWith( + credentials.newMasterKey, + userId, + ); + expect(keyService.setUserKey).toHaveBeenCalledWith(masterKeyEncryptedUserKey[0], userId); + }); + + it("should set the private key to state", async () => { + // Arrange + setupMocks(); + + // Act + await sut.setInitialPassword(credentials, userType, userId); + + // Assert + expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(setPasswordRequest); + expect(keyService.setPrivateKey).toHaveBeenCalledWith(keyPair[1].encryptedString, userId); + }); + + it("should set the local master key hash to state", async () => { + // Arrange + setupMocks(); + + // Act + await sut.setInitialPassword(credentials, userType, userId); + + // Assert + expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(setPasswordRequest); + expect(masterPasswordService.setMasterKeyHash).toHaveBeenCalledWith( + credentials.newLocalMasterKeyHash, + userId, + ); + }); + + describe("given resetPasswordAutoEnroll is true", () => { + it(`should handle reset password (account recovery) auto enroll`, async () => { + // Arrange + credentials.resetPasswordAutoEnroll = true; + + setupMocks({ ...defaultMockConfig, resetPasswordAutoEnroll: true }); + + // Act + await sut.setInitialPassword(credentials, userType, userId); + + // Assert + expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(setPasswordRequest); + expect( + organizationUserApiService.putOrganizationUserResetPasswordEnrollment, + ).toHaveBeenCalledWith(credentials.orgId, userId, enrollmentRequest); + }); + + it("should throw if organization keys are not found", async () => { + // Arrange + credentials.resetPasswordAutoEnroll = true; + organizationKeys = null; + + setupMocks({ ...defaultMockConfig, resetPasswordAutoEnroll: true }); + + // Act + const promise = sut.setInitialPassword(credentials, userType, userId); + + // Assert + await expect(promise).rejects.toThrow( + "Organization keys response is null. Could not handle reset password auto enroll.", + ); + expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(setPasswordRequest); + expect( + organizationUserApiService.putOrganizationUserResetPasswordEnrollment, + ).not.toHaveBeenCalled(); + }); + + ["orgPublicKeyEncryptedUserKey", "orgPublicKeyEncryptedUserKey.encryptedString"].forEach( + (property) => { + it("should throw if orgPublicKeyEncryptedUserKey is not found", async () => { + // Arrange + credentials.resetPasswordAutoEnroll = true; + + if (property === "orgPublicKeyEncryptedUserKey") { + orgPublicKeyEncryptedUserKey = null; + } else { + orgPublicKeyEncryptedUserKey.encryptedString = "" as EncryptedString; + } + + setupMocks({ ...defaultMockConfig, resetPasswordAutoEnroll: true }); + + // Act + const promise = sut.setInitialPassword(credentials, userType, userId); + + // Assert + await expect(promise).rejects.toThrow( + "orgPublicKeyEncryptedUserKey not found. Could not handle reset password auto enroll.", + ); + expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith( + setPasswordRequest, + ); + expect( + organizationUserApiService.putOrganizationUserResetPasswordEnrollment, + ).not.toHaveBeenCalled(); + }); + }, + ); + }); + + describe("given resetPasswordAutoEnroll is false", () => { + it(`should NOT handle reset password (account recovery) auto enroll`, async () => { + // Arrange + credentials.resetPasswordAutoEnroll = false; + + setupMocks(); + + // Act + await sut.setInitialPassword(credentials, userType, userId); + + // Assert + expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(setPasswordRequest); + expect( + organizationUserApiService.putOrganizationUserResetPasswordEnrollment, + ).not.toHaveBeenCalled(); + }); + }); + }); + }); + + describe("given SetInitialPasswordUserType.TDE_ORG_USER_RESET_PASSWORD_PERMISSION_REQUIRES_MP", () => { + beforeEach(() => { + userType = SetInitialPasswordUserType.TDE_ORG_USER_RESET_PASSWORD_PERMISSION_REQUIRES_MP; + setPasswordRequest.keys = null; + }); + + it("should NOT generate a keyPair", async () => { + // Arrange + setupMocks({ ...defaultMockConfig, userType }); + + // Act + await sut.setInitialPassword(credentials, userType, userId); + + // Assert + expect(keyService.userPrivateKey$).not.toHaveBeenCalled(); + expect(keyService.userPublicKey$).not.toHaveBeenCalled(); + expect(encryptService.wrapDecapsulationKey).not.toHaveBeenCalled(); + expect(keyService.makeKeyPair).not.toHaveBeenCalled(); + }); + + describe("given the user has a userKey", () => { + it("should successfully set an initial password", async () => { + // Arrange + setupMocks({ ...defaultMockConfig, userType }); + + // Act + await sut.setInitialPassword(credentials, userType, userId); + + // Assert + expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(setPasswordRequest); + }); + }); + + describe("given the user does NOT have a userKey", () => { + it("should successfully set an initial password", async () => { + // Arrange + setupMocks({ ...defaultMockConfig, userType }); + + // Act + await sut.setInitialPassword(credentials, userType, userId); + + // Assert + expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(setPasswordRequest); + }); + }); + + describe("given the initial password has been successfully set", () => { + it("should clear the ForceSetPasswordReason by setting it to None", async () => { + // Arrange + setupMocks({ ...defaultMockConfig, userType }); + + // Act + await sut.setInitialPassword(credentials, userType, userId); + + // Assert + expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(setPasswordRequest); + expect(masterPasswordService.setForceSetPasswordReason).toHaveBeenCalledWith( + ForceSetPasswordReason.None, + userId, + ); + }); + + it("should update account decryption properties", async () => { + // Arrange + setupMocks({ ...defaultMockConfig, userType }); + + // Act + await sut.setInitialPassword(credentials, userType, userId); + + // Assert + expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(setPasswordRequest); + expect(userDecryptionOptionsService.setUserDecryptionOptions).toHaveBeenCalledWith( + userDecryptionOptions, + ); + expect(kdfConfigService.setKdfConfig).toHaveBeenCalledWith(userId, credentials.kdfConfig); + expect(masterPasswordService.setMasterKey).toHaveBeenCalledWith( + credentials.newMasterKey, + userId, + ); + expect(keyService.setUserKey).toHaveBeenCalledWith(masterKeyEncryptedUserKey[0], userId); + }); + + it("should NOT set the private key to state", async () => { + // Arrange + setupMocks({ ...defaultMockConfig, userType }); + + // Act + await sut.setInitialPassword(credentials, userType, userId); + + // Assert + expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(setPasswordRequest); + expect(keyService.setPrivateKey).not.toHaveBeenCalled(); + }); + + it("should set the local master key hash to state", async () => { + // Arrange + setupMocks({ ...defaultMockConfig, userType }); + + // Act + await sut.setInitialPassword(credentials, userType, userId); + + // Assert + expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(setPasswordRequest); + expect(masterPasswordService.setMasterKeyHash).toHaveBeenCalledWith( + credentials.newLocalMasterKeyHash, + userId, + ); + }); + + describe("given resetPasswordAutoEnroll is true", () => { + it(`should handle reset password (account recovery) auto enroll`, async () => { + // Arrange + credentials.resetPasswordAutoEnroll = true; + + setupMocks({ ...defaultMockConfig, userType, resetPasswordAutoEnroll: true }); + + // Act + await sut.setInitialPassword(credentials, userType, userId); + + // Assert + expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(setPasswordRequest); + expect( + organizationUserApiService.putOrganizationUserResetPasswordEnrollment, + ).toHaveBeenCalledWith(credentials.orgId, userId, enrollmentRequest); + }); + }); + + describe("given resetPasswordAutoEnroll is false", () => { + it(`should NOT handle reset password (account recovery) auto enroll`, async () => { + // Arrange + setupMocks({ ...defaultMockConfig, userType }); + + // Act + await sut.setInitialPassword(credentials, userType, userId); + + // Assert + expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(setPasswordRequest); + expect( + organizationUserApiService.putOrganizationUserResetPasswordEnrollment, + ).not.toHaveBeenCalled(); + }); + }); + }); + }); + }); +}); diff --git a/libs/angular/src/auth/password-management/set-initial-password/set-initial-password.component.html b/libs/angular/src/auth/password-management/set-initial-password/set-initial-password.component.html new file mode 100644 index 00000000000..c83cbbe3521 --- /dev/null +++ b/libs/angular/src/auth/password-management/set-initial-password/set-initial-password.component.html @@ -0,0 +1,29 @@ +@if (initializing) { +
+ +
+} @else { + + {{ "resetPasswordAutoEnrollInviteWarning" | i18n }} + + + +} diff --git a/libs/angular/src/auth/password-management/set-initial-password/set-initial-password.component.ts b/libs/angular/src/auth/password-management/set-initial-password/set-initial-password.component.ts new file mode 100644 index 00000000000..fbab9eaa2c3 --- /dev/null +++ b/libs/angular/src/auth/password-management/set-initial-password/set-initial-password.component.ts @@ -0,0 +1,249 @@ +import { CommonModule } from "@angular/common"; +import { Component, OnInit } from "@angular/core"; +import { ActivatedRoute, Router } from "@angular/router"; +import { firstValueFrom } from "rxjs"; + +// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. +// eslint-disable-next-line no-restricted-imports +import { + InputPasswordComponent, + InputPasswordFlow, + PasswordInputResult, +} from "@bitwarden/auth/angular"; +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 { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; +import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; +import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; +import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; +import { SyncService } from "@bitwarden/common/platform/sync"; +import { UserId } from "@bitwarden/common/types/guid"; +import { + AnonLayoutWrapperDataService, + CalloutComponent, + DialogService, + ToastService, +} from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; + +import { + SetInitialPasswordCredentials, + SetInitialPasswordService, + SetInitialPasswordUserType, +} from "./set-initial-password.service.abstraction"; + +@Component({ + standalone: true, + templateUrl: "set-initial-password.component.html", + imports: [CalloutComponent, CommonModule, InputPasswordComponent, I18nPipe], +}) +export class SetInitialPasswordComponent implements OnInit { + protected inputPasswordFlow = InputPasswordFlow.SetInitialPasswordAuthedUser; + + protected email?: string; + protected forceSetPasswordReason?: ForceSetPasswordReason; + protected initializing = true; + protected masterPasswordPolicyOptions: MasterPasswordPolicyOptions | null = null; + protected orgId?: string; + protected orgSsoIdentifier?: string; + protected resetPasswordAutoEnroll?: boolean; + protected submitting = false; + protected userId?: UserId; + protected userType?: SetInitialPasswordUserType; + + constructor( + private accountService: AccountService, + private activatedRoute: ActivatedRoute, + private anonLayoutWrapperDataService: AnonLayoutWrapperDataService, + private dialogService: DialogService, + private i18nService: I18nService, + private masterPasswordService: InternalMasterPasswordServiceAbstraction, + private messagingService: MessagingService, + private organizationApiService: OrganizationApiServiceAbstraction, + private policyApiService: PolicyApiServiceAbstraction, + private router: Router, + private setInitialPasswordService: SetInitialPasswordService, + private ssoLoginService: SsoLoginServiceAbstraction, + private syncService: SyncService, + private toastService: ToastService, + private validationService: ValidationService, + ) {} + + async ngOnInit() { + await this.syncService.fullSync(true); + + const activeAccount = await firstValueFrom(this.accountService.activeAccount$); + this.userId = activeAccount?.id; + this.email = activeAccount?.email; + + await this.determineUserType(); + await this.handleQueryParams(); + + this.initializing = false; + } + + private async determineUserType() { + if (!this.userId) { + throw new Error("userId not found. Could not determine user type."); + } + + this.forceSetPasswordReason = await firstValueFrom( + this.masterPasswordService.forceSetPasswordReason$(this.userId), + ); + + if ( + this.forceSetPasswordReason === + ForceSetPasswordReason.TdeUserWithoutPasswordHasPasswordResetPermission + ) { + this.userType = SetInitialPasswordUserType.TDE_ORG_USER_RESET_PASSWORD_PERMISSION_REQUIRES_MP; + this.anonLayoutWrapperDataService.setAnonLayoutWrapperData({ + pageTitle: { key: "setMasterPassword" }, + pageSubtitle: { key: "orgPermissionsUpdatedMustSetPassword" }, + }); + } else { + this.userType = SetInitialPasswordUserType.JIT_PROVISIONED_MP_ORG_USER; + this.anonLayoutWrapperDataService.setAnonLayoutWrapperData({ + pageTitle: { key: "joinOrganization" }, + pageSubtitle: { key: "finishJoiningThisOrganizationBySettingAMasterPassword" }, + }); + } + } + + private async handleQueryParams() { + if (!this.userId) { + throw new Error("userId not found. Could not handle query params."); + } + + const qParams = await firstValueFrom(this.activatedRoute.queryParams); + + this.orgSsoIdentifier = + qParams.identifier ?? + (await this.ssoLoginService.getActiveUserOrganizationSsoIdentifier(this.userId)); + + if (this.orgSsoIdentifier != null) { + try { + const autoEnrollStatus = await this.organizationApiService.getAutoEnrollStatus( + this.orgSsoIdentifier, + ); + this.orgId = autoEnrollStatus.id; + this.resetPasswordAutoEnroll = autoEnrollStatus.resetPasswordEnabled; + this.masterPasswordPolicyOptions = + await this.policyApiService.getMasterPasswordPolicyOptsForOrgUser(this.orgId); + } catch { + this.toastService.showToast({ + variant: "error", + title: "", + message: this.i18nService.t("errorOccurred"), + }); + } + } + } + + protected async handlePasswordFormSubmit(passwordInputResult: PasswordInputResult) { + this.submitting = true; + + if (!passwordInputResult.newMasterKey) { + throw new Error("newMasterKey not found. Could not set initial password."); + } + if (!passwordInputResult.newServerMasterKeyHash) { + throw new Error("newServerMasterKeyHash not found. Could not set initial password."); + } + if (!passwordInputResult.newLocalMasterKeyHash) { + throw new Error("newLocalMasterKeyHash not found. Could not set initial password."); + } + // newPasswordHint can have an empty string as a valid value, so we specifically check for null or undefined + if (passwordInputResult.newPasswordHint == null) { + throw new Error("newPasswordHint not found. Could not set initial password."); + } + if (!passwordInputResult.kdfConfig) { + throw new Error("kdfConfig not found. Could not set initial password."); + } + if (!this.userId) { + throw new Error("userId not found. Could not set initial password."); + } + if (!this.userType) { + throw new Error("userType not found. Could not set initial password."); + } + if (!this.orgSsoIdentifier) { + throw new Error("orgSsoIdentifier not found. Could not set initial password."); + } + if (!this.orgId) { + throw new Error("orgId not found. Could not set initial password."); + } + // resetPasswordAutoEnroll can have `false` as a valid value, so we specifically check for null or undefined + if (this.resetPasswordAutoEnroll == null) { + throw new Error("resetPasswordAutoEnroll not found. Could not set initial password."); + } + + try { + const credentials: SetInitialPasswordCredentials = { + newMasterKey: passwordInputResult.newMasterKey, + newServerMasterKeyHash: passwordInputResult.newServerMasterKeyHash, + newLocalMasterKeyHash: passwordInputResult.newLocalMasterKeyHash, + newPasswordHint: passwordInputResult.newPasswordHint, + kdfConfig: passwordInputResult.kdfConfig, + orgSsoIdentifier: this.orgSsoIdentifier, + orgId: this.orgId, + resetPasswordAutoEnroll: this.resetPasswordAutoEnroll, + }; + + await this.setInitialPasswordService.setInitialPassword( + credentials, + this.userType, + this.userId, + ); + + this.showSuccessToastByUserType(); + + this.submitting = false; + await this.router.navigate(["vault"]); + } catch (e) { + this.validationService.showError(e); + this.submitting = false; + } + } + + private showSuccessToastByUserType() { + if (this.userType === SetInitialPasswordUserType.JIT_PROVISIONED_MP_ORG_USER) { + this.toastService.showToast({ + variant: "success", + title: "", + message: this.i18nService.t("accountSuccessfullyCreated"), + }); + + this.toastService.showToast({ + variant: "success", + title: "", + message: this.i18nService.t("inviteAccepted"), + }); + } + + if ( + this.userType === + SetInitialPasswordUserType.TDE_ORG_USER_RESET_PASSWORD_PERMISSION_REQUIRES_MP + ) { + this.toastService.showToast({ + variant: "success", + title: "", + message: this.i18nService.t("masterPasswordSuccessfullySet"), + }); + } + } + + protected async logout() { + const confirmed = await this.dialogService.openSimpleDialog({ + title: { key: "logOut" }, + content: { key: "logOutConfirmation" }, + acceptButtonText: { key: "logOut" }, + type: "warning", + }); + + if (confirmed) { + this.messagingService.send("logout"); + } + } +} diff --git a/libs/angular/src/auth/password-management/set-initial-password/set-initial-password.service.abstraction.ts b/libs/angular/src/auth/password-management/set-initial-password/set-initial-password.service.abstraction.ts new file mode 100644 index 00000000000..e594053a906 --- /dev/null +++ b/libs/angular/src/auth/password-management/set-initial-password/set-initial-password.service.abstraction.ts @@ -0,0 +1,64 @@ +import { UserId } from "@bitwarden/common/types/guid"; +import { MasterKey } from "@bitwarden/common/types/key"; +import { KdfConfig } from "@bitwarden/key-management"; + +export const _SetInitialPasswordUserType = { + /** + * A user being "just-in-time" (JIT) provisioned into a master-password-encryption org + */ + JIT_PROVISIONED_MP_ORG_USER: "jit_provisioned_mp_org_user", + + /** + * Could be one of two scenarios: + * 1. A user being "just-in-time" (JIT) provisioned into a trusted-device-encryption org + * with the reset password permission granted ("manage account recovery"), which requires + * that the user sets a master password + * 2. An user in a trusted-device-encryption org whose permissions were upgraded to include + * the reset password permission ("manage account recovery"), which requires that the user + * sets a master password + */ + TDE_ORG_USER_RESET_PASSWORD_PERMISSION_REQUIRES_MP: + "tde_org_user_reset_password_permission_requires_mp", +} as const; + +type _SetInitialPasswordUserType = typeof _SetInitialPasswordUserType; + +export type SetInitialPasswordUserType = + _SetInitialPasswordUserType[keyof _SetInitialPasswordUserType]; +export const SetInitialPasswordUserType: Readonly<{ + [K in keyof typeof _SetInitialPasswordUserType]: SetInitialPasswordUserType; +}> = Object.freeze(_SetInitialPasswordUserType); + +export interface SetInitialPasswordCredentials { + newMasterKey: MasterKey; + newServerMasterKeyHash: string; + newLocalMasterKeyHash: string; + newPasswordHint: string; + kdfConfig: KdfConfig; + orgSsoIdentifier: string; + orgId: string; + resetPasswordAutoEnroll: boolean; +} + +/** + * Handles setting an initial password for an existing authed user. + * + * To see the different scenarios where an existing authed user needs to set an + * initial password, see {@link SetInitialPasswordUserType} + */ +export abstract class SetInitialPasswordService { + /** + * Sets an initial password for an existing authed user who is either: + * - {@link SetInitialPasswordUserType.JIT_PROVISIONED_MP_ORG_USER} + * - {@link SetInitialPasswordUserType.TDE_ORG_USER_RESET_PASSWORD_PERMISSION_REQUIRES_MP} + * + * @param credentials An object of the credentials needed to set the initial password + * @throws If any property on the `credentials` object is null or undefined, or if a + * masterKeyEncryptedUserKey or newKeyPair could not be created. + */ + abstract setInitialPassword: ( + credentials: SetInitialPasswordCredentials, + userType: SetInitialPasswordUserType, + userId: UserId, + ) => Promise; +} diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 780604f048d..96a95de501e 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -339,6 +339,8 @@ import { VaultExportServiceAbstraction, } from "@bitwarden/vault-export-core"; +import { DefaultSetInitialPasswordService } from "../auth/password-management/set-initial-password/default-set-initial-password.service.implementation"; +import { SetInitialPasswordService } from "../auth/password-management/set-initial-password/set-initial-password.service.abstraction"; import { DeviceTrustToastService as DeviceTrustToastServiceAbstraction } from "../auth/services/device-trust-toast.service.abstraction"; import { DeviceTrustToastService } from "../auth/services/device-trust-toast.service.implementation"; import { FormValidationErrorsService as FormValidationErrorsServiceAbstraction } from "../platform/abstractions/form-validation-errors.service"; @@ -1419,6 +1421,22 @@ const safeProviders: SafeProvider[] = [ InternalUserDecryptionOptionsServiceAbstraction, ], }), + safeProvider({ + provide: SetInitialPasswordService, + useClass: DefaultSetInitialPasswordService, + deps: [ + ApiServiceAbstraction, + EncryptService, + I18nServiceAbstraction, + KdfConfigService, + KeyService, + MasterPasswordApiServiceAbstraction, + InternalMasterPasswordServiceAbstraction, + OrganizationApiServiceAbstraction, + OrganizationUserApiService, + InternalUserDecryptionOptionsServiceAbstraction, + ], + }), safeProvider({ provide: DefaultServerSettingsService, useClass: DefaultServerSettingsService,