1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-12 06:23:38 +00:00

Merge remote-tracking branch 'origin' into auth/pm-7882/email-verification-removal

This commit is contained in:
Patrick Pimentel
2024-12-27 17:35:51 -05:00
468 changed files with 11501 additions and 7965 deletions

View File

@@ -1,398 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Directive, NgZone, OnDestroy, OnInit } from "@angular/core";
import { Router } from "@angular/router";
import { firstValueFrom, Subject } from "rxjs";
import { concatMap, map, take, takeUntil } from "rxjs/operators";
import { PinServiceAbstraction, PinLockType } from "@bitwarden/auth/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service";
import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service";
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
import { InternalPolicyService } 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 { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust.service.abstraction";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { VerificationType } from "@bitwarden/common/auth/enums/verification-type";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
import {
MasterPasswordVerification,
MasterPasswordVerificationResponse,
} from "@bitwarden/common/auth/types/verification";
import { ClientType } from "@bitwarden/common/enums";
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 { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { KeySuffixOptions } from "@bitwarden/common/platform/enums";
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
import { UserId } from "@bitwarden/common/types/guid";
import { UserKey } from "@bitwarden/common/types/key";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { DialogService, ToastService } from "@bitwarden/components";
import {
KdfConfigService,
KeyService,
BiometricStateService,
BiometricsService,
} from "@bitwarden/key-management";
@Directive()
export class LockComponent implements OnInit, OnDestroy {
masterPassword = "";
pin = "";
showPassword = false;
email: string;
pinEnabled = false;
masterPasswordEnabled = false;
webVaultHostname = "";
formPromise: Promise<MasterPasswordVerificationResponse>;
supportsBiometric: boolean;
biometricLock: boolean;
private activeUserId: UserId;
protected successRoute = "vault";
protected forcePasswordResetRoute = "update-temp-password";
protected onSuccessfulSubmit: () => Promise<void>;
private invalidPinAttempts = 0;
private pinLockType: PinLockType;
private enforcedMasterPasswordOptions: MasterPasswordPolicyOptions = undefined;
private destroy$ = new Subject<void>();
constructor(
protected masterPasswordService: InternalMasterPasswordServiceAbstraction,
protected router: Router,
protected i18nService: I18nService,
protected platformUtilsService: PlatformUtilsService,
protected messagingService: MessagingService,
protected keyService: KeyService,
protected vaultTimeoutService: VaultTimeoutService,
protected vaultTimeoutSettingsService: VaultTimeoutSettingsService,
protected environmentService: EnvironmentService,
protected stateService: StateService,
protected apiService: ApiService,
protected logService: LogService,
protected ngZone: NgZone,
protected policyApiService: PolicyApiServiceAbstraction,
protected policyService: InternalPolicyService,
protected passwordStrengthService: PasswordStrengthServiceAbstraction,
protected dialogService: DialogService,
protected deviceTrustService: DeviceTrustServiceAbstraction,
protected userVerificationService: UserVerificationService,
protected pinService: PinServiceAbstraction,
protected biometricStateService: BiometricStateService,
protected biometricsService: BiometricsService,
protected accountService: AccountService,
protected authService: AuthService,
protected kdfConfigService: KdfConfigService,
protected syncService: SyncService,
protected toastService: ToastService,
) {}
async ngOnInit() {
this.accountService.activeAccount$
.pipe(
concatMap(async (account) => {
this.activeUserId = account?.id;
await this.load(account?.id);
}),
takeUntil(this.destroy$),
)
.subscribe();
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
async submit() {
if (this.pinEnabled) {
return await this.handlePinRequiredUnlock();
}
await this.handleMasterPasswordRequiredUnlock();
}
async logOut() {
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "logOut" },
content: { key: "logOutConfirmation" },
acceptButtonText: { key: "logOut" },
type: "warning",
});
if (confirmed) {
this.messagingService.send("logout", { userId: this.activeUserId });
}
}
async unlockBiometric(): Promise<boolean> {
if (!this.biometricLock) {
return;
}
await this.biometricStateService.setUserPromptCancelled();
const userKey = await this.keyService.getUserKeyFromStorage(
KeySuffixOptions.Biometric,
this.activeUserId,
);
if (userKey) {
await this.setUserKeyAndContinue(userKey, this.activeUserId, false);
}
return !!userKey;
}
async isBiometricUnlockAvailable(): Promise<boolean> {
if (!(await this.biometricsService.supportsBiometric())) {
return false;
}
return this.biometricsService.isBiometricUnlockAvailable();
}
togglePassword() {
this.showPassword = !this.showPassword;
const input = document.getElementById(this.pinEnabled ? "pin" : "masterPassword");
if (this.ngZone.isStable) {
input.focus();
} else {
this.ngZone.onStable.pipe(take(1)).subscribe(() => input.focus());
}
}
private async handlePinRequiredUnlock() {
if (this.pin == null || this.pin === "") {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: this.i18nService.t("pinRequired"),
});
return;
}
return await this.doUnlockWithPin();
}
private async doUnlockWithPin() {
const MAX_INVALID_PIN_ENTRY_ATTEMPTS = 5;
try {
const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
const userKey = await this.pinService.decryptUserKeyWithPin(this.pin, userId);
if (userKey) {
await this.setUserKeyAndContinue(userKey, userId);
return; // successfully unlocked
}
// Failure state: invalid PIN or failed decryption
this.invalidPinAttempts++;
// Log user out if they have entered an invalid PIN too many times
if (this.invalidPinAttempts >= MAX_INVALID_PIN_ENTRY_ATTEMPTS) {
this.toastService.showToast({
variant: "error",
title: null,
message: this.i18nService.t("tooManyInvalidPinEntryAttemptsLoggingOut"),
});
this.messagingService.send("logout");
return;
}
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: this.i18nService.t("invalidPin"),
});
} catch {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: this.i18nService.t("unexpectedError"),
});
}
}
private async handleMasterPasswordRequiredUnlock() {
if (this.masterPassword == null || this.masterPassword === "") {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: this.i18nService.t("masterPasswordRequired"),
});
return;
}
await this.doUnlockWithMasterPassword();
}
private async doUnlockWithMasterPassword() {
const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
const verification = {
type: VerificationType.MasterPassword,
secret: this.masterPassword,
} as MasterPasswordVerification;
let passwordValid = false;
let response: MasterPasswordVerificationResponse;
try {
this.formPromise = this.userVerificationService.verifyUserByMasterPassword(
verification,
userId,
this.email,
);
response = await this.formPromise;
this.enforcedMasterPasswordOptions = MasterPasswordPolicyOptions.fromResponse(
response.policyOptions,
);
passwordValid = true;
} catch (e) {
this.logService.error(e);
} finally {
this.formPromise = null;
}
if (!passwordValid) {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: this.i18nService.t("invalidMasterPassword"),
});
return;
}
const userKey = await this.masterPasswordService.decryptUserKeyWithMasterKey(
response.masterKey,
userId,
);
await this.setUserKeyAndContinue(userKey, userId, true);
}
private async setUserKeyAndContinue(
key: UserKey,
userId: UserId,
evaluatePasswordAfterUnlock = false,
) {
await this.keyService.setUserKey(key, userId);
// 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$);
await this.deviceTrustService.trustDeviceIfRequired(activeAccount.id);
await this.doContinue(evaluatePasswordAfterUnlock);
}
private async doContinue(evaluatePasswordAfterUnlock: boolean) {
await this.biometricStateService.resetUserPromptCancelled();
this.messagingService.send("unlocked");
if (evaluatePasswordAfterUnlock) {
try {
// If we do not have any saved policies, attempt to load them from the service
if (this.enforcedMasterPasswordOptions == undefined) {
this.enforcedMasterPasswordOptions = await firstValueFrom(
this.policyService.masterPasswordPolicyOptions$(),
);
}
if (this.requirePasswordChange()) {
const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
await this.masterPasswordService.setForceSetPasswordReason(
ForceSetPasswordReason.WeakMasterPassword,
userId,
);
// 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]);
return;
}
} catch (e) {
// Do not prevent unlock if there is an error evaluating policies
this.logService.error(e);
}
}
// Vault can be de-synced since notifications get ignored while locked. Need to check whether sync is required using the sync service.
const clientType = this.platformUtilsService.getClientType();
if (clientType === ClientType.Browser || clientType === ClientType.Desktop) {
// Desktop and Browser have better offline support and to facilitate this we don't make the user wait for what
// could be an HTTP Timeout because their server is unreachable.
await Promise.race([
this.syncService
.fullSync(false)
.catch((err) => this.logService.error("Error during unlock sync", err)),
new Promise<void>((resolve) =>
setTimeout(() => {
this.logService.warning("Skipping sync wait, continuing to unlock.");
resolve();
}, 5_000),
),
]);
} else {
await this.syncService.fullSync(false);
}
if (this.onSuccessfulSubmit != null) {
await this.onSuccessfulSubmit();
} else if (this.router != 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.successRoute]);
}
}
private async load(userId: UserId) {
this.pinLockType = await this.pinService.getPinLockType(userId);
this.pinEnabled = await this.pinService.isPinDecryptionAvailable(userId);
this.masterPasswordEnabled = await this.userVerificationService.hasMasterPassword();
this.supportsBiometric = await this.biometricsService.supportsBiometric();
this.biometricLock =
(await this.vaultTimeoutSettingsService.isBiometricLockSet()) &&
((await this.keyService.hasUserKeyStored(KeySuffixOptions.Biometric)) ||
!this.platformUtilsService.supportsSecureStorage());
this.email = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.email)),
);
this.webVaultHostname = (await this.environmentService.getEnvironment()).getHostname();
}
/**
* Checks if the master password meets the enforced policy requirements
* If not, returns false
*/
private requirePasswordChange(): boolean {
if (
this.enforcedMasterPasswordOptions == undefined ||
!this.enforcedMasterPasswordOptions.enforceOnLogin
) {
return false;
}
const passwordStrength = this.passwordStrengthService.getPasswordStrength(
this.masterPassword,
this.email,
)?.score;
return !this.policyService.evaluateMasterPassword(
passwordStrength,
this.masterPassword,
this.enforcedMasterPasswordOptions,
);
}
}

View File

@@ -298,6 +298,7 @@ import {
IndividualVaultExportServiceAbstraction,
} from "@bitwarden/vault-export-core";
import { NewDeviceVerificationNoticeService } from "../../../vault/src/services/new-device-verification-notice.service";
import { FormValidationErrorsService as FormValidationErrorsServiceAbstraction } from "../platform/abstractions/form-validation-errors.service";
import { ViewCacheService } from "../platform/abstractions/view-cache.service";
import { FormValidationErrorsService } from "../platform/services/form-validation-errors.service";
@@ -1383,7 +1384,6 @@ const safeProviders: SafeProvider[] = [
AccountServiceAbstraction,
KdfConfigService,
KeyServiceAbstraction,
ApiServiceAbstraction,
],
}),
safeProvider({
@@ -1401,6 +1401,7 @@ const safeProviders: SafeProvider[] = [
useClass: DefaultLoginDecryptionOptionsService,
deps: [MessagingServiceAbstraction],
}),
safeProvider(NewDeviceVerificationNoticeService),
safeProvider({
provide: UserAsymmetricKeysRegenerationApiService,
useClass: DefaultUserAsymmetricKeysRegenerationApiService,

View File

@@ -0,0 +1 @@
export * from "./new-device-verification-notice.guard";

View File

@@ -0,0 +1,225 @@
import { TestBed } from "@angular/core/testing";
import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from "@angular/router";
import { BehaviorSubject } from "rxjs";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
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 "../../../../vault/src/services/new-device-verification-notice.service";
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().mockResolvedValue(false);
const getProfileTwoFactorEnabled = jest.fn().mockResolvedValue(false);
const policyAppliesToActiveUser$ = jest.fn().mockReturnValue(new BehaviorSubject<boolean>(false));
const noticeState$ = jest.fn().mockReturnValue(new BehaviorSubject(null));
const getProfileCreationDate = jest.fn().mockResolvedValue(eightDaysAgo);
beforeEach(() => {
getFeatureFlag.mockClear();
isSelfHost.mockClear();
getProfileCreationDate.mockClear();
getProfileTwoFactorEnabled.mockClear();
policyAppliesToActiveUser$.mockClear();
createUrlTree.mockClear();
TestBed.configureTestingModule({
providers: [
{ provide: Router, useValue: { createUrlTree } },
{ provide: ConfigService, useValue: { getFeatureFlag } },
{ provide: NewDeviceVerificationNoticeService, useValue: { noticeState$ } },
{ provide: AccountService, useValue: { activeAccount$ } },
{ provide: PlatformUtilsService, useValue: { isSelfHost } },
{ provide: PolicyService, useValue: { policyAppliesToActiveUser$ } },
{
provide: VaultProfileService,
useValue: { getProfileCreationDate, getProfileTwoFactorEnabled },
},
],
});
});
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(policyAppliesToActiveUser$).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` SSO is required", async () => {
policyAppliesToActiveUser$.mockReturnValueOnce(new BehaviorSubject(true));
expect(await newDeviceGuard()).toBe(true);
expect(policyAppliesToActiveUser$).toHaveBeenCalledWith(PolicyType.RequireSso);
});
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);
});
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);
});
});
});

View File

@@ -0,0 +1,121 @@
import { inject } from "@angular/core";
import { ActivatedRouteSnapshot, CanActivateFn, Router } from "@angular/router";
import { Observable, firstValueFrom } from "rxjs";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
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 "../../../../vault/src/services/new-device-verification-notice.service";
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 policyService = inject(PolicyService);
const vaultProfileService = inject(VaultProfileService);
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"]);
}
const has2FAEnabled = await hasATwoFactorProviderEnabled(vaultProfileService, currentAcct.id);
const isSelfHosted = await platformUtilsService.isSelfHost();
const requiresSSO = await isSSORequired(policyService);
const isProfileLessThanWeekOld = await profileIsLessThanWeekOld(
vaultProfileService,
currentAcct.id,
);
// When any of the following are true, the device verification notice is
// not applicable for the user.
if (has2FAEnabled || isSelfHosted || requiresSSO || isProfileLessThanWeekOld) {
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 the user is required to login via SSO */
async function isSSORequired(policyService: PolicyService) {
return firstValueFrom(policyService.policyAppliesToActiveUser$(PolicyType.RequireSso));
}
/** 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;
}

View File

@@ -0,0 +1,94 @@
import { TestBed } from "@angular/core/testing";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { VaultProfileService } from "./vault-profile.service";
describe("VaultProfileService", () => {
let service: VaultProfileService;
const userId = "profile-id";
const hardcodedDateString = "2024-02-24T12:00:00Z";
const getProfile = jest.fn().mockResolvedValue({
creationDate: hardcodedDateString,
twoFactorEnabled: true,
id: "new-user-id",
});
beforeEach(() => {
getProfile.mockClear();
TestBed.configureTestingModule({
providers: [{ provide: ApiService, useValue: { getProfile } }],
});
jest.useFakeTimers();
jest.setSystemTime(new Date("2024-02-22T00:00:00Z"));
service = TestBed.runInInjectionContext(() => new VaultProfileService());
service["userId"] = userId;
});
afterEach(() => {
jest.useRealTimers();
});
describe("getProfileCreationDate", () => {
it("calls `getProfile` when stored profile date is not set", async () => {
expect(service["profileCreatedDate"]).toBeNull();
const date = await service.getProfileCreationDate(userId);
expect(date.toISOString()).toBe("2024-02-24T12:00:00.000Z");
expect(getProfile).toHaveBeenCalled();
});
it("calls `getProfile` when stored profile id does not match", async () => {
service["profileCreatedDate"] = hardcodedDateString;
service["userId"] = "old-user-id";
const date = await service.getProfileCreationDate(userId);
expect(date.toISOString()).toBe("2024-02-24T12:00:00.000Z");
expect(getProfile).toHaveBeenCalled();
});
it("does not call `getProfile` when the date is already stored", async () => {
service["profileCreatedDate"] = hardcodedDateString;
const date = await service.getProfileCreationDate(userId);
expect(date.toISOString()).toBe("2024-02-24T12:00:00.000Z");
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();
});
});
});

View File

@@ -0,0 +1,64 @@
import { Injectable, inject } from "@angular/core";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
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
*/
export class VaultProfileService {
private apiService = inject(ApiService);
private userId: string | null = null;
/** Profile creation stored as a string. */
private profileCreatedDate: string | null = null;
/** True when 2FA is enabled on the profile. */
private profile2FAEnabled: boolean | null = null;
/**
* Returns the creation date of the profile.
* Note: `Date`s are mutable in JS, creating a new
* instance is important to avoid unwanted changes.
*/
async getProfileCreationDate(userId: string): Promise<Date> {
if (this.profileCreatedDate && userId === this.userId) {
return Promise.resolve(new Date(this.profileCreatedDate));
}
const profile = await this.fetchAndCacheProfile();
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;
}
private async fetchAndCacheProfile(): Promise<ProfileResponse> {
const profile = await this.apiService.getProfile();
this.userId = profile.id;
this.profileCreatedDate = profile.creationDate;
this.profile2FAEnabled = profile.twoFactorEnabled;
return profile;
}
}