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:
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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'">
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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$),
|
||||
)
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
) {
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
>
|
||||
<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
|
||||
>
|
||||
<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
|
||||
>
|
||||
<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
|
||||
>
|
||||
<a [routerLink]="backToRoute" bitLink linkType="primary">{{
|
||||
"viewAllLogInOptions" | i18n
|
||||
}}</a>
|
||||
</div>
|
||||
</ng-container>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -81,7 +81,7 @@ export class DefaultRegistrationFinishService implements RegistrationFinishServi
|
||||
|
||||
const registerFinishRequest = new RegisterFinishRequest(
|
||||
email,
|
||||
passwordInputResult.masterKeyHash,
|
||||
passwordInputResult.serverMasterKeyHash,
|
||||
passwordInputResult.hint,
|
||||
encryptedUserKey,
|
||||
userAsymmetricKeysRequest,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
</app-callout>
|
||||
|
||||
<auth-input-password
|
||||
[buttonText]="'createAccount' | i18n"
|
||||
[inputPasswordFlow]="InputPasswordFlow.SetInitialPassword"
|
||||
[primaryButtonText]="{ key: 'createAccount' }"
|
||||
[email]="email"
|
||||
[loading]="submitting"
|
||||
[masterPasswordPolicyOptions]="masterPasswordPolicyOptions"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
136
libs/auth/src/angular/validators/compare-inputs.validator.ts
Normal file
136
libs/auth/src/angular/validators/compare-inputs.validator.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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 } },
|
||||
],
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
export enum ProviderType {
|
||||
Msp = 0,
|
||||
Reseller = 1,
|
||||
MultiOrganizationEnterprise = 2,
|
||||
BusinessUnit = 2,
|
||||
}
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -6,4 +6,5 @@ export class OrganizationSponsorshipCreateRequest {
|
||||
sponsoredEmail: string;
|
||||
planSponsorshipType: PlanSponsorshipType;
|
||||
friendlyName: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]));
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user