mirror of
https://github.com/bitwarden/browser
synced 2026-02-09 13:10:17 +00:00
Merge remote-tracking branch 'origin' into auth/pm-18720/change-password-component-non-dialog-v2
This commit is contained in:
@@ -6,7 +6,6 @@ import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { LoginSuccessHandlerService } from "@bitwarden/auth/common";
|
||||
import { WebAuthnLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login.service.abstraction";
|
||||
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
|
||||
import { WebAuthnLoginCredentialAssertionView } from "@bitwarden/common/auth/models/view/webauthn-login/webauthn-login-credential-assertion.view";
|
||||
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
@@ -21,7 +20,6 @@ export class BaseLoginViaWebAuthnComponent implements OnInit {
|
||||
protected currentState: State = "assert";
|
||||
|
||||
protected successRoute = "/vault";
|
||||
protected forcePasswordResetRoute = "/update-temp-password";
|
||||
|
||||
constructor(
|
||||
private webAuthnLoginService: WebAuthnLoginServiceAbstraction,
|
||||
@@ -73,11 +71,6 @@ export class BaseLoginViaWebAuthnComponent implements OnInit {
|
||||
await this.loginSuccessHandlerService.run(authResult.userId);
|
||||
}
|
||||
|
||||
if (authResult.forcePasswordReset == ForceSetPasswordReason.AdminForcePasswordReset) {
|
||||
await this.router.navigate([this.forcePasswordResetRoute]);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.router.navigate([this.successRoute]);
|
||||
} catch (error) {
|
||||
if (error instanceof ErrorResponse) {
|
||||
|
||||
@@ -83,11 +83,12 @@ export class ChangePasswordComponent implements OnInit, OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
const email = await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(map((a) => a?.email)),
|
||||
const [userId, email] = await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(map((a) => [a?.id, a?.email])),
|
||||
);
|
||||
|
||||
if (this.kdfConfig == null) {
|
||||
this.kdfConfig = await this.kdfConfigService.getKdfConfig();
|
||||
this.kdfConfig = await this.kdfConfigService.getKdfConfig(userId);
|
||||
}
|
||||
|
||||
// Create new master key
|
||||
|
||||
@@ -1,611 +0,0 @@
|
||||
import { Component } from "@angular/core";
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { ActivatedRoute, Router } from "@angular/router";
|
||||
import { MockProxy, mock } from "jest-mock-extended";
|
||||
import { BehaviorSubject, Observable, of } from "rxjs";
|
||||
|
||||
import {
|
||||
FakeKeyConnectorUserDecryptionOption as KeyConnectorUserDecryptionOption,
|
||||
LoginStrategyServiceAbstraction,
|
||||
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 { 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 { 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 { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
|
||||
|
||||
import { SsoComponent } from "./sso.component";
|
||||
// test component that extends the SsoComponent
|
||||
@Component({})
|
||||
class TestSsoComponent extends SsoComponent {}
|
||||
|
||||
interface SsoComponentProtected {
|
||||
twoFactorRoute: string;
|
||||
successRoute: string;
|
||||
trustedDeviceEncRoute: string;
|
||||
changePasswordRoute: string;
|
||||
forcePasswordResetRoute: string;
|
||||
logIn(code: string, codeVerifier: string, orgIdFromState: string): Promise<AuthResult>;
|
||||
handleLoginError(e: any): Promise<void>;
|
||||
}
|
||||
|
||||
// The ideal scenario would be to not have to test the protected / private methods of the SsoComponent
|
||||
// but that will require a refactor of the SsoComponent class which is out of scope for now.
|
||||
// This test suite allows us to be sure that the new Trusted Device encryption flows + mild refactors
|
||||
// of the SsoComponent don't break the existing post login flows.
|
||||
describe("SsoComponent", () => {
|
||||
let component: TestSsoComponent;
|
||||
let _component: SsoComponentProtected;
|
||||
let fixture: ComponentFixture<TestSsoComponent>;
|
||||
const userId = "userId" as UserId;
|
||||
|
||||
// Mock Services
|
||||
let mockLoginStrategyService: MockProxy<LoginStrategyServiceAbstraction>;
|
||||
let mockRouter: MockProxy<Router>;
|
||||
let mockI18nService: MockProxy<I18nService>;
|
||||
|
||||
let mockQueryParams: Observable<any>;
|
||||
let mockActivatedRoute: ActivatedRoute;
|
||||
|
||||
let mockSsoLoginService: MockProxy<SsoLoginServiceAbstraction>;
|
||||
let mockStateService: MockProxy<StateService>;
|
||||
let mockToastService: MockProxy<ToastService>;
|
||||
let mockApiService: MockProxy<ApiService>;
|
||||
let mockCryptoFunctionService: MockProxy<CryptoFunctionService>;
|
||||
let mockEnvironmentService: MockProxy<EnvironmentService>;
|
||||
let mockPasswordGenerationService: MockProxy<PasswordGenerationServiceAbstraction>;
|
||||
let mockLogService: MockProxy<LogService>;
|
||||
let mockUserDecryptionOptionsService: MockProxy<UserDecryptionOptionsServiceAbstraction>;
|
||||
let mockConfigService: MockProxy<ConfigService>;
|
||||
let mockMasterPasswordService: FakeMasterPasswordService;
|
||||
let mockAccountService: FakeAccountService;
|
||||
let mockPlatformUtilsService: MockProxy<PlatformUtilsService>;
|
||||
|
||||
// Mock authService.logIn params
|
||||
let code: string;
|
||||
let codeVerifier: string;
|
||||
let orgIdFromState: string;
|
||||
|
||||
// Mock component callbacks
|
||||
let mockOnSuccessfulLogin: jest.Mock;
|
||||
let mockOnSuccessfulLoginNavigate: jest.Mock;
|
||||
let mockOnSuccessfulLoginTwoFactorNavigate: jest.Mock;
|
||||
let mockOnSuccessfulLoginChangePasswordNavigate: jest.Mock;
|
||||
let mockOnSuccessfulLoginForceResetNavigate: jest.Mock;
|
||||
let mockOnSuccessfulLoginTdeNavigate: jest.Mock;
|
||||
|
||||
let mockUserDecryptionOpts: {
|
||||
noMasterPassword: UserDecryptionOptions;
|
||||
withMasterPassword: UserDecryptionOptions;
|
||||
withMasterPasswordAndTrustedDevice: UserDecryptionOptions;
|
||||
withMasterPasswordAndTrustedDeviceWithManageResetPassword: UserDecryptionOptions;
|
||||
withMasterPasswordAndKeyConnector: UserDecryptionOptions;
|
||||
noMasterPasswordWithTrustedDevice: UserDecryptionOptions;
|
||||
noMasterPasswordWithTrustedDeviceWithManageResetPassword: UserDecryptionOptions;
|
||||
noMasterPasswordWithKeyConnector: UserDecryptionOptions;
|
||||
};
|
||||
|
||||
let selectedUserDecryptionOptions: BehaviorSubject<UserDecryptionOptions>;
|
||||
|
||||
beforeEach(() => {
|
||||
// Mock Services
|
||||
mockLoginStrategyService = mock<LoginStrategyServiceAbstraction>();
|
||||
mockRouter = mock<Router>();
|
||||
mockI18nService = mock<I18nService>();
|
||||
|
||||
// Default mockQueryParams
|
||||
mockQueryParams = of({ code: "code", state: "state" });
|
||||
// Create a custom mock for ActivatedRoute with mock queryParams
|
||||
mockActivatedRoute = {
|
||||
queryParams: mockQueryParams,
|
||||
} as any as ActivatedRoute;
|
||||
|
||||
mockSsoLoginService = mock();
|
||||
mockStateService = mock();
|
||||
mockToastService = mock();
|
||||
mockApiService = mock();
|
||||
mockCryptoFunctionService = mock();
|
||||
mockEnvironmentService = mock();
|
||||
mockPasswordGenerationService = mock();
|
||||
mockLogService = mock();
|
||||
mockUserDecryptionOptionsService = mock();
|
||||
mockConfigService = mock();
|
||||
mockAccountService = mockAccountServiceWith(userId);
|
||||
mockMasterPasswordService = new FakeMasterPasswordService();
|
||||
mockPlatformUtilsService = mock();
|
||||
|
||||
// Mock loginStrategyService.logIn params
|
||||
code = "code";
|
||||
codeVerifier = "codeVerifier";
|
||||
orgIdFromState = "orgIdFromState";
|
||||
|
||||
// Mock component callbacks
|
||||
mockOnSuccessfulLogin = jest.fn();
|
||||
mockOnSuccessfulLoginNavigate = jest.fn();
|
||||
mockOnSuccessfulLoginTwoFactorNavigate = jest.fn();
|
||||
mockOnSuccessfulLoginChangePasswordNavigate = jest.fn();
|
||||
mockOnSuccessfulLoginForceResetNavigate = jest.fn();
|
||||
mockOnSuccessfulLoginTdeNavigate = jest.fn();
|
||||
|
||||
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>(null);
|
||||
mockUserDecryptionOptionsService.userDecryptionOptions$ = selectedUserDecryptionOptions;
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [TestSsoComponent],
|
||||
providers: [
|
||||
{ provide: SsoLoginServiceAbstraction, useValue: mockSsoLoginService },
|
||||
{ provide: LoginStrategyServiceAbstraction, useValue: mockLoginStrategyService },
|
||||
{ provide: Router, useValue: mockRouter },
|
||||
{ provide: I18nService, useValue: mockI18nService },
|
||||
{ provide: ActivatedRoute, useValue: mockActivatedRoute },
|
||||
{ provide: StateService, useValue: mockStateService },
|
||||
{ provide: ToastService, useValue: mockToastService },
|
||||
|
||||
{ provide: ApiService, useValue: mockApiService },
|
||||
{ provide: CryptoFunctionService, useValue: mockCryptoFunctionService },
|
||||
{ provide: EnvironmentService, useValue: mockEnvironmentService },
|
||||
{
|
||||
provide: PasswordGenerationServiceAbstraction,
|
||||
useValue: mockPasswordGenerationService,
|
||||
},
|
||||
|
||||
{
|
||||
provide: UserDecryptionOptionsServiceAbstraction,
|
||||
useValue: mockUserDecryptionOptionsService,
|
||||
},
|
||||
{ provide: LogService, useValue: mockLogService },
|
||||
{ provide: ConfigService, useValue: mockConfigService },
|
||||
{ provide: InternalMasterPasswordServiceAbstraction, useValue: mockMasterPasswordService },
|
||||
{ provide: AccountService, useValue: mockAccountService },
|
||||
{ provide: PlatformUtilsService, useValue: mockPlatformUtilsService },
|
||||
],
|
||||
});
|
||||
|
||||
fixture = TestBed.createComponent(TestSsoComponent);
|
||||
component = fixture.componentInstance;
|
||||
_component = component as any;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Reset all mocks after each test
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it("should create", () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
describe("navigateViaCallbackOrRoute(...)", () => {
|
||||
it("calls the provided callback when callback is defined", async () => {
|
||||
const callback = jest.fn().mockResolvedValue(null);
|
||||
const commands = ["some", "route"];
|
||||
|
||||
await (component as any).navigateViaCallbackOrRoute(callback, commands);
|
||||
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
expect(mockRouter.navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls router.navigate when callback is not defined", async () => {
|
||||
const commands = ["some", "route"];
|
||||
|
||||
await (component as any).navigateViaCallbackOrRoute(undefined, commands);
|
||||
|
||||
expect(mockRouter.navigate).toHaveBeenCalledTimes(1);
|
||||
expect(mockRouter.navigate).toHaveBeenCalledWith(commands, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe("logIn(...)", () => {
|
||||
describe("2FA scenarios", () => {
|
||||
beforeEach(() => {
|
||||
const authResult = new AuthResult();
|
||||
authResult.twoFactorProviders = { [TwoFactorProviderType.Authenticator]: {} };
|
||||
|
||||
// use standard user with MP because this test is not concerned with password reset.
|
||||
selectedUserDecryptionOptions.next(mockUserDecryptionOpts.withMasterPassword);
|
||||
|
||||
mockLoginStrategyService.logIn.mockResolvedValue(authResult);
|
||||
});
|
||||
|
||||
it("calls authService.logIn and navigates to the component's defined 2FA route when the auth result requires 2FA and onSuccessfulLoginTwoFactorNavigate is not defined", async () => {
|
||||
await _component.logIn(code, codeVerifier, orgIdFromState);
|
||||
expect(mockLoginStrategyService.logIn).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(mockOnSuccessfulLoginTwoFactorNavigate).not.toHaveBeenCalled();
|
||||
|
||||
expect(mockRouter.navigate).toHaveBeenCalledTimes(1);
|
||||
expect(mockRouter.navigate).toHaveBeenCalledWith([_component.twoFactorRoute], {
|
||||
queryParams: {
|
||||
identifier: orgIdFromState,
|
||||
sso: "true",
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockLogService.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls onSuccessfulLoginTwoFactorNavigate instead of router.navigate when response.requiresTwoFactor is true and the callback is defined", async () => {
|
||||
mockOnSuccessfulLoginTwoFactorNavigate = jest.fn().mockResolvedValue(null);
|
||||
component.onSuccessfulLoginTwoFactorNavigate = mockOnSuccessfulLoginTwoFactorNavigate;
|
||||
|
||||
await _component.logIn(code, codeVerifier, orgIdFromState);
|
||||
|
||||
expect(mockLoginStrategyService.logIn).toHaveBeenCalledTimes(1);
|
||||
expect(mockOnSuccessfulLoginTwoFactorNavigate).toHaveBeenCalledTimes(1);
|
||||
expect(mockRouter.navigate).not.toHaveBeenCalled();
|
||||
expect(mockLogService.error).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// Shared test helpers
|
||||
const testChangePasswordOnSuccessfulLogin = () => {
|
||||
it("navigates to the component's defined change password route when onSuccessfulLoginChangePasswordNavigate callback is undefined", async () => {
|
||||
await _component.logIn(code, codeVerifier, orgIdFromState);
|
||||
expect(mockLoginStrategyService.logIn).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(mockOnSuccessfulLoginChangePasswordNavigate).not.toHaveBeenCalled();
|
||||
|
||||
expect(mockRouter.navigate).toHaveBeenCalledTimes(1);
|
||||
expect(mockRouter.navigate).toHaveBeenCalledWith([_component.changePasswordRoute], {
|
||||
queryParams: {
|
||||
identifier: orgIdFromState,
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockLogService.error).not.toHaveBeenCalled();
|
||||
});
|
||||
};
|
||||
|
||||
const testOnSuccessfulLoginChangePasswordNavigate = () => {
|
||||
it("calls onSuccessfulLoginChangePasswordNavigate instead of router.navigate when the callback is defined", async () => {
|
||||
mockOnSuccessfulLoginChangePasswordNavigate = jest.fn().mockResolvedValue(null);
|
||||
component.onSuccessfulLoginChangePasswordNavigate =
|
||||
mockOnSuccessfulLoginChangePasswordNavigate;
|
||||
|
||||
await _component.logIn(code, codeVerifier, orgIdFromState);
|
||||
|
||||
expect(mockLoginStrategyService.logIn).toHaveBeenCalledTimes(1);
|
||||
expect(mockOnSuccessfulLoginChangePasswordNavigate).toHaveBeenCalledTimes(1);
|
||||
expect(mockRouter.navigate).not.toHaveBeenCalled();
|
||||
expect(mockLogService.error).not.toHaveBeenCalled();
|
||||
});
|
||||
};
|
||||
|
||||
const testForceResetOnSuccessfulLogin = (reasonString: string) => {
|
||||
it(`navigates to the component's defined forcePasswordResetRoute when response.forcePasswordReset is ${reasonString}`, async () => {
|
||||
await _component.logIn(code, codeVerifier, orgIdFromState);
|
||||
|
||||
expect(mockLoginStrategyService.logIn).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(mockOnSuccessfulLoginForceResetNavigate).not.toHaveBeenCalled();
|
||||
|
||||
expect(mockRouter.navigate).toHaveBeenCalledTimes(1);
|
||||
expect(mockRouter.navigate).toHaveBeenCalledWith([_component.forcePasswordResetRoute], {
|
||||
queryParams: {
|
||||
identifier: orgIdFromState,
|
||||
},
|
||||
});
|
||||
expect(mockLogService.error).not.toHaveBeenCalled();
|
||||
});
|
||||
};
|
||||
|
||||
const testOnSuccessfulLoginForceResetNavigate = (reasonString: string) => {
|
||||
it(`calls onSuccessfulLoginForceResetNavigate instead of router.navigate when response.forcePasswordReset is ${reasonString} and the callback is defined`, async () => {
|
||||
mockOnSuccessfulLoginForceResetNavigate = jest.fn().mockResolvedValue(null);
|
||||
component.onSuccessfulLoginForceResetNavigate = mockOnSuccessfulLoginForceResetNavigate;
|
||||
|
||||
await _component.logIn(code, codeVerifier, orgIdFromState);
|
||||
|
||||
expect(mockLoginStrategyService.logIn).toHaveBeenCalledTimes(1);
|
||||
expect(mockOnSuccessfulLoginForceResetNavigate).toHaveBeenCalledTimes(1);
|
||||
expect(mockRouter.navigate).not.toHaveBeenCalled();
|
||||
expect(mockLogService.error).not.toHaveBeenCalled();
|
||||
});
|
||||
};
|
||||
|
||||
describe("Trusted Device Encryption scenarios", () => {
|
||||
beforeEach(() => {
|
||||
mockConfigService.getFeatureFlag.mockResolvedValue(true); // TDE enabled
|
||||
});
|
||||
|
||||
describe("Given Trusted Device Encryption is enabled and user needs to set a master password", () => {
|
||||
let authResult;
|
||||
beforeEach(() => {
|
||||
selectedUserDecryptionOptions.next(
|
||||
mockUserDecryptionOpts.noMasterPasswordWithTrustedDeviceWithManageResetPassword,
|
||||
);
|
||||
|
||||
authResult = new AuthResult();
|
||||
mockLoginStrategyService.logIn.mockResolvedValue(authResult);
|
||||
});
|
||||
|
||||
it("navigates to the component's defined trustedDeviceEncRoute route and sets correct flag when onSuccessfulLoginTdeNavigate is undefined ", async () => {
|
||||
await _component.logIn(code, codeVerifier, orgIdFromState);
|
||||
expect(mockLoginStrategyService.logIn).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(mockMasterPasswordService.mock.setForceSetPasswordReason).toHaveBeenCalledWith(
|
||||
ForceSetPasswordReason.TdeUserWithoutPasswordHasPasswordResetPermission,
|
||||
userId,
|
||||
);
|
||||
|
||||
expect(mockOnSuccessfulLoginTdeNavigate).not.toHaveBeenCalled();
|
||||
|
||||
expect(mockRouter.navigate).toHaveBeenCalledTimes(1);
|
||||
expect(mockRouter.navigate).toHaveBeenCalledWith(
|
||||
[_component.trustedDeviceEncRoute],
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect(mockLogService.error).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Given Trusted Device Encryption is enabled, user doesn't need to set a MP, and forcePasswordReset is required", () => {
|
||||
[
|
||||
ForceSetPasswordReason.AdminForcePasswordReset,
|
||||
// ForceResetPasswordReason.WeakMasterPassword, -- not possible in SSO flow as set client side
|
||||
].forEach((forceResetPasswordReason) => {
|
||||
const reasonString = ForceSetPasswordReason[forceResetPasswordReason];
|
||||
let authResult;
|
||||
beforeEach(() => {
|
||||
selectedUserDecryptionOptions.next(
|
||||
mockUserDecryptionOpts.withMasterPasswordAndTrustedDevice,
|
||||
);
|
||||
|
||||
authResult = new AuthResult();
|
||||
authResult.forcePasswordReset = ForceSetPasswordReason.AdminForcePasswordReset;
|
||||
mockLoginStrategyService.logIn.mockResolvedValue(authResult);
|
||||
});
|
||||
|
||||
testForceResetOnSuccessfulLogin(reasonString);
|
||||
testOnSuccessfulLoginForceResetNavigate(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.logIn.mockResolvedValue(authResult);
|
||||
});
|
||||
|
||||
it("navigates to the component's defined trusted device encryption route when login is successful and no callback is defined", async () => {
|
||||
await _component.logIn(code, codeVerifier, orgIdFromState);
|
||||
|
||||
expect(mockLoginStrategyService.logIn).toHaveBeenCalledTimes(1);
|
||||
expect(mockRouter.navigate).toHaveBeenCalledTimes(1);
|
||||
expect(mockRouter.navigate).toHaveBeenCalledWith(
|
||||
[_component.trustedDeviceEncRoute],
|
||||
undefined,
|
||||
);
|
||||
expect(mockLogService.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls onSuccessfulLoginTdeNavigate instead of router.navigate when the callback is defined", async () => {
|
||||
mockOnSuccessfulLoginTdeNavigate = jest.fn().mockResolvedValue(null);
|
||||
component.onSuccessfulLoginTdeNavigate = mockOnSuccessfulLoginTdeNavigate;
|
||||
|
||||
await _component.logIn(code, codeVerifier, orgIdFromState);
|
||||
|
||||
expect(mockLoginStrategyService.logIn).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(mockOnSuccessfulLoginTdeNavigate).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(mockRouter.navigate).not.toHaveBeenCalled();
|
||||
expect(mockLogService.error).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Set Master Password scenarios", () => {
|
||||
beforeEach(() => {
|
||||
const authResult = new AuthResult();
|
||||
mockLoginStrategyService.logIn.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();
|
||||
testOnSuccessfulLoginChangePasswordNavigate();
|
||||
});
|
||||
|
||||
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.logIn(code, codeVerifier, orgIdFromState);
|
||||
expect(mockLoginStrategyService.logIn).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(mockOnSuccessfulLoginChangePasswordNavigate).not.toHaveBeenCalled();
|
||||
expect(mockRouter.navigate).not.toHaveBeenCalledWith([_component.changePasswordRoute], {
|
||||
queryParams: {
|
||||
identifier: orgIdFromState,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Force Master Password Reset scenarios", () => {
|
||||
[
|
||||
ForceSetPasswordReason.AdminForcePasswordReset,
|
||||
// ForceResetPasswordReason.WeakMasterPassword, -- not possible in SSO flow as set client side
|
||||
].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.logIn.mockResolvedValue(authResult);
|
||||
});
|
||||
|
||||
testForceResetOnSuccessfulLogin(reasonString);
|
||||
testOnSuccessfulLoginForceResetNavigate(reasonString);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Success scenarios", () => {
|
||||
beforeEach(() => {
|
||||
const authResult = new AuthResult();
|
||||
authResult.twoFactorProviders = null;
|
||||
// use standard user with MP because this test is not concerned with password reset.
|
||||
selectedUserDecryptionOptions.next(mockUserDecryptionOpts.withMasterPassword);
|
||||
authResult.forcePasswordReset = ForceSetPasswordReason.None;
|
||||
mockLoginStrategyService.logIn.mockResolvedValue(authResult);
|
||||
});
|
||||
|
||||
it("calls authService.logIn and navigates to the component's defined success route when the login is successful", async () => {
|
||||
await _component.logIn(code, codeVerifier, orgIdFromState);
|
||||
|
||||
expect(mockLoginStrategyService.logIn).toHaveBeenCalled();
|
||||
|
||||
expect(mockOnSuccessfulLoginNavigate).not.toHaveBeenCalled();
|
||||
expect(mockOnSuccessfulLogin).not.toHaveBeenCalled();
|
||||
|
||||
expect(mockRouter.navigate).toHaveBeenCalledTimes(1);
|
||||
expect(mockRouter.navigate).toHaveBeenCalledWith([_component.successRoute], undefined);
|
||||
expect(mockLogService.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls onSuccessfulLogin if defined when login is successful", async () => {
|
||||
mockOnSuccessfulLogin = jest.fn().mockResolvedValue(null);
|
||||
component.onSuccessfulLogin = mockOnSuccessfulLogin;
|
||||
|
||||
await _component.logIn(code, codeVerifier, orgIdFromState);
|
||||
|
||||
expect(mockLoginStrategyService.logIn).toHaveBeenCalled();
|
||||
expect(mockOnSuccessfulLogin).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(mockOnSuccessfulLoginNavigate).not.toHaveBeenCalled();
|
||||
|
||||
expect(mockRouter.navigate).toHaveBeenCalledTimes(1);
|
||||
expect(mockRouter.navigate).toHaveBeenCalledWith([_component.successRoute], undefined);
|
||||
|
||||
expect(mockLogService.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls onSuccessfulLoginNavigate instead of router.navigate when login is successful and the callback is defined", async () => {
|
||||
mockOnSuccessfulLoginNavigate = jest.fn().mockResolvedValue(null);
|
||||
component.onSuccessfulLoginNavigate = mockOnSuccessfulLoginNavigate;
|
||||
|
||||
await _component.logIn(code, codeVerifier, orgIdFromState);
|
||||
|
||||
expect(mockLoginStrategyService.logIn).toHaveBeenCalled();
|
||||
|
||||
expect(mockOnSuccessfulLoginNavigate).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(mockRouter.navigate).not.toHaveBeenCalled();
|
||||
|
||||
expect(mockLogService.error).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error scenarios", () => {
|
||||
it("calls handleLoginError when an error is thrown during logIn", async () => {
|
||||
const errorMessage = "Key Connector error";
|
||||
const error = new Error(errorMessage);
|
||||
mockLoginStrategyService.logIn.mockRejectedValue(error);
|
||||
|
||||
const handleLoginErrorSpy = jest.spyOn(_component, "handleLoginError");
|
||||
|
||||
await _component.logIn(code, codeVerifier, orgIdFromState);
|
||||
|
||||
expect(handleLoginErrorSpy).toHaveBeenCalledWith(error);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleLoginError(e)", () => {
|
||||
it("logs the error and shows a toast when the error message is 'Key Connector error'", async () => {
|
||||
const errorMessage = "Key Connector error";
|
||||
const error = new Error(errorMessage);
|
||||
|
||||
mockI18nService.t.mockReturnValueOnce("ssoKeyConnectorError");
|
||||
|
||||
await _component.handleLoginError(error);
|
||||
|
||||
expect(mockLogService.error).toHaveBeenCalledTimes(1);
|
||||
expect(mockLogService.error).toHaveBeenCalledWith(error);
|
||||
|
||||
expect(mockToastService.showToast).toHaveBeenCalledTimes(1);
|
||||
expect(mockToastService.showToast).toHaveBeenCalledWith({
|
||||
variant: "error",
|
||||
title: null,
|
||||
message: "ssoKeyConnectorError",
|
||||
});
|
||||
|
||||
expect(mockRouter.navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,422 +0,0 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Directive, OnInit } from "@angular/core";
|
||||
import { ActivatedRoute, NavigationExtras, Router } from "@angular/router";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
import { first } from "rxjs/operators";
|
||||
|
||||
import {
|
||||
LoginStrategyServiceAbstraction,
|
||||
SsoLoginCredentials,
|
||||
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 { 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 { 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 { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { ToastService } from "@bitwarden/components";
|
||||
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
|
||||
|
||||
@Directive()
|
||||
export class SsoComponent implements OnInit {
|
||||
identifier: string;
|
||||
loggingIn = false;
|
||||
|
||||
formPromise: Promise<AuthResult>;
|
||||
initiateSsoFormPromise: Promise<SsoPreValidateResponse>;
|
||||
onSuccessfulLogin: () => Promise<void>;
|
||||
onSuccessfulLoginNavigate: () => Promise<void>;
|
||||
onSuccessfulLoginTwoFactorNavigate: () => Promise<void>;
|
||||
onSuccessfulLoginChangePasswordNavigate: () => Promise<void>;
|
||||
onSuccessfulLoginForceResetNavigate: () => Promise<void>;
|
||||
|
||||
onSuccessfulLoginTde: () => Promise<void>;
|
||||
onSuccessfulLoginTdeNavigate: () => Promise<void>;
|
||||
|
||||
protected twoFactorRoute = "2fa";
|
||||
protected successRoute = "lock";
|
||||
protected trustedDeviceEncRoute = "login-initiated";
|
||||
protected changePasswordRoute = "set-password-jit";
|
||||
protected forcePasswordResetRoute = "update-temp-password";
|
||||
protected clientId: string;
|
||||
protected redirectUri: string;
|
||||
protected state: string;
|
||||
protected codeChallenge: string;
|
||||
|
||||
constructor(
|
||||
protected ssoLoginService: SsoLoginServiceAbstraction,
|
||||
protected loginStrategyService: LoginStrategyServiceAbstraction,
|
||||
protected router: Router,
|
||||
protected i18nService: I18nService,
|
||||
protected route: ActivatedRoute,
|
||||
protected stateService: StateService,
|
||||
protected platformUtilsService: PlatformUtilsService,
|
||||
protected apiService: ApiService,
|
||||
protected cryptoFunctionService: CryptoFunctionService,
|
||||
protected environmentService: EnvironmentService,
|
||||
protected passwordGenerationService: PasswordGenerationServiceAbstraction,
|
||||
protected logService: LogService,
|
||||
protected userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction,
|
||||
protected configService: ConfigService,
|
||||
protected masterPasswordService: InternalMasterPasswordServiceAbstraction,
|
||||
protected accountService: AccountService,
|
||||
protected toastService: ToastService,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
// eslint-disable-next-line rxjs/no-async-subscribe
|
||||
this.route.queryParams.pipe(first()).subscribe(async (qParams) => {
|
||||
if (qParams.code != null && qParams.state != null) {
|
||||
const codeVerifier = await this.ssoLoginService.getCodeVerifier();
|
||||
const state = await this.ssoLoginService.getSsoState();
|
||||
await this.ssoLoginService.setCodeVerifier(null);
|
||||
await this.ssoLoginService.setSsoState(null);
|
||||
|
||||
if (qParams.redirectUri != null) {
|
||||
this.redirectUri = qParams.redirectUri;
|
||||
}
|
||||
|
||||
if (
|
||||
qParams.code != null &&
|
||||
codeVerifier != null &&
|
||||
state != null &&
|
||||
this.checkState(state, qParams.state)
|
||||
) {
|
||||
const ssoOrganizationIdentifier = this.getOrgIdentifierFromState(qParams.state);
|
||||
await this.logIn(qParams.code, codeVerifier, ssoOrganizationIdentifier);
|
||||
}
|
||||
} else if (
|
||||
qParams.clientId != null &&
|
||||
qParams.redirectUri != null &&
|
||||
qParams.state != null &&
|
||||
qParams.codeChallenge != null
|
||||
) {
|
||||
this.redirectUri = qParams.redirectUri;
|
||||
this.state = qParams.state;
|
||||
this.codeChallenge = qParams.codeChallenge;
|
||||
this.clientId = qParams.clientId;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async submit(returnUri?: string, includeUserIdentifier?: boolean) {
|
||||
if (this.identifier == null || this.identifier === "") {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: this.i18nService.t("ssoValidationFailed"),
|
||||
message: this.i18nService.t("ssoIdentifierRequired"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.initiateSsoFormPromise = this.apiService.preValidateSso(this.identifier);
|
||||
const response = await this.initiateSsoFormPromise;
|
||||
|
||||
const authorizeUrl = await this.buildAuthorizeUrl(
|
||||
returnUri,
|
||||
includeUserIdentifier,
|
||||
response.token,
|
||||
);
|
||||
this.platformUtilsService.launchUri(authorizeUrl, { sameWindow: true });
|
||||
}
|
||||
|
||||
protected async buildAuthorizeUrl(
|
||||
returnUri?: string,
|
||||
includeUserIdentifier?: boolean,
|
||||
token?: string,
|
||||
): Promise<string> {
|
||||
let codeChallenge = this.codeChallenge;
|
||||
let state = this.state;
|
||||
|
||||
const passwordOptions: any = {
|
||||
type: "password",
|
||||
length: 64,
|
||||
uppercase: true,
|
||||
lowercase: true,
|
||||
numbers: true,
|
||||
special: false,
|
||||
};
|
||||
|
||||
if (codeChallenge == null) {
|
||||
const codeVerifier = await this.passwordGenerationService.generatePassword(passwordOptions);
|
||||
const codeVerifierHash = await this.cryptoFunctionService.hash(codeVerifier, "sha256");
|
||||
codeChallenge = Utils.fromBufferToUrlB64(codeVerifierHash);
|
||||
await this.ssoLoginService.setCodeVerifier(codeVerifier);
|
||||
}
|
||||
|
||||
if (state == null) {
|
||||
state = await this.passwordGenerationService.generatePassword(passwordOptions);
|
||||
if (returnUri) {
|
||||
state += `_returnUri='${returnUri}'`;
|
||||
}
|
||||
}
|
||||
|
||||
// Add Organization Identifier to state
|
||||
state += `_identifier=${this.identifier}`;
|
||||
|
||||
// Save state (regardless of new or existing)
|
||||
await this.ssoLoginService.setSsoState(state);
|
||||
|
||||
const env = await firstValueFrom(this.environmentService.environment$);
|
||||
|
||||
let authorizeUrl =
|
||||
env.getIdentityUrl() +
|
||||
"/connect/authorize?" +
|
||||
"client_id=" +
|
||||
this.clientId +
|
||||
"&redirect_uri=" +
|
||||
encodeURIComponent(this.redirectUri) +
|
||||
"&" +
|
||||
"response_type=code&scope=api offline_access&" +
|
||||
"state=" +
|
||||
state +
|
||||
"&code_challenge=" +
|
||||
codeChallenge +
|
||||
"&" +
|
||||
"code_challenge_method=S256&response_mode=query&" +
|
||||
"domain_hint=" +
|
||||
encodeURIComponent(this.identifier) +
|
||||
"&ssoToken=" +
|
||||
encodeURIComponent(token);
|
||||
|
||||
if (includeUserIdentifier) {
|
||||
const userIdentifier = await this.apiService.getSsoUserIdentifier();
|
||||
authorizeUrl += `&user_identifier=${encodeURIComponent(userIdentifier)}`;
|
||||
}
|
||||
|
||||
return authorizeUrl;
|
||||
}
|
||||
|
||||
private async logIn(code: string, codeVerifier: string, orgSsoIdentifier: string): Promise<void> {
|
||||
this.loggingIn = true;
|
||||
try {
|
||||
const email = await this.ssoLoginService.getSsoEmail();
|
||||
|
||||
const credentials = new SsoLoginCredentials(
|
||||
code,
|
||||
codeVerifier,
|
||||
this.redirectUri,
|
||||
orgSsoIdentifier,
|
||||
email,
|
||||
);
|
||||
this.formPromise = this.loginStrategyService.logIn(credentials);
|
||||
const authResult = await this.formPromise;
|
||||
|
||||
if (authResult.requiresTwoFactor) {
|
||||
return await this.handleTwoFactorRequired(orgSsoIdentifier);
|
||||
}
|
||||
|
||||
// Everything after the 2FA check is considered a successful login
|
||||
// Just have to figure out where to send the user
|
||||
|
||||
// Save off the OrgSsoIdentifier for use in the TDE flows (or elsewhere)
|
||||
// - TDE login decryption options component
|
||||
// - Browser SSO on extension open
|
||||
// Note: you cannot set this in state before 2FA b/c there won't be an account in state.
|
||||
const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
|
||||
await this.ssoLoginService.setActiveUserOrganizationSsoIdentifier(orgSsoIdentifier, userId);
|
||||
|
||||
// Users enrolled in admin acct recovery can be forced to set a new password after
|
||||
// having the admin set a temp password for them (affects TDE & standard users)
|
||||
if (authResult.forcePasswordReset == ForceSetPasswordReason.AdminForcePasswordReset) {
|
||||
// Weak password is not a valid scenario here b/c we cannot have evaluated a MP yet
|
||||
return await this.handleForcePasswordReset(orgSsoIdentifier);
|
||||
}
|
||||
|
||||
// must come after 2fa check since user decryption options aren't available if 2fa is required
|
||||
const userDecryptionOpts = await firstValueFrom(
|
||||
this.userDecryptionOptionsService.userDecryptionOptions$,
|
||||
);
|
||||
|
||||
const tdeEnabled = await this.isTrustedDeviceEncEnabled(
|
||||
userDecryptionOpts.trustedDeviceOption,
|
||||
);
|
||||
|
||||
if (tdeEnabled) {
|
||||
return await this.handleTrustedDeviceEncryptionEnabled(
|
||||
authResult,
|
||||
orgSsoIdentifier,
|
||||
userDecryptionOpts,
|
||||
);
|
||||
}
|
||||
|
||||
// In the standard, non TDE case, a user must set password if they don't
|
||||
// have one and they aren't using key connector.
|
||||
// Note: TDE & Key connector are mutually exclusive org config options.
|
||||
const requireSetPassword =
|
||||
!userDecryptionOpts.hasMasterPassword &&
|
||||
userDecryptionOpts.keyConnectorOption === undefined;
|
||||
|
||||
if (requireSetPassword || authResult.resetMasterPassword) {
|
||||
// Change implies going no password -> password in this case
|
||||
return await this.handleChangePasswordRequired(orgSsoIdentifier);
|
||||
}
|
||||
|
||||
// Standard SSO login success case
|
||||
return await this.handleSuccessfulLogin();
|
||||
} catch (e) {
|
||||
await this.handleLoginError(e);
|
||||
}
|
||||
}
|
||||
|
||||
private async isTrustedDeviceEncEnabled(
|
||||
trustedDeviceOption: TrustedDeviceUserDecryptionOption,
|
||||
): Promise<boolean> {
|
||||
return trustedDeviceOption !== undefined;
|
||||
}
|
||||
|
||||
private async handleTwoFactorRequired(orgIdentifier: string) {
|
||||
await this.navigateViaCallbackOrRoute(
|
||||
this.onSuccessfulLoginTwoFactorNavigate,
|
||||
[this.twoFactorRoute],
|
||||
{
|
||||
queryParams: {
|
||||
identifier: orgIdentifier,
|
||||
sso: "true",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private async handleTrustedDeviceEncryptionEnabled(
|
||||
authResult: AuthResult,
|
||||
orgIdentifier: string,
|
||||
userDecryptionOpts: UserDecryptionOptions,
|
||||
): Promise<void> {
|
||||
// Tde offboarding takes precedence
|
||||
if (
|
||||
!userDecryptionOpts.hasMasterPassword &&
|
||||
userDecryptionOpts.trustedDeviceOption.isTdeOffboarding
|
||||
) {
|
||||
const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
|
||||
await this.masterPasswordService.setForceSetPasswordReason(
|
||||
ForceSetPasswordReason.TdeOffboarding,
|
||||
userId,
|
||||
);
|
||||
} else if (
|
||||
// If user doesn't have a MP, but has reset password permission, they must set a MP
|
||||
!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 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) {
|
||||
// Don't await b/c causes hang on desktop & browser
|
||||
// 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.navigateViaCallbackOrRoute(
|
||||
this.onSuccessfulLoginChangePasswordNavigate,
|
||||
[this.changePasswordRoute],
|
||||
{
|
||||
queryParams: {
|
||||
identifier: orgIdentifier,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private async handleForcePasswordReset(orgIdentifier: string) {
|
||||
await this.navigateViaCallbackOrRoute(
|
||||
this.onSuccessfulLoginForceResetNavigate,
|
||||
[this.forcePasswordResetRoute],
|
||||
{
|
||||
queryParams: {
|
||||
identifier: orgIdentifier,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private async handleSuccessfulLogin() {
|
||||
if (this.onSuccessfulLogin != null) {
|
||||
// Don't await b/c causes hang on desktop & browser
|
||||
// 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 handleLoginError(e: any) {
|
||||
this.logService.error(e);
|
||||
|
||||
// TODO: Key Connector Service should pass this error message to the logout callback instead of displaying here
|
||||
if (e.message === "Key Connector error") {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: null,
|
||||
message: this.i18nService.t("ssoKeyConnectorError"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async navigateViaCallbackOrRoute(
|
||||
callback: () => Promise<unknown>,
|
||||
commands: unknown[],
|
||||
extras?: NavigationExtras,
|
||||
): Promise<void> {
|
||||
if (callback) {
|
||||
await callback();
|
||||
} else {
|
||||
await this.router.navigate(commands, extras);
|
||||
}
|
||||
}
|
||||
|
||||
private getOrgIdentifierFromState(state: string): string {
|
||||
if (state === null || state === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const stateSplit = state.split("_identifier=");
|
||||
return stateSplit.length > 1 ? stateSplit[1] : null;
|
||||
}
|
||||
|
||||
private checkState(state: string, checkState: string): boolean {
|
||||
if (state === null || state === undefined) {
|
||||
return false;
|
||||
}
|
||||
if (checkState === null || checkState === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const stateSplit = state.split("_identifier=");
|
||||
const checkStateSplit = checkState.split("_identifier=");
|
||||
return stateSplit[0] === checkStateSplit[0];
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
// @ts-strict-ignore
|
||||
import { Directive } from "@angular/core";
|
||||
import { Router } from "@angular/router";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
|
||||
@@ -10,6 +11,7 @@ import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/ma
|
||||
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
|
||||
import { VerificationType } from "@bitwarden/common/auth/enums/verification-type";
|
||||
import { PasswordRequest } from "@bitwarden/common/auth/models/request/password.request";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { Verification } from "@bitwarden/common/auth/types/verification";
|
||||
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
@@ -96,8 +98,8 @@ export class UpdatePasswordComponent extends BaseChangePasswordComponent {
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
this.kdfConfig = await this.kdfConfigService.getKdfConfig();
|
||||
const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
|
||||
this.kdfConfig = await this.kdfConfigService.getKdfConfig(userId);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -110,10 +110,11 @@ export class UpdateTempPasswordComponent extends BaseChangePasswordComponent imp
|
||||
}
|
||||
|
||||
async setupSubmitActions(): Promise<boolean> {
|
||||
this.email = await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(map((a) => a?.email)),
|
||||
const [userId, email] = await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(map((a) => [a?.id, a?.email])),
|
||||
);
|
||||
this.kdfConfig = await this.kdfConfigService.getKdfConfig();
|
||||
this.email = email;
|
||||
this.kdfConfig = await this.kdfConfigService.getKdfConfig(userId);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 320 KiB After Width: | Height: | Size: 81 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -101,200 +101,102 @@ $icomoon-font-path: "~@bitwarden/angular/src/scss/bwicons/fonts/" !default;
|
||||
|
||||
// For new icons - add their glyph name and value to the map below
|
||||
$icons: (
|
||||
"universal-access": "\e991",
|
||||
"save-changes": "\e988",
|
||||
"browser": "\e985",
|
||||
"browser-alt": "\e9a3",
|
||||
"mobile": "\e986",
|
||||
"mobile-alt": "\e9a4",
|
||||
"cli": "\e987",
|
||||
"providers": "\e983",
|
||||
"vault": "\e984",
|
||||
"vault-f": "\e9ab",
|
||||
"folder-closed-f": "\e982",
|
||||
"rocket": "\e9ee",
|
||||
"ellipsis-h": "\e9ef",
|
||||
"ellipsis-v": "\e9f0",
|
||||
"safari": "\e974",
|
||||
"opera": "\e975",
|
||||
"firefox": "\e976",
|
||||
"edge": "\e977",
|
||||
"chrome": "\e978",
|
||||
"star-f": "\e979",
|
||||
"arrow-circle-up": "\e97a",
|
||||
"arrow-circle-right": "\e97b",
|
||||
"arrow-circle-left": "\e97c",
|
||||
"arrow-circle-down": "\e97d",
|
||||
"undo": "\e97e",
|
||||
"bolt": "\e97f",
|
||||
"puzzle": "\e980",
|
||||
"rss": "\e973",
|
||||
"dbl-angle-left": "\e970",
|
||||
"dbl-angle-right": "\e971",
|
||||
"hamburger": "\e972",
|
||||
"bw-folder-open-f1": "\e93e",
|
||||
"desktop": "\e96a",
|
||||
"desktop-alt": "\e9a2",
|
||||
"angle-up": "\e969",
|
||||
"user": "\e900",
|
||||
"user-f": "\e901",
|
||||
"user-monitor": "\e9a7",
|
||||
"key": "\e902",
|
||||
"share-square": "\e903",
|
||||
"hashtag": "\e904",
|
||||
"clone": "\e905",
|
||||
"list-alt": "\e906",
|
||||
"id-card": "\e907",
|
||||
"credit-card": "\e908",
|
||||
"globe": "\e909",
|
||||
"sticky-note": "\e90a",
|
||||
"folder": "\e90b",
|
||||
"lock": "\e90c",
|
||||
"lock-f": "\e90d",
|
||||
"generate": "\e90e",
|
||||
"generate-f": "\e90f",
|
||||
"cog": "\e910",
|
||||
"cog-f": "\e911",
|
||||
"check-circle": "\e912",
|
||||
"eye": "\e913",
|
||||
"pencil-square": "\e914",
|
||||
"bookmark": "\e915",
|
||||
"files": "\e916",
|
||||
"trash": "\e917",
|
||||
"plus": "\e918",
|
||||
"plus-f": "\e9a9",
|
||||
"star": "\e919",
|
||||
"list": "\e91a",
|
||||
"angle-down": "\e92d",
|
||||
"external-link": "\e91c",
|
||||
"refresh": "\e91d",
|
||||
"search": "\e91f",
|
||||
"filter": "\e920",
|
||||
"plus-circle": "\e921",
|
||||
"user-circle": "\e922",
|
||||
"question-circle": "\e923",
|
||||
"cogs": "\e924",
|
||||
"minus-circle": "\e925",
|
||||
"send": "\e926",
|
||||
"send-f": "\e927",
|
||||
"download": "\e928",
|
||||
"pencil": "\e929",
|
||||
"sign-out": "\e92a",
|
||||
"share": "\e92b",
|
||||
"clock": "\e92c",
|
||||
"angle-left": "\e96b",
|
||||
"caret-left": "\e92e",
|
||||
"square": "\e92f",
|
||||
"collection": "\e930",
|
||||
"bank": "\e931",
|
||||
"shield": "\e932",
|
||||
"stop": "\e933",
|
||||
"plus-square": "\e934",
|
||||
"save": "\e935",
|
||||
"sign-in": "\e936",
|
||||
"spinner": "\e937",
|
||||
"dollar": "\e939",
|
||||
"check": "\e93a",
|
||||
"check-square": "\e93b",
|
||||
"minus-square": "\e93c",
|
||||
"close": "\e93d",
|
||||
"share-arrow": "\e96c",
|
||||
"paperclip": "\e93f",
|
||||
"bitcoin": "\e940",
|
||||
"cut": "\e941",
|
||||
"frown": "\e942",
|
||||
"folder-open": "\e943",
|
||||
"bug": "\e946",
|
||||
"chain-broken": "\e947",
|
||||
"dashboard": "\e948",
|
||||
"envelope": "\e949",
|
||||
"exclamation-circle": "\e94a",
|
||||
"exclamation-triangle": "\e94b",
|
||||
"caret-right": "\e94c",
|
||||
"file-pdf": "\e94e",
|
||||
"file-text": "\e94f",
|
||||
"info-circle": "\e952",
|
||||
"lightbulb": "\e953",
|
||||
"link": "\e954",
|
||||
"linux": "\e956",
|
||||
"long-arrow-right": "\e957",
|
||||
"money": "\e958",
|
||||
"play": "\e959",
|
||||
"reddit": "\e95a",
|
||||
"refresh-tab": "\e95b",
|
||||
"sitemap": "\e95c",
|
||||
"sliders": "\e95d",
|
||||
"tag": "\e95e",
|
||||
"thumb-tack": "\e95f",
|
||||
"thumbs-up": "\e960",
|
||||
"unlock": "\e962",
|
||||
"users": "\e963",
|
||||
"wrench": "\e965",
|
||||
"ban": "\e967",
|
||||
"camera": "\e968",
|
||||
"angle-right": "\e91b",
|
||||
"eye-slash": "\e96d",
|
||||
"file": "\e96e",
|
||||
"paste": "\e96f",
|
||||
"github": "\e950",
|
||||
"facebook": "\e94d",
|
||||
"paypal": "\e938",
|
||||
"brave": "\e951",
|
||||
"google": "\e9a5",
|
||||
"duckduckgo": "\e9bb",
|
||||
"tor": "\e9bc",
|
||||
"vivaldi": "\e9bd",
|
||||
"linkedin": "\e955",
|
||||
"discourse": "\e91e",
|
||||
"twitter": "\e961",
|
||||
"x-twitter": "\e9be",
|
||||
"youtube": "\e966",
|
||||
"windows": "\e964",
|
||||
"apple": "\e945",
|
||||
"android": "\e944",
|
||||
"error": "\e981",
|
||||
"numbered-list": "\e989",
|
||||
"billing": "\e98a",
|
||||
"family": "\e98b",
|
||||
"provider": "\e98c",
|
||||
"business": "\e98d",
|
||||
"learning": "\e98e",
|
||||
"chat": "\e990",
|
||||
"server": "\e98f",
|
||||
"search-book": "\e992",
|
||||
"twitch": "\e993",
|
||||
"community": "\e994",
|
||||
"mastodon": "\e995",
|
||||
"insurance": "\e996",
|
||||
"wireless": "\e997",
|
||||
"software-license": "\e998",
|
||||
"instagram": "\e999",
|
||||
"down-solid": "\e99a",
|
||||
"up-solid": "\e99b",
|
||||
"up-down-btn": "\e99c",
|
||||
"caret-up": "\e99d",
|
||||
"caret-down": "\e99e",
|
||||
"passkey": "\e99f",
|
||||
"lock-encrypted": "\e9a0",
|
||||
"back": "\e9a8",
|
||||
"popout": "\e9aa",
|
||||
"wand": "\e9a6",
|
||||
"msp": "\e9a1",
|
||||
"totp-codes-alt": "\e9ac",
|
||||
"totp-codes-alt2": "\e9ad",
|
||||
"totp-codes": "\e9ae",
|
||||
"authenticator": "\e9af",
|
||||
"fingerprint": "\e9b0",
|
||||
"expired": "\e9ba",
|
||||
"icon-1": "\e9b1",
|
||||
"icon-2": "\e9b2",
|
||||
"icon-3": "\e9b3",
|
||||
"icon-4": "\e9b4",
|
||||
"icon-5": "\e9b5",
|
||||
"icon-6": "\e9b6",
|
||||
"icon-7": "\e9b7",
|
||||
"icon-8": "\e9b8",
|
||||
"icon-9": "\e9b9",
|
||||
"angle-down": "\e900",
|
||||
"angle-left": "\e901",
|
||||
"angle-right": "\e902",
|
||||
"angle-up": "\e903",
|
||||
"bell": "\e904",
|
||||
"billing": "\e905",
|
||||
"bitcoin": "\e906",
|
||||
"browser-alt": "\e907",
|
||||
"browser": "\e908",
|
||||
"brush": "\e909",
|
||||
"bug": "\e90a",
|
||||
"business": "\e90b",
|
||||
"camera": "\e90c",
|
||||
"check-circle": "\e90e",
|
||||
"check": "\e90f",
|
||||
"cli": "\e910",
|
||||
"clock": "\e911",
|
||||
"close": "\e912",
|
||||
"cog-f": "\e913",
|
||||
"cog": "\e914",
|
||||
"collection": "\e915",
|
||||
"collection-shared": "\e916",
|
||||
"clone": "\e917",
|
||||
"dollar": "\e919",
|
||||
"down-solid": "\e91a",
|
||||
"download": "\e91b",
|
||||
"drag-and-drop": "\e91c",
|
||||
"ellipsis-h": "\e91d",
|
||||
"ellipsis-v": "\e91e",
|
||||
"envelope": "\e91f",
|
||||
"error": "\e920",
|
||||
"exclamation-triangle": "\e921",
|
||||
"external-link": "\e922",
|
||||
"eye-slash": "\e923",
|
||||
"eye": "\e924",
|
||||
"family": "\e925",
|
||||
"file-text": "\e926",
|
||||
"file": "\e927",
|
||||
"files": "\e928",
|
||||
"filter": "\e929",
|
||||
"folder": "\e92a",
|
||||
"generate": "\e92b",
|
||||
"globe": "\e92c",
|
||||
"hashtag": "\e92d",
|
||||
"id-card": "\e92e",
|
||||
"info-circle": "\e92f",
|
||||
"import": "\e930",
|
||||
"key": "\e931",
|
||||
"list-alt": "\e933",
|
||||
"list": "\e934",
|
||||
"lock-encrypted": "\e935",
|
||||
"lock-f": "\e936",
|
||||
"lock": "\e937",
|
||||
"shield": "\e938",
|
||||
"minus-circle": "\e939",
|
||||
"mobile": "\e93a",
|
||||
"msp": "\e93b",
|
||||
"sticky-note": "\e93c",
|
||||
"numbered-list": "\e93d",
|
||||
"paperclip": "\e93e",
|
||||
"passkey": "\e93f",
|
||||
"pencil-square": "\e940",
|
||||
"pencil": "\e941",
|
||||
"plus-circle": "\e942",
|
||||
"plus": "\e943",
|
||||
"popout": "\e944",
|
||||
"provider": "\e945",
|
||||
"puzzle": "\e946",
|
||||
"question-circle": "\e947",
|
||||
"refresh": "\e948",
|
||||
"search": "\e949",
|
||||
"send": "\e94a",
|
||||
"share": "\e94b",
|
||||
"sign-in": "\e94c",
|
||||
"sign-out": "\e94d",
|
||||
"sliders": "\e94e",
|
||||
"spinner": "\e94f",
|
||||
"star-f": "\e950",
|
||||
"star": "\e951",
|
||||
"tag": "\e952",
|
||||
"trash": "\e953",
|
||||
"undo": "\e954",
|
||||
"universal-access": "\e955",
|
||||
"unlock": "\e956",
|
||||
"up-down-btn": "\e957",
|
||||
"up-solid": "\e958",
|
||||
"user-monitor": "\e959",
|
||||
"user": "\e95a",
|
||||
"users": "\e95b",
|
||||
"vault": "\e95c",
|
||||
"wireless": "\e95d",
|
||||
"wrench": "\e95e",
|
||||
"paypal": "\e95f",
|
||||
"credit-card": "\e9a2",
|
||||
"desktop": "\e9a3",
|
||||
"archive": "\e9c1",
|
||||
);
|
||||
|
||||
@each $name, $glyph in $icons {
|
||||
|
||||
@@ -138,11 +138,13 @@ import {
|
||||
import { AccountBillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/account/account-billing-api.service.abstraction";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
|
||||
import { OrganizationBillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/organizations/organization-billing-api.service.abstraction";
|
||||
import { OrganizationSponsorshipApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/organizations/organization-sponsorship-api.service.abstraction";
|
||||
import { TaxServiceAbstraction } from "@bitwarden/common/billing/abstractions/tax.service.abstraction";
|
||||
import { AccountBillingApiService } from "@bitwarden/common/billing/services/account/account-billing-api.service";
|
||||
import { DefaultBillingAccountProfileStateService } from "@bitwarden/common/billing/services/account/billing-account-profile-state.service";
|
||||
import { BillingApiService } from "@bitwarden/common/billing/services/billing-api.service";
|
||||
import { OrganizationBillingApiService } from "@bitwarden/common/billing/services/organization/organization-billing-api.service";
|
||||
import { OrganizationSponsorshipApiService } from "@bitwarden/common/billing/services/organization/organization-sponsorship-api.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";
|
||||
@@ -312,7 +314,7 @@ import {
|
||||
UserAsymmetricKeysRegenerationService,
|
||||
} from "@bitwarden/key-management";
|
||||
import { SafeInjectionToken } from "@bitwarden/ui-common";
|
||||
import { NewDeviceVerificationNoticeService, PasswordRepromptService } from "@bitwarden/vault";
|
||||
import { PasswordRepromptService } from "@bitwarden/vault";
|
||||
import {
|
||||
IndividualVaultExportService,
|
||||
IndividualVaultExportServiceAbstraction,
|
||||
@@ -586,7 +588,11 @@ const safeProviders: SafeProvider[] = [
|
||||
useClass: AvatarService,
|
||||
deps: [ApiServiceAbstraction, StateProvider],
|
||||
}),
|
||||
safeProvider({ provide: LogService, useFactory: () => new ConsoleLogService(false), deps: [] }),
|
||||
safeProvider({
|
||||
provide: LogService,
|
||||
useFactory: () => new ConsoleLogService(process.env.NODE_ENV === "development"),
|
||||
deps: [],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: CollectionService,
|
||||
useClass: DefaultCollectionService,
|
||||
@@ -1065,6 +1071,11 @@ const safeProviders: SafeProvider[] = [
|
||||
// subscribes to sync notifications and will update itself based on that.
|
||||
deps: [ApiServiceAbstraction, SyncService],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: OrganizationSponsorshipApiServiceAbstraction,
|
||||
useClass: OrganizationSponsorshipApiService,
|
||||
deps: [ApiServiceAbstraction],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: OrganizationBillingApiServiceAbstraction,
|
||||
useClass: OrganizationBillingApiService,
|
||||
@@ -1450,7 +1461,6 @@ const safeProviders: SafeProvider[] = [
|
||||
useClass: DefaultLoginDecryptionOptionsService,
|
||||
deps: [MessagingServiceAbstraction],
|
||||
}),
|
||||
safeProvider(NewDeviceVerificationNoticeService),
|
||||
safeProvider({
|
||||
provide: UserAsymmetricKeysRegenerationApiService,
|
||||
useClass: DefaultUserAsymmetricKeysRegenerationApiService,
|
||||
|
||||
@@ -202,7 +202,7 @@ export class AttachmentsComponent implements OnInit {
|
||||
attachment.key != null
|
||||
? attachment.key
|
||||
: await this.keyService.getOrgKey(this.cipher.organizationId);
|
||||
const decBuf = await this.encryptService.decryptToBytes(encBuf, key);
|
||||
const decBuf = await this.encryptService.decryptFileData(encBuf, key);
|
||||
this.fileDownloadService.download({
|
||||
fileName: attachment.fileName,
|
||||
blobData: decBuf,
|
||||
@@ -281,7 +281,7 @@ export class AttachmentsComponent implements OnInit {
|
||||
attachment.key != null
|
||||
? attachment.key
|
||||
: await this.keyService.getOrgKey(this.cipher.organizationId);
|
||||
const decBuf = await this.encryptService.decryptToBytes(encBuf, key);
|
||||
const decBuf = await this.encryptService.decryptFileData(encBuf, key);
|
||||
const activeUserId = await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(getUserId),
|
||||
);
|
||||
@@ -343,7 +343,12 @@ export class AttachmentsComponent implements OnInit {
|
||||
}
|
||||
|
||||
protected deleteCipherAttachment(attachmentId: string, userId: UserId) {
|
||||
return this.cipherService.deleteAttachmentWithServer(this.cipher.id, attachmentId, userId);
|
||||
return this.cipherService.deleteAttachmentWithServer(
|
||||
this.cipher.id,
|
||||
attachmentId,
|
||||
userId,
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
protected async reupload(attachment: AttachmentView) {
|
||||
|
||||
@@ -463,7 +463,7 @@ export class ViewComponent implements OnDestroy, OnInit {
|
||||
attachment.key != null
|
||||
? attachment.key
|
||||
: await this.keyService.getOrgKey(this.cipher.organizationId);
|
||||
const decBuf = await this.encryptService.decryptToBytes(encBuf, key);
|
||||
const decBuf = await this.encryptService.decryptFileData(encBuf, key);
|
||||
this.fileDownloadService.download({
|
||||
fileName: attachment.fileName,
|
||||
blobData: decBuf,
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./new-device-verification-notice.guard";
|
||||
@@ -1,293 +0,0 @@
|
||||
import { TestBed } from "@angular/core/testing";
|
||||
import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from "@angular/router";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { NewDeviceVerificationNoticeService } from "@bitwarden/vault";
|
||||
|
||||
import { VaultProfileService } from "../services/vault-profile.service";
|
||||
|
||||
import { NewDeviceVerificationNoticeGuard } from "./new-device-verification-notice.guard";
|
||||
|
||||
describe("NewDeviceVerificationNoticeGuard", () => {
|
||||
const _state = Object.freeze({}) as RouterStateSnapshot;
|
||||
const emptyRoute = Object.freeze({ queryParams: {} }) as ActivatedRouteSnapshot;
|
||||
const eightDaysAgo = new Date();
|
||||
eightDaysAgo.setDate(eightDaysAgo.getDate() - 8);
|
||||
|
||||
const account = {
|
||||
id: "account-id",
|
||||
} as unknown as Account;
|
||||
|
||||
const activeAccount$ = new BehaviorSubject<Account | null>(account);
|
||||
|
||||
const createUrlTree = jest.fn();
|
||||
const getFeatureFlag = jest.fn().mockImplementation((key) => {
|
||||
if (key === FeatureFlag.NewDeviceVerificationTemporaryDismiss) {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
return Promise.resolve(false);
|
||||
});
|
||||
const isSelfHost = jest.fn().mockReturnValue(false);
|
||||
const getProfileTwoFactorEnabled = jest.fn().mockResolvedValue(false);
|
||||
const noticeState$ = jest.fn().mockReturnValue(new BehaviorSubject(null));
|
||||
const skipState$ = jest.fn().mockReturnValue(new BehaviorSubject(null));
|
||||
const getProfileCreationDate = jest.fn().mockResolvedValue(eightDaysAgo);
|
||||
const hasMasterPasswordAndMasterKeyHash = jest.fn().mockResolvedValue(true);
|
||||
const getUserSSOBound = jest.fn().mockResolvedValue(false);
|
||||
const getUserSSOBoundAdminOwner = jest.fn().mockResolvedValue(false);
|
||||
|
||||
beforeEach(() => {
|
||||
getFeatureFlag.mockClear();
|
||||
isSelfHost.mockClear();
|
||||
getProfileCreationDate.mockClear();
|
||||
getProfileTwoFactorEnabled.mockClear();
|
||||
createUrlTree.mockClear();
|
||||
hasMasterPasswordAndMasterKeyHash.mockClear();
|
||||
getUserSSOBound.mockClear();
|
||||
getUserSSOBoundAdminOwner.mockClear();
|
||||
skipState$.mockClear();
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: Router, useValue: { createUrlTree } },
|
||||
{ provide: ConfigService, useValue: { getFeatureFlag } },
|
||||
{ provide: NewDeviceVerificationNoticeService, useValue: { noticeState$, skipState$ } },
|
||||
{ provide: AccountService, useValue: { activeAccount$ } },
|
||||
{ provide: PlatformUtilsService, useValue: { isSelfHost } },
|
||||
{ provide: UserVerificationService, useValue: { hasMasterPasswordAndMasterKeyHash } },
|
||||
{
|
||||
provide: VaultProfileService,
|
||||
useValue: {
|
||||
getProfileCreationDate,
|
||||
getProfileTwoFactorEnabled,
|
||||
getUserSSOBound,
|
||||
getUserSSOBoundAdminOwner,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
function newDeviceGuard(route?: ActivatedRouteSnapshot) {
|
||||
// Run the guard within injection context so `inject` works as you'd expect
|
||||
// Pass state object to make TypeScript happy
|
||||
return TestBed.runInInjectionContext(async () =>
|
||||
NewDeviceVerificationNoticeGuard(route ?? emptyRoute, _state),
|
||||
);
|
||||
}
|
||||
|
||||
describe("fromNewDeviceVerification", () => {
|
||||
const route = {
|
||||
queryParams: { fromNewDeviceVerification: "true" },
|
||||
} as unknown as ActivatedRouteSnapshot;
|
||||
|
||||
it("returns `true` when `fromNewDeviceVerification` is present", async () => {
|
||||
expect(await newDeviceGuard(route)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not execute other logic", async () => {
|
||||
// `fromNewDeviceVerification` param should exit early,
|
||||
// not foolproof but a quick way to test that other logic isn't executed
|
||||
await newDeviceGuard(route);
|
||||
|
||||
expect(getFeatureFlag).not.toHaveBeenCalled();
|
||||
expect(isSelfHost).not.toHaveBeenCalled();
|
||||
expect(getProfileTwoFactorEnabled).not.toHaveBeenCalled();
|
||||
expect(getProfileCreationDate).not.toHaveBeenCalled();
|
||||
expect(hasMasterPasswordAndMasterKeyHash).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("missing current account", () => {
|
||||
afterAll(() => {
|
||||
// reset `activeAccount$` observable
|
||||
activeAccount$.next(account);
|
||||
});
|
||||
|
||||
it("redirects to login when account is missing", async () => {
|
||||
activeAccount$.next(null);
|
||||
|
||||
await newDeviceGuard();
|
||||
|
||||
expect(createUrlTree).toHaveBeenCalledWith(["/login"]);
|
||||
});
|
||||
});
|
||||
|
||||
it("returns `true` when 2FA is enabled", async () => {
|
||||
getProfileTwoFactorEnabled.mockResolvedValueOnce(true);
|
||||
|
||||
expect(await newDeviceGuard()).toBe(true);
|
||||
});
|
||||
|
||||
it("returns `true` when the user is self hosted", async () => {
|
||||
isSelfHost.mockReturnValueOnce(true);
|
||||
|
||||
expect(await newDeviceGuard()).toBe(true);
|
||||
});
|
||||
|
||||
it("returns `true` when the profile was created less than a week ago", async () => {
|
||||
const sixDaysAgo = new Date();
|
||||
sixDaysAgo.setDate(sixDaysAgo.getDate() - 6);
|
||||
|
||||
getProfileCreationDate.mockResolvedValueOnce(sixDaysAgo);
|
||||
|
||||
expect(await newDeviceGuard()).toBe(true);
|
||||
});
|
||||
|
||||
it("returns `true` when the profile service throws an error", async () => {
|
||||
getProfileCreationDate.mockRejectedValueOnce(new Error("test"));
|
||||
|
||||
expect(await newDeviceGuard()).toBe(true);
|
||||
});
|
||||
|
||||
it("returns `true` when the skip state value is set to true", async () => {
|
||||
skipState$.mockReturnValueOnce(new BehaviorSubject(true));
|
||||
|
||||
expect(await newDeviceGuard()).toBe(true);
|
||||
expect(skipState$.mock.calls[0][0]).toBe("account-id");
|
||||
expect(skipState$.mock.calls.length).toBe(1);
|
||||
});
|
||||
|
||||
describe("SSO bound", () => {
|
||||
beforeEach(() => {
|
||||
getFeatureFlag.mockImplementation((key) => {
|
||||
if (key === FeatureFlag.NewDeviceVerificationPermanentDismiss) {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
return Promise.resolve(false);
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
getFeatureFlag.mockReturnValue(false);
|
||||
});
|
||||
|
||||
it('returns "true" when the user is SSO bound and not an admin or owner', async () => {
|
||||
getUserSSOBound.mockResolvedValueOnce(true);
|
||||
getUserSSOBoundAdminOwner.mockResolvedValueOnce(false);
|
||||
|
||||
expect(await newDeviceGuard()).toBe(true);
|
||||
});
|
||||
|
||||
it('returns "true" when the user is an admin or owner of an SSO bound organization and has not logged in with their master password', async () => {
|
||||
getUserSSOBound.mockResolvedValueOnce(true);
|
||||
getUserSSOBoundAdminOwner.mockResolvedValueOnce(true);
|
||||
hasMasterPasswordAndMasterKeyHash.mockResolvedValueOnce(false);
|
||||
|
||||
expect(await newDeviceGuard()).toBe(true);
|
||||
});
|
||||
|
||||
it("shows notice when the user is an admin or owner of an SSO bound organization and logged in with their master password", async () => {
|
||||
getUserSSOBound.mockResolvedValueOnce(true);
|
||||
getUserSSOBoundAdminOwner.mockResolvedValueOnce(true);
|
||||
hasMasterPasswordAndMasterKeyHash.mockResolvedValueOnce(true);
|
||||
|
||||
await newDeviceGuard();
|
||||
|
||||
expect(createUrlTree).toHaveBeenCalledWith(["/new-device-notice"]);
|
||||
});
|
||||
|
||||
it("shows notice when the user that is not in an SSO bound organization", async () => {
|
||||
getUserSSOBound.mockResolvedValueOnce(false);
|
||||
getUserSSOBoundAdminOwner.mockResolvedValueOnce(false);
|
||||
hasMasterPasswordAndMasterKeyHash.mockResolvedValueOnce(true);
|
||||
|
||||
await newDeviceGuard();
|
||||
|
||||
expect(createUrlTree).toHaveBeenCalledWith(["/new-device-notice"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("temp flag", () => {
|
||||
beforeEach(() => {
|
||||
getFeatureFlag.mockImplementation((key) => {
|
||||
if (key === FeatureFlag.NewDeviceVerificationTemporaryDismiss) {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
return Promise.resolve(false);
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
getFeatureFlag.mockReturnValue(false);
|
||||
});
|
||||
|
||||
it("redirects to notice when the user has not dismissed it", async () => {
|
||||
noticeState$.mockReturnValueOnce(new BehaviorSubject(null));
|
||||
|
||||
await newDeviceGuard();
|
||||
|
||||
expect(createUrlTree).toHaveBeenCalledWith(["/new-device-notice"]);
|
||||
expect(noticeState$).toHaveBeenCalledWith(account.id);
|
||||
});
|
||||
|
||||
it("redirects to notice when the user dismissed it more than 7 days ago", async () => {
|
||||
const eighteenDaysAgo = new Date();
|
||||
eighteenDaysAgo.setDate(eighteenDaysAgo.getDate() - 18);
|
||||
|
||||
noticeState$.mockReturnValueOnce(
|
||||
new BehaviorSubject({ last_dismissal: eighteenDaysAgo.toISOString() }),
|
||||
);
|
||||
|
||||
await newDeviceGuard();
|
||||
|
||||
expect(createUrlTree).toHaveBeenCalledWith(["/new-device-notice"]);
|
||||
});
|
||||
|
||||
it("returns true when the user dismissed less than 7 days ago", async () => {
|
||||
const fourDaysAgo = new Date();
|
||||
fourDaysAgo.setDate(fourDaysAgo.getDate() - 4);
|
||||
|
||||
noticeState$.mockReturnValueOnce(
|
||||
new BehaviorSubject({ last_dismissal: fourDaysAgo.toISOString() }),
|
||||
);
|
||||
|
||||
expect(await newDeviceGuard()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("permanent flag", () => {
|
||||
beforeEach(() => {
|
||||
getFeatureFlag.mockImplementation((key) => {
|
||||
if (key === FeatureFlag.NewDeviceVerificationPermanentDismiss) {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
return Promise.resolve(false);
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
getFeatureFlag.mockReturnValue(false);
|
||||
});
|
||||
|
||||
it("redirects when the user has not dismissed", async () => {
|
||||
noticeState$.mockReturnValueOnce(new BehaviorSubject(null));
|
||||
|
||||
await newDeviceGuard();
|
||||
|
||||
expect(createUrlTree).toHaveBeenCalledWith(["/new-device-notice"]);
|
||||
|
||||
noticeState$.mockReturnValueOnce(new BehaviorSubject({ permanent_dismissal: null }));
|
||||
|
||||
await newDeviceGuard();
|
||||
|
||||
expect(createUrlTree).toHaveBeenCalledTimes(2);
|
||||
expect(createUrlTree).toHaveBeenCalledWith(["/new-device-notice"]);
|
||||
});
|
||||
|
||||
it("returns `true` when the user has dismissed", async () => {
|
||||
noticeState$.mockReturnValueOnce(new BehaviorSubject({ permanent_dismissal: true }));
|
||||
|
||||
expect(await newDeviceGuard()).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,166 +0,0 @@
|
||||
import { inject } from "@angular/core";
|
||||
import { ActivatedRouteSnapshot, CanActivateFn, Router } from "@angular/router";
|
||||
import { firstValueFrom, Observable } from "rxjs";
|
||||
|
||||
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { NewDeviceVerificationNoticeService } from "@bitwarden/vault";
|
||||
|
||||
import { VaultProfileService } from "../services/vault-profile.service";
|
||||
|
||||
export const NewDeviceVerificationNoticeGuard: CanActivateFn = async (
|
||||
route: ActivatedRouteSnapshot,
|
||||
) => {
|
||||
const router = inject(Router);
|
||||
const configService = inject(ConfigService);
|
||||
const newDeviceVerificationNoticeService = inject(NewDeviceVerificationNoticeService);
|
||||
const accountService = inject(AccountService);
|
||||
const platformUtilsService = inject(PlatformUtilsService);
|
||||
const vaultProfileService = inject(VaultProfileService);
|
||||
const userVerificationService = inject(UserVerificationService);
|
||||
|
||||
if (route.queryParams["fromNewDeviceVerification"]) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const tempNoticeFlag = await configService.getFeatureFlag(
|
||||
FeatureFlag.NewDeviceVerificationTemporaryDismiss,
|
||||
);
|
||||
const permNoticeFlag = await configService.getFeatureFlag(
|
||||
FeatureFlag.NewDeviceVerificationPermanentDismiss,
|
||||
);
|
||||
|
||||
if (!tempNoticeFlag && !permNoticeFlag) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const currentAcct$: Observable<Account | null> = accountService.activeAccount$;
|
||||
const currentAcct = await firstValueFrom(currentAcct$);
|
||||
|
||||
if (!currentAcct) {
|
||||
return router.createUrlTree(["/login"]);
|
||||
}
|
||||
|
||||
// Currently used by the auth recovery login flow and will get cleaned up in PM-18485.
|
||||
if (await firstValueFrom(newDeviceVerificationNoticeService.skipState$(currentAcct.id))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const isSelfHosted = platformUtilsService.isSelfHost();
|
||||
const userIsSSOUser = await ssoAppliesToUser(
|
||||
userVerificationService,
|
||||
vaultProfileService,
|
||||
currentAcct.id,
|
||||
);
|
||||
const has2FAEnabled = await hasATwoFactorProviderEnabled(vaultProfileService, currentAcct.id);
|
||||
const isProfileLessThanWeekOld = await profileIsLessThanWeekOld(
|
||||
vaultProfileService,
|
||||
currentAcct.id,
|
||||
);
|
||||
|
||||
// When any of the following are true, the device verification notice is
|
||||
// not applicable for the user. When the user has *not* logged in with their
|
||||
// master password, assume they logged in with SSO.
|
||||
if (has2FAEnabled || isSelfHosted || userIsSSOUser || isProfileLessThanWeekOld) {
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// Skip showing the notice if there was a problem determining applicability
|
||||
// The most likely problem to occur is the user not having a network connection
|
||||
return true;
|
||||
}
|
||||
|
||||
const userItems$ = newDeviceVerificationNoticeService.noticeState$(currentAcct.id);
|
||||
const userItems = await firstValueFrom(userItems$);
|
||||
|
||||
// Show the notice when:
|
||||
// - The temp notice flag is enabled
|
||||
// - The user hasn't dismissed the notice or the user dismissed it more than 7 days ago
|
||||
if (
|
||||
tempNoticeFlag &&
|
||||
(!userItems?.last_dismissal || isMoreThan7DaysAgo(userItems?.last_dismissal))
|
||||
) {
|
||||
return router.createUrlTree(["/new-device-notice"]);
|
||||
}
|
||||
|
||||
// Show the notice when:
|
||||
// - The permanent notice flag is enabled
|
||||
// - The user hasn't dismissed the notice
|
||||
if (permNoticeFlag && !userItems?.permanent_dismissal) {
|
||||
return router.createUrlTree(["/new-device-notice"]);
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
/** Returns true has one 2FA provider enabled */
|
||||
async function hasATwoFactorProviderEnabled(
|
||||
vaultProfileService: VaultProfileService,
|
||||
userId: string,
|
||||
): Promise<boolean> {
|
||||
return vaultProfileService.getProfileTwoFactorEnabled(userId);
|
||||
}
|
||||
|
||||
/** Returns true when the user's profile is less than a week old */
|
||||
async function profileIsLessThanWeekOld(
|
||||
vaultProfileService: VaultProfileService,
|
||||
userId: string,
|
||||
): Promise<boolean> {
|
||||
const creationDate = await vaultProfileService.getProfileCreationDate(userId);
|
||||
return !isMoreThan7DaysAgo(creationDate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when either:
|
||||
* - The user is SSO bound to an organization and is not an Admin or Owner
|
||||
* - The user is an Admin or Owner of an organization with SSO bound and has not logged in with their master password
|
||||
*
|
||||
* NOTE: There are edge cases where this does not satisfy the original requirement of showing the notice to
|
||||
* users who are subject to the SSO required policy. When Owners and Admins log in with their MP they will see the notice
|
||||
* when they log in with SSO they will not. This is a concession made because the original logic references policies would not work for TDE users.
|
||||
* When this guard is run for those users a sync hasn't occurred and thus the policies are not available.
|
||||
*/
|
||||
async function ssoAppliesToUser(
|
||||
userVerificationService: UserVerificationService,
|
||||
vaultProfileService: VaultProfileService,
|
||||
userId: string,
|
||||
) {
|
||||
const userSSOBound = await vaultProfileService.getUserSSOBound(userId);
|
||||
const userSSOBoundAdminOwner = await vaultProfileService.getUserSSOBoundAdminOwner(userId);
|
||||
const userLoggedInWithMP = await userLoggedInWithMasterPassword(userVerificationService, userId);
|
||||
|
||||
const nonOwnerAdminSsoUser = userSSOBound && !userSSOBoundAdminOwner;
|
||||
const ssoAdminOwnerLoggedInWithMP = userSSOBoundAdminOwner && !userLoggedInWithMP;
|
||||
|
||||
return nonOwnerAdminSsoUser || ssoAdminOwnerLoggedInWithMP;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the user logged in with their master password.
|
||||
*/
|
||||
async function userLoggedInWithMasterPassword(
|
||||
userVerificationService: UserVerificationService,
|
||||
userId: string,
|
||||
) {
|
||||
return userVerificationService.hasMasterPasswordAndMasterKeyHash(userId);
|
||||
}
|
||||
|
||||
/** Returns the true when the date given is older than 7 days */
|
||||
function isMoreThan7DaysAgo(date?: string | Date): boolean {
|
||||
if (!date) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const inputDate = new Date(date).getTime();
|
||||
const today = new Date().getTime();
|
||||
|
||||
const differenceInMS = today - inputDate;
|
||||
const msInADay = 1000 * 60 * 60 * 24;
|
||||
const differenceInDays = Math.round(differenceInMS / msInADay);
|
||||
|
||||
return differenceInDays > 7;
|
||||
}
|
||||
@@ -68,94 +68,4 @@ describe("VaultProfileService", () => {
|
||||
expect(getProfile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getProfileTwoFactorEnabled", () => {
|
||||
it("calls `getProfile` when stored 2FA property is not stored", async () => {
|
||||
expect(service["profile2FAEnabled"]).toBeNull();
|
||||
|
||||
const twoFactorEnabled = await service.getProfileTwoFactorEnabled(userId);
|
||||
|
||||
expect(twoFactorEnabled).toBe(true);
|
||||
expect(getProfile).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls `getProfile` when stored profile id does not match", async () => {
|
||||
service["profile2FAEnabled"] = false;
|
||||
service["userId"] = "old-user-id";
|
||||
|
||||
const twoFactorEnabled = await service.getProfileTwoFactorEnabled(userId);
|
||||
|
||||
expect(twoFactorEnabled).toBe(true);
|
||||
expect(getProfile).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not call `getProfile` when 2FA property is already stored", async () => {
|
||||
service["profile2FAEnabled"] = false;
|
||||
|
||||
const twoFactorEnabled = await service.getProfileTwoFactorEnabled(userId);
|
||||
|
||||
expect(twoFactorEnabled).toBe(false);
|
||||
expect(getProfile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getUserSSOBound", () => {
|
||||
it("calls `getProfile` when stored ssoBound property is not stored", async () => {
|
||||
expect(service["userIsSsoBound"]).toBeNull();
|
||||
|
||||
const userIsSsoBound = await service.getUserSSOBound(userId);
|
||||
|
||||
expect(userIsSsoBound).toBe(true);
|
||||
expect(getProfile).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls `getProfile` when stored profile id does not match", async () => {
|
||||
service["userIsSsoBound"] = false;
|
||||
service["userId"] = "old-user-id";
|
||||
|
||||
const userIsSsoBound = await service.getUserSSOBound(userId);
|
||||
|
||||
expect(userIsSsoBound).toBe(true);
|
||||
expect(getProfile).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not call `getProfile` when ssoBound property is already stored", async () => {
|
||||
service["userIsSsoBound"] = false;
|
||||
|
||||
const userIsSsoBound = await service.getUserSSOBound(userId);
|
||||
|
||||
expect(userIsSsoBound).toBe(false);
|
||||
expect(getProfile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getUserSSOBoundAdminOwner", () => {
|
||||
it("calls `getProfile` when stored userIsSsoBoundAdminOwner property is not stored", async () => {
|
||||
expect(service["userIsSsoBoundAdminOwner"]).toBeNull();
|
||||
|
||||
const userIsSsoBoundAdminOwner = await service.getUserSSOBoundAdminOwner(userId);
|
||||
|
||||
expect(userIsSsoBoundAdminOwner).toBe(true);
|
||||
expect(getProfile).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls `getProfile` when stored profile id does not match", async () => {
|
||||
service["userIsSsoBoundAdminOwner"] = false;
|
||||
service["userId"] = "old-user-id";
|
||||
|
||||
const userIsSsoBoundAdminOwner = await service.getUserSSOBoundAdminOwner(userId);
|
||||
|
||||
expect(userIsSsoBoundAdminOwner).toBe(true);
|
||||
expect(getProfile).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not call `getProfile` when userIsSsoBoundAdminOwner property is already stored", async () => {
|
||||
service["userIsSsoBoundAdminOwner"] = false;
|
||||
|
||||
const userIsSsoBoundAdminOwner = await service.getUserSSOBoundAdminOwner(userId);
|
||||
|
||||
expect(userIsSsoBoundAdminOwner).toBe(false);
|
||||
expect(getProfile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
import { Injectable, inject } from "@angular/core";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { OrganizationUserType } from "@bitwarden/common/admin-console/enums";
|
||||
import { ProfileResponse } from "@bitwarden/common/models/response/profile.response";
|
||||
|
||||
@Injectable({
|
||||
providedIn: "root",
|
||||
})
|
||||
/**
|
||||
* Class to provide profile level details without having to call the API each time.
|
||||
* NOTE: This is a temporary service and can be replaced once the `UnauthenticatedExtensionUIRefresh` flag goes live.
|
||||
* The `UnauthenticatedExtensionUIRefresh` introduces a sync that takes place upon logging in. These details can then
|
||||
* be added to account object and retrieved from there.
|
||||
* TODO: PM-16202
|
||||
* Class to provide profile level details to vault entities without having to call the API each time.
|
||||
*/
|
||||
export class VaultProfileService {
|
||||
private apiService = inject(ApiService);
|
||||
@@ -22,15 +17,6 @@ export class VaultProfileService {
|
||||
/** Profile creation stored as a string. */
|
||||
private profileCreatedDate: string | null = null;
|
||||
|
||||
/** True when 2FA is enabled on the profile. */
|
||||
private profile2FAEnabled: boolean | null = null;
|
||||
|
||||
/** True when ssoBound is true for any of the users organizations */
|
||||
private userIsSsoBound: boolean | null = null;
|
||||
|
||||
/** True when the user is an admin or owner of the ssoBound organization */
|
||||
private userIsSsoBoundAdminOwner: boolean | null = null;
|
||||
|
||||
/**
|
||||
* Returns the creation date of the profile.
|
||||
* Note: `Date`s are mutable in JS, creating a new
|
||||
@@ -46,56 +32,11 @@ export class VaultProfileService {
|
||||
return new Date(profile.creationDate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether there is a 2FA provider on the profile.
|
||||
*/
|
||||
async getProfileTwoFactorEnabled(userId: string): Promise<boolean> {
|
||||
if (this.profile2FAEnabled !== null && userId === this.userId) {
|
||||
return Promise.resolve(this.profile2FAEnabled);
|
||||
}
|
||||
|
||||
const profile = await this.fetchAndCacheProfile();
|
||||
|
||||
return profile.twoFactorEnabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the user logs in with SSO for any organization.
|
||||
*/
|
||||
async getUserSSOBound(userId: string): Promise<boolean> {
|
||||
if (this.userIsSsoBound !== null && userId === this.userId) {
|
||||
return Promise.resolve(this.userIsSsoBound);
|
||||
}
|
||||
|
||||
await this.fetchAndCacheProfile();
|
||||
|
||||
return !!this.userIsSsoBound;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the user is an Admin or Owner of an organization with `ssoBound` true.
|
||||
*/
|
||||
async getUserSSOBoundAdminOwner(userId: string): Promise<boolean> {
|
||||
if (this.userIsSsoBoundAdminOwner !== null && userId === this.userId) {
|
||||
return Promise.resolve(this.userIsSsoBoundAdminOwner);
|
||||
}
|
||||
|
||||
await this.fetchAndCacheProfile();
|
||||
|
||||
return !!this.userIsSsoBoundAdminOwner;
|
||||
}
|
||||
|
||||
private async fetchAndCacheProfile(): Promise<ProfileResponse> {
|
||||
const profile = await this.apiService.getProfile();
|
||||
|
||||
this.userId = profile.id;
|
||||
this.profileCreatedDate = profile.creationDate;
|
||||
this.profile2FAEnabled = profile.twoFactorEnabled;
|
||||
const ssoBoundOrg = profile.organizations.find((org) => org.ssoBound);
|
||||
this.userIsSsoBound = !!ssoBoundOrg;
|
||||
this.userIsSsoBoundAdminOwner =
|
||||
ssoBoundOrg?.type === OrganizationUserType.Admin ||
|
||||
ssoBoundOrg?.type === OrganizationUserType.Owner;
|
||||
|
||||
return profile;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user