1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-14 07:13:32 +00:00

[AC-1070] Enforce master password policy on login (#4795)

* [EC-1070] Introduce flag for enforcing master password policy on login

* [EC-1070] Update master password policy form

Add the ability to toggle enforceOnLogin flag in web

* [EC-1070] Add API method to retrieve all policies for the current user

* [EC-1070] Refactor forcePasswordReset in state service to support more options

- Use an options class to provide a reason and optional organization id
- Use the OnDiskMemory storage location so the option persists between the same auth session

* [AC-1070] Retrieve single master password policy from identity token response

Additionally, store the policy in the login strategy for future use

* [EC-1070] Introduce master password evaluation in the password login strategy

- If a master password policy is returned from the identity result, evaluate the password.
- If the password does not meet the requirements, save the forcePasswordReset options
- Add support for 2FA by storing the results of the password evaluation on the login strategy instance
- Add unit tests to password login strategy

* [AC-1070] Modify admin password reset component to support update master password on login

- Modify the warning message to depend on the reason

- Use the forcePasswordResetOptions in the update temp password component

* [EC-1070] Require current master password when updating weak mp on login

- Inject user verification service to verify the user
- Conditionally show the current master password field only when updating a weak mp. Admin reset does not require the current master password.

* [EC-1070] Implement password policy check during vault unlock

Checking the master password during unlock is the only applicable place to enforce the master password policy check for SSO users.

* [EC-1070] CLI - Add ability to load MP policies on login

Inject policyApi and organization services into the login command

* [EC-1070] CLI - Refactor update temp password logic to support updating weak passwords

- Introduce new shared method for collecting a valid and confirmed master password from the CLI and generating a new encryption key
- Add separate methods for updating temp passwords and weak passwords.
- Utilize those methods during login flow if not using an API key

* [EC-1070] Add route guard to force password reset when required

* [AC-1070] Use master password policy from verify password response in lock component

* [EC-1070] Update labels in update password component

* [AC-1070] Fix policy service tests

* [AC-1070] CLI - Force sync before any password reset flow

Move up the call to sync the vault before attempting to collect a new master password. Ensures the master password policies are available.

* [AC-1070] Remove unused getAllPolicies method from policy api service

* [AC-1070] Fix missing enforceOnLogin copy in policy service

* [AC-1070] Include current master password on desktop/browser update password page templates

* [AC-1070] Check for forced password reset on account switch in Desktop

* [AC-1070] Rename WeakMasterPasswordOnLogin to WeakMasterPassword

* [AC-1070] Update AuthServiceInitOptions

* [AC-1070] Add None force reset password reason

* [AC-1070] Remove redundant ForcePasswordResetOptions class and replace with ForcePasswordResetReason enum

* [AC-1070] Rename ForceResetPasswordReason file

* [AC-1070] Simplify conditional

* [AC-1070] Refactor logic that saves password reset flag

* [AC-1070] Remove redundant constructors

* [AC-1070] Remove unnecessary state service call

* [AC-1070] Update master password policy component

- Use typed reactive form
- Use CL form components
- Remove bootstrap
- Update error component to support min/max
- Use Utils.minimumPasswordLength value for min value form validation

* [AC-1070] Cleanup leftover html comment

* [AC-1070] Remove overridden default values from MasterPasswordPolicyResponse

* [AC-1070] Hide current master password input in browser for admin password reset

* [AC-1070] Remove clientside user verification

* [AC-1070] Update temp password web component to use CL

- Use CL for form inputs in the Web component template
- Remove most of the bootstrap classes in the Web component template
- Use userVerificationService to build the password request
- Remove redundant current master password null check

* [AC-1070] Replace repeated user inputs email parsing helpers

- Update passwordStrength() method to accept an optional email argument that will be parsed into separate user inputs for use with zxcvbn
- Remove all other repeated getUserInput helper methods that parsed user emails and use the new passwordStrength signature

* [AC-1070] Fix broken login command after forcePasswordReset enum refactor

* [AC-1070] Reduce side effects in base login strategy

- Remove masterPasswordPolicy property from base login.strategy.ts
- Include an IdentityResponse in base startLogin() in addition to AuthResult
- Use the new IdentityResponse to parse the master password policy info only in the PasswordLoginStrategy

* [AC-1070] Cleanup password login strategy tests

* [AC-1070] Remove unused field

* [AC-1070] Strongly type postAccountVerifyPassword API service method

- Remove redundant verify master password response
- Use MasterPasswordPolicyResponse instead

* [AC-1070] Use ForceResetPassword.None during account switch check

* [AC-1070] Fix check for forcePasswordReset reason after addition of None

* [AC-1070] Redirect a user home if on the update temp password page without a reason

* [AC-1070] Use bit-select and bit-option

* [AC-1070] Reduce explicit form control definitions for readability

* [AC-1070] Import SelectModule in Shared web module

* [AC-1070] Add check for missing 'at' symbol

* [AC-1070] Remove redundant unpacking and null coalescing

* [AC-1070] Update passwordStrength signature and add jsdocs

* [AC-1070] Remove variable abbreviation

* [AC-1070] Restore Id attributes on form inputs

* [AC-1070] Clarify input value min/max error messages

* [AC-1070] Add input min/max value example to storybook

* [AC-1070] Add missing spinner to update temp password form

* [AC-1070] Add missing ids to form elements

* [AC-1070] Remove duplicate force sync and update comment

* [AC-1070] Switch backticks to quotation marks

---------

Co-authored-by: Thomas Rittson <31796059+eliykat@users.noreply.github.com>
This commit is contained in:
Shane Melton
2023-04-17 07:35:37 -07:00
committed by GitHub
parent ad0c460687
commit 07c2c2af20
60 changed files with 1057 additions and 503 deletions

View File

@@ -80,6 +80,7 @@ import { IdentityCaptchaResponse } from "../auth/models/response/identity-captch
import { IdentityTokenResponse } from "../auth/models/response/identity-token.response";
import { IdentityTwoFactorResponse } from "../auth/models/response/identity-two-factor.response";
import { KeyConnectorUserKeyResponse } from "../auth/models/response/key-connector-user-key.response";
import { MasterPasswordPolicyResponse } from "../auth/models/response/master-password-policy.response";
import { PreloginResponse } from "../auth/models/response/prelogin.response";
import { RegisterResponse } from "../auth/models/response/register.response";
import { SsoPreValidateResponse } from "../auth/models/response/sso-pre-validate.response";
@@ -187,7 +188,9 @@ export abstract class ApiService {
postAccountKeys: (request: KeysRequest) => Promise<any>;
postAccountVerifyEmail: () => Promise<any>;
postAccountVerifyEmailToken: (request: VerifyEmailRequest) => Promise<any>;
postAccountVerifyPassword: (request: SecretVerificationRequest) => Promise<any>;
postAccountVerifyPassword: (
request: SecretVerificationRequest
) => Promise<MasterPasswordPolicyResponse>;
postAccountRecoverDelete: (request: DeleteRecoverRequest) => Promise<any>;
postAccountRecoverDeleteToken: (request: VerifyDeleteRecoverRequest) => Promise<any>;
postAccountKdf: (request: KdfRequest) => Promise<any>;

View File

@@ -8,6 +8,7 @@ import { ProviderData } from "../admin-console/models/data/provider.data";
import { Policy } from "../admin-console/models/domain/policy";
import { CollectionView } from "../admin-console/models/view/collection.view";
import { EnvironmentUrls } from "../auth/models/domain/environment-urls";
import { ForceResetPasswordReason } from "../auth/models/domain/force-reset-password-reason";
import { KdfConfig } from "../auth/models/domain/kdf-config";
import { KdfType, ThemeType, UriMatchType } from "../enums";
import { EventData } from "../models/data/event.data";
@@ -261,8 +262,11 @@ export abstract class StateService<T extends Account = Account> {
setEventCollection: (value: EventData[], options?: StorageOptions) => Promise<void>;
getEverBeenUnlocked: (options?: StorageOptions) => Promise<boolean>;
setEverBeenUnlocked: (value: boolean, options?: StorageOptions) => Promise<void>;
getForcePasswordReset: (options?: StorageOptions) => Promise<boolean>;
setForcePasswordReset: (value: boolean, options?: StorageOptions) => Promise<void>;
getForcePasswordResetReason: (options?: StorageOptions) => Promise<ForceResetPasswordReason>;
setForcePasswordResetReason: (
value: ForceResetPasswordReason,
options?: StorageOptions
) => Promise<void>;
getInstalledVersion: (options?: StorageOptions) => Promise<string>;
setInstalledVersion: (value: string, options?: StorageOptions) => Promise<void>;
getIsAuthenticated: (options?: StorageOptions) => Promise<boolean>;

View File

@@ -7,6 +7,7 @@ import { PolicyResponse } from "../../models/response/policy.response";
export class PolicyApiServiceAbstraction {
getPolicy: (organizationId: string, type: PolicyType) => Promise<PolicyResponse>;
getPolicies: (organizationId: string) => Promise<ListResponse<PolicyResponse>>;
getPoliciesByToken: (
organizationId: string,
token: string,

View File

@@ -1,3 +1,4 @@
import { MasterPasswordPolicyResponse } from "../../../auth/models/response/master-password-policy.response";
import Domain from "../../../models/domain/domain-base";
export class MasterPasswordPolicyOptions extends Domain {
@@ -7,4 +8,26 @@ export class MasterPasswordPolicyOptions extends Domain {
requireLower = false;
requireNumbers = false;
requireSpecial = false;
/**
* Flag to indicate if the policy should be enforced on login.
* If true, and the user's password does not meet the policy requirements,
* the user will be forced to update their password.
*/
enforceOnLogin = false;
static fromResponse(policy: MasterPasswordPolicyResponse): MasterPasswordPolicyOptions {
if (policy == null) {
return null;
}
const options = new MasterPasswordPolicyOptions();
options.minComplexity = policy.minComplexity;
options.minLength = policy.minLength;
options.requireUpper = policy.requireUpper;
options.requireLower = policy.requireLower;
options.requireNumbers = policy.requireNumbers;
options.requireSpecial = policy.requireSpecial;
options.enforceOnLogin = policy.enforceOnLogin;
return options;
}
}

View File

@@ -1,4 +1,4 @@
import { of, concatMap, BehaviorSubject, Observable, map } from "rxjs";
import { BehaviorSubject, concatMap, map, Observable, of } from "rxjs";
import { StateService } from "../../../abstractions/state.service";
import { Utils } from "../../../misc/utils";
@@ -137,6 +137,10 @@ export class PolicyService implements InternalPolicyServiceAbstraction {
if (currentPolicy.data.requireSpecial) {
enforcedOptions.requireSpecial = true;
}
if (currentPolicy.data.enforceOnLogin) {
enforcedOptions.enforceOnLogin = true;
}
});
return enforcedOptions;

View File

@@ -7,20 +7,24 @@ import { LogService } from "../../abstractions/log.service";
import { MessagingService } from "../../abstractions/messaging.service";
import { PlatformUtilsService } from "../../abstractions/platformUtils.service";
import { StateService } from "../../abstractions/state.service";
import { PolicyService } from "../../admin-console/abstractions/policy/policy.service.abstraction";
import { Utils } from "../../misc/utils";
import { Account, AccountProfile, AccountTokens } from "../../models/domain/account";
import { EncString } from "../../models/domain/enc-string";
import { PasswordGenerationService } from "../../tools/generator/password";
import { AuthService } from "../abstractions/auth.service";
import { TokenService } from "../abstractions/token.service";
import { TwoFactorService } from "../abstractions/two-factor.service";
import { TwoFactorProviderType } from "../enums/two-factor-provider-type";
import { AuthResult } from "../models/domain/auth-result";
import { ForceResetPasswordReason } from "../models/domain/force-reset-password-reason";
import { PasswordLogInCredentials } from "../models/domain/log-in-credentials";
import { PasswordTokenRequest } from "../models/request/identity-token/password-token.request";
import { TokenTwoFactorRequest } from "../models/request/identity-token/token-two-factor.request";
import { IdentityCaptchaResponse } from "../models/response/identity-captcha.response";
import { IdentityTokenResponse } from "../models/response/identity-token.response";
import { IdentityTwoFactorResponse } from "../models/response/identity-two-factor.response";
import { MasterPasswordPolicyResponse } from "../models/response/master-password-policy.response";
import { PasswordLogInStrategy } from "./password-login.strategy";
@@ -50,7 +54,9 @@ const twoFactorProviderType = TwoFactorProviderType.Authenticator;
const twoFactorToken = "TWO_FACTOR_TOKEN";
const twoFactorRemember = true;
export function identityTokenResponseFactory() {
export function identityTokenResponseFactory(
masterPasswordPolicyResponse: MasterPasswordPolicyResponse = null
) {
return new IdentityTokenResponse({
ForcePasswordReset: false,
Kdf: kdf,
@@ -63,6 +69,7 @@ export function identityTokenResponseFactory() {
refresh_token: refreshToken,
scope: "api offline_access",
token_type: "Bearer",
MasterPasswordPolicy: masterPasswordPolicyResponse,
});
}
@@ -77,6 +84,8 @@ describe("LogInStrategy", () => {
let stateService: MockProxy<StateService>;
let twoFactorService: MockProxy<TwoFactorService>;
let authService: MockProxy<AuthService>;
let policyService: MockProxy<PolicyService>;
let passwordGenerationService: MockProxy<PasswordGenerationService>;
let passwordLogInStrategy: PasswordLogInStrategy;
let credentials: PasswordLogInCredentials;
@@ -92,6 +101,8 @@ describe("LogInStrategy", () => {
stateService = mock<StateService>();
twoFactorService = mock<TwoFactorService>();
authService = mock<AuthService>();
policyService = mock<PolicyService>();
passwordGenerationService = mock<PasswordGenerationService>();
appIdService.getAppId.mockResolvedValue(deviceId);
tokenService.decodeToken.calledWith(accessToken).mockResolvedValue(decodedToken);
@@ -107,6 +118,8 @@ describe("LogInStrategy", () => {
logService,
stateService,
twoFactorService,
passwordGenerationService,
policyService,
authService
);
credentials = new PasswordLogInCredentials(email, masterPassword);
@@ -155,7 +168,7 @@ describe("LogInStrategy", () => {
const result = await passwordLogInStrategy.logIn(credentials);
expect(result).toEqual({
forcePasswordReset: true,
forcePasswordReset: ForceResetPasswordReason.AdminForcePasswordReset,
resetMasterPassword: true,
twoFactorProviders: null,
captchaSiteKey: "",

View File

@@ -11,11 +11,12 @@ import { TokenService } from "../abstractions/token.service";
import { TwoFactorService } from "../abstractions/two-factor.service";
import { TwoFactorProviderType } from "../enums/two-factor-provider-type";
import { AuthResult } from "../models/domain/auth-result";
import { ForceResetPasswordReason } from "../models/domain/force-reset-password-reason";
import {
UserApiLogInCredentials,
PasswordlessLogInCredentials,
PasswordLogInCredentials,
SsoLogInCredentials,
PasswordlessLogInCredentials,
UserApiLogInCredentials,
} from "../models/domain/log-in-credentials";
import { DeviceRequest } from "../models/request/identity-token/device.request";
import { PasswordTokenRequest } from "../models/request/identity-token/password-token.request";
@@ -26,6 +27,8 @@ import { IdentityCaptchaResponse } from "../models/response/identity-captcha.res
import { IdentityTokenResponse } from "../models/response/identity-token.response";
import { IdentityTwoFactorResponse } from "../models/response/identity-two-factor.response";
type IdentityResponse = IdentityTokenResponse | IdentityTwoFactorResponse | IdentityCaptchaResponse;
export abstract class LogInStrategy {
protected abstract tokenRequest: UserApiTokenRequest | PasswordTokenRequest | SsoTokenRequest;
protected captchaBypassToken: string = null;
@@ -58,20 +61,21 @@ export abstract class LogInStrategy {
captchaResponse: string = null
): Promise<AuthResult> {
this.tokenRequest.setTwoFactor(twoFactor);
return this.startLogIn();
const [authResult] = await this.startLogIn();
return authResult;
}
protected async startLogIn(): Promise<AuthResult> {
protected async startLogIn(): Promise<[AuthResult, IdentityResponse]> {
this.twoFactorService.clearSelectedProvider();
const response = await this.apiService.postIdentityToken(this.tokenRequest);
if (response instanceof IdentityTwoFactorResponse) {
return this.processTwoFactorResponse(response);
return [await this.processTwoFactorResponse(response), response];
} else if (response instanceof IdentityCaptchaResponse) {
return this.processCaptchaResponse(response);
return [await this.processCaptchaResponse(response), response];
} else if (response instanceof IdentityTokenResponse) {
return this.processTokenResponse(response);
return [await this.processTokenResponse(response), response];
}
throw new Error("Invalid response object.");
@@ -126,7 +130,10 @@ export abstract class LogInStrategy {
protected async processTokenResponse(response: IdentityTokenResponse): Promise<AuthResult> {
const result = new AuthResult();
result.resetMasterPassword = response.resetMasterPassword;
result.forcePasswordReset = response.forcePasswordReset;
if (response.forcePasswordReset) {
result.forcePasswordReset = ForceResetPasswordReason.AdminForcePasswordReset;
}
await this.saveAccountInformation(response);
@@ -153,6 +160,7 @@ export abstract class LogInStrategy {
private async processTwoFactorResponse(response: IdentityTwoFactorResponse): Promise<AuthResult> {
const result = new AuthResult();
result.twoFactorProviders = response.twoFactorProviders2;
this.twoFactorService.setProviders(response);
this.captchaBypassToken = response.captchaToken ?? null;
return result;

View File

@@ -7,13 +7,19 @@ import { LogService } from "../../abstractions/log.service";
import { MessagingService } from "../../abstractions/messaging.service";
import { PlatformUtilsService } from "../../abstractions/platformUtils.service";
import { StateService } from "../../abstractions/state.service";
import { PolicyService } from "../../admin-console/abstractions/policy/policy.service.abstraction";
import { HashPurpose } from "../../enums";
import { Utils } from "../../misc/utils";
import { SymmetricCryptoKey } from "../../models/domain/symmetric-crypto-key";
import { PasswordGenerationService } from "../../tools/generator/password";
import { AuthService } from "../abstractions/auth.service";
import { TokenService } from "../abstractions/token.service";
import { TwoFactorService } from "../abstractions/two-factor.service";
import { TwoFactorProviderType } from "../enums/two-factor-provider-type";
import { ForceResetPasswordReason } from "../models/domain/force-reset-password-reason";
import { PasswordLogInCredentials } from "../models/domain/log-in-credentials";
import { IdentityTwoFactorResponse } from "../models/response/identity-two-factor.response";
import { MasterPasswordPolicyResponse } from "../models/response/master-password-policy.response";
import { identityTokenResponseFactory } from "./login.strategy.spec";
import { PasswordLogInStrategy } from "./password-login.strategy";
@@ -28,6 +34,10 @@ const preloginKey = new SymmetricCryptoKey(
)
);
const deviceId = Utils.newGuid();
const masterPasswordPolicy = new MasterPasswordPolicyResponse({
EnforceOnLogin: true,
MinLength: 8,
});
describe("PasswordLogInStrategy", () => {
let cryptoService: MockProxy<CryptoService>;
@@ -40,6 +50,8 @@ describe("PasswordLogInStrategy", () => {
let stateService: MockProxy<StateService>;
let twoFactorService: MockProxy<TwoFactorService>;
let authService: MockProxy<AuthService>;
let policyService: MockProxy<PolicyService>;
let passwordGenerationService: MockProxy<PasswordGenerationService>;
let passwordLogInStrategy: PasswordLogInStrategy;
let credentials: PasswordLogInCredentials;
@@ -55,6 +67,8 @@ describe("PasswordLogInStrategy", () => {
stateService = mock<StateService>();
twoFactorService = mock<TwoFactorService>();
authService = mock<AuthService>();
policyService = mock<PolicyService>();
passwordGenerationService = mock<PasswordGenerationService>();
appIdService.getAppId.mockResolvedValue(deviceId);
tokenService.decodeToken.mockResolvedValue({});
@@ -68,6 +82,8 @@ describe("PasswordLogInStrategy", () => {
.calledWith(masterPassword, expect.anything(), HashPurpose.LocalAuthorization)
.mockResolvedValue(localHashedPassword);
policyService.evaluateMasterPassword.mockReturnValue(true);
passwordLogInStrategy = new PasswordLogInStrategy(
cryptoService,
apiService,
@@ -78,11 +94,15 @@ describe("PasswordLogInStrategy", () => {
logService,
stateService,
twoFactorService,
passwordGenerationService,
policyService,
authService
);
credentials = new PasswordLogInCredentials(email, masterPassword);
apiService.postIdentityToken.mockResolvedValue(identityTokenResponseFactory());
apiService.postIdentityToken.mockResolvedValue(
identityTokenResponseFactory(masterPasswordPolicy)
);
});
it("sends master password credentials to the server", async () => {
@@ -110,4 +130,75 @@ describe("PasswordLogInStrategy", () => {
expect(cryptoService.setKey).toHaveBeenCalledWith(preloginKey);
expect(cryptoService.setKeyHash).toHaveBeenCalledWith(localHashedPassword);
});
it("does not force the user to update their master password when there are no requirements", async () => {
apiService.postIdentityToken.mockResolvedValueOnce(identityTokenResponseFactory(null));
const result = await passwordLogInStrategy.logIn(credentials);
expect(policyService.evaluateMasterPassword).not.toHaveBeenCalled();
expect(result.forcePasswordReset).toEqual(ForceResetPasswordReason.None);
});
it("does not force the user to update their master password when it meets requirements", async () => {
passwordGenerationService.passwordStrength.mockReturnValue({ score: 5 } as any);
policyService.evaluateMasterPassword.mockReturnValue(true);
const result = await passwordLogInStrategy.logIn(credentials);
expect(policyService.evaluateMasterPassword).toHaveBeenCalled();
expect(result.forcePasswordReset).toEqual(ForceResetPasswordReason.None);
});
it("forces the user to update their master password on successful login when it does not meet master password policy requirements", async () => {
passwordGenerationService.passwordStrength.mockReturnValue({ score: 0 } as any);
policyService.evaluateMasterPassword.mockReturnValue(false);
const result = await passwordLogInStrategy.logIn(credentials);
expect(policyService.evaluateMasterPassword).toHaveBeenCalled();
expect(stateService.setForcePasswordResetReason).toHaveBeenCalledWith(
ForceResetPasswordReason.WeakMasterPassword
);
expect(result.forcePasswordReset).toEqual(ForceResetPasswordReason.WeakMasterPassword);
});
it("forces the user to update their master password on successful 2FA login when it does not meet master password policy requirements", async () => {
passwordGenerationService.passwordStrength.mockReturnValue({ score: 0 } as any);
policyService.evaluateMasterPassword.mockReturnValue(false);
const token2FAResponse = new IdentityTwoFactorResponse({
TwoFactorProviders: ["0"],
TwoFactorProviders2: { 0: null },
error: "invalid_grant",
error_description: "Two factor required.",
MasterPasswordPolicy: masterPasswordPolicy,
});
// First login request fails requiring 2FA
apiService.postIdentityToken.mockResolvedValueOnce(token2FAResponse);
const firstResult = await passwordLogInStrategy.logIn(credentials);
// Second login request succeeds
apiService.postIdentityToken.mockResolvedValueOnce(
identityTokenResponseFactory(masterPasswordPolicy)
);
const secondResult = await passwordLogInStrategy.logInTwoFactor(
{
provider: TwoFactorProviderType.Authenticator,
token: "123456",
remember: false,
},
""
);
// First login attempt should not save the force password reset options
expect(firstResult.forcePasswordReset).toEqual(ForceResetPasswordReason.None);
// Second login attempt should save the force password reset options and return in result
expect(stateService.setForcePasswordResetReason).toHaveBeenCalledWith(
ForceResetPasswordReason.WeakMasterPassword
);
expect(secondResult.forcePasswordReset).toEqual(ForceResetPasswordReason.WeakMasterPassword);
});
});

View File

@@ -5,15 +5,22 @@ import { LogService } from "../../abstractions/log.service";
import { MessagingService } from "../../abstractions/messaging.service";
import { PlatformUtilsService } from "../../abstractions/platformUtils.service";
import { StateService } from "../../abstractions/state.service";
import { PolicyService } from "../../admin-console/abstractions/policy/policy.service.abstraction";
import { MasterPasswordPolicyOptions } from "../../admin-console/models/domain/master-password-policy-options";
import { HashPurpose } from "../../enums";
import { SymmetricCryptoKey } from "../../models/domain/symmetric-crypto-key";
import { PasswordGenerationServiceAbstraction } from "../../tools/generator/password";
import { AuthService } from "../abstractions/auth.service";
import { TokenService } from "../abstractions/token.service";
import { TwoFactorService } from "../abstractions/two-factor.service";
import { AuthResult } from "../models/domain/auth-result";
import { ForceResetPasswordReason } from "../models/domain/force-reset-password-reason";
import { PasswordLogInCredentials } from "../models/domain/log-in-credentials";
import { PasswordTokenRequest } from "../models/request/identity-token/password-token.request";
import { TokenTwoFactorRequest } from "../models/request/identity-token/token-two-factor.request";
import { IdentityCaptchaResponse } from "../models/response/identity-captcha.response";
import { IdentityTokenResponse } from "../models/response/identity-token.response";
import { IdentityTwoFactorResponse } from "../models/response/identity-two-factor.response";
import { LogInStrategy } from "./login.strategy";
@@ -31,6 +38,12 @@ export class PasswordLogInStrategy extends LogInStrategy {
private localHashedPassword: string;
private key: SymmetricCryptoKey;
/**
* Options to track if the user needs to update their password due to a password that does not meet an organization's
* master password policy.
*/
private forcePasswordResetReason: ForceResetPasswordReason = ForceResetPasswordReason.None;
constructor(
cryptoService: CryptoService,
apiService: ApiService,
@@ -39,8 +52,10 @@ export class PasswordLogInStrategy extends LogInStrategy {
platformUtilsService: PlatformUtilsService,
messagingService: MessagingService,
logService: LogService,
stateService: StateService,
protected stateService: StateService,
twoFactorService: TwoFactorService,
private passwordGenerationService: PasswordGenerationServiceAbstraction,
private policyService: PolicyService,
private authService: AuthService
) {
super(
@@ -66,7 +81,19 @@ export class PasswordLogInStrategy extends LogInStrategy {
captchaResponse: string
): Promise<AuthResult> {
this.tokenRequest.captchaResponse = captchaResponse ?? this.captchaBypassToken;
return super.logInTwoFactor(twoFactor);
const result = await super.logInTwoFactor(twoFactor);
// 2FA was successful, save the force update password options with the state service if defined
if (
!result.requiresTwoFactor &&
!result.requiresCaptcha &&
this.forcePasswordResetReason != ForceResetPasswordReason.None
) {
await this.stateService.setForcePasswordResetReason(this.forcePasswordResetReason);
result.forcePasswordReset = this.forcePasswordResetReason;
}
return result;
}
async logIn(credentials: PasswordLogInCredentials) {
@@ -90,6 +117,52 @@ export class PasswordLogInStrategy extends LogInStrategy {
await this.buildDeviceRequest()
);
return this.startLogIn();
const [authResult, identityResponse] = await this.startLogIn();
const masterPasswordPolicyOptions =
this.getMasterPasswordPolicyOptionsFromResponse(identityResponse);
// The identity result can contain master password policies for the user's organizations
if (masterPasswordPolicyOptions?.enforceOnLogin) {
// If there is a policy active, evaluate the supplied password before its no longer in memory
const meetsRequirements = this.evaluateMasterPassword(
credentials,
masterPasswordPolicyOptions
);
if (!meetsRequirements) {
if (authResult.requiresCaptcha || authResult.requiresTwoFactor) {
// Save the flag to this strategy for later use as the master password is about to pass out of scope
this.forcePasswordResetReason = ForceResetPasswordReason.WeakMasterPassword;
} else {
// Authentication was successful, save the force update password options with the state service
await this.stateService.setForcePasswordResetReason(
ForceResetPasswordReason.WeakMasterPassword
);
authResult.forcePasswordReset = ForceResetPasswordReason.WeakMasterPassword;
}
}
}
return authResult;
}
private getMasterPasswordPolicyOptionsFromResponse(
response: IdentityTokenResponse | IdentityTwoFactorResponse | IdentityCaptchaResponse
): MasterPasswordPolicyOptions {
if (response == null || response instanceof IdentityCaptchaResponse) {
return null;
}
return MasterPasswordPolicyOptions.fromResponse(response.masterPasswordPolicy);
}
private evaluateMasterPassword(
{ masterPassword, email }: PasswordLogInCredentials,
options: MasterPasswordPolicyOptions
): boolean {
const passwordStrength = this.passwordGenerationService.passwordStrength(
masterPassword,
email
)?.score;
return this.policyService.evaluateMasterPassword(passwordStrength, masterPassword, options);
}
}

View File

@@ -81,6 +81,7 @@ export class PasswordlessLogInStrategy extends LogInStrategy {
);
this.tokenRequest.setPasswordlessAccessCode(credentials.authRequestId);
return this.startLogIn();
const [authResult] = await this.startLogIn();
return authResult;
}
}

View File

@@ -65,6 +65,7 @@ export class SsoLogInStrategy extends LogInStrategy {
await this.buildDeviceRequest()
);
return this.startLogIn();
const [authResult] = await this.startLogIn();
return authResult;
}
}

View File

@@ -59,7 +59,8 @@ export class UserApiLogInStrategy extends LogInStrategy {
await this.buildDeviceRequest()
);
return this.startLogIn();
const [authResult] = await this.startLogIn();
return authResult;
}
protected async saveAccountInformation(tokenResponse: IdentityTokenResponse) {

View File

@@ -1,10 +1,12 @@
import { Utils } from "../../../misc/utils";
import { TwoFactorProviderType } from "../../enums/two-factor-provider-type";
import { ForceResetPasswordReason } from "./force-reset-password-reason";
export class AuthResult {
captchaSiteKey = "";
resetMasterPassword = false;
forcePasswordReset = false;
forcePasswordReset: ForceResetPasswordReason = ForceResetPasswordReason.None;
twoFactorProviders: Map<TwoFactorProviderType, { [key: string]: string }> = null;
get requiresCaptcha() {

View File

@@ -0,0 +1,17 @@
export enum ForceResetPasswordReason {
/**
* A password reset should not be forced.
*/
None,
/**
* Occurs when an organization admin forces a user to reset their password.
*/
AdminForcePasswordReset,
/**
* Occurs when a user logs in / unlocks their vault with a master password that does not meet an organization's
* master password policy that is enforced on login/unlock.
*/
WeakMasterPassword,
}

View File

@@ -1,6 +1,8 @@
import { KdfType } from "../../../enums";
import { BaseResponse } from "../../../models/response/base.response";
import { MasterPasswordPolicyResponse } from "./master-password-policy.response";
export class IdentityTokenResponse extends BaseResponse {
accessToken: string;
expiresIn: number;
@@ -16,6 +18,7 @@ export class IdentityTokenResponse extends BaseResponse {
kdfMemory?: number;
kdfParallelism?: number;
forcePasswordReset: boolean;
masterPasswordPolicy: MasterPasswordPolicyResponse;
apiUseKeyConnector: boolean;
keyConnectorUrl: string;
@@ -37,5 +40,8 @@ export class IdentityTokenResponse extends BaseResponse {
this.forcePasswordReset = this.getResponseProperty("ForcePasswordReset");
this.apiUseKeyConnector = this.getResponseProperty("ApiUseKeyConnector");
this.keyConnectorUrl = this.getResponseProperty("KeyConnectorUrl");
this.masterPasswordPolicy = new MasterPasswordPolicyResponse(
this.getResponseProperty("MasterPasswordPolicy")
);
}
}

View File

@@ -1,10 +1,13 @@
import { BaseResponse } from "../../../models/response/base.response";
import { TwoFactorProviderType } from "../../enums/two-factor-provider-type";
import { MasterPasswordPolicyResponse } from "./master-password-policy.response";
export class IdentityTwoFactorResponse extends BaseResponse {
twoFactorProviders: TwoFactorProviderType[];
twoFactorProviders2 = new Map<TwoFactorProviderType, { [key: string]: string }>();
captchaToken: string;
masterPasswordPolicy: MasterPasswordPolicyResponse;
constructor(response: any) {
super(response);
@@ -19,5 +22,8 @@ export class IdentityTwoFactorResponse extends BaseResponse {
}
}
}
this.masterPasswordPolicy = new MasterPasswordPolicyResponse(
this.getResponseProperty("MasterPasswordPolicy")
);
}
}

View File

@@ -0,0 +1,29 @@
import { BaseResponse } from "../../../models/response/base.response";
export class MasterPasswordPolicyResponse extends BaseResponse {
minComplexity: number;
minLength: number;
requireUpper: boolean;
requireLower: boolean;
requireNumbers: boolean;
requireSpecial: boolean;
/**
* Flag to indicate if the policy should be enforced on login.
* If true, and the user's password does not meet the policy requirements,
* the user will be forced to update their password.
*/
enforceOnLogin: boolean;
constructor(response: any) {
super(response);
this.minComplexity = this.getResponseProperty("MinComplexity");
this.minLength = this.getResponseProperty("MinLength");
this.requireUpper = this.getResponseProperty("RequireUpper");
this.requireLower = this.getResponseProperty("RequireLower");
this.requireNumbers = this.getResponseProperty("RequireNumbers");
this.requireSpecial = this.getResponseProperty("RequireSpecial");
this.enforceOnLogin = this.getResponseProperty("EnforceOnLogin");
}
}

View File

@@ -10,12 +10,14 @@ import { LogService } from "../../abstractions/log.service";
import { MessagingService } from "../../abstractions/messaging.service";
import { PlatformUtilsService } from "../../abstractions/platformUtils.service";
import { StateService } from "../../abstractions/state.service";
import { PolicyService } from "../../admin-console/abstractions/policy/policy.service.abstraction";
import { KdfType, KeySuffixOptions } from "../../enums";
import { Utils } from "../../misc/utils";
import { SymmetricCryptoKey } from "../../models/domain/symmetric-crypto-key";
import { PreloginRequest } from "../../models/request/prelogin.request";
import { ErrorResponse } from "../../models/response/error.response";
import { AuthRequestPushNotification } from "../../models/response/notification.response";
import { PasswordGenerationServiceAbstraction } from "../../tools/generator/password";
import { AuthService as AuthServiceAbstraction } from "../abstractions/auth.service";
import { KeyConnectorService } from "../abstractions/key-connector.service";
import { TokenService } from "../abstractions/token.service";
@@ -29,10 +31,10 @@ import { UserApiLogInStrategy } from "../login-strategies/user-api-login.strateg
import { AuthResult } from "../models/domain/auth-result";
import { KdfConfig } from "../models/domain/kdf-config";
import {
UserApiLogInCredentials,
PasswordlessLogInCredentials,
PasswordLogInCredentials,
SsoLogInCredentials,
PasswordlessLogInCredentials,
UserApiLogInCredentials,
} from "../models/domain/log-in-credentials";
import { TokenTwoFactorRequest } from "../models/request/identity-token/token-two-factor.request";
import { PasswordlessAuthRequest } from "../models/request/passwordless-auth.request";
@@ -92,7 +94,9 @@ export class AuthService implements AuthServiceAbstraction {
protected stateService: StateService,
protected twoFactorService: TwoFactorService,
protected i18nService: I18nService,
protected encryptService: EncryptService
protected encryptService: EncryptService,
protected passwordGenerationService: PasswordGenerationServiceAbstraction,
protected policyService: PolicyService
) {}
async logIn(
@@ -122,6 +126,8 @@ export class AuthService implements AuthServiceAbstraction {
this.logService,
this.stateService,
this.twoFactorService,
this.passwordGenerationService,
this.policyService,
this
);
break;

View File

@@ -9,6 +9,7 @@ import { Policy } from "../../admin-console/models/domain/policy";
import { CollectionView } from "../../admin-console/models/view/collection.view";
import { AuthenticationStatus } from "../../auth/enums/authentication-status";
import { EnvironmentUrls } from "../../auth/models/domain/environment-urls";
import { ForceResetPasswordReason } from "../../auth/models/domain/force-reset-password-reason";
import { KdfType, UriMatchType } from "../../enums";
import { Utils } from "../../misc/utils";
import { GeneratedPasswordHistory } from "../../tools/generator/password";
@@ -180,7 +181,7 @@ export class AccountProfile {
entityId?: string;
entityType?: string;
everBeenUnlocked?: boolean;
forcePasswordReset?: boolean;
forcePasswordResetReason?: ForceResetPasswordReason;
hasPremiumPersonally?: boolean;
hasPremiumFromOrganization?: boolean;
lastSync?: string;

View File

@@ -87,6 +87,7 @@ import { IdentityCaptchaResponse } from "../auth/models/response/identity-captch
import { IdentityTokenResponse } from "../auth/models/response/identity-token.response";
import { IdentityTwoFactorResponse } from "../auth/models/response/identity-two-factor.response";
import { KeyConnectorUserKeyResponse } from "../auth/models/response/key-connector-user-key.response";
import { MasterPasswordPolicyResponse } from "../auth/models/response/master-password-policy.response";
import { PreloginResponse } from "../auth/models/response/prelogin.response";
import { RegisterResponse } from "../auth/models/response/register.response";
import { SsoPreValidateResponse } from "../auth/models/response/sso-pre-validate.response";
@@ -421,8 +422,10 @@ export class ApiService implements ApiServiceAbstraction {
return this.send("POST", "/accounts/verify-email-token", request, false, false);
}
postAccountVerifyPassword(request: SecretVerificationRequest): Promise<any> {
return this.send("POST", "/accounts/verify-password", request, true, false);
postAccountVerifyPassword(
request: SecretVerificationRequest
): Promise<MasterPasswordPolicyResponse> {
return this.send("POST", "/accounts/verify-password", request, true, true);
}
postAccountRecoverDelete(request: DeleteRecoverRequest): Promise<any> {

View File

@@ -16,6 +16,7 @@ import { ProviderData } from "../admin-console/models/data/provider.data";
import { Policy } from "../admin-console/models/domain/policy";
import { CollectionView } from "../admin-console/models/view/collection.view";
import { EnvironmentUrls } from "../auth/models/domain/environment-urls";
import { ForceResetPasswordReason } from "../auth/models/domain/force-reset-password-reason";
import { KdfConfig } from "../auth/models/domain/kdf-config";
import { HtmlStorageLocation, KdfType, StorageLocation, ThemeType, UriMatchType } from "../enums";
import { VaultTimeoutAction } from "../enums/vault-timeout-action.enum";
@@ -1635,21 +1636,27 @@ export class StateService<
);
}
async getForcePasswordReset(options?: StorageOptions): Promise<boolean> {
async getForcePasswordResetReason(options?: StorageOptions): Promise<ForceResetPasswordReason> {
return (
(await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions())))
?.profile?.forcePasswordReset ?? false
(
await this.getAccount(
this.reconcileOptions(options, await this.defaultOnDiskMemoryOptions())
)
)?.profile?.forcePasswordResetReason ?? ForceResetPasswordReason.None
);
}
async setForcePasswordReset(value: boolean, options?: StorageOptions): Promise<void> {
async setForcePasswordResetReason(
value: ForceResetPasswordReason,
options?: StorageOptions
): Promise<void> {
const account = await this.getAccount(
this.reconcileOptions(options, await this.defaultInMemoryOptions())
this.reconcileOptions(options, await this.defaultOnDiskMemoryOptions())
);
account.profile.forcePasswordReset = value;
account.profile.forcePasswordResetReason = value;
await this.saveAccount(
account,
this.reconcileOptions(options, await this.defaultInMemoryOptions())
this.reconcileOptions(options, await this.defaultOnDiskMemoryOptions())
);
}

View File

@@ -17,7 +17,11 @@ export abstract class PasswordGenerationServiceAbstraction {
getHistory: () => Promise<GeneratedPasswordHistory[]>;
addHistory: (password: string) => Promise<void>;
clear: (userId?: string) => Promise<void>;
passwordStrength: (password: string, userInputs?: string[]) => zxcvbn.ZXCVBNResult;
passwordStrength: (
password: string,
email?: string,
userInputs?: string[]
) => zxcvbn.ZXCVBNResult;
normalizeOptions: (
options: PasswordGeneratorOptions,
enforcedPolicyOptions: PasswordGeneratorPolicyOptions

View File

@@ -387,14 +387,27 @@ export class PasswordGenerationService implements PasswordGenerationServiceAbstr
await this.stateService.setDecryptedPasswordGenerationHistory(null, { userId: userId });
}
passwordStrength(password: string, userInputs: string[] = null): zxcvbn.ZXCVBNResult {
/**
* Calculates a password strength score using zxcvbn.
* @param password The password to calculate the strength of.
* @param emailInput An unparsed email address to use as user input.
* @param userInputs An array of additional user inputs to use when calculating the strength.
*/
passwordStrength(
password: string,
emailInput: string = null,
userInputs: string[] = null
): zxcvbn.ZXCVBNResult {
if (password == null || password.length === 0) {
return null;
}
let globalUserInputs = ["bitwarden", "bit", "warden"];
if (userInputs != null && userInputs.length > 0) {
globalUserInputs = globalUserInputs.concat(userInputs);
}
const globalUserInputs = [
"bitwarden",
"bit",
"warden",
...(userInputs ?? []),
...this.emailToUserInputs(emailInput),
];
// Use a hash set to get rid of any duplicate user inputs
const finalUserInputs = Array.from(new Set(globalUserInputs));
const result = zxcvbn(password, finalUserInputs);
@@ -463,6 +476,27 @@ export class PasswordGenerationService implements PasswordGenerationServiceAbstr
this.sanitizePasswordLength(options, false);
}
/**
* Convert an email address into a list of user inputs for zxcvbn by
* taking the local part of the email address and splitting it into words.
* @param email
* @private
*/
private emailToUserInputs(email: string): string[] {
if (email == null || email.length === 0) {
return [];
}
const atPosition = email.indexOf("@");
if (atPosition < 0) {
return [];
}
return email
.substring(0, atPosition)
.trim()
.toLowerCase()
.split(/[^A-Za-z0-9]/);
}
private capitalize(str: string) {
return str.charAt(0).toUpperCase() + str.slice(1);
}

View File

@@ -15,6 +15,7 @@ import { ProviderData } from "../../../admin-console/models/data/provider.data";
import { CollectionDetailsResponse } from "../../../admin-console/models/response/collection.response";
import { PolicyResponse } from "../../../admin-console/models/response/policy.response";
import { KeyConnectorService } from "../../../auth/abstractions/key-connector.service";
import { ForceResetPasswordReason } from "../../../auth/models/domain/force-reset-password-reason";
import { sequentialize } from "../../../misc/sequentialize";
import { DomainsResponse } from "../../../models/response/domains.response";
import {
@@ -311,9 +312,15 @@ export class SyncService implements SyncServiceAbstraction {
await this.stateService.setEmailVerified(response.emailVerified);
await this.stateService.setHasPremiumPersonally(response.premiumPersonally);
await this.stateService.setHasPremiumFromOrganization(response.premiumFromOrganization);
await this.stateService.setForcePasswordReset(response.forcePasswordReset);
await this.keyConnectorService.setUsesKeyConnector(response.usesKeyConnector);
// The `forcePasswordReset` flag indicates an admin has reset the user's password and must be updated
if (response.forcePasswordReset) {
await this.stateService.setForcePasswordResetReason(
ForceResetPasswordReason.AdminForcePasswordReset
);
}
await this.syncProfileOrganizations(response);
const providers: { [id: string]: ProviderData } = {};