diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 957386ba576..ee8cd412625 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -1173,6 +1173,12 @@ "message": "Oh no! We couldn't save this. Try entering the details manually.", "description": "Detailed error message shown when saving login details fails." }, + "changePasswordWarning": { + "message": "After changing your password, you will need to log in with your new password. Active sessions on other devices will be logged out within one hour." + }, + "accountRecoveryUpdateMasterPasswordSubtitle": { + "message": "Change your master password to complete account recovery." + }, "enableChangedPasswordNotification": { "message": "Ask to update existing login" }, @@ -3454,6 +3460,9 @@ "logInRequestSent": { "message": "Request sent" }, + "masterPasswordChanged": { + "message": "Master password saved" + }, "exposedMasterPassword": { "message": "Exposed Master Password" }, diff --git a/apps/browser/src/popup/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts index f836f5ffac7..da5a6c43d36 100644 --- a/apps/browser/src/popup/app-routing.module.ts +++ b/apps/browser/src/popup/app-routing.module.ts @@ -15,6 +15,7 @@ import { tdeDecryptionRequiredGuard, unauthGuardFn, } from "@bitwarden/angular/auth/guards"; +import { ChangePasswordComponent } from "@bitwarden/angular/auth/password-management/change-password"; 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 { @@ -331,7 +332,15 @@ const routes: Routes = [ { path: "update-temp-password", component: UpdateTempPasswordComponent, - canActivate: [authGuard], + canActivate: [ + canAccessFeature( + FeatureFlag.PM16117_ChangeExistingPasswordRefactor, + false, + `/change-password`, + false, + ), + authGuard, + ], data: { elevation: 1 } satisfies RouteDataProperties, }, { @@ -555,6 +564,23 @@ const routes: Routes = [ showBackButton: true, } satisfies RouteDataProperties & ExtensionAnonLayoutWrapperData, }, + { + path: "change-password", + data: { + elevation: 1, + hideFooter: true, + } satisfies RouteDataProperties & ExtensionAnonLayoutWrapperData, + children: [ + { + path: "", + component: ChangePasswordComponent, + }, + ], + canActivate: [ + canAccessFeature(FeatureFlag.PM16117_ChangeExistingPasswordRefactor), + authGuard, + ], + }, ], }, { diff --git a/apps/desktop/src/app/app-routing.module.ts b/apps/desktop/src/app/app-routing.module.ts index 42846878d03..0a60478c94d 100644 --- a/apps/desktop/src/app/app-routing.module.ts +++ b/apps/desktop/src/app/app-routing.module.ts @@ -14,6 +14,7 @@ import { tdeDecryptionRequiredGuard, unauthGuardFn, } from "@bitwarden/angular/auth/guards"; +import { ChangePasswordComponent } from "@bitwarden/angular/auth/password-management/change-password"; 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"; @@ -119,7 +120,15 @@ const routes: Routes = [ { path: "update-temp-password", component: UpdateTempPasswordComponent, - canActivate: [authGuard], + canActivate: [ + canAccessFeature( + FeatureFlag.PM16117_ChangeExistingPasswordRefactor, + false, + `/change-password`, + false, + ), + authGuard, + ], }, { path: "remove-password", @@ -340,6 +349,14 @@ const routes: Routes = [ }, } satisfies RouteDataProperties & AnonLayoutWrapperData, }, + { + path: "change-password", + component: ChangePasswordComponent, + canActivate: [ + canAccessFeature(FeatureFlag.PM16117_ChangeExistingPasswordRefactor), + authGuard, + ], + }, ], }, ]; diff --git a/apps/desktop/src/app/services/services.module.ts b/apps/desktop/src/app/services/services.module.ts index 0abd810bd18..4111a62d3b2 100644 --- a/apps/desktop/src/app/services/services.module.ts +++ b/apps/desktop/src/app/services/services.module.ts @@ -382,12 +382,11 @@ const safeProviders: SafeProvider[] = [ provide: SetPasswordJitService, useClass: DesktopSetPasswordJitService, deps: [ - ApiService, - MasterPasswordApiService, - KeyService, EncryptService, I18nServiceAbstraction, KdfConfigService, + KeyService, + MasterPasswordApiService, InternalMasterPasswordServiceAbstraction, OrganizationApiServiceAbstraction, OrganizationUserApiService, diff --git a/apps/desktop/src/auth/login/desktop-login-component.service.ts b/apps/desktop/src/auth/login/desktop-login-component.service.ts index 60e7791b384..d7e7ba0178b 100644 --- a/apps/desktop/src/auth/login/desktop-login-component.service.ts +++ b/apps/desktop/src/auth/login/desktop-login-component.service.ts @@ -84,7 +84,7 @@ export class DesktopLoginComponentService } catch (err) { this.toastService.showToast({ variant: "error", - title: this.i18nService.t("errorOccured"), + title: this.i18nService.t("errorOccurred"), message: this.i18nService.t("ssoError"), }); } diff --git a/apps/desktop/src/auth/set-password.component.ts b/apps/desktop/src/auth/set-password.component.ts index 48b18d7294c..55ad1f48a77 100644 --- a/apps/desktop/src/auth/set-password.component.ts +++ b/apps/desktop/src/auth/set-password.component.ts @@ -4,7 +4,6 @@ import { ActivatedRoute, Router } from "@angular/router"; import { OrganizationUserApiService } from "@bitwarden/admin-console/common"; import { SetPasswordComponent as BaseSetPasswordComponent } from "@bitwarden/angular/auth/components/set-password.component"; 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 { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; @@ -32,52 +31,50 @@ const BroadcasterSubscriptionId = "SetPasswordComponent"; }) export class SetPasswordComponent extends BaseSetPasswordComponent implements OnInit, OnDestroy { constructor( - accountService: AccountService, - masterPasswordService: InternalMasterPasswordServiceAbstraction, - apiService: ApiService, - i18nService: I18nService, - keyService: KeyService, - messagingService: MessagingService, - platformUtilsService: PlatformUtilsService, - policyApiService: PolicyApiServiceAbstraction, - policyService: PolicyService, - router: Router, - masterPasswordApiService: MasterPasswordApiService, - syncService: SyncService, - route: ActivatedRoute, + protected accountService: AccountService, + protected dialogService: DialogService, + protected encryptService: EncryptService, + protected i18nService: I18nService, + protected kdfConfigService: KdfConfigService, + protected keyService: KeyService, + protected masterPasswordApiService: MasterPasswordApiService, + protected masterPasswordService: InternalMasterPasswordServiceAbstraction, + protected messagingService: MessagingService, + protected organizationApiService: OrganizationApiServiceAbstraction, + protected organizationUserApiService: OrganizationUserApiService, + protected platformUtilsService: PlatformUtilsService, + protected policyApiService: PolicyApiServiceAbstraction, + protected policyService: PolicyService, + protected route: ActivatedRoute, + protected router: Router, + protected ssoLoginService: SsoLoginServiceAbstraction, + protected syncService: SyncService, + protected toastService: ToastService, + protected userDecryptionOptionsService: InternalUserDecryptionOptionsServiceAbstraction, private broadcasterService: BroadcasterService, private ngZone: NgZone, - organizationApiService: OrganizationApiServiceAbstraction, - organizationUserApiService: OrganizationUserApiService, - userDecryptionOptionsService: InternalUserDecryptionOptionsServiceAbstraction, - ssoLoginService: SsoLoginServiceAbstraction, - dialogService: DialogService, - kdfConfigService: KdfConfigService, - encryptService: EncryptService, - toastService: ToastService, ) { super( accountService, - masterPasswordService, + dialogService, + encryptService, i18nService, + kdfConfigService, keyService, + masterPasswordApiService, + masterPasswordService, messagingService, + organizationApiService, + organizationUserApiService, platformUtilsService, policyApiService, policyService, - router, - masterPasswordApiService, - apiService, - syncService, route, - organizationApiService, - organizationUserApiService, - userDecryptionOptionsService, + router, ssoLoginService, - dialogService, - kdfConfigService, - encryptService, + syncService, toastService, + userDecryptionOptionsService, ); } diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index 703b65c35b4..a139c0c712c 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -2407,6 +2407,15 @@ "updateWeakMasterPasswordWarning": { "message": "Your master password does not meet one or more of your organization policies. In order to access the vault, you must update your master password now. Proceeding will log you out of your current session, requiring you to log back in. Active sessions on other devices may continue to remain active for up to one hour." }, + "changePasswordWarning": { + "message": "After changing your password, you will need to log in with your new password. Active sessions on other devices will be logged out within one hour." + }, + "accountRecoveryUpdateMasterPasswordSubtitle": { + "message": "Change your master password to complete account recovery." + }, + "updateMasterPasswordSubtitle": { + "message": "Your master password does not meet this organization’s requirements. Change your master password to continue." + }, "tdeDisabledMasterPasswordRequired": { "message": "Your organization has disabled trusted device encryption. Please set a master password to access your vault." }, diff --git a/apps/web/src/app/auth/core/services/change-password/web-change-password.service.ts b/apps/web/src/app/auth/core/services/change-password/web-change-password.service.ts index b75aef0f1fc..ed384763241 100644 --- a/apps/web/src/app/auth/core/services/change-password/web-change-password.service.ts +++ b/apps/web/src/app/auth/core/services/change-password/web-change-password.service.ts @@ -1,8 +1,12 @@ -import { ChangePasswordService, DefaultChangePasswordService } from "@bitwarden/auth/angular"; +import { + ChangePasswordService, + DefaultChangePasswordService, +} from "@bitwarden/angular/auth/password-management/change-password"; import { Account } from "@bitwarden/common/auth/abstractions/account.service"; import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; import { KeyService } from "@bitwarden/key-management"; +import { RouterService } from "@bitwarden/web-vault/app/core"; import { UserKeyRotationService } from "@bitwarden/web-vault/app/key-management/key-rotation/user-key-rotation.service"; export class WebChangePasswordService @@ -14,6 +18,7 @@ export class WebChangePasswordService protected masterPasswordApiService: MasterPasswordApiService, protected masterPasswordService: InternalMasterPasswordServiceAbstraction, private userKeyRotationService: UserKeyRotationService, + private routerService: RouterService, ) { super(keyService, masterPasswordApiService, masterPasswordService); } @@ -31,4 +36,8 @@ export class WebChangePasswordService newPasswordHint, ); } + + async clearDeeplinkState() { + await this.routerService.getAndClearLoginRedirectUrl(); + } } diff --git a/apps/web/src/app/auth/core/services/login-decryption-options/web-login-decryption-options.service.ts b/apps/web/src/app/auth/core/services/login-decryption-options/web-login-decryption-options.service.ts index 3de3ec46457..fb30f04ffc4 100644 --- a/apps/web/src/app/auth/core/services/login-decryption-options/web-login-decryption-options.service.ts +++ b/apps/web/src/app/auth/core/services/login-decryption-options/web-login-decryption-options.service.ts @@ -4,10 +4,10 @@ import { LoginDecryptionOptionsService, DefaultLoginDecryptionOptionsService, } from "@bitwarden/auth/angular"; +import { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { RouterService } from "../../../../core/router.service"; -import { AcceptOrganizationInviteService } from "../../../organization-invite/accept-organization.service"; export class WebLoginDecryptionOptionsService extends DefaultLoginDecryptionOptionsService @@ -16,7 +16,7 @@ export class WebLoginDecryptionOptionsService constructor( protected messagingService: MessagingService, private routerService: RouterService, - private acceptOrganizationInviteService: AcceptOrganizationInviteService, + private organizationInviteService: OrganizationInviteService, ) { super(messagingService); } @@ -27,7 +27,7 @@ export class WebLoginDecryptionOptionsService // accepted while being enrolled in admin recovery. So we need to clear // the redirect and stored org invite. await this.routerService.getAndClearLoginRedirectUrl(); - await this.acceptOrganizationInviteService.clearOrganizationInvitation(); + await this.organizationInviteService.clearOrganizationInvitation(); } catch (error) { throw new Error(error); } diff --git a/apps/web/src/app/auth/core/services/login/web-login-component.service.spec.ts b/apps/web/src/app/auth/core/services/login/web-login-component.service.spec.ts index 95ddc74c3c5..4cc06baf32b 100644 --- a/apps/web/src/app/auth/core/services/login/web-login-component.service.spec.ts +++ b/apps/web/src/app/auth/core/services/login/web-login-component.service.spec.ts @@ -10,7 +10,9 @@ import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; import { ResetPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/reset-password-policy-options"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; +import { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.service"; import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; @@ -22,7 +24,6 @@ import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legac // FIXME: remove `src` and fix import // eslint-disable-next-line no-restricted-imports import { RouterService } from "../../../../../../../../apps/web/src/app/core"; -import { AcceptOrganizationInviteService } from "../../../organization-invite/accept-organization.service"; import { WebLoginComponentService } from "./web-login-component.service"; @@ -32,7 +33,7 @@ jest.mock("../../../../../utils/flags", () => ({ describe("WebLoginComponentService", () => { let service: WebLoginComponentService; - let acceptOrganizationInviteService: MockProxy; + let organizationInviteService: MockProxy; let logService: MockProxy; let policyApiService: MockProxy; let internalPolicyService: MockProxy; @@ -44,9 +45,10 @@ describe("WebLoginComponentService", () => { let ssoLoginService: MockProxy; const mockUserId = Utils.newGuid() as UserId; let accountService: FakeAccountService; + let configService: MockProxy; beforeEach(() => { - acceptOrganizationInviteService = mock(); + organizationInviteService = mock(); logService = mock(); policyApiService = mock(); internalPolicyService = mock(); @@ -57,12 +59,13 @@ describe("WebLoginComponentService", () => { platformUtilsService = mock(); ssoLoginService = mock(); accountService = mockAccountServiceWith(mockUserId); + configService = mock(); TestBed.configureTestingModule({ providers: [ WebLoginComponentService, { provide: DefaultLoginComponentService, useClass: WebLoginComponentService }, - { provide: AcceptOrganizationInviteService, useValue: acceptOrganizationInviteService }, + { provide: OrganizationInviteService, useValue: organizationInviteService }, { provide: LogService, useValue: logService }, { provide: PolicyApiServiceAbstraction, useValue: policyApiService }, { provide: InternalPolicyService, useValue: internalPolicyService }, @@ -73,6 +76,7 @@ describe("WebLoginComponentService", () => { { provide: PlatformUtilsService, useValue: platformUtilsService }, { provide: SsoLoginServiceAbstraction, useValue: ssoLoginService }, { provide: AccountService, useValue: accountService }, + { provide: ConfigService, useValue: configService }, ], }); service = TestBed.inject(WebLoginComponentService); @@ -84,14 +88,14 @@ describe("WebLoginComponentService", () => { describe("getOrgPoliciesFromOrgInvite", () => { it("returns undefined if organization invite is null", async () => { - acceptOrganizationInviteService.getOrganizationInvite.mockResolvedValue(null); + organizationInviteService.getOrganizationInvite.mockResolvedValue(null); const result = await service.getOrgPoliciesFromOrgInvite(); expect(result).toBeUndefined(); }); it("logs an error if getPoliciesByToken throws an error", async () => { const error = new Error("Test error"); - acceptOrganizationInviteService.getOrganizationInvite.mockResolvedValue({ + organizationInviteService.getOrganizationInvite.mockResolvedValue({ organizationId: "org-id", token: "token", email: "email", @@ -117,7 +121,7 @@ describe("WebLoginComponentService", () => { const resetPasswordPolicyOptions = new ResetPasswordPolicyOptions(); resetPasswordPolicyOptions.autoEnrollEnabled = autoEnrollEnabled; - acceptOrganizationInviteService.getOrganizationInvite.mockResolvedValue({ + organizationInviteService.getOrganizationInvite.mockResolvedValue({ organizationId: "org-id", token: "token", email: "email", diff --git a/apps/web/src/app/auth/core/services/login/web-login-component.service.ts b/apps/web/src/app/auth/core/services/login/web-login-component.service.ts index 36e7143ccd0..cf0adb91144 100644 --- a/apps/web/src/app/auth/core/services/login/web-login-component.service.ts +++ b/apps/web/src/app/auth/core/services/login/web-login-component.service.ts @@ -11,18 +11,21 @@ import { } from "@bitwarden/auth/angular"; import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options"; import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.service"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy"; import { RouterService } from "../../../../core/router.service"; -import { AcceptOrganizationInviteService } from "../../../organization-invite/accept-organization.service"; @Injectable() export class WebLoginComponentService @@ -30,7 +33,7 @@ export class WebLoginComponentService implements LoginComponentService { constructor( - protected acceptOrganizationInviteService: AcceptOrganizationInviteService, + protected organizationInviteService: OrganizationInviteService, protected logService: LogService, protected policyApiService: PolicyApiServiceAbstraction, protected policyService: InternalPolicyService, @@ -42,6 +45,7 @@ export class WebLoginComponentService ssoLoginService: SsoLoginServiceAbstraction, private router: Router, private accountService: AccountService, + private configService: ConfigService, ) { super( cryptoFunctionService, @@ -66,8 +70,8 @@ export class WebLoginComponentService return; } - async getOrgPoliciesFromOrgInvite(): Promise { - const orgInvite = await this.acceptOrganizationInviteService.getOrganizationInvite(); + async getOrgPoliciesFromOrgInvite(): Promise { + const orgInvite = await this.organizationInviteService.getOrganizationInvite(); if (orgInvite != null) { let policies: Policy[]; @@ -84,7 +88,7 @@ export class WebLoginComponentService } if (policies == null) { - return; + return undefined; } const resetPasswordPolicy = this.policyService.getResetPasswordPolicyOptions( @@ -95,12 +99,23 @@ export class WebLoginComponentService const isPolicyAndAutoEnrollEnabled = resetPasswordPolicy[1] && resetPasswordPolicy[0].autoEnrollEnabled; - const enforcedPasswordPolicyOptions = await firstValueFrom( - this.accountService.activeAccount$.pipe( - getUserId, - switchMap((userId) => this.policyService.masterPasswordPolicyOptions$(userId, policies)), - ), - ); + let enforcedPasswordPolicyOptions: MasterPasswordPolicyOptions; + + if ( + await this.configService.getFeatureFlag(FeatureFlag.PM16117_ChangeExistingPasswordRefactor) + ) { + enforcedPasswordPolicyOptions = + this.policyService.combinePoliciesIntoMasterPasswordPolicyOptions(policies); + } else { + enforcedPasswordPolicyOptions = await firstValueFrom( + this.accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => + this.policyService.masterPasswordPolicyOptions$(userId, policies), + ), + ), + ); + } return { policies, diff --git a/apps/web/src/app/auth/core/services/organization-invite/web-organization-invite.service.ts b/apps/web/src/app/auth/core/services/organization-invite/web-organization-invite.service.ts new file mode 100644 index 00000000000..b799358dbae --- /dev/null +++ b/apps/web/src/app/auth/core/services/organization-invite/web-organization-invite.service.ts @@ -0,0 +1,38 @@ +import { firstValueFrom } from "rxjs"; + +import { OrganizationInvite } from "@bitwarden/common/auth/services/organization-invite/organization-invite"; +import { ORGANIZATION_INVITE } from "@bitwarden/common/auth/services/organization-invite/organization-invite-state"; +import { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.service"; +import { GlobalState, GlobalStateProvider } from "@bitwarden/common/platform/state"; + +export class WebOrganizationInviteService implements OrganizationInviteService { + private organizationInvitationState: GlobalState; + + constructor(private readonly globalStateProvider: GlobalStateProvider) { + this.organizationInvitationState = this.globalStateProvider.get(ORGANIZATION_INVITE); + } + + /** + * Returns the currently stored organization invite + */ + async getOrganizationInvite(): Promise { + return await firstValueFrom(this.organizationInvitationState.state$); + } + + /** + * Stores a new organization invite + * @param invite an organization invite + * @throws if the invite is nullish + */ + async setOrganizationInvitation(invite: OrganizationInvite): Promise { + if (invite == null) { + throw new Error("Invite cannot be null. Use clearOrganizationInvitation instead."); + } + await this.organizationInvitationState.update(() => invite); + } + + /** Clears the currently stored organization invite */ + async clearOrganizationInvitation(): Promise { + await this.organizationInvitationState.update(() => null); + } +} 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 index b90d0624b3f..b562c54894b 100644 --- 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 @@ -15,6 +15,7 @@ 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 { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.service"; 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"; @@ -25,7 +26,6 @@ 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"; @@ -43,7 +43,7 @@ describe("WebSetInitialPasswordService", () => { let organizationApiService: MockProxy; let organizationUserApiService: MockProxy; let userDecryptionOptionsService: MockProxy; - let acceptOrganizationInviteService: MockProxy; + let organizationInviteService: MockProxy; let routerService: MockProxy; beforeEach(() => { @@ -57,7 +57,7 @@ describe("WebSetInitialPasswordService", () => { organizationApiService = mock(); organizationUserApiService = mock(); userDecryptionOptionsService = mock(); - acceptOrganizationInviteService = mock(); + organizationInviteService = mock(); routerService = mock(); sut = new WebSetInitialPasswordService( @@ -71,7 +71,7 @@ describe("WebSetInitialPasswordService", () => { organizationApiService, organizationUserApiService, userDecryptionOptionsService, - acceptOrganizationInviteService, + organizationInviteService, routerService, ); }); @@ -169,9 +169,7 @@ describe("WebSetInitialPasswordService", () => { // Assert expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(setPasswordRequest); - expect(acceptOrganizationInviteService.clearOrganizationInvitation).toHaveBeenCalledTimes( - 1, - ); + expect(organizationInviteService.clearOrganizationInvitation).toHaveBeenCalledTimes(1); }); }); @@ -201,7 +199,7 @@ describe("WebSetInitialPasswordService", () => { // Assert await expect(promise).rejects.toThrow(); expect(masterPasswordApiService.setPassword).not.toHaveBeenCalled(); - expect(acceptOrganizationInviteService.clearOrganizationInvitation).not.toHaveBeenCalled(); + expect(organizationInviteService.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 index 41e7e8ad4ab..19ddbf5e260 100644 --- 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 @@ -9,12 +9,12 @@ import { InternalUserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth 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 { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.service"; 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 @@ -32,7 +32,7 @@ export class WebSetInitialPasswordService protected organizationApiService: OrganizationApiServiceAbstraction, protected organizationUserApiService: OrganizationUserApiService, protected userDecryptionOptionsService: InternalUserDecryptionOptionsServiceAbstraction, - private acceptOrganizationInviteService: AcceptOrganizationInviteService, + private organizationInviteService: OrganizationInviteService, private routerService: RouterService, ) { super( @@ -78,6 +78,6 @@ export class WebSetInitialPasswordService * as clear the org invite itself that was originally set in state by the AcceptOrganizationComponent. */ await this.routerService.getAndClearLoginRedirectUrl(); - await this.acceptOrganizationInviteService.clearOrganizationInvitation(); + await this.organizationInviteService.clearOrganizationInvitation(); } } diff --git a/apps/web/src/app/auth/core/services/registration/web-registration-finish.service.spec.ts b/apps/web/src/app/auth/core/services/registration/web-registration-finish.service.spec.ts index afaf1bd49d2..e491f95c1b9 100644 --- a/apps/web/src/app/auth/core/services/registration/web-registration-finish.service.spec.ts +++ b/apps/web/src/app/auth/core/services/registration/web-registration-finish.service.spec.ts @@ -9,20 +9,16 @@ import { PolicyService } from "@bitwarden/common/admin-console/abstractions/poli import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options"; import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; import { AccountApiService } from "@bitwarden/common/auth/abstractions/account-api.service"; +import { OrganizationInvite } from "@bitwarden/common/auth/services/organization-invite/organization-invite"; +import { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.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 { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec"; 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, KeyService } from "@bitwarden/key-management"; -import { AcceptOrganizationInviteService } from "../../../organization-invite/accept-organization.service"; -import { OrganizationInvite } from "../../../organization-invite/organization-invite"; - import { WebRegistrationFinishService } from "./web-registration-finish.service"; describe("WebRegistrationFinishService", () => { @@ -30,32 +26,28 @@ describe("WebRegistrationFinishService", () => { let keyService: MockProxy; let accountApiService: MockProxy; - let acceptOrgInviteService: MockProxy; + let organizationInviteService: MockProxy; let policyApiService: MockProxy; let logService: MockProxy; let policyService: MockProxy; let configService: MockProxy; - const mockUserId = Utils.newGuid() as UserId; - let accountService: FakeAccountService; beforeEach(() => { keyService = mock(); accountApiService = mock(); - acceptOrgInviteService = mock(); + organizationInviteService = mock(); policyApiService = mock(); logService = mock(); policyService = mock(); - accountService = mockAccountServiceWith(mockUserId); configService = mock(); service = new WebRegistrationFinishService( keyService, accountApiService, - acceptOrgInviteService, + organizationInviteService, policyApiService, logService, policyService, - accountService, configService, ); }); @@ -76,21 +68,21 @@ describe("WebRegistrationFinishService", () => { }); it("returns null when the org invite is null", async () => { - acceptOrgInviteService.getOrganizationInvite.mockResolvedValue(null); + organizationInviteService.getOrganizationInvite.mockResolvedValue(null); const result = await service.getOrgNameFromOrgInvite(); expect(result).toBeNull(); - expect(acceptOrgInviteService.getOrganizationInvite).toHaveBeenCalled(); + expect(organizationInviteService.getOrganizationInvite).toHaveBeenCalled(); }); it("returns the organization name from the organization invite when it exists", async () => { - acceptOrgInviteService.getOrganizationInvite.mockResolvedValue(orgInvite); + organizationInviteService.getOrganizationInvite.mockResolvedValue(orgInvite); const result = await service.getOrgNameFromOrgInvite(); expect(result).toEqual(orgInvite.organizationName); - expect(acceptOrgInviteService.getOrganizationInvite).toHaveBeenCalled(); + expect(organizationInviteService.getOrganizationInvite).toHaveBeenCalled(); }); }); @@ -106,22 +98,22 @@ describe("WebRegistrationFinishService", () => { }); it("returns null when the org invite is null", async () => { - acceptOrgInviteService.getOrganizationInvite.mockResolvedValue(null); + organizationInviteService.getOrganizationInvite.mockResolvedValue(null); const result = await service.getMasterPasswordPolicyOptsFromOrgInvite(); expect(result).toBeNull(); - expect(acceptOrgInviteService.getOrganizationInvite).toHaveBeenCalled(); + expect(organizationInviteService.getOrganizationInvite).toHaveBeenCalled(); }); it("returns null when the policies are null", async () => { - acceptOrgInviteService.getOrganizationInvite.mockResolvedValue(orgInvite); + organizationInviteService.getOrganizationInvite.mockResolvedValue(orgInvite); policyApiService.getPoliciesByToken.mockResolvedValue(null); const result = await service.getMasterPasswordPolicyOptsFromOrgInvite(); expect(result).toBeNull(); - expect(acceptOrgInviteService.getOrganizationInvite).toHaveBeenCalled(); + expect(organizationInviteService.getOrganizationInvite).toHaveBeenCalled(); expect(policyApiService.getPoliciesByToken).toHaveBeenCalledWith( orgInvite.organizationId, orgInvite.token, @@ -131,13 +123,13 @@ describe("WebRegistrationFinishService", () => { }); it("logs an error and returns null when policies cannot be fetched", async () => { - acceptOrgInviteService.getOrganizationInvite.mockResolvedValue(orgInvite); + organizationInviteService.getOrganizationInvite.mockResolvedValue(orgInvite); policyApiService.getPoliciesByToken.mockRejectedValue(new Error("error")); const result = await service.getMasterPasswordPolicyOptsFromOrgInvite(); expect(result).toBeNull(); - expect(acceptOrgInviteService.getOrganizationInvite).toHaveBeenCalled(); + expect(organizationInviteService.getOrganizationInvite).toHaveBeenCalled(); expect(policyApiService.getPoliciesByToken).toHaveBeenCalledWith( orgInvite.organizationId, orgInvite.token, @@ -151,14 +143,14 @@ describe("WebRegistrationFinishService", () => { const masterPasswordPolicies = [new Policy()]; const masterPasswordPolicyOptions = new MasterPasswordPolicyOptions(); - acceptOrgInviteService.getOrganizationInvite.mockResolvedValue(orgInvite); + organizationInviteService.getOrganizationInvite.mockResolvedValue(orgInvite); policyApiService.getPoliciesByToken.mockResolvedValue(masterPasswordPolicies); policyService.masterPasswordPolicyOptions$.mockReturnValue(of(masterPasswordPolicyOptions)); const result = await service.getMasterPasswordPolicyOptsFromOrgInvite(); expect(result).toEqual(masterPasswordPolicyOptions); - expect(acceptOrgInviteService.getOrganizationInvite).toHaveBeenCalled(); + expect(organizationInviteService.getOrganizationInvite).toHaveBeenCalled(); expect(policyApiService.getPoliciesByToken).toHaveBeenCalledWith( orgInvite.organizationId, orgInvite.token, @@ -225,7 +217,7 @@ describe("WebRegistrationFinishService", () => { keyService.makeUserKey.mockResolvedValue([userKey, userKeyEncString]); keyService.makeKeyPair.mockResolvedValue(userKeyPair); accountApiService.registerFinish.mockResolvedValue(); - acceptOrgInviteService.getOrganizationInvite.mockResolvedValue(null); + organizationInviteService.getOrganizationInvite.mockResolvedValue(null); await service.finishRegistration(email, passwordInputResult, emailVerificationToken); @@ -261,7 +253,7 @@ describe("WebRegistrationFinishService", () => { keyService.makeUserKey.mockResolvedValue([userKey, userKeyEncString]); keyService.makeKeyPair.mockResolvedValue(userKeyPair); accountApiService.registerFinish.mockResolvedValue(); - acceptOrgInviteService.getOrganizationInvite.mockResolvedValue(orgInvite); + organizationInviteService.getOrganizationInvite.mockResolvedValue(orgInvite); await service.finishRegistration(email, passwordInputResult); @@ -297,7 +289,7 @@ describe("WebRegistrationFinishService", () => { keyService.makeUserKey.mockResolvedValue([userKey, userKeyEncString]); keyService.makeKeyPair.mockResolvedValue(userKeyPair); accountApiService.registerFinish.mockResolvedValue(); - acceptOrgInviteService.getOrganizationInvite.mockResolvedValue(null); + organizationInviteService.getOrganizationInvite.mockResolvedValue(null); await service.finishRegistration( email, @@ -338,7 +330,7 @@ describe("WebRegistrationFinishService", () => { keyService.makeUserKey.mockResolvedValue([userKey, userKeyEncString]); keyService.makeKeyPair.mockResolvedValue(userKeyPair); accountApiService.registerFinish.mockResolvedValue(); - acceptOrgInviteService.getOrganizationInvite.mockResolvedValue(null); + organizationInviteService.getOrganizationInvite.mockResolvedValue(null); await service.finishRegistration( email, @@ -381,7 +373,7 @@ describe("WebRegistrationFinishService", () => { keyService.makeUserKey.mockResolvedValue([userKey, userKeyEncString]); keyService.makeKeyPair.mockResolvedValue(userKeyPair); accountApiService.registerFinish.mockResolvedValue(); - acceptOrgInviteService.getOrganizationInvite.mockResolvedValue(null); + organizationInviteService.getOrganizationInvite.mockResolvedValue(null); await service.finishRegistration( email, diff --git a/apps/web/src/app/auth/core/services/registration/web-registration-finish.service.ts b/apps/web/src/app/auth/core/services/registration/web-registration-finish.service.ts index 05b8ab5cb0f..d6f0a27a79c 100644 --- a/apps/web/src/app/auth/core/services/registration/web-registration-finish.service.ts +++ b/apps/web/src/app/auth/core/services/registration/web-registration-finish.service.ts @@ -12,16 +12,14 @@ import { PolicyService } from "@bitwarden/common/admin-console/abstractions/poli import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options"; import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; import { AccountApiService } from "@bitwarden/common/auth/abstractions/account-api.service"; -import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { RegisterFinishRequest } from "@bitwarden/common/auth/models/request/registration/register-finish.request"; +import { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.service"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { EncryptedString, EncString } from "@bitwarden/common/platform/models/domain/enc-string"; import { KeyService } from "@bitwarden/key-management"; -import { AcceptOrganizationInviteService } from "../../../organization-invite/accept-organization.service"; - export class WebRegistrationFinishService extends DefaultRegistrationFinishService implements RegistrationFinishService @@ -29,18 +27,17 @@ export class WebRegistrationFinishService constructor( protected keyService: KeyService, protected accountApiService: AccountApiService, - private acceptOrgInviteService: AcceptOrganizationInviteService, + private organizationInviteService: OrganizationInviteService, private policyApiService: PolicyApiServiceAbstraction, private logService: LogService, private policyService: PolicyService, - private accountService: AccountService, private configService: ConfigService, ) { super(keyService, accountApiService); } override async getOrgNameFromOrgInvite(): Promise { - const orgInvite = await this.acceptOrgInviteService.getOrganizationInvite(); + const orgInvite = await this.organizationInviteService.getOrganizationInvite(); if (orgInvite == null) { return null; } @@ -50,7 +47,7 @@ export class WebRegistrationFinishService override async getMasterPasswordPolicyOptsFromOrgInvite(): Promise { // If there's a deep linked org invite, use it to get the password policies - const orgInvite = await this.acceptOrgInviteService.getOrganizationInvite(); + const orgInvite = await this.organizationInviteService.getOrganizationInvite(); if (orgInvite == null) { return null; @@ -115,7 +112,7 @@ export class WebRegistrationFinishService // web specific logic // Org invites are deep linked. Non-existent accounts are redirected to the register page. // Org user id and token are included here only for validation and two factor purposes. - const orgInvite = await this.acceptOrgInviteService.getOrganizationInvite(); + const orgInvite = await this.organizationInviteService.getOrganizationInvite(); if (orgInvite != null) { registerRequest.organizationUserId = orgInvite.organizationUserId; registerRequest.orgInviteToken = orgInvite.token; diff --git a/apps/web/src/app/auth/core/services/set-password-jit/web-set-password-jit.service.ts b/apps/web/src/app/auth/core/services/set-password-jit/web-set-password-jit.service.ts index 62175f1256d..3078b8e3b83 100644 --- a/apps/web/src/app/auth/core/services/set-password-jit/web-set-password-jit.service.ts +++ b/apps/web/src/app/auth/core/services/set-password-jit/web-set-password-jit.service.ts @@ -5,16 +5,16 @@ import { SetPasswordCredentials, SetPasswordJitService, } from "@bitwarden/auth/angular"; +import { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.service"; 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); + organizationInviteService = inject(OrganizationInviteService); override async setPassword(credentials: SetPasswordCredentials) { await super.setPassword(credentials); @@ -22,6 +22,6 @@ export class WebSetPasswordJitService // 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(); + await this.organizationInviteService.clearOrganizationInvitation(); } } diff --git a/apps/web/src/app/auth/organization-invite/accept-organization.component.ts b/apps/web/src/app/auth/organization-invite/accept-organization.component.ts index 838a3029711..b60007ca91e 100644 --- a/apps/web/src/app/auth/organization-invite/accept-organization.component.ts +++ b/apps/web/src/app/auth/organization-invite/accept-organization.component.ts @@ -4,13 +4,14 @@ import { Component } from "@angular/core"; import { ActivatedRoute, Params, Router } from "@angular/router"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { OrganizationInvite } from "@bitwarden/common/auth/services/organization-invite/organization-invite"; +import { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { BaseAcceptComponent } from "../../common/base.accept.component"; import { AcceptOrganizationInviteService } from "./accept-organization.service"; -import { OrganizationInvite } from "./organization-invite"; @Component({ templateUrl: "accept-organization.component.html", @@ -21,18 +22,19 @@ export class AcceptOrganizationComponent extends BaseAcceptComponent { protected requiredParameters: string[] = ["organizationId", "organizationUserId", "token"]; constructor( - router: Router, - platformUtilsService: PlatformUtilsService, - i18nService: I18nService, - route: ActivatedRoute, - authService: AuthService, + protected router: Router, + protected platformUtilsService: PlatformUtilsService, + protected i18nService: I18nService, + protected route: ActivatedRoute, + protected authService: AuthService, private acceptOrganizationInviteService: AcceptOrganizationInviteService, + private organizationInviteService: OrganizationInviteService, ) { super(router, platformUtilsService, i18nService, route, authService); } async authedHandler(qParams: Params): Promise { - const invite = OrganizationInvite.fromParams(qParams); + const invite = this.fromParams(qParams); const success = await this.acceptOrganizationInviteService.validateAndAcceptInvite(invite); if (!success) { @@ -52,9 +54,9 @@ export class AcceptOrganizationComponent extends BaseAcceptComponent { } async unauthedHandler(qParams: Params): Promise { - const invite = OrganizationInvite.fromParams(qParams); + const invite = this.fromParams(qParams); - await this.acceptOrganizationInviteService.setOrganizationInvitation(invite); + await this.organizationInviteService.setOrganizationInvitation(invite); await this.navigateInviteAcceptance(invite); } @@ -94,4 +96,21 @@ export class AcceptOrganizationComponent extends BaseAcceptComponent { }); return; } + + private fromParams(params: Params): OrganizationInvite | null { + if (params == null) { + return null; + } + + return Object.assign(new OrganizationInvite(), { + email: params.email, + initOrganization: params.initOrganization?.toLocaleLowerCase() === "true", + orgSsoIdentifier: params.orgSsoIdentifier, + orgUserHasExistingUser: params.orgUserHasExistingUser?.toLocaleLowerCase() === "true", + organizationId: params.organizationId, + organizationName: params.organizationName, + organizationUserId: params.organizationUserId, + token: params.token, + }); + } } diff --git a/apps/web/src/app/auth/organization-invite/accept-organization.service.spec.ts b/apps/web/src/app/auth/organization-invite/accept-organization.service.spec.ts index 253328b0c04..2fd869049bb 100644 --- a/apps/web/src/app/auth/organization-invite/accept-organization.service.spec.ts +++ b/apps/web/src/app/auth/organization-invite/accept-organization.service.spec.ts @@ -1,6 +1,5 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { FakeGlobalStateProvider } from "@bitwarden/common/../spec/fake-state-provider"; import { MockProxy, mock } from "jest-mock-extended"; import { BehaviorSubject } from "rxjs"; @@ -15,22 +14,18 @@ import { ResetPasswordPolicyOptions } from "@bitwarden/common/admin-console/mode import { OrganizationKeysResponse } from "@bitwarden/common/admin-console/models/response/organization-keys.response"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { OrganizationInvite } from "@bitwarden/common/auth/services/organization-invite/organization-invite"; +import { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; -import { FakeGlobalState } from "@bitwarden/common/spec/fake-state"; import { OrgKey } from "@bitwarden/common/types/key"; -import { DialogService } from "@bitwarden/components"; import { KeyService } from "@bitwarden/key-management"; import { I18nService } from "../../core/i18n.service"; -import { - AcceptOrganizationInviteService, - ORGANIZATION_INVITE, -} from "./accept-organization.service"; -import { OrganizationInvite } from "./organization-invite"; +import { AcceptOrganizationInviteService } from "./accept-organization.service"; describe("AcceptOrganizationInviteService", () => { let sut: AcceptOrganizationInviteService; @@ -43,10 +38,8 @@ describe("AcceptOrganizationInviteService", () => { let logService: MockProxy; let organizationApiService: MockProxy; let organizationUserApiService: MockProxy; + let organizationInviteService: MockProxy; let i18nService: MockProxy; - let globalStateProvider: FakeGlobalStateProvider; - let globalState: FakeGlobalState; - let dialogService: MockProxy; let accountService: MockProxy; beforeEach(() => { @@ -59,10 +52,8 @@ describe("AcceptOrganizationInviteService", () => { logService = mock(); organizationApiService = mock(); organizationUserApiService = mock(); + organizationInviteService = mock(); i18nService = mock(); - globalStateProvider = new FakeGlobalStateProvider(); - globalState = globalStateProvider.getFake(ORGANIZATION_INVITE); - dialogService = mock(); accountService = mock(); sut = new AcceptOrganizationInviteService( @@ -76,8 +67,7 @@ describe("AcceptOrganizationInviteService", () => { organizationApiService, organizationUserApiService, i18nService, - globalStateProvider, - dialogService, + organizationInviteService, accountService, ); }); @@ -103,8 +93,10 @@ describe("AcceptOrganizationInviteService", () => { expect(result).toBe(true); expect(organizationUserApiService.postOrganizationUserAcceptInit).toHaveBeenCalled(); expect(apiService.refreshIdentityToken).toHaveBeenCalled(); - expect(globalState.nextMock).toHaveBeenCalledWith(null); expect(organizationUserApiService.postOrganizationUserAccept).not.toHaveBeenCalled(); + expect(organizationInviteService.getOrganizationInvite).not.toHaveBeenCalled(); + expect(organizationInviteService.setOrganizationInvitation).not.toHaveBeenCalled(); + expect(organizationInviteService.clearOrganizationInvitation).toHaveBeenCalled(); expect(authService.logOut).not.toHaveBeenCalled(); }); @@ -121,13 +113,16 @@ describe("AcceptOrganizationInviteService", () => { expect(result).toBe(false); expect(authService.logOut).toHaveBeenCalled(); - expect(globalState.nextMock).toHaveBeenCalledWith(invite); + expect(organizationInviteService.setOrganizationInvitation).toHaveBeenCalledWith(invite); + expect(organizationInviteService.clearOrganizationInvitation).toHaveBeenCalled(); }); it("clears the stored invite when a master password policy check is required but the stored invite doesn't match the provided one", async () => { const storedInvite = createOrgInvite({ email: "wrongemail@example.com" }); const providedInvite = createOrgInvite(); - await globalState.update(() => storedInvite); + organizationInviteService.getOrganizationInvite.mockReturnValueOnce( + Promise.resolve(storedInvite), + ); policyApiService.getPoliciesByToken.mockResolvedValue([ { type: PolicyType.MasterPassword, @@ -139,7 +134,11 @@ describe("AcceptOrganizationInviteService", () => { expect(result).toBe(false); expect(authService.logOut).toHaveBeenCalled(); - expect(globalState.nextMock).toHaveBeenCalledWith(providedInvite); + expect(organizationInviteService.setOrganizationInvitation).toHaveBeenCalledWith( + providedInvite, + ); + expect(organizationInviteService.getOrganizationInvite).toHaveBeenCalledWith(); + expect(organizationInviteService.clearOrganizationInvitation).toHaveBeenCalled(); }); it("accepts the invitation request when the organization doesn't have a master password policy", async () => { @@ -151,8 +150,10 @@ describe("AcceptOrganizationInviteService", () => { expect(result).toBe(true); expect(organizationUserApiService.postOrganizationUserAccept).toHaveBeenCalled(); expect(apiService.refreshIdentityToken).toHaveBeenCalled(); - expect(globalState.nextMock).toHaveBeenCalledWith(null); expect(organizationUserApiService.postOrganizationUserAcceptInit).not.toHaveBeenCalled(); + expect(organizationInviteService.setOrganizationInvitation).not.toHaveBeenCalled(); + expect(organizationInviteService.getOrganizationInvite).not.toHaveBeenCalled(); + expect(organizationInviteService.clearOrganizationInvitation).toHaveBeenCalled(); expect(authService.logOut).not.toHaveBeenCalled(); }); @@ -165,7 +166,7 @@ describe("AcceptOrganizationInviteService", () => { } as Policy, ]); // an existing invite means the user has already passed the master password policy - await globalState.update(() => invite); + organizationInviteService.getOrganizationInvite.mockReturnValueOnce(Promise.resolve(invite)); policyService.getResetPasswordPolicyOptions.mockReturnValue([ { @@ -179,6 +180,8 @@ describe("AcceptOrganizationInviteService", () => { expect(result).toBe(true); expect(organizationUserApiService.postOrganizationUserAccept).toHaveBeenCalled(); expect(organizationUserApiService.postOrganizationUserAcceptInit).not.toHaveBeenCalled(); + expect(organizationInviteService.getOrganizationInvite).toHaveBeenCalledWith(); + expect(organizationInviteService.clearOrganizationInvitation).toHaveBeenCalled(); expect(authService.logOut).not.toHaveBeenCalled(); }); @@ -202,7 +205,7 @@ describe("AcceptOrganizationInviteService", () => { encryptedString: "encryptedString", } as EncString); - await globalState.update(() => invite); + organizationInviteService.getOrganizationInvite.mockReturnValueOnce(Promise.resolve(invite)); policyService.getResetPasswordPolicyOptions.mockReturnValue([ { @@ -220,6 +223,9 @@ describe("AcceptOrganizationInviteService", () => { ); expect(organizationUserApiService.postOrganizationUserAccept).toHaveBeenCalled(); expect(organizationUserApiService.postOrganizationUserAcceptInit).not.toHaveBeenCalled(); + expect(organizationInviteService.getOrganizationInvite).toHaveBeenCalledTimes(1); + expect(organizationInviteService.getOrganizationInvite).toHaveBeenCalledWith(); + expect(organizationInviteService.clearOrganizationInvitation).toHaveBeenCalled(); expect(authService.logOut).not.toHaveBeenCalled(); }); }); diff --git a/apps/web/src/app/auth/organization-invite/accept-organization.service.ts b/apps/web/src/app/auth/organization-invite/accept-organization.service.ts index c68b174166d..a5f5eb828fa 100644 --- a/apps/web/src/app/auth/organization-invite/accept-organization.service.ts +++ b/apps/web/src/app/auth/organization-invite/accept-organization.service.ts @@ -17,36 +17,17 @@ import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; import { OrganizationKeysRequest } from "@bitwarden/common/admin-console/models/request/organization-keys.request"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { OrganizationInvite } from "@bitwarden/common/auth/services/organization-invite/organization-invite"; +import { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; -import { - GlobalState, - GlobalStateProvider, - KeyDefinition, - ORGANIZATION_INVITE_DISK, -} from "@bitwarden/common/platform/state"; import { OrgKey } from "@bitwarden/common/types/key"; -import { DialogService } from "@bitwarden/components"; import { KeyService } from "@bitwarden/key-management"; -import { OrganizationInvite } from "./organization-invite"; - -// We're storing the organization invite for 2 reasons: -// 1. If the org requires a MP policy check, we need to keep track that the user has already been redirected when they return. -// 2. The MP policy check happens on login/register flows, we need to store the token to retrieve the policies then. -export const ORGANIZATION_INVITE = new KeyDefinition( - ORGANIZATION_INVITE_DISK, - "organizationInvite", - { - deserializer: (invite) => (invite ? OrganizationInvite.fromJSON(invite) : null), - }, -); - @Injectable() export class AcceptOrganizationInviteService { - private organizationInvitationState: GlobalState; private orgNameSubject: BehaviorSubject = new BehaviorSubject(null); private policyCache: Policy[]; @@ -64,34 +45,9 @@ export class AcceptOrganizationInviteService { private readonly organizationApiService: OrganizationApiServiceAbstraction, private readonly organizationUserApiService: OrganizationUserApiService, private readonly i18nService: I18nService, - private readonly globalStateProvider: GlobalStateProvider, - private readonly dialogService: DialogService, + private readonly organizationInviteService: OrganizationInviteService, private readonly accountService: AccountService, - ) { - this.organizationInvitationState = this.globalStateProvider.get(ORGANIZATION_INVITE); - } - - /** Returns the currently stored organization invite */ - async getOrganizationInvite(): Promise { - return await firstValueFrom(this.organizationInvitationState.state$); - } - - /** - * Stores a new organization invite - * @param invite an organization invite - * @throws if the invite is nullish - */ - async setOrganizationInvitation(invite: OrganizationInvite): Promise { - if (invite == null) { - throw new Error("Invite cannot be null. Use clearOrganizationInvitation instead."); - } - await this.organizationInvitationState.update(() => invite); - } - - /** Clears the currently stored organization invite */ - async clearOrganizationInvitation(): Promise { - await this.organizationInvitationState.update(() => null); - } + ) {} /** * Validates and accepts the organization invitation if possible. @@ -113,7 +69,7 @@ export class AcceptOrganizationInviteService { // Accepting an org invite from existing org if (await this.masterPasswordPolicyCheckRequired(invite)) { - await this.setOrganizationInvitation(invite); + await this.organizationInviteService.setOrganizationInvitation(invite); this.authService.logOut(() => { /* Do nothing */ }); @@ -134,7 +90,7 @@ export class AcceptOrganizationInviteService { ), ); await this.apiService.refreshIdentityToken(); - await this.clearOrganizationInvitation(); + await this.organizationInviteService.clearOrganizationInvitation(); } private async prepareAcceptAndInitRequest( @@ -170,7 +126,7 @@ export class AcceptOrganizationInviteService { ); await this.apiService.refreshIdentityToken(); - await this.clearOrganizationInvitation(); + await this.organizationInviteService.clearOrganizationInvitation(); } private async prepareAcceptRequest( @@ -224,10 +180,10 @@ export class AcceptOrganizationInviteService { (p) => p.type === PolicyType.MasterPassword && p.enabled, ); - let storedInvite = await this.getOrganizationInvite(); + let storedInvite = await this.organizationInviteService.getOrganizationInvite(); if (storedInvite?.email !== invite.email) { // clear stored invites if the email doesn't match - await this.clearOrganizationInvitation(); + await this.organizationInviteService.clearOrganizationInvitation(); storedInvite = null; } // if we don't have an org invite stored, we know the user hasn't been redirected yet to check the MP policy diff --git a/apps/web/src/app/auth/organization-invite/organization-invite.ts b/apps/web/src/app/auth/organization-invite/organization-invite.ts deleted file mode 100644 index 65414113e74..00000000000 --- a/apps/web/src/app/auth/organization-invite/organization-invite.ts +++ /dev/null @@ -1,40 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { Params } from "@angular/router"; -import { Jsonify } from "type-fest"; - -export class OrganizationInvite { - email: string; - initOrganization: boolean; - orgSsoIdentifier: string; - orgUserHasExistingUser: boolean; - organizationId: string; - organizationName: string; - organizationUserId: string; - token: string; - - static fromJSON(json: Jsonify): OrganizationInvite | null { - if (json == null) { - return null; - } - - return Object.assign(new OrganizationInvite(), json); - } - - static fromParams(params: Params): OrganizationInvite | null { - if (params == null) { - return null; - } - - return Object.assign(new OrganizationInvite(), { - email: params.email, - initOrganization: params.initOrganization?.toLocaleLowerCase() === "true", - orgSsoIdentifier: params.orgSsoIdentifier, - orgUserHasExistingUser: params.orgUserHasExistingUser?.toLocaleLowerCase() === "true", - organizationId: params.organizationId, - organizationName: params.organizationName, - organizationUserId: params.organizationUserId, - token: params.token, - }); - } -} diff --git a/apps/web/src/app/auth/set-password.component.ts b/apps/web/src/app/auth/set-password.component.ts index e297426f2c1..f61981a93d3 100644 --- a/apps/web/src/app/auth/set-password.component.ts +++ b/apps/web/src/app/auth/set-password.component.ts @@ -1,13 +1,12 @@ import { Component, inject } from "@angular/core"; import { SetPasswordComponent as BaseSetPasswordComponent } from "@bitwarden/angular/auth/components/set-password.component"; +import { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.service"; import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; import { MasterKey, UserKey } from "@bitwarden/common/types/key"; import { RouterService } from "../core"; -import { AcceptOrganizationInviteService } from "./organization-invite/accept-organization.service"; - @Component({ selector: "app-set-password", templateUrl: "set-password.component.html", @@ -15,7 +14,7 @@ import { AcceptOrganizationInviteService } from "./organization-invite/accept-or }) export class SetPasswordComponent extends BaseSetPasswordComponent { routerService = inject(RouterService); - acceptOrganizationInviteService = inject(AcceptOrganizationInviteService); + organizationInviteService = inject(OrganizationInviteService); protected override async onSetPasswordSuccess( masterKey: MasterKey, @@ -26,6 +25,6 @@ export class SetPasswordComponent extends BaseSetPasswordComponent { // 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(); + await this.organizationInviteService.clearOrganizationInvitation(); } } diff --git a/apps/web/src/app/auth/settings/change-password.component.ts b/apps/web/src/app/auth/settings/change-password.component.ts index 15d106057ba..ce10a0e5a34 100644 --- a/apps/web/src/app/auth/settings/change-password.component.ts +++ b/apps/web/src/app/auth/settings/change-password.component.ts @@ -43,34 +43,34 @@ export class ChangePasswordComponent characterMinimumMessage = ""; constructor( - i18nService: I18nService, - keyService: KeyService, - messagingService: MessagingService, - platformUtilsService: PlatformUtilsService, - policyService: PolicyService, private auditService: AuditService, private cipherService: CipherService, - private syncService: SyncService, + private keyRotationService: UserKeyRotationService, private masterPasswordApiService: MasterPasswordApiService, private router: Router, - dialogService: DialogService, + private syncService: SyncService, private userVerificationService: UserVerificationService, - private keyRotationService: UserKeyRotationService, - kdfConfigService: KdfConfigService, + protected accountService: AccountService, + protected dialogService: DialogService, + protected i18nService: I18nService, + protected kdfConfigService: KdfConfigService, + protected keyService: KeyService, protected masterPasswordService: InternalMasterPasswordServiceAbstraction, - accountService: AccountService, - toastService: ToastService, + protected messagingService: MessagingService, + protected platformUtilsService: PlatformUtilsService, + protected policyService: PolicyService, + protected toastService: ToastService, ) { super( + accountService, + dialogService, i18nService, + kdfConfigService, keyService, + masterPasswordService, messagingService, platformUtilsService, policyService, - dialogService, - kdfConfigService, - masterPasswordService, - accountService, toastService, ); } @@ -244,8 +244,7 @@ export class ChangePasswordComponent await this.masterPasswordApiService.postPassword(request); this.toastService.showToast({ variant: "success", - title: this.i18nService.t("masterPasswordChanged"), - message: this.i18nService.t("masterPasswordChangedDesc"), + message: this.i18nService.t("masterPasswordChanged"), }); this.messagingService.send("logout"); } catch { diff --git a/apps/web/src/app/auth/settings/emergency-access/takeover/emergency-access-takeover.component.ts b/apps/web/src/app/auth/settings/emergency-access/takeover/emergency-access-takeover.component.ts index d683545db59..ede60887725 100644 --- a/apps/web/src/app/auth/settings/emergency-access/takeover/emergency-access-takeover.component.ts +++ b/apps/web/src/app/auth/settings/emergency-access/takeover/emergency-access-takeover.component.ts @@ -71,15 +71,15 @@ export class EmergencyAccessTakeoverComponent protected toastService: ToastService, ) { super( + accountService, + dialogService, i18nService, + kdfConfigService, keyService, + masterPasswordService, messagingService, platformUtilsService, policyService, - dialogService, - kdfConfigService, - masterPasswordService, - accountService, toastService, ); } diff --git a/apps/web/src/app/auth/settings/security/password-settings/password-settings.component.html b/apps/web/src/app/auth/settings/security/password-settings/password-settings.component.html index fc6620762f9..b918e113e46 100644 --- a/apps/web/src/app/auth/settings/security/password-settings/password-settings.component.html +++ b/apps/web/src/app/auth/settings/security/password-settings/password-settings.component.html @@ -1,7 +1,6 @@

{{ "changeMasterPassword" | i18n }}

- {{ "loggedOutWarning" | i18n }}
diff --git a/apps/web/src/app/auth/settings/security/password-settings/password-settings.component.ts b/apps/web/src/app/auth/settings/security/password-settings/password-settings.component.ts index d94df18136e..0698ffe1f8d 100644 --- a/apps/web/src/app/auth/settings/security/password-settings/password-settings.component.ts +++ b/apps/web/src/app/auth/settings/security/password-settings/password-settings.component.ts @@ -2,7 +2,8 @@ import { Component, OnInit } from "@angular/core"; import { Router } from "@angular/router"; import { firstValueFrom } from "rxjs"; -import { ChangePasswordComponent, InputPasswordFlow } from "@bitwarden/auth/angular"; +import { ChangePasswordComponent } from "@bitwarden/angular/auth/password-management/change-password"; +import { InputPasswordFlow } from "@bitwarden/auth/angular"; import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common"; import { CalloutModule } from "@bitwarden/components"; import { I18nPipe } from "@bitwarden/ui-common"; @@ -16,6 +17,7 @@ import { WebauthnLoginSettingsModule } from "../../webauthn-login-settings"; }) export class PasswordSettingsComponent implements OnInit { inputPasswordFlow = InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation; + changePasswordFeatureFlag = false; constructor( private router: Router, diff --git a/apps/web/src/app/auth/update-password.component.ts b/apps/web/src/app/auth/update-password.component.ts index c975f7c4168..bc53f824228 100644 --- a/apps/web/src/app/auth/update-password.component.ts +++ b/apps/web/src/app/auth/update-password.component.ts @@ -1,11 +1,10 @@ import { Component, inject } from "@angular/core"; import { UpdatePasswordComponent as BaseUpdatePasswordComponent } from "@bitwarden/angular/auth/components/update-password.component"; +import { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.service"; import { RouterService } from "../core"; -import { AcceptOrganizationInviteService } from "./organization-invite/accept-organization.service"; - @Component({ selector: "app-update-password", templateUrl: "update-password.component.html", @@ -13,13 +12,13 @@ import { AcceptOrganizationInviteService } from "./organization-invite/accept-or }) export class UpdatePasswordComponent extends BaseUpdatePasswordComponent { private routerService = inject(RouterService); - private acceptOrganizationInviteService = inject(AcceptOrganizationInviteService); + private organizationInviteService = inject(OrganizationInviteService); override async cancel() { // clearing the login redirect url so that the user // does not join the organization if they cancel await this.routerService.getAndClearLoginRedirectUrl(); - await this.acceptOrganizationInviteService.clearOrganizationInvitation(); + await this.organizationInviteService.clearOrganizationInvitation(); await super.cancel(); } } diff --git a/apps/web/src/app/billing/trial-initiation/complete-trial-initiation/complete-trial-initiation.component.ts b/apps/web/src/app/billing/trial-initiation/complete-trial-initiation/complete-trial-initiation.component.ts index 2b927f6db09..ce02ee8715a 100644 --- a/apps/web/src/app/billing/trial-initiation/complete-trial-initiation/complete-trial-initiation.component.ts +++ b/apps/web/src/app/billing/trial-initiation/complete-trial-initiation/complete-trial-initiation.component.ts @@ -18,6 +18,7 @@ import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/mod import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.service"; import { OrganizationBillingServiceAbstraction as OrganizationBillingService, OrganizationInformation, @@ -31,7 +32,6 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service" import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; import { ToastService } from "@bitwarden/components"; -import { AcceptOrganizationInviteService } from "../../../auth/organization-invite/accept-organization.service"; import { OrganizationCreatedEvent, SubscriptionProduct, @@ -115,7 +115,7 @@ export class CompleteTrialInitiationComponent implements OnInit, OnDestroy { private i18nService: I18nService, private routerService: RouterService, private organizationBillingService: OrganizationBillingService, - private acceptOrganizationInviteService: AcceptOrganizationInviteService, + private organizationInviteService: OrganizationInviteService, private toastService: ToastService, private registrationFinishService: RegistrationFinishService, private validationService: ValidationService, @@ -174,7 +174,7 @@ export class CompleteTrialInitiationComponent implements OnInit, OnDestroy { this.setupFamilySponsorship(qParams.sponsorshipToken); }); - const invite = await this.acceptOrganizationInviteService.getOrganizationInvite(); + const invite = await this.organizationInviteService.getOrganizationInvite(); let policies: Policy[] | null = null; if (invite != null) { diff --git a/apps/web/src/app/core/core.module.ts b/apps/web/src/app/core/core.module.ts index b8baa762e91..9cfe3117d40 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 { ChangePasswordService } from "@bitwarden/angular/auth/password-management/change-password"; 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 { @@ -34,7 +35,6 @@ import { SsoComponentService, LoginDecryptionOptionsService, TwoFactorAuthDuoComponentService, - ChangePasswordService, } from "@bitwarden/auth/angular"; import { InternalUserDecryptionOptionsServiceAbstraction, @@ -52,6 +52,7 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction"; import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; +import { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.service"; import { ClientType } from "@bitwarden/common/enums"; import { ProcessReloadServiceAbstraction } from "@bitwarden/common/key-management/abstractions/process-reload.service"; import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; @@ -108,6 +109,7 @@ import { } from "@bitwarden/key-management"; import { LockComponentService } from "@bitwarden/key-management-ui"; import { DefaultSshImportPromptService, SshImportPromptService } from "@bitwarden/vault"; +import { WebOrganizationInviteService } from "@bitwarden/web-vault/app/auth/core/services/organization-invite/web-organization-invite.service"; import { flagEnabled } from "../../utils/flags"; import { PolicyListService } from "../admin-console/core/policy-list.service"; @@ -122,7 +124,6 @@ import { WebSetInitialPasswordService, } from "../auth"; import { WebSsoComponentService } from "../auth/core/services/login/web-sso-component.service"; -import { AcceptOrganizationInviteService } from "../auth/organization-invite/accept-organization.service"; import { HtmlStorageService } from "../core/html-storage.service"; import { I18nService } from "../core/i18n.service"; import { WebFileDownloadService } from "../core/web-file-download.service"; @@ -246,17 +247,21 @@ const safeProviders: SafeProvider[] = [ provide: CLIENT_TYPE, useValue: ClientType.Web, }), + safeProvider({ + provide: OrganizationInviteService, + useClass: WebOrganizationInviteService, + deps: [GlobalStateProvider], + }), safeProvider({ provide: RegistrationFinishServiceAbstraction, useClass: WebRegistrationFinishService, deps: [ KeyServiceAbstraction, AccountApiServiceAbstraction, - AcceptOrganizationInviteService, + OrganizationInviteService, PolicyApiServiceAbstraction, LogService, PolicyService, - AccountService, ConfigService, ], }), @@ -275,12 +280,11 @@ const safeProviders: SafeProvider[] = [ provide: SetPasswordJitService, useClass: WebSetPasswordJitService, deps: [ - ApiService, - MasterPasswordApiService, - KeyServiceAbstraction, EncryptService, I18nServiceAbstraction, KdfConfigService, + KeyServiceAbstraction, + MasterPasswordApiService, InternalMasterPasswordServiceAbstraction, OrganizationApiServiceAbstraction, OrganizationUserApiService, @@ -301,7 +305,7 @@ const safeProviders: SafeProvider[] = [ OrganizationApiServiceAbstraction, OrganizationUserApiService, InternalUserDecryptionOptionsServiceAbstraction, - AcceptOrganizationInviteService, + OrganizationInviteService, RouterService, ], }), @@ -314,7 +318,7 @@ const safeProviders: SafeProvider[] = [ provide: LoginComponentService, useClass: WebLoginComponentService, deps: [ - AcceptOrganizationInviteService, + OrganizationInviteService, LogService, PolicyApiServiceAbstraction, InternalPolicyService, @@ -326,6 +330,7 @@ const safeProviders: SafeProvider[] = [ SsoLoginServiceAbstraction, Router, AccountService, + ConfigService, ], }), safeProvider({ @@ -378,7 +383,7 @@ const safeProviders: SafeProvider[] = [ safeProvider({ provide: LoginDecryptionOptionsService, useClass: WebLoginDecryptionOptionsService, - deps: [MessagingService, RouterService, AcceptOrganizationInviteService], + deps: [MessagingService, RouterService, OrganizationInviteService], }), safeProvider({ provide: IpcService, @@ -398,6 +403,7 @@ const safeProviders: SafeProvider[] = [ MasterPasswordApiService, InternalMasterPasswordServiceAbstraction, UserKeyRotationService, + RouterService, ], }), ]; diff --git a/apps/web/src/app/oss-routing.module.ts b/apps/web/src/app/oss-routing.module.ts index 31b9ca26e70..d3e7fc495ca 100644 --- a/apps/web/src/app/oss-routing.module.ts +++ b/apps/web/src/app/oss-routing.module.ts @@ -10,6 +10,7 @@ import { unauthGuardFn, activeAuthGuard, } from "@bitwarden/angular/auth/guards"; +import { ChangePasswordComponent } from "@bitwarden/angular/auth/password-management/change-password"; 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 { @@ -144,13 +145,29 @@ const routes: Routes = [ { path: "update-temp-password", component: UpdateTempPasswordComponent, - canActivate: [authGuard], + canActivate: [ + canAccessFeature( + FeatureFlag.PM16117_ChangeExistingPasswordRefactor, + false, + "change-password", + false, + ), + authGuard, + ], data: { titleId: "updateTempPassword" } satisfies RouteDataProperties, }, { path: "update-password", component: UpdatePasswordComponent, - canActivate: [authGuard], + canActivate: [ + canAccessFeature( + FeatureFlag.PM16117_ChangeExistingPasswordRefactor, + false, + "change-password", + false, + ), + authGuard, + ], data: { titleId: "updatePassword" } satisfies RouteDataProperties, }, ], @@ -580,6 +597,14 @@ const routes: Routes = [ }, ], }, + { + path: "change-password", + component: ChangePasswordComponent, + canActivate: [ + canAccessFeature(FeatureFlag.PM16117_ChangeExistingPasswordRefactor), + authGuard, + ], + }, { path: "setup-extension", data: { diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index bc2e49e85cd..50a2cdbc4a9 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -1785,6 +1785,9 @@ "loggedOutWarning": { "message": "Proceeding will log you out of your current session, requiring you to log back in. Active sessions on other devices may continue to remain active for up to one hour." }, + "changePasswordWarning": { + "message": "After changing your password, you will need to log in with your new password. Active sessions on other devices will be logged out within one hour." + }, "emailChanged": { "message": "Email saved" }, @@ -6077,6 +6080,12 @@ "updateMasterPassword": { "message": "Update master password" }, + "accountRecoveryUpdateMasterPasswordSubtitle": { + "message": "Change your master password to complete account recovery." + }, + "updateMasterPasswordSubtitle": { + "message": "Your master password does not meet this organization’s requirements. Change your master password to continue." + }, "updateMasterPasswordWarning": { "message": "Your master password was recently changed by an administrator in your organization. In order to access the vault, you must update your master password now. Proceeding will log you out of your current session, requiring you to log back in. Active sessions on other devices may continue to remain active for up to one hour." }, diff --git a/libs/angular/src/auth/components/change-password.component.ts b/libs/angular/src/auth/components/change-password.component.ts index ca81f741b23..6adb684681c 100644 --- a/libs/angular/src/auth/components/change-password.component.ts +++ b/libs/angular/src/auth/components/change-password.component.ts @@ -37,15 +37,15 @@ export class ChangePasswordComponent implements OnInit, OnDestroy { protected destroy$ = new Subject(); constructor( + protected accountService: AccountService, + protected dialogService: DialogService, protected i18nService: I18nService, + protected kdfConfigService: KdfConfigService, protected keyService: KeyService, + protected masterPasswordService: InternalMasterPasswordServiceAbstraction, protected messagingService: MessagingService, protected platformUtilsService: PlatformUtilsService, protected policyService: PolicyService, - protected dialogService: DialogService, - protected kdfConfigService: KdfConfigService, - protected masterPasswordService: InternalMasterPasswordServiceAbstraction, - protected accountService: AccountService, protected toastService: ToastService, ) {} diff --git a/libs/angular/src/auth/components/set-password.component.ts b/libs/angular/src/auth/components/set-password.component.ts index 53f6abaa33c..1550b648734 100644 --- a/libs/angular/src/auth/components/set-password.component.ts +++ b/libs/angular/src/auth/components/set-password.component.ts @@ -14,7 +14,6 @@ import { // 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 { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; @@ -58,38 +57,37 @@ export class SetPasswordComponent extends BaseChangePasswordComponent implements ForceSetPasswordReason = ForceSetPasswordReason; constructor( - accountService: AccountService, - masterPasswordService: InternalMasterPasswordServiceAbstraction, - i18nService: I18nService, - keyService: KeyService, - messagingService: MessagingService, - platformUtilsService: PlatformUtilsService, - private policyApiService: PolicyApiServiceAbstraction, - policyService: PolicyService, + protected accountService: AccountService, + protected dialogService: DialogService, + protected encryptService: EncryptService, + protected i18nService: I18nService, + protected kdfConfigService: KdfConfigService, + protected keyService: KeyService, + protected masterPasswordApiService: MasterPasswordApiService, + protected masterPasswordService: InternalMasterPasswordServiceAbstraction, + protected messagingService: MessagingService, + protected organizationApiService: OrganizationApiServiceAbstraction, + protected organizationUserApiService: OrganizationUserApiService, + protected platformUtilsService: PlatformUtilsService, + protected policyApiService: PolicyApiServiceAbstraction, + protected policyService: PolicyService, + protected route: ActivatedRoute, protected router: Router, - private masterPasswordApiService: MasterPasswordApiService, - private apiService: ApiService, - private syncService: SyncService, - private route: ActivatedRoute, - private organizationApiService: OrganizationApiServiceAbstraction, - private organizationUserApiService: OrganizationUserApiService, - private userDecryptionOptionsService: InternalUserDecryptionOptionsServiceAbstraction, - private ssoLoginService: SsoLoginServiceAbstraction, - dialogService: DialogService, - kdfConfigService: KdfConfigService, - private encryptService: EncryptService, + protected ssoLoginService: SsoLoginServiceAbstraction, + protected syncService: SyncService, protected toastService: ToastService, + protected userDecryptionOptionsService: InternalUserDecryptionOptionsServiceAbstraction, ) { super( + accountService, + dialogService, i18nService, + kdfConfigService, keyService, + masterPasswordService, messagingService, platformUtilsService, policyService, - dialogService, - kdfConfigService, - masterPasswordService, - accountService, toastService, ); } diff --git a/libs/angular/src/auth/components/update-password.component.ts b/libs/angular/src/auth/components/update-password.component.ts index 47affbecdf2..839c3b24ebf 100644 --- a/libs/angular/src/auth/components/update-password.component.ts +++ b/libs/angular/src/auth/components/update-password.component.ts @@ -52,15 +52,15 @@ export class UpdatePasswordComponent extends BaseChangePasswordComponent { toastService: ToastService, ) { super( + accountService, + dialogService, i18nService, + kdfConfigService, keyService, + masterPasswordService, messagingService, platformUtilsService, policyService, - dialogService, - kdfConfigService, - masterPasswordService, - accountService, toastService, ); } diff --git a/libs/angular/src/auth/components/update-temp-password.component.ts b/libs/angular/src/auth/components/update-temp-password.component.ts index db2f319998a..87db26a6b59 100644 --- a/libs/angular/src/auth/components/update-temp-password.component.ts +++ b/libs/angular/src/auth/components/update-temp-password.component.ts @@ -64,15 +64,15 @@ export class UpdateTempPasswordComponent extends BaseChangePasswordComponent imp toastService: ToastService, ) { super( + accountService, + dialogService, i18nService, + kdfConfigService, keyService, + masterPasswordService, messagingService, platformUtilsService, policyService, - dialogService, - kdfConfigService, - masterPasswordService, - accountService, toastService, ); } diff --git a/libs/angular/src/auth/guards/auth.guard.ts b/libs/angular/src/auth/guards/auth.guard.ts index 58ee3a59bbe..3722a7c802a 100644 --- a/libs/angular/src/auth/guards/auth.guard.ts +++ b/libs/angular/src/auth/guards/auth.guard.ts @@ -47,9 +47,6 @@ export const authGuard: CanActivateFn = async ( 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 ( @@ -114,6 +111,10 @@ export const authGuard: CanActivateFn = async ( return router.createUrlTree([route]); } + const isChangePasswordFlagOn = await configService.getFeatureFlag( + FeatureFlag.PM16117_ChangeExistingPasswordRefactor, + ); + // Post- Account Recovery or Weak Password on login if ( (forceSetPasswordReason === ForceSetPasswordReason.AdminForcePasswordReset || diff --git a/libs/auth/src/angular/change-password/change-password.component.html b/libs/angular/src/auth/password-management/change-password/change-password.component.html similarity index 67% rename from libs/auth/src/angular/change-password/change-password.component.html rename to libs/angular/src/auth/password-management/change-password/change-password.component.html index fff873225be..7604ffacea7 100644 --- a/libs/auth/src/angular/change-password/change-password.component.html +++ b/libs/angular/src/auth/password-management/change-password/change-password.component.html @@ -6,6 +6,12 @@ > {{ "loading" | i18n }} } @else { + {{ "changePasswordWarning" | i18n }} + } diff --git a/libs/angular/src/auth/password-management/change-password/change-password.component.ts b/libs/angular/src/auth/password-management/change-password/change-password.component.ts new file mode 100644 index 00000000000..78128962384 --- /dev/null +++ b/libs/angular/src/auth/password-management/change-password/change-password.component.ts @@ -0,0 +1,202 @@ +import { Component, Input, OnInit } from "@angular/core"; +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 { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options"; +import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; +import { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.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 { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; +import { SyncService } from "@bitwarden/common/platform/sync"; +import { UserId } from "@bitwarden/common/types/guid"; +import { + AnonLayoutWrapperDataService, + DialogService, + ToastService, + Icons, + CalloutComponent, +} from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; + +import { ChangePasswordService } from "./change-password.service.abstraction"; + +/** + * Change Password Component + * + * NOTE: The change password component uses the input-password component which will show the + * current password input form in some flows, although it could be left off. This is intentional + * and by design to maintain a strong security posture as some flows could have the user + * end up at a change password without having one before. + */ +@Component({ + selector: "auth-change-password", + templateUrl: "change-password.component.html", + imports: [InputPasswordComponent, I18nPipe, CalloutComponent], +}) +export class ChangePasswordComponent implements OnInit { + @Input() inputPasswordFlow: InputPasswordFlow = InputPasswordFlow.ChangePassword; + + activeAccount: Account | null = null; + email?: string; + userId?: UserId; + masterPasswordPolicyOptions?: MasterPasswordPolicyOptions; + initializing = true; + submitting = false; + formPromise?: Promise; + forceSetPasswordReason: ForceSetPasswordReason = ForceSetPasswordReason.None; + + protected readonly ForceSetPasswordReason = ForceSetPasswordReason; + + constructor( + private accountService: AccountService, + private changePasswordService: ChangePasswordService, + private i18nService: I18nService, + private masterPasswordService: InternalMasterPasswordServiceAbstraction, + private anonLayoutWrapperDataService: AnonLayoutWrapperDataService, + private organizationInviteService: OrganizationInviteService, + private messagingService: MessagingService, + private policyService: PolicyService, + private toastService: ToastService, + private syncService: SyncService, + private dialogService: DialogService, + private logService: LogService, + ) {} + + async ngOnInit() { + this.activeAccount = await firstValueFrom(this.accountService.activeAccount$); + + if (!this.activeAccount) { + throw new Error("No active active account found while trying to change passwords."); + } + + this.userId = this.activeAccount.id; + this.email = this.activeAccount.email; + + if (!this.userId) { + throw new Error("userId not found"); + } + + this.masterPasswordPolicyOptions = await firstValueFrom( + this.policyService.masterPasswordPolicyOptions$(this.userId), + ); + + this.forceSetPasswordReason = await firstValueFrom( + this.masterPasswordService.forceSetPasswordReason$(this.userId), + ); + + if (this.forceSetPasswordReason === ForceSetPasswordReason.AdminForcePasswordReset) { + this.anonLayoutWrapperDataService.setAnonLayoutWrapperData({ + pageIcon: Icons.LockIcon, + pageTitle: { key: "updateMasterPassword" }, + pageSubtitle: { key: "accountRecoveryUpdateMasterPasswordSubtitle" }, + }); + } else if (this.forceSetPasswordReason === ForceSetPasswordReason.WeakMasterPassword) { + this.anonLayoutWrapperDataService.setAnonLayoutWrapperData({ + pageIcon: Icons.LockIcon, + pageTitle: { key: "updateMasterPassword" }, + pageSubtitle: { key: "updateMasterPasswordSubtitle" }, + maxWidth: "lg", + }); + } + + this.initializing = false; + } + + async logOut() { + const confirmed = await this.dialogService.openSimpleDialog({ + title: { key: "logOut" }, + content: { key: "logOutConfirmation" }, + acceptButtonText: { key: "logOut" }, + type: "warning", + }); + + if (confirmed) { + await this.organizationInviteService.clearOrganizationInvitation(); + + if (this.changePasswordService.clearDeeplinkState) { + await this.changePasswordService.clearDeeplinkState(); + } + + // TODO: PM-23515 eventually use the logout service instead of messaging service once it is available without circular dependencies + this.messagingService.send("logout"); + } + } + + async handlePasswordFormSubmit(passwordInputResult: PasswordInputResult) { + this.submitting = true; + + try { + if (passwordInputResult.rotateUserKey) { + if (this.activeAccount == null) { + throw new Error("activeAccount not found"); + } + + if ( + passwordInputResult.currentPassword == null || + passwordInputResult.newPasswordHint == null + ) { + throw new Error("currentPassword or newPasswordHint not found"); + } + + await this.syncService.fullSync(true); + + await this.changePasswordService.rotateUserKeyMasterPasswordAndEncryptedData( + passwordInputResult.currentPassword, + passwordInputResult.newPassword, + this.activeAccount, + passwordInputResult.newPasswordHint, + ); + } else { + if (!this.userId) { + throw new Error("userId not found"); + } + + if (this.forceSetPasswordReason === ForceSetPasswordReason.AdminForcePasswordReset) { + await this.changePasswordService.changePasswordForAccountRecovery( + passwordInputResult, + this.userId, + ); + } else { + await this.changePasswordService.changePassword(passwordInputResult, this.userId); + } + + this.toastService.showToast({ + variant: "success", + message: this.i18nService.t("masterPasswordChanged"), + }); + + // TODO: PM-23515 eventually use the logout service instead of messaging service once it is available without circular dependencies + this.messagingService.send("logout"); + } + } catch (error) { + this.logService.error(error); + this.toastService.showToast({ + variant: "error", + title: "", + message: this.i18nService.t("errorOccurred"), + }); + } finally { + this.submitting = false; + } + } + + /** + * Shows the logout button in the case of admin force reset password or weak password upon login. + */ + protected secondaryButtonText(): { key: string } | undefined { + return this.forceSetPasswordReason === ForceSetPasswordReason.AdminForcePasswordReset || + this.forceSetPasswordReason === ForceSetPasswordReason.WeakMasterPassword + ? { key: "logOut" } + : undefined; + } +} diff --git a/libs/auth/src/angular/change-password/change-password.service.abstraction.ts b/libs/angular/src/auth/password-management/change-password/change-password.service.abstraction.ts similarity index 56% rename from libs/auth/src/angular/change-password/change-password.service.abstraction.ts rename to libs/angular/src/auth/password-management/change-password/change-password.service.abstraction.ts index b036db439f8..2fd3bbae67a 100644 --- a/libs/auth/src/angular/change-password/change-password.service.abstraction.ts +++ b/libs/angular/src/auth/password-management/change-password/change-password.service.abstraction.ts @@ -1,3 +1,5 @@ +// 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 { PasswordInputResult } from "@bitwarden/auth/angular"; import { Account } from "@bitwarden/common/auth/abstractions/account.service"; import { UserId } from "@bitwarden/common/types/guid"; @@ -32,5 +34,29 @@ export abstract class ChangePasswordService { * @param userId the `userId` * @throws if the `userId`, `currentMasterKey`, or `currentServerMasterKeyHash` is not found */ - abstract changePassword(passwordInputResult: PasswordInputResult, userId: UserId): Promise; + abstract changePassword( + passwordInputResult: PasswordInputResult, + userId: UserId | null, + ): Promise; + + /** + * Changes the user's password and re-encrypts the user key with the `newMasterKey`. + * - Specifically, this method uses credentials from the `passwordInputResult` to: + * 1. Decrypt the user key with the `currentMasterKey` + * 2. Re-encrypt that user key with the `newMasterKey`, resulting in a `newMasterKeyEncryptedUserKey` + * 3. Build a `PasswordRequest` object that gets PUTed to `"/accounts/update-temp-password"` so that the + * ForcePasswordReset gets set to false. + * @param passwordInputResult + * @param userId + */ + abstract changePasswordForAccountRecovery( + passwordInputResult: PasswordInputResult, + userId: UserId, + ): Promise; + + /** + * Optional method that will clear up any deep link state. + * - Currently only used on the web change password service. + */ + clearDeeplinkState?: () => Promise; } diff --git a/libs/auth/src/angular/change-password/default-change-password.service.spec.ts b/libs/angular/src/auth/password-management/change-password/default-change-password.service.spec.ts similarity index 77% rename from libs/auth/src/angular/change-password/default-change-password.service.spec.ts rename to libs/angular/src/auth/password-management/change-password/default-change-password.service.spec.ts index add2e62adbc..78969c61610 100644 --- a/libs/auth/src/angular/change-password/default-change-password.service.spec.ts +++ b/libs/angular/src/auth/password-management/change-password/default-change-password.service.spec.ts @@ -1,5 +1,8 @@ import { mock, MockProxy } from "jest-mock-extended"; +// 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 { PasswordInputResult } from "@bitwarden/auth/angular"; import { Account } from "@bitwarden/common/auth/abstractions/account.service"; import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; @@ -9,8 +12,6 @@ import { UserId } from "@bitwarden/common/types/guid"; import { MasterKey, UserKey } from "@bitwarden/common/types/key"; import { KeyService, PBKDF2KdfConfig } from "@bitwarden/key-management"; -import { PasswordInputResult } from "../input-password/password-input-result"; - import { ChangePasswordService } from "./change-password.service.abstraction"; import { DefaultChangePasswordService } from "./default-change-password.service"; @@ -109,7 +110,7 @@ describe("DefaultChangePasswordService", () => { it("should throw if a currentMasterKey was not found", async () => { // Arrange const incorrectPasswordInputResult = { ...passwordInputResult }; - incorrectPasswordInputResult.currentMasterKey = null; + incorrectPasswordInputResult.currentMasterKey = undefined; // Act const testFn = sut.changePassword(incorrectPasswordInputResult, userId); @@ -123,7 +124,7 @@ describe("DefaultChangePasswordService", () => { it("should throw if a currentServerMasterKeyHash was not found", async () => { // Arrange const incorrectPasswordInputResult = { ...passwordInputResult }; - incorrectPasswordInputResult.currentServerMasterKeyHash = null; + incorrectPasswordInputResult.currentServerMasterKeyHash = undefined; // Act const testFn = sut.changePassword(incorrectPasswordInputResult, userId); @@ -174,4 +175,43 @@ describe("DefaultChangePasswordService", () => { ); }); }); + + describe("changePasswordForAccountRecovery()", () => { + it("should call the putUpdateTempPassword() API method with the correct UpdateTempPasswordRequest credentials", async () => { + // Act + await sut.changePasswordForAccountRecovery(passwordInputResult, userId); + + // Assert + expect(masterPasswordApiService.putUpdateTempPassword).toHaveBeenCalledWith( + expect.objectContaining({ + newMasterPasswordHash: passwordInputResult.newServerMasterKeyHash, + masterPasswordHint: passwordInputResult.newPasswordHint, + key: newMasterKeyEncryptedUserKey[1].encryptedString, + }), + ); + }); + + it("should throw an error if user key decryption fails", async () => { + // Arrange + masterPasswordService.decryptUserKeyWithMasterKey.mockResolvedValue(null); + + // Act + const testFn = sut.changePasswordForAccountRecovery(passwordInputResult, userId); + + // Assert + await expect(testFn).rejects.toThrow("Could not decrypt user key"); + }); + + it("should throw an error if putUpdateTempPassword() fails", async () => { + // Arrange + masterPasswordApiService.putUpdateTempPassword.mockRejectedValueOnce(new Error("error")); + + // Act + const testFn = sut.changePasswordForAccountRecovery(passwordInputResult, userId); + + // Assert + await expect(testFn).rejects.toThrow("Could not change password"); + expect(masterPasswordApiService.putUpdateTempPassword).toHaveBeenCalled(); + }); + }); }); diff --git a/libs/auth/src/angular/change-password/default-change-password.service.ts b/libs/angular/src/auth/password-management/change-password/default-change-password.service.ts similarity index 50% rename from libs/auth/src/angular/change-password/default-change-password.service.ts rename to libs/angular/src/auth/password-management/change-password/default-change-password.service.ts index 4c5f3d10d74..888799d863a 100644 --- a/libs/auth/src/angular/change-password/default-change-password.service.ts +++ b/libs/angular/src/auth/password-management/change-password/default-change-password.service.ts @@ -1,11 +1,18 @@ -import { PasswordInputResult, ChangePasswordService } from "@bitwarden/auth/angular"; +// 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 { PasswordInputResult } from "@bitwarden/auth/angular"; import { Account } from "@bitwarden/common/auth/abstractions/account.service"; import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction"; import { PasswordRequest } from "@bitwarden/common/auth/models/request/password.request"; +import { UpdateTempPasswordRequest } from "@bitwarden/common/auth/models/request/update-temp-password.request"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; +import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; import { UserId } from "@bitwarden/common/types/guid"; +import { UserKey } from "@bitwarden/common/types/key"; import { KeyService } from "@bitwarden/key-management"; +import { ChangePasswordService } from "./change-password.service.abstraction"; + export class DefaultChangePasswordService implements ChangePasswordService { constructor( protected keyService: KeyService, @@ -22,7 +29,11 @@ export class DefaultChangePasswordService implements ChangePasswordService { throw new Error("rotateUserKeyMasterPasswordAndEncryptedData() is only implemented in Web"); } - async changePassword(passwordInputResult: PasswordInputResult, userId: UserId) { + private async preparePasswordChange( + passwordInputResult: PasswordInputResult, + userId: UserId | null, + request: PasswordRequest | UpdateTempPasswordRequest, + ): Promise<[UserKey, EncString]> { if (!userId) { throw new Error("userId not found"); } @@ -45,15 +56,32 @@ export class DefaultChangePasswordService implements ChangePasswordService { throw new Error("Could not decrypt user key"); } - const newMasterKeyEncryptedUserKey = await this.keyService.encryptUserKeyWithMasterKey( + const newKeyValue = await this.keyService.encryptUserKeyWithMasterKey( passwordInputResult.newMasterKey, decryptedUserKey, ); + if (request instanceof PasswordRequest) { + request.masterPasswordHash = passwordInputResult.currentServerMasterKeyHash; + request.newMasterPasswordHash = passwordInputResult.newServerMasterKeyHash; + request.masterPasswordHint = passwordInputResult.newPasswordHint; + } else if (request instanceof UpdateTempPasswordRequest) { + request.newMasterPasswordHash = passwordInputResult.newServerMasterKeyHash; + request.masterPasswordHint = passwordInputResult.newPasswordHint; + } + + return newKeyValue; + } + + async changePassword(passwordInputResult: PasswordInputResult, userId: UserId | null) { const request = new PasswordRequest(); - request.masterPasswordHash = passwordInputResult.currentServerMasterKeyHash; - request.newMasterPasswordHash = passwordInputResult.newServerMasterKeyHash; - request.masterPasswordHint = passwordInputResult.newPasswordHint; + + const newMasterKeyEncryptedUserKey = await this.preparePasswordChange( + passwordInputResult, + userId, + request, + ); + request.key = newMasterKeyEncryptedUserKey[1].encryptedString as string; try { @@ -62,4 +90,23 @@ export class DefaultChangePasswordService implements ChangePasswordService { throw new Error("Could not change password"); } } + + async changePasswordForAccountRecovery(passwordInputResult: PasswordInputResult, userId: UserId) { + const request = new UpdateTempPasswordRequest(); + + const newMasterKeyEncryptedUserKey = await this.preparePasswordChange( + passwordInputResult, + userId, + request, + ); + + request.key = newMasterKeyEncryptedUserKey[1].encryptedString as string; + + try { + // TODO: PM-23047 will look to consolidate this into the change password endpoint. + await this.masterPasswordApiService.putUpdateTempPassword(request); + } catch { + throw new Error("Could not change password"); + } + } } diff --git a/libs/angular/src/auth/password-management/change-password/index.ts b/libs/angular/src/auth/password-management/change-password/index.ts new file mode 100644 index 00000000000..32734d39bc0 --- /dev/null +++ b/libs/angular/src/auth/password-management/change-password/index.ts @@ -0,0 +1,3 @@ +export * from "./change-password.component"; +export * from "./change-password.service.abstraction"; +export * from "./default-change-password.service"; diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index acb1553387b..d51d5e650c5 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -11,6 +11,10 @@ import { DefaultOrganizationUserApiService, OrganizationUserApiService, } from "@bitwarden/admin-console/common"; +import { + ChangePasswordService, + DefaultChangePasswordService, +} from "@bitwarden/angular/auth/password-management/change-password"; // 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 { @@ -29,8 +33,6 @@ import { TwoFactorAuthComponentService, TwoFactorAuthEmailComponentService, TwoFactorAuthWebAuthnComponentService, - ChangePasswordService, - DefaultChangePasswordService, } from "@bitwarden/auth/angular"; // 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 @@ -115,6 +117,8 @@ import { AvatarService } from "@bitwarden/common/auth/services/avatar.service"; import { DevicesServiceImplementation } from "@bitwarden/common/auth/services/devices/devices.service.implementation"; import { DevicesApiServiceImplementation } from "@bitwarden/common/auth/services/devices-api.service.implementation"; import { MasterPasswordApiService } from "@bitwarden/common/auth/services/master-password/master-password-api.service.implementation"; +import { DefaultOrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/default-organization-invite.service"; +import { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.service"; import { PasswordResetEnrollmentServiceImplementation } from "@bitwarden/common/auth/services/password-reset-enrollment.service.implementation"; import { SsoLoginService } from "@bitwarden/common/auth/services/sso-login.service"; import { TokenService } from "@bitwarden/common/auth/services/token.service"; @@ -1406,16 +1410,20 @@ const safeProviders: SafeProvider[] = [ useClass: DefaultKdfConfigService, deps: [StateProvider], }), + safeProvider({ + provide: OrganizationInviteService, + useClass: DefaultOrganizationInviteService, + deps: [], + }), safeProvider({ provide: SetPasswordJitService, useClass: DefaultSetPasswordJitService, deps: [ - ApiServiceAbstraction, - MasterPasswordApiServiceAbstraction, - KeyService, EncryptService, I18nServiceAbstraction, KdfConfigService, + KeyService, + MasterPasswordApiServiceAbstraction, InternalMasterPasswordServiceAbstraction, OrganizationApiServiceAbstraction, OrganizationUserApiService, diff --git a/libs/auth/src/angular/change-password/change-password.component.ts b/libs/auth/src/angular/change-password/change-password.component.ts deleted file mode 100644 index 617b7ce9dd0..00000000000 --- a/libs/auth/src/angular/change-password/change-password.component.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { Component, Input, OnInit } from "@angular/core"; -import { firstValueFrom } from "rxjs"; - -import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; -import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options"; -import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; -import { SyncService } from "@bitwarden/common/platform/sync"; -import { UserId } from "@bitwarden/common/types/guid"; -// 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 { ToastService } from "@bitwarden/components"; -import { I18nPipe } from "@bitwarden/ui-common"; - -import { - InputPasswordComponent, - InputPasswordFlow, -} from "../input-password/input-password.component"; -import { PasswordInputResult } from "../input-password/password-input-result"; - -import { ChangePasswordService } from "./change-password.service.abstraction"; - -@Component({ - selector: "auth-change-password", - templateUrl: "change-password.component.html", - imports: [InputPasswordComponent, I18nPipe], -}) -export class ChangePasswordComponent implements OnInit { - @Input() inputPasswordFlow: InputPasswordFlow = InputPasswordFlow.ChangePassword; - - activeAccount: Account | null = null; - email?: string; - userId?: UserId; - masterPasswordPolicyOptions?: MasterPasswordPolicyOptions; - initializing = true; - submitting = false; - - constructor( - private accountService: AccountService, - private changePasswordService: ChangePasswordService, - private i18nService: I18nService, - private messagingService: MessagingService, - private policyService: PolicyService, - private toastService: ToastService, - private syncService: SyncService, - ) {} - - async ngOnInit() { - this.activeAccount = await firstValueFrom(this.accountService.activeAccount$); - this.userId = this.activeAccount?.id; - this.email = this.activeAccount?.email; - - if (!this.userId) { - throw new Error("userId not found"); - } - - this.masterPasswordPolicyOptions = await firstValueFrom( - this.policyService.masterPasswordPolicyOptions$(this.userId), - ); - - this.initializing = false; - } - - async handlePasswordFormSubmit(passwordInputResult: PasswordInputResult) { - this.submitting = true; - - try { - if (passwordInputResult.rotateUserKey) { - if (this.activeAccount == null) { - throw new Error("activeAccount not found"); - } - - if ( - passwordInputResult.currentPassword == null || - passwordInputResult.newPasswordHint == null - ) { - throw new Error("currentPassword or newPasswordHint not found"); - } - - await this.syncService.fullSync(true); - - await this.changePasswordService.rotateUserKeyMasterPasswordAndEncryptedData( - passwordInputResult.currentPassword, - passwordInputResult.newPassword, - this.activeAccount, - passwordInputResult.newPasswordHint, - ); - } else { - if (!this.userId) { - throw new Error("userId not found"); - } - - await this.changePasswordService.changePassword(passwordInputResult, this.userId); - - this.toastService.showToast({ - variant: "success", - title: this.i18nService.t("masterPasswordChanged"), - message: this.i18nService.t("masterPasswordChangedDesc"), - }); - - this.messagingService.send("logout"); - } - } catch { - this.toastService.showToast({ - variant: "error", - title: "", - message: this.i18nService.t("errorOccurred"), - }); - } finally { - this.submitting = false; - } - } -} diff --git a/libs/auth/src/angular/index.ts b/libs/auth/src/angular/index.ts index fc5ffd71e9a..aa0041c7ec3 100644 --- a/libs/auth/src/angular/index.ts +++ b/libs/auth/src/angular/index.ts @@ -1,10 +1,6 @@ /** * This barrel file should only contain Angular exports */ -// change password -export * from "./change-password/change-password.component"; -export * from "./change-password/change-password.service.abstraction"; -export * from "./change-password/default-change-password.service"; // fingerprint dialog export * from "./fingerprint-dialog/fingerprint-dialog.component"; diff --git a/libs/auth/src/angular/input-password/input-password.component.html b/libs/auth/src/angular/input-password/input-password.component.html index b5a9f5a56e9..bf3a51b98bb 100644 --- a/libs/auth/src/angular/input-password/input-password.component.html +++ b/libs/auth/src/angular/input-password/input-password.component.html @@ -32,7 +32,7 @@
- + {{ "newMasterPass" | i18n }} val?.trim().toLowerCase() }) email?: string; @Input() userId?: UserId; @Input() loading = false; - @Input() masterPasswordPolicyOptions: MasterPasswordPolicyOptions | null = null; + @Input() masterPasswordPolicyOptions?: MasterPasswordPolicyOptions; @Input() inlineButtons = false; @Input() primaryButtonText?: Translation; @@ -169,7 +169,7 @@ export class InputPasswordComponent implements OnInit { protected get minPasswordLengthMsg() { if ( - this.masterPasswordPolicyOptions != null && + this.masterPasswordPolicyOptions != undefined && this.masterPasswordPolicyOptions.minLength > 0 ) { return this.i18nService.t("characterMinimum", this.masterPasswordPolicyOptions.minLength); @@ -463,7 +463,7 @@ export class InputPasswordComponent implements OnInit { /** * Returns `true` if the current password is correct (it can be used to successfully decrypt - * the masterKeyEncrypedUserKey), `false` otherwise + * the masterKeyEncryptedUserKey), `false` otherwise */ private async verifyCurrentPassword( currentPassword: string, diff --git a/libs/auth/src/angular/login/login.component.ts b/libs/auth/src/angular/login/login.component.ts index 5e5d5bde4e3..b3509850ac0 100644 --- a/libs/auth/src/angular/login/login.component.ts +++ b/libs/auth/src/angular/login/login.component.ts @@ -18,9 +18,12 @@ import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; import { DevicesApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices-api.service.abstraction"; import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result"; import { ClientType, HttpStatusCode } from "@bitwarden/common/enums"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; @@ -122,6 +125,8 @@ export class LoginComponent implements OnInit, OnDestroy { private logService: LogService, private validationService: ValidationService, private loginSuccessHandlerService: LoginSuccessHandlerService, + private masterPasswordService: MasterPasswordServiceAbstraction, + private configService: ConfigService, ) { this.clientType = this.platformUtilsService.getClientType(); } @@ -225,7 +230,29 @@ export class LoginComponent implements OnInit, OnDestroy { return; } - const credentials = new PasswordLoginCredentials(email, masterPassword); + let credentials: PasswordLoginCredentials; + + if ( + await this.configService.getFeatureFlag(FeatureFlag.PM16117_ChangeExistingPasswordRefactor) + ) { + // Try to retrieve any org policies from an org invite now so we can send it to the + // login strategies. Since it is optional and we only want to be doing this on the + // web we will only send in content in the right context. + const orgPoliciesFromInvite = this.loginComponentService.getOrgPoliciesFromOrgInvite + ? await this.loginComponentService.getOrgPoliciesFromOrgInvite() + : null; + + const orgMasterPasswordPolicyOptions = orgPoliciesFromInvite?.enforcedPasswordPolicyOptions; + + credentials = new PasswordLoginCredentials( + email, + masterPassword, + undefined, + orgMasterPasswordPolicyOptions, + ); + } else { + credentials = new PasswordLoginCredentials(email, masterPassword); + } try { const authResult = await this.loginStrategyService.logIn(credentials); @@ -284,7 +311,7 @@ export class LoginComponent implements OnInit, OnDestroy { This is now unsupported and requires a downgraded client */ this.toastService.showToast({ variant: "error", - title: this.i18nService.t("errorOccured"), + title: this.i18nService.t("errorOccurred"), message: this.i18nService.t("legacyEncryptionUnsupported"), }); return; @@ -325,7 +352,13 @@ export class LoginComponent implements OnInit, OnDestroy { orgPolicies.enforcedPasswordPolicyOptions, ); if (isPasswordChangeRequired) { - await this.router.navigate(["update-password"]); + const changePasswordFeatureFlagOn = await this.configService.getFeatureFlag( + FeatureFlag.PM16117_ChangeExistingPasswordRefactor, + ); + + await this.router.navigate( + changePasswordFeatureFlagOn ? ["change-password"] : ["update-password"], + ); return; } } @@ -337,9 +370,15 @@ export class LoginComponent implements OnInit, OnDestroy { await this.router.navigate(["vault"]); } } + /** * Checks if the master password meets the enforced policy requirements * and if the user is required to change their password. + * + * TODO: This is duplicate checking that we want to only do in the password login strategy. + * Once we no longer need the policies state being set to reference later in change password + * via using the Admin Console's new policy endpoint changes we can remove this. Consult + * PM-23001 for details. */ private async isPasswordChangeRequiredByOrgPolicy( enforcedPasswordPolicyOptions: MasterPasswordPolicyOptions, diff --git a/libs/auth/src/angular/new-device-verification/new-device-verification.component.ts b/libs/auth/src/angular/new-device-verification/new-device-verification.component.ts index a8aa3bd5525..4325b4bcbc1 100644 --- a/libs/auth/src/angular/new-device-verification/new-device-verification.component.ts +++ b/libs/auth/src/angular/new-device-verification/new-device-verification.component.ts @@ -2,11 +2,17 @@ import { CommonModule } from "@angular/common"; import { Component, OnDestroy, OnInit } from "@angular/core"; import { FormBuilder, ReactiveFormsModule, Validators } from "@angular/forms"; import { Router } from "@angular/router"; -import { Subject, takeUntil } from "rxjs"; +import { firstValueFrom, Subject, takeUntil } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { LoginSuccessHandlerService } from "@bitwarden/auth/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. @@ -61,6 +67,9 @@ export class NewDeviceVerificationComponent implements OnInit, OnDestroy { private logService: LogService, private i18nService: I18nService, private loginSuccessHandlerService: LoginSuccessHandlerService, + private configService: ConfigService, + private accountService: AccountService, + private masterPasswordService: MasterPasswordServiceAbstraction, ) {} async ngOnInit() { @@ -141,8 +150,29 @@ export class NewDeviceVerificationComponent implements OnInit, OnDestroy { // eslint-disable-next-line @typescript-eslint/no-floating-promises this.loginSuccessHandlerService.run(authResult.userId); - // If verification succeeds, navigate to vault - await this.router.navigate(["/vault"]); + // TODO: PM-22663 use the new service to handle routing. + if ( + await this.configService.getFeatureFlag(FeatureFlag.PM16117_ChangeExistingPasswordRefactor) + ) { + const activeUserId = await firstValueFrom( + this.accountService.activeAccount$.pipe(getUserId), + ); + + const forceSetPasswordReason = await firstValueFrom( + this.masterPasswordService.forceSetPasswordReason$(activeUserId), + ); + + if ( + forceSetPasswordReason === ForceSetPasswordReason.WeakMasterPassword || + forceSetPasswordReason === ForceSetPasswordReason.AdminForcePasswordReset + ) { + await this.router.navigate(["/change-password"]); + } else { + await this.router.navigate(["/vault"]); + } + } else { + await this.router.navigate(["/vault"]); + } } catch (e) { this.logService.error(e); let errorMessage = diff --git a/libs/auth/src/angular/set-password-jit/default-set-password-jit.service.spec.ts b/libs/auth/src/angular/set-password-jit/default-set-password-jit.service.spec.ts index 37afa77f0d4..2fd01f79ca9 100644 --- a/libs/auth/src/angular/set-password-jit/default-set-password-jit.service.spec.ts +++ b/libs/auth/src/angular/set-password-jit/default-set-password-jit.service.spec.ts @@ -8,7 +8,6 @@ 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"; @@ -33,7 +32,6 @@ import { SetPasswordCredentials } from "./set-password-jit.service.abstraction"; describe("DefaultSetPasswordJitService", () => { let sut: DefaultSetPasswordJitService; - let apiService: MockProxy; let masterPasswordApiService: MockProxy; let keyService: MockProxy; let encryptService: MockProxy; @@ -45,7 +43,6 @@ describe("DefaultSetPasswordJitService", () => { let userDecryptionOptionsService: MockProxy; beforeEach(() => { - apiService = mock(); masterPasswordApiService = mock(); keyService = mock(); encryptService = mock(); @@ -57,12 +54,11 @@ describe("DefaultSetPasswordJitService", () => { userDecryptionOptionsService = mock(); sut = new DefaultSetPasswordJitService( - apiService, - masterPasswordApiService, - keyService, encryptService, i18nService, kdfConfigService, + keyService, + masterPasswordApiService, masterPasswordService, organizationApiService, organizationUserApiService, diff --git a/libs/auth/src/angular/set-password-jit/default-set-password-jit.service.ts b/libs/auth/src/angular/set-password-jit/default-set-password-jit.service.ts index ec274b9c4af..7d228fccb9b 100644 --- a/libs/auth/src/angular/set-password-jit/default-set-password-jit.service.ts +++ b/libs/auth/src/angular/set-password-jit/default-set-password-jit.service.ts @@ -9,7 +9,6 @@ import { OrganizationUserResetPasswordEnrollmentRequest, } from "@bitwarden/admin-console/common"; 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"; @@ -31,12 +30,11 @@ import { export class DefaultSetPasswordJitService implements SetPasswordJitService { constructor( - protected apiService: ApiService, - protected masterPasswordApiService: MasterPasswordApiService, - protected keyService: KeyService, protected encryptService: EncryptService, protected i18nService: I18nService, protected kdfConfigService: KdfConfigService, + protected keyService: KeyService, + protected masterPasswordApiService: MasterPasswordApiService, protected masterPasswordService: InternalMasterPasswordServiceAbstraction, protected organizationApiService: OrganizationApiServiceAbstraction, protected organizationUserApiService: OrganizationUserApiService, diff --git a/libs/auth/src/angular/two-factor-auth/two-factor-auth.component.ts b/libs/auth/src/angular/two-factor-auth/two-factor-auth.component.ts index 43a63498634..a281411f971 100644 --- a/libs/auth/src/angular/two-factor-auth/two-factor-auth.component.ts +++ b/libs/auth/src/angular/two-factor-auth/two-factor-auth.component.ts @@ -394,7 +394,7 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy { this.toastService.showToast({ variant: "error", - title: this.i18nService.t("errorOccured"), + title: this.i18nService.t("errorOccurred"), message: this.i18nService.t("legacyEncryptionUnsupported"), }); return true; @@ -494,7 +494,7 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy { return; } - const defaultSuccessRoute = await this.determineDefaultSuccessRoute(); + const defaultSuccessRoute = await this.determineDefaultSuccessRoute(authResult.userId); await this.router.navigate([defaultSuccessRoute], { queryParams: { @@ -503,12 +503,28 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy { }); } - private async determineDefaultSuccessRoute(): Promise { + private async determineDefaultSuccessRoute(userId: UserId): Promise { const activeAccountStatus = await firstValueFrom(this.authService.activeAccountStatus$); if (activeAccountStatus === AuthenticationStatus.Locked) { return "lock"; } + // TODO: PM-22663 use the new service to handle routing. + if ( + await this.configService.getFeatureFlag(FeatureFlag.PM16117_ChangeExistingPasswordRefactor) + ) { + const forceSetPasswordReason = await firstValueFrom( + this.masterPasswordService.forceSetPasswordReason$(userId), + ); + + if ( + forceSetPasswordReason === ForceSetPasswordReason.WeakMasterPassword || + forceSetPasswordReason === ForceSetPasswordReason.AdminForcePasswordReset + ) { + return "change-password"; + } + } + return "vault"; } diff --git a/libs/auth/src/common/login-strategies/auth-request-login.strategy.spec.ts b/libs/auth/src/common/login-strategies/auth-request-login.strategy.spec.ts index 487afcb3001..0b19fecdc4e 100644 --- a/libs/auth/src/common/login-strategies/auth-request-login.strategy.spec.ts +++ b/libs/auth/src/common/login-strategies/auth-request-login.strategy.spec.ts @@ -14,6 +14,7 @@ import { VaultTimeoutSettingsService, } from "@bitwarden/common/key-management/vault-timeout"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; @@ -55,6 +56,7 @@ describe("AuthRequestLoginStrategy", () => { let vaultTimeoutSettingsService: MockProxy; let kdfConfigService: MockProxy; let environmentService: MockProxy; + let configService: MockProxy; const mockUserId = Utils.newGuid() as UserId; let accountService: FakeAccountService; @@ -91,6 +93,7 @@ describe("AuthRequestLoginStrategy", () => { vaultTimeoutSettingsService = mock(); kdfConfigService = mock(); environmentService = mock(); + configService = mock(); accountService = mockAccountServiceWith(mockUserId); masterPasswordService = new FakeMasterPasswordService(); @@ -121,6 +124,7 @@ describe("AuthRequestLoginStrategy", () => { vaultTimeoutSettingsService, kdfConfigService, environmentService, + configService, ); tokenResponse = identityTokenResponseFactory(); diff --git a/libs/auth/src/common/login-strategies/login.strategy.spec.ts b/libs/auth/src/common/login-strategies/login.strategy.spec.ts index a0ccba649b6..cc9cae20b5c 100644 --- a/libs/auth/src/common/login-strategies/login.strategy.spec.ts +++ b/libs/auth/src/common/login-strategies/login.strategy.spec.ts @@ -24,6 +24,7 @@ import { VaultTimeoutSettingsService, } from "@bitwarden/common/key-management/vault-timeout"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; @@ -123,6 +124,7 @@ describe("LoginStrategy", () => { let vaultTimeoutSettingsService: MockProxy; let kdfConfigService: MockProxy; let environmentService: MockProxy; + let configService: MockProxy; let passwordLoginStrategy: PasswordLoginStrategy; let credentials: PasswordLoginCredentials; @@ -148,6 +150,7 @@ describe("LoginStrategy", () => { passwordStrengthService = mock(); billingAccountProfileStateService = mock(); environmentService = mock(); + configService = mock(); vaultTimeoutSettingsService = mock(); @@ -177,6 +180,7 @@ describe("LoginStrategy", () => { vaultTimeoutSettingsService, kdfConfigService, environmentService, + configService, ); credentials = new PasswordLoginCredentials(email, masterPassword); }); @@ -491,6 +495,7 @@ describe("LoginStrategy", () => { vaultTimeoutSettingsService, kdfConfigService, environmentService, + configService, ); apiService.postIdentityToken.mockResolvedValue(identityTokenResponseFactory()); @@ -551,6 +556,7 @@ describe("LoginStrategy", () => { vaultTimeoutSettingsService, kdfConfigService, environmentService, + configService, ); const result = await passwordLoginStrategy.logIn(credentials); diff --git a/libs/auth/src/common/login-strategies/login.strategy.ts b/libs/auth/src/common/login-strategies/login.strategy.ts index dc51ce1fa04..463ea676163 100644 --- a/libs/auth/src/common/login-strategies/login.strategy.ts +++ b/libs/auth/src/common/login-strategies/login.strategy.ts @@ -25,6 +25,7 @@ import { } from "@bitwarden/common/key-management/vault-timeout"; import { KeysRequest } from "@bitwarden/common/models/request/keys.request"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; @@ -91,6 +92,7 @@ export abstract class LoginStrategy { protected vaultTimeoutSettingsService: VaultTimeoutSettingsService, protected KdfConfigService: KdfConfigService, protected environmentService: EnvironmentService, + protected configService: ConfigService, ) {} abstract exportCache(): CacheData; diff --git a/libs/auth/src/common/login-strategies/password-login.strategy.spec.ts b/libs/auth/src/common/login-strategies/password-login.strategy.spec.ts index f996aa7a1f6..61a06f94b02 100644 --- a/libs/auth/src/common/login-strategies/password-login.strategy.spec.ts +++ b/libs/auth/src/common/login-strategies/password-login.strategy.spec.ts @@ -3,6 +3,7 @@ import { BehaviorSubject } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options"; import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type"; @@ -11,6 +12,7 @@ import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/id import { IdentityTwoFactorResponse } from "@bitwarden/common/auth/models/response/identity-two-factor.response"; import { MasterPasswordPolicyResponse } from "@bitwarden/common/auth/models/response/master-password-policy.response"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { FakeMasterPasswordService } from "@bitwarden/common/key-management/master-password/services/fake-master-password.service"; import { @@ -18,6 +20,7 @@ import { VaultTimeoutSettingsService, } from "@bitwarden/common/key-management/vault-timeout"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; @@ -54,7 +57,7 @@ const masterKey = new SymmetricCryptoKey( ) as MasterKey; const userId = Utils.newGuid() as UserId; const deviceId = Utils.newGuid(); -const masterPasswordPolicy = new MasterPasswordPolicyResponse({ +const masterPasswordPolicyResponse = new MasterPasswordPolicyResponse({ EnforceOnLogin: true, MinLength: 8, }); @@ -82,6 +85,7 @@ describe("PasswordLoginStrategy", () => { let vaultTimeoutSettingsService: MockProxy; let kdfConfigService: MockProxy; let environmentService: MockProxy; + let configService: MockProxy; let passwordLoginStrategy: PasswordLoginStrategy; let credentials: PasswordLoginCredentials; @@ -109,6 +113,7 @@ describe("PasswordLoginStrategy", () => { vaultTimeoutSettingsService = mock(); kdfConfigService = mock(); environmentService = mock(); + configService = mock(); appIdService.getAppId.mockResolvedValue(deviceId); tokenService.decodeAccessToken.mockResolvedValue({ @@ -148,9 +153,10 @@ describe("PasswordLoginStrategy", () => { vaultTimeoutSettingsService, kdfConfigService, environmentService, + configService, ); credentials = new PasswordLoginCredentials(email, masterPassword); - tokenResponse = identityTokenResponseFactory(masterPasswordPolicy); + tokenResponse = identityTokenResponseFactory(masterPasswordPolicyResponse); apiService.postIdentityToken.mockResolvedValue(tokenResponse); @@ -227,6 +233,67 @@ describe("PasswordLoginStrategy", () => { expect(policyService.evaluateMasterPassword).toHaveBeenCalled(); }); + it("when given master password policies as part of the login credentials from an org invite, it combines them with the token response policies to evaluate the user's password as weak", async () => { + const passwordStrengthScore = 0; + + passwordStrengthService.getPasswordStrength.mockReturnValue({ + score: passwordStrengthScore, + } as any); + policyService.evaluateMasterPassword.mockReturnValue(false); + tokenService.decodeAccessToken.mockResolvedValue({ sub: userId }); + + jest + .spyOn(configService, "getFeatureFlag") + .mockImplementation((flag: FeatureFlag) => + Promise.resolve(flag === FeatureFlag.PM16117_ChangeExistingPasswordRefactor), + ); + + credentials.masterPasswordPoliciesFromOrgInvite = Object.assign( + new MasterPasswordPolicyOptions(), + { + minLength: 10, + minComplexity: 2, + requireUpper: true, + requireLower: true, + requireNumbers: true, + requireSpecial: true, + enforceOnLogin: true, + }, + ); + + const combinedMasterPasswordPolicyOptions = Object.assign(new MasterPasswordPolicyOptions(), { + minLength: 10, + minComplexity: 2, + requireUpper: true, + requireLower: true, + requireNumbers: true, + requireSpecial: true, + enforceOnLogin: false, + }); + + policyService.combineMasterPasswordPolicyOptions.mockReturnValue( + combinedMasterPasswordPolicyOptions, + ); + + await passwordLoginStrategy.logIn(credentials); + + expect(policyService.combineMasterPasswordPolicyOptions).toHaveBeenCalledWith( + credentials.masterPasswordPoliciesFromOrgInvite, + MasterPasswordPolicyOptions.fromResponse(masterPasswordPolicyResponse), + ); + + expect(policyService.evaluateMasterPassword).toHaveBeenCalledWith( + passwordStrengthScore, + credentials.masterPassword, + combinedMasterPasswordPolicyOptions, + ); + + expect(masterPasswordService.mock.setForceSetPasswordReason).toHaveBeenCalledWith( + ForceSetPasswordReason.WeakMasterPassword, + userId, + ); + }); + it("forces the user to update their master password on successful login when it does not meet master password policy requirements", async () => { passwordStrengthService.getPasswordStrength.mockReturnValue({ score: 0 } as any); policyService.evaluateMasterPassword.mockReturnValue(false); @@ -251,7 +318,7 @@ describe("PasswordLoginStrategy", () => { TwoFactorProviders2: { 0: null }, error: "invalid_grant", error_description: "Two factor required.", - MasterPasswordPolicy: masterPasswordPolicy, + MasterPasswordPolicy: masterPasswordPolicyResponse, }); // First login request fails requiring 2FA @@ -271,7 +338,7 @@ describe("PasswordLoginStrategy", () => { TwoFactorProviders2: { 0: null }, error: "invalid_grant", error_description: "Two factor required.", - MasterPasswordPolicy: masterPasswordPolicy, + MasterPasswordPolicy: masterPasswordPolicyResponse, }); // First login request fails requiring 2FA @@ -280,7 +347,7 @@ describe("PasswordLoginStrategy", () => { // Second login request succeeds apiService.postIdentityToken.mockResolvedValueOnce( - identityTokenResponseFactory(masterPasswordPolicy), + identityTokenResponseFactory(masterPasswordPolicyResponse), ); await passwordLoginStrategy.logInTwoFactor({ provider: TwoFactorProviderType.Authenticator, diff --git a/libs/auth/src/common/login-strategies/password-login.strategy.ts b/libs/auth/src/common/login-strategies/password-login.strategy.ts index 8b92e65f1f8..cd3d5df1d5e 100644 --- a/libs/auth/src/common/login-strategies/password-login.strategy.ts +++ b/libs/auth/src/common/login-strategies/password-login.strategy.ts @@ -12,6 +12,7 @@ import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/ide import { IdentityDeviceVerificationResponse } from "@bitwarden/common/auth/models/response/identity-device-verification.response"; import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response"; import { IdentityTwoFactorResponse } from "@bitwarden/common/auth/models/response/identity-two-factor.response"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { HashPurpose } from "@bitwarden/common/platform/enums"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength"; @@ -75,7 +76,7 @@ export class PasswordLoginStrategy extends LoginStrategy { this.localMasterKeyHash$ = this.cache.pipe(map((state) => state.localMasterKeyHash)); } - override async logIn(credentials: PasswordLoginCredentials) { + override async logIn(credentials: PasswordLoginCredentials): Promise { const { email, masterPassword, twoFactor } = credentials; const data = new PasswordLoginStrategyData(); @@ -163,18 +164,42 @@ export class PasswordLoginStrategy extends LoginStrategy { credentials: PasswordLoginCredentials, authResult: AuthResult, ): Promise { - // TODO: PM-21084 - investigate if we should be sending down masterPasswordPolicy on the IdentityDeviceVerificationResponse like we do for the IdentityTwoFactorResponse + // TODO: PM-21084 - investigate if we should be sending down masterPasswordPolicy on the + // IdentityDeviceVerificationResponse like we do for the IdentityTwoFactorResponse // If the response is a device verification response, we don't need to evaluate the password if (identityResponse instanceof IdentityDeviceVerificationResponse) { return; } // The identity result can contain master password policies for the user's organizations - const masterPasswordPolicyOptions = - this.getMasterPasswordPolicyOptionsFromResponse(identityResponse); + let masterPasswordPolicyOptions: MasterPasswordPolicyOptions | undefined; - if (!masterPasswordPolicyOptions?.enforceOnLogin) { - return; + if ( + await this.configService.getFeatureFlag(FeatureFlag.PM16117_ChangeExistingPasswordRefactor) + ) { + // Get the master password policy options from both the org invite and the identity response. + masterPasswordPolicyOptions = this.policyService.combineMasterPasswordPolicyOptions( + credentials.masterPasswordPoliciesFromOrgInvite, + this.getMasterPasswordPolicyOptionsFromResponse(identityResponse), + ); + + // We deliberately do not check enforceOnLogin as existing users who are logging + // in after getting an org invite should always be forced to set a password that + // meets the org's policy. Org Invite -> Registration also works this way for + // new BW users as well. + if ( + !credentials.masterPasswordPoliciesFromOrgInvite && + !masterPasswordPolicyOptions?.enforceOnLogin + ) { + return; + } + } else { + masterPasswordPolicyOptions = + this.getMasterPasswordPolicyOptionsFromResponse(identityResponse); + + if (!masterPasswordPolicyOptions?.enforceOnLogin) { + return; + } } // If there is a policy active, evaluate the supplied password before its no longer in memory diff --git a/libs/auth/src/common/login-strategies/sso-login.strategy.spec.ts b/libs/auth/src/common/login-strategies/sso-login.strategy.spec.ts index 98142003c6e..ea041081985 100644 --- a/libs/auth/src/common/login-strategies/sso-login.strategy.spec.ts +++ b/libs/auth/src/common/login-strategies/sso-login.strategy.spec.ts @@ -139,7 +139,6 @@ describe("SsoLoginStrategy", () => { deviceTrustService, authRequestService, i18nService, - configService, accountService, masterPasswordService, keyService, @@ -157,6 +156,7 @@ describe("SsoLoginStrategy", () => { vaultTimeoutSettingsService, kdfConfigService, environmentService, + configService, ); credentials = new SsoLoginCredentials(ssoCode, ssoCodeVerifier, ssoRedirectUrl, ssoOrgId); }); diff --git a/libs/auth/src/common/login-strategies/sso-login.strategy.ts b/libs/auth/src/common/login-strategies/sso-login.strategy.ts index 8b60e42f03e..8ab84f0968a 100644 --- a/libs/auth/src/common/login-strategies/sso-login.strategy.ts +++ b/libs/auth/src/common/login-strategies/sso-login.strategy.ts @@ -13,7 +13,6 @@ import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction"; import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { UserId } from "@bitwarden/common/types/guid"; @@ -74,7 +73,6 @@ export class SsoLoginStrategy extends LoginStrategy { private deviceTrustService: DeviceTrustServiceAbstraction, private authRequestService: AuthRequestServiceAbstraction, private i18nService: I18nService, - private configService: ConfigService, ...sharedDeps: ConstructorParameters ) { super(...sharedDeps); diff --git a/libs/auth/src/common/login-strategies/user-api-login.strategy.spec.ts b/libs/auth/src/common/login-strategies/user-api-login.strategy.spec.ts index 957a6a8e777..7114afbf94f 100644 --- a/libs/auth/src/common/login-strategies/user-api-login.strategy.spec.ts +++ b/libs/auth/src/common/login-strategies/user-api-login.strategy.spec.ts @@ -13,6 +13,7 @@ import { VaultTimeoutSettingsService, } from "@bitwarden/common/key-management/vault-timeout"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { Environment, EnvironmentService, @@ -56,6 +57,7 @@ describe("UserApiLoginStrategy", () => { let billingAccountProfileStateService: MockProxy; let vaultTimeoutSettingsService: MockProxy; let kdfConfigService: MockProxy; + let configService: MockProxy; let apiLogInStrategy: UserApiLoginStrategy; let credentials: UserApiLoginCredentials; @@ -88,6 +90,7 @@ describe("UserApiLoginStrategy", () => { billingAccountProfileStateService = mock(); vaultTimeoutSettingsService = mock(); kdfConfigService = mock(); + configService = mock(); appIdService.getAppId.mockResolvedValue(deviceId); tokenService.getTwoFactorToken.mockResolvedValue(null); @@ -115,6 +118,7 @@ describe("UserApiLoginStrategy", () => { vaultTimeoutSettingsService, kdfConfigService, environmentService, + configService, ); credentials = new UserApiLoginCredentials(apiClientId, apiClientSecret); diff --git a/libs/auth/src/common/login-strategies/webauthn-login.strategy.spec.ts b/libs/auth/src/common/login-strategies/webauthn-login.strategy.spec.ts index 432e4142d0c..f5ba2d0be23 100644 --- a/libs/auth/src/common/login-strategies/webauthn-login.strategy.spec.ts +++ b/libs/auth/src/common/login-strategies/webauthn-login.strategy.spec.ts @@ -16,6 +16,7 @@ import { VaultTimeoutSettingsService, } from "@bitwarden/common/key-management/vault-timeout"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; @@ -54,6 +55,7 @@ describe("WebAuthnLoginStrategy", () => { let vaultTimeoutSettingsService: MockProxy; let kdfConfigService: MockProxy; let environmentService: MockProxy; + let configService: MockProxy; let webAuthnLoginStrategy!: WebAuthnLoginStrategy; @@ -98,6 +100,7 @@ describe("WebAuthnLoginStrategy", () => { vaultTimeoutSettingsService = mock(); kdfConfigService = mock(); environmentService = mock(); + configService = mock(); tokenService.getTwoFactorToken.mockResolvedValue(null); appIdService.getAppId.mockResolvedValue(deviceId); @@ -124,6 +127,7 @@ describe("WebAuthnLoginStrategy", () => { vaultTimeoutSettingsService, kdfConfigService, environmentService, + configService, ); // Create credentials diff --git a/libs/auth/src/common/models/domain/login-credentials.ts b/libs/auth/src/common/models/domain/login-credentials.ts index bce8ce54de5..96ee88945eb 100644 --- a/libs/auth/src/common/models/domain/login-credentials.ts +++ b/libs/auth/src/common/models/domain/login-credentials.ts @@ -2,6 +2,7 @@ // @ts-strict-ignore import { Jsonify } from "type-fest"; +import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options"; import { AuthenticationType } from "@bitwarden/common/auth/enums/authentication-type"; import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/identity-token/token-two-factor.request"; import { WebAuthnLoginAssertionResponseRequest } from "@bitwarden/common/auth/services/webauthn-login/request/webauthn-login-assertion-response.request"; @@ -15,6 +16,7 @@ export class PasswordLoginCredentials { public email: string, public masterPassword: string, public twoFactor?: TokenTwoFactorRequest, + public masterPasswordPoliciesFromOrgInvite?: MasterPasswordPolicyOptions, ) {} } diff --git a/libs/auth/src/common/services/login-strategies/login-strategy.service.ts b/libs/auth/src/common/services/login-strategies/login-strategy.service.ts index 767d52de370..6900e5e5872 100644 --- a/libs/auth/src/common/services/login-strategies/login-strategy.service.ts +++ b/libs/auth/src/common/services/login-strategies/login-strategy.service.ts @@ -402,6 +402,7 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction { this.vaultTimeoutSettingsService, this.kdfConfigService, this.environmentService, + this.configService, ]; return source.pipe( @@ -425,7 +426,6 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction { this.deviceTrustService, this.authRequestService, this.i18nService, - this.configService, ...sharedDeps, ); case AuthenticationType.UserApiKey: diff --git a/libs/common/src/admin-console/abstractions/policy/policy.service.abstraction.ts b/libs/common/src/admin-console/abstractions/policy/policy.service.abstraction.ts index bf02872ed7c..8df7e44986b 100644 --- a/libs/common/src/admin-console/abstractions/policy/policy.service.abstraction.ts +++ b/libs/common/src/admin-console/abstractions/policy/policy.service.abstraction.ts @@ -50,6 +50,25 @@ export abstract class PolicyService { policies?: Policy[], ) => Observable; + /** + * Combines all Master Password policies that are passed in and returns + * back the strongest combination of all the policies in the form of a + * MasterPasswordPolicyOptions. + * @param policies + */ + abstract combinePoliciesIntoMasterPasswordPolicyOptions( + policies: Policy[], + ): MasterPasswordPolicyOptions | undefined; + + /** + * Takes an arbitrary amount of Master Password Policy options in any form and merges them + * together using the strictest combination of all of them. + * @param masterPasswordPolicyOptions + */ + abstract combineMasterPasswordPolicyOptions( + ...masterPasswordPolicyOptions: MasterPasswordPolicyOptions[] + ): MasterPasswordPolicyOptions | undefined; + /** * Evaluates whether a proposed Master Password complies with all Master Password policies that apply to the user. */ diff --git a/libs/common/src/admin-console/services/policy/default-policy.service.spec.ts b/libs/common/src/admin-console/services/policy/default-policy.service.spec.ts index 7787bdbc943..9db61ec5c95 100644 --- a/libs/common/src/admin-console/services/policy/default-policy.service.spec.ts +++ b/libs/common/src/admin-console/services/policy/default-policy.service.spec.ts @@ -536,6 +536,152 @@ describe("PolicyService", () => { }); }); + describe("combinePoliciesIntoMasterPasswordPolicyOptions", () => { + let policyService: DefaultPolicyService; + let stateProvider: FakeStateProvider; + let organizationService: MockProxy; + + beforeEach(() => { + stateProvider = new FakeStateProvider(mockAccountServiceWith(userId)); + organizationService = mock(); + policyService = new DefaultPolicyService(stateProvider, organizationService); + }); + + it("returns undefined when there are no policies", () => { + const result = policyService.combinePoliciesIntoMasterPasswordPolicyOptions([]); + expect(result).toBeUndefined(); + }); + + it("returns options for a single policy", () => { + const masterPasswordPolicyRequirements = { + minComplexity: 3, + minLength: 10, + requireUpper: true, + }; + const policies = [ + new Policy( + policyData( + "1", + "org1", + PolicyType.MasterPassword, + true, + masterPasswordPolicyRequirements, + ), + ), + ]; + + const result = policyService.combinePoliciesIntoMasterPasswordPolicyOptions(policies); + + expect(result).toEqual({ + minComplexity: 3, + minLength: 10, + requireUpper: true, + requireLower: false, + requireNumbers: false, + requireSpecial: false, + enforceOnLogin: false, + }); + }); + + it("merges options from multiple policies", () => { + const masterPasswordPolicyRequirements1 = { + minComplexity: 3, + minLength: 10, + requireUpper: true, + }; + const masterPasswordPolicyRequirements2 = { minComplexity: 5, requireNumbers: true }; + const policies = [ + new Policy( + policyData( + "1", + "org1", + PolicyType.MasterPassword, + true, + masterPasswordPolicyRequirements1, + ), + ), + new Policy( + policyData( + "2", + "org2", + PolicyType.MasterPassword, + true, + masterPasswordPolicyRequirements2, + ), + ), + ]; + + const result = policyService.combinePoliciesIntoMasterPasswordPolicyOptions(policies); + + expect(result).toEqual({ + minComplexity: 5, + minLength: 10, + requireUpper: true, + requireLower: false, + requireNumbers: true, + requireSpecial: false, + enforceOnLogin: false, + }); + }); + + it("ignores disabled policies", () => { + const masterPasswordPolicyRequirements = { + minComplexity: 3, + minLength: 10, + requireUpper: true, + }; + const policies = [ + new Policy( + policyData( + "1", + "org1", + PolicyType.MasterPassword, + false, + masterPasswordPolicyRequirements, + ), + ), + ]; + + const result = policyService.combinePoliciesIntoMasterPasswordPolicyOptions(policies); + + expect(result).toBeUndefined(); + }); + + it("ignores policies with no data", () => { + const policies = [new Policy(policyData("1", "org1", PolicyType.MasterPassword, true))]; + + const result = policyService.combinePoliciesIntoMasterPasswordPolicyOptions(policies); + + expect(result).toBeUndefined(); + }); + + it("returns undefined when policies are not MasterPassword related", () => { + const unrelatedPolicyRequirements = { + minComplexity: 3, + minLength: 10, + requireUpper: true, + }; + const policies = [ + new Policy( + policyData( + "1", + "org1", + PolicyType.MaximumVaultTimeout, + true, + unrelatedPolicyRequirements, + ), + ), + new Policy( + policyData("2", "org2", PolicyType.DisableSend, true, unrelatedPolicyRequirements), + ), + ]; + + const result = policyService.combinePoliciesIntoMasterPasswordPolicyOptions(policies); + + expect(result).toBeUndefined(); + }); + }); + function policyData( id: string, organizationId: string, diff --git a/libs/common/src/admin-console/services/policy/default-policy.service.ts b/libs/common/src/admin-console/services/policy/default-policy.service.ts index b6e03ddf257..667dd9082a4 100644 --- a/libs/common/src/admin-console/services/policy/default-policy.service.ts +++ b/libs/common/src/admin-console/services/policy/default-policy.service.ts @@ -89,6 +89,8 @@ export class DefaultPolicyService implements PolicyService { const policies$ = policies ? of(policies) : this.policies$(userId); return policies$.pipe( map((obsPolicies) => { + // TODO: replace with this.combinePoliciesIntoMasterPasswordPolicyOptions(obsPolicies)) once + // FeatureFlag.PM16117_ChangeExistingPasswordRefactor is removed. let enforcedOptions: MasterPasswordPolicyOptions | undefined = undefined; const filteredPolicies = obsPolicies.filter((p) => p.type === PolicyType.MasterPassword) ?? []; @@ -146,6 +148,47 @@ export class DefaultPolicyService implements PolicyService { ); } + combinePoliciesIntoMasterPasswordPolicyOptions( + policies: Policy[], + ): MasterPasswordPolicyOptions | undefined { + let enforcedOptions: MasterPasswordPolicyOptions | undefined = undefined; + const filteredPolicies = policies.filter((p) => p.type === PolicyType.MasterPassword) ?? []; + + if (filteredPolicies.length === 0) { + return; + } + + filteredPolicies.forEach((currentPolicy) => { + if (!currentPolicy.enabled || !currentPolicy.data) { + return undefined; + } + + if (!enforcedOptions) { + enforcedOptions = new MasterPasswordPolicyOptions(); + } + + this.mergeMasterPasswordPolicyOptions(enforcedOptions, currentPolicy.data); + }); + + return enforcedOptions; + } + + combineMasterPasswordPolicyOptions( + ...policies: MasterPasswordPolicyOptions[] + ): MasterPasswordPolicyOptions | undefined { + let combinedOptions: MasterPasswordPolicyOptions | undefined = undefined; + + policies.forEach((currentOptions) => { + if (!combinedOptions) { + combinedOptions = new MasterPasswordPolicyOptions(); + } + + this.mergeMasterPasswordPolicyOptions(combinedOptions, currentOptions); + }); + + return combinedOptions; + } + evaluateMasterPassword( passwordStrength: number, newPassword: string, @@ -245,4 +288,28 @@ export class DefaultPolicyService implements PolicyService { return organization.canManagePolicies; } } + + private mergeMasterPasswordPolicyOptions( + target: MasterPasswordPolicyOptions | undefined, + source: MasterPasswordPolicyOptions | undefined, + ) { + if (!target) { + target = new MasterPasswordPolicyOptions(); + } + + // For complexity and minLength, take the highest value. + // For boolean settings, enable it if either policy has it enabled (OR). + if (source) { + target.minComplexity = Math.max( + target.minComplexity, + source.minComplexity ?? target.minComplexity, + ); + target.minLength = Math.max(target.minLength, source.minLength ?? target.minLength); + target.requireUpper = Boolean(target.requireUpper || source.requireUpper); + target.requireLower = Boolean(target.requireLower || source.requireLower); + target.requireNumbers = Boolean(target.requireNumbers || source.requireNumbers); + target.requireSpecial = Boolean(target.requireSpecial || source.requireSpecial); + target.enforceOnLogin = Boolean(target.enforceOnLogin || source.enforceOnLogin); + } + } } diff --git a/libs/common/src/auth/services/organization-invite/default-organization-invite.service.ts b/libs/common/src/auth/services/organization-invite/default-organization-invite.service.ts new file mode 100644 index 00000000000..0ebbbaa8c0c --- /dev/null +++ b/libs/common/src/auth/services/organization-invite/default-organization-invite.service.ts @@ -0,0 +1,26 @@ +import { OrganizationInvite } from "@bitwarden/common/auth/services/organization-invite/organization-invite"; +import { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.service"; + +export class DefaultOrganizationInviteService implements OrganizationInviteService { + /** + * No-op implementation. + */ + async getOrganizationInvite(): Promise { + return null; + } + + /** + * No-op implementation. + * @param invite an organization invite + */ + async setOrganizationInvitation(invite: OrganizationInvite): Promise { + return; + } + + /** + * No-op implementation. + * */ + async clearOrganizationInvitation(): Promise { + return; + } +} diff --git a/libs/common/src/auth/services/organization-invite/organization-invite-state.ts b/libs/common/src/auth/services/organization-invite/organization-invite-state.ts new file mode 100644 index 00000000000..c544fa3269f --- /dev/null +++ b/libs/common/src/auth/services/organization-invite/organization-invite-state.ts @@ -0,0 +1,13 @@ +import { OrganizationInvite } from "@bitwarden/common/auth/services/organization-invite/organization-invite"; +import { KeyDefinition, ORGANIZATION_INVITE_DISK } from "@bitwarden/common/platform/state"; + +// We're storing the organization invite for 2 reasons: +// 1. If the org requires a MP policy check, we need to keep track that the user has already been redirected when they return. +// 2. The MP policy check happens on login/register flows, we need to store the token to retrieve the policies then. +export const ORGANIZATION_INVITE = new KeyDefinition( + ORGANIZATION_INVITE_DISK, + "organizationInvite", + { + deserializer: (invite) => (invite ? OrganizationInvite.fromJSON(invite) : null), + }, +); diff --git a/libs/common/src/auth/services/organization-invite/organization-invite.service.ts b/libs/common/src/auth/services/organization-invite/organization-invite.service.ts new file mode 100644 index 00000000000..15d9b1533f5 --- /dev/null +++ b/libs/common/src/auth/services/organization-invite/organization-invite.service.ts @@ -0,0 +1,20 @@ +import { OrganizationInvite } from "@bitwarden/common/auth/services/organization-invite/organization-invite"; + +export abstract class OrganizationInviteService { + /** + * Returns the currently stored organization invite + */ + abstract getOrganizationInvite: () => Promise; + + /** + * Stores a new organization invite + * @param invite an organization invite + * @throws if the invite is nullish + */ + abstract setOrganizationInvitation: (invite: OrganizationInvite) => Promise; + + /** + * Clears the currently stored organization invite + */ + abstract clearOrganizationInvitation: () => Promise; +} diff --git a/libs/common/src/auth/services/organization-invite/organization-invite.ts b/libs/common/src/auth/services/organization-invite/organization-invite.ts new file mode 100644 index 00000000000..d18fdcedb41 --- /dev/null +++ b/libs/common/src/auth/services/organization-invite/organization-invite.ts @@ -0,0 +1,20 @@ +import { Jsonify } from "type-fest"; + +export class OrganizationInvite { + email?: string; + initOrganization?: boolean; + orgSsoIdentifier?: string; + orgUserHasExistingUser?: boolean; + organizationId?: string; + organizationName?: string; + organizationUserId?: string; + token?: string; + + static fromJSON(json: Jsonify): OrganizationInvite | null { + if (json == null) { + return null; + } + + return Object.assign(new OrganizationInvite(), json); + } +}