1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-13 23:13:36 +00:00

Merge remote-tracking branch 'origin' into innovation/archive/web-work

This commit is contained in:
Patrick Pimentel
2025-04-18 15:39:04 -04:00
1201 changed files with 32709 additions and 30762 deletions

View File

@@ -1,6 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { combineLatest, firstValueFrom, map, Observable, of, switchMap } from "rxjs";
import { combineLatest, firstValueFrom, map, Observable, of, shareReplay, switchMap } from "rxjs";
import { Jsonify } from "type-fest";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
@@ -8,10 +8,10 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { Utils } from "@bitwarden/common/platform/misc/utils";
import {
ActiveUserState,
StateProvider,
COLLECTION_DATA,
DeriveDefinition,
DerivedState,
StateProvider,
UserKeyDefinition,
} from "@bitwarden/common/platform/state";
import { CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid";
@@ -84,6 +84,7 @@ export class DefaultCollectionService implements CollectionService {
switchMap(([userId, collectionData]) =>
combineLatest([of(collectionData), this.keyService.orgKeys$(userId)]),
),
shareReplay({ refCount: false, bufferSize: 1 }),
);
this.decryptedCollectionDataState = this.stateProvider.getDerived(

View File

@@ -64,10 +64,12 @@ export class OrganizationUserUserDetailsResponse extends OrganizationUserRespons
export class OrganizationUserDetailsResponse extends OrganizationUserResponse {
managedByOrganization: boolean;
ssoExternalId: string;
constructor(response: any) {
super(response);
this.managedByOrganization = this.getResponseProperty("ManagedByOrganization") ?? false;
this.ssoExternalId = this.getResponseProperty("SsoExternalId");
}
}

View File

@@ -1,11 +1,12 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Directive, OnDestroy, OnInit } from "@angular/core";
import { Subject, firstValueFrom, map, takeUntil } from "rxjs";
import { Subject, firstValueFrom, map, switchMap, takeUntil } 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 { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
@@ -52,9 +53,12 @@ export class ChangePasswordComponent implements OnInit, OnDestroy {
this.email = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.email)),
);
this.policyService
.masterPasswordPolicyOptions$()
.pipe(takeUntil(this.destroy$))
this.accountService.activeAccount$
.pipe(
getUserId,
switchMap((userId) => this.policyService.masterPasswordPolicyOptions$(userId)),
takeUntil(this.destroy$),
)
.subscribe(
(enforcedPasswordPolicyOptions) =>
(this.enforcedPolicyOptions ??= enforcedPasswordPolicyOptions),

View File

@@ -211,7 +211,10 @@ export class SetPasswordComponent extends BaseChangePasswordComponent implements
// RSA Encrypt user key with organization public key
const userKey = await this.keyService.getUserKey();
const encryptedUserKey = await this.encryptService.rsaEncrypt(userKey.key, publicKey);
const encryptedUserKey = await this.encryptService.encapsulateKeyUnsigned(
userKey,
publicKey,
);
const resetRequest = new OrganizationUserResetPasswordEnrollmentRequest();
resetRequest.masterPasswordHash = masterPasswordHash;

View File

@@ -1,6 +1,5 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { DialogRef } from "@angular/cdk/dialog";
import { Directive, OnInit } from "@angular/core";
import { FormBuilder, Validators } from "@angular/forms";
import { firstValueFrom } from "rxjs";
@@ -9,6 +8,7 @@ import { PinServiceAbstraction } from "@bitwarden/auth/common";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { DialogRef } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
@Directive()
@@ -16,7 +16,7 @@ export class SetPinComponent implements OnInit {
showMasterPasswordOnClientRestartOption = true;
setPinForm = this.formBuilder.group({
pin: ["", [Validators.required]],
pin: ["", [Validators.required, Validators.minLength(4)]],
requireMasterPasswordOnClientRestart: true,
});
@@ -37,24 +37,26 @@ export class SetPinComponent implements OnInit {
}
submit = async () => {
const pin = this.setPinForm.get("pin").value;
const pinFormControl = this.setPinForm.controls.pin;
const requireMasterPasswordOnClientRestart = this.setPinForm.get(
"requireMasterPasswordOnClientRestart",
).value;
if (Utils.isNullOrWhitespace(pin)) {
this.dialogRef.close(false);
if (Utils.isNullOrWhitespace(pinFormControl.value) || pinFormControl.invalid) {
return;
}
const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
const userKey = await this.keyService.getUserKey();
const userKeyEncryptedPin = await this.pinService.createUserKeyEncryptedPin(pin, userKey);
const userKeyEncryptedPin = await this.pinService.createUserKeyEncryptedPin(
pinFormControl.value,
userKey,
);
await this.pinService.setUserKeyEncryptedPin(userKeyEncryptedPin, userId);
const pinKeyEncryptedUserKey = await this.pinService.createPinKeyEncryptedUserKey(
pin,
pinFormControl.value,
userKey,
userId,
);

View File

@@ -17,10 +17,10 @@ import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { FakeMasterPasswordService } from "@bitwarden/common/key-management/master-password/services/fake-master-password.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";

View File

@@ -18,9 +18,9 @@ import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/
import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
import { SsoPreValidateResponse } from "@bitwarden/common/auth/models/response/sso-pre-validate.response";
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";

View File

@@ -1,44 +0,0 @@
import { Directive, EventEmitter, OnInit, Output } from "@angular/core";
import { Router } from "@angular/router";
import { firstValueFrom } from "rxjs";
import {
TwoFactorProviderDetails,
TwoFactorService,
} from "@bitwarden/common/auth/abstractions/two-factor.service";
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
@Directive()
export class TwoFactorOptionsComponentV1 implements OnInit {
@Output() onProviderSelected = new EventEmitter<TwoFactorProviderType>();
@Output() onRecoverSelected = new EventEmitter();
providers: any[] = [];
constructor(
protected twoFactorService: TwoFactorService,
protected router: Router,
protected i18nService: I18nService,
protected platformUtilsService: PlatformUtilsService,
protected win: Window,
protected environmentService: EnvironmentService,
) {}
async ngOnInit() {
this.providers = await this.twoFactorService.getSupportedProviders(this.win);
}
async choose(p: TwoFactorProviderDetails) {
this.onProviderSelected.emit(p.type);
}
async recover() {
const env = await firstValueFrom(this.environmentService.environment$);
const webVault = env.getWebVaultUrl();
this.platformUtilsService.launchUri(webVault + "/#/recover-2fa");
this.onRecoverSelected.emit();
}
}

View File

@@ -1,505 +0,0 @@
import { Component } from "@angular/core";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { ActivatedRoute, convertToParamMap, Router } from "@angular/router";
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs";
import { WINDOW } from "@bitwarden/angular/services/injection-tokens";
import {
LoginStrategyServiceAbstraction,
LoginEmailServiceAbstraction,
FakeKeyConnectorUserDecryptionOption as KeyConnectorUserDecryptionOption,
FakeTrustedDeviceUserDecryptionOption as TrustedDeviceUserDecryptionOption,
FakeUserDecryptionOptions as UserDecryptionOptions,
UserDecryptionOptionsServiceAbstraction,
} from "@bitwarden/auth/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service";
import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/identity-token/token-two-factor.request";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { FakeMasterPasswordService } from "@bitwarden/common/key-management/master-password/services/fake-master-password.service";
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 { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
import { ToastService } from "@bitwarden/components";
import { TwoFactorComponentV1 } from "./two-factor-v1.component";
// test component that extends the TwoFactorComponent
@Component({})
class TestTwoFactorComponent extends TwoFactorComponentV1 {}
interface TwoFactorComponentProtected {
trustedDeviceEncRoute: string;
changePasswordRoute: string;
forcePasswordResetRoute: string;
successRoute: string;
}
describe("TwoFactorComponent", () => {
let component: TestTwoFactorComponent;
let _component: TwoFactorComponentProtected;
let fixture: ComponentFixture<TestTwoFactorComponent>;
const userId = "userId" as UserId;
// Mock Services
let mockLoginStrategyService: MockProxy<LoginStrategyServiceAbstraction>;
let mockRouter: MockProxy<Router>;
let mockI18nService: MockProxy<I18nService>;
let mockApiService: MockProxy<ApiService>;
let mockPlatformUtilsService: MockProxy<PlatformUtilsService>;
let mockWin: MockProxy<Window>;
let mockEnvironmentService: MockProxy<EnvironmentService>;
let mockStateService: MockProxy<StateService>;
let mockLogService: MockProxy<LogService>;
let mockTwoFactorService: MockProxy<TwoFactorService>;
let mockAppIdService: MockProxy<AppIdService>;
let mockLoginEmailService: MockProxy<LoginEmailServiceAbstraction>;
let mockUserDecryptionOptionsService: MockProxy<UserDecryptionOptionsServiceAbstraction>;
let mockSsoLoginService: MockProxy<SsoLoginServiceAbstraction>;
let mockConfigService: MockProxy<ConfigService>;
let mockMasterPasswordService: FakeMasterPasswordService;
let mockAccountService: FakeAccountService;
let mockToastService: MockProxy<ToastService>;
let mockUserDecryptionOpts: {
noMasterPassword: UserDecryptionOptions;
withMasterPassword: UserDecryptionOptions;
withMasterPasswordAndTrustedDevice: UserDecryptionOptions;
withMasterPasswordAndTrustedDeviceWithManageResetPassword: UserDecryptionOptions;
withMasterPasswordAndKeyConnector: UserDecryptionOptions;
noMasterPasswordWithTrustedDevice: UserDecryptionOptions;
noMasterPasswordWithTrustedDeviceWithManageResetPassword: UserDecryptionOptions;
noMasterPasswordWithKeyConnector: UserDecryptionOptions;
};
let selectedUserDecryptionOptions: BehaviorSubject<UserDecryptionOptions>;
let authenticationSessionTimeoutSubject: BehaviorSubject<boolean>;
beforeEach(() => {
authenticationSessionTimeoutSubject = new BehaviorSubject<boolean>(false);
mockLoginStrategyService = mock<LoginStrategyServiceAbstraction>();
mockLoginStrategyService.authenticationSessionTimeout$ = authenticationSessionTimeoutSubject;
mockRouter = mock<Router>();
mockI18nService = mock<I18nService>();
mockApiService = mock<ApiService>();
mockPlatformUtilsService = mock<PlatformUtilsService>();
mockWin = mock<Window>();
mockEnvironmentService = mock<EnvironmentService>();
mockStateService = mock<StateService>();
mockLogService = mock<LogService>();
mockTwoFactorService = mock<TwoFactorService>();
mockAppIdService = mock<AppIdService>();
mockLoginEmailService = mock<LoginEmailServiceAbstraction>();
mockUserDecryptionOptionsService = mock<UserDecryptionOptionsServiceAbstraction>();
mockSsoLoginService = mock<SsoLoginServiceAbstraction>();
mockConfigService = mock<ConfigService>();
mockAccountService = mockAccountServiceWith(userId);
mockToastService = mock<ToastService>();
mockMasterPasswordService = new FakeMasterPasswordService();
mockUserDecryptionOpts = {
noMasterPassword: new UserDecryptionOptions({
hasMasterPassword: false,
trustedDeviceOption: undefined,
keyConnectorOption: undefined,
}),
withMasterPassword: new UserDecryptionOptions({
hasMasterPassword: true,
trustedDeviceOption: undefined,
keyConnectorOption: undefined,
}),
withMasterPasswordAndTrustedDevice: new UserDecryptionOptions({
hasMasterPassword: true,
trustedDeviceOption: new TrustedDeviceUserDecryptionOption(true, false, false, false),
keyConnectorOption: undefined,
}),
withMasterPasswordAndTrustedDeviceWithManageResetPassword: new UserDecryptionOptions({
hasMasterPassword: true,
trustedDeviceOption: new TrustedDeviceUserDecryptionOption(true, false, true, false),
keyConnectorOption: undefined,
}),
withMasterPasswordAndKeyConnector: new UserDecryptionOptions({
hasMasterPassword: true,
trustedDeviceOption: undefined,
keyConnectorOption: new KeyConnectorUserDecryptionOption("http://example.com"),
}),
noMasterPasswordWithTrustedDevice: new UserDecryptionOptions({
hasMasterPassword: false,
trustedDeviceOption: new TrustedDeviceUserDecryptionOption(true, false, false, false),
keyConnectorOption: undefined,
}),
noMasterPasswordWithTrustedDeviceWithManageResetPassword: new UserDecryptionOptions({
hasMasterPassword: false,
trustedDeviceOption: new TrustedDeviceUserDecryptionOption(true, false, true, false),
keyConnectorOption: undefined,
}),
noMasterPasswordWithKeyConnector: new UserDecryptionOptions({
hasMasterPassword: false,
trustedDeviceOption: undefined,
keyConnectorOption: new KeyConnectorUserDecryptionOption("http://example.com"),
}),
};
selectedUserDecryptionOptions = new BehaviorSubject<UserDecryptionOptions>(
mockUserDecryptionOpts.withMasterPassword,
);
mockUserDecryptionOptionsService.userDecryptionOptions$ = selectedUserDecryptionOptions;
TestBed.configureTestingModule({
declarations: [TestTwoFactorComponent],
providers: [
{ provide: LoginStrategyServiceAbstraction, useValue: mockLoginStrategyService },
{ provide: Router, useValue: mockRouter },
{ provide: I18nService, useValue: mockI18nService },
{ provide: ApiService, useValue: mockApiService },
{ provide: PlatformUtilsService, useValue: mockPlatformUtilsService },
{ provide: WINDOW, useValue: mockWin },
{ provide: EnvironmentService, useValue: mockEnvironmentService },
{ provide: StateService, useValue: mockStateService },
{
provide: ActivatedRoute,
useValue: {
snapshot: {
// Default to standard 2FA flow - not SSO + 2FA
queryParamMap: convertToParamMap({ sso: "false" }),
},
},
},
{ provide: LogService, useValue: mockLogService },
{ provide: TwoFactorService, useValue: mockTwoFactorService },
{ provide: AppIdService, useValue: mockAppIdService },
{ provide: LoginEmailServiceAbstraction, useValue: mockLoginEmailService },
{
provide: UserDecryptionOptionsServiceAbstraction,
useValue: mockUserDecryptionOptionsService,
},
{ provide: SsoLoginServiceAbstraction, useValue: mockSsoLoginService },
{ provide: ConfigService, useValue: mockConfigService },
{ provide: InternalMasterPasswordServiceAbstraction, useValue: mockMasterPasswordService },
{ provide: AccountService, useValue: mockAccountService },
{ provide: ToastService, useValue: mockToastService },
],
});
fixture = TestBed.createComponent(TestTwoFactorComponent);
component = fixture.componentInstance;
_component = component as any;
});
afterEach(() => {
// Reset all mocks after each test
jest.resetAllMocks();
});
it("should create", () => {
expect(component).toBeTruthy();
});
// Shared tests
const testChangePasswordOnSuccessfulLogin = () => {
it("navigates to the component's defined change password route when user doesn't have a MP and key connector isn't enabled", async () => {
// Act
await component.doSubmit();
// Assert
expect(mockRouter.navigate).toHaveBeenCalledTimes(1);
expect(mockRouter.navigate).toHaveBeenCalledWith([_component.changePasswordRoute], {
queryParams: {
identifier: component.orgIdentifier,
},
});
});
};
const testForceResetOnSuccessfulLogin = (reasonString: string) => {
it(`navigates to the component's defined forcePasswordResetRoute route when response.forcePasswordReset is ${reasonString}`, async () => {
// Act
await component.doSubmit();
// expect(mockRouter.navigate).toHaveBeenCalledTimes(1);
expect(mockRouter.navigate).toHaveBeenCalledWith([_component.forcePasswordResetRoute], {
queryParams: {
identifier: component.orgIdentifier,
},
});
});
};
describe("Standard 2FA scenarios", () => {
describe("doSubmit", () => {
const token = "testToken";
const remember = false;
const captchaToken = "testCaptchaToken";
beforeEach(() => {
component.token = token;
component.remember = remember;
component.captchaToken = captchaToken;
selectedUserDecryptionOptions.next(mockUserDecryptionOpts.withMasterPassword);
});
it("calls authService.logInTwoFactor with correct parameters when form is submitted", async () => {
// Arrange
mockLoginStrategyService.logInTwoFactor.mockResolvedValue(new AuthResult());
// Act
await component.doSubmit();
// Assert
expect(mockLoginStrategyService.logInTwoFactor).toHaveBeenCalledWith(
new TokenTwoFactorRequest(component.selectedProviderType, token, remember),
captchaToken,
);
});
it("should return when handleCaptchaRequired returns true", async () => {
// Arrange
const captchaSiteKey = "testCaptchaSiteKey";
const authResult = new AuthResult();
authResult.captchaSiteKey = captchaSiteKey;
mockLoginStrategyService.logInTwoFactor.mockResolvedValue(authResult);
// Note: the any casts are required b/c typescript cant recognize that
// handleCaptureRequired is a method on TwoFactorComponent b/c it is inherited
// from the CaptchaProtectedComponent
const handleCaptchaRequiredSpy = jest
.spyOn<any, any>(component, "handleCaptchaRequired")
.mockReturnValue(true);
// Act
const result = await component.doSubmit();
// Assert
expect(handleCaptchaRequiredSpy).toHaveBeenCalled();
expect(result).toBeUndefined();
});
it("calls onSuccessfulLogin when defined", async () => {
// Arrange
component.onSuccessfulLogin = jest.fn().mockResolvedValue(undefined);
mockLoginStrategyService.logInTwoFactor.mockResolvedValue(new AuthResult());
// Act
await component.doSubmit();
// Assert
expect(component.onSuccessfulLogin).toHaveBeenCalled();
});
it("calls loginEmailService.clearValues() when login is successful", async () => {
// Arrange
mockLoginStrategyService.logInTwoFactor.mockResolvedValue(new AuthResult());
// spy on loginEmailService.clearValues
const clearValuesSpy = jest.spyOn(mockLoginEmailService, "clearValues");
// Act
await component.doSubmit();
// Assert
expect(clearValuesSpy).toHaveBeenCalled();
});
describe("Set Master Password scenarios", () => {
beforeEach(() => {
const authResult = new AuthResult();
mockLoginStrategyService.logInTwoFactor.mockResolvedValue(authResult);
});
describe("Given user needs to set a master password", () => {
beforeEach(() => {
// Only need to test the case where the user has no master password to test the primary change mp flow here
selectedUserDecryptionOptions.next(mockUserDecryptionOpts.noMasterPassword);
});
testChangePasswordOnSuccessfulLogin();
});
it("does not navigate to the change password route when the user has key connector even if user has no master password", async () => {
selectedUserDecryptionOptions.next(
mockUserDecryptionOpts.noMasterPasswordWithKeyConnector,
);
await component.doSubmit();
expect(mockRouter.navigate).not.toHaveBeenCalledWith([_component.changePasswordRoute], {
queryParams: {
identifier: component.orgIdentifier,
},
});
});
});
describe("Force Master Password Reset scenarios", () => {
[
ForceSetPasswordReason.AdminForcePasswordReset,
ForceSetPasswordReason.WeakMasterPassword,
].forEach((forceResetPasswordReason) => {
const reasonString = ForceSetPasswordReason[forceResetPasswordReason];
beforeEach(() => {
// use standard user with MP because this test is not concerned with password reset.
selectedUserDecryptionOptions.next(mockUserDecryptionOpts.withMasterPassword);
const authResult = new AuthResult();
authResult.forcePasswordReset = forceResetPasswordReason;
mockLoginStrategyService.logInTwoFactor.mockResolvedValue(authResult);
});
testForceResetOnSuccessfulLogin(reasonString);
});
});
it("calls onSuccessfulLoginNavigate when the callback is defined", async () => {
// Arrange
component.onSuccessfulLoginNavigate = jest.fn().mockResolvedValue(undefined);
mockLoginStrategyService.logInTwoFactor.mockResolvedValue(new AuthResult());
// Act
await component.doSubmit();
// Assert
expect(component.onSuccessfulLoginNavigate).toHaveBeenCalled();
});
it("navigates to the component's defined success route when the login is successful and onSuccessfulLoginNavigate is undefined", async () => {
mockLoginStrategyService.logInTwoFactor.mockResolvedValue(new AuthResult());
// Act
await component.doSubmit();
// Assert
expect(component.onSuccessfulLoginNavigate).not.toBeDefined();
expect(mockRouter.navigate).toHaveBeenCalledTimes(1);
expect(mockRouter.navigate).toHaveBeenCalledWith([_component.successRoute], undefined);
});
});
});
describe("SSO > 2FA scenarios", () => {
beforeEach(() => {
const mockActivatedRoute = TestBed.inject(ActivatedRoute);
mockActivatedRoute.snapshot.queryParamMap.get = jest.fn().mockReturnValue("true");
});
describe("doSubmit", () => {
const token = "testToken";
const remember = false;
const captchaToken = "testCaptchaToken";
beforeEach(() => {
component.token = token;
component.remember = remember;
component.captchaToken = captchaToken;
});
describe("Trusted Device Encryption scenarios", () => {
beforeEach(() => {
mockConfigService.getFeatureFlag.mockResolvedValue(true);
});
describe("Given Trusted Device Encryption is enabled and user needs to set a master password", () => {
beforeEach(() => {
selectedUserDecryptionOptions.next(
mockUserDecryptionOpts.noMasterPasswordWithTrustedDeviceWithManageResetPassword,
);
const authResult = new AuthResult();
mockLoginStrategyService.logInTwoFactor.mockResolvedValue(authResult);
});
it("navigates to the component's defined trusted device encryption route and sets correct flag when user doesn't have a MP and key connector isn't enabled", async () => {
// Act
await component.doSubmit();
// Assert
expect(mockMasterPasswordService.mock.setForceSetPasswordReason).toHaveBeenCalledWith(
ForceSetPasswordReason.TdeUserWithoutPasswordHasPasswordResetPermission,
userId,
);
expect(mockRouter.navigate).toHaveBeenCalledTimes(1);
expect(mockRouter.navigate).toHaveBeenCalledWith(
[_component.trustedDeviceEncRoute],
undefined,
);
});
});
describe("Given Trusted Device Encryption is enabled, user doesn't need to set a MP, and forcePasswordReset is required", () => {
[
ForceSetPasswordReason.AdminForcePasswordReset,
ForceSetPasswordReason.WeakMasterPassword,
].forEach((forceResetPasswordReason) => {
const reasonString = ForceSetPasswordReason[forceResetPasswordReason];
beforeEach(() => {
// use standard user with MP because this test is not concerned with password reset.
selectedUserDecryptionOptions.next(
mockUserDecryptionOpts.withMasterPasswordAndTrustedDevice,
);
const authResult = new AuthResult();
authResult.forcePasswordReset = forceResetPasswordReason;
mockLoginStrategyService.logInTwoFactor.mockResolvedValue(authResult);
});
testForceResetOnSuccessfulLogin(reasonString);
});
});
describe("Given Trusted Device Encryption is enabled, user doesn't need to set a MP, and forcePasswordReset is not required", () => {
let authResult;
beforeEach(() => {
selectedUserDecryptionOptions.next(
mockUserDecryptionOpts.withMasterPasswordAndTrustedDevice,
);
authResult = new AuthResult();
authResult.forcePasswordReset = ForceSetPasswordReason.None;
mockLoginStrategyService.logInTwoFactor.mockResolvedValue(authResult);
});
it("navigates to the component's defined trusted device encryption route when login is successful and onSuccessfulLoginTdeNavigate is undefined", async () => {
await component.doSubmit();
expect(mockRouter.navigate).toHaveBeenCalledTimes(1);
expect(mockRouter.navigate).toHaveBeenCalledWith(
[_component.trustedDeviceEncRoute],
undefined,
);
});
it("calls onSuccessfulLoginTdeNavigate instead of router.navigate when the callback is defined", async () => {
component.onSuccessfulLoginTdeNavigate = jest.fn().mockResolvedValue(undefined);
await component.doSubmit();
expect(mockRouter.navigate).not.toHaveBeenCalled();
expect(component.onSuccessfulLoginTdeNavigate).toHaveBeenCalled();
});
});
});
});
});
it("navigates to the timeout route when timeout expires", async () => {
authenticationSessionTimeoutSubject.next(true);
expect(mockRouter.navigate).toHaveBeenCalledWith(["authentication-timeout"]);
});
});

View File

@@ -1,514 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Directive, Inject, OnInit, OnDestroy } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { ActivatedRoute, NavigationExtras, Router } from "@angular/router";
import { firstValueFrom } from "rxjs";
import { first } from "rxjs/operators";
import { WINDOW } from "@bitwarden/angular/services/injection-tokens";
import {
LoginStrategyServiceAbstraction,
LoginEmailServiceAbstraction,
TrustedDeviceUserDecryptionOption,
UserDecryptionOptions,
UserDecryptionOptionsServiceAbstraction,
} from "@bitwarden/auth/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service";
import { AuthenticationType } from "@bitwarden/common/auth/enums/authentication-type";
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/identity-token/token-two-factor.request";
import { TwoFactorEmailRequest } from "@bitwarden/common/auth/models/request/two-factor-email.request";
import { TwoFactorProviders } from "@bitwarden/common/auth/services/two-factor.service";
import { WebAuthnIFrame } from "@bitwarden/common/auth/webauthn-iframe";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
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 { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { ToastService } from "@bitwarden/components";
import { CaptchaProtectedComponent } from "./captcha-protected.component";
@Directive()
export class TwoFactorComponentV1 extends CaptchaProtectedComponent implements OnInit, OnDestroy {
token = "";
remember = false;
webAuthnReady = false;
webAuthnNewTab = false;
providers = TwoFactorProviders;
providerType = TwoFactorProviderType;
selectedProviderType: TwoFactorProviderType = TwoFactorProviderType.Authenticator;
webAuthnSupported = false;
webAuthn: WebAuthnIFrame = null;
title = "";
twoFactorEmail: string = null;
formPromise: Promise<any>;
emailPromise: Promise<any>;
orgIdentifier: string = null;
duoFramelessUrl: string = null;
duoResultListenerInitialized = false;
onSuccessfulLogin: () => Promise<void>;
onSuccessfulLoginNavigate: () => Promise<void>;
onSuccessfulLoginTde: () => Promise<void>;
onSuccessfulLoginTdeNavigate: () => Promise<void>;
protected loginRoute = "login";
protected trustedDeviceEncRoute = "login-initiated";
protected changePasswordRoute = "set-password";
protected forcePasswordResetRoute = "update-temp-password";
protected successRoute = "vault";
protected twoFactorTimeoutRoute = "authentication-timeout";
get isDuoProvider(): boolean {
return (
this.selectedProviderType === TwoFactorProviderType.Duo ||
this.selectedProviderType === TwoFactorProviderType.OrganizationDuo
);
}
constructor(
protected loginStrategyService: LoginStrategyServiceAbstraction,
protected router: Router,
protected i18nService: I18nService,
protected apiService: ApiService,
protected platformUtilsService: PlatformUtilsService,
@Inject(WINDOW) protected win: Window,
protected environmentService: EnvironmentService,
protected stateService: StateService,
protected route: ActivatedRoute,
protected logService: LogService,
protected twoFactorService: TwoFactorService,
protected appIdService: AppIdService,
protected loginEmailService: LoginEmailServiceAbstraction,
protected userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction,
protected ssoLoginService: SsoLoginServiceAbstraction,
protected configService: ConfigService,
protected masterPasswordService: InternalMasterPasswordServiceAbstraction,
protected accountService: AccountService,
protected toastService: ToastService,
) {
super(environmentService, i18nService, platformUtilsService, toastService);
this.webAuthnSupported = this.platformUtilsService.supportsWebAuthn(win);
// Add subscription to authenticationSessionTimeout$ and navigate to twoFactorTimeoutRoute if expired
this.loginStrategyService.authenticationSessionTimeout$
.pipe(takeUntilDestroyed())
.subscribe(async (expired) => {
if (!expired) {
return;
}
try {
await this.router.navigate([this.twoFactorTimeoutRoute]);
} catch (err) {
this.logService.error(`Failed to navigate to ${this.twoFactorTimeoutRoute} route`, err);
}
});
}
async ngOnInit() {
if (!(await this.authing()) || (await this.twoFactorService.getProviders()) == null) {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.router.navigate([this.loginRoute]);
return;
}
this.route.queryParams.pipe(first()).subscribe((qParams) => {
if (qParams.identifier != null) {
this.orgIdentifier = qParams.identifier;
}
});
if (await this.needsLock()) {
this.successRoute = "lock";
}
if (this.win != null && this.webAuthnSupported) {
const env = await firstValueFrom(this.environmentService.environment$);
const webVaultUrl = env.getWebVaultUrl();
this.webAuthn = new WebAuthnIFrame(
this.win,
webVaultUrl,
this.webAuthnNewTab,
this.platformUtilsService,
this.i18nService,
(token: string) => {
this.token = token;
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.submit();
},
(error: string) => {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: this.i18nService.t("webauthnCancelOrTimeout"),
});
},
(info: string) => {
if (info === "ready") {
this.webAuthnReady = true;
}
},
);
}
this.selectedProviderType = await this.twoFactorService.getDefaultProvider(
this.webAuthnSupported,
);
await this.init();
}
ngOnDestroy(): void {
this.cleanupWebAuthn();
this.webAuthn = null;
}
async init() {
if (this.selectedProviderType == null) {
this.title = this.i18nService.t("loginUnavailable");
return;
}
this.cleanupWebAuthn();
this.title = (TwoFactorProviders as any)[this.selectedProviderType].name;
const providerData = await this.twoFactorService.getProviders().then((providers) => {
return providers.get(this.selectedProviderType);
});
switch (this.selectedProviderType) {
case TwoFactorProviderType.WebAuthn:
if (!this.webAuthnNewTab) {
setTimeout(async () => {
await this.authWebAuthn();
}, 500);
}
break;
case TwoFactorProviderType.Duo:
case TwoFactorProviderType.OrganizationDuo:
// Setup listener for duo-redirect.ts connector to send back the code
if (!this.duoResultListenerInitialized) {
// setup client specific duo result listener
this.setupDuoResultListener();
this.duoResultListenerInitialized = true;
}
// flow must be launched by user so they can choose to remember the device or not.
this.duoFramelessUrl = providerData.AuthUrl;
break;
case TwoFactorProviderType.Email:
this.twoFactorEmail = providerData.Email;
if ((await this.twoFactorService.getProviders()).size > 1) {
await this.sendEmail(false);
}
break;
default:
break;
}
}
async submit() {
await this.setupCaptcha();
if (this.token == null || this.token === "") {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: this.i18nService.t("verificationCodeRequired"),
});
return;
}
if (this.selectedProviderType === TwoFactorProviderType.WebAuthn) {
if (this.webAuthn != null) {
this.webAuthn.stop();
} else {
return;
}
} else if (
this.selectedProviderType === TwoFactorProviderType.Email ||
this.selectedProviderType === TwoFactorProviderType.Authenticator
) {
this.token = this.token.replace(" ", "").trim();
}
await this.doSubmit();
if (this.selectedProviderType === TwoFactorProviderType.WebAuthn && this.webAuthn != null) {
this.webAuthn.start();
}
}
async doSubmit() {
this.formPromise = this.loginStrategyService.logInTwoFactor(
new TokenTwoFactorRequest(this.selectedProviderType, this.token, this.remember),
this.captchaToken,
);
const authResult: AuthResult = await this.formPromise;
await this.handleLoginResponse(authResult);
}
protected handleMigrateEncryptionKey(result: AuthResult): boolean {
if (!result.requiresEncryptionKeyMigration) {
return false;
}
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccured"),
message: this.i18nService.t("encryptionKeyMigrationRequired"),
});
return true;
}
// Each client will have own implementation
protected setupDuoResultListener(): void {}
private async handleLoginResponse(authResult: AuthResult) {
if (this.handleCaptchaRequired(authResult)) {
return;
} else if (this.handleMigrateEncryptionKey(authResult)) {
return;
}
// Save off the OrgSsoIdentifier for use in the TDE flows
// - TDE login decryption options component
// - Browser SSO on extension open
const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
await this.ssoLoginService.setActiveUserOrganizationSsoIdentifier(this.orgIdentifier, userId);
this.loginEmailService.clearValues();
// note: this flow affects both TDE & standard users
if (this.isForcePasswordResetRequired(authResult)) {
return await this.handleForcePasswordReset(this.orgIdentifier);
}
const userDecryptionOpts = await firstValueFrom(
this.userDecryptionOptionsService.userDecryptionOptions$,
);
const tdeEnabled = await this.isTrustedDeviceEncEnabled(userDecryptionOpts.trustedDeviceOption);
if (tdeEnabled) {
return await this.handleTrustedDeviceEncryptionEnabled(
authResult,
this.orgIdentifier,
userDecryptionOpts,
);
}
// User must set password if they don't have one and they aren't using either TDE or key connector.
const requireSetPassword =
!userDecryptionOpts.hasMasterPassword && userDecryptionOpts.keyConnectorOption === undefined;
if (requireSetPassword || authResult.resetMasterPassword) {
// Change implies going no password -> password in this case
return await this.handleChangePasswordRequired(this.orgIdentifier);
}
return await this.handleSuccessfulLogin();
}
private async isTrustedDeviceEncEnabled(
trustedDeviceOption: TrustedDeviceUserDecryptionOption,
): Promise<boolean> {
const ssoTo2faFlowActive = this.route.snapshot.queryParamMap.get("sso") === "true";
return ssoTo2faFlowActive && trustedDeviceOption !== undefined;
}
private async handleTrustedDeviceEncryptionEnabled(
authResult: AuthResult,
orgIdentifier: string,
userDecryptionOpts: UserDecryptionOptions,
): Promise<void> {
// If user doesn't have a MP, but has reset password permission, they must set a MP
if (
!userDecryptionOpts.hasMasterPassword &&
userDecryptionOpts.trustedDeviceOption.hasManageResetPasswordPermission
) {
// Set flag so that auth guard can redirect to set password screen after decryption (trusted or untrusted device)
// Note: we cannot directly navigate to the set password screen in this scenario as we are in a pre-decryption state, and
// if you try to set a new MP before decrypting, you will invalidate the user's data by making a new user key.
const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
await this.masterPasswordService.setForceSetPasswordReason(
ForceSetPasswordReason.TdeUserWithoutPasswordHasPasswordResetPermission,
userId,
);
}
if (this.onSuccessfulLoginTde != null) {
// Note: awaiting this will currently cause a hang on desktop & browser as they will wait for a full sync to complete
// before navigating to the success route.
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.onSuccessfulLoginTde();
}
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.navigateViaCallbackOrRoute(
this.onSuccessfulLoginTdeNavigate,
// Navigate to TDE page (if user was on trusted device and TDE has decrypted
// their user key, the login-initiated guard will redirect them to the vault)
[this.trustedDeviceEncRoute],
);
}
private async handleChangePasswordRequired(orgIdentifier: string) {
await this.router.navigate([this.changePasswordRoute], {
queryParams: {
identifier: orgIdentifier,
},
});
}
/**
* Determines if a user needs to reset their password based on certain conditions.
* Users can be forced to reset their password via an admin or org policy disallowing weak passwords.
* Note: this is different from the SSO component login flow as a user can
* login with MP and then have to pass 2FA to finish login and we can actually
* evaluate if they have a weak password at that time.
*
* @param {AuthResult} authResult - The authentication result.
* @returns {boolean} Returns true if a password reset is required, false otherwise.
*/
private isForcePasswordResetRequired(authResult: AuthResult): boolean {
const forceResetReasons = [
ForceSetPasswordReason.AdminForcePasswordReset,
ForceSetPasswordReason.WeakMasterPassword,
];
return forceResetReasons.includes(authResult.forcePasswordReset);
}
private async handleForcePasswordReset(orgIdentifier: string) {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.router.navigate([this.forcePasswordResetRoute], {
queryParams: {
identifier: orgIdentifier,
},
});
}
private async handleSuccessfulLogin() {
if (this.onSuccessfulLogin != null) {
// Note: awaiting this will currently cause a hang on desktop & browser as they will wait for a full sync to complete
// before navigating to the success route.
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.onSuccessfulLogin();
}
await this.navigateViaCallbackOrRoute(this.onSuccessfulLoginNavigate, [this.successRoute]);
}
private async navigateViaCallbackOrRoute(
callback: () => Promise<unknown>,
commands: unknown[],
extras?: NavigationExtras,
): Promise<void> {
if (callback) {
await callback();
} else {
await this.router.navigate(commands, extras);
}
}
async sendEmail(doToast: boolean) {
if (this.selectedProviderType !== TwoFactorProviderType.Email) {
return;
}
if (this.emailPromise != null) {
return;
}
if ((await this.loginStrategyService.getEmail()) == null) {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: this.i18nService.t("sessionTimeout"),
});
return;
}
try {
const request = new TwoFactorEmailRequest();
request.email = await this.loginStrategyService.getEmail();
request.masterPasswordHash = await this.loginStrategyService.getMasterPasswordHash();
request.ssoEmail2FaSessionToken =
await this.loginStrategyService.getSsoEmail2FaSessionToken();
request.deviceIdentifier = await this.appIdService.getAppId();
request.authRequestAccessCode = await this.loginStrategyService.getAccessCode();
request.authRequestId = await this.loginStrategyService.getAuthRequestId();
this.emailPromise = this.apiService.postTwoFactorEmail(request);
await this.emailPromise;
if (doToast) {
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t("verificationCodeEmailSent", this.twoFactorEmail),
});
}
} catch (e) {
this.logService.error(e);
}
this.emailPromise = null;
}
async authWebAuthn() {
const providerData = await this.twoFactorService.getProviders().then((providers) => {
return providers.get(this.selectedProviderType);
});
if (!this.webAuthnSupported || this.webAuthn == null) {
return;
}
this.webAuthn.init(providerData);
}
private cleanupWebAuthn() {
if (this.webAuthn != null) {
this.webAuthn.stop();
this.webAuthn.cleanup();
}
}
private async authing(): Promise<boolean> {
return (await firstValueFrom(this.loginStrategyService.currentAuthType$)) !== null;
}
private async needsLock(): Promise<boolean> {
const authType = await firstValueFrom(this.loginStrategyService.currentAuthType$);
return authType == AuthenticationType.Sso || authType == AuthenticationType.UserApiKey;
}
async launchDuoFrameless() {
if (this.duoFramelessUrl === null) {
this.toastService.showToast({
variant: "error",
title: null,
message: this.i18nService.t("duoHealthCheckResultsInNullAuthUrlError"),
});
return;
}
this.platformUtilsService.launchUri(this.duoFramelessUrl);
}
}

View File

@@ -1,36 +0,0 @@
import { Type, inject } from "@angular/core";
import { Route, Routes } from "@angular/router";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { componentRouteSwap } from "../../utils/component-route-swap";
/**
* Helper function to swap between two components based on the UnauthenticatedExtensionUIRefresh feature flag.
* We need this because the auth teams's authenticated UI will be refreshed as part of the MVP but the
* unauthenticated UIs will not necessarily make the cut.
* Note: Even though this is primarily an extension refresh initiative, this will be used across clients
* as we are consolidating the unauthenticated UIs into single libs/auth components which affects all clients.
* @param defaultComponent - The current non-refreshed component to render.
* @param refreshedComponent - The new refreshed component to render.
* @param options - The shared route options to apply to both components.
* @param altOptions - The alt route options to apply to the alt component. If not provided, the base options will be used.
*/
export function unauthUiRefreshSwap(
defaultComponent: Type<any>,
refreshedComponent: Type<any>,
options: Route,
altOptions?: Route,
): Routes {
return componentRouteSwap(
defaultComponent,
refreshedComponent,
async () => {
const configService = inject(ConfigService);
return configService.getFeatureFlag(FeatureFlag.UnauthenticatedExtensionUIRefresh);
},
options,
altOptions,
);
}

View File

@@ -1,167 +0,0 @@
import { AbstractControl, UntypedFormGroup, ValidationErrors, ValidatorFn } from "@angular/forms";
import { FormGroupControls } from "../../platform/abstractions/form-validation-errors.service";
export class InputsFieldMatch {
/**
* Check to ensure two fields do not have the same value
*
* @deprecated Use compareInputs() instead
*/
static validateInputsDoesntMatch(matchTo: string, errorMessage: string): ValidatorFn {
return (control: AbstractControl) => {
if (control.parent && control.parent.controls) {
return control?.value === (control?.parent?.controls as FormGroupControls)[matchTo].value
? {
inputsMatchError: {
message: errorMessage,
},
}
: null;
}
return null;
};
}
//check to ensure two fields have the same value
static validateInputsMatch(matchTo: string, errorMessage: string): ValidatorFn {
return (control: AbstractControl) => {
if (control.parent && control.parent.controls) {
return control?.value === (control?.parent?.controls as FormGroupControls)[matchTo].value
? null
: {
inputsDoesntMatchError: {
message: errorMessage,
},
};
}
return null;
};
}
/**
* Checks the formGroup if two fields have the same value and validation is controlled from either field
*
* @deprecated
* Use compareInputs() instead.
*
* For more info on deprecation
* - Do not use untyped `options` object in formBuilder.group() {@link https://angular.dev/api/forms/UntypedFormBuilder}
* - Use formBuilder.group() overload with AbstractControlOptions type instead {@link https://angular.dev/api/forms/AbstractControlOptions}
*
* Remove this method after deprecated instances are replaced
*/
static validateFormInputsMatch(field: string, fieldMatchTo: string, errorMessage: string) {
return (formGroup: UntypedFormGroup) => {
const fieldCtrl = formGroup.controls[field];
const fieldMatchToCtrl = formGroup.controls[fieldMatchTo];
if (fieldCtrl.value !== fieldMatchToCtrl.value) {
fieldMatchToCtrl.setErrors({
inputsDoesntMatchError: {
message: errorMessage,
},
});
} else {
fieldMatchToCtrl.setErrors(null);
}
};
}
/**
* Checks whether two form controls do or do not have the same input value (except for empty string values).
*
* - Validation is controlled from either form control.
* - The error message is displayed under controlB by default, but can be set to controlA.
*
* @param validationGoal Whether you want to verify that the form control input values match or do not match
* @param controlNameA The name of the first form control to compare.
* @param controlNameB The name of the second form control to compare.
* @param errorMessage The error message to display if there is an error. This will probably
* be an i18n translated string.
* @param showErrorOn The control under which you want to display the error (default is controlB).
*/
static compareInputs(
validationGoal: "match" | "doNotMatch",
controlNameA: string,
controlNameB: string,
errorMessage: string,
showErrorOn: "controlA" | "controlB" = "controlB",
): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const controlA = control.get(controlNameA);
const controlB = control.get(controlNameB);
if (!controlA || !controlB) {
return null;
}
const controlThatShowsError = showErrorOn === "controlA" ? controlA : controlB;
// Don't compare empty strings
if (controlA.value === "" && controlB.value === "") {
return pass();
}
const controlValuesMatch = controlA.value === controlB.value;
if (validationGoal === "match") {
if (controlValuesMatch) {
return pass();
} else {
return fail();
}
}
if (validationGoal === "doNotMatch") {
if (!controlValuesMatch) {
return pass();
} else {
return fail();
}
}
return null; // default return
function fail() {
controlThatShowsError.setErrors({
// Preserve any pre-existing errors
...controlThatShowsError.errors,
// Add new inputMatchError
inputMatchError: {
message: errorMessage,
},
});
return {
inputMatchError: {
message: errorMessage,
},
};
}
function pass(): null {
// Get the current errors object
const errorsObj = controlThatShowsError?.errors;
if (errorsObj != null) {
// Remove any inputMatchError if it exists, since that is the sole error we are targeting with this validator
if (errorsObj?.inputMatchError) {
delete errorsObj.inputMatchError;
}
// Check if the errorsObj is now empty
const isEmptyObj = Object.keys(errorsObj).length === 0;
// If the errorsObj is empty, set errors to null, otherwise set the errors to an object of pre-existing errors (other than inputMatchError)
controlThatShowsError.setErrors(isEmptyObj ? null : errorsObj);
}
// Return null for this validator
return null;
}
};
}
}

View File

@@ -1,6 +1,5 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog";
import { Component, ElementRef, Inject, OnInit, ViewChild } from "@angular/core";
import { FormControl, FormGroup, Validators } from "@angular/forms";
import { firstValueFrom, map } from "rxjs";
@@ -16,7 +15,7 @@ import { BitPayInvoiceRequest } from "@bitwarden/common/billing/models/request/b
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { UserId } from "@bitwarden/common/types/guid";
import { DialogService } from "@bitwarden/components";
import { DIALOG_DATA, DialogConfig, DialogRef, DialogService } from "@bitwarden/components";
export type AddAccountCreditDialogParams = {
organizationId?: string;

View File

@@ -35,7 +35,7 @@
{{ "open" | i18n | titlecase }}
</span>
<span *ngIf="expandedInvoiceStatus === 'unpaid'">
<i class="bwi bwi-exclamation-circle tw-text-muted" aria-hidden="true"></i>
<i class="bwi bwi-error tw-text-muted" aria-hidden="true"></i>
{{ "unpaid" | i18n | titlecase }}
</span>
<span *ngIf="expandedInvoiceStatus === 'paid'">

View File

@@ -77,6 +77,18 @@ export class ManageTaxInformationComponent implements OnInit, OnDestroy {
}
async ngOnInit() {
this.formGroup.valueChanges.pipe(takeUntil(this.destroy$)).subscribe((values) => {
this.taxInformation = {
country: values.country,
postalCode: values.postalCode,
taxId: values.taxId,
line1: values.line1,
line2: values.line2,
city: values.city,
state: values.state,
};
});
if (this.startWith) {
this.formGroup.controls.country.setValue(this.startWith.country);
this.formGroup.controls.postalCode.setValue(this.startWith.postalCode);
@@ -95,18 +107,6 @@ export class ManageTaxInformationComponent implements OnInit, OnDestroy {
}
}
this.formGroup.valueChanges.pipe(takeUntil(this.destroy$)).subscribe((values) => {
this.taxInformation = {
country: values.country,
postalCode: values.postalCode,
taxId: values.taxId,
line1: values.line1,
line2: values.line2,
city: values.city,
state: values.state,
};
});
this.formGroup.controls.country.valueChanges
.pipe(debounceTime(1000), takeUntil(this.destroy$))
.subscribe((country: string) => {

View File

@@ -9,7 +9,7 @@ type Deserializer<T> = {
* @param jsonValue The JSON object representation of your state.
* @returns The fully typed version of your state.
*/
readonly deserializer?: (jsonValue: Jsonify<T>) => T;
readonly deserializer?: (jsonValue: Jsonify<T>) => T | null;
};
type BaseCacheOptions<T> = {

View File

@@ -4,48 +4,48 @@ import { ErrorHandler, LOCALE_ID, NgModule } from "@angular/core";
import { Subject } from "rxjs";
import {
OrganizationUserApiService,
DefaultOrganizationUserApiService,
CollectionService,
DefaultCollectionService,
DefaultOrganizationUserApiService,
OrganizationUserApiService,
} from "@bitwarden/admin-console/common";
import {
SetPasswordJitService,
DefaultSetPasswordJitService,
RegistrationFinishService as RegistrationFinishServiceAbstraction,
DefaultRegistrationFinishService,
AnonLayoutWrapperDataService,
DefaultAnonLayoutWrapperDataService,
LoginComponentService,
DefaultLoginApprovalComponentService,
DefaultLoginComponentService,
LoginDecryptionOptionsService,
DefaultLoginDecryptionOptionsService,
TwoFactorAuthComponentService,
DefaultRegistrationFinishService,
DefaultSetPasswordJitService,
DefaultTwoFactorAuthComponentService,
DefaultTwoFactorAuthEmailComponentService,
TwoFactorAuthEmailComponentService,
DefaultTwoFactorAuthWebAuthnComponentService,
LoginComponentService,
LoginDecryptionOptionsService,
RegistrationFinishService as RegistrationFinishServiceAbstraction,
SetPasswordJitService,
TwoFactorAuthComponentService,
TwoFactorAuthEmailComponentService,
TwoFactorAuthWebAuthnComponentService,
DefaultLoginApprovalComponentService,
} from "@bitwarden/auth/angular";
import {
AuthRequestServiceAbstraction,
AuthRequestService,
PinServiceAbstraction,
PinService,
LoginStrategyServiceAbstraction,
LoginStrategyService,
LoginEmailServiceAbstraction,
LoginEmailService,
InternalUserDecryptionOptionsServiceAbstraction,
UserDecryptionOptionsService,
UserDecryptionOptionsServiceAbstraction,
LogoutReason,
AuthRequestApiService,
AuthRequestService,
AuthRequestServiceAbstraction,
DefaultAuthRequestApiService,
DefaultLoginSuccessHandlerService,
LoginSuccessHandlerService,
InternalUserDecryptionOptionsServiceAbstraction,
LoginApprovalComponentServiceAbstraction,
LoginEmailService,
LoginEmailServiceAbstraction,
LoginStrategyService,
LoginStrategyServiceAbstraction,
LoginSuccessHandlerService,
LogoutReason,
PinService,
PinServiceAbstraction,
UserDecryptionOptionsService,
UserDecryptionOptionsServiceAbstraction,
} from "@bitwarden/auth/common";
import { ApiService as ApiServiceAbstraction } from "@bitwarden/common/abstractions/api.service";
import { AuditService as AuditServiceAbstraction } from "@bitwarden/common/abstractions/audit.service";
@@ -75,12 +75,13 @@ import { OrganizationApiService } from "@bitwarden/common/admin-console/services
import { OrgDomainApiService } from "@bitwarden/common/admin-console/services/organization-domain/org-domain-api.service";
import { OrgDomainService } from "@bitwarden/common/admin-console/services/organization-domain/org-domain.service";
import { DefaultOrganizationManagementPreferencesService } from "@bitwarden/common/admin-console/services/organization-management-preferences/default-organization-management-preferences.service";
import { DefaultPolicyService } from "@bitwarden/common/admin-console/services/policy/default-policy.service";
import { PolicyApiService } from "@bitwarden/common/admin-console/services/policy/policy-api.service";
import { PolicyService } from "@bitwarden/common/admin-console/services/policy/policy.service";
import { ProviderApiService } from "@bitwarden/common/admin-console/services/provider/provider-api.service";
import { ProviderService } from "@bitwarden/common/admin-console/services/provider.service";
import { AccountApiService as AccountApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/account-api.service";
import {
AccountService,
AccountService as AccountServiceAbstraction,
InternalAccountService,
} from "@bitwarden/common/auth/abstractions/account.service";
@@ -117,16 +118,16 @@ import { WebAuthnLoginApiService } from "@bitwarden/common/auth/services/webauth
import { WebAuthnLoginPrfKeyService } from "@bitwarden/common/auth/services/webauthn-login/webauthn-login-prf-key.service";
import { WebAuthnLoginService } from "@bitwarden/common/auth/services/webauthn-login/webauthn-login.service";
import {
AutofillSettingsServiceAbstraction,
AutofillSettingsService,
AutofillSettingsServiceAbstraction,
} from "@bitwarden/common/autofill/services/autofill-settings.service";
import {
BadgeSettingsServiceAbstraction,
BadgeSettingsService,
BadgeSettingsServiceAbstraction,
} from "@bitwarden/common/autofill/services/badge-settings.service";
import {
DomainSettingsService,
DefaultDomainSettingsService,
DomainSettingsService,
} from "@bitwarden/common/autofill/services/domain-settings.service";
import {
BillingApiServiceAbstraction,
@@ -143,9 +144,11 @@ import { OrganizationBillingApiService } from "@bitwarden/common/billing/service
import { OrganizationBillingService } from "@bitwarden/common/billing/services/organization-billing.service";
import { TaxService } from "@bitwarden/common/billing/services/tax.service";
import { BulkEncryptService } from "@bitwarden/common/key-management/crypto/abstractions/bulk-encrypt.service";
import { CryptoFunctionService as CryptoFunctionServiceAbstraction } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { BulkEncryptServiceImplementation } from "@bitwarden/common/key-management/crypto/services/bulk-encrypt.service.implementation";
import { MultithreadEncryptServiceImplementation } from "@bitwarden/common/key-management/crypto/services/multithread-encrypt.service.implementation";
import { WebCryptoFunctionService } from "@bitwarden/common/key-management/crypto/services/web-crypto-function.service";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
import { DeviceTrustService } from "@bitwarden/common/key-management/device-trust/services/device-trust.service.implementation";
import { KeyConnectorService as KeyConnectorServiceAbstraction } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service";
@@ -165,7 +168,6 @@ import { AppIdService as AppIdServiceAbstraction } from "@bitwarden/common/platf
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
import { ConfigApiServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config-api.service.abstraction";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { CryptoFunctionService as CryptoFunctionServiceAbstraction } from "@bitwarden/common/platform/abstractions/crypto-function.service";
import {
EnvironmentService,
RegionConfig,
@@ -199,8 +201,8 @@ import {
WebPushNotificationsApiService,
} from "@bitwarden/common/platform/notifications/internal";
import {
TaskSchedulerService,
DefaultTaskSchedulerService,
TaskSchedulerService,
} from "@bitwarden/common/platform/scheduling";
import { AppIdService } from "@bitwarden/common/platform/services/app-id.service";
import { ConfigApiService } from "@bitwarden/common/platform/services/config/config-api.service";
@@ -218,13 +220,12 @@ import { StateService } from "@bitwarden/common/platform/services/state.service"
import { StorageServiceProvider } from "@bitwarden/common/platform/services/storage-service.provider";
import { UserAutoUnlockKeyService } from "@bitwarden/common/platform/services/user-auto-unlock-key.service";
import { ValidationService } from "@bitwarden/common/platform/services/validation.service";
import { WebCryptoFunctionService } from "@bitwarden/common/platform/services/web-crypto-function.service";
import {
ActiveUserStateProvider,
DerivedStateProvider,
GlobalStateProvider,
SingleUserStateProvider,
StateProvider,
DerivedStateProvider,
} from "@bitwarden/common/platform/state";
/* eslint-disable import/no-restricted-paths -- We need the implementations to inject, but generally these should not be accessed */
import { DefaultActiveUserStateProvider } from "@bitwarden/common/platform/state/implementations/default-active-user-state.provider";
@@ -279,6 +280,7 @@ import { FolderApiService } from "@bitwarden/common/vault/services/folder/folder
import { FolderService } from "@bitwarden/common/vault/services/folder/folder.service";
import { TotpService } from "@bitwarden/common/vault/services/totp.service";
import { VaultSettingsService } from "@bitwarden/common/vault/services/vault-settings/vault-settings.service";
import { DefaultTaskService, TaskService } from "@bitwarden/common/vault/tasks";
import { ToastService } from "@bitwarden/components";
import {
GeneratorHistoryService,
@@ -291,34 +293,32 @@ import {
UsernameGenerationServiceAbstraction,
} from "@bitwarden/generator-legacy";
import {
KeyService,
DefaultKeyService,
BiometricsService,
BiometricStateService,
DefaultBiometricStateService,
BiometricsService,
DefaultKdfConfigService,
KdfConfigService,
UserAsymmetricKeysRegenerationService,
DefaultUserAsymmetricKeysRegenerationService,
UserAsymmetricKeysRegenerationApiService,
DefaultKeyService,
DefaultUserAsymmetricKeysRegenerationApiService,
DefaultUserAsymmetricKeysRegenerationService,
KdfConfigService,
KeyService,
UserAsymmetricKeysRegenerationApiService,
UserAsymmetricKeysRegenerationService,
} from "@bitwarden/key-management";
import { SafeInjectionToken } from "@bitwarden/ui-common";
import {
DefaultTaskService,
DefaultEndUserNotificationService,
EndUserNotificationService,
NewDeviceVerificationNoticeService,
PasswordRepromptService,
TaskService,
} from "@bitwarden/vault";
import {
VaultExportService,
VaultExportServiceAbstraction,
OrganizationVaultExportService,
OrganizationVaultExportServiceAbstraction,
IndividualVaultExportService,
IndividualVaultExportServiceAbstraction,
OrganizationVaultExportService,
OrganizationVaultExportServiceAbstraction,
VaultExportService,
VaultExportServiceAbstraction,
} from "@bitwarden/vault-export-core";
import { DeviceTrustToastService as DeviceTrustToastServiceAbstraction } from "../auth/services/device-trust-toast.service.abstraction";
@@ -333,24 +333,24 @@ import { AbstractThemingService } from "../platform/services/theming/theming.ser
import { safeProvider, SafeProvider } from "../platform/utils/safe-provider";
import {
CLIENT_TYPE,
DEFAULT_VAULT_TIMEOUT,
ENV_ADDITIONAL_REGIONS,
INTRAPROCESS_MESSAGING_SUBJECT,
LOCALES_DIRECTORY,
LOCKED_CALLBACK,
LOGOUT_CALLBACK,
LOG_MAC_FAILURES,
LOGOUT_CALLBACK,
MEMORY_STORAGE,
OBSERVABLE_DISK_STORAGE,
OBSERVABLE_MEMORY_STORAGE,
REFRESH_ACCESS_TOKEN_ERROR_CALLBACK,
SECURE_STORAGE,
STATE_FACTORY,
SUPPORTS_SECURE_STORAGE,
SYSTEM_LANGUAGE,
SYSTEM_THEME_OBSERVABLE,
WINDOW,
DEFAULT_VAULT_TIMEOUT,
INTRAPROCESS_MESSAGING_SUBJECT,
CLIENT_TYPE,
REFRESH_ACCESS_TOKEN_ERROR_CALLBACK,
ENV_ADDITIONAL_REGIONS,
} from "./injection-tokens";
import { ModalService } from "./modal.service";
@@ -505,6 +505,7 @@ const safeProviders: SafeProvider[] = [
configService: ConfigService,
stateProvider: StateProvider,
accountService: AccountServiceAbstraction,
logService: LogService,
) =>
new CipherService(
keyService,
@@ -520,6 +521,7 @@ const safeProviders: SafeProvider[] = [
configService,
stateProvider,
accountService,
logService,
),
deps: [
KeyService,
@@ -535,6 +537,7 @@ const safeProviders: SafeProvider[] = [
ConfigService,
StateProvider,
AccountServiceAbstraction,
LogService,
],
}),
safeProvider({
@@ -846,6 +849,7 @@ const safeProviders: SafeProvider[] = [
CryptoFunctionServiceAbstraction,
KdfConfigService,
AccountServiceAbstraction,
ApiServiceAbstraction,
],
}),
safeProvider({
@@ -946,7 +950,7 @@ const safeProviders: SafeProvider[] = [
}),
safeProvider({
provide: InternalPolicyService,
useClass: PolicyService,
useClass: DefaultPolicyService,
deps: [StateProvider, OrganizationServiceAbstraction],
}),
safeProvider({
@@ -956,7 +960,7 @@ const safeProviders: SafeProvider[] = [
safeProvider({
provide: PolicyApiServiceAbstraction,
useClass: PolicyApiService,
deps: [InternalPolicyService, ApiServiceAbstraction],
deps: [InternalPolicyService, ApiServiceAbstraction, AccountService],
}),
safeProvider({
provide: InternalMasterPasswordServiceAbstraction,
@@ -1171,9 +1175,7 @@ const safeProviders: SafeProvider[] = [
KdfConfigService,
KeyGenerationServiceAbstraction,
LogService,
MasterPasswordServiceAbstraction,
StateProvider,
StateServiceAbstraction,
],
}),
safeProvider({
@@ -1253,12 +1255,13 @@ const safeProviders: SafeProvider[] = [
I18nServiceAbstraction,
OrganizationApiServiceAbstraction,
SyncService,
ConfigService,
],
}),
safeProvider({
provide: AutofillSettingsServiceAbstraction,
useClass: AutofillSettingsService,
deps: [StateProvider, PolicyServiceAbstraction],
deps: [StateProvider, PolicyServiceAbstraction, AccountService],
}),
safeProvider({
provide: BadgeSettingsServiceAbstraction,
@@ -1468,17 +1471,25 @@ const safeProviders: SafeProvider[] = [
safeProvider({
provide: LoginSuccessHandlerService,
useClass: DefaultLoginSuccessHandlerService,
deps: [SyncService, UserAsymmetricKeysRegenerationService],
deps: [SyncService, UserAsymmetricKeysRegenerationService, LoginEmailService],
}),
safeProvider({
provide: TaskService,
useClass: DefaultTaskService,
deps: [StateProvider, ApiServiceAbstraction, OrganizationServiceAbstraction, ConfigService],
deps: [
StateProvider,
ApiServiceAbstraction,
OrganizationServiceAbstraction,
ConfigService,
AuthServiceAbstraction,
NotificationsService,
MessageListener,
],
}),
safeProvider({
provide: EndUserNotificationService,
useClass: DefaultEndUserNotificationService,
deps: [StateProvider, ApiServiceAbstraction],
deps: [StateProvider, ApiServiceAbstraction, NotificationsService],
}),
safeProvider({
provide: DeviceTrustToastServiceAbstraction,

View File

@@ -155,9 +155,14 @@ export class AddEditComponent implements OnInit, OnDestroy {
}
async ngOnInit() {
this.policyService
.policyAppliesToActiveUser$(PolicyType.DisableSend)
.pipe(takeUntil(this.destroy$))
this.accountService.activeAccount$
.pipe(
getUserId,
switchMap((userId) =>
this.policyService.policyAppliesToUser$(PolicyType.DisableSend, userId),
),
takeUntil(this.destroy$),
)
.subscribe((policyAppliesToActiveUser) => {
this.disableSend = policyAppliesToActiveUser;
if (this.disableSend) {
@@ -168,7 +173,7 @@ export class AddEditComponent implements OnInit, OnDestroy {
this.accountService.activeAccount$
.pipe(
getUserId,
switchMap((userId) => this.policyService.getAll$(PolicyType.SendOptions, userId)),
switchMap((userId) => this.policyService.policiesByType$(PolicyType.SendOptions, userId)),
map((policies) => policies?.some((p) => p.data.disableHideEmail)),
takeUntil(this.destroy$),
)

View File

@@ -9,6 +9,7 @@ import {
from,
switchMap,
takeUntil,
combineLatest,
} from "rxjs";
import { SearchService } from "@bitwarden/common/abstractions/search.service";
@@ -85,18 +86,23 @@ export class SendComponent implements OnInit, OnDestroy {
) {}
async ngOnInit() {
const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
this.policyService
.policyAppliesToActiveUser$(PolicyType.DisableSend)
.pipe(takeUntil(this.destroy$))
.subscribe((policyAppliesToActiveUser) => {
this.disableSend = policyAppliesToActiveUser;
this.accountService.activeAccount$
.pipe(
getUserId,
switchMap((userId) =>
this.policyService.policyAppliesToUser$(PolicyType.DisableSend, userId),
),
takeUntil(this.destroy$),
)
.subscribe((policyAppliesToUser) => {
this.disableSend = policyAppliesToUser;
});
this._searchText$
combineLatest([this._searchText$, this.accountService.activeAccount$.pipe(getUserId)])
.pipe(
switchMap((searchText) => from(this.searchService.isSearchable(userId, searchText))),
switchMap(([searchText, userId]) =>
from(this.searchService.isSearchable(userId, searchText)),
),
takeUntil(this.destroy$),
)
.subscribe((isSearchable) => {

View File

@@ -2,7 +2,7 @@
// @ts-strict-ignore
import { DatePipe } from "@angular/common";
import { Directive, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core";
import { concatMap, firstValueFrom, map, Observable, Subject, takeUntil } from "rxjs";
import { concatMap, firstValueFrom, map, Observable, Subject, switchMap, takeUntil } from "rxjs";
import { CollectionService, CollectionView } from "@bitwarden/admin-console/common";
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
@@ -193,9 +193,12 @@ export class AddEditComponent implements OnInit, OnDestroy {
}
async ngOnInit() {
this.policyService
.policyAppliesToActiveUser$(PolicyType.PersonalOwnership)
this.accountService.activeAccount$
.pipe(
getUserId,
switchMap((userId) =>
this.policyService.policyAppliesToUser$(PolicyType.PersonalOwnership, userId),
),
concatMap(async (policyAppliesToActiveUser) => {
this.personalOwnershipPolicyAppliesToActiveUser = policyAppliesToActiveUser;
await this.init();
@@ -419,10 +422,15 @@ export class AddEditComponent implements OnInit, OnDestroy {
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
const cipher = await this.encryptCipher(activeUserId);
try {
this.formPromise = this.saveCipher(cipher);
await this.formPromise;
this.cipher.id = cipher.id;
const savedCipher = await this.formPromise;
// Reset local cipher from the saved cipher returned from the server
this.cipher = await savedCipher.decrypt(
await this.cipherService.getKeyForCipherKeyDecryption(savedCipher, activeUserId),
);
this.toastService.showToast({
variant: "success",
title: null,

View File

@@ -1,13 +1,22 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Directive, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core";
import { BehaviorSubject, Subject, firstValueFrom, from, switchMap, takeUntil } from "rxjs";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import {
BehaviorSubject,
Subject,
combineLatest,
filter,
from,
of,
switchMap,
takeUntil,
} from "rxjs";
import { SearchService } from "@bitwarden/common/abstractions/search.service";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
@@ -21,17 +30,17 @@ export class VaultItemsComponent implements OnInit, OnDestroy {
loaded = false;
ciphers: CipherView[] = [];
filter: (cipher: CipherView) => boolean = null;
deleted = false;
organization: Organization;
protected searchPending = false;
private userId: UserId;
/** Construct filters as an observable so it can be appended to the cipher stream. */
private _filter$ = new BehaviorSubject<(cipher: CipherView) => boolean | null>(null);
private destroy$ = new Subject<void>();
private searchTimeout: any = null;
private isSearchable: boolean = false;
private _searchText$ = new BehaviorSubject<string>("");
get searchText() {
return this._searchText$.value;
}
@@ -39,18 +48,28 @@ export class VaultItemsComponent implements OnInit, OnDestroy {
this._searchText$.next(value);
}
get filter() {
return this._filter$.value;
}
set filter(value: (cipher: CipherView) => boolean | null) {
this._filter$.next(value);
}
constructor(
protected searchService: SearchService,
protected cipherService: CipherService,
protected accountService: AccountService,
) {}
) {
this.subscribeToCiphers();
}
async ngOnInit() {
this.userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
this._searchText$
combineLatest([getUserId(this.accountService.activeAccount$), this._searchText$])
.pipe(
switchMap((searchText) => from(this.searchService.isSearchable(this.userId, searchText))),
switchMap(([userId, searchText]) =>
from(this.searchService.isSearchable(userId, searchText)),
),
takeUntil(this.destroy$),
)
.subscribe((isSearchable) => {
@@ -80,23 +99,6 @@ export class VaultItemsComponent implements OnInit, OnDestroy {
async applyFilter(filter: (cipher: CipherView) => boolean = null) {
this.filter = filter;
await this.search(null);
}
async search(timeout: number = null, indexedCiphers?: CipherView[]) {
this.searchPending = false;
if (this.searchTimeout != null) {
clearTimeout(this.searchTimeout);
}
if (timeout == null) {
await this.doSearch(indexedCiphers);
return;
}
this.searchPending = true;
this.searchTimeout = setTimeout(async () => {
await this.doSearch(indexedCiphers);
this.searchPending = false;
}, timeout);
}
selectCipher(cipher: CipherView) {
@@ -121,25 +123,44 @@ export class VaultItemsComponent implements OnInit, OnDestroy {
protected deletedFilter: (cipher: CipherView) => boolean = (c) => c.isDeleted === this.deleted;
protected async doSearch(indexedCiphers?: CipherView[], userId?: UserId) {
// Get userId from activeAccount if not provided from parent stream
if (!userId) {
userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
}
/**
* Creates stream of dependencies that results in the list of ciphers to display
* within the vault list.
*
* Note: This previously used promises but race conditions with how the ciphers were
* stored in electron. Using observables is more reliable as fresh values will always
* cascade through the components.
*/
private subscribeToCiphers() {
getUserId(this.accountService.activeAccount$)
.pipe(
switchMap((userId) =>
combineLatest([
this.cipherService.cipherViews$(userId).pipe(filter((ciphers) => ciphers != null)),
this.cipherService.failedToDecryptCiphers$(userId),
this._searchText$,
this._filter$,
of(userId),
]),
),
switchMap(([indexedCiphers, failedCiphers, searchText, filter, userId]) => {
let allCiphers = indexedCiphers ?? [];
const _failedCiphers = failedCiphers ?? [];
indexedCiphers =
indexedCiphers ?? (await firstValueFrom(this.cipherService.cipherViews$(userId)));
allCiphers = [..._failedCiphers, ...allCiphers];
const failedCiphers = await firstValueFrom(this.cipherService.failedToDecryptCiphers$(userId));
if (failedCiphers != null && failedCiphers.length > 0) {
indexedCiphers = [...failedCiphers, ...indexedCiphers];
}
this.ciphers = await this.searchService.searchCiphers(
this.userId,
this.searchText,
[this.filter, this.deletedFilter],
indexedCiphers,
);
return this.searchService.searchCiphers(
userId,
searchText,
[filter, this.deletedFilter],
allCiphers,
);
}),
takeUntilDestroyed(),
)
.subscribe((ciphers) => {
this.ciphers = ciphers;
this.loaded = true;
});
}
}

View File

@@ -11,7 +11,17 @@ import {
OnInit,
Output,
} from "@angular/core";
import { filter, firstValueFrom, map, Observable } from "rxjs";
import {
BehaviorSubject,
combineLatest,
filter,
firstValueFrom,
map,
Observable,
of,
switchMap,
tap,
} from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
@@ -46,11 +56,22 @@ import { DialogService, ToastService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
import { PasswordRepromptService } from "@bitwarden/vault";
const BroadcasterSubscriptionId = "ViewComponent";
const BroadcasterSubscriptionId = "BaseViewComponent";
@Directive()
export class ViewComponent implements OnDestroy, OnInit {
@Input() cipherId: string;
/** Observable of cipherId$ that will update each time the `Input` updates */
private _cipherId$ = new BehaviorSubject<string>(null);
@Input()
set cipherId(value: string) {
this._cipherId$.next(value);
}
get cipherId(): string {
return this._cipherId$.getValue();
}
@Input() collectionId: string;
@Output() onEditCipher = new EventEmitter<CipherView>();
@Output() onCloneCipher = new EventEmitter<CipherView>();
@@ -59,6 +80,7 @@ export class ViewComponent implements OnDestroy, OnInit {
@Output() onRestoredCipher = new EventEmitter<CipherView>();
canDeleteCipher$: Observable<boolean>;
canRestoreCipher$: Observable<boolean>;
cipher: CipherView;
showPassword: boolean;
showPasswordCount: boolean;
@@ -125,13 +147,30 @@ export class ViewComponent implements OnDestroy, OnInit {
switch (message.command) {
case "syncCompleted":
if (message.successfully) {
await this.load();
this.changeDetectorRef.detectChanges();
}
break;
}
});
});
// Set up the subscription to the activeAccount$ and cipherId$ observables
combineLatest([this.accountService.activeAccount$.pipe(getUserId), this._cipherId$])
.pipe(
tap(() => this.cleanUp()),
switchMap(([userId, cipherId]) => {
const cipher$ = this.cipherService.cipherViews$(userId).pipe(
map((ciphers) => ciphers?.find((c) => c.id === cipherId)),
filter((cipher) => !!cipher),
);
return combineLatest([of(userId), cipher$]);
}),
)
.subscribe(([userId, cipher]) => {
this.cipher = cipher;
void this.constructCipherDetails(userId);
});
}
ngOnDestroy() {
@@ -139,76 +178,8 @@ export class ViewComponent implements OnDestroy, OnInit {
this.cleanUp();
}
async load() {
this.cleanUp();
// Grab individual cipher from `cipherViews$` for the most up-to-date information
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
this.cipher = await firstValueFrom(
this.cipherService.cipherViews$(activeUserId).pipe(
map((ciphers) => ciphers?.find((c) => c.id === this.cipherId)),
filter((cipher) => !!cipher),
),
);
this.canAccessPremium = await firstValueFrom(
this.billingAccountProfileStateService.hasPremiumFromAnySource$(activeUserId),
);
this.showPremiumRequiredTotp =
this.cipher.login.totp && !this.canAccessPremium && !this.cipher.organizationUseTotp;
this.canDeleteCipher$ = this.cipherAuthorizationService.canDeleteCipher$(this.cipher, [
this.collectionId as CollectionId,
]);
if (this.cipher.folderId) {
this.folder = await (
await firstValueFrom(this.folderService.folderViews$(activeUserId))
).find((f) => f.id == this.cipher.folderId);
}
const canGenerateTotp =
this.cipher.type === CipherType.Login &&
this.cipher.login.totp &&
(this.cipher.organizationUseTotp || this.canAccessPremium);
this.totpInfo$ = canGenerateTotp
? this.totpService.getCode$(this.cipher.login.totp).pipe(
map((response) => {
const epoch = Math.round(new Date().getTime() / 1000.0);
const mod = epoch % response.period;
// Format code
const totpCodeFormatted =
response.code.length > 4
? `${response.code.slice(0, Math.floor(response.code.length / 2))} ${response.code.slice(Math.floor(response.code.length / 2))}`
: response.code;
return {
totpCode: response.code,
totpCodeFormatted,
totpDash: +(Math.round(((78.6 / response.period) * mod + "e+2") as any) + "e-2"),
totpSec: response.period - mod,
totpLow: response.period - mod <= 7,
} as TotpInfo;
}),
)
: undefined;
if (this.previousCipherId !== this.cipherId) {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.eventCollectionService.collect(EventType.Cipher_ClientViewed, this.cipherId);
}
this.previousCipherId = this.cipherId;
}
async edit() {
if (await this.promptPassword()) {
this.onEditCipher.emit(this.cipher);
return true;
}
return false;
this.onEditCipher.emit(this.cipher);
}
async clone() {
@@ -536,4 +507,61 @@ export class ViewComponent implements OnDestroy, OnInit {
this.showCardCode = false;
this.passwordReprompted = false;
}
/**
* When a cipher is viewed, construct all details for the view that are not directly
* available from the cipher object itself.
*/
private async constructCipherDetails(userId: UserId) {
this.canAccessPremium = await firstValueFrom(
this.billingAccountProfileStateService.hasPremiumFromAnySource$(userId),
);
this.showPremiumRequiredTotp =
this.cipher.login.totp && !this.canAccessPremium && !this.cipher.organizationUseTotp;
this.canDeleteCipher$ = this.cipherAuthorizationService.canDeleteCipher$(this.cipher, [
this.collectionId as CollectionId,
]);
this.canRestoreCipher$ = this.cipherAuthorizationService.canRestoreCipher$(this.cipher);
if (this.cipher.folderId) {
this.folder = await (
await firstValueFrom(this.folderService.folderViews$(userId))
).find((f) => f.id == this.cipher.folderId);
}
const canGenerateTotp =
this.cipher.type === CipherType.Login &&
this.cipher.login.totp &&
(this.cipher.organizationUseTotp || this.canAccessPremium);
this.totpInfo$ = canGenerateTotp
? this.totpService.getCode$(this.cipher.login.totp).pipe(
map((response) => {
const epoch = Math.round(new Date().getTime() / 1000.0);
const mod = epoch % response.period;
// Format code
const totpCodeFormatted =
response.code.length > 4
? `${response.code.slice(0, Math.floor(response.code.length / 2))} ${response.code.slice(Math.floor(response.code.length / 2))}`
: response.code;
return {
totpCode: response.code,
totpCodeFormatted,
totpDash: +(Math.round(((78.6 / response.period) * mod + "e+2") as any) + "e-2"),
totpSec: response.period - mod,
totpLow: response.period - mod <= 7,
} as TotpInfo;
}),
)
: undefined;
if (this.previousCipherId !== this.cipherId) {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.eventCollectionService.collect(EventType.Cipher_ClientViewed, this.cipherId);
}
this.previousCipherId = this.cipherId;
}
}

View File

@@ -112,13 +112,23 @@ export class VaultFilterService implements DeprecatedVaultFilterServiceAbstracti
async checkForSingleOrganizationPolicy(): Promise<boolean> {
return await firstValueFrom(
this.policyService.policyAppliesToActiveUser$(PolicyType.SingleOrg),
this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) =>
this.policyService.policyAppliesToUser$(PolicyType.SingleOrg, userId),
),
),
);
}
async checkForPersonalOwnershipPolicy(): Promise<boolean> {
return await firstValueFrom(
this.policyService.policyAppliesToActiveUser$(PolicyType.PersonalOwnership),
this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) =>
this.policyService.policyAppliesToUser$(PolicyType.PersonalOwnership, userId),
),
),
);
}

View File

@@ -10,14 +10,14 @@
[routerLink]="['/']"
class="tw-w-[128px] tw-block tw-mb-12 [&>*]:tw-align-top"
>
<bit-icon [icon]="logo"></bit-icon>
<bit-icon [icon]="logo" [ariaLabel]="'appLogoLabel' | i18n"></bit-icon>
</a>
<div
class="tw-text-center tw-mb-4 sm:tw-mb-6"
[ngClass]="{ 'tw-max-w-md tw-mx-auto': titleAreaMaxWidth === 'md' }"
>
<div class="tw-mx-auto tw-max-w-24 sm:tw-max-w-28 md:tw-max-w-32">
<div *ngIf="!hideIcon" class="tw-mx-auto tw-max-w-24 sm:tw-max-w-28 md:tw-max-w-32">
<bit-icon [icon]="icon"></bit-icon>
</div>

View File

@@ -39,6 +39,7 @@ export class AnonLayoutComponent implements OnInit, OnChanges {
@Input() showReadonlyHostname: boolean;
@Input() hideLogo: boolean = false;
@Input() hideFooter: boolean = false;
@Input() hideIcon: boolean = false;
/**
* Max width of the title area content

View File

@@ -163,6 +163,22 @@ export const WithCustomIcon: Story = {
}),
};
export const HideIcon: Story = {
render: (args) => ({
props: args,
template:
// Projected content (the <div>) and styling is just a sample and can be replaced with any content/styling.
`
<auth-anon-layout [title]="title" [subtitle]="subtitle" [showReadonlyHostname]="showReadonlyHostname" [hideIcon]="true" >
<div>
<div class="tw-font-bold">Primary Projected Content Area (customizable)</div>
<div>Lorem ipsum dolor sit amet consectetur adipisicing elit. Necessitatibus illum vero, placeat recusandae esse ratione eius minima veniam nemo, quas beatae! Impedit molestiae alias sapiente explicabo. Sapiente corporis ipsa numquam?</div>
</div>
</auth-anon-layout>
`,
}),
};
export const HideLogo: Story = {
render: (args) => ({
props: args,

View File

@@ -1,8 +1,7 @@
import { DIALOG_DATA } from "@angular/cdk/dialog";
import { Component, Inject } from "@angular/core";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { ButtonModule, DialogModule, DialogService } from "@bitwarden/components";
import { DIALOG_DATA, ButtonModule, DialogModule, DialogService } from "@bitwarden/components";
export type FingerprintDialogData = {
fingerprint: string[];

View File

@@ -77,3 +77,6 @@ export * from "./two-factor-auth";
// device verification
export * from "./new-device-verification/new-device-verification.component";
// validators
export * from "./validators/compare-inputs.validator";

View File

@@ -4,14 +4,36 @@
[policy]="masterPasswordPolicyOptions"
></auth-password-callout>
<bit-form-field
*ngIf="
inputPasswordFlow === InputPasswordFlow.ChangePassword ||
inputPasswordFlow === InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation
"
>
<bit-label>{{ "currentMasterPass" | i18n }}</bit-label>
<input
id="input-password-form_current-password"
bitInput
type="password"
formControlName="currentPassword"
/>
<button
type="button"
bitIconButton
bitSuffix
bitPasswordInputToggle
[(toggled)]="showPassword"
></button>
</bit-form-field>
<div class="tw-mb-6">
<bit-form-field>
<bit-label>{{ "masterPassword" | i18n }}</bit-label>
<bit-label>{{ "newMasterPass" | i18n }}</bit-label>
<input
id="input-password-form_password"
id="input-password-form_new-password"
bitInput
type="password"
formControlName="password"
formControlName="newPassword"
/>
<button
type="button"
@@ -30,7 +52,7 @@
<tools-password-strength
[showText]="true"
[email]="email"
[password]="formGroup.controls.password.value"
[password]="formGroup.controls.newPassword.value"
(passwordStrengthScore)="getPasswordStrengthScore($event)"
></tools-password-strength>
</div>
@@ -38,10 +60,10 @@
<bit-form-field>
<bit-label>{{ "confirmMasterPassword" | i18n }}</bit-label>
<input
id="input-password-form_confirmed-password"
id="input-password-form_confirm-new-password"
bitInput
type="password"
formControlName="confirmedPassword"
formControlName="confirmNewPassword"
/>
<button
type="button"
@@ -65,16 +87,40 @@
<bit-label>{{ "checkForBreaches" | i18n }}</bit-label>
</bit-form-control>
<button
type="submit"
bitButton
bitFormButton
buttonType="primary"
[block]="btnBlock"
[loading]="loading"
<bit-form-control
*ngIf="inputPasswordFlow === InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation"
>
{{ buttonText || ("setMasterPassword" | i18n) }}
</button>
<input type="checkbox" bitCheckbox formControlName="rotateUserKey" />
<bit-label>
{{ "rotateAccountEncKey" | i18n }}
<a
href="https://bitwarden.com/help/account-encryption-key/#rotate-your-encryption-key"
target="_blank"
rel="noreferrer"
appA11yTitle="{{ 'impactOfRotatingYourEncryptionKey' | i18n }}"
>
<i class="bwi bwi-question-circle" aria-hidden="true"></i>
</a>
</bit-label>
</bit-form-control>
<div class="tw-flex tw-gap-2" [ngClass]="inlineButtons ? 'tw-flex-row' : 'tw-flex-col'">
<button type="submit" bitButton bitFormButton buttonType="primary" [loading]="loading">
{{ primaryButtonTextStr || ("setMasterPassword" | i18n) }}
</button>
<button
*ngIf="secondaryButtonText"
type="button"
bitButton
bitFormButton
buttonType="secondary"
[loading]="loading"
(click)="onSecondaryButtonClick.emit()"
>
{{ secondaryButtonTextStr }}
</button>
</div>
<bit-error-summary *ngIf="showErrorSummary" [formGroup]="formGroup"></bit-error-summary>
</form>

View File

@@ -1,7 +1,5 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, EventEmitter, Input, Output } from "@angular/core";
import { ReactiveFormsModule, FormBuilder, Validators } from "@angular/forms";
import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core";
import { ReactiveFormsModule, FormBuilder, Validators, FormGroup } from "@angular/forms";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import {
@@ -23,19 +21,41 @@ import {
IconButtonModule,
InputModule,
ToastService,
Translation,
} from "@bitwarden/components";
import { DEFAULT_KDF_CONFIG, KeyService } from "@bitwarden/key-management";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { InputsFieldMatch } from "../../../../angular/src/auth/validators/inputs-field-match.validator";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { SharedModule } from "../../../../components/src/shared";
import { PasswordCalloutComponent } from "../password-callout/password-callout.component";
import { compareInputs, ValidationGoal } from "../validators/compare-inputs.validator";
import { PasswordInputResult } from "./password-input-result";
/**
* Determines which form input elements will be displayed in the UI.
*/
export enum InputPasswordFlow {
/**
* - Input: New password
* - Input: Confirm new password
* - Input: Hint
* - Checkbox: Check for breaches
*/
SetInitialPassword,
/**
* Everything above, plus:
* - Input: Current password (as the first element in the UI)
*/
ChangePassword,
/**
* Everything above, plus:
* - Checkbox: Rotate account encryption key (as the last element in the UI)
*/
ChangePasswordWithOptionalUserKeyRotation,
}
@Component({
standalone: true,
selector: "auth-input-password",
@@ -54,44 +74,52 @@ import { PasswordInputResult } from "./password-input-result";
JslibModule,
],
})
export class InputPasswordComponent {
export class InputPasswordComponent implements OnInit {
@Output() onPasswordFormSubmit = new EventEmitter<PasswordInputResult>();
@Output() onSecondaryButtonClick = new EventEmitter<void>();
@Input({ required: true }) email: string;
@Input() buttonText: string;
@Input({ required: true }) inputPasswordFlow!: InputPasswordFlow;
@Input({ required: true }) email!: string;
@Input() loading = false;
@Input() masterPasswordPolicyOptions: MasterPasswordPolicyOptions | null = null;
@Input() loading: boolean = false;
@Input() btnBlock: boolean = true;
@Input() inlineButtons = false;
@Input() primaryButtonText?: Translation;
protected primaryButtonTextStr: string = "";
@Input() secondaryButtonText?: Translation;
protected secondaryButtonTextStr: string = "";
protected InputPasswordFlow = InputPasswordFlow;
private minHintLength = 0;
protected maxHintLength = 50;
protected minPasswordLength = Utils.minimumPasswordLength;
protected minPasswordMsg = "";
protected passwordStrengthScore: PasswordStrengthScore;
protected passwordStrengthScore: PasswordStrengthScore = 0;
protected showErrorSummary = false;
protected showPassword = false;
protected formGroup = this.formBuilder.group(
protected formGroup = this.formBuilder.nonNullable.group(
{
password: ["", [Validators.required, Validators.minLength(this.minPasswordLength)]],
confirmedPassword: ["", Validators.required],
newPassword: ["", [Validators.required, Validators.minLength(this.minPasswordLength)]],
confirmNewPassword: ["", Validators.required],
hint: [
"", // must be string (not null) because we check length in validation
[Validators.minLength(this.minHintLength), Validators.maxLength(this.maxHintLength)],
],
checkForBreaches: true,
checkForBreaches: [true],
},
{
validators: [
InputsFieldMatch.compareInputs(
"match",
"password",
"confirmedPassword",
compareInputs(
ValidationGoal.InputsShouldMatch,
"newPassword",
"confirmNewPassword",
this.i18nService.t("masterPassDoesntMatch"),
),
InputsFieldMatch.compareInputs(
"doNotMatch",
"password",
compareInputs(
ValidationGoal.InputsShouldNotMatch,
"newPassword",
"hint",
this.i18nService.t("hintEqualsPassword"),
),
@@ -109,6 +137,41 @@ export class InputPasswordComponent {
private toastService: ToastService,
) {}
ngOnInit(): void {
if (
this.inputPasswordFlow === InputPasswordFlow.ChangePassword ||
this.inputPasswordFlow === InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation
) {
// https://github.com/angular/angular/issues/48794
(this.formGroup as FormGroup<any>).addControl(
"currentPassword",
this.formBuilder.control("", Validators.required),
);
}
if (this.inputPasswordFlow === InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation) {
// https://github.com/angular/angular/issues/48794
(this.formGroup as FormGroup<any>).addControl(
"rotateUserKey",
this.formBuilder.control(false),
);
}
if (this.primaryButtonText) {
this.primaryButtonTextStr = this.i18nService.t(
this.primaryButtonText.key,
...(this.primaryButtonText?.placeholders ?? []),
);
}
if (this.secondaryButtonText) {
this.secondaryButtonTextStr = this.i18nService.t(
this.secondaryButtonText.key,
...(this.secondaryButtonText?.placeholders ?? []),
);
}
}
get minPasswordLengthMsg() {
if (
this.masterPasswordPolicyOptions != null &&
@@ -132,10 +195,10 @@ export class InputPasswordComponent {
return;
}
const password = this.formGroup.controls.password.value;
const newPassword = this.formGroup.controls.newPassword.value;
const passwordEvaluatedSuccessfully = await this.evaluatePassword(
password,
const passwordEvaluatedSuccessfully = await this.evaluateNewPassword(
newPassword,
this.passwordStrengthScore,
this.formGroup.controls.checkForBreaches.value,
);
@@ -152,38 +215,55 @@ export class InputPasswordComponent {
}
const masterKey = await this.keyService.makeMasterKey(
password,
newPassword,
this.email.trim().toLowerCase(),
kdfConfig,
);
const masterKeyHash = await this.keyService.hashMasterKey(password, masterKey);
const serverMasterKeyHash = await this.keyService.hashMasterKey(
newPassword,
masterKey,
HashPurpose.ServerAuthorization,
);
const localMasterKeyHash = await this.keyService.hashMasterKey(
password,
newPassword,
masterKey,
HashPurpose.LocalAuthorization,
);
this.onPasswordFormSubmit.emit({
masterKey,
masterKeyHash,
localMasterKeyHash,
kdfConfig,
const passwordInputResult: PasswordInputResult = {
newPassword,
hint: this.formGroup.controls.hint.value,
password,
});
kdfConfig,
masterKey,
serverMasterKeyHash,
localMasterKeyHash,
};
if (
this.inputPasswordFlow === InputPasswordFlow.ChangePassword ||
this.inputPasswordFlow === InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation
) {
passwordInputResult.currentPassword = this.formGroup.get("currentPassword")?.value;
}
if (this.inputPasswordFlow === InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation) {
passwordInputResult.rotateUserKey = this.formGroup.get("rotateUserKey")?.value;
}
this.onPasswordFormSubmit.emit(passwordInputResult);
};
// Returns true if the password passes all checks, false otherwise
private async evaluatePassword(
password: string,
private async evaluateNewPassword(
newPassword: string,
passwordStrengthScore: PasswordStrengthScore,
checkForBreaches: boolean,
) {
// Check if the password is breached, weak, or both
const passwordIsBreached =
checkForBreaches && (await this.auditService.passwordLeaked(password));
checkForBreaches && (await this.auditService.passwordLeaked(newPassword));
const passwordWeak = passwordStrengthScore != null && passwordStrengthScore < 3;
@@ -224,7 +304,7 @@ export class InputPasswordComponent {
this.masterPasswordPolicyOptions != null &&
!this.policyService.evaluateMasterPassword(
this.passwordStrengthScore,
password,
newPassword,
this.masterPasswordPolicyOptions,
)
) {

View File

@@ -6,9 +6,9 @@ import * as stories from "./input-password.stories.ts";
# InputPassword Component
The `InputPasswordComponent` allows a user to enter a master password and hint. On submission it
creates a master key, master key hash, and emits those values to the parent (along with the hint and
default kdfConfig).
The `InputPasswordComponent` allows a user to enter master password related credentials. On
submission it creates a master key, master key hash, and emits those values to the parent (along
with the other values found in `PasswordInputResult`).
The component is intended for re-use in different scenarios throughout the application. Therefore it
is mostly presentational and simply emits values rather than acting on them itself. It is the job of
@@ -18,26 +18,66 @@ the parent component to act on those values as needed.
## `@Input()`'s
- `email` (**required**) - the parent component must provide an email so that the
`InputPasswordComponent` can create a master key.
- `buttonText` (optional) - an `i18n` translated string that can be used as button text (default
text is "Set master password").
- `masterPasswordPolicyOptions` (optional) - used to display and enforce master password policy
requirements.
**Required**
- `inputPasswordFlow` - the parent component must provide the correct flow, which is used to
determine which form input elements will be displayed in the UI.
- `email` - the parent component must provide an email so that the `InputPasswordComponent` can
create a master key.
**Optional**
- `loading` - a boolean used to indicate that the parent component is performing some
long-running/async operation and that the form should be disabled until the operation is complete.
The primary button will also show a spinner if `loading` true.
- `masterPasswordPolicyOptions` - used to display and enforce master password policy requirements.
- `inlineButtons` - takes a boolean that determines if the button(s) should be displayed inline (as
opposed to full-width)
- `primaryButtonText` - takes a `Translation` object that can be used as button text
- `secondaryButtonText` - takes a `Translation` object that can be used as button text
## `@Output()`'s
- `onPasswordFormSubmit` - on form submit, emits a `PasswordInputResult` object
- `onSecondaryButtonClick` - on click, emits a notice that the secondary button has been clicked.
The parent component can listen for this event and take some custom action as needed (go back,
cancel, logout, etc.)
<br />
## Form Input Fields
The `InputPasswordComponent` allows a user to enter:
The `InputPasswordComponent` can handle up to 6 different form input fields, depending on the
`InputPasswordFlow` provided by the parent component.
1. Master password
2. Master password confirmation
3. Hint (optional)
4. Chooses whether to check for password breaches (checkbox)
**InputPasswordFlow.SetInitialPassword**
Validation ensures that the master password and confirmed master password are the same, and that the
master password and hint values are not the same.
- Input: New password
- Input: Confirm new password
- Input: Hint
- Checkbox: Check for breaches
**InputPasswordFlow.ChangePassword**
Includes everything above, plus:
- Input: Current password (as the first element in the UI)
**InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation**
Includes everything above, plus:
- Checkbox: Rotate account encryption key (as the last element in the UI)
<br />
## Validation
Validation ensures that:
- The current password and new password are NOT the same
- The new password and confirmed new password are the same
- The new password and password hint are NOT the same
<br />
@@ -57,19 +97,23 @@ When the form is submitted, the `InputPasswordComponent` does the following in o
```typescript
export interface PasswordInputResult {
masterKey: MasterKey;
masterKeyHash: string;
kdfConfig: PBKDF2KdfConfig;
newPassword: string;
hint: string;
kdfConfig: PBKDF2KdfConfig;
masterKey: MasterKey;
serverMasterKeyHash: string;
localMasterKeyHash: string;
currentPassword?: string; // included if the flow is ChangePassword or ChangePasswordWithOptionalUserKeyRotation
rotateUserKey?: boolean; // included if the flow is ChangePasswordWithOptionalUserKeyRotation
}
```
# Default Example
# Example - InputPasswordFlow.SetInitialPassword
<Story of={stories.Default} />
<Story of={stories.SetInitialPassword} />
<br />
# With Policy Requrements
# Example - With Policy Requrements
<Story of={stories.WithPolicy} />
<Story of={stories.WithPolicies} />

View File

@@ -1,5 +1,3 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { importProvidersFrom } from "@angular/core";
import { BrowserAnimationsModule } from "@angular/platform-browser/animations";
import { action } from "@storybook/addon-actions";
@@ -18,7 +16,7 @@ import { KeyService } from "@bitwarden/key-management";
// eslint-disable-next-line import/no-restricted-paths, no-restricted-imports
import { PreloadedEnglishI18nModule } from "../../../../../apps/web/src/app/core/tests";
import { InputPasswordComponent } from "./input-password.component";
import { InputPasswordComponent, InputPasswordFlow } from "./input-password.component";
export default {
title: "Auth/Input Password",
@@ -62,7 +60,7 @@ export default {
provide: PasswordStrengthServiceAbstraction,
useValue: {
getPasswordStrength: (password) => {
let score = 0;
let score: number | null = null;
if (password.length === 0) {
score = null;
} else if (password.length <= 4) {
@@ -88,6 +86,12 @@ export default {
}),
],
args: {
InputPasswordFlow: {
SetInitialPassword: InputPasswordFlow.SetInitialPassword,
ChangePassword: InputPasswordFlow.ChangePassword,
ChangePasswordWithOptionalUserKeyRotation:
InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation,
},
masterPasswordPolicyOptions: {
minComplexity: 4,
minLength: 14,
@@ -96,25 +100,77 @@ export default {
requireNumbers: true,
requireSpecial: true,
} as MasterPasswordPolicyOptions,
argTypes: {
onSecondaryButtonClick: { action: "onSecondaryButtonClick" },
},
},
} as Meta;
type Story = StoryObj<InputPasswordComponent>;
export const Default: Story = {
export const SetInitialPassword: Story = {
render: (args) => ({
props: args,
template: `
<auth-input-password></auth-input-password>
<auth-input-password [inputPasswordFlow]="InputPasswordFlow.SetInitialPassword"></auth-input-password>
`,
}),
};
export const WithPolicy: Story = {
export const ChangePassword: Story = {
render: (args) => ({
props: args,
template: `
<auth-input-password [masterPasswordPolicyOptions]="masterPasswordPolicyOptions"></auth-input-password>
<auth-input-password [inputPasswordFlow]="InputPasswordFlow.ChangePassword"></auth-input-password>
`,
}),
};
export const ChangePasswordWithOptionalUserKeyRotation: Story = {
render: (args) => ({
props: args,
template: `
<auth-input-password
[inputPasswordFlow]="InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation"
></auth-input-password>
`,
}),
};
export const WithPolicies: Story = {
render: (args) => ({
props: args,
template: `
<auth-input-password
[inputPasswordFlow]="InputPasswordFlow.SetInitialPassword"
[masterPasswordPolicyOptions]="masterPasswordPolicyOptions"
></auth-input-password>
`,
}),
};
export const SecondaryButton: Story = {
render: (args) => ({
props: args,
template: `
<auth-input-password
[inputPasswordFlow]="InputPasswordFlow.SetInitialPassword"
[secondaryButtonText]="{ key: 'cancel' }"
(onSecondaryButtonClick)="onSecondaryButtonClick()"
></auth-input-password>
`,
}),
};
export const SecondaryButtonWithPlaceHolderText: Story = {
render: (args) => ({
props: args,
template: `
<auth-input-password
[inputPasswordFlow]="InputPasswordFlow.SetInitialPassword"
[secondaryButtonText]="{ key: 'backTo', placeholders: ['homepage'] }"
(onSecondaryButtonClick)="onSecondaryButtonClick()"
></auth-input-password>
`,
}),
};
@@ -123,7 +179,24 @@ export const InlineButton: Story = {
render: (args) => ({
props: args,
template: `
<auth-input-password [btnBlock]="false" [masterPasswordPolicyOptions]="masterPasswordPolicyOptions"></auth-input-password>
<auth-input-password
[inputPasswordFlow]="InputPasswordFlow.SetInitialPassword"
[inlineButtons]="true"
></auth-input-password>
`,
}),
};
export const InlineButtons: Story = {
render: (args) => ({
props: args,
template: `
<auth-input-password
[inputPasswordFlow]="InputPasswordFlow.SetInitialPassword"
[secondaryButtonText]="{ key: 'cancel' }"
[inlineButtons]="true"
(onSecondaryButtonClick)="onSecondaryButtonClick()"
></auth-input-password>
`,
}),
};

View File

@@ -2,10 +2,12 @@ import { MasterKey } from "@bitwarden/common/types/key";
import { PBKDF2KdfConfig } from "@bitwarden/key-management";
export interface PasswordInputResult {
masterKey: MasterKey;
masterKeyHash: string;
localMasterKeyHash: string;
kdfConfig: PBKDF2KdfConfig;
newPassword: string;
hint: string;
password: string;
kdfConfig: PBKDF2KdfConfig;
masterKey: MasterKey;
serverMasterKeyHash: string;
localMasterKeyHash: string;
currentPassword?: string;
rotateUserKey?: boolean;
}

View File

@@ -1,4 +1,3 @@
import { DialogRef, DIALOG_DATA } from "@angular/cdk/dialog";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { mock, MockProxy } from "jest-mock-extended";
import { of } from "rxjs";
@@ -15,7 +14,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { UserId } from "@bitwarden/common/types/guid";
import { ToastService } from "@bitwarden/components";
import { DialogRef, DIALOG_DATA, ToastService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
import { LoginApprovalComponent } from "./login-approval.component";

View File

@@ -1,6 +1,5 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { DIALOG_DATA, DialogRef } from "@angular/cdk/dialog";
import { CommonModule } from "@angular/common";
import { Component, OnInit, OnDestroy, Inject } from "@angular/core";
import { Subject, firstValueFrom, map } from "rxjs";
@@ -19,6 +18,8 @@ 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 {
DIALOG_DATA,
DialogRef,
AsyncActionsModule,
ButtonModule,
DialogModule,

View File

@@ -0,0 +1,203 @@
# Authentication Flows Documentation
## Standard Auth Request Flows
### Flow 1: Unauthed user requests approval from device; Approving device has a masterKey in memory
1. Unauthed user clicks "Login with device"
2. Navigates to /login-with-device which creates a StandardAuthRequest
3. Receives approval from a device with authRequestPublicKey(masterKey)
4. Decrypts masterKey
5. Decrypts userKey
6. Proceeds to vault
### Flow 2: Unauthed user requests approval from device; Approving device does NOT have a masterKey in memory
1. Unauthed user clicks "Login with device"
2. Navigates to /login-with-device which creates a StandardAuthRequest
3. Receives approval from a device with authRequestPublicKey(userKey)
4. Decrypts userKey
5. Proceeds to vault
**Note:** This flow is an uncommon scenario and relates to TDE off-boarding. The following describes how a user could
get into this flow:
1. An SSO TD user logs into a device via an Admin auth request approval, therefore this device does NOT have a masterKey
in memory
2. The org admin:
- Changes the member decryption options from "Trusted devices" to "Master password" AND
- Turns off the "Require single sign-on authentication" policy
3. On another device, the user clicks "Login with device", which they can do because the org no longer requires SSO
4. The user approves from the device they had previously logged into with SSO TD, which does NOT have a masterKey in
memory
### Flow 3: Authed SSO TD user requests approval from device; Approving device has a masterKey in memory
1. SSO TD user authenticates via SSO
2. Navigates to /login-initiated
3. Clicks "Approve from your other device"
4. Navigates to /login-with-device which creates a StandardAuthRequest
5. Receives approval from device with authRequestPublicKey(masterKey)
6. Decrypts masterKey
7. Decrypts userKey
8. Establishes trust (if required)
9. Proceeds to vault
### Flow 4: Authed SSO TD user requests approval from device; Approving device does NOT have a masterKey in memory
1. SSO TD user authenticates via SSO
2. Navigates to /login-initiated
3. Clicks "Approve from your other device"
4. Navigates to /login-with-device which creates a StandardAuthRequest
5. Receives approval from device with authRequestPublicKey(userKey)
6. Decrypts userKey
7. Establishes trust (if required)
8. Proceeds to vault
## Admin Auth Request Flow
### Flow: Authed SSO TD user requests admin approval
1. SSO TD user authenticates via SSO
2. Navigates to /login-initiated
3. Clicks "Request admin approval"
4. Navigates to /admin-approval-requested which creates an AdminAuthRequest
5. Receives approval from device with authRequestPublicKey(userKey)
6. Decrypts userKey
7. Establishes trust (if required)
8. Proceeds to vault
**Note:** TDE users are required to be enrolled in admin account recovery, which gives the admin access to the user's
userKey. This is how admins are able to send over the authRequestPublicKey(userKey) to the user to allow them to unlock.
## Summary Table
| Flow | Auth Status | Clicks Button [active route] | Navigates to | Approving device has masterKey in memory\* |
| --------------- | ----------- | --------------------------------------------------- | ------------------------- | ------------------------------------------------- |
| Standard Flow 1 | unauthed | "Login with device" [/login] | /login-with-device | yes |
| Standard Flow 2 | unauthed | "Login with device" [/login] | /login-with-device | no |
| Standard Flow 3 | authed | "Approve from your other device" [/login-initiated] | /login-with-device | yes |
| Standard Flow 4 | authed | "Approve from your other device" [/login-initiated] | /login-with-device | no |
| Admin Flow | authed | "Request admin approval" [/login-initiated] | /admin-approval-requested | NA - admin requests always send encrypted userKey |
**Note:** The phrase "in memory" here is important. It is possible for a user to have a master password for their
account, but not have a masterKey IN MEMORY for a specific device. For example, if a user registers an account with a
master password, then joins an SSO TD org, then logs in to a device via SSO and admin auth request, they are now logged
into that device but that device does not have masterKey IN MEMORY.
## State Management
### View Cache
The component uses `LoginViaAuthRequestCacheService` to manage persistent state across extension close and reopen.
This cache stores:
- Auth Request ID
- Private Key
- Access Code
The cache is used to:
1. Preserve authentication state during extension close
2. Allow resumption of pending auth requests
3. Enable processing of approved requests after extension close and reopen.
### Component State Variables
Key state variables maintained during the authentication process:
#### Authentication Keys
```
private authRequestKeyPair: {
publicKey: Uint8Array | undefined;
privateKey: Uint8Array | undefined;
} | undefined
```
- Stores the RSA key pair used for secure communication
- Generated during auth request initialization
- Required for decrypting approved auth responses
#### Access Code
```
private accessCode: string | undefined
```
- 25-character generated password
- Used for retrieving auth responses when user is not authenticated
- Required for standard auth flows
#### Authentication Status
```
private authStatus: AuthenticationStatus | undefined
```
- Tracks whether user is authenticated via SSO
- Determines available flows and API endpoints
- Affects navigation paths (`/login` vs `/login-initiated`)
#### Flow Control
```
protected flow = Flow.StandardAuthRequest
```
- Determines current authentication flow (Standard vs Admin)
- Affects UI rendering and request handling
- Set based on route and authentication state
### State Flow Examples
#### Standard Auth Request Cache Flow
1. User initiates login with device
2. Component generates auth request and keys
3. Cache service stores:
```
cacheLoginView(
authRequestResponse.id,
authRequestKeyPair.privateKey,
accessCode
)
```
4. On page refresh/revisit:
- Component retrieves cached view
- Reestablishes connection using cached credentials
- Continues monitoring for approval
#### Admin Auth Request State Flow
1. User requests admin approval
2. Component stores admin request in `AuthRequestService`:
```
setAdminAuthRequest(
new AdminAuthRequestStorable({
id: authRequestResponse.id,
privateKey: authRequestKeyPair.privateKey
}),
userId
)
```
3. On subsequent visits:
- Component checks for existing admin requests
- Either resumes monitoring or starts new request
- Clears state after successful approval
### State Cleanup
State cleanup occurs in several scenarios:
- Component destruction (`ngOnDestroy`)
- Successful authentication
- Request denial or timeout
- Manual navigation away
Key cleanup actions:
1. Hub connection termination
2. Cache clearance
3. Admin request state removal
4. Key pair disposal

View File

@@ -1,57 +1,65 @@
<div class="tw-text-center">
<ng-container *ngIf="flow === Flow.StandardAuthRequest">
<p *ngIf="clientType !== ClientType.Web">
{{ "notificationSentDevicePart1" | i18n }}
<a
bitLink
linkType="primary"
class="tw-cursor-pointer"
[href]="deviceManagementUrl"
target="_blank"
rel="noreferrer"
>{{ "notificationSentDeviceAnchor" | i18n }}</a
>. {{ "notificationSentDevicePart2" | i18n }}
</p>
<p *ngIf="clientType === ClientType.Web">
{{ "notificationSentDeviceComplete" | i18n }}
</p>
<ng-container *ngIf="loading">
<div class="tw-flex tw-items-center tw-justify-center">
<i class="bwi bwi-spinner bwi-spin bwi-3x" aria-hidden="true"></i>
</div>
</ng-container>
<div class="tw-font-semibold">{{ "fingerprintPhraseHeader" | i18n }}</div>
<code class="tw-text-code">{{ fingerprintPhrase }}</code>
<ng-container *ngIf="!loading">
<div class="tw-text-center">
<ng-container *ngIf="flow === Flow.StandardAuthRequest">
<p *ngIf="clientType !== ClientType.Web">
{{ "notificationSentDevicePart1" | i18n }}
<a
bitLink
linkType="primary"
class="tw-cursor-pointer"
[href]="deviceManagementUrl"
target="_blank"
rel="noreferrer"
>{{ "notificationSentDeviceAnchor" | i18n }}</a
>. {{ "notificationSentDevicePart2" | i18n }}
</p>
<p *ngIf="clientType === ClientType.Web">
{{ "notificationSentDeviceComplete" | i18n }}
</p>
<button
*ngIf="showResendNotification"
type="button"
bitButton
block
buttonType="secondary"
class="tw-mt-4"
(click)="startStandardAuthRequestLogin(true)"
>
{{ "resendNotification" | i18n }}
</button>
<div class="tw-font-semibold">{{ "fingerprintPhraseHeader" | i18n }}</div>
<code class="tw-text-code">{{ fingerprintPhrase }}</code>
<div *ngIf="clientType !== ClientType.Browser" class="tw-mt-4">
<span>{{ "needAnotherOptionV1" | i18n }}</span
>&nbsp;
<a [routerLink]="backToRoute" bitLink linkType="primary">{{
"viewAllLogInOptions" | i18n
}}</a>
</div>
</ng-container>
<button
*ngIf="showResendNotification"
type="button"
bitButton
block
buttonType="secondary"
class="tw-mt-4"
(click)="handleNewStandardAuthRequestLogin()"
>
{{ "resendNotification" | i18n }}
</button>
<ng-container *ngIf="flow === Flow.AdminAuthRequest">
<p>{{ "youWillBeNotifiedOnceTheRequestIsApproved" | i18n }}</p>
<div *ngIf="clientType !== ClientType.Browser" class="tw-mt-4">
<span>{{ "needAnotherOptionV1" | i18n }}</span
>&nbsp;
<a [routerLink]="backToRoute" bitLink linkType="primary">{{
"viewAllLogInOptions" | i18n
}}</a>
</div>
</ng-container>
<div class="tw-font-semibold">{{ "fingerprintPhraseHeader" | i18n }}</div>
<code class="tw-text-code">{{ fingerprintPhrase }}</code>
<ng-container *ngIf="flow === Flow.AdminAuthRequest">
<p>{{ "youWillBeNotifiedOnceTheRequestIsApproved" | i18n }}</p>
<div class="tw-mt-4">
<span>{{ "troubleLoggingIn" | i18n }}</span
>&nbsp;
<a [routerLink]="backToRoute" bitLink linkType="primary">{{
"viewAllLogInOptions" | i18n
}}</a>
</div>
</ng-container>
</div>
<div class="tw-font-semibold">{{ "fingerprintPhraseHeader" | i18n }}</div>
<code class="tw-text-code">{{ fingerprintPhrase }}</code>
<div class="tw-mt-4">
<span>{{ "troubleLoggingIn" | i18n }}</span
>&nbsp;
<a [routerLink]="backToRoute" bitLink linkType="primary">{{
"viewAllLogInOptions" | i18n
}}</a>
</div>
</ng-container>
</div>
</ng-container>

View File

@@ -25,11 +25,11 @@ import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth
import { LoginViaAuthRequestView } from "@bitwarden/common/auth/models/view/login-via-auth-request.view";
import { ClientType, HttpStatusCode } from "@bitwarden/common/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
@@ -62,12 +62,13 @@ const matchOptions: IsActiveMatchOptions = {
providers: [{ provide: LoginViaAuthRequestCacheService }],
})
export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
private authRequest: AuthRequest | undefined = undefined;
private authRequestKeyPair:
| { publicKey: Uint8Array | undefined; privateKey: Uint8Array | undefined }
| undefined = undefined;
private accessCode: string | undefined = undefined;
private authStatus: AuthenticationStatus | undefined = undefined;
private showResendNotificationTimeoutSeconds = 12;
protected loading = true;
protected backToRoute = "/login";
protected clientType: ClientType;
@@ -110,13 +111,14 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
this.authRequestService.authRequestPushNotification$
.pipe(takeUntilDestroyed())
.subscribe((requestId) => {
this.verifyAndHandleApprovedAuthReq(requestId).catch((e: Error) => {
this.loading = true;
this.handleExistingAuthRequestLogin(requestId).catch((e: Error) => {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("error"),
message: e.message,
});
this.loading = false;
this.logService.error("Failed to use approved auth request: " + e.message);
});
});
@@ -149,24 +151,12 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
} else {
await this.initStandardAuthRequestFlow();
}
this.loading = false;
}
private async initAdminAuthRequestFlow(): Promise<void> {
this.flow = Flow.AdminAuthRequest;
// Get email from state for admin auth requests because it is available and also
// prevents it from being lost on refresh as the loginEmailService email does not persist.
this.email = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.email)),
);
if (!this.email) {
await this.handleMissingEmail();
return;
}
// We only allow a single admin approval request to be active at a time
// so we must check state to see if we have an existing one or not
const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
if (!userId) {
this.logService.error(
@@ -175,12 +165,13 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
return;
}
const existingAdminAuthRequest = await this.authRequestService.getAdminAuthRequest(userId);
// [Admin Request Flow State Management] Check cached auth request
const existingAdminAuthRequest = await this.reloadCachedAdminAuthRequest(userId);
if (existingAdminAuthRequest) {
await this.handleExistingAdminAuthRequest(existingAdminAuthRequest, userId);
await this.handleExistingAdminAuthRequestLogin(existingAdminAuthRequest, userId);
} else {
await this.startAdminAuthRequestLogin();
await this.handleNewAdminAuthRequestLogin();
}
}
@@ -194,7 +185,24 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
return;
}
await this.startStandardAuthRequestLogin();
// [Standard Flow State Management] Check cached auth request
const cachedAuthRequest: LoginViaAuthRequestView | null =
this.loginViaAuthRequestCacheService.getCachedLoginViaAuthRequestView();
if (cachedAuthRequest) {
this.logService.info("Found cached auth request.");
if (!cachedAuthRequest.id) {
this.logService.error(
"No id on the cached auth request when in the standard auth request flow.",
);
return;
}
await this.reloadCachedStandardAuthRequest(cachedAuthRequest);
await this.handleExistingAuthRequestLogin(cachedAuthRequest.id);
} else {
await this.handleNewStandardAuthRequestLogin();
}
}
private async handleMissingEmail(): Promise<void> {
@@ -212,11 +220,17 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
this.loginViaAuthRequestCacheService.clearCacheLoginView();
}
private async startAdminAuthRequestLogin(): Promise<void> {
private async handleNewAdminAuthRequestLogin(): Promise<void> {
try {
await this.buildAuthRequest(AuthRequestType.AdminApproval);
if (!this.email) {
this.logService.error("No email when starting admin auth request login.");
return;
}
if (!this.authRequest) {
// At this point we know there is no
const authRequest = await this.buildAuthRequest(this.email, AuthRequestType.AdminApproval);
if (!authRequest) {
this.logService.error("Auth request failed to build.");
return;
}
@@ -226,9 +240,9 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
return;
}
const authRequestResponse = await this.authRequestApiService.postAdminAuthRequest(
this.authRequest as AuthRequest,
);
const authRequestResponse =
await this.authRequestApiService.postAdminAuthRequest(authRequest);
const adminAuthReqStorable = new AdminAuthRequestStorable({
id: authRequestResponse.id,
privateKey: this.authRequestKeyPair.privateKey,
@@ -253,104 +267,154 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
}
}
protected async startStandardAuthRequestLogin(
clearCachedRequest: boolean = false,
/**
* We only allow a single admin approval request to be active at a time
* so we can check to see if it's stored in state with the state service
* provider.
* @param userId
* @protected
*/
protected async reloadCachedAdminAuthRequest(
userId: UserId,
): Promise<AdminAuthRequestStorable | null> {
// Get email from state for admin auth requests because it is available and also
// prevents it from being lost on refresh as the loginEmailService email does not persist.
this.email = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.email)),
);
if (!this.email) {
await this.handleMissingEmail();
return null;
}
return await this.authRequestService.getAdminAuthRequest(userId);
}
/**
* Restores a cached authentication request into the component's state.
*
* This function checks for the presence of a cached authentication request and,
* if available, updates the component's state with the necessary details to
* continue processing the request. It ensures that the user's email and the
* private key from the cached request are available.
*
* The private key is converted from Base64 to an ArrayBuffer, and a fingerprint
* phrase is derived to verify the request's integrity. The function then sets
* the authentication request key pair in the component's state, preparing it
* to handle any responses or approvals.
*
* @param cachedAuthRequest The request to load into the component state
* @returns Promise to await for completion
*/
protected async reloadCachedStandardAuthRequest(
cachedAuthRequest: LoginViaAuthRequestView,
): Promise<void> {
if (cachedAuthRequest) {
if (!this.email) {
this.logService.error(
"Email not defined when trying to reload cached standard auth request.",
);
return;
}
if (!cachedAuthRequest.privateKey) {
this.logService.error(
"No private key on the cached auth request when trying to reload cached standard auth request.",
);
return;
}
if (!cachedAuthRequest.accessCode) {
this.logService.error(
"No access code on the cached auth request when trying to reload cached standard auth request.",
);
return;
}
const privateKey = Utils.fromB64ToArray(cachedAuthRequest.privateKey);
// Re-derive the user's fingerprint phrase
// It is important to not use the server's public key here as it could have been compromised via MITM
const derivedPublicKeyArrayBuffer =
await this.cryptoFunctionService.rsaExtractPublicKey(privateKey);
this.fingerprintPhrase = await this.authRequestService.getFingerprintPhrase(
this.email,
derivedPublicKeyArrayBuffer,
);
// We don't need the public key for handling the authentication request because
// the handleExistingAuthRequestLogin function will receive the public key back
// from the looked up auth request, and all we need is to make sure that
// we can use the cached private key that is associated with it.
this.authRequestKeyPair = {
privateKey: privateKey,
publicKey: undefined,
};
this.accessCode = cachedAuthRequest.accessCode;
}
}
protected async handleNewStandardAuthRequestLogin(): Promise<void> {
this.showResendNotification = false;
if (await this.configService.getFeatureFlag(FeatureFlag.PM9112_DeviceApprovalPersistence)) {
// Used for manually refreshing the auth request when clicking the resend auth request
// on the ui.
if (clearCachedRequest) {
this.loginViaAuthRequestCacheService.clearCacheLoginView();
try {
if (!this.email) {
this.logService.error("Email not defined when starting standard auth request login.");
return;
}
try {
const loginAuthRequestView: LoginViaAuthRequestView | null =
this.loginViaAuthRequestCacheService.getCachedLoginViaAuthRequestView();
const authRequest = await this.buildAuthRequest(
this.email,
AuthRequestType.AuthenticateAndUnlock,
);
if (!loginAuthRequestView) {
await this.buildAuthRequest(AuthRequestType.AuthenticateAndUnlock);
// I tried several ways to get the IDE/linter to play nice with checking for null values
// in less code / more efficiently, but it struggles to identify code paths that
// are more complicated than this.
if (!this.authRequest) {
this.logService.error("AuthRequest failed to initialize from buildAuthRequest.");
return;
}
if (!this.fingerprintPhrase) {
this.logService.error("FingerprintPhrase failed to initialize from buildAuthRequest.");
return;
}
if (!this.authRequestKeyPair) {
this.logService.error("KeyPair failed to initialize from buildAuthRequest.");
return;
}
const authRequestResponse: AuthRequestResponse =
await this.authRequestApiService.postAuthRequest(this.authRequest);
this.loginViaAuthRequestCacheService.cacheLoginView(
this.authRequest,
authRequestResponse,
this.fingerprintPhrase,
this.authRequestKeyPair,
);
if (authRequestResponse.id) {
await this.anonymousHubService.createHubConnection(authRequestResponse.id);
}
} else {
// Grab the cached information and store it back in component state.
// We don't need the public key for handling the authentication request because
// the verifyAndHandleApprovedAuthReq function will receive the public key back
// from the looked up auth request and all we need is to make sure that
// we can use the cached private key that is associated with it.
this.authRequest = loginAuthRequestView.authRequest;
this.fingerprintPhrase = loginAuthRequestView.fingerprintPhrase;
this.authRequestKeyPair = {
privateKey: loginAuthRequestView.privateKey
? Utils.fromB64ToArray(loginAuthRequestView.privateKey)
: undefined,
publicKey: undefined,
};
if (!loginAuthRequestView.authRequestResponse) {
this.logService.error("No cached auth request response.");
return;
}
if (loginAuthRequestView.authRequestResponse.id) {
await this.anonymousHubService.createHubConnection(
loginAuthRequestView.authRequestResponse.id,
);
}
}
} catch (e) {
this.logService.error(e);
// I tried several ways to get the IDE/linter to play nice with checking for null values
// in less code / more efficiently, but it struggles to identify code paths that
// are more complicated than this.
if (!authRequest) {
this.logService.error("AuthRequest failed to initialize from buildAuthRequest.");
return;
}
} else {
try {
await this.buildAuthRequest(AuthRequestType.AuthenticateAndUnlock);
if (!this.authRequest) {
this.logService.error("No auth request found.");
if (!this.fingerprintPhrase) {
this.logService.error("FingerprintPhrase failed to initialize from buildAuthRequest.");
return;
}
if (!this.authRequestKeyPair) {
this.logService.error("KeyPair failed to initialize from buildAuthRequest.");
return;
}
const authRequestResponse: AuthRequestResponse =
await this.authRequestApiService.postAuthRequest(authRequest);
if (await this.configService.getFeatureFlag(FeatureFlag.PM9112_DeviceApprovalPersistence)) {
if (!this.authRequestKeyPair.privateKey) {
this.logService.error("No private key when trying to cache the login view.");
return;
}
const authRequestResponse = await this.authRequestApiService.postAuthRequest(
this.authRequest,
);
if (authRequestResponse.id) {
await this.anonymousHubService.createHubConnection(authRequestResponse.id);
if (!this.accessCode) {
this.logService.error("No access code when trying to cache the login view.");
return;
}
} catch (e) {
this.logService.error(e);
this.loginViaAuthRequestCacheService.cacheLoginView(
authRequestResponse.id,
this.authRequestKeyPair.privateKey,
this.accessCode,
);
}
if (authRequestResponse.id) {
await this.anonymousHubService.createHubConnection(authRequestResponse.id);
}
} catch (e) {
this.logService.error(e);
}
setTimeout(() => {
@@ -358,7 +422,10 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
}, this.showResendNotificationTimeoutSeconds * 1000);
}
private async buildAuthRequest(authRequestType: AuthRequestType): Promise<void> {
private async buildAuthRequest(
email: string,
authRequestType: AuthRequestType,
): Promise<AuthRequest> {
const authRequestKeyPairArray = await this.cryptoFunctionService.rsaGenerateKeyPair(2048);
this.authRequestKeyPair = {
@@ -369,36 +436,27 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
const deviceIdentifier = await this.appIdService.getAppId();
if (!this.authRequestKeyPair.publicKey) {
this.logService.error("AuthRequest public key not set to value in building auth request.");
return;
const errorMessage = "No public key when building an auth request.";
this.logService.error(errorMessage);
throw new Error(errorMessage);
}
const publicKey = Utils.fromBufferToB64(this.authRequestKeyPair.publicKey);
const accessCode = await this.passwordGenerationService.generatePassword({
this.fingerprintPhrase = await this.authRequestService.getFingerprintPhrase(
email,
this.authRequestKeyPair.publicKey,
);
this.accessCode = await this.passwordGenerationService.generatePassword({
type: "password",
length: 25,
});
if (!this.email) {
this.logService.error("Email not defined when building auth request.");
return;
}
const b64PublicKey = Utils.fromBufferToB64(this.authRequestKeyPair.publicKey);
this.fingerprintPhrase = await this.authRequestService.getFingerprintPhrase(
this.email,
this.authRequestKeyPair.publicKey,
);
this.authRequest = new AuthRequest(
this.email,
deviceIdentifier,
publicKey,
authRequestType,
accessCode,
);
return new AuthRequest(email, deviceIdentifier, b64PublicKey, authRequestType, this.accessCode);
}
private async handleExistingAdminAuthRequest(
private async handleExistingAdminAuthRequestLogin(
adminAuthRequestStorable: AdminAuthRequestStorable,
userId: UserId,
): Promise<void> {
@@ -414,7 +472,7 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
);
} catch (error) {
if (error instanceof ErrorResponse && error.statusCode === HttpStatusCode.NotFound) {
return await this.handleExistingAdminAuthReqDeletedOrDenied(userId);
return await this.clearExistingAdminAuthRequestAndStartNewRequest(userId);
}
this.logService.error(error);
return;
@@ -422,28 +480,12 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
// Request doesn't exist anymore
if (!adminAuthRequestResponse) {
return await this.handleExistingAdminAuthReqDeletedOrDenied(userId);
return await this.clearExistingAdminAuthRequestAndStartNewRequest(userId);
}
// Re-derive the user's fingerprint phrase
// It is important to not use the server's public key here as it could have been compromised via MITM
const derivedPublicKeyArrayBuffer = await this.cryptoFunctionService.rsaExtractPublicKey(
adminAuthRequestStorable.privateKey,
);
if (!this.email) {
this.logService.error("Email not defined when handling an existing an admin auth request.");
return;
}
this.fingerprintPhrase = await this.authRequestService.getFingerprintPhrase(
this.email,
derivedPublicKeyArrayBuffer,
);
// Request denied
if (adminAuthRequestResponse.isAnswered && !adminAuthRequestResponse.requestApproved) {
return await this.handleExistingAdminAuthReqDeletedOrDenied(userId);
return await this.clearExistingAdminAuthRequestAndStartNewRequest(userId);
}
// Request approved
@@ -455,6 +497,22 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
);
}
if (!this.email) {
this.logService.error("Email not defined when handling an existing an admin auth request.");
return;
}
// Re-derive the user's fingerprint phrase
// It is important to not use the server's public key here as it could have been compromised via MITM
const derivedPublicKeyArrayBuffer = await this.cryptoFunctionService.rsaExtractPublicKey(
adminAuthRequestStorable.privateKey,
);
this.fingerprintPhrase = await this.authRequestService.getFingerprintPhrase(
this.email,
derivedPublicKeyArrayBuffer,
);
// Request still pending response from admin set keypair and create hub connection
// so that any approvals will be received via push notification
this.authRequestKeyPair = {
@@ -464,117 +522,99 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
await this.anonymousHubService.createHubConnection(adminAuthRequestStorable.id);
}
private async verifyAndHandleApprovedAuthReq(requestId: string): Promise<void> {
/**
* ***********************************
* Standard Auth Request Flows
* ***********************************
*
* Flow 1: Unauthed user requests approval from device; Approving device has a masterKey in memory.
*
* Unauthed user clicks "Login with device" > navigates to /login-with-device which creates a StandardAuthRequest
* > receives approval from a device with authRequestPublicKey(masterKey) > decrypts masterKey > decrypts userKey > proceed to vault
*
* Flow 2: Unauthed user requests approval from device; Approving device does NOT have a masterKey in memory.
*
* Unauthed user clicks "Login with device" > navigates to /login-with-device which creates a StandardAuthRequest
* > receives approval from a device with authRequestPublicKey(userKey) > decrypts userKey > proceeds to vault
*
* Note: this flow is an uncommon scenario and relates to TDE off-boarding. The following describes how a user could get into this flow:
* 1) An SSO TD user logs into a device via an Admin auth request approval, therefore this device does NOT have a masterKey in memory.
* 2) The org admin...
* (2a) Changes the member decryption options from "Trusted devices" to "Master password" AND
* (2b) Turns off the "Require single sign-on authentication" policy
* 3) On another device, the user clicks "Login with device", which they can do because the org no longer requires SSO.
* 4) The user approves from the device they had previously logged into with SSO TD, which does NOT have a masterKey in memory (see step 1 above).
*
* Flow 3: Authed SSO TD user requests approval from device; Approving device has a masterKey in memory.
*
* SSO TD user authenticates via SSO > navigates to /login-initiated > clicks "Approve from your other device"
* > navigates to /login-with-device which creates a StandardAuthRequest > receives approval from device with authRequestPublicKey(masterKey)
* > decrypts masterKey > decrypts userKey > establishes trust (if required) > proceeds to vault
*
* Flow 4: Authed SSO TD user requests approval from device; Approving device does NOT have a masterKey in memory.
*
* SSO TD user authenticates via SSO > navigates to /login-initiated > clicks "Approve from your other device"
* > navigates to /login-with-device which creates a StandardAuthRequest > receives approval from device with authRequestPublicKey(userKey)
* > decrypts userKey > establishes trust (if required) > proceeds to vault
*
* ***********************************
* Admin Auth Request Flow
* ***********************************
*
* Flow: Authed SSO TD user requests admin approval.
*
* SSO TD user authenticates via SSO > navigates to /login-initiated > clicks "Request admin approval"
* > navigates to /admin-approval-requested which creates an AdminAuthRequest > receives approval from device with authRequestPublicKey(userKey)
* > decrypts userKey > establishes trust (if required) > proceeds to vault
*
* Note: TDE users are required to be enrolled in admin password reset, which gives the admin access to the user's userKey.
* This is how admins are able to send over the authRequestPublicKey(userKey) to the user to allow them to unlock.
*
*
* Summary Table
* |-------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
* | Flow | Auth Status | Clicks Button [active route] | Navigates to | Approving device has masterKey in memory (see note 1) |
* |-------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
* | Standard Flow 1 | unauthed | "Login with device" [/login] | /login-with-device | yes |
* | Standard Flow 2 | unauthed | "Login with device" [/login] | /login-with-device | no |
* | Standard Flow 3 | authed | "Approve from your other device" [/login-initiated] | /login-with-device | yes |
* | Standard Flow 4 | authed | "Approve from your other device" [/login-initiated] | /login-with-device | no |
* | Admin Flow | authed | "Request admin approval" [/login-initiated] | /admin-approval-requested | NA - admin requests always send encrypted userKey |
* |-------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
* * Note 1: The phrase "in memory" here is important. It is possible for a user to have a master password for their account, but not have a masterKey IN MEMORY for
* a specific device. For example, if a user registers an account with a master password, then joins an SSO TD org, then logs in to a device via SSO and
* admin auth request, they are now logged into that device but that device does not have masterKey IN MEMORY.
*/
/**
* This is used for trying to get the auth request back out of state.
* @param requestId
* @private
*/
private async retrieveAuthRequest(requestId: string): Promise<AuthRequestResponse> {
let authRequestResponse: AuthRequestResponse | undefined = undefined;
try {
// There are two cases here, the first being
const userHasAuthenticatedViaSSO = this.authStatus === AuthenticationStatus.Locked;
// Get the response based on whether we've authenticated or not. We need to call a different API method
// based on whether we have a token or need to use the accessCode.
if (userHasAuthenticatedViaSSO) {
// Get the auth request from the server
// User is authenticated, therefore the endpoint does not require an access code.
const authRequestResponse = await this.authRequestApiService.getAuthRequest(requestId);
if (authRequestResponse.requestApproved) {
// Handles Standard Flows 3-4 and Admin Flow
await this.handleAuthenticatedFlows(authRequestResponse);
}
authRequestResponse = await this.authRequestApiService.getAuthRequest(requestId);
} else {
if (!this.authRequest) {
this.logService.error("No auth request defined when handling approved auth request.");
return;
if (!this.accessCode) {
const errorMessage = "No access code available when handling approved auth request.";
this.logService.error(errorMessage);
throw new Error(errorMessage);
}
// Get the auth request from the server
// User is unauthenticated, therefore the endpoint requires an access code for user verification.
const authRequestResponse = await this.authRequestApiService.getAuthResponse(
authRequestResponse = await this.authRequestApiService.getAuthResponse(
requestId,
this.authRequest.accessCode,
this.accessCode,
);
}
} catch (error) {
// If the request no longer exists, we treat it as if it's been answered (and denied).
if (error instanceof ErrorResponse && error.statusCode === HttpStatusCode.NotFound) {
authRequestResponse = undefined;
} else {
this.logService.error(error);
}
}
if (authRequestResponse.requestApproved) {
// Handles Standard Flows 1-2
await this.handleUnauthenticatedFlows(authRequestResponse, requestId);
if (authRequestResponse === undefined) {
throw new Error("Auth request response not generated");
}
return authRequestResponse;
}
/**
* Determines if the Auth Request has been approved, deleted or denied, and handles
* the response accordingly.
* @param requestId The ID of the Auth Request to process
* @returns A boolean indicating whether the Auth Request was successfully processed
*/
private async handleExistingAuthRequestLogin(requestId: string): Promise<void> {
this.showResendNotification = false;
try {
const authRequestResponse = await this.retrieveAuthRequest(requestId);
// Request doesn't exist anymore, so we'll clear the cache and start a new request.
if (!authRequestResponse) {
return await this.clearExistingStandardAuthRequestAndStartNewRequest();
}
// Request denied, so we'll clear the cache and start a new request.
if (authRequestResponse.isAnswered && !authRequestResponse.requestApproved) {
return await this.clearExistingStandardAuthRequestAndStartNewRequest();
}
// Request approved, so we'll log the user in.
if (authRequestResponse.requestApproved) {
const userHasAuthenticatedViaSSO = this.authStatus === AuthenticationStatus.Locked;
if (userHasAuthenticatedViaSSO) {
// [Standard Flow 3-4] Handle authenticated SSO TD user flows
return await this.handleAuthenticatedFlows(authRequestResponse);
} else {
// [Standard Flow 1-2] Handle unauthenticated user flows
return await this.handleUnauthenticatedFlows(authRequestResponse, requestId);
}
}
// At this point, we know that the request is still pending, so we'll start a hub connection to listen for a response.
await this.anonymousHubService.createHubConnection(requestId);
} catch (error) {
if (error instanceof ErrorResponse) {
await this.router.navigate([this.backToRoute]);
this.validationService.showError(error);
return;
}
this.logService.error(error);
} finally {
// Manually clean out the cache to make sure sensitive
// data does not persist longer than it needs to.
this.loginViaAuthRequestCacheService.clearCacheLoginView();
}
setTimeout(() => {
this.showResendNotification = true;
}, this.showResendNotificationTimeoutSeconds * 1000);
}
private async handleAuthenticatedFlows(authRequestResponse: AuthRequestResponse) {
// [Standard Flow 3-4] Handle authenticated SSO TD user flows
const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
if (!userId) {
this.logService.error(
@@ -599,6 +639,7 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
authRequestResponse: AuthRequestResponse,
requestId: string,
) {
// [Standard Flow 1-2] Handle unauthenticated user flows
const authRequestLoginCredentials = await this.buildAuthRequestLoginCredentials(
requestId,
authRequestResponse,
@@ -609,6 +650,9 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
return;
}
// Clear the cached auth request from state since we're using it to log in.
this.loginViaAuthRequestCacheService.clearCacheLoginView();
// Note: keys are set by AuthRequestLoginStrategy success handling
const authResult = await this.loginStrategyService.logIn(authRequestLoginCredentials);
@@ -621,21 +665,20 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
userId: UserId,
): Promise<void> {
/**
* See verifyAndHandleApprovedAuthReq() for flow details.
*
* [Flow Type Detection]
* We determine the type of `key` based on the presence or absence of `masterPasswordHash`:
* - If `masterPasswordHash` has a value, we receive the `key` as an authRequestPublicKey(masterKey) [plus we have authRequestPublicKey(masterPasswordHash)]
* - If `masterPasswordHash` does not have a value, we receive the `key` as an authRequestPublicKey(userKey)
* - If `masterPasswordHash` exists: Standard Flow 1 or 3 (device has masterKey)
* - If no `masterPasswordHash`: Standard Flow 2, 4, or Admin Flow (device sends userKey)
*/
if (authRequestResponse.masterPasswordHash) {
// ...in Standard Auth Request Flow 3
// [Standard Flow 1 or 3] Device has masterKey
await this.authRequestService.setKeysAfterDecryptingSharedMasterKeyAndHash(
authRequestResponse,
privateKey,
userId,
);
} else {
// ...in Standard Auth Request Flow 4 or Admin Auth Request Flow
// [Standard Flow 2, 4, or Admin Flow] Device sends userKey
await this.authRequestService.setUserKeyAfterDecryptingSharedUserKey(
authRequestResponse,
privateKey,
@@ -643,15 +686,20 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
);
}
// [Admin Flow Cleanup] Clear one-time use admin auth request
// clear the admin auth request from state so it cannot be used again (it's a one time use)
// TODO: this should eventually be enforced via deleting this on the server once it is used
await this.authRequestService.clearAdminAuthRequest(userId);
// [Standard Flow Cleanup] Clear the cached auth request from state
this.loginViaAuthRequestCacheService.clearCacheLoginView();
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("loginApproved"),
});
// [Device Trust] Establish trust if required
// Now that we have a decrypted user key in memory, we can check if we
// need to establish trust on the current device
const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
@@ -686,9 +734,9 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
return;
}
if (!this.authRequest) {
if (!this.accessCode) {
this.logService.error(
"AuthRequest not defined when building auth request login credentials.",
"Access code not defined when building auth request login credentials.",
);
return;
}
@@ -711,7 +759,7 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
return new AuthRequestLoginCredentials(
this.email,
this.authRequest.accessCode,
this.accessCode,
requestId,
null, // no userKey
masterKey,
@@ -725,7 +773,7 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
);
return new AuthRequestLoginCredentials(
this.email,
this.authRequest.accessCode,
this.accessCode,
requestId,
userKey,
null, // no masterKey
@@ -734,12 +782,20 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
}
}
private async handleExistingAdminAuthReqDeletedOrDenied(userId: UserId) {
private async clearExistingAdminAuthRequestAndStartNewRequest(userId: UserId) {
// clear the admin auth request from state
await this.authRequestService.clearAdminAuthRequest(userId);
// start new auth request
await this.startAdminAuthRequestLogin();
await this.handleNewAdminAuthRequestLogin();
}
private async clearExistingStandardAuthRequestAndStartNewRequest(): Promise<void> {
// clear the auth request from state
this.loginViaAuthRequestCacheService.clearCacheLoginView();
// start new auth request
await this.handleNewStandardAuthRequestLogin();
}
private async handlePostLoginNavigation(loginResponse: AuthResult) {
@@ -753,11 +809,6 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
}
private async handleSuccessfulLoginNavigation(userId: UserId) {
if (this.flow === Flow.StandardAuthRequest) {
// Only need to set remembered email on standard login with auth req flow
await this.loginEmailService.saveEmailSettings();
}
await this.loginSuccessHandlerService.run(userId);
await this.router.navigate(["vault"]);
}

View File

@@ -3,7 +3,7 @@ import { of } from "rxjs";
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";
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
import {
EnvironmentService,
Environment,
@@ -14,7 +14,7 @@ import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legac
import { DefaultLoginComponentService } from "./default-login-component.service";
jest.mock("@bitwarden/common/platform/abstractions/crypto-function.service");
jest.mock("@bitwarden/common/key-management/crypto/abstractions/crypto-function.service");
jest.mock("@bitwarden/common/platform/abstractions/environment.service");
jest.mock("@bitwarden/common/platform/abstractions/platform-utils.service");
jest.mock("@bitwarden/common/auth/abstractions/sso-login.service.abstraction");

View File

@@ -3,7 +3,7 @@
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";
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";

View File

@@ -27,7 +27,12 @@
<!-- Remember Email input -->
<bit-form-control>
<input type="checkbox" formControlName="rememberEmail" bitCheckbox />
<input
type="checkbox"
formControlName="rememberEmail"
(input)="onRememberEmailInput($event)"
bitCheckbox
/>
<bit-label>{{ "rememberEmail" | i18n }}</bit-label>
</bit-form-control>
@@ -39,18 +44,18 @@
<div class="tw-text-center">{{ "or" | i18n }}</div>
<!-- Link to Login with Passkey page -->
<!-- Button to Login with Passkey -->
<ng-container *ngIf="isLoginWithPasskeySupported()">
<a
<button
type="button"
bitButton
block
linkType="primary"
routerLink="/login-with-passkey"
(mousedown)="$event.preventDefault()"
buttonType="secondary"
(click)="handleLoginWithPasskeyClick()"
>
<i class="bwi bwi-passkey tw-mr-1"></i>
{{ "logInWithPasskey" | i18n }}
</a>
</button>
</ng-container>
<!-- Button to Login with SSO -->

View File

@@ -148,6 +148,62 @@ export class LoginComponent implements OnInit, OnDestroy {
this.destroy$.complete();
}
private async defaultOnInit(): Promise<void> {
let paramEmailIsSet = false;
const params = await firstValueFrom(this.activatedRoute.queryParams);
if (params) {
const qParamsEmail = params.email;
// If there is an email in the query params, set that email as the form field value
if (qParamsEmail != null && qParamsEmail.indexOf("@") > -1) {
this.formGroup.controls.email.setValue(qParamsEmail);
paramEmailIsSet = true;
}
}
// If there are no params or no email in the query params, loadEmailSettings from state
if (!paramEmailIsSet) {
await this.loadRememberedEmail();
}
// Check to see if the device is known so that we can show the Login with Device option
if (this.emailFormControl.value) {
await this.getKnownDevice(this.emailFormControl.value);
}
// Backup check to handle unknown case where activatedRoute is not available
// This shouldn't happen under normal circumstances
if (!this.activatedRoute) {
await this.loadRememberedEmail();
}
}
private async desktopOnInit(): Promise<void> {
// TODO: refactor to not use deprecated broadcaster service.
this.broadcasterService.subscribe(BroadcasterSubscriptionId, async (message: any) => {
this.ngZone.run(() => {
switch (message.command) {
case "windowIsFocused":
if (this.deferFocus === null) {
this.deferFocus = !message.windowIsFocused;
if (!this.deferFocus) {
this.focusInput();
}
} else if (this.deferFocus && message.windowIsFocused) {
this.focusInput();
this.deferFocus = false;
}
break;
default:
}
});
});
this.messagingService.send("getWindowIsFocused");
}
submit = async (): Promise<void> => {
if (this.clientType === ClientType.Desktop) {
if (this.loginUiState !== LoginUiState.MASTER_PASSWORD_ENTRY) {
@@ -172,7 +228,6 @@ export class LoginComponent implements OnInit, OnDestroy {
try {
const authResult = await this.loginStrategyService.logIn(credentials);
await this.saveEmailSettings();
await this.handleAuthResult(authResult);
} catch (error) {
this.logService.error(error);
@@ -250,7 +305,6 @@ export class LoginComponent implements OnInit, OnDestroy {
// 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) {
@@ -288,7 +342,6 @@ 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.
@@ -344,16 +397,9 @@ export class LoginComponent implements OnInit, OnDestroy {
return;
}
await this.saveEmailSettings();
await this.router.navigate(["/login-with-device"]);
}
protected async validateEmail(): Promise<boolean> {
this.formGroup.controls.email.markAsTouched();
this.formGroup.controls.email.updateValueAndValidity({ onlySelf: true, emitEvent: true });
return this.formGroup.controls.email.valid;
}
protected async toggleLoginUiState(value: LoginUiState): Promise<void> {
this.loginUiState = value;
@@ -399,37 +445,14 @@ export class LoginComponent implements OnInit, OnDestroy {
}
}
/**
* Set the email value from the input field.
* @param event The event object from the input field.
*/
onEmailInput(event: Event) {
const emailInput = event.target as HTMLInputElement;
this.formGroup.controls.email.setValue(emailInput.value);
this.loginEmailService.setLoginEmail(emailInput.value);
}
isLoginWithPasskeySupported() {
return this.loginComponentService.isLoginWithPasskeySupported();
}
protected async goToHint(): Promise<void> {
await this.saveEmailSettings();
await this.router.navigateByUrl("/hint");
}
protected async saveEmailSettings(): Promise<void> {
const email = this.formGroup.value.email;
if (!email) {
this.logService.error("Email is required to save email settings.");
return;
}
await this.loginEmailService.setLoginEmail(email);
this.loginEmailService.setRememberEmail(this.formGroup.value.rememberEmail ?? false);
await this.loginEmailService.saveEmailSettings();
}
/**
* Continue button clicked (or enter key pressed).
* Adds the login url to the browser's history so that the back button can be used to go back to the email entry state.
@@ -445,13 +468,44 @@ export class LoginComponent implements OnInit, OnDestroy {
* Continue to the master password entry state (only if email is validated)
*/
protected async continue(): Promise<void> {
const isEmailValid = await this.validateEmail();
const isEmailValid = this.validateEmail();
if (isEmailValid) {
await this.toggleLoginUiState(LoginUiState.MASTER_PASSWORD_ENTRY);
}
}
/**
* Handle the Login with Passkey button click.
* We need a handler here in order to persist the remember email selection to state before routing.
* @param event - The event object.
*/
async handleLoginWithPasskeyClick() {
await this.router.navigate(["/login-with-passkey"]);
}
/**
* Handle the SSO button click.
* @param event - The event object.
*/
async handleSsoClick() {
// Make sure the email is valid
const isEmailValid = this.validateEmail();
if (!isEmailValid) {
return;
}
// Make sure the email is not empty, for type safety
const email = this.formGroup.value.email;
if (!email) {
this.logService.error("Email is required for SSO");
return;
}
// Send the user to SSO, either through routing or through redirecting to the web app
await this.loginComponentService.redirectToSsoLogin(email);
}
/**
* Call to check if the device is known.
* Known means that the user has logged in with this device before.
@@ -473,23 +527,21 @@ export class LoginComponent implements OnInit, OnDestroy {
}
}
private async loadEmailSettings(): Promise<void> {
// Try to load the email from memory first
const email = await firstValueFrom(this.loginEmailService.loginEmail$);
const rememberEmail = this.loginEmailService.getRememberEmail();
if (email) {
this.formGroup.controls.email.setValue(email);
this.formGroup.controls.rememberEmail.setValue(rememberEmail);
/**
* Check to see if the user has remembered an email on the current device.
* If so, set the email in the form field and set rememberEmail to true. If not, set rememberEmail to false.
*/
private async loadRememberedEmail(): Promise<void> {
const storedEmail = await firstValueFrom(this.loginEmailService.rememberedEmail$);
if (storedEmail) {
this.formGroup.controls.email.setValue(storedEmail);
this.formGroup.controls.rememberEmail.setValue(true);
// If we load an email into the form, we need to initialize it for the login process as well
// so that other login components can use it.
// We do this here as it's possible that a user doesn't edit the email field before submitting.
this.loginEmailService.setLoginEmail(storedEmail);
} else {
// If there is no email in memory, check for a storedEmail on disk
const storedEmail = await firstValueFrom(this.loginEmailService.storedEmail$);
if (storedEmail) {
this.formGroup.controls.email.setValue(storedEmail);
// If there is a storedEmail, rememberEmail defaults to true
this.formGroup.controls.rememberEmail.setValue(true);
}
this.formGroup.controls.rememberEmail.setValue(false);
}
}
@@ -503,62 +555,6 @@ export class LoginComponent implements OnInit, OnDestroy {
?.focus();
}
private async defaultOnInit(): Promise<void> {
let paramEmailIsSet = false;
const params = await firstValueFrom(this.activatedRoute.queryParams);
if (params) {
const qParamsEmail = params.email;
// If there is an email in the query params, set that email as the form field value
if (qParamsEmail != null && qParamsEmail.indexOf("@") > -1) {
this.formGroup.controls.email.setValue(qParamsEmail);
paramEmailIsSet = true;
}
}
// If there are no params or no email in the query params, loadEmailSettings from state
if (!paramEmailIsSet) {
await this.loadEmailSettings();
}
// Check to see if the device is known so that we can show the Login with Device option
if (this.emailFormControl.value) {
await this.getKnownDevice(this.emailFormControl.value);
}
// Backup check to handle unknown case where activatedRoute is not available
// This shouldn't happen under normal circumstances
if (!this.activatedRoute) {
await this.loadEmailSettings();
}
}
private async desktopOnInit(): Promise<void> {
// TODO: refactor to not use deprecated broadcaster service.
this.broadcasterService.subscribe(BroadcasterSubscriptionId, async (message: any) => {
this.ngZone.run(() => {
switch (message.command) {
case "windowIsFocused":
if (this.deferFocus === null) {
this.deferFocus = !message.windowIsFocused;
if (!this.deferFocus) {
this.focusInput();
}
} else if (this.deferFocus && message.windowIsFocused) {
this.focusInput();
this.deferFocus = false;
}
break;
default:
}
});
});
this.messagingService.send("getWindowIsFocused");
}
/**
* Helper function to determine if the back button should be shown.
* @returns true if the back button should be shown.
@@ -597,27 +593,56 @@ export class LoginComponent implements OnInit, OnDestroy {
};
/**
* Handle the SSO button click.
* Validates the email and displays any validation errors.
* @returns true if the email is valid, false otherwise.
*/
async handleSsoClick() {
const email = this.formGroup.value.email;
protected validateEmail(): boolean {
this.formGroup.controls.email.markAsTouched();
this.formGroup.controls.email.updateValueAndValidity({ onlySelf: true, emitEvent: true });
return this.formGroup.controls.email.valid;
}
// Make sure the email is valid
const isEmailValid = await this.validateEmail();
if (!isEmailValid) {
return;
/**
* Persist the entered email address and the user's choice to remember it to state.
*/
private async persistEmailIfValid(): Promise<void> {
if (this.formGroup.controls.email.valid) {
const email = this.formGroup.value.email;
const rememberEmail = this.formGroup.value.rememberEmail ?? false;
if (!email) {
return;
}
await this.loginEmailService.setLoginEmail(email);
await this.loginEmailService.setRememberedEmailChoice(email, rememberEmail);
} else {
await this.loginEmailService.clearLoginEmail();
await this.loginEmailService.clearRememberedEmail();
}
}
// Make sure the email is not empty, for type safety
if (!email) {
this.logService.error("Email is required for SSO");
return;
}
/**
* Set the email value from the input field and persists to state if valid.
* We only update the form controls onSubmit instead of onBlur because we don't want to show validation errors until
* the user submits. This is because currently our validation errors are shown below the input fields, and
* displaying them causes the screen to "jump".
* @param event The event object from the input field.
*/
async onEmailInput(event: Event) {
const emailInput = event.target as HTMLInputElement;
this.formGroup.controls.email.setValue(emailInput.value);
await this.persistEmailIfValid();
}
// Save the email configuration for the login component
await this.saveEmailSettings();
// Send the user to SSO, either through routing or through redirecting to the web app
await this.loginComponentService.redirectToSsoLogin(email);
/**
* Set the Remember Email value from the input field and persists to state if valid.
* We only update the form controls onSubmit instead of onBlur because we don't want to show validation errors until
* the user submits. This is because currently our validation errors are shown below the input fields, and
* displaying them causes the screen to "jump".
* @param event The event object from the input field.
*/
async onRememberEmailInput(event: Event) {
const rememberEmailInput = event.target as HTMLInputElement;
this.formGroup.controls.rememberEmail.setValue(rememberEmailInput.checked);
await this.persistEmailIfValid();
}
}

View File

@@ -5,10 +5,10 @@ import { Router } from "@angular/router";
import { 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 { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import {
AsyncActionsModule,
ButtonModule,
@@ -17,7 +17,6 @@ import {
LinkModule,
} from "@bitwarden/components";
import { LoginEmailServiceAbstraction } from "../../common/abstractions/login-email.service";
import { LoginStrategyServiceAbstraction } from "../../common/abstractions/login-strategy.service";
/**
@@ -60,8 +59,7 @@ export class NewDeviceVerificationComponent implements OnInit, OnDestroy {
private loginStrategyService: LoginStrategyServiceAbstraction,
private logService: LogService,
private i18nService: I18nService,
private syncService: SyncService,
private loginEmailService: LoginEmailServiceAbstraction,
private loginSuccessHandlerService: LoginSuccessHandlerService,
) {}
async ngOnInit() {
@@ -143,9 +141,7 @@ export class NewDeviceVerificationComponent implements OnInit, OnDestroy {
return;
}
this.loginEmailService.clearValues();
await this.syncService.fullSync(true);
this.loginSuccessHandlerService.run(authResult.userId);
// If verification succeeds, navigate to vault
await this.router.navigate(["/vault"]);

View File

@@ -60,11 +60,11 @@ describe("DefaultRegistrationFinishService", () => {
masterKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as MasterKey;
passwordInputResult = {
masterKey: masterKey,
masterKeyHash: "masterKeyHash",
serverMasterKeyHash: "serverMasterKeyHash",
localMasterKeyHash: "localMasterKeyHash",
kdfConfig: DEFAULT_KDF_CONFIG,
hint: "hint",
password: "password",
newPassword: "password",
};
userKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as UserKey;
@@ -101,7 +101,7 @@ describe("DefaultRegistrationFinishService", () => {
expect.objectContaining({
email,
emailVerificationToken: emailVerificationToken,
masterPasswordHash: passwordInputResult.masterKeyHash,
masterPasswordHash: passwordInputResult.serverMasterKeyHash,
masterPasswordHint: passwordInputResult.hint,
userSymmetricKey: userKeyEncString.encryptedString,
userAsymmetricKeys: {

View File

@@ -81,7 +81,7 @@ export class DefaultRegistrationFinishService implements RegistrationFinishServi
const registerFinishRequest = new RegisterFinishRequest(
email,
passwordInputResult.masterKeyHash,
passwordInputResult.serverMasterKeyHash,
passwordInputResult.hint,
encryptedUserKey,
userAsymmetricKeysRequest,

View File

@@ -5,8 +5,9 @@
<auth-input-password
*ngIf="!loading"
[email]="email"
[inputPasswordFlow]="InputPasswordFlow.SetInitialPassword"
[masterPasswordPolicyOptions]="masterPasswordPolicyOptions"
[loading]="submitting"
[primaryButtonText]="{ key: 'createAccount' }"
(onPasswordFormSubmit)="handlePasswordFormSubmit($event)"
[buttonText]="'createAccount' | i18n"
></auth-input-password>

View File

@@ -22,7 +22,10 @@ import {
PasswordLoginCredentials,
} from "../../../common";
import { AnonLayoutWrapperDataService } from "../../anon-layout/anon-layout-wrapper-data.service";
import { InputPasswordComponent } from "../../input-password/input-password.component";
import {
InputPasswordComponent,
InputPasswordFlow,
} from "../../input-password/input-password.component";
import { PasswordInputResult } from "../../input-password/password-input-result";
import { RegistrationFinishService } from "./registration-finish.service";
@@ -36,6 +39,8 @@ import { RegistrationFinishService } from "./registration-finish.service";
export class RegistrationFinishComponent implements OnInit, OnDestroy {
private destroy$ = new Subject<void>();
InputPasswordFlow = InputPasswordFlow;
loading = true;
submitting = false;
email: string;
@@ -176,7 +181,7 @@ export class RegistrationFinishComponent implements OnInit, OnDestroy {
try {
const credentials = new PasswordLoginCredentials(
this.email,
passwordInputResult.password,
passwordInputResult.newPassword,
captchaBypassToken,
null,
);

View File

@@ -1,6 +1,5 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { DialogRef } from "@angular/cdk/dialog";
import { CommonModule } from "@angular/common";
import { Component, OnDestroy, OnInit } from "@angular/core";
import {
@@ -20,6 +19,7 @@ import {
Region,
} from "@bitwarden/common/platform/abstractions/environment.service";
import {
DialogRef,
AsyncActionsModule,
ButtonModule,
DialogModule,

View File

@@ -112,11 +112,11 @@ describe("DefaultSetPasswordJitService", () => {
passwordInputResult = {
masterKey: masterKey,
masterKeyHash: "masterKeyHash",
serverMasterKeyHash: "serverMasterKeyHash",
localMasterKeyHash: "localMasterKeyHash",
hint: "hint",
kdfConfig: DEFAULT_KDF_CONFIG,
password: "password",
newPassword: "password",
};
credentials = {
@@ -131,7 +131,7 @@ describe("DefaultSetPasswordJitService", () => {
userDecryptionOptionsService.userDecryptionOptions$ = userDecryptionOptionsSubject;
setPasswordRequest = new SetPasswordRequest(
passwordInputResult.masterKeyHash,
passwordInputResult.serverMasterKeyHash,
protectedUserKey[1].encryptedString,
passwordInputResult.hint,
orgSsoIdentifier,
@@ -174,7 +174,7 @@ describe("DefaultSetPasswordJitService", () => {
}
keyService.userKey$.mockReturnValue(of(userKey));
encryptService.rsaEncrypt.mockResolvedValue(userKeyEncString);
encryptService.encapsulateKeyUnsigned.mockResolvedValue(userKeyEncString);
organizationUserApiService.putOrganizationUserResetPasswordEnrollment.mockResolvedValue(
undefined,
@@ -216,7 +216,7 @@ describe("DefaultSetPasswordJitService", () => {
// Assert
expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(setPasswordRequest);
expect(organizationApiService.getKeys).toHaveBeenCalledWith(orgId);
expect(encryptService.rsaEncrypt).toHaveBeenCalledWith(userKey.key, orgPublicKey);
expect(encryptService.encapsulateKeyUnsigned).toHaveBeenCalledWith(userKey, orgPublicKey);
expect(
organizationUserApiService.putOrganizationUserResetPasswordEnrollment,
).toHaveBeenCalled();

View File

@@ -44,7 +44,7 @@ export class DefaultSetPasswordJitService implements SetPasswordJitService {
async setPassword(credentials: SetPasswordCredentials): Promise<void> {
const {
masterKey,
masterKeyHash,
serverMasterKeyHash,
localMasterKeyHash,
hint,
kdfConfig,
@@ -70,7 +70,7 @@ export class DefaultSetPasswordJitService implements SetPasswordJitService {
const [keyPair, keysRequest] = await this.makeKeyPairAndRequest(protectedUserKey);
const request = new SetPasswordRequest(
masterKeyHash,
serverMasterKeyHash,
protectedUserKey[1].encryptedString,
hint,
orgSsoIdentifier,
@@ -92,7 +92,7 @@ export class DefaultSetPasswordJitService implements SetPasswordJitService {
await this.masterPasswordService.setMasterKeyHash(localMasterKeyHash, userId);
if (resetPasswordAutoEnroll) {
await this.handleResetPasswordAutoEnroll(masterKeyHash, orgId, userId);
await this.handleResetPasswordAutoEnroll(serverMasterKeyHash, orgId, userId);
}
}
@@ -161,7 +161,7 @@ export class DefaultSetPasswordJitService implements SetPasswordJitService {
throw new Error("userKey not found. Could not handle reset password auto enroll.");
}
const encryptedUserKey = await this.encryptService.rsaEncrypt(userKey.key, publicKey);
const encryptedUserKey = await this.encryptService.encapsulateKeyUnsigned(userKey, publicKey);
const resetRequest = new OrganizationUserResetPasswordEnrollmentRequest();
resetRequest.masterPasswordHash = masterKeyHash;

View File

@@ -13,7 +13,8 @@
</app-callout>
<auth-input-password
[buttonText]="'createAccount' | i18n"
[inputPasswordFlow]="InputPasswordFlow.SetInitialPassword"
[primaryButtonText]="{ key: 'createAccount' }"
[email]="email"
[loading]="submitting"
[masterPasswordPolicyOptions]="masterPasswordPolicyOptions"

View File

@@ -18,7 +18,10 @@ import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.serv
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { ToastService } from "../../../../components/src/toast";
import { InputPasswordComponent } from "../input-password/input-password.component";
import {
InputPasswordComponent,
InputPasswordFlow,
} from "../input-password/input-password.component";
import { PasswordInputResult } from "../input-password/password-input-result";
import {
@@ -33,6 +36,7 @@ import {
imports: [CommonModule, InputPasswordComponent, JslibModule],
})
export class SetPasswordJitComponent implements OnInit {
protected InputPasswordFlow = InputPasswordFlow;
protected email: string;
protected masterPasswordPolicyOptions: MasterPasswordPolicyOptions;
protected orgId: string;

View File

@@ -6,7 +6,7 @@ import { PBKDF2KdfConfig } from "@bitwarden/key-management";
export interface SetPasswordCredentials {
masterKey: MasterKey;
masterKeyHash: string;
serverMasterKeyHash: string;
localMasterKeyHash: string;
kdfConfig: PBKDF2KdfConfig;
hint: string;

View File

@@ -25,11 +25,11 @@ import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/for
import { SsoPreValidateResponse } from "@bitwarden/common/auth/models/response/sso-pre-validate.response";
import { ClientType, HttpStatusCode } from "@bitwarden/common/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";

View File

@@ -1,10 +1,10 @@
import { DialogModule } from "@angular/cdk/dialog";
import { CommonModule } from "@angular/common";
import { Component, Input } from "@angular/core";
import { ReactiveFormsModule, FormsModule, FormControl } from "@angular/forms";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import {
DialogModule,
ButtonModule,
LinkModule,
TypographyModule,

View File

@@ -1,4 +1,3 @@
import { DialogModule } from "@angular/cdk/dialog";
import { CommonModule } from "@angular/common";
import { Component, DestroyRef, EventEmitter, Input, OnInit, Output } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
@@ -8,6 +7,7 @@ import { JslibModule } from "@bitwarden/angular/jslib.module";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import {
DialogModule,
ButtonModule,
LinkModule,
TypographyModule,

View File

@@ -1,4 +1,3 @@
import { DialogModule } from "@angular/cdk/dialog";
import { CommonModule } from "@angular/common";
import { Component, Input, OnInit } from "@angular/core";
import { ReactiveFormsModule, FormsModule, FormControl } from "@angular/forms";
@@ -14,6 +13,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import {
DialogModule,
ButtonModule,
LinkModule,
TypographyModule,

View File

@@ -1,4 +1,3 @@
import { DialogModule } from "@angular/cdk/dialog";
import { CommonModule } from "@angular/common";
import { Component, EventEmitter, Inject, OnDestroy, OnInit, Output } from "@angular/core";
import { ReactiveFormsModule, FormsModule } from "@angular/forms";
@@ -15,6 +14,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import {
DialogModule,
ButtonModule,
LinkModule,
TypographyModule,

View File

@@ -1,10 +1,10 @@
import { DialogModule } from "@angular/cdk/dialog";
import { CommonModule } from "@angular/common";
import { Component, Input } from "@angular/core";
import { ReactiveFormsModule, FormsModule, FormControl } from "@angular/forms";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import {
DialogModule,
ButtonModule,
LinkModule,
TypographyModule,

View File

@@ -254,19 +254,6 @@ describe("TwoFactorAuthComponent", () => {
);
});
it("calls loginEmailService.clearValues() when login is successful", async () => {
// Arrange
mockLoginStrategyService.logInTwoFactor.mockResolvedValue(new AuthResult());
// spy on loginEmailService.clearValues
const clearValuesSpy = jest.spyOn(mockLoginEmailService, "clearValues");
// Act
await component.submit(token, remember);
// Assert
expect(clearValuesSpy).toHaveBeenCalled();
});
describe("Set Master Password scenarios", () => {
beforeEach(() => {
const authResult = new AuthResult();

View File

@@ -382,7 +382,6 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy {
// User is fully logged in so handle any post login logic before executing navigation
await this.loginSuccessHandlerService.run(authResult.userId);
this.loginEmailService.clearValues();
// Save off the OrgSsoIdentifier for use in the TDE flows
// - TDE login decryption options component

View File

@@ -1,4 +1,3 @@
import { DialogRef } from "@angular/cdk/dialog";
import { CommonModule } from "@angular/common";
import { Component, OnInit } from "@angular/core";
@@ -9,6 +8,7 @@ import {
} from "@bitwarden/common/auth/abstractions/two-factor.service";
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
import {
DialogRef,
ButtonModule,
DialogModule,
DialogService,

View File

@@ -1,6 +1,5 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { DIALOG_DATA, DialogRef } from "@angular/cdk/dialog";
import { CommonModule } from "@angular/common";
import { Component, Inject } from "@angular/core";
import { FormBuilder, ReactiveFormsModule } from "@angular/forms";
@@ -12,6 +11,8 @@ import { VerificationWithSecret } from "@bitwarden/common/auth/types/verificatio
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import {
DIALOG_DATA,
DialogRef,
AsyncActionsModule,
ButtonModule,
CalloutModule,

View File

@@ -0,0 +1,241 @@
import { FormControl, FormGroup, ValidationErrors } from "@angular/forms";
import { compareInputs, ValidationGoal } from "./compare-inputs.validator";
describe("compareInputs", () => {
let validationErrorsObj: ValidationErrors;
beforeEach(() => {
// Use a fresh object for each test so that a mutation in one test doesn't affect another test
validationErrorsObj = {
compareInputsError: {
message: "Custom error message",
},
};
});
it("should throw an error if compareInputs is not being applied to a FormGroup", () => {
// Arrange
const notAFormGroup = new FormControl("form-control");
// Act
const validatorFn = compareInputs(
ValidationGoal.InputsShouldMatch,
"ctrlA",
"ctrlB",
"Custom error message",
);
// Assert
expect(() => validatorFn(notAFormGroup)).toThrow(
"compareInputs only supports validation at the FormGroup level",
);
});
it("should throw an error if either control is not found", () => {
// Arrange
const formGroupMissingControl = new FormGroup({
ctrlA: new FormControl("content"),
});
// Act
const validatorFn = compareInputs(
ValidationGoal.InputsShouldMatch,
"ctrlA",
"ctrlB", // ctrlB is missing above
"Custom error message",
);
// Assert
expect(() => validatorFn(formGroupMissingControl)).toThrow(
"[compareInputs validator] one or both of the specified controls could not be found in the form group",
);
});
it("should throw an error if the name of one of the form controls is incorrect or mispelled", () => {
// Arrange
const formGroupMissingControl = new FormGroup({
ctrlA: new FormControl("content"),
ctrlB: new FormControl("content"),
});
// Act
const validatorFn = compareInputs(
ValidationGoal.InputsShouldMatch,
"ctrlA",
"ctrlC", // ctrlC is incorrect (mimics a developer misspelling a form control name)
"Custom error message",
);
// Assert
expect(() => validatorFn(formGroupMissingControl)).toThrow(
"[compareInputs validator] one or both of the specified controls could not be found in the form group",
);
});
it("should return null if both controls have empty string values", () => {
// Arrange
const formGroup = new FormGroup({
ctrlA: new FormControl(""),
ctrlB: new FormControl(""),
});
// Act
const validatorFn = compareInputs(
ValidationGoal.InputsShouldMatch,
"ctrlA",
"ctrlB",
"Custom error message",
);
const result = validatorFn(formGroup);
// Assert
expect(result).toBeNull();
});
it("should call setErrors() on ctrlB if validation fails", () => {
// Arrange
const formGroup = new FormGroup({
ctrlA: new FormControl("apple"),
ctrlB: new FormControl("banana"),
});
const ctrlBSetErrorsSpy = jest.spyOn(formGroup.controls.ctrlB, "setErrors");
// Act
const validatorFn = compareInputs(
ValidationGoal.InputsShouldMatch,
"ctrlA",
"ctrlB",
"Custom error message",
);
validatorFn(formGroup);
// Assert
expect(ctrlBSetErrorsSpy).toHaveBeenCalledWith(validationErrorsObj);
});
it("should call setErrors() on ctrlA if validation fails and 'showErrorOn' is set to 'controlA'", () => {
// Arrange
const formGroup = new FormGroup({
ctrlA: new FormControl("apple"),
ctrlB: new FormControl("banana"),
});
const ctrlASetErrorsSpy = jest.spyOn(formGroup.controls.ctrlA, "setErrors");
const ctrlBSetErrorsSpy = jest.spyOn(formGroup.controls.ctrlB, "setErrors");
// Act
const validatorFn = compareInputs(
ValidationGoal.InputsShouldMatch,
"ctrlA",
"ctrlB",
"Custom error message",
"controlA",
);
validatorFn(formGroup);
// Assert
expect(ctrlASetErrorsSpy).toHaveBeenCalledWith(validationErrorsObj);
expect(ctrlBSetErrorsSpy).not.toHaveBeenCalled();
});
it("should not call setErrors() on ctrlB if validation passes and there is not a pre-existing error on ctrlB", () => {
// Arrange
const formGroup = new FormGroup({
ctrlA: new FormControl("apple"),
ctrlB: new FormControl("apple"),
});
const ctrlBSetErrorsSpy = jest.spyOn(formGroup.controls.ctrlB, "setErrors");
// Act
const validatorFn = compareInputs(
ValidationGoal.InputsShouldMatch,
"ctrlA",
"ctrlB",
"Custom error message",
);
validatorFn(formGroup);
// Assert
expect(ctrlBSetErrorsSpy).not.toHaveBeenCalled();
});
it("should call setErrors(null) on ctrlB if validation passes and there is a pre-existing error on ctrlB", () => {
// Arrange
const formGroup = new FormGroup({
ctrlA: new FormControl("apple"),
ctrlB: new FormControl("apple"),
});
const ctrlBSetErrorsSpy = jest.spyOn(formGroup.controls.ctrlB, "setErrors");
formGroup.controls.ctrlB.setErrors(validationErrorsObj); // the pre-existing error
// Act
const validatorFn = compareInputs(
ValidationGoal.InputsShouldMatch,
"ctrlA",
"ctrlB",
"Custom error message",
);
validatorFn(formGroup);
// Assert
expect(ctrlBSetErrorsSpy).toHaveBeenCalledWith(null);
});
const cases = [
{
expected: null,
goal: ValidationGoal.InputsShouldMatch,
matchStatus: "match",
values: { ctrlA: "apple", ctrlB: "apple" },
},
{
expected: "a ValidationErrors object",
goal: ValidationGoal.InputsShouldMatch,
matchStatus: "do not match",
values: { ctrlA: "apple", ctrlB: "banana" },
},
{
expected: null,
goal: ValidationGoal.InputsShouldNotMatch,
matchStatus: "do not match",
values: { ctrlA: "apple", ctrlB: "banana" },
},
{
expected: "a ValidationErrors object",
goal: ValidationGoal.InputsShouldNotMatch,
matchStatus: "match",
values: { ctrlA: "apple", ctrlB: "apple" },
},
];
cases.forEach(({ goal, expected, matchStatus, values }) => {
const goalString =
goal === ValidationGoal.InputsShouldMatch ? "InputsShouldMatch" : "InputsShouldNotMatch";
it(`should return ${expected} if the goal is ${goalString} and the inputs ${matchStatus}`, () => {
// Arrange
const formGroup = new FormGroup({
ctrlA: new FormControl(values.ctrlA),
ctrlB: new FormControl(values.ctrlB),
});
// Act
const validatorFn = compareInputs(goal, "ctrlA", "ctrlB", "Custom error message");
const result = validatorFn(formGroup);
// Assert
expect(result).toEqual(expected === null ? null : validationErrorsObj);
});
});
});

View File

@@ -0,0 +1,136 @@
import { AbstractControl, FormGroup, ValidationErrors, ValidatorFn } from "@angular/forms";
export enum ValidationGoal {
InputsShouldMatch,
InputsShouldNotMatch,
}
/**
* A cross-field validator that evaluates whether two form controls do or do
* not have the same input value (except for empty string values). This validator
* gets added to the entire FormGroup, not to an individual FormControl, like so:
*
* > ```
* > formGroup = new FormGroup({
* > password: new FormControl(),
* > confirmPassword: new FormControl(),
* > },
* > {
* > validators: compareInputs(...),
* > });
* > ```
*
* Notes:
* - Validation is controlled from either form control.
* - The error message is displayed under controlB by default, but can be set to controlA.
* - For more info on custom validators and cross-field validation:
* - https://v18.angular.dev/guide/forms/form-validation#defining-custom-validators
* - https://v18.angular.dev/guide/forms/form-validation#cross-field-validation
*
* @param validationGoal Whether you want to verify that the form controls do or do not have matching input values.
* @param controlNameA The name of the first form control to compare.
* @param controlNameB The name of the second form control to compare.
* @param errorMessage The error message to display if there is an error. This will probably
* be an i18n translated string.
* @param showErrorOn The control under which you want to display the error (default is controlB).
*
* @returns A validator function that can be used on a FormGroup.
*/
export function compareInputs(
validationGoal: ValidationGoal,
controlNameA: string,
controlNameB: string,
errorMessage: string,
showErrorOn: "controlA" | "controlB" = "controlB",
): ValidatorFn {
/**
* Documentation for the inner ValidatorFn that gets returned:
*
* @param formGroup The AbstractControl that we want to perform validation on. In this case we
* perform validation on the FormGroup, which is a subclass of AbstractControl.
* The reason we validate at the FormGroup level and not at the FormControl level
* is because we want to compare two child FormControls in a single validator, so
* we use the FormGroup as the common ancestor.
*
* @returns A ValidationErrors object if the validation fails, or null if the validation passes.
*/
return (formGroup: AbstractControl): ValidationErrors | null => {
if (!(formGroup instanceof FormGroup)) {
throw new Error("compareInputs only supports validation at the FormGroup level");
}
const controlA = formGroup.get(controlNameA);
const controlB = formGroup.get(controlNameB);
if (!controlA || !controlB) {
throw new Error(
"[compareInputs validator] one or both of the specified controls could not be found in the form group",
);
}
const controlThatShowsError = showErrorOn === "controlA" ? controlA : controlB;
// Don't compare empty strings
if (controlA.value === "" && controlB.value === "") {
return pass();
}
const controlValuesMatch = controlA.value === controlB.value;
if (validationGoal === ValidationGoal.InputsShouldMatch) {
if (controlValuesMatch) {
return pass();
} else {
return fail();
}
}
if (validationGoal === ValidationGoal.InputsShouldNotMatch) {
if (!controlValuesMatch) {
return pass();
} else {
return fail();
}
}
return null; // default return
function fail() {
controlThatShowsError.setErrors({
// Preserve any pre-existing errors
...(controlThatShowsError.errors || {}),
// Add new compareInputsError
compareInputsError: {
message: errorMessage,
},
});
return {
compareInputsError: {
message: errorMessage,
},
};
}
function pass(): null {
// Get the current errors object
const errorsObj = controlThatShowsError?.errors;
if (errorsObj != null) {
// Remove any compareInputsError if it exists, since that is the sole error we are targeting with this validator
if (errorsObj?.compareInputsError) {
delete errorsObj.compareInputsError;
}
// Check if the errorsObj is now empty
const isEmptyObj = Object.keys(errorsObj).length === 0;
// If the errorsObj is empty, set errors to null, otherwise set the errors to an object of pre-existing errors (other than compareInputsError)
controlThatShowsError.setErrors(isEmptyObj ? null : errorsObj);
}
// Return null for this validator
return null;
}
};
}

View File

@@ -2,25 +2,32 @@ import { ComponentFixture, TestBed } from "@angular/core/testing";
import { BehaviorSubject } from "rxjs";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import {
VaultTimeoutSettingsService,
VaultTimeoutStringType,
} from "@bitwarden/common/key-management/vault-timeout";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { mockAccountServiceWith } from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
import { VaultTimeoutInputComponent } from "./vault-timeout-input.component";
describe("VaultTimeoutInputComponent", () => {
let component: VaultTimeoutInputComponent;
let fixture: ComponentFixture<VaultTimeoutInputComponent>;
const get$ = jest.fn().mockReturnValue(new BehaviorSubject({}));
const policiesByType$ = jest.fn().mockReturnValue(new BehaviorSubject({}));
const availableVaultTimeoutActions$ = jest.fn().mockReturnValue(new BehaviorSubject([]));
const mockUserId = Utils.newGuid() as UserId;
const accountService = mockAccountServiceWith(mockUserId);
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [VaultTimeoutInputComponent],
providers: [
{ provide: PolicyService, useValue: { get$ } },
{ provide: PolicyService, useValue: { policiesByType$ } },
{ provide: AccountService, useValue: accountService },
{ provide: VaultTimeoutSettingsService, useValue: { availableVaultTimeoutActions$ } },
{ provide: I18nService, useValue: { t: (key: string) => key } },
],

View File

@@ -14,12 +14,15 @@ import {
ValidationErrors,
Validator,
} from "@angular/forms";
import { filter, map, Observable, Subject, takeUntil } from "rxjs";
import { filter, map, Observable, Subject, switchMap, takeUntil } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { getFirstPolicy } from "@bitwarden/common/admin-console/services/policy/default-policy.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import {
VaultTimeout,
VaultTimeoutAction,
@@ -123,12 +126,17 @@ export class VaultTimeoutInputComponent
private policyService: PolicyService,
private vaultTimeoutSettingsService: VaultTimeoutSettingsService,
private i18nService: I18nService,
private accountService: AccountService,
) {}
async ngOnInit() {
this.policyService
.get$(PolicyType.MaximumVaultTimeout)
this.accountService.activeAccount$
.pipe(
getUserId,
switchMap((userId) =>
this.policyService.policiesByType$(PolicyType.MaximumVaultTimeout, userId),
),
getFirstPolicy,
filter((policy) => policy != null),
takeUntil(this.destroy$),
)
@@ -136,7 +144,6 @@ export class VaultTimeoutInputComponent
this.vaultTimeoutPolicy = policy;
this.applyVaultTimeoutPolicy();
});
this.form.valueChanges
.pipe(takeUntil(this.destroy$))
.subscribe((value: VaultTimeoutFormValue) => {

View File

@@ -1,43 +1,34 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Observable } from "rxjs";
export abstract class LoginEmailServiceAbstraction {
/**
* An observable that monitors the loginEmail in memory.
* An observable that monitors the loginEmail.
* The loginEmail is the email that is being used in the current login process.
*/
loginEmail$: Observable<string | null>;
abstract loginEmail$: Observable<string | null>;
/**
* An observable that monitors the storedEmail on disk.
* An observable that monitors the remembered email.
* This will return null if an account is being added.
*/
storedEmail$: Observable<string | null>;
abstract rememberedEmail$: Observable<string | null>;
/**
* Sets the loginEmail in memory.
* The loginEmail is the email that is being used in the current login process.
* Consumed through `loginEmail$` observable.
*/
setLoginEmail: (email: string) => Promise<void>;
abstract setLoginEmail: (email: string) => Promise<void>;
/**
* Gets from memory whether or not the email should be stored on disk when `saveEmailSettings` is called.
* @returns A boolean stating whether or not the email should be stored on disk.
* Persist the user's choice of whether to remember their email on subsequent login attempts.
* Consumed through `rememberedEmail$` observable.
*/
getRememberEmail: () => boolean;
abstract setRememberedEmailChoice: (email: string, remember: boolean) => Promise<void>;
/**
* Sets in memory whether or not the email should be stored on disk when `saveEmailSettings` is called.
* Clears the in-progress login email, to be used after a successful login.
*/
setRememberEmail: (value: boolean) => void;
abstract clearLoginEmail: () => Promise<void>;
/**
* Sets the email and rememberEmail properties in memory to null.
* Clears the remembered email.
*/
clearValues: () => void;
/**
* Saves or clears the email on disk
* - If an account is being added, only changes the stored email when rememberEmail is true.
* - If rememberEmail is true, sets the email on disk to the current email.
* - If rememberEmail is false, sets the email on disk to null.
* Always clears the email and rememberEmail properties from memory.
* @returns A promise that resolves once the email settings are saved.
*/
saveEmailSettings: () => Promise<void>;
abstract clearRememberedEmail: () => Promise<void>;
}

View File

@@ -1,4 +1,4 @@
import { EncString, EncryptedString } from "@bitwarden/common/platform/models/domain/enc-string";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { UserId } from "@bitwarden/common/types/guid";
import { PinKey, UserKey } from "@bitwarden/common/types/key";
import { KdfConfig } from "@bitwarden/key-management";
@@ -90,17 +90,6 @@ export abstract class PinServiceAbstraction {
*/
abstract clearUserKeyEncryptedPin(userId: UserId): Promise<void>;
/**
* Gets the old MasterKey, encrypted by the PinKey (formerly called `pinProtected`).
* Deprecated and used for migration purposes only.
*/
abstract getOldPinKeyEncryptedMasterKey: (userId: UserId) => Promise<EncryptedString | null>;
/**
* Clears the old MasterKey, encrypted by the PinKey.
*/
abstract clearOldPinKeyEncryptedMasterKey: (userId: UserId) => Promise<void>;
/**
* Makes a PinKey from the provided PIN.
*/

View File

@@ -231,7 +231,9 @@ describe("WebAuthnLoginStrategy", () => {
const mockUserKey = new SymmetricCryptoKey(mockUserKeyArray) as UserKey;
encryptService.decryptToBytes.mockResolvedValue(mockPrfPrivateKey);
encryptService.rsaDecrypt.mockResolvedValue(mockUserKeyArray);
encryptService.decapsulateKeyUnsigned.mockResolvedValue(
new SymmetricCryptoKey(mockUserKeyArray),
);
// Act
await webAuthnLoginStrategy.logIn(webAuthnCredentials);
@@ -249,8 +251,8 @@ describe("WebAuthnLoginStrategy", () => {
idTokenResponse.userDecryptionOptions.webAuthnPrfOption.encryptedPrivateKey,
webAuthnCredentials.prfKey,
);
expect(encryptService.rsaDecrypt).toHaveBeenCalledTimes(1);
expect(encryptService.rsaDecrypt).toHaveBeenCalledWith(
expect(encryptService.decapsulateKeyUnsigned).toHaveBeenCalledTimes(1);
expect(encryptService.decapsulateKeyUnsigned).toHaveBeenCalledWith(
idTokenResponse.userDecryptionOptions.webAuthnPrfOption.encryptedUserKey,
mockPrfPrivateKey,
);
@@ -278,7 +280,7 @@ describe("WebAuthnLoginStrategy", () => {
// Assert
expect(encryptService.decryptToBytes).not.toHaveBeenCalled();
expect(encryptService.rsaDecrypt).not.toHaveBeenCalled();
expect(encryptService.decapsulateKeyUnsigned).not.toHaveBeenCalled();
expect(keyService.setUserKey).not.toHaveBeenCalled();
});
@@ -330,7 +332,7 @@ describe("WebAuthnLoginStrategy", () => {
apiService.postIdentityToken.mockResolvedValue(idTokenResponse);
encryptService.rsaDecrypt.mockResolvedValue(null);
encryptService.decapsulateKeyUnsigned.mockResolvedValue(null);
// Act
await webAuthnLoginStrategy.logIn(webAuthnCredentials);

View File

@@ -7,7 +7,6 @@ import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
import { WebAuthnLoginTokenRequest } from "@bitwarden/common/auth/models/request/identity-token/webauthn-login-token.request";
import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { UserId } from "@bitwarden/common/types/guid";
import { UserKey } from "@bitwarden/common/types/key";
@@ -89,13 +88,13 @@ export class WebAuthnLoginStrategy extends LoginStrategy {
);
// decrypt user key with private key
const userKey = await this.encryptService.rsaDecrypt(
const userKey = await this.encryptService.decapsulateKeyUnsigned(
new EncString(webAuthnPrfOption.encryptedUserKey.encryptedString),
privateKey,
);
if (userKey) {
await this.keyService.setUserKey(new SymmetricCryptoKey(userKey) as UserKey, userId);
await this.keyService.setUserKey(userKey as UserKey, userId);
}
}
}

View File

@@ -88,6 +88,9 @@ describe("AuthRequestService", () => {
encryptService.rsaEncrypt.mockResolvedValue({
encryptedString: "ENCRYPTED_STRING",
} as EncString);
encryptService.encapsulateKeyUnsigned.mockResolvedValue({
encryptedString: "ENCRYPTED_STRING",
} as EncString);
appIdService.getAppId.mockResolvedValue("APP_ID");
});
it("should throw if auth request is missing id or key", async () => {
@@ -111,7 +114,10 @@ describe("AuthRequestService", () => {
new AuthRequestResponse({ id: "123", publicKey: "KEY" }),
);
expect(encryptService.rsaEncrypt).toHaveBeenCalledWith(new Uint8Array(64), expect.anything());
expect(encryptService.encapsulateKeyUnsigned).toHaveBeenCalledWith(
{ encKey: new Uint8Array(64) },
expect.anything(),
);
});
it("should use the user key if the master key and hash do not exist", async () => {
@@ -122,7 +128,10 @@ describe("AuthRequestService", () => {
new AuthRequestResponse({ id: "123", publicKey: "KEY" }),
);
expect(encryptService.rsaEncrypt).toHaveBeenCalledWith(new Uint8Array(64), expect.anything());
expect(encryptService.encapsulateKeyUnsigned).toHaveBeenCalledWith(
{ key: new Uint8Array(64) },
expect.anything(),
);
});
});
describe("setUserKeyAfterDecryptingSharedUserKey", () => {
@@ -214,7 +223,9 @@ describe("AuthRequestService", () => {
const mockDecryptedUserKeyBytes = new Uint8Array(64);
const mockDecryptedUserKey = new SymmetricCryptoKey(mockDecryptedUserKeyBytes) as UserKey;
encryptService.rsaDecrypt.mockResolvedValueOnce(mockDecryptedUserKeyBytes);
encryptService.decapsulateKeyUnsigned.mockResolvedValueOnce(
new SymmetricCryptoKey(mockDecryptedUserKeyBytes),
);
// Act
const result = await sut.decryptPubKeyEncryptedUserKey(
@@ -223,7 +234,7 @@ describe("AuthRequestService", () => {
);
// Assert
expect(encryptService.rsaDecrypt).toBeCalledWith(
expect(encryptService.decapsulateKeyUnsigned).toBeCalledWith(
new EncString(mockPubKeyEncryptedUserKey),
mockPrivateKey,
);
@@ -244,9 +255,10 @@ describe("AuthRequestService", () => {
const mockDecryptedMasterKeyHashBytes = new Uint8Array(64);
const mockDecryptedMasterKeyHash = Utils.fromBufferToUtf8(mockDecryptedMasterKeyHashBytes);
encryptService.rsaDecrypt
.mockResolvedValueOnce(mockDecryptedMasterKeyBytes)
.mockResolvedValueOnce(mockDecryptedMasterKeyHashBytes);
encryptService.rsaDecrypt.mockResolvedValueOnce(mockDecryptedMasterKeyHashBytes);
encryptService.decapsulateKeyUnsigned.mockResolvedValueOnce(
new SymmetricCryptoKey(mockDecryptedMasterKeyBytes),
);
// Act
const result = await sut.decryptPubKeyEncryptedMasterKeyAndHash(
@@ -256,13 +268,11 @@ describe("AuthRequestService", () => {
);
// Assert
expect(encryptService.rsaDecrypt).toHaveBeenNthCalledWith(
1,
expect(encryptService.decapsulateKeyUnsigned).toHaveBeenCalledWith(
new EncString(mockPubKeyEncryptedMasterKey),
mockPrivateKey,
);
expect(encryptService.rsaDecrypt).toHaveBeenNthCalledWith(
2,
expect(encryptService.rsaDecrypt).toHaveBeenCalledWith(
new EncString(mockPubKeyEncryptedMasterKeyHash),
mockPrivateKey,
);

View File

@@ -14,7 +14,6 @@ import { AuthRequestPushNotification } from "@bitwarden/common/models/response/n
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.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 {
AUTH_REQUEST_DISK_LOCAL,
StateProvider,
@@ -116,13 +115,12 @@ export class AuthRequestService implements AuthRequestServiceAbstraction {
Utils.fromUtf8ToArray(masterKeyHash),
pubKey,
);
keyToEncrypt = masterKey.encKey;
keyToEncrypt = masterKey;
} else {
const userKey = await this.keyService.getUserKey();
keyToEncrypt = userKey.key;
keyToEncrypt = await this.keyService.getUserKey();
}
const encryptedKey = await this.encryptService.rsaEncrypt(keyToEncrypt, pubKey);
const encryptedKey = await this.encryptService.encapsulateKeyUnsigned(keyToEncrypt, pubKey);
const response = new PasswordlessAuthRequest(
encryptedKey.encryptedString,
@@ -171,12 +169,10 @@ export class AuthRequestService implements AuthRequestServiceAbstraction {
pubKeyEncryptedUserKey: string,
privateKey: Uint8Array,
): Promise<UserKey> {
const decryptedUserKeyBytes = await this.encryptService.rsaDecrypt(
return (await this.encryptService.decapsulateKeyUnsigned(
new EncString(pubKeyEncryptedUserKey),
privateKey,
);
return new SymmetricCryptoKey(decryptedUserKeyBytes) as UserKey;
)) as UserKey;
}
async decryptPubKeyEncryptedMasterKeyAndHash(
@@ -184,17 +180,15 @@ export class AuthRequestService implements AuthRequestServiceAbstraction {
pubKeyEncryptedMasterKeyHash: string,
privateKey: Uint8Array,
): Promise<{ masterKey: MasterKey; masterKeyHash: string }> {
const decryptedMasterKeyArrayBuffer = await this.encryptService.rsaDecrypt(
const masterKey = (await this.encryptService.decapsulateKeyUnsigned(
new EncString(pubKeyEncryptedMasterKey),
privateKey,
);
)) as MasterKey;
const decryptedMasterKeyHashArrayBuffer = await this.encryptService.rsaDecrypt(
new EncString(pubKeyEncryptedMasterKeyHash),
privateKey,
);
const masterKey = new SymmetricCryptoKey(decryptedMasterKeyArrayBuffer) as MasterKey;
const masterKeyHash = Utils.fromBufferToUtf8(decryptedMasterKeyHashArrayBuffer);
return {

View File

@@ -2,11 +2,9 @@ import { signal } from "@angular/core";
import { TestBed } from "@angular/core/testing";
import { ViewCacheService } from "@bitwarden/angular/platform/abstractions/view-cache.service";
import { AuthRequestType } from "@bitwarden/common/auth/enums/auth-request-type";
import { AuthRequest } from "@bitwarden/common/auth/models/request/auth.request";
import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response";
import { LoginViaAuthRequestView } from "@bitwarden/common/auth/models/view/login-via-auth-request.view";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { LoginViaAuthRequestCacheService } from "./default-login-via-auth-request-cache.service";
@@ -39,12 +37,12 @@ describe("LoginViaAuthRequestCache", () => {
});
it("`getCachedLoginViaAuthRequestView` returns the cached data", async () => {
cacheSignal.set({ ...buildAuthenticMockAuthView() });
cacheSignal.set({ ...buildMockState() });
service = testBed.inject(LoginViaAuthRequestCacheService);
await service.init();
expect(service.getCachedLoginViaAuthRequestView()).toEqual({
...buildAuthenticMockAuthView(),
...buildMockState(),
});
});
@@ -54,20 +52,19 @@ describe("LoginViaAuthRequestCache", () => {
const parameters = buildAuthenticMockAuthView();
service.cacheLoginView(
parameters.authRequest,
parameters.authRequestResponse,
parameters.fingerprintPhrase,
{ publicKey: new Uint8Array(), privateKey: new Uint8Array() },
);
service.cacheLoginView(parameters.id, parameters.privateKey, parameters.accessCode);
expect(cacheSignal.set).toHaveBeenCalledWith(parameters);
expect(cacheSignal.set).toHaveBeenCalledWith({
id: parameters.id,
privateKey: Utils.fromBufferToB64(parameters.privateKey),
accessCode: parameters.accessCode,
});
});
});
describe("feature disabled", () => {
beforeEach(async () => {
cacheSignal.set({ ...buildAuthenticMockAuthView() } as LoginViaAuthRequestView);
cacheSignal.set({ ...buildMockState() } as LoginViaAuthRequestView);
getFeatureFlag.mockResolvedValue(false);
cacheSetMock.mockClear();
@@ -82,12 +79,7 @@ describe("LoginViaAuthRequestCache", () => {
it("does not update the signal value", () => {
const params = buildAuthenticMockAuthView();
service.cacheLoginView(
params.authRequest,
params.authRequestResponse,
params.fingerprintPhrase,
{ publicKey: new Uint8Array(), privateKey: new Uint8Array() },
);
service.cacheLoginView(params.id, params.privateKey, params.accessCode);
expect(cacheSignal.set).not.toHaveBeenCalled();
});
@@ -95,17 +87,17 @@ describe("LoginViaAuthRequestCache", () => {
const buildAuthenticMockAuthView = () => {
return {
fingerprintPhrase: "",
privateKey: "",
publicKey: "",
authRequest: new AuthRequest(
"test@gmail.com",
"deviceIdentifier",
"publicKey",
AuthRequestType.Unlock,
"accessCode",
),
authRequestResponse: new AuthRequestResponse({}),
id: "testId",
privateKey: new Uint8Array(),
accessCode: "testAccessCode",
};
};
const buildMockState = () => {
return {
id: "testId",
privateKey: Utils.fromBufferToB64(new Uint8Array()),
accessCode: "testAccessCode",
};
};
});

View File

@@ -1,8 +1,6 @@
import { inject, Injectable, WritableSignal } from "@angular/core";
import { ViewCacheService } from "@bitwarden/angular/platform/abstractions/view-cache.service";
import { AuthRequest } from "@bitwarden/common/auth/models/request/auth.request";
import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response";
import { LoginViaAuthRequestView } from "@bitwarden/common/auth/models/view/login-via-auth-request.view";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
@@ -45,12 +43,7 @@ export class LoginViaAuthRequestCacheService {
/**
* Update the cache with the new LoginView.
*/
cacheLoginView(
authRequest: AuthRequest,
authRequestResponse: AuthRequestResponse,
fingerprintPhrase: string,
keys: { privateKey: Uint8Array | undefined; publicKey: Uint8Array | undefined },
): void {
cacheLoginView(id: string, privateKey: Uint8Array, accessCode: string): void {
if (!this.featureEnabled) {
return;
}
@@ -59,11 +52,9 @@ export class LoginViaAuthRequestCacheService {
// data can be properly formed when json-ified. If not done, they are not stored properly and
// will not be parsable by the cryptography library after coming out of storage.
this.defaultLoginViaAuthRequestCache.set({
authRequest,
authRequestResponse,
fingerprintPhrase,
privateKey: keys.privateKey ? Utils.fromBufferToB64(keys.privateKey.buffer) : undefined,
publicKey: keys.publicKey ? Utils.fromBufferToB64(keys.publicKey.buffer) : undefined,
id: id,
privateKey: Utils.fromBufferToB64(privateKey.buffer),
accessCode: accessCode,
} as LoginViaAuthRequestView);
}

View File

@@ -14,7 +14,7 @@ import { UserId } from "@bitwarden/common/types/guid";
import { LoginEmailService, STORED_EMAIL } from "./login-email.service";
describe("LoginEmailService", () => {
let sut: LoginEmailService;
let service: LoginEmailService;
let accountService: FakeAccountService;
let authService: MockProxy<AuthService>;
@@ -34,119 +34,93 @@ describe("LoginEmailService", () => {
mockAuthStatuses$ = new BehaviorSubject<Record<UserId, AuthenticationStatus>>({});
authService.authStatuses$ = mockAuthStatuses$;
sut = new LoginEmailService(accountService, authService, stateProvider);
service = new LoginEmailService(accountService, authService, stateProvider);
});
afterEach(() => {
jest.clearAllMocks();
});
describe("storedEmail$", () => {
it("returns the stored email when not adding an account", async () => {
await sut.setLoginEmail("userEmail@bitwarden.com");
sut.setRememberEmail(true);
await sut.saveEmailSettings();
describe("rememberedEmail$", () => {
it("returns the remembered email when not adding an account", async () => {
const testEmail = "test@bitwarden.com";
const result = await firstValueFrom(sut.storedEmail$);
await service.setRememberedEmailChoice(testEmail, true);
expect(result).toEqual("userEmail@bitwarden.com");
const result = await firstValueFrom(service.rememberedEmail$);
expect(result).toEqual(testEmail);
});
it("returns the stored email when not adding an account and the user has just logged in", async () => {
await sut.setLoginEmail("userEmail@bitwarden.com");
sut.setRememberEmail(true);
await sut.saveEmailSettings();
it("returns the remembered email when not adding an account and the user has just logged in", async () => {
const testEmail = "test@bitwarden.com";
await service.setRememberedEmailChoice(testEmail, true);
mockAuthStatuses$.next({ [userId]: AuthenticationStatus.Unlocked });
// account service already initialized with userId as active user
const result = await firstValueFrom(sut.storedEmail$);
const result = await firstValueFrom(service.rememberedEmail$);
expect(result).toEqual("userEmail@bitwarden.com");
expect(result).toEqual(testEmail);
});
it("returns null when adding an account", async () => {
await sut.setLoginEmail("userEmail@bitwarden.com");
sut.setRememberEmail(true);
await sut.saveEmailSettings();
const testEmail = "test@bitwarden.com";
await service.setRememberedEmailChoice(testEmail, true);
mockAuthStatuses$.next({
[userId]: AuthenticationStatus.Unlocked,
["OtherUserId" as UserId]: AuthenticationStatus.Locked,
});
const result = await firstValueFrom(sut.storedEmail$);
const result = await firstValueFrom(service.rememberedEmail$);
expect(result).toBeNull();
});
});
describe("saveEmailSettings", () => {
it("saves the email when not adding an account", async () => {
await sut.setLoginEmail("userEmail@bitwarden.com");
sut.setRememberEmail(true);
await sut.saveEmailSettings();
describe("setRememberedEmailChoice", () => {
it("sets the remembered email when remember is true", async () => {
const testEmail = "test@bitwarden.com";
await service.setRememberedEmailChoice(testEmail, true);
const result = await firstValueFrom(storedEmailState.state$);
expect(result).toEqual("userEmail@bitwarden.com");
expect(result).toEqual(testEmail);
});
it("clears the email when not adding an account and rememberEmail is false", async () => {
it("clears the remembered email when remember is false", async () => {
storedEmailState.stateSubject.next("initialEmail@bitwarden.com");
await sut.setLoginEmail("userEmail@bitwarden.com");
sut.setRememberEmail(false);
await sut.saveEmailSettings();
const testEmail = "test@bitwarden.com";
await service.setRememberedEmailChoice(testEmail, false);
const result = await firstValueFrom(storedEmailState.state$);
expect(result).toBeNull();
});
});
it("saves the email when adding an account", async () => {
mockAuthStatuses$.next({
[userId]: AuthenticationStatus.Unlocked,
["OtherUserId" as UserId]: AuthenticationStatus.Locked,
});
describe("setLoginEmail", () => {
it("sets the login email", async () => {
const testEmail = "test@bitwarden.com";
await service.setLoginEmail(testEmail);
await sut.setLoginEmail("userEmail@bitwarden.com");
sut.setRememberEmail(true);
await sut.saveEmailSettings();
const result = await firstValueFrom(storedEmailState.state$);
expect(result).toEqual("userEmail@bitwarden.com");
expect(await firstValueFrom(service.loginEmail$)).toEqual(testEmail);
});
});
it("does not clear the email when adding an account and rememberEmail is false", async () => {
storedEmailState.stateSubject.next("initialEmail@bitwarden.com");
describe("clearLoginEmail", () => {
it("clears the login email", async () => {
const testEmail = "test@bitwarden.com";
await service.setLoginEmail(testEmail);
await service.clearLoginEmail();
mockAuthStatuses$.next({
[userId]: AuthenticationStatus.Unlocked,
["OtherUserId" as UserId]: AuthenticationStatus.Locked,
});
await sut.setLoginEmail("userEmail@bitwarden.com");
sut.setRememberEmail(false);
await sut.saveEmailSettings();
const result = await firstValueFrom(storedEmailState.state$);
// result should not be null
expect(result).toEqual("initialEmail@bitwarden.com");
});
it("does not clear the email and rememberEmail after saving", async () => {
// Browser uses these values to maintain the email between login and 2fa components so
// we do not want to clear them too early.
await sut.setLoginEmail("userEmail@bitwarden.com");
sut.setRememberEmail(true);
await sut.saveEmailSettings();
const result = await firstValueFrom(sut.loginEmail$);
expect(result).toBe("userEmail@bitwarden.com");
expect(await firstValueFrom(service.loginEmail$)).toBeNull();
});
});
});

View File

@@ -1,5 +1,3 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Observable, firstValueFrom, switchMap } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
@@ -26,8 +24,6 @@ export const STORED_EMAIL = new KeyDefinition<string>(LOGIN_EMAIL_DISK, "storedE
});
export class LoginEmailService implements LoginEmailServiceAbstraction {
private rememberEmail: boolean;
// True if an account is currently being added through account switching
private readonly addingAccount$: Observable<boolean>;
@@ -35,7 +31,7 @@ export class LoginEmailService implements LoginEmailServiceAbstraction {
loginEmail$: Observable<string | null>;
private readonly storedEmailState: GlobalState<string>;
storedEmail$: Observable<string | null>;
rememberedEmail$: Observable<string | null>;
constructor(
private accountService: AccountService,
@@ -60,7 +56,7 @@ export class LoginEmailService implements LoginEmailServiceAbstraction {
this.loginEmail$ = this.loginEmailState.state$;
this.storedEmail$ = this.storedEmailState.state$.pipe(
this.rememberedEmail$ = this.storedEmailState.state$.pipe(
switchMap(async (storedEmail) => {
// When adding an account, we don't show the stored email
if (await firstValueFrom(this.addingAccount$)) {
@@ -71,44 +67,32 @@ export class LoginEmailService implements LoginEmailServiceAbstraction {
);
}
/** Sets the login email in memory.
* The login email is the email that is being used in the current login process.
*/
async setLoginEmail(email: string) {
await this.loginEmailState.update((_) => email);
}
getRememberEmail() {
return this.rememberEmail;
/**
* Clears the in-progress login email from state.
* Note: Only clear on successful login or you are sure they are not needed.
* The extension client uses these values to maintain the email between login and 2fa components so
* we do not want to clear them too early.
*/
async clearLoginEmail() {
await this.loginEmailState.update((_) => null);
}
setRememberEmail(value: boolean) {
this.rememberEmail = value ?? false;
async setRememberedEmailChoice(email: string, remember: boolean): Promise<void> {
if (remember) {
await this.storedEmailState.update((_) => email);
} else {
await this.storedEmailState.update((_) => null);
}
}
// Note: only clear values on successful login or you are sure they are not needed.
// Browser uses these values to maintain the email between login and 2fa components so
// we do not want to clear them too early.
async clearValues() {
await this.setLoginEmail(null);
this.rememberEmail = false;
}
async saveEmailSettings() {
const addingAccount = await firstValueFrom(this.addingAccount$);
const email = await firstValueFrom(this.loginEmail$);
await this.storedEmailState.update((storedEmail) => {
// If we're adding an account, only overwrite the stored email when rememberEmail is true
if (addingAccount) {
if (this.rememberEmail) {
return email;
}
return storedEmail;
}
// Saving with rememberEmail set to false will clear the stored email
if (this.rememberEmail) {
return email;
}
return null;
});
async clearRememberedEmail(): Promise<void> {
await this.storedEmailState.update((_) => null);
}
}

View File

@@ -3,14 +3,17 @@ import { UserId } from "@bitwarden/common/types/guid";
import { UserAsymmetricKeysRegenerationService } from "@bitwarden/key-management";
import { LoginSuccessHandlerService } from "../../abstractions/login-success-handler.service";
import { LoginEmailService } from "../login-email/login-email.service";
export class DefaultLoginSuccessHandlerService implements LoginSuccessHandlerService {
constructor(
private syncService: SyncService,
private userAsymmetricKeysRegenerationService: UserAsymmetricKeysRegenerationService,
private loginEmailService: LoginEmailService,
) {}
async run(userId: UserId): Promise<void> {
await this.syncService.fullSync(true);
await this.userAsymmetricKeysRegenerationService.regenerateIfNeeded(userId);
await this.loginEmailService.clearLoginEmail();
}
}

View File

@@ -3,12 +3,10 @@
import { firstValueFrom, map } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
import { KeyGenerationService } from "@bitwarden/common/platform/abstractions/key-generation.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { EncString, EncryptedString } from "@bitwarden/common/platform/models/domain/enc-string";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import {
@@ -18,7 +16,7 @@ import {
UserKeyDefinition,
} from "@bitwarden/common/platform/state";
import { UserId } from "@bitwarden/common/types/guid";
import { MasterKey, PinKey, UserKey } from "@bitwarden/common/types/key";
import { PinKey, UserKey } from "@bitwarden/common/types/key";
import { KdfConfig, KdfConfigService } from "@bitwarden/key-management";
import { PinServiceAbstraction } from "../../abstractions/pin.service.abstraction";
@@ -73,19 +71,6 @@ export const USER_KEY_ENCRYPTED_PIN = new UserKeyDefinition<EncryptedString>(
},
);
/**
* The old MasterKey, encrypted by the PinKey (formerly called `pinProtected`).
* Deprecated and used for migration purposes only.
*/
export const OLD_PIN_KEY_ENCRYPTED_MASTER_KEY = new UserKeyDefinition<EncryptedString>(
PIN_DISK,
"oldPinKeyEncryptedMasterKey",
{
deserializer: (jsonValue) => jsonValue,
clearOn: ["logout"],
},
);
export class PinService implements PinServiceAbstraction {
constructor(
private accountService: AccountService,
@@ -94,9 +79,7 @@ export class PinService implements PinServiceAbstraction {
private kdfConfigService: KdfConfigService,
private keyGenerationService: KeyGenerationService,
private logService: LogService,
private masterPasswordService: MasterPasswordServiceAbstraction,
private stateProvider: StateProvider,
private stateService: StateService,
) {}
async getPinKeyEncryptedUserKeyPersistent(userId: UserId): Promise<EncString | null> {
@@ -190,9 +173,7 @@ export class PinService implements PinServiceAbstraction {
this.accountService.accounts$.pipe(map((accounts) => accounts[userId].email)),
);
const kdfConfig = await this.kdfConfigService.getKdfConfig();
const pinKey = await this.makePinKey(pin, email, kdfConfig);
return await this.encryptService.encrypt(userKey.key, pinKey);
}
@@ -242,45 +223,24 @@ export class PinService implements PinServiceAbstraction {
return await this.encryptService.encrypt(pin, userKey);
}
async getOldPinKeyEncryptedMasterKey(userId: UserId): Promise<EncryptedString | null> {
this.validateUserId(userId, "Cannot get oldPinKeyEncryptedMasterKey.");
return await firstValueFrom(
this.stateProvider.getUserState$(OLD_PIN_KEY_ENCRYPTED_MASTER_KEY, userId),
);
}
async clearOldPinKeyEncryptedMasterKey(userId: UserId): Promise<void> {
this.validateUserId(userId, "Cannot clear oldPinKeyEncryptedMasterKey.");
await this.stateProvider.setUserState(OLD_PIN_KEY_ENCRYPTED_MASTER_KEY, null, userId);
}
async makePinKey(pin: string, salt: string, kdfConfig: KdfConfig): Promise<PinKey> {
const start = Date.now();
const pinKey = await this.keyGenerationService.deriveKeyFromPassword(pin, salt, kdfConfig);
this.logService.info(`[Pin Service] deriving pin key took ${Date.now() - start}ms`);
return (await this.keyGenerationService.stretchKey(pinKey)) as PinKey;
}
async getPinLockType(userId: UserId): Promise<PinLockType> {
this.validateUserId(userId, "Cannot get PinLockType.");
/**
* We can't check the `userKeyEncryptedPin` (formerly called `protectedPin`) for both because old
* accounts only used it for MP on Restart
*/
const aUserKeyEncryptedPinIsSet = !!(await this.getUserKeyEncryptedPin(userId));
const aPinKeyEncryptedUserKeyPersistentIsSet =
!!(await this.getPinKeyEncryptedUserKeyPersistent(userId));
const anOldPinKeyEncryptedMasterKeyIsSet =
!!(await this.getOldPinKeyEncryptedMasterKey(userId));
if (aPinKeyEncryptedUserKeyPersistentIsSet || anOldPinKeyEncryptedMasterKeyIsSet) {
if (aPinKeyEncryptedUserKeyPersistentIsSet) {
return "PERSISTENT";
} else if (
aUserKeyEncryptedPinIsSet &&
!aPinKeyEncryptedUserKeyPersistentIsSet &&
!anOldPinKeyEncryptedMasterKeyIsSet
) {
} else if (aUserKeyEncryptedPinIsSet && !aPinKeyEncryptedUserKeyPersistentIsSet) {
return "EPHEMERAL";
} else {
return "DISABLED";
@@ -302,7 +262,7 @@ export class PinService implements PinServiceAbstraction {
case "DISABLED":
return false;
case "PERSISTENT":
// The above getPinLockType call ensures that we have either a PinKeyEncryptedUserKey or OldPinKeyEncryptedMasterKey set.
// The above getPinLockType call ensures that we have either a PinKeyEncryptedUserKey set.
return true;
case "EPHEMERAL": {
// The above getPinLockType call ensures that we have a UserKeyEncryptedPin set.
@@ -326,31 +286,21 @@ export class PinService implements PinServiceAbstraction {
try {
const pinLockType = await this.getPinLockType(userId);
const requireMasterPasswordOnClientRestart = pinLockType === "EPHEMERAL";
const { pinKeyEncryptedUserKey, oldPinKeyEncryptedMasterKey } =
await this.getPinKeyEncryptedKeys(pinLockType, userId);
const pinKeyEncryptedUserKey = await this.getPinKeyEncryptedKeys(pinLockType, userId);
const email = await firstValueFrom(
this.accountService.accounts$.pipe(map((accounts) => accounts[userId].email)),
);
const kdfConfig = await this.kdfConfigService.getKdfConfig();
let userKey: UserKey;
if (oldPinKeyEncryptedMasterKey) {
userKey = await this.decryptAndMigrateOldPinKeyEncryptedMasterKey(
userId,
pin,
email,
kdfConfig,
requireMasterPasswordOnClientRestart,
oldPinKeyEncryptedMasterKey,
);
} else {
userKey = await this.decryptUserKey(userId, pin, email, kdfConfig, pinKeyEncryptedUserKey);
}
const userKey: UserKey = await this.decryptUserKey(
userId,
pin,
email,
kdfConfig,
pinKeyEncryptedUserKey,
);
if (!userKey) {
this.logService.warning(`User key null after pin key decryption.`);
return null;
@@ -394,109 +344,23 @@ export class PinService implements PinServiceAbstraction {
}
/**
* Creates a new `pinKeyEncryptedUserKey` and clears the `oldPinKeyEncryptedMasterKey`.
* @returns UserKey
*/
private async decryptAndMigrateOldPinKeyEncryptedMasterKey(
userId: UserId,
pin: string,
email: string,
kdfConfig: KdfConfig,
requireMasterPasswordOnClientRestart: boolean,
oldPinKeyEncryptedMasterKey: EncString,
): Promise<UserKey> {
this.validateUserId(userId, "Cannot decrypt and migrate oldPinKeyEncryptedMasterKey.");
const masterKey = await this.decryptMasterKeyWithPin(
userId,
pin,
email,
kdfConfig,
oldPinKeyEncryptedMasterKey,
);
const encUserKey = await this.stateService.getEncryptedCryptoSymmetricKey({ userId: userId });
const userKey = await this.masterPasswordService.decryptUserKeyWithMasterKey(
masterKey,
userId,
encUserKey ? new EncString(encUserKey) : undefined,
);
const pinKeyEncryptedUserKey = await this.createPinKeyEncryptedUserKey(pin, userKey, userId);
await this.storePinKeyEncryptedUserKey(
pinKeyEncryptedUserKey,
requireMasterPasswordOnClientRestart,
userId,
);
const userKeyEncryptedPin = await this.createUserKeyEncryptedPin(pin, userKey);
await this.setUserKeyEncryptedPin(userKeyEncryptedPin, userId);
await this.clearOldPinKeyEncryptedMasterKey(userId);
return userKey;
}
// Only for migration purposes
private async decryptMasterKeyWithPin(
userId: UserId,
pin: string,
salt: string,
kdfConfig: KdfConfig,
oldPinKeyEncryptedMasterKey?: EncString,
): Promise<MasterKey> {
this.validateUserId(userId, "Cannot decrypt master key with PIN.");
if (!oldPinKeyEncryptedMasterKey) {
const oldPinKeyEncryptedMasterKeyString = await this.getOldPinKeyEncryptedMasterKey(userId);
if (oldPinKeyEncryptedMasterKeyString == null) {
throw new Error("No oldPinKeyEncrytedMasterKey found.");
}
oldPinKeyEncryptedMasterKey = new EncString(oldPinKeyEncryptedMasterKeyString);
}
const pinKey = await this.makePinKey(pin, salt, kdfConfig);
const masterKey = await this.encryptService.decryptToBytes(oldPinKeyEncryptedMasterKey, pinKey);
return new SymmetricCryptoKey(masterKey) as MasterKey;
}
/**
* Gets the user's `pinKeyEncryptedUserKey` (persistent or ephemeral) and `oldPinKeyEncryptedMasterKey`
* Gets the user's `pinKeyEncryptedUserKey` (persistent or ephemeral)
* (if one exists) based on the user's PinLockType.
*
* @remarks The `oldPinKeyEncryptedMasterKey` (formerly `pinProtected`) is only used for migration and
* will be null for all migrated accounts.
* @throws If PinLockType is 'DISABLED' or if userId is not provided
*/
private async getPinKeyEncryptedKeys(
pinLockType: PinLockType,
userId: UserId,
): Promise<{ pinKeyEncryptedUserKey: EncString; oldPinKeyEncryptedMasterKey?: EncString }> {
): Promise<EncString> {
this.validateUserId(userId, "Cannot get PinKey encrypted keys.");
switch (pinLockType) {
case "PERSISTENT": {
const pinKeyEncryptedUserKey = await this.getPinKeyEncryptedUserKeyPersistent(userId);
const oldPinKeyEncryptedMasterKey = await this.getOldPinKeyEncryptedMasterKey(userId);
return {
pinKeyEncryptedUserKey,
oldPinKeyEncryptedMasterKey: oldPinKeyEncryptedMasterKey
? new EncString(oldPinKeyEncryptedMasterKey)
: undefined,
};
return await this.getPinKeyEncryptedUserKeyPersistent(userId);
}
case "EPHEMERAL": {
const pinKeyEncryptedUserKey = await this.getPinKeyEncryptedUserKeyEphemeral(userId);
return {
pinKeyEncryptedUserKey,
oldPinKeyEncryptedMasterKey: undefined, // Going forward, we only migrate non-ephemeral version
};
return await this.getPinKeyEncryptedUserKeyEphemeral(userId);
}
case "DISABLED":
throw new Error("Pin is disabled");

View File

@@ -1,11 +1,9 @@
import { mock } from "jest-mock-extended";
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
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 { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
import { KeyGenerationService } from "@bitwarden/common/platform/abstractions/key-generation.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.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";
@@ -15,14 +13,13 @@ import {
mockAccountServiceWith,
} from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
import { MasterKey, PinKey, UserKey } from "@bitwarden/common/types/key";
import { PinKey, UserKey } from "@bitwarden/common/types/key";
import { DEFAULT_KDF_CONFIG, KdfConfigService } from "@bitwarden/key-management";
import {
PinService,
PIN_KEY_ENCRYPTED_USER_KEY_PERSISTENT,
PIN_KEY_ENCRYPTED_USER_KEY_EPHEMERAL,
OLD_PIN_KEY_ENCRYPTED_MASTER_KEY,
USER_KEY_ENCRYPTED_PIN,
PinLockType,
} from "./pin.service.implementation";
@@ -31,7 +28,6 @@ describe("PinService", () => {
let sut: PinService;
let accountService: FakeAccountService;
let masterPasswordService: FakeMasterPasswordService;
let stateProvider: FakeStateProvider;
const cryptoFunctionService = mock<CryptoFunctionService>();
@@ -39,11 +35,9 @@ describe("PinService", () => {
const kdfConfigService = mock<KdfConfigService>();
const keyGenerationService = mock<KeyGenerationService>();
const logService = mock<LogService>();
const stateService = mock<StateService>();
const mockUserId = Utils.newGuid() as UserId;
const mockUserKey = new SymmetricCryptoKey(randomBytes(64)) as UserKey;
const mockMasterKey = new SymmetricCryptoKey(randomBytes(32)) as MasterKey;
const mockPinKey = new SymmetricCryptoKey(randomBytes(32)) as PinKey;
const mockUserEmail = "user@example.com";
const mockPin = "1234";
@@ -57,15 +51,10 @@ describe("PinService", () => {
"2.fb5kOEZvh9zPABbP8WRmSQ==|Yi6ZAJY+UtqCKMUSqp1ahY9Kf8QuneKXs6BMkpNsakLVOzTYkHHlilyGABMF7GzUO8QHyZi7V/Ovjjg+Naf3Sm8qNhxtDhibITv4k8rDnM0=|TFkq3h2VNTT1z5BFbebm37WYuxyEHXuRo0DZJI7TQnw=",
);
const oldPinKeyEncryptedMasterKeyPostMigration: any = null;
const oldPinKeyEncryptedMasterKeyPreMigrationPersistent =
"2.fb5kOEZvh9zPABbP8WRmSQ==|Yi6ZAJY+UtqCKMUSqp1ahY9Kf8QuneKXs6BMkpNsakLVOzTYkHHlilyGABMF7GzUO8QHyZi7V/Ovjjg+Naf3Sm8qNhxtDhibITv4k8rDnM0=|TFkq3h2VNTT1z5BFbebm37WYuxyEHXuRo0DZJI7TQnw=";
beforeEach(() => {
jest.clearAllMocks();
accountService = mockAccountServiceWith(mockUserId, { email: mockUserEmail });
masterPasswordService = new FakeMasterPasswordService();
stateProvider = new FakeStateProvider(accountService);
sut = new PinService(
@@ -75,9 +64,7 @@ describe("PinService", () => {
kdfConfigService,
keyGenerationService,
logService,
masterPasswordService,
stateProvider,
stateService,
);
});
@@ -111,12 +98,6 @@ describe("PinService", () => {
await expect(sut.clearUserKeyEncryptedPin(undefined)).rejects.toThrow(
"User ID is required. Cannot clear userKeyEncryptedPin.",
);
await expect(sut.getOldPinKeyEncryptedMasterKey(undefined)).rejects.toThrow(
"User ID is required. Cannot get oldPinKeyEncryptedMasterKey.",
);
await expect(sut.clearOldPinKeyEncryptedMasterKey(undefined)).rejects.toThrow(
"User ID is required. Cannot clear oldPinKeyEncryptedMasterKey.",
);
await expect(
sut.createPinKeyEncryptedUserKey(mockPin, mockUserKey, undefined),
).rejects.toThrow("User ID is required. Cannot create pinKeyEncryptedUserKey.");
@@ -288,31 +269,6 @@ describe("PinService", () => {
});
});
describe("oldPinKeyEncryptedMasterKey methods", () => {
describe("getOldPinKeyEncryptedMasterKey()", () => {
it("should get the oldPinKeyEncryptedMasterKey of the specified userId", async () => {
await sut.getOldPinKeyEncryptedMasterKey(mockUserId);
expect(stateProvider.mock.getUserState$).toHaveBeenCalledWith(
OLD_PIN_KEY_ENCRYPTED_MASTER_KEY,
mockUserId,
);
});
});
describe("clearOldPinKeyEncryptedMasterKey()", () => {
it("should clear the oldPinKeyEncryptedMasterKey of the specified userId", async () => {
await sut.clearOldPinKeyEncryptedMasterKey(mockUserId);
expect(stateProvider.mock.setUserState).toHaveBeenCalledWith(
OLD_PIN_KEY_ENCRYPTED_MASTER_KEY,
null,
mockUserId,
);
});
});
});
describe("makePinKey()", () => {
it("should make a PinKey", async () => {
// Arrange
@@ -346,26 +302,10 @@ describe("PinService", () => {
expect(result).toBe("PERSISTENT");
});
it("should return 'PERSISTENT' if an old oldPinKeyEncryptedMasterKey is found", async () => {
// Arrange
sut.getUserKeyEncryptedPin = jest.fn().mockResolvedValue(null);
sut.getPinKeyEncryptedUserKeyPersistent = jest.fn().mockResolvedValue(null);
sut.getOldPinKeyEncryptedMasterKey = jest
.fn()
.mockResolvedValue(oldPinKeyEncryptedMasterKeyPreMigrationPersistent);
// Act
const result = await sut.getPinLockType(mockUserId);
// Assert
expect(result).toBe("PERSISTENT");
});
it("should return 'EPHEMERAL' if neither a pinKeyEncryptedUserKey (persistent version) nor an old oldPinKeyEncryptedMasterKey are found, but a userKeyEncryptedPin is found", async () => {
it("should return 'EPHEMERAL' if a pinKeyEncryptedUserKey (persistent version) is not found but a userKeyEncryptedPin is found", async () => {
// Arrange
sut.getUserKeyEncryptedPin = jest.fn().mockResolvedValue(mockUserKeyEncryptedPin);
sut.getPinKeyEncryptedUserKeyPersistent = jest.fn().mockResolvedValue(null);
sut.getOldPinKeyEncryptedMasterKey = jest.fn().mockResolvedValue(null);
// Act
const result = await sut.getPinLockType(mockUserId);
@@ -374,11 +314,10 @@ describe("PinService", () => {
expect(result).toBe("EPHEMERAL");
});
it("should return 'DISABLED' if ALL three of these are NOT found: userKeyEncryptedPin, pinKeyEncryptedUserKey (persistent version), oldPinKeyEncryptedMasterKey", async () => {
it("should return 'DISABLED' if both of these are NOT found: userKeyEncryptedPin, pinKeyEncryptedUserKey (persistent version)", async () => {
// Arrange
sut.getUserKeyEncryptedPin = jest.fn().mockResolvedValue(null);
sut.getPinKeyEncryptedUserKeyPersistent = jest.fn().mockResolvedValue(null);
sut.getOldPinKeyEncryptedMasterKey = jest.fn().mockResolvedValue(null);
// Act
const result = await sut.getPinLockType(mockUserId);
@@ -476,46 +415,20 @@ describe("PinService", () => {
});
describe("decryptUserKeyWithPin()", () => {
async function setupDecryptUserKeyWithPinMocks(
pinLockType: PinLockType,
migrationStatus: "PRE" | "POST" = "POST",
) {
async function setupDecryptUserKeyWithPinMocks(pinLockType: PinLockType) {
sut.getPinLockType = jest.fn().mockResolvedValue(pinLockType);
mockPinEncryptedKeyDataByPinLockType(pinLockType, migrationStatus);
mockPinEncryptedKeyDataByPinLockType(pinLockType);
kdfConfigService.getKdfConfig.mockResolvedValue(DEFAULT_KDF_CONFIG);
if (pinLockType === "PERSISTENT" && migrationStatus === "PRE") {
await mockDecryptAndMigrateOldPinKeyEncryptedMasterKeyFn();
} else {
mockDecryptUserKeyFn();
}
mockDecryptUserKeyFn();
sut.getUserKeyEncryptedPin = jest.fn().mockResolvedValue(mockUserKeyEncryptedPin);
encryptService.decryptToUtf8.mockResolvedValue(mockPin);
cryptoFunctionService.compareFast.calledWith(mockPin, "1234").mockResolvedValue(true);
}
async function mockDecryptAndMigrateOldPinKeyEncryptedMasterKeyFn() {
sut.makePinKey = jest.fn().mockResolvedValue(mockPinKey);
encryptService.decryptToBytes.mockResolvedValue(mockMasterKey.key);
stateService.getEncryptedCryptoSymmetricKey.mockResolvedValue(mockUserKey.keyB64);
masterPasswordService.mock.decryptUserKeyWithMasterKey.mockResolvedValue(mockUserKey);
sut.createPinKeyEncryptedUserKey = jest
.fn()
.mockResolvedValue(pinKeyEncryptedUserKeyPersistant);
await sut.storePinKeyEncryptedUserKey(pinKeyEncryptedUserKeyPersistant, false, mockUserId);
sut.createUserKeyEncryptedPin = jest.fn().mockResolvedValue(mockUserKeyEncryptedPin);
await sut.setUserKeyEncryptedPin(mockUserKeyEncryptedPin, mockUserId);
await sut.clearOldPinKeyEncryptedMasterKey(mockUserId);
}
function mockDecryptUserKeyFn() {
sut.getPinKeyEncryptedUserKeyPersistent = jest
.fn()
@@ -524,26 +437,12 @@ describe("PinService", () => {
encryptService.decryptToBytes.mockResolvedValue(mockUserKey.key);
}
function mockPinEncryptedKeyDataByPinLockType(
pinLockType: PinLockType,
migrationStatus: "PRE" | "POST" = "POST",
) {
function mockPinEncryptedKeyDataByPinLockType(pinLockType: PinLockType) {
switch (pinLockType) {
case "PERSISTENT":
sut.getPinKeyEncryptedUserKeyPersistent = jest
.fn()
.mockResolvedValue(pinKeyEncryptedUserKeyPersistant);
if (migrationStatus === "PRE") {
sut.getOldPinKeyEncryptedMasterKey = jest
.fn()
.mockResolvedValue(oldPinKeyEncryptedMasterKeyPreMigrationPersistent);
} else {
sut.getOldPinKeyEncryptedMasterKey = jest
.fn()
.mockResolvedValue(oldPinKeyEncryptedMasterKeyPostMigration); // null
}
break;
case "EPHEMERAL":
sut.getPinKeyEncryptedUserKeyEphemeral = jest
@@ -557,49 +456,16 @@ describe("PinService", () => {
}
}
const testCases: { pinLockType: PinLockType; migrationStatus: "PRE" | "POST" }[] = [
{ pinLockType: "PERSISTENT", migrationStatus: "PRE" },
{ pinLockType: "PERSISTENT", migrationStatus: "POST" },
{ pinLockType: "EPHEMERAL", migrationStatus: "POST" },
const testCases: { pinLockType: PinLockType }[] = [
{ pinLockType: "PERSISTENT" },
{ pinLockType: "EPHEMERAL" },
];
testCases.forEach(({ pinLockType, migrationStatus }) => {
describe(`given a ${pinLockType} PIN (${migrationStatus} migration)`, () => {
if (pinLockType === "PERSISTENT" && migrationStatus === "PRE") {
it("should clear the oldPinKeyEncryptedMasterKey from state", async () => {
// Arrange
await setupDecryptUserKeyWithPinMocks(pinLockType, migrationStatus);
// Act
await sut.decryptUserKeyWithPin(mockPin, mockUserId);
// Assert
expect(stateProvider.mock.setUserState).toHaveBeenCalledWith(
OLD_PIN_KEY_ENCRYPTED_MASTER_KEY,
null,
mockUserId,
);
});
it("should set the new pinKeyEncrypterUserKeyPersistent to state", async () => {
// Arrange
await setupDecryptUserKeyWithPinMocks(pinLockType, migrationStatus);
// Act
await sut.decryptUserKeyWithPin(mockPin, mockUserId);
// Assert
expect(stateProvider.mock.setUserState).toHaveBeenCalledWith(
PIN_KEY_ENCRYPTED_USER_KEY_PERSISTENT,
pinKeyEncryptedUserKeyPersistant.encryptedString,
mockUserId,
);
});
}
testCases.forEach(({ pinLockType }) => {
describe(`given a ${pinLockType} PIN)`, () => {
it(`should successfully decrypt and return user key when using a valid PIN`, async () => {
// Arrange
await setupDecryptUserKeyWithPinMocks(pinLockType, migrationStatus);
await setupDecryptUserKeyWithPinMocks(pinLockType);
// Act
const result = await sut.decryptUserKeyWithPin(mockPin, mockUserId);
@@ -610,7 +476,7 @@ describe("PinService", () => {
it(`should return null when PIN is incorrect and user key cannot be decrypted`, async () => {
// Arrange
await setupDecryptUserKeyWithPinMocks(pinLockType, migrationStatus);
await setupDecryptUserKeyWithPinMocks(pinLockType);
sut.decryptUserKeyWithPin = jest.fn().mockResolvedValue(null);
// Act
@@ -623,7 +489,7 @@ describe("PinService", () => {
// not sure if this is a realistic scenario but going to test it anyway
it(`should return null when PIN doesn't match after successful user key decryption`, async () => {
// Arrange
await setupDecryptUserKeyWithPinMocks(pinLockType, migrationStatus);
await setupDecryptUserKeyWithPinMocks(pinLockType);
encryptService.decryptToUtf8.mockResolvedValue("9999"); // non matching PIN
// Act

View File

@@ -1,5 +1,3 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { ListResponse } from "../../../models/response/list.response";
import { PolicyType } from "../../enums";
import { MasterPasswordPolicyOptions } from "../../models/domain/master-password-policy-options";
@@ -7,19 +5,23 @@ import { Policy } from "../../models/domain/policy";
import { PolicyRequest } from "../../models/request/policy.request";
import { PolicyResponse } from "../../models/response/policy.response";
export class PolicyApiServiceAbstraction {
getPolicy: (organizationId: string, type: PolicyType) => Promise<PolicyResponse>;
getPolicies: (organizationId: string) => Promise<ListResponse<PolicyResponse>>;
export abstract class PolicyApiServiceAbstraction {
abstract getPolicy: (organizationId: string, type: PolicyType) => Promise<PolicyResponse>;
abstract getPolicies: (organizationId: string) => Promise<ListResponse<PolicyResponse>>;
getPoliciesByToken: (
abstract getPoliciesByToken: (
organizationId: string,
token: string,
email: string,
organizationUserId: string,
) => Promise<Policy[] | undefined>;
getMasterPasswordPolicyOptsForOrgUser: (
abstract getMasterPasswordPolicyOptsForOrgUser: (
orgId: string,
) => Promise<MasterPasswordPolicyOptions | null>;
putPolicy: (organizationId: string, type: PolicyType, request: PolicyRequest) => Promise<any>;
abstract putPolicy: (
organizationId: string,
type: PolicyType,
request: PolicyRequest,
) => Promise<any>;
}

View File

@@ -1,5 +1,3 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Observable } from "rxjs";
import { UserId } from "../../../types/guid";
@@ -11,43 +9,27 @@ import { ResetPasswordPolicyOptions } from "../../models/domain/reset-password-p
export abstract class PolicyService {
/**
* All policies for the active user from sync data.
* All policies for the provided user from sync data.
* May include policies that are disabled or otherwise do not apply to the user. Be careful using this!
* Consider using {@link get$} or {@link getAll$} instead, which will only return policies that should be enforced against the user.
* Consider {@link policiesByType$} instead, which will only return policies that should be enforced against the user.
*/
policies$: Observable<Policy[]>;
abstract policies$: (userId: UserId) => Observable<Policy[]>;
/**
* @returns the first {@link Policy} found that applies to the active user.
* @returns all {@link Policy} objects of a given type that apply to the specified user.
* A policy "applies" if it is enabled and the user is not exempt (e.g. because they are an Owner).
* @param policyType the {@link PolicyType} to search for
* @see {@link getAll$} if you need all policies of a given type
* @param userId the {@link UserId} to search against
*/
get$: (policyType: PolicyType) => Observable<Policy>;
abstract policiesByType$: (policyType: PolicyType, userId: UserId) => Observable<Policy[]>;
/**
* @returns all {@link Policy} objects of a given type that apply to the specified user (or the active user if not specified).
* @returns true if a policy of the specified type applies to the specified user, otherwise false.
* A policy "applies" if it is enabled and the user is not exempt (e.g. because they are an Owner).
* @param policyType the {@link PolicyType} to search for
*/
getAll$: (policyType: PolicyType, userId: UserId) => Observable<Policy[]>;
/**
* All {@link Policy} objects for the specified user (from sync data).
* May include policies that are disabled or otherwise do not apply to the user.
* Consider using {@link getAll$} instead, which will only return policies that should be enforced against the user.
*/
getAll: (policyType: PolicyType) => Promise<Policy[]>;
/**
* @returns true if a policy of the specified type applies to the active user, otherwise false.
* A policy "applies" if it is enabled and the user is not exempt (e.g. because they are an Owner).
* This does not take into account the policy's configuration - if that is important, use {@link getAll$} to get the
* This does not take into account the policy's configuration - if that is important, use {@link policiesByType$} to get the
* {@link Policy} objects and then filter by Policy.data.
*/
policyAppliesToActiveUser$: (policyType: PolicyType) => Observable<boolean>;
policyAppliesToUser: (policyType: PolicyType) => Promise<boolean>;
abstract policyAppliesToUser$: (policyType: PolicyType, userId: UserId) => Observable<boolean>;
// Policy specific interfaces
@@ -56,28 +38,31 @@ export abstract class PolicyService {
* @returns a set of options which represent the minimum Master Password settings that the user must
* comply with in order to comply with **all** Master Password policies.
*/
masterPasswordPolicyOptions$: (policies?: Policy[]) => Observable<MasterPasswordPolicyOptions>;
abstract masterPasswordPolicyOptions$: (
userId: UserId,
policies?: Policy[],
) => Observable<MasterPasswordPolicyOptions | undefined>;
/**
* Evaluates whether a proposed Master Password complies with all Master Password policies that apply to the user.
*/
evaluateMasterPassword: (
abstract evaluateMasterPassword: (
passwordStrength: number,
newPassword: string,
enforcedPolicyOptions?: MasterPasswordPolicyOptions,
) => boolean;
/**
* @returns Reset Password policy options for the specified organization and a boolean indicating whether the policy
* @returns {@link ResetPasswordPolicyOptions} for the specified organization and a boolean indicating whether the policy
* is enabled
*/
getResetPasswordPolicyOptions: (
abstract getResetPasswordPolicyOptions: (
policies: Policy[],
orgId: string,
) => [ResetPasswordPolicyOptions, boolean];
}
export abstract class InternalPolicyService extends PolicyService {
upsert: (policy: PolicyData) => Promise<void>;
replace: (policies: { [id: string]: PolicyData }, userId: UserId) => Promise<void>;
abstract upsert: (policy: PolicyData, userId: UserId) => Promise<void>;
abstract replace: (policies: { [id: string]: PolicyData }, userId: UserId) => Promise<void>;
}

View File

@@ -1,68 +0,0 @@
import { Observable } from "rxjs";
import { UserId } from "../../../types/guid";
import { PolicyType } from "../../enums";
import { PolicyData } from "../../models/data/policy.data";
import { MasterPasswordPolicyOptions } from "../../models/domain/master-password-policy-options";
import { Policy } from "../../models/domain/policy";
import { ResetPasswordPolicyOptions } from "../../models/domain/reset-password-policy-options";
export abstract class vNextPolicyService {
/**
* All policies for the provided user from sync data.
* May include policies that are disabled or otherwise do not apply to the user. Be careful using this!
* Consider {@link policiesByType$} instead, which will only return policies that should be enforced against the user.
*/
abstract policies$: (userId: UserId) => Observable<Policy[]>;
/**
* @returns all {@link Policy} objects of a given type that apply to the specified user.
* A policy "applies" if it is enabled and the user is not exempt (e.g. because they are an Owner).
* @param policyType the {@link PolicyType} to search for
* @param userId the {@link UserId} to search against
*/
abstract policiesByType$: (policyType: PolicyType, userId: UserId) => Observable<Policy[]>;
/**
* @returns true if a policy of the specified type applies to the specified user, otherwise false.
* A policy "applies" if it is enabled and the user is not exempt (e.g. because they are an Owner).
* This does not take into account the policy's configuration - if that is important, use {@link policiesByType$} to get the
* {@link Policy} objects and then filter by Policy.data.
*/
abstract policyAppliesToUser$: (policyType: PolicyType, userId: UserId) => Observable<boolean>;
// Policy specific interfaces
/**
* Combines all Master Password policies that apply to the user.
* @returns a set of options which represent the minimum Master Password settings that the user must
* comply with in order to comply with **all** Master Password policies.
*/
abstract masterPasswordPolicyOptions$: (
userId: UserId,
policies?: Policy[],
) => Observable<MasterPasswordPolicyOptions | undefined>;
/**
* Evaluates whether a proposed Master Password complies with all Master Password policies that apply to the user.
*/
abstract evaluateMasterPassword: (
passwordStrength: number,
newPassword: string,
enforcedPolicyOptions?: MasterPasswordPolicyOptions,
) => boolean;
/**
* @returns {@link ResetPasswordPolicyOptions} for the specified organization and a boolean indicating whether the policy
* is enabled
*/
abstract getResetPasswordPolicyOptions: (
policies: Policy[],
orgId: string,
) => [ResetPasswordPolicyOptions, boolean];
}
export abstract class vNextInternalPolicyService extends vNextPolicyService {
abstract upsert: (policy: PolicyData, userId: UserId) => Promise<void>;
abstract replace: (policies: { [id: string]: PolicyData }, userId: UserId) => Promise<void>;
}

View File

@@ -1,5 +1,5 @@
export enum ProviderType {
Msp = 0,
Reseller = 1,
MultiOrganizationEnterprise = 2,
BusinessUnit = 2,
}

View File

@@ -58,6 +58,7 @@ describe("ORGANIZATIONS state", () => {
familySponsorshipLastSyncDate: new Date(),
userIsManagedByOrganization: false,
useRiskInsights: false,
useAdminSponsoredFamilies: false,
},
};
const result = sut.deserializer(JSON.parse(JSON.stringify(expectedResult)));

View File

@@ -60,6 +60,7 @@ export class OrganizationData {
allowAdminAccessToAllCollectionItems: boolean;
userIsManagedByOrganization: boolean;
useRiskInsights: boolean;
useAdminSponsoredFamilies: boolean;
constructor(
response?: ProfileOrganizationResponse,
@@ -122,6 +123,7 @@ export class OrganizationData {
this.allowAdminAccessToAllCollectionItems = response.allowAdminAccessToAllCollectionItems;
this.userIsManagedByOrganization = response.userIsManagedByOrganization;
this.useRiskInsights = response.useRiskInsights;
this.useAdminSponsoredFamilies = response.useAdminSponsoredFamilies;
this.isMember = options.isMember;
this.isProviderUser = options.isProviderUser;

View File

@@ -1,4 +1,9 @@
import { ProviderStatusType, ProviderUserStatusType, ProviderUserType } from "../../enums";
import {
ProviderStatusType,
ProviderType,
ProviderUserStatusType,
ProviderUserType,
} from "../../enums";
import { ProfileProviderResponse } from "../response/profile-provider.response";
export class ProviderData {
@@ -10,6 +15,7 @@ export class ProviderData {
userId: string;
useEvents: boolean;
providerStatus: ProviderStatusType;
providerType: ProviderType;
constructor(response: ProfileProviderResponse) {
this.id = response.id;
@@ -20,5 +26,6 @@ export class ProviderData {
this.userId = response.userId;
this.useEvents = response.useEvents;
this.providerStatus = response.providerStatus;
this.providerType = response.providerType;
}
}

View File

@@ -31,8 +31,10 @@ export class EncryptedOrganizationKey implements BaseEncryptedOrganizationKey {
constructor(private key: string) {}
async decrypt(encryptService: EncryptService, privateKey: UserPrivateKey) {
const decValue = await encryptService.rsaDecrypt(this.encryptedOrganizationKey, privateKey);
return new SymmetricCryptoKey(decValue) as OrgKey;
return (await encryptService.decapsulateKeyUnsigned(
this.encryptedOrganizationKey,
privateKey,
)) as OrgKey;
}
get encryptedOrganizationKey() {

View File

@@ -90,6 +90,7 @@ export class Organization {
*/
userIsManagedByOrganization: boolean;
useRiskInsights: boolean;
useAdminSponsoredFamilies: boolean;
constructor(obj?: OrganizationData) {
if (obj == null) {
@@ -148,6 +149,7 @@ export class Organization {
this.allowAdminAccessToAllCollectionItems = obj.allowAdminAccessToAllCollectionItems;
this.userIsManagedByOrganization = obj.userIsManagedByOrganization;
this.useRiskInsights = obj.useRiskInsights;
this.useAdminSponsoredFamilies = obj.useAdminSponsoredFamilies;
}
get canAccess() {
@@ -331,8 +333,7 @@ export class Organization {
get hasBillableProvider() {
return (
this.hasProvider &&
(this.providerType === ProviderType.Msp ||
this.providerType === ProviderType.MultiOrganizationEnterprise)
(this.providerType === ProviderType.Msp || this.providerType === ProviderType.BusinessUnit)
);
}

View File

@@ -1,6 +1,11 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { ProviderStatusType, ProviderUserStatusType, ProviderUserType } from "../../enums";
import {
ProviderStatusType,
ProviderType,
ProviderUserStatusType,
ProviderUserType,
} from "../../enums";
import { ProviderData } from "../data/provider.data";
export class Provider {
@@ -12,6 +17,7 @@ export class Provider {
userId: string;
useEvents: boolean;
providerStatus: ProviderStatusType;
providerType: ProviderType;
constructor(obj?: ProviderData) {
if (obj == null) {
@@ -26,6 +32,7 @@ export class Provider {
this.userId = obj.userId;
this.useEvents = obj.useEvents;
this.providerStatus = obj.providerStatus;
this.providerType = obj.providerType;
}
get canAccess() {

View File

@@ -6,4 +6,5 @@ export class OrganizationSponsorshipCreateRequest {
sponsoredEmail: string;
planSponsorshipType: PlanSponsorshipType;
friendlyName: string;
notes?: string;
}

View File

@@ -55,6 +55,7 @@ export class ProfileOrganizationResponse extends BaseResponse {
allowAdminAccessToAllCollectionItems: boolean;
userIsManagedByOrganization: boolean;
useRiskInsights: boolean;
useAdminSponsoredFamilies: boolean;
constructor(response: any) {
super(response);
@@ -121,5 +122,6 @@ export class ProfileOrganizationResponse extends BaseResponse {
);
this.userIsManagedByOrganization = this.getResponseProperty("UserIsManagedByOrganization");
this.useRiskInsights = this.getResponseProperty("UseRiskInsights");
this.useAdminSponsoredFamilies = this.getResponseProperty("UseAdminSponsoredFamilies");
}
}

View File

@@ -1,5 +1,10 @@
import { BaseResponse } from "../../../models/response/base.response";
import { ProviderStatusType, ProviderUserStatusType, ProviderUserType } from "../../enums";
import {
ProviderStatusType,
ProviderType,
ProviderUserStatusType,
ProviderUserType,
} from "../../enums";
import { PermissionsApi } from "../api/permissions.api";
export class ProfileProviderResponse extends BaseResponse {
@@ -13,6 +18,7 @@ export class ProfileProviderResponse extends BaseResponse {
userId: string;
useEvents: boolean;
providerStatus: ProviderStatusType;
providerType: ProviderType;
constructor(response: any) {
super(response);
@@ -26,5 +32,6 @@ export class ProfileProviderResponse extends BaseResponse {
this.userId = this.getResponseProperty("UserId");
this.useEvents = this.getResponseProperty("UseEvents");
this.providerStatus = this.getResponseProperty("ProviderStatus");
this.providerType = this.getResponseProperty("ProviderType");
}
}

View File

@@ -15,11 +15,11 @@ import { MasterPasswordPolicyOptions } from "../../../admin-console/models/domai
import { Organization } from "../../../admin-console/models/domain/organization";
import { Policy } from "../../../admin-console/models/domain/policy";
import { ResetPasswordPolicyOptions } from "../../../admin-console/models/domain/reset-password-policy-options";
import { POLICIES } from "../../../admin-console/services/policy/policy.service";
import { PolicyId, UserId } from "../../../types/guid";
import { OrganizationService } from "../../abstractions/organization/organization.service.abstraction";
import { DefaultvNextPolicyService, getFirstPolicy } from "./default-vnext-policy.service";
import { DefaultPolicyService, getFirstPolicy } from "./default-policy.service";
import { POLICIES } from "./policy-state";
describe("PolicyService", () => {
const userId = "userId" as UserId;
@@ -27,7 +27,7 @@ describe("PolicyService", () => {
let organizationService: MockProxy<OrganizationService>;
let singleUserState: FakeSingleUserState<Record<PolicyId, PolicyData>>;
let policyService: DefaultvNextPolicyService;
let policyService: DefaultPolicyService;
beforeEach(() => {
const accountService = mockAccountServiceWith(userId);
@@ -59,7 +59,7 @@ describe("PolicyService", () => {
organizationService.organizations$.calledWith(userId).mockReturnValue(organizations$);
policyService = new DefaultvNextPolicyService(stateProvider, organizationService);
policyService = new DefaultPolicyService(stateProvider, organizationService);
});
it("upsert", async () => {

View File

@@ -3,7 +3,7 @@ import { combineLatest, map, Observable, of } from "rxjs";
import { StateProvider } from "../../../platform/state";
import { UserId } from "../../../types/guid";
import { OrganizationService } from "../../abstractions/organization/organization.service.abstraction";
import { vNextPolicyService } from "../../abstractions/policy/vnext-policy.service";
import { PolicyService } from "../../abstractions/policy/policy.service.abstraction";
import { OrganizationUserStatusType, PolicyType } from "../../enums";
import { PolicyData } from "../../models/data/policy.data";
import { MasterPasswordPolicyOptions } from "../../models/domain/master-password-policy-options";
@@ -11,7 +11,7 @@ import { Organization } from "../../models/domain/organization";
import { Policy } from "../../models/domain/policy";
import { ResetPasswordPolicyOptions } from "../../models/domain/reset-password-policy-options";
import { POLICIES } from "./vnext-policy-state";
import { POLICIES } from "./policy-state";
export function policyRecordToArray(policiesMap: { [id: string]: PolicyData }) {
return Object.values(policiesMap || {}).map((f) => new Policy(f));
@@ -21,7 +21,7 @@ export const getFirstPolicy = map<Policy[], Policy | undefined>((policies) => {
return policies.at(0) ?? undefined;
});
export class DefaultvNextPolicyService implements vNextPolicyService {
export class DefaultPolicyService implements PolicyService {
constructor(
private stateProvider: StateProvider,
private organizationService: OrganizationService,
@@ -89,7 +89,7 @@ export class DefaultvNextPolicyService implements vNextPolicyService {
const policies$ = policies ? of(policies) : this.policies$(userId);
return policies$.pipe(
map((obsPolicies) => {
const enforcedOptions: MasterPasswordPolicyOptions = new MasterPasswordPolicyOptions();
let enforcedOptions: MasterPasswordPolicyOptions | undefined = undefined;
const filteredPolicies =
obsPolicies.filter((p) => p.type === PolicyType.MasterPassword) ?? [];
@@ -102,6 +102,10 @@ export class DefaultvNextPolicyService implements vNextPolicyService {
return;
}
if (!enforcedOptions) {
enforcedOptions = new MasterPasswordPolicyOptions();
}
if (
currentPolicy.data.minComplexity != null &&
currentPolicy.data.minComplexity > enforcedOptions.minComplexity

View File

@@ -1,6 +1,8 @@
import { firstValueFrom } from "rxjs";
import { firstValueFrom, map, switchMap } from "rxjs";
import { ApiService } from "../../../abstractions/api.service";
import { AccountService } from "../../../auth/abstractions/account.service";
import { getUserId } from "../../../auth/services/account.service";
import { HttpStatusCode } from "../../../enums";
import { ErrorResponse } from "../../../models/response/error.response";
import { ListResponse } from "../../../models/response/list.response";
@@ -18,6 +20,7 @@ export class PolicyApiService implements PolicyApiServiceAbstraction {
constructor(
private policyService: InternalPolicyService,
private apiService: ApiService,
private accountService: AccountService,
) {}
async getPolicy(organizationId: string, type: PolicyType): Promise<PolicyResponse> {
@@ -93,8 +96,14 @@ export class PolicyApiService implements PolicyApiServiceAbstraction {
return null;
}
return await firstValueFrom(
this.policyService.masterPasswordPolicyOptions$([masterPasswordPolicy]),
return firstValueFrom(
this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) =>
this.policyService.masterPasswordPolicyOptions$(userId, [masterPasswordPolicy]),
),
map((policy) => policy ?? null),
),
);
} catch (error) {
// If policy not found, return null
@@ -114,8 +123,9 @@ export class PolicyApiService implements PolicyApiServiceAbstraction {
true,
true,
);
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
const response = new PolicyResponse(r);
const data = new PolicyData(response);
await this.policyService.upsert(data);
await this.policyService.upsert(data, userId);
}
}

View File

@@ -1,556 +0,0 @@
import { mock, MockProxy } from "jest-mock-extended";
import { firstValueFrom, of } from "rxjs";
import { FakeStateProvider, mockAccountServiceWith } from "../../../../spec";
import { FakeActiveUserState, FakeSingleUserState } from "../../../../spec/fake-state";
import {
OrganizationUserStatusType,
OrganizationUserType,
PolicyType,
} from "../../../admin-console/enums";
import { PermissionsApi } from "../../../admin-console/models/api/permissions.api";
import { OrganizationData } from "../../../admin-console/models/data/organization.data";
import { PolicyData } from "../../../admin-console/models/data/policy.data";
import { MasterPasswordPolicyOptions } from "../../../admin-console/models/domain/master-password-policy-options";
import { Organization } from "../../../admin-console/models/domain/organization";
import { Policy } from "../../../admin-console/models/domain/policy";
import { ResetPasswordPolicyOptions } from "../../../admin-console/models/domain/reset-password-policy-options";
import { POLICIES, PolicyService } from "../../../admin-console/services/policy/policy.service";
import { PolicyId, UserId } from "../../../types/guid";
import { OrganizationService } from "../../abstractions/organization/organization.service.abstraction";
describe("PolicyService", () => {
const userId = "userId" as UserId;
let stateProvider: FakeStateProvider;
let organizationService: MockProxy<OrganizationService>;
let activeUserState: FakeActiveUserState<Record<PolicyId, PolicyData>>;
let singleUserState: FakeSingleUserState<Record<PolicyId, PolicyData>>;
let policyService: PolicyService;
beforeEach(() => {
const accountService = mockAccountServiceWith(userId);
stateProvider = new FakeStateProvider(accountService);
organizationService = mock<OrganizationService>();
activeUserState = stateProvider.activeUser.getFake(POLICIES);
singleUserState = stateProvider.singleUser.getFake(activeUserState.userId, POLICIES);
const organizations$ = of([
// User
organization("org1", true, true, OrganizationUserStatusType.Confirmed, false),
// Owner
organization(
"org2",
true,
true,
OrganizationUserStatusType.Confirmed,
false,
OrganizationUserType.Owner,
),
// Does not use policies
organization("org3", true, false, OrganizationUserStatusType.Confirmed, false),
// Another User
organization("org4", true, true, OrganizationUserStatusType.Confirmed, false),
// Another User
organization("org5", true, true, OrganizationUserStatusType.Confirmed, false),
// Can manage policies
organization("org6", true, true, OrganizationUserStatusType.Confirmed, true),
]);
organizationService.organizations$.mockReturnValue(organizations$);
policyService = new PolicyService(stateProvider, organizationService);
});
it("upsert", async () => {
activeUserState.nextState(
arrayToRecord([
policyData("1", "test-organization", PolicyType.MaximumVaultTimeout, true, { minutes: 14 }),
]),
);
await policyService.upsert(policyData("99", "test-organization", PolicyType.DisableSend, true));
expect(await firstValueFrom(policyService.policies$)).toEqual([
{
id: "1",
organizationId: "test-organization",
type: PolicyType.MaximumVaultTimeout,
enabled: true,
data: { minutes: 14 },
},
{
id: "99",
organizationId: "test-organization",
type: PolicyType.DisableSend,
enabled: true,
},
]);
});
it("replace", async () => {
activeUserState.nextState(
arrayToRecord([
policyData("1", "test-organization", PolicyType.MaximumVaultTimeout, true, { minutes: 14 }),
]),
);
await policyService.replace(
{
"2": policyData("2", "test-organization", PolicyType.DisableSend, true),
},
userId,
);
expect(await firstValueFrom(policyService.policies$)).toEqual([
{
id: "2",
organizationId: "test-organization",
type: PolicyType.DisableSend,
enabled: true,
},
]);
});
describe("masterPasswordPolicyOptions", () => {
it("returns default policy options", async () => {
const data: any = {
minComplexity: 5,
minLength: 20,
requireUpper: true,
};
const model = [
new Policy(policyData("1", "test-organization-3", PolicyType.MasterPassword, true, data)),
];
const result = await firstValueFrom(policyService.masterPasswordPolicyOptions$(model));
expect(result).toEqual({
minComplexity: 5,
minLength: 20,
requireLower: false,
requireNumbers: false,
requireSpecial: false,
requireUpper: true,
enforceOnLogin: false,
});
});
it("returns null", async () => {
const data: any = {};
const model = [
new Policy(
policyData("3", "test-organization-3", PolicyType.DisablePersonalVaultExport, true, data),
),
new Policy(
policyData("4", "test-organization-3", PolicyType.MaximumVaultTimeout, true, data),
),
];
const result = await firstValueFrom(policyService.masterPasswordPolicyOptions$(model));
expect(result).toEqual(null);
});
it("returns specified policy options", async () => {
const data: any = {
minLength: 14,
};
const model = [
new Policy(
policyData("3", "test-organization-3", PolicyType.DisablePersonalVaultExport, true, data),
),
new Policy(policyData("4", "test-organization-3", PolicyType.MasterPassword, true, data)),
];
const result = await firstValueFrom(policyService.masterPasswordPolicyOptions$(model));
expect(result).toEqual({
minComplexity: 0,
minLength: 14,
requireLower: false,
requireNumbers: false,
requireSpecial: false,
requireUpper: false,
enforceOnLogin: false,
});
});
});
describe("evaluateMasterPassword", () => {
it("false", async () => {
const enforcedPolicyOptions = new MasterPasswordPolicyOptions();
enforcedPolicyOptions.minLength = 14;
const result = policyService.evaluateMasterPassword(10, "password", enforcedPolicyOptions);
expect(result).toEqual(false);
});
it("true", async () => {
const enforcedPolicyOptions = new MasterPasswordPolicyOptions();
const result = policyService.evaluateMasterPassword(0, "password", enforcedPolicyOptions);
expect(result).toEqual(true);
});
});
describe("getResetPasswordPolicyOptions", () => {
it("default", async () => {
const result = policyService.getResetPasswordPolicyOptions([], "");
expect(result).toEqual([new ResetPasswordPolicyOptions(), false]);
});
it("returns autoEnrollEnabled true", async () => {
const data: any = {
autoEnrollEnabled: true,
};
const policies = [
new Policy(policyData("5", "test-organization-3", PolicyType.ResetPassword, true, data)),
];
const result = policyService.getResetPasswordPolicyOptions(policies, "test-organization-3");
expect(result).toEqual([{ autoEnrollEnabled: true }, true]);
});
});
describe("get$", () => {
it("returns the specified PolicyType", async () => {
activeUserState.nextState(
arrayToRecord([
policyData("policy1", "org1", PolicyType.ActivateAutofill, true),
policyData("policy2", "org1", PolicyType.DisablePersonalVaultExport, true),
policyData("policy3", "org1", PolicyType.RemoveUnlockWithPin, true),
]),
);
await expect(
firstValueFrom(policyService.get$(PolicyType.ActivateAutofill)),
).resolves.toMatchObject({
id: "policy1",
organizationId: "org1",
type: PolicyType.ActivateAutofill,
enabled: true,
});
await expect(
firstValueFrom(policyService.get$(PolicyType.DisablePersonalVaultExport)),
).resolves.toMatchObject({
id: "policy2",
organizationId: "org1",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
});
await expect(
firstValueFrom(policyService.get$(PolicyType.RemoveUnlockWithPin)),
).resolves.toMatchObject({
id: "policy3",
organizationId: "org1",
type: PolicyType.RemoveUnlockWithPin,
enabled: true,
});
});
it("does not return disabled policies", async () => {
activeUserState.nextState(
arrayToRecord([
policyData("policy1", "org1", PolicyType.ActivateAutofill, true),
policyData("policy2", "org1", PolicyType.DisablePersonalVaultExport, false),
]),
);
const result = await firstValueFrom(
policyService.get$(PolicyType.DisablePersonalVaultExport),
);
expect(result).toBeNull();
});
it("does not return policies that do not apply to the user because the user's role is exempt", async () => {
activeUserState.nextState(
arrayToRecord([
policyData("policy1", "org1", PolicyType.ActivateAutofill, true),
policyData("policy2", "org2", PolicyType.DisablePersonalVaultExport, false),
]),
);
const result = await firstValueFrom(
policyService.get$(PolicyType.DisablePersonalVaultExport),
);
expect(result).toBeNull();
});
it.each([
["owners", "org2"],
["administrators", "org6"],
])("returns the password generator policy for %s", async (_, organization) => {
activeUserState.nextState(
arrayToRecord([
policyData("policy1", "org1", PolicyType.ActivateAutofill, false),
policyData("policy2", organization, PolicyType.PasswordGenerator, true),
]),
);
const result = await firstValueFrom(policyService.get$(PolicyType.PasswordGenerator));
expect(result).toBeTruthy();
});
it("does not return policies for organizations that do not use policies", async () => {
activeUserState.nextState(
arrayToRecord([
policyData("policy1", "org3", PolicyType.ActivateAutofill, true),
policyData("policy2", "org2", PolicyType.DisablePersonalVaultExport, true),
]),
);
const result = await firstValueFrom(policyService.get$(PolicyType.ActivateAutofill));
expect(result).toBeNull();
});
});
describe("getAll$", () => {
it("returns the specified PolicyTypes", async () => {
singleUserState.nextState(
arrayToRecord([
policyData("policy1", "org4", PolicyType.DisablePersonalVaultExport, true),
policyData("policy2", "org1", PolicyType.ActivateAutofill, true),
policyData("policy3", "org5", PolicyType.DisablePersonalVaultExport, true),
policyData("policy4", "org1", PolicyType.DisablePersonalVaultExport, true),
]),
);
const result = await firstValueFrom(
policyService.getAll$(PolicyType.DisablePersonalVaultExport, activeUserState.userId),
);
expect(result).toEqual([
{
id: "policy1",
organizationId: "org4",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
},
{
id: "policy3",
organizationId: "org5",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
},
{
id: "policy4",
organizationId: "org1",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
},
]);
});
it("does not return disabled policies", async () => {
singleUserState.nextState(
arrayToRecord([
policyData("policy1", "org4", PolicyType.DisablePersonalVaultExport, true),
policyData("policy2", "org1", PolicyType.ActivateAutofill, true),
policyData("policy3", "org5", PolicyType.DisablePersonalVaultExport, false), // disabled
policyData("policy4", "org1", PolicyType.DisablePersonalVaultExport, true),
]),
);
const result = await firstValueFrom(
policyService.getAll$(PolicyType.DisablePersonalVaultExport, activeUserState.userId),
);
expect(result).toEqual([
{
id: "policy1",
organizationId: "org4",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
},
{
id: "policy4",
organizationId: "org1",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
},
]);
});
it("does not return policies that do not apply to the user because the user's role is exempt", async () => {
singleUserState.nextState(
arrayToRecord([
policyData("policy1", "org4", PolicyType.DisablePersonalVaultExport, true),
policyData("policy2", "org1", PolicyType.ActivateAutofill, true),
policyData("policy3", "org5", PolicyType.DisablePersonalVaultExport, true),
policyData("policy4", "org2", PolicyType.DisablePersonalVaultExport, true), // owner
]),
);
const result = await firstValueFrom(
policyService.getAll$(PolicyType.DisablePersonalVaultExport, activeUserState.userId),
);
expect(result).toEqual([
{
id: "policy1",
organizationId: "org4",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
},
{
id: "policy3",
organizationId: "org5",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
},
]);
});
it("does not return policies for organizations that do not use policies", async () => {
singleUserState.nextState(
arrayToRecord([
policyData("policy1", "org4", PolicyType.DisablePersonalVaultExport, true),
policyData("policy2", "org1", PolicyType.ActivateAutofill, true),
policyData("policy3", "org3", PolicyType.DisablePersonalVaultExport, true), // does not use policies
policyData("policy4", "org1", PolicyType.DisablePersonalVaultExport, true),
]),
);
const result = await firstValueFrom(
policyService.getAll$(PolicyType.DisablePersonalVaultExport, activeUserState.userId),
);
expect(result).toEqual([
{
id: "policy1",
organizationId: "org4",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
},
{
id: "policy4",
organizationId: "org1",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
},
]);
});
});
describe("policyAppliesToActiveUser$", () => {
it("returns true when the policyType applies to the user", async () => {
activeUserState.nextState(
arrayToRecord([
policyData("policy1", "org4", PolicyType.DisablePersonalVaultExport, true),
policyData("policy2", "org1", PolicyType.ActivateAutofill, true),
policyData("policy3", "org5", PolicyType.DisablePersonalVaultExport, true),
policyData("policy4", "org1", PolicyType.DisablePersonalVaultExport, true),
]),
);
const result = await firstValueFrom(
policyService.policyAppliesToActiveUser$(PolicyType.DisablePersonalVaultExport),
);
expect(result).toBe(true);
});
it("returns false when policyType is disabled", async () => {
activeUserState.nextState(
arrayToRecord([
policyData("policy2", "org1", PolicyType.ActivateAutofill, true),
policyData("policy3", "org5", PolicyType.DisablePersonalVaultExport, false), // disabled
]),
);
const result = await firstValueFrom(
policyService.policyAppliesToActiveUser$(PolicyType.DisablePersonalVaultExport),
);
expect(result).toBe(false);
});
it("returns false when the policyType does not apply to the user because the user's role is exempt", async () => {
activeUserState.nextState(
arrayToRecord([
policyData("policy2", "org1", PolicyType.ActivateAutofill, true),
policyData("policy4", "org2", PolicyType.DisablePersonalVaultExport, true), // owner
]),
);
const result = await firstValueFrom(
policyService.policyAppliesToActiveUser$(PolicyType.DisablePersonalVaultExport),
);
expect(result).toBe(false);
});
it("returns false for organizations that do not use policies", async () => {
activeUserState.nextState(
arrayToRecord([
policyData("policy2", "org1", PolicyType.ActivateAutofill, true),
policyData("policy3", "org3", PolicyType.DisablePersonalVaultExport, true), // does not use policies
]),
);
const result = await firstValueFrom(
policyService.policyAppliesToActiveUser$(PolicyType.DisablePersonalVaultExport),
);
expect(result).toBe(false);
});
});
function policyData(
id: string,
organizationId: string,
type: PolicyType,
enabled: boolean,
data?: any,
) {
const policyData = new PolicyData({} as any);
policyData.id = id as PolicyId;
policyData.organizationId = organizationId;
policyData.type = type;
policyData.enabled = enabled;
policyData.data = data;
return policyData;
}
function organizationData(
id: string,
enabled: boolean,
usePolicies: boolean,
status: OrganizationUserStatusType,
managePolicies: boolean,
type: OrganizationUserType = OrganizationUserType.User,
) {
const organizationData = new OrganizationData({} as any, {} as any);
organizationData.id = id;
organizationData.enabled = enabled;
organizationData.usePolicies = usePolicies;
organizationData.status = status;
organizationData.permissions = new PermissionsApi({ managePolicies: managePolicies } as any);
organizationData.type = type;
return organizationData;
}
function organization(
id: string,
enabled: boolean,
usePolicies: boolean,
status: OrganizationUserStatusType,
managePolicies: boolean,
type: OrganizationUserType = OrganizationUserType.User,
) {
return new Organization(
organizationData(id, enabled, usePolicies, status, managePolicies, type),
);
}
function arrayToRecord(input: PolicyData[]): Record<PolicyId, PolicyData> {
return Object.fromEntries(input.map((i) => [i.id, i]));
}
});

View File

@@ -1,257 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { combineLatest, firstValueFrom, map, Observable, of, switchMap } from "rxjs";
import { UserKeyDefinition, POLICIES_DISK, StateProvider } from "../../../platform/state";
import { PolicyId, UserId } from "../../../types/guid";
import { OrganizationService } from "../../abstractions/organization/organization.service.abstraction";
import { InternalPolicyService as InternalPolicyServiceAbstraction } from "../../abstractions/policy/policy.service.abstraction";
import { OrganizationUserStatusType, PolicyType } from "../../enums";
import { PolicyData } from "../../models/data/policy.data";
import { MasterPasswordPolicyOptions } from "../../models/domain/master-password-policy-options";
import { Organization } from "../../models/domain/organization";
import { Policy } from "../../models/domain/policy";
import { ResetPasswordPolicyOptions } from "../../models/domain/reset-password-policy-options";
const policyRecordToArray = (policiesMap: { [id: string]: PolicyData }) =>
Object.values(policiesMap || {}).map((f) => new Policy(f));
export const POLICIES = UserKeyDefinition.record<PolicyData, PolicyId>(POLICIES_DISK, "policies", {
deserializer: (policyData) => policyData,
clearOn: ["logout"],
});
export class PolicyService implements InternalPolicyServiceAbstraction {
private activeUserPolicyState = this.stateProvider.getActive(POLICIES);
private activeUserPolicies$ = this.activeUserPolicyState.state$.pipe(
map((policyData) => policyRecordToArray(policyData)),
);
policies$ = this.activeUserPolicies$;
constructor(
private stateProvider: StateProvider,
private organizationService: OrganizationService,
) {}
get$(policyType: PolicyType): Observable<Policy> {
const filteredPolicies$ = this.activeUserPolicies$.pipe(
map((policies) => policies.filter((p) => p.type === policyType)),
);
const organizations$ = this.stateProvider.activeUserId$.pipe(
switchMap((userId) => this.organizationService.organizations$(userId)),
);
return combineLatest([filteredPolicies$, organizations$]).pipe(
map(
([policies, organizations]) =>
this.enforcedPolicyFilter(policies, organizations)?.at(0) ?? null,
),
);
}
getAll$(policyType: PolicyType, userId: UserId) {
const filteredPolicies$ = this.stateProvider.getUserState$(POLICIES, userId).pipe(
map((policyData) => policyRecordToArray(policyData)),
map((policies) => policies.filter((p) => p.type === policyType)),
);
return combineLatest([filteredPolicies$, this.organizationService.organizations$(userId)]).pipe(
map(([policies, organizations]) => this.enforcedPolicyFilter(policies, organizations)),
);
}
async getAll(policyType: PolicyType) {
return await firstValueFrom(
this.policies$.pipe(map((policies) => policies.filter((p) => p.type === policyType))),
);
}
policyAppliesToActiveUser$(policyType: PolicyType) {
return this.get$(policyType).pipe(map((policy) => policy != null));
}
async policyAppliesToUser(policyType: PolicyType) {
return await firstValueFrom(this.policyAppliesToActiveUser$(policyType));
}
private enforcedPolicyFilter(policies: Policy[], organizations: Organization[]) {
const orgDict = Object.fromEntries(organizations.map((o) => [o.id, o]));
return policies.filter((policy) => {
const organization = orgDict[policy.organizationId];
// This shouldn't happen, i.e. the user should only have policies for orgs they are a member of
// But if it does, err on the side of enforcing the policy
if (organization == null) {
return true;
}
return (
policy.enabled &&
organization.status >= OrganizationUserStatusType.Accepted &&
organization.usePolicies &&
!this.isExemptFromPolicy(policy.type, organization)
);
});
}
masterPasswordPolicyOptions$(policies?: Policy[]): Observable<MasterPasswordPolicyOptions> {
const observable = policies ? of(policies) : this.policies$;
return observable.pipe(
map((obsPolicies) => {
let enforcedOptions: MasterPasswordPolicyOptions = null;
const filteredPolicies = obsPolicies.filter((p) => p.type === PolicyType.MasterPassword);
if (filteredPolicies == null || filteredPolicies.length === 0) {
return enforcedOptions;
}
filteredPolicies.forEach((currentPolicy) => {
if (!currentPolicy.enabled || currentPolicy.data == null) {
return;
}
if (enforcedOptions == null) {
enforcedOptions = new MasterPasswordPolicyOptions();
}
if (
currentPolicy.data.minComplexity != null &&
currentPolicy.data.minComplexity > enforcedOptions.minComplexity
) {
enforcedOptions.minComplexity = currentPolicy.data.minComplexity;
}
if (
currentPolicy.data.minLength != null &&
currentPolicy.data.minLength > enforcedOptions.minLength
) {
enforcedOptions.minLength = currentPolicy.data.minLength;
}
if (currentPolicy.data.requireUpper) {
enforcedOptions.requireUpper = true;
}
if (currentPolicy.data.requireLower) {
enforcedOptions.requireLower = true;
}
if (currentPolicy.data.requireNumbers) {
enforcedOptions.requireNumbers = true;
}
if (currentPolicy.data.requireSpecial) {
enforcedOptions.requireSpecial = true;
}
if (currentPolicy.data.enforceOnLogin) {
enforcedOptions.enforceOnLogin = true;
}
});
return enforcedOptions;
}),
);
}
evaluateMasterPassword(
passwordStrength: number,
newPassword: string,
enforcedPolicyOptions: MasterPasswordPolicyOptions,
): boolean {
if (enforcedPolicyOptions == null) {
return true;
}
if (
enforcedPolicyOptions.minComplexity > 0 &&
enforcedPolicyOptions.minComplexity > passwordStrength
) {
return false;
}
if (
enforcedPolicyOptions.minLength > 0 &&
enforcedPolicyOptions.minLength > newPassword.length
) {
return false;
}
if (enforcedPolicyOptions.requireUpper && newPassword.toLocaleLowerCase() === newPassword) {
return false;
}
if (enforcedPolicyOptions.requireLower && newPassword.toLocaleUpperCase() === newPassword) {
return false;
}
if (enforcedPolicyOptions.requireNumbers && !/[0-9]/.test(newPassword)) {
return false;
}
// eslint-disable-next-line
if (enforcedPolicyOptions.requireSpecial && !/[!@#$%\^&*]/g.test(newPassword)) {
return false;
}
return true;
}
getResetPasswordPolicyOptions(
policies: Policy[],
orgId: string,
): [ResetPasswordPolicyOptions, boolean] {
const resetPasswordPolicyOptions = new ResetPasswordPolicyOptions();
if (policies == null || orgId == null) {
return [resetPasswordPolicyOptions, false];
}
const policy = policies.find(
(p) => p.organizationId === orgId && p.type === PolicyType.ResetPassword && p.enabled,
);
resetPasswordPolicyOptions.autoEnrollEnabled = policy?.data?.autoEnrollEnabled ?? false;
return [resetPasswordPolicyOptions, policy?.enabled ?? false];
}
async upsert(policy: PolicyData): Promise<void> {
await this.activeUserPolicyState.update((policies) => {
policies ??= {};
policies[policy.id] = policy;
return policies;
});
}
async replace(policies: { [id: string]: PolicyData }, userId: UserId): Promise<void> {
await this.stateProvider.setUserState(POLICIES, policies, userId);
}
/**
* Determines whether an orgUser is exempt from a specific policy because of their role
* Generally orgUsers who can manage policies are exempt from them, but some policies are stricter
*/
private isExemptFromPolicy(policyType: PolicyType, organization: Organization) {
switch (policyType) {
case PolicyType.MaximumVaultTimeout:
// Max Vault Timeout applies to everyone except owners
return organization.isOwner;
case PolicyType.PasswordGenerator:
// password generation policy applies to everyone
return false;
case PolicyType.PersonalOwnership:
// individual vault policy applies to everyone except admins and owners
return organization.isAdmin;
case PolicyType.FreeFamiliesSponsorshipPolicy:
// free Bitwarden families policy applies to everyone
return false;
case PolicyType.RemoveUnlockWithPin:
// free Remove Unlock with PIN policy applies to everyone
return false;
default:
return organization.canManagePolicies;
}
}
}

View File

@@ -4,7 +4,12 @@ import { FakeAccountService, FakeStateProvider, mockAccountServiceWith } from ".
import { FakeActiveUserState, FakeSingleUserState } from "../../../spec/fake-state";
import { Utils } from "../../platform/misc/utils";
import { UserId } from "../../types/guid";
import { ProviderStatusType, ProviderUserStatusType, ProviderUserType } from "../enums";
import {
ProviderStatusType,
ProviderType,
ProviderUserStatusType,
ProviderUserType,
} from "../enums";
import { ProviderData } from "../models/data/provider.data";
import { Provider } from "../models/domain/provider";
@@ -67,6 +72,7 @@ describe("PROVIDERS key definition", () => {
userId: "string",
useEvents: true,
providerStatus: ProviderStatusType.Pending,
providerType: ProviderType.Msp,
},
};
const result = sut.deserializer(JSON.parse(JSON.stringify(expectedResult)));

Some files were not shown because too many files have changed in this diff Show More