mirror of
https://github.com/bitwarden/browser
synced 2025-12-10 13:23:34 +00:00
feat(prelogin): [Auth/PM-23801] Move Prelogin Request (#17080)
* feat(prelogin): [PM-23801] Move Prelogin Request - Initial implementation. * test(prelogin): [PM-23801] Move Prelogin Request - Removed unneeded test.
This commit is contained in:
committed by
GitHub
parent
f7899991a0
commit
5aa6d38d80
@@ -1,4 +1,4 @@
|
|||||||
<!--
|
<!--
|
||||||
# Table of Contents
|
# Table of Contents
|
||||||
|
|
||||||
This file contains a single consolidated template for all visual clients.
|
This file contains a single consolidated template for all visual clients.
|
||||||
|
|||||||
102
libs/auth/src/angular/login/login.component.spec.ts
Normal file
102
libs/auth/src/angular/login/login.component.spec.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { FormBuilder } from "@angular/forms";
|
||||||
|
import { mock } from "jest-mock-extended";
|
||||||
|
|
||||||
|
import { LoginStrategyServiceAbstraction } from "@bitwarden/auth/common";
|
||||||
|
import { ClientType } from "@bitwarden/common/enums";
|
||||||
|
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||||
|
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||||
|
|
||||||
|
import { LoginComponent } from "./login.component";
|
||||||
|
|
||||||
|
describe("LoginComponent continue() integration", () => {
|
||||||
|
function createComponent({ flagEnabled }: { flagEnabled: boolean }) {
|
||||||
|
const activatedRoute: any = { queryParams: { subscribe: () => {} } };
|
||||||
|
const anonLayoutWrapperDataService: any = { setAnonLayoutWrapperData: () => {} };
|
||||||
|
const appIdService: any = {};
|
||||||
|
const broadcasterService: any = { subscribe: () => {}, unsubscribe: () => {} };
|
||||||
|
const destroyRef: any = {};
|
||||||
|
const devicesApiService: any = {};
|
||||||
|
const formBuilder = new FormBuilder();
|
||||||
|
const i18nService: any = { t: () => "" };
|
||||||
|
const loginEmailService: any = {
|
||||||
|
rememberedEmail$: { pipe: () => ({}) },
|
||||||
|
setLoginEmail: async () => {},
|
||||||
|
setRememberedEmailChoice: async () => {},
|
||||||
|
clearLoginEmail: async () => {},
|
||||||
|
};
|
||||||
|
const loginComponentService: any = {
|
||||||
|
showBackButton: () => {},
|
||||||
|
isLoginWithPasskeySupported: () => false,
|
||||||
|
redirectToSsoLogin: async () => {},
|
||||||
|
};
|
||||||
|
const loginStrategyService = mock<LoginStrategyServiceAbstraction>();
|
||||||
|
const messagingService: any = { send: () => {} };
|
||||||
|
const ngZone: any = { isStable: true, onStable: { pipe: () => ({ subscribe: () => {} }) } };
|
||||||
|
const passwordStrengthService: any = {};
|
||||||
|
const platformUtilsService = mock<PlatformUtilsService>();
|
||||||
|
platformUtilsService.getClientType.mockReturnValue(ClientType.Browser);
|
||||||
|
const policyService: any = { replace: async () => {}, evaluateMasterPassword: () => true };
|
||||||
|
const router: any = { navigate: async () => {}, navigateByUrl: async () => {} };
|
||||||
|
const toastService: any = { showToast: () => {} };
|
||||||
|
const logService: any = { error: () => {} };
|
||||||
|
const validationService: any = { showError: () => {} };
|
||||||
|
const loginSuccessHandlerService: any = { run: async () => {} };
|
||||||
|
const configService = mock<ConfigService>();
|
||||||
|
configService.getFeatureFlag.mockResolvedValue(flagEnabled);
|
||||||
|
const ssoLoginService: any = { ssoRequiredCache$: { pipe: () => ({}) } };
|
||||||
|
const environmentService: any = { environment$: { pipe: () => ({}) } };
|
||||||
|
|
||||||
|
const component = new LoginComponent(
|
||||||
|
activatedRoute,
|
||||||
|
anonLayoutWrapperDataService,
|
||||||
|
appIdService,
|
||||||
|
broadcasterService,
|
||||||
|
destroyRef,
|
||||||
|
devicesApiService,
|
||||||
|
formBuilder,
|
||||||
|
i18nService,
|
||||||
|
loginEmailService,
|
||||||
|
loginComponentService,
|
||||||
|
loginStrategyService,
|
||||||
|
messagingService,
|
||||||
|
ngZone,
|
||||||
|
passwordStrengthService,
|
||||||
|
platformUtilsService,
|
||||||
|
policyService,
|
||||||
|
router,
|
||||||
|
toastService,
|
||||||
|
logService,
|
||||||
|
validationService,
|
||||||
|
loginSuccessHandlerService,
|
||||||
|
configService,
|
||||||
|
ssoLoginService,
|
||||||
|
environmentService,
|
||||||
|
);
|
||||||
|
|
||||||
|
jest.spyOn(component as any, "toggleLoginUiState").mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
return { component, loginStrategyService };
|
||||||
|
}
|
||||||
|
|
||||||
|
it("calls getPasswordPrelogin on continue when flag enabled and email valid", async () => {
|
||||||
|
const { component, loginStrategyService } = createComponent({ flagEnabled: true });
|
||||||
|
(component as any).formGroup.controls.email.setValue("user@example.com");
|
||||||
|
(component as any).formGroup.controls.rememberEmail.setValue(false);
|
||||||
|
(component as any).formGroup.controls.masterPassword.setValue("irrelevant");
|
||||||
|
|
||||||
|
await (component as any).continue();
|
||||||
|
|
||||||
|
expect(loginStrategyService.getPasswordPrelogin).toHaveBeenCalledWith("user@example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not call getPasswordPrelogin when flag disabled", async () => {
|
||||||
|
const { component, loginStrategyService } = createComponent({ flagEnabled: false });
|
||||||
|
(component as any).formGroup.controls.email.setValue("user@example.com");
|
||||||
|
(component as any).formGroup.controls.rememberEmail.setValue(false);
|
||||||
|
(component as any).formGroup.controls.masterPassword.setValue("irrelevant");
|
||||||
|
|
||||||
|
await (component as any).continue();
|
||||||
|
|
||||||
|
expect(loginStrategyService.getPasswordPrelogin).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -550,6 +550,8 @@ export class LoginComponent implements OnInit, OnDestroy {
|
|||||||
const isEmailValid = this.validateEmail();
|
const isEmailValid = this.validateEmail();
|
||||||
|
|
||||||
if (isEmailValid) {
|
if (isEmailValid) {
|
||||||
|
await this.makePasswordPreloginCall();
|
||||||
|
|
||||||
await this.toggleLoginUiState(LoginUiState.MASTER_PASSWORD_ENTRY);
|
await this.toggleLoginUiState(LoginUiState.MASTER_PASSWORD_ENTRY);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -652,6 +654,23 @@ export class LoginComponent implements OnInit, OnDestroy {
|
|||||||
history.back();
|
history.back();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async makePasswordPreloginCall() {
|
||||||
|
// Prefetch prelogin KDF config when enabled
|
||||||
|
try {
|
||||||
|
const flagEnabled = await this.configService.getFeatureFlag(
|
||||||
|
FeatureFlag.PM23801_PrefetchPasswordPrelogin,
|
||||||
|
);
|
||||||
|
if (flagEnabled) {
|
||||||
|
const email = this.formGroup.value.email;
|
||||||
|
if (email) {
|
||||||
|
void this.loginStrategyService.getPasswordPrelogin(email);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logService.error("Failed to prefetch prelogin data.", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle the popstate event to transition back to the email entry state when the back button is clicked.
|
* Handle the popstate event to transition back to the email entry state when the back button is clicked.
|
||||||
* Also handles the case where the user clicks the forward button.
|
* Also handles the case where the user clicks the forward button.
|
||||||
|
|||||||
@@ -65,7 +65,11 @@ export abstract class LoginStrategyServiceAbstraction {
|
|||||||
/**
|
/**
|
||||||
* Creates a master key from the provided master password and email.
|
* Creates a master key from the provided master password and email.
|
||||||
*/
|
*/
|
||||||
abstract makePreloginKey(masterPassword: string, email: string): Promise<MasterKey>;
|
abstract makePasswordPreLoginMasterKey(masterPassword: string, email: string): Promise<MasterKey>;
|
||||||
|
/**
|
||||||
|
* Prefetch and cache the KDF configuration for the given email. No-op if already in-flight or cached.
|
||||||
|
*/
|
||||||
|
abstract getPasswordPrelogin(email: string): Promise<void>;
|
||||||
/**
|
/**
|
||||||
* Emits true if the authentication session has expired.
|
* Emits true if the authentication session has expired.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ describe("PasswordLoginStrategy", () => {
|
|||||||
sub: userId,
|
sub: userId,
|
||||||
});
|
});
|
||||||
|
|
||||||
loginStrategyService.makePreloginKey.mockResolvedValue(masterKey);
|
loginStrategyService.makePasswordPreLoginMasterKey.mockResolvedValue(masterKey);
|
||||||
|
|
||||||
keyService.hashMasterKey
|
keyService.hashMasterKey
|
||||||
.calledWith(masterPassword, expect.anything(), undefined)
|
.calledWith(masterPassword, expect.anything(), undefined)
|
||||||
|
|||||||
@@ -79,7 +79,10 @@ export class PasswordLoginStrategy extends LoginStrategy {
|
|||||||
const { email, masterPassword, twoFactor } = credentials;
|
const { email, masterPassword, twoFactor } = credentials;
|
||||||
|
|
||||||
const data = new PasswordLoginStrategyData();
|
const data = new PasswordLoginStrategyData();
|
||||||
data.masterKey = await this.loginStrategyService.makePreloginKey(masterPassword, email);
|
data.masterKey = await this.loginStrategyService.makePasswordPreLoginMasterKey(
|
||||||
|
masterPassword,
|
||||||
|
email,
|
||||||
|
);
|
||||||
data.userEnteredEmail = email;
|
data.userEnteredEmail = email;
|
||||||
|
|
||||||
// Hash the password early (before authentication) so we don't persist it in memory in plaintext
|
// Hash the password early (before authentication) so we don't persist it in memory in plaintext
|
||||||
|
|||||||
@@ -37,7 +37,13 @@ import {
|
|||||||
} from "@bitwarden/common/spec";
|
} from "@bitwarden/common/spec";
|
||||||
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
|
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
|
||||||
import { UserId } from "@bitwarden/common/types/guid";
|
import { UserId } from "@bitwarden/common/types/guid";
|
||||||
import { KdfConfigService, KdfType, KeyService, PBKDF2KdfConfig } from "@bitwarden/key-management";
|
import {
|
||||||
|
Argon2KdfConfig,
|
||||||
|
KdfConfigService,
|
||||||
|
KdfType,
|
||||||
|
KeyService,
|
||||||
|
PBKDF2KdfConfig,
|
||||||
|
} from "@bitwarden/key-management";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AuthRequestServiceAbstraction,
|
AuthRequestServiceAbstraction,
|
||||||
@@ -158,6 +164,321 @@ describe("LoginStrategyService", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("PM23801_PrefetchPasswordPrelogin", () => {
|
||||||
|
describe("Flag On", () => {
|
||||||
|
it("prefetches and caches KDF, then makePrePasswordLoginMasterKey uses cached", async () => {
|
||||||
|
configService.getFeatureFlag.mockResolvedValue(true);
|
||||||
|
|
||||||
|
const email = "a@a.com";
|
||||||
|
apiService.postPrelogin.mockResolvedValue(
|
||||||
|
new PreloginResponse({
|
||||||
|
Kdf: KdfType.PBKDF2_SHA256,
|
||||||
|
KdfIterations: PBKDF2KdfConfig.PRELOGIN_ITERATIONS_MIN,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
keyService.makeMasterKey.mockResolvedValue({} as any);
|
||||||
|
|
||||||
|
await sut.getPasswordPrelogin(email);
|
||||||
|
|
||||||
|
await sut.makePasswordPreLoginMasterKey("pw", email);
|
||||||
|
|
||||||
|
expect(apiService.postPrelogin).toHaveBeenCalledTimes(1);
|
||||||
|
const calls = keyService.makeMasterKey.mock.calls as any[];
|
||||||
|
expect(calls[0][2]).toBeInstanceOf(PBKDF2KdfConfig);
|
||||||
|
expect(keyService.makeMasterKey).toHaveBeenCalledWith(
|
||||||
|
"pw",
|
||||||
|
email.trim().toLowerCase(),
|
||||||
|
expect.any(PBKDF2KdfConfig),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("awaits in-flight prelogin promise in makePrePasswordLoginMasterKey", async () => {
|
||||||
|
configService.getFeatureFlag.mockResolvedValue(true);
|
||||||
|
|
||||||
|
const email = "a@a.com";
|
||||||
|
let resolveFn: (v: any) => void;
|
||||||
|
const deferred = new Promise<PreloginResponse>((resolve) => (resolveFn = resolve));
|
||||||
|
apiService.postPrelogin.mockReturnValue(deferred as any);
|
||||||
|
keyService.makeMasterKey.mockResolvedValue({} as any);
|
||||||
|
|
||||||
|
void sut.getPasswordPrelogin(email);
|
||||||
|
|
||||||
|
const makeKeyPromise = sut.makePasswordPreLoginMasterKey("pw", email);
|
||||||
|
|
||||||
|
// Resolve after makePrePasswordLoginMasterKey has started awaiting
|
||||||
|
resolveFn!(
|
||||||
|
new PreloginResponse({
|
||||||
|
Kdf: KdfType.PBKDF2_SHA256,
|
||||||
|
KdfIterations: PBKDF2KdfConfig.PRELOGIN_ITERATIONS_MIN,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await makeKeyPromise;
|
||||||
|
|
||||||
|
expect(apiService.postPrelogin).toHaveBeenCalledTimes(1);
|
||||||
|
expect(keyService.makeMasterKey).toHaveBeenCalledWith(
|
||||||
|
"pw",
|
||||||
|
email,
|
||||||
|
expect.any(PBKDF2KdfConfig),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("no cache and no in-flight request", async () => {
|
||||||
|
configService.getFeatureFlag.mockResolvedValue(true);
|
||||||
|
|
||||||
|
const email = "a@a.com";
|
||||||
|
apiService.postPrelogin.mockResolvedValue(
|
||||||
|
new PreloginResponse({
|
||||||
|
Kdf: KdfType.PBKDF2_SHA256,
|
||||||
|
KdfIterations: PBKDF2KdfConfig.PRELOGIN_ITERATIONS_MIN,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
keyService.makeMasterKey.mockResolvedValue({} as any);
|
||||||
|
|
||||||
|
await sut.makePasswordPreLoginMasterKey("pw", email);
|
||||||
|
|
||||||
|
expect(apiService.postPrelogin).toHaveBeenCalledTimes(1);
|
||||||
|
expect(keyService.makeMasterKey).toHaveBeenCalledWith(
|
||||||
|
"pw",
|
||||||
|
email,
|
||||||
|
expect.any(PBKDF2KdfConfig),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to API call when prefetched email differs", async () => {
|
||||||
|
configService.getFeatureFlag.mockResolvedValue(true);
|
||||||
|
|
||||||
|
const emailPrefetched = "a@a.com";
|
||||||
|
const emailUsed = "b@b.com";
|
||||||
|
|
||||||
|
// Prefetch for A
|
||||||
|
apiService.postPrelogin.mockResolvedValueOnce(
|
||||||
|
new PreloginResponse({
|
||||||
|
Kdf: KdfType.PBKDF2_SHA256,
|
||||||
|
KdfIterations: PBKDF2KdfConfig.PRELOGIN_ITERATIONS_MIN,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await sut.getPasswordPrelogin(emailPrefetched);
|
||||||
|
|
||||||
|
// makePrePasswordLoginMasterKey for B (forces new API call) -> Argon2
|
||||||
|
apiService.postPrelogin.mockResolvedValueOnce(
|
||||||
|
new PreloginResponse({
|
||||||
|
Kdf: KdfType.Argon2id,
|
||||||
|
KdfIterations: 2,
|
||||||
|
KdfMemory: 16,
|
||||||
|
KdfParallelism: 1,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
keyService.makeMasterKey.mockResolvedValue({} as any);
|
||||||
|
|
||||||
|
await sut.makePasswordPreLoginMasterKey("pw", emailUsed);
|
||||||
|
|
||||||
|
expect(apiService.postPrelogin).toHaveBeenCalledTimes(2);
|
||||||
|
const calls = keyService.makeMasterKey.mock.calls as any[];
|
||||||
|
expect(calls[calls.length - 1][2]).toBeInstanceOf(Argon2KdfConfig);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores stale prelogin resolution for older email (versioning)", async () => {
|
||||||
|
configService.getFeatureFlag.mockResolvedValue(true);
|
||||||
|
|
||||||
|
const emailA = "a@a.com";
|
||||||
|
const emailB = "b@b.com";
|
||||||
|
|
||||||
|
let resolveA!: (v: any) => void;
|
||||||
|
let resolveB!: (v: any) => void;
|
||||||
|
const deferredA = new Promise<PreloginResponse>((res) => (resolveA = res));
|
||||||
|
const deferredB = new Promise<PreloginResponse>((res) => (resolveB = res));
|
||||||
|
|
||||||
|
// First call returns A, second returns B
|
||||||
|
apiService.postPrelogin.mockImplementationOnce(() => deferredA as any);
|
||||||
|
apiService.postPrelogin.mockImplementationOnce(() => deferredB as any);
|
||||||
|
keyService.makeMasterKey.mockResolvedValue({} as any);
|
||||||
|
|
||||||
|
// Start A prefetch, then B prefetch (B supersedes A)
|
||||||
|
void sut.getPasswordPrelogin(emailA);
|
||||||
|
void sut.getPasswordPrelogin(emailB);
|
||||||
|
|
||||||
|
// Resolve A (stale) to PBKDF2, then B to Argon2
|
||||||
|
resolveA(
|
||||||
|
new PreloginResponse({
|
||||||
|
Kdf: KdfType.PBKDF2_SHA256,
|
||||||
|
KdfIterations: PBKDF2KdfConfig.PRELOGIN_ITERATIONS_MIN,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
resolveB(
|
||||||
|
new PreloginResponse({
|
||||||
|
Kdf: KdfType.Argon2id,
|
||||||
|
KdfIterations: 2,
|
||||||
|
KdfMemory: 16,
|
||||||
|
KdfParallelism: 1,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await sut.makePasswordPreLoginMasterKey("pwB", emailB);
|
||||||
|
|
||||||
|
// Ensure B's Argon2 config is used and stale A doesn't overwrite
|
||||||
|
const calls = keyService.makeMasterKey.mock.calls as any[];
|
||||||
|
const argB = calls.find((c) => c[0] === "pwB")[2];
|
||||||
|
expect(argB).toBeInstanceOf(Argon2KdfConfig);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles concurrent getPasswordPrelogin calls for same email; uses latest result", async () => {
|
||||||
|
configService.getFeatureFlag.mockResolvedValue(true);
|
||||||
|
|
||||||
|
const email = "a@a.com";
|
||||||
|
let resolve1!: (v: any) => void;
|
||||||
|
let resolve2!: (v: any) => void;
|
||||||
|
const deferred1 = new Promise<PreloginResponse>((res) => (resolve1 = res));
|
||||||
|
const deferred2 = new Promise<PreloginResponse>((res) => (resolve2 = res));
|
||||||
|
|
||||||
|
apiService.postPrelogin.mockImplementationOnce(() => deferred1 as any);
|
||||||
|
apiService.postPrelogin.mockImplementationOnce(() => deferred2 as any);
|
||||||
|
keyService.makeMasterKey.mockResolvedValue({} as any);
|
||||||
|
|
||||||
|
void sut.getPasswordPrelogin(email);
|
||||||
|
void sut.getPasswordPrelogin(email);
|
||||||
|
|
||||||
|
// First resolves to PBKDF2, second resolves to Argon2 (latest wins)
|
||||||
|
resolve1(
|
||||||
|
new PreloginResponse({
|
||||||
|
Kdf: KdfType.PBKDF2_SHA256,
|
||||||
|
KdfIterations: PBKDF2KdfConfig.PRELOGIN_ITERATIONS_MIN,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
resolve2(
|
||||||
|
new PreloginResponse({
|
||||||
|
Kdf: KdfType.Argon2id,
|
||||||
|
KdfIterations: 2,
|
||||||
|
KdfMemory: 16,
|
||||||
|
KdfParallelism: 1,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await sut.makePasswordPreLoginMasterKey("pw", email);
|
||||||
|
|
||||||
|
expect(apiService.postPrelogin).toHaveBeenCalledTimes(2);
|
||||||
|
const calls = keyService.makeMasterKey.mock.calls as any[];
|
||||||
|
expect(calls[0][2]).toBeInstanceOf(Argon2KdfConfig);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not throw when prefetch network error occurs; fallback works in makePrePasswordLoginMasterKey", async () => {
|
||||||
|
configService.getFeatureFlag.mockResolvedValue(true);
|
||||||
|
|
||||||
|
const email = "a@a.com";
|
||||||
|
|
||||||
|
// Prefetch throws non-404 error
|
||||||
|
const err: any = new Error("network");
|
||||||
|
err.statusCode = 500;
|
||||||
|
apiService.postPrelogin.mockRejectedValueOnce(err);
|
||||||
|
|
||||||
|
await expect(sut.getPasswordPrelogin(email)).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
// makePrePasswordLoginMasterKey falls back to a new API call which succeeds
|
||||||
|
apiService.postPrelogin.mockResolvedValueOnce(
|
||||||
|
new PreloginResponse({
|
||||||
|
Kdf: KdfType.PBKDF2_SHA256,
|
||||||
|
KdfIterations: PBKDF2KdfConfig.PRELOGIN_ITERATIONS_MIN,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
keyService.makeMasterKey.mockResolvedValue({} as any);
|
||||||
|
|
||||||
|
await sut.makePasswordPreLoginMasterKey("pw", email);
|
||||||
|
|
||||||
|
expect(apiService.postPrelogin).toHaveBeenCalledTimes(2);
|
||||||
|
const calls = keyService.makeMasterKey.mock.calls as any[];
|
||||||
|
expect(calls[0][2]).toBeInstanceOf(PBKDF2KdfConfig);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats 404 as null prefetch and falls back in makePrePasswordLoginMasterKey", async () => {
|
||||||
|
configService.getFeatureFlag.mockResolvedValue(true);
|
||||||
|
|
||||||
|
const email = "a@a.com";
|
||||||
|
|
||||||
|
const notFound: any = new Error("not found");
|
||||||
|
notFound.statusCode = 404;
|
||||||
|
apiService.postPrelogin.mockRejectedValueOnce(notFound);
|
||||||
|
|
||||||
|
await sut.getPasswordPrelogin(email);
|
||||||
|
|
||||||
|
// Fallback call on makePrePasswordLoginMasterKey
|
||||||
|
apiService.postPrelogin.mockResolvedValueOnce(
|
||||||
|
new PreloginResponse({
|
||||||
|
Kdf: KdfType.Argon2id,
|
||||||
|
KdfIterations: 2,
|
||||||
|
KdfMemory: 16,
|
||||||
|
KdfParallelism: 1,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
keyService.makeMasterKey.mockResolvedValue({} as any);
|
||||||
|
|
||||||
|
await sut.makePasswordPreLoginMasterKey("pw", email);
|
||||||
|
|
||||||
|
expect(apiService.postPrelogin).toHaveBeenCalledTimes(2);
|
||||||
|
const calls = keyService.makeMasterKey.mock.calls as any[];
|
||||||
|
expect(calls[0][2]).toBeInstanceOf(Argon2KdfConfig);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("awaits rejected current prelogin promise and then falls back in makePrePasswordLoginMasterKey", async () => {
|
||||||
|
configService.getFeatureFlag.mockResolvedValue(true);
|
||||||
|
|
||||||
|
const email = "a@a.com";
|
||||||
|
const err: any = new Error("network");
|
||||||
|
err.statusCode = 500;
|
||||||
|
let rejectFn!: (e: any) => void;
|
||||||
|
const deferred = new Promise<PreloginResponse>((_res, rej) => (rejectFn = rej));
|
||||||
|
apiService.postPrelogin.mockReturnValueOnce(deferred as any);
|
||||||
|
keyService.makeMasterKey.mockResolvedValue({} as any);
|
||||||
|
|
||||||
|
void sut.getPasswordPrelogin(email);
|
||||||
|
const makeKey = sut.makePasswordPreLoginMasterKey("pw", email);
|
||||||
|
|
||||||
|
rejectFn(err);
|
||||||
|
|
||||||
|
// Fallback call succeeds
|
||||||
|
apiService.postPrelogin.mockResolvedValueOnce(
|
||||||
|
new PreloginResponse({
|
||||||
|
Kdf: KdfType.PBKDF2_SHA256,
|
||||||
|
KdfIterations: PBKDF2KdfConfig.PRELOGIN_ITERATIONS_MIN,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await makeKey;
|
||||||
|
|
||||||
|
expect(apiService.postPrelogin).toHaveBeenCalledTimes(2);
|
||||||
|
const calls = keyService.makeMasterKey.mock.calls as any[];
|
||||||
|
expect(calls[0][2]).toBeInstanceOf(PBKDF2KdfConfig);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Flag Off", () => {
|
||||||
|
// remove when pm-23801 feature flag comes out
|
||||||
|
it("uses legacy API path", async () => {
|
||||||
|
configService.getFeatureFlag.mockResolvedValue(false);
|
||||||
|
|
||||||
|
const email = "a@a.com";
|
||||||
|
// prefetch shouldn't affect behavior when flag off
|
||||||
|
apiService.postPrelogin.mockResolvedValue(
|
||||||
|
new PreloginResponse({
|
||||||
|
Kdf: KdfType.PBKDF2_SHA256,
|
||||||
|
KdfIterations: PBKDF2KdfConfig.PRELOGIN_ITERATIONS_MIN,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
keyService.makeMasterKey.mockResolvedValue({} as any);
|
||||||
|
|
||||||
|
await sut.getPasswordPrelogin(email);
|
||||||
|
await sut.makePasswordPreLoginMasterKey("pw", email);
|
||||||
|
|
||||||
|
// Called twice: once for prefetch, once for legacy path in makePrePasswordLoginMasterKey
|
||||||
|
expect(apiService.postPrelogin).toHaveBeenCalledTimes(2);
|
||||||
|
expect(keyService.makeMasterKey).toHaveBeenCalledWith(
|
||||||
|
"pw",
|
||||||
|
email,
|
||||||
|
expect.any(PBKDF2KdfConfig),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("should return an AuthResult on successful login", async () => {
|
it("should return an AuthResult on successful login", async () => {
|
||||||
const credentials = new PasswordLoginCredentials("EMAIL", "MASTER_PASSWORD");
|
const credentials = new PasswordLoginCredentials("EMAIL", "MASTER_PASSWORD");
|
||||||
apiService.postIdentityToken.mockResolvedValue(
|
apiService.postIdentityToken.mockResolvedValue(
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { AuthenticationType } from "@bitwarden/common/auth/enums/authentication-
|
|||||||
import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
|
import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
|
||||||
import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/identity-token/token-two-factor.request";
|
import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/identity-token/token-two-factor.request";
|
||||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
|
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
|
||||||
|
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||||
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
|
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
|
||||||
import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service";
|
import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service";
|
||||||
@@ -92,6 +93,32 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction {
|
|||||||
private authRequestPushNotificationState: GlobalState<string | null>;
|
private authRequestPushNotificationState: GlobalState<string | null>;
|
||||||
private authenticationTimeoutSubject = new BehaviorSubject<boolean>(false);
|
private authenticationTimeoutSubject = new BehaviorSubject<boolean>(false);
|
||||||
|
|
||||||
|
// Prefetched password prelogin
|
||||||
|
//
|
||||||
|
// About versioning:
|
||||||
|
// Users can quickly change emails (e.g., continue with user1, go back, continue with user2)
|
||||||
|
// which triggers overlapping async prelogin requests. We use a monotonically increasing
|
||||||
|
// "version" to associate each prelogin attempt with the state at the time it was started.
|
||||||
|
// Only if BOTH the email and the version still match when the promise resolves do we commit
|
||||||
|
// the resulting KDF config or clear the in-flight promise. This prevents stale results from
|
||||||
|
// user1 overwriting user2's state in race conditions.
|
||||||
|
private passwordPrelogin: {
|
||||||
|
email: string | null;
|
||||||
|
kdfConfig: KdfConfig | null;
|
||||||
|
promise: Promise<KdfConfig | null> | null;
|
||||||
|
/**
|
||||||
|
* Version guard for prelogin attempts.
|
||||||
|
* Incremented at the start of getPasswordPrelogin for each new submission.
|
||||||
|
* Used to ignore stale async resolutions when email changes mid-flight.
|
||||||
|
*/
|
||||||
|
version: number;
|
||||||
|
} = {
|
||||||
|
email: null,
|
||||||
|
kdfConfig: null,
|
||||||
|
promise: null,
|
||||||
|
version: 0,
|
||||||
|
};
|
||||||
|
|
||||||
authenticationSessionTimeout$: Observable<boolean> =
|
authenticationSessionTimeout$: Observable<boolean> =
|
||||||
this.authenticationTimeoutSubject.asObservable();
|
this.authenticationTimeoutSubject.asObservable();
|
||||||
|
|
||||||
@@ -308,33 +335,106 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async makePreloginKey(masterPassword: string, email: string): Promise<MasterKey> {
|
async makePasswordPreLoginMasterKey(masterPassword: string, email: string): Promise<MasterKey> {
|
||||||
email = email.trim().toLowerCase();
|
email = email.trim().toLowerCase();
|
||||||
let kdfConfig: KdfConfig | undefined;
|
|
||||||
|
if (await this.configService.getFeatureFlag(FeatureFlag.PM23801_PrefetchPasswordPrelogin)) {
|
||||||
|
let kdfConfig: KdfConfig | null = null;
|
||||||
|
if (this.passwordPrelogin.email === email) {
|
||||||
|
if (this.passwordPrelogin.kdfConfig) {
|
||||||
|
kdfConfig = this.passwordPrelogin.kdfConfig;
|
||||||
|
} else if (this.passwordPrelogin.promise != null) {
|
||||||
|
try {
|
||||||
|
await this.passwordPrelogin.promise;
|
||||||
|
} catch (error) {
|
||||||
|
this.logService.error(
|
||||||
|
"Failed to prefetch prelogin data, falling back to fetching now.",
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
kdfConfig = this.passwordPrelogin.kdfConfig;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!kdfConfig) {
|
||||||
|
try {
|
||||||
|
const preloginResponse = await this.apiService.postPrelogin(new PreloginRequest(email));
|
||||||
|
kdfConfig = this.buildKdfConfigFromPrelogin(preloginResponse);
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e == null || e.statusCode !== 404) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!kdfConfig) {
|
||||||
|
throw new Error("KDF config is required");
|
||||||
|
}
|
||||||
|
kdfConfig.validateKdfConfigForPrelogin();
|
||||||
|
return await this.keyService.makeMasterKey(masterPassword, email, kdfConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy behavior when flag is disabled
|
||||||
|
let legacyKdfConfig: KdfConfig | undefined;
|
||||||
try {
|
try {
|
||||||
const preloginResponse = await this.apiService.postPrelogin(new PreloginRequest(email));
|
const preloginResponse = await this.apiService.postPrelogin(new PreloginRequest(email));
|
||||||
if (preloginResponse != null) {
|
legacyKdfConfig = this.buildKdfConfigFromPrelogin(preloginResponse) ?? undefined;
|
||||||
kdfConfig =
|
|
||||||
preloginResponse.kdf === KdfType.PBKDF2_SHA256
|
|
||||||
? new PBKDF2KdfConfig(preloginResponse.kdfIterations)
|
|
||||||
: new Argon2KdfConfig(
|
|
||||||
preloginResponse.kdfIterations,
|
|
||||||
preloginResponse.kdfMemory,
|
|
||||||
preloginResponse.kdfParallelism,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e == null || e.statusCode !== 404) {
|
if (e == null || e.statusCode !== 404) {
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!kdfConfig) {
|
if (!legacyKdfConfig) {
|
||||||
throw new Error("KDF config is required");
|
throw new Error("KDF config is required");
|
||||||
}
|
}
|
||||||
kdfConfig.validateKdfConfigForPrelogin();
|
legacyKdfConfig.validateKdfConfigForPrelogin();
|
||||||
|
return await this.keyService.makeMasterKey(masterPassword, email, legacyKdfConfig);
|
||||||
|
}
|
||||||
|
|
||||||
return await this.keyService.makeMasterKey(masterPassword, email, kdfConfig);
|
async getPasswordPrelogin(email: string): Promise<void> {
|
||||||
|
const normalizedEmail = email.trim().toLowerCase();
|
||||||
|
const version = ++this.passwordPrelogin.version;
|
||||||
|
|
||||||
|
this.passwordPrelogin.email = normalizedEmail;
|
||||||
|
this.passwordPrelogin.kdfConfig = null;
|
||||||
|
const promise: Promise<KdfConfig | null> = (async () => {
|
||||||
|
try {
|
||||||
|
const preloginResponse = await this.apiService.postPrelogin(
|
||||||
|
new PreloginRequest(normalizedEmail),
|
||||||
|
);
|
||||||
|
return this.buildKdfConfigFromPrelogin(preloginResponse);
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e == null || e.statusCode !== 404) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
this.passwordPrelogin.promise = promise;
|
||||||
|
promise
|
||||||
|
.then((cfg) => {
|
||||||
|
// Only apply if still for the same email and same version
|
||||||
|
if (
|
||||||
|
this.passwordPrelogin.email === normalizedEmail &&
|
||||||
|
this.passwordPrelogin.version === version &&
|
||||||
|
cfg
|
||||||
|
) {
|
||||||
|
this.passwordPrelogin.kdfConfig = cfg;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// swallow; best-effort prefetch
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (
|
||||||
|
this.passwordPrelogin.email === normalizedEmail &&
|
||||||
|
this.passwordPrelogin.version === version
|
||||||
|
) {
|
||||||
|
this.passwordPrelogin.promise = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async clearCache(): Promise<void> {
|
private async clearCache(): Promise<void> {
|
||||||
@@ -342,6 +442,12 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction {
|
|||||||
await this.loginStrategyCacheState.update((_) => null);
|
await this.loginStrategyCacheState.update((_) => null);
|
||||||
this.authenticationTimeoutSubject.next(false);
|
this.authenticationTimeoutSubject.next(false);
|
||||||
await this.clearSessionTimeout();
|
await this.clearSessionTimeout();
|
||||||
|
|
||||||
|
// Increment to invalidate any in-flight requests
|
||||||
|
this.passwordPrelogin.version++;
|
||||||
|
this.passwordPrelogin.email = null;
|
||||||
|
this.passwordPrelogin.kdfConfig = null;
|
||||||
|
this.passwordPrelogin.promise = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async startSessionTimeout(): Promise<void> {
|
private async startSessionTimeout(): Promise<void> {
|
||||||
@@ -449,4 +555,24 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private buildKdfConfigFromPrelogin(
|
||||||
|
preloginResponse: {
|
||||||
|
kdf: KdfType;
|
||||||
|
kdfIterations: number;
|
||||||
|
kdfMemory?: number;
|
||||||
|
kdfParallelism?: number;
|
||||||
|
} | null,
|
||||||
|
): KdfConfig | null {
|
||||||
|
if (preloginResponse == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return preloginResponse.kdf === KdfType.PBKDF2_SHA256
|
||||||
|
? new PBKDF2KdfConfig(preloginResponse.kdfIterations)
|
||||||
|
: new Argon2KdfConfig(
|
||||||
|
preloginResponse.kdfIterations,
|
||||||
|
preloginResponse.kdfMemory,
|
||||||
|
preloginResponse.kdfParallelism,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export enum FeatureFlag {
|
|||||||
|
|
||||||
/* Auth */
|
/* Auth */
|
||||||
PM22110_DisableAlternateLoginMethods = "pm-22110-disable-alternate-login-methods",
|
PM22110_DisableAlternateLoginMethods = "pm-22110-disable-alternate-login-methods",
|
||||||
|
PM23801_PrefetchPasswordPrelogin = "pm-23801-prefetch-password-prelogin",
|
||||||
|
|
||||||
/* Autofill */
|
/* Autofill */
|
||||||
MacOsNativeCredentialSync = "macos-native-credential-sync",
|
MacOsNativeCredentialSync = "macos-native-credential-sync",
|
||||||
@@ -111,6 +112,7 @@ export const DefaultFeatureFlagValue = {
|
|||||||
|
|
||||||
/* Auth */
|
/* Auth */
|
||||||
[FeatureFlag.PM22110_DisableAlternateLoginMethods]: FALSE,
|
[FeatureFlag.PM22110_DisableAlternateLoginMethods]: FALSE,
|
||||||
|
[FeatureFlag.PM23801_PrefetchPasswordPrelogin]: FALSE,
|
||||||
|
|
||||||
/* Billing */
|
/* Billing */
|
||||||
[FeatureFlag.TrialPaymentOptional]: FALSE,
|
[FeatureFlag.TrialPaymentOptional]: FALSE,
|
||||||
|
|||||||
Reference in New Issue
Block a user