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 209af41e311..5d5770e2325 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 @@ -74,10 +74,10 @@ describe("WebLoginComponentService", () => { expect(service).toBeTruthy(); }); - describe("getOrgPolicies", () => { + describe("getOrgPoliciesFromOrgInvite", () => { it("returns undefined if organization invite is null", async () => { acceptOrganizationInviteService.getOrganizationInvite.mockResolvedValue(null); - const result = await service.getOrgPolicies(); + const result = await service.getOrgPoliciesFromOrgInvite(); expect(result).toBeUndefined(); }); @@ -94,7 +94,7 @@ describe("WebLoginComponentService", () => { organizationName: "org-name", }); policyApiService.getPoliciesByToken.mockRejectedValue(error); - await service.getOrgPolicies(); + await service.getOrgPoliciesFromOrgInvite(); expect(logService.error).toHaveBeenCalledWith(error); }); @@ -130,7 +130,7 @@ describe("WebLoginComponentService", () => { of(masterPasswordPolicyOptions), ); - const result = await service.getOrgPolicies(); + const result = await service.getOrgPoliciesFromOrgInvite(); expect(result).toEqual({ policies: policies, 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 ce1bce40e39..aa0c204750f 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 @@ -48,7 +48,7 @@ export class WebLoginComponentService this.clientType = this.platformUtilsService.getClientType(); } - async getOrgPolicies(): Promise { + async getOrgPoliciesFromOrgInvite(): Promise { const orgInvite = await this.acceptOrganizationInviteService.getOrganizationInvite(); if (orgInvite != null) { diff --git a/libs/auth/src/angular/login/default-login-component.service.spec.ts b/libs/auth/src/angular/login/default-login-component.service.spec.ts index 05b24da56cc..446ab44b4ee 100644 --- a/libs/auth/src/angular/login/default-login-component.service.spec.ts +++ b/libs/auth/src/angular/login/default-login-component.service.spec.ts @@ -56,13 +56,6 @@ describe("DefaultLoginComponentService", () => { expect(service).toBeTruthy(); }); - describe("getOrgPolicies", () => { - it("returns null", async () => { - const result = await service.getOrgPolicies(); - expect(result).toBeNull(); - }); - }); - describe("isLoginWithPasskeySupported", () => { it("returns true when clientType is Web", () => { service["clientType"] = ClientType.Web; diff --git a/libs/auth/src/angular/login/default-login-component.service.ts b/libs/auth/src/angular/login/default-login-component.service.ts index 84a7d923d12..41b761ce1d9 100644 --- a/libs/auth/src/angular/login/default-login-component.service.ts +++ b/libs/auth/src/angular/login/default-login-component.service.ts @@ -2,7 +2,7 @@ // @ts-strict-ignore import { firstValueFrom } from "rxjs"; -import { LoginComponentService, PasswordPolicies } from "@bitwarden/auth/angular"; +import { LoginComponentService } from "@bitwarden/auth/angular"; import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; import { ClientType } from "@bitwarden/common/enums"; import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service"; @@ -23,10 +23,6 @@ export class DefaultLoginComponentService implements LoginComponentService { protected ssoLoginService: SsoLoginServiceAbstraction, ) {} - async getOrgPolicies(): Promise { - return null; - } - isLoginWithPasskeySupported(): boolean { return this.clientType === ClientType.Web; } diff --git a/libs/auth/src/angular/login/login-component.service.ts b/libs/auth/src/angular/login/login-component.service.ts index 8ca857cef59..1147c5d8644 100644 --- a/libs/auth/src/angular/login/login-component.service.ts +++ b/libs/auth/src/angular/login/login-component.service.ts @@ -23,7 +23,7 @@ export abstract class LoginComponentService { * Gets the organization policies if there is an organization invite. * - Used by: Web */ - getOrgPolicies: () => Promise; + getOrgPoliciesFromOrgInvite?: () => Promise; /** * Indicates whether login with passkey is supported on the given client diff --git a/libs/auth/src/angular/login/login.component.ts b/libs/auth/src/angular/login/login.component.ts index 66fe2503508..f31e02fdb1f 100644 --- a/libs/auth/src/angular/login/login.component.ts +++ b/libs/auth/src/angular/login/login.component.ts @@ -12,6 +12,7 @@ import { PasswordLoginCredentials, } from "@bitwarden/auth/common"; import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { PolicyData } from "@bitwarden/common/admin-console/models/data/policy.data"; import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options"; import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; import { DevicesApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices-api.service.abstraction"; @@ -30,6 +31,7 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength"; +import { UserId } from "@bitwarden/common/types/guid"; import { AsyncActionsModule, ButtonModule, @@ -43,7 +45,7 @@ import { import { AnonLayoutWrapperDataService } from "../anon-layout/anon-layout-wrapper-data.service"; import { VaultIcon, WaveIcon } from "../icons"; -import { LoginComponentService } from "./login-component.service"; +import { LoginComponentService, PasswordPolicies } from "./login-component.service"; const BroadcasterSubscriptionId = "LoginComponent"; @@ -72,7 +74,6 @@ export class LoginComponent implements OnInit, OnDestroy { @ViewChild("masterPasswordInputRef") masterPasswordInputRef: ElementRef | undefined; private destroy$ = new Subject(); - private enforcedMasterPasswordOptions: MasterPasswordPolicyOptions | undefined = undefined; readonly Icons = { WaveIcon, VaultIcon }; clientType: ClientType; @@ -97,11 +98,6 @@ export class LoginComponent implements OnInit, OnDestroy { return this.formGroup.controls.email; } - // Web properties - enforcedPasswordPolicyOptions: MasterPasswordPolicyOptions | undefined; - policies: Policy[] | undefined; - showResetPasswordAutoEnrollWarning = false; - // Desktop properties deferFocus: boolean | null = null; @@ -281,18 +277,39 @@ export class LoginComponent implements OnInit, OnDestroy { return; } + // User logged in successfully so execute side effects await this.loginSuccessHandlerService.run(authResult.userId); + this.loginEmailService.clearValues(); + // Determine where to send the user next if (authResult.forcePasswordReset != ForceSetPasswordReason.None) { - this.loginEmailService.clearValues(); await this.router.navigate(["update-temp-password"]); return; } - // If none of the above cases are true, proceed with login... - await this.evaluatePassword(); - - this.loginEmailService.clearValues(); + // TODO: PM-18269 - evaluate if we can combine this with the + // password evaluation done in the password login strategy. + // If there's an existing org invite, use it to get the org's password policies + // so we can evaluate the MP against the org policies + if (this.loginComponentService.getOrgPoliciesFromOrgInvite) { + const orgPolicies: PasswordPolicies | null = + await this.loginComponentService.getOrgPoliciesFromOrgInvite(); + + if (orgPolicies) { + // Since we have retrieved the policies, we can go ahead and set them into state for future use + // e.g., the update-password page currently only references state for policy data and + // doesn't fallback to pulling them from the server like it should if they are null. + await this.setPoliciesIntoState(authResult.userId, orgPolicies.policies); + + const isPasswordChangeRequired = await this.isPasswordChangeRequiredByOrgPolicy( + orgPolicies.enforcedPasswordPolicyOptions, + ); + if (isPasswordChangeRequired) { + await this.router.navigate(["update-password"]); + return; + } + } + } if (this.clientType === ClientType.Browser) { await this.router.navigate(["/tabs/vault"]); @@ -310,54 +327,51 @@ export class LoginComponent implements OnInit, OnDestroy { await this.loginComponentService.launchSsoBrowserWindow(email, clientId); } - protected async evaluatePassword(): Promise { + /** + * Checks if the master password meets the enforced policy requirements + * and if the user is required to change their password. + */ + private async isPasswordChangeRequiredByOrgPolicy( + enforcedPasswordPolicyOptions: MasterPasswordPolicyOptions, + ): Promise { try { - // If we do not have any saved policies, attempt to load them from the service - if (this.enforcedMasterPasswordOptions == undefined) { - this.enforcedMasterPasswordOptions = await firstValueFrom( - this.policyService.masterPasswordPolicyOptions$(), - ); + if (enforcedPasswordPolicyOptions == undefined) { + return false; } - if (this.requirePasswordChange()) { - await this.router.navigate(["update-password"]); - return; + // Note: we deliberately do not check enforcedPasswordPolicyOptions.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. + + const masterPassword = this.formGroup.controls.masterPassword.value; + + // Return false if masterPassword is null/undefined since this is only evaluated after successful login + if (!masterPassword) { + return false; } + + const passwordStrength = this.passwordStrengthService.getPasswordStrength( + masterPassword, + this.formGroup.value.email ?? undefined, + )?.score; + + return !this.policyService.evaluateMasterPassword( + passwordStrength, + masterPassword, + enforcedPasswordPolicyOptions, + ); } catch (e) { // Do not prevent unlock if there is an error evaluating policies this.logService.error(e); - } - } - - /** - * Checks if the master password meets the enforced policy requirements - * If not, returns false - */ - private requirePasswordChange(): boolean { - if ( - this.enforcedMasterPasswordOptions == undefined || - !this.enforcedMasterPasswordOptions.enforceOnLogin - ) { - return false; - } - - const masterPassword = this.formGroup.controls.masterPassword.value; - - // Return false if masterPassword is null/undefined since this is only evaluated after successful login - if (!masterPassword) { return false; } + } - const passwordStrength = this.passwordStrengthService.getPasswordStrength( - masterPassword, - this.formGroup.value.email ?? undefined, - )?.score; - - return !this.policyService.evaluateMasterPassword( - passwordStrength, - masterPassword, - this.enforcedMasterPasswordOptions, - ); + private async setPoliciesIntoState(userId: UserId, policies: Policy[]): Promise { + const policiesData: { [id: string]: PolicyData } = {}; + policies.map((p) => (policiesData[p.id] = PolicyData.fromPolicy(p))); + await this.policyService.replace(policiesData, userId); } protected async startAuthRequestLogin(): Promise { @@ -528,12 +542,6 @@ export class LoginComponent implements OnInit, OnDestroy { } private async defaultOnInit(): Promise { - // If there's an existing org invite, use it to get the password policies - const orgPolicies = await this.loginComponentService.getOrgPolicies(); - - this.policies = orgPolicies?.policies; - this.showResetPasswordAutoEnrollWarning = orgPolicies?.isPolicyAndAutoEnrollEnabled ?? false; - let paramEmailIsSet = false; const params = await firstValueFrom(this.activatedRoute.queryParams);