mirror of
https://github.com/bitwarden/browser
synced 2025-12-28 14:13:22 +00:00
feat(auth): [PM-8221] implement device verification for unknown devices
Add device verification flow that requires users to enter an OTP when logging in from an unrecognized device. This includes: - New device verification route and guard - Email OTP verification component - Authentication timeout handling PM-8221
This commit is contained in:
@@ -321,4 +321,67 @@ describe("LoginStrategyService", () => {
|
||||
`PBKDF2 iterations must be at least ${PBKDF2KdfConfig.PRELOGIN_ITERATIONS_MIN}, but was ${PBKDF2KdfConfig.PRELOGIN_ITERATIONS_MIN - 1}; possible pre-login downgrade attack detected.`,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns an AuthResult on successful new device verification", async () => {
|
||||
const credentials = new PasswordLoginCredentials("EMAIL", "MASTER_PASSWORD");
|
||||
const deviceVerificationOtp = "123456";
|
||||
|
||||
// Setup initial login and device verification response
|
||||
apiService.postPrelogin.mockResolvedValue(
|
||||
new PreloginResponse({
|
||||
Kdf: KdfType.Argon2id,
|
||||
KdfIterations: 2,
|
||||
KdfMemory: 16,
|
||||
KdfParallelism: 1,
|
||||
}),
|
||||
);
|
||||
|
||||
apiService.postIdentityToken.mockResolvedValueOnce(
|
||||
new IdentityTwoFactorResponse({
|
||||
TwoFactorProviders: ["0"],
|
||||
TwoFactorProviders2: { 0: null },
|
||||
error: "invalid_grant",
|
||||
error_description: "Two factor required.",
|
||||
email: undefined,
|
||||
ssoEmail2faSessionToken: undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
await sut.logIn(credentials);
|
||||
|
||||
// Successful device verification login
|
||||
apiService.postIdentityToken.mockResolvedValueOnce(
|
||||
new IdentityTokenResponse({
|
||||
ForcePasswordReset: false,
|
||||
Kdf: KdfType.Argon2id,
|
||||
KdfIterations: 2,
|
||||
KdfMemory: 16,
|
||||
KdfParallelism: 1,
|
||||
Key: "KEY",
|
||||
PrivateKey: "PRIVATE_KEY",
|
||||
ResetMasterPassword: false,
|
||||
access_token: "ACCESS_TOKEN",
|
||||
expires_in: 3600,
|
||||
refresh_token: "REFRESH_TOKEN",
|
||||
scope: "api offline_access",
|
||||
token_type: "Bearer",
|
||||
}),
|
||||
);
|
||||
|
||||
tokenService.decodeAccessToken.calledWith("ACCESS_TOKEN").mockResolvedValue({
|
||||
sub: "USER_ID",
|
||||
name: "NAME",
|
||||
email: "EMAIL",
|
||||
premium: false,
|
||||
});
|
||||
|
||||
const result = await sut.logInNewDeviceVerification(deviceVerificationOtp);
|
||||
|
||||
expect(result).toBeInstanceOf(AuthResult);
|
||||
expect(apiService.postIdentityToken).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
newDeviceOtp: deviceVerificationOtp,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import {
|
||||
combineLatestWith,
|
||||
distinctUntilChanged,
|
||||
@@ -15,6 +13,7 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service";
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust.service.abstraction";
|
||||
import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service";
|
||||
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction";
|
||||
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
|
||||
@@ -35,9 +34,6 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
|
||||
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
|
||||
import { TaskSchedulerService, ScheduledTaskNames } from "@bitwarden/common/platform/scheduling";
|
||||
import { GlobalState, GlobalStateProvider } from "@bitwarden/common/platform/state";
|
||||
// FIXME: remove `src` and fix import
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/src/auth/abstractions/device-trust.service.abstraction";
|
||||
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
|
||||
import { MasterKey } from "@bitwarden/common/types/key";
|
||||
import {
|
||||
@@ -51,12 +47,24 @@ import {
|
||||
|
||||
import { AuthRequestServiceAbstraction, LoginStrategyServiceAbstraction } from "../../abstractions";
|
||||
import { InternalUserDecryptionOptionsServiceAbstraction } from "../../abstractions/user-decryption-options.service.abstraction";
|
||||
import { AuthRequestLoginStrategy } from "../../login-strategies/auth-request-login.strategy";
|
||||
import {
|
||||
AuthRequestLoginStrategy,
|
||||
AuthRequestLoginStrategyData,
|
||||
} from "../../login-strategies/auth-request-login.strategy";
|
||||
import { LoginStrategy } from "../../login-strategies/login.strategy";
|
||||
import { PasswordLoginStrategy } from "../../login-strategies/password-login.strategy";
|
||||
import { SsoLoginStrategy } from "../../login-strategies/sso-login.strategy";
|
||||
import { UserApiLoginStrategy } from "../../login-strategies/user-api-login.strategy";
|
||||
import { WebAuthnLoginStrategy } from "../../login-strategies/webauthn-login.strategy";
|
||||
import {
|
||||
PasswordLoginStrategy,
|
||||
PasswordLoginStrategyData,
|
||||
} from "../../login-strategies/password-login.strategy";
|
||||
import { SsoLoginStrategy, SsoLoginStrategyData } from "../../login-strategies/sso-login.strategy";
|
||||
import {
|
||||
UserApiLoginStrategy,
|
||||
UserApiLoginStrategyData,
|
||||
} from "../../login-strategies/user-api-login.strategy";
|
||||
import {
|
||||
WebAuthnLoginStrategy,
|
||||
WebAuthnLoginStrategyData,
|
||||
} from "../../login-strategies/webauthn-login.strategy";
|
||||
import {
|
||||
UserApiLoginCredentials,
|
||||
PasswordLoginCredentials,
|
||||
@@ -76,14 +84,15 @@ import {
|
||||
const sessionTimeoutLength = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
export class LoginStrategyService implements LoginStrategyServiceAbstraction {
|
||||
private sessionTimeoutSubscription: Subscription;
|
||||
private sessionTimeoutSubscription: Subscription | undefined;
|
||||
private currentAuthnTypeState: GlobalState<AuthenticationType | null>;
|
||||
private loginStrategyCacheState: GlobalState<CacheData | null>;
|
||||
private loginStrategyCacheExpirationState: GlobalState<Date | null>;
|
||||
private authRequestPushNotificationState: GlobalState<string>;
|
||||
private twoFactorTimeoutSubject = new BehaviorSubject<boolean>(false);
|
||||
private authRequestPushNotificationState: GlobalState<string | null>;
|
||||
private authenticationTimeoutSubject = new BehaviorSubject<boolean>(false);
|
||||
|
||||
twoFactorTimeout$: Observable<boolean> = this.twoFactorTimeoutSubject.asObservable();
|
||||
authenticationSessionTimeout$: Observable<boolean> =
|
||||
this.authenticationTimeoutSubject.asObservable();
|
||||
|
||||
private loginStrategy$: Observable<
|
||||
| UserApiLoginStrategy
|
||||
@@ -132,7 +141,7 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction {
|
||||
this.taskSchedulerService.registerTaskHandler(
|
||||
ScheduledTaskNames.loginStrategySessionTimeout,
|
||||
async () => {
|
||||
this.twoFactorTimeoutSubject.next(true);
|
||||
this.authenticationTimeoutSubject.next(true);
|
||||
try {
|
||||
await this.clearCache();
|
||||
} catch (e) {
|
||||
@@ -153,7 +162,7 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction {
|
||||
async getEmail(): Promise<string | null> {
|
||||
const strategy = await firstValueFrom(this.loginStrategy$);
|
||||
|
||||
if ("email$" in strategy) {
|
||||
if (strategy && "email$" in strategy) {
|
||||
return await firstValueFrom(strategy.email$);
|
||||
}
|
||||
return null;
|
||||
@@ -162,7 +171,7 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction {
|
||||
async getMasterPasswordHash(): Promise<string | null> {
|
||||
const strategy = await firstValueFrom(this.loginStrategy$);
|
||||
|
||||
if ("serverMasterKeyHash$" in strategy) {
|
||||
if (strategy && "serverMasterKeyHash$" in strategy) {
|
||||
return await firstValueFrom(strategy.serverMasterKeyHash$);
|
||||
}
|
||||
return null;
|
||||
@@ -171,7 +180,7 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction {
|
||||
async getSsoEmail2FaSessionToken(): Promise<string | null> {
|
||||
const strategy = await firstValueFrom(this.loginStrategy$);
|
||||
|
||||
if ("ssoEmail2FaSessionToken$" in strategy) {
|
||||
if (strategy && "ssoEmail2FaSessionToken$" in strategy) {
|
||||
return await firstValueFrom(strategy.ssoEmail2FaSessionToken$);
|
||||
}
|
||||
return null;
|
||||
@@ -180,7 +189,7 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction {
|
||||
async getAccessCode(): Promise<string | null> {
|
||||
const strategy = await firstValueFrom(this.loginStrategy$);
|
||||
|
||||
if ("accessCode$" in strategy) {
|
||||
if (strategy && "accessCode$" in strategy) {
|
||||
return await firstValueFrom(strategy.accessCode$);
|
||||
}
|
||||
return null;
|
||||
@@ -189,7 +198,7 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction {
|
||||
async getAuthRequestId(): Promise<string | null> {
|
||||
const strategy = await firstValueFrom(this.loginStrategy$);
|
||||
|
||||
if ("authRequestId$" in strategy) {
|
||||
if (strategy && "authRequestId$" in strategy) {
|
||||
return await firstValueFrom(strategy.authRequestId$);
|
||||
}
|
||||
return null;
|
||||
@@ -204,7 +213,7 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction {
|
||||
| WebAuthnLoginCredentials,
|
||||
): Promise<AuthResult> {
|
||||
await this.clearCache();
|
||||
this.twoFactorTimeoutSubject.next(false);
|
||||
this.authenticationTimeoutSubject.next(false);
|
||||
|
||||
await this.currentAuthnTypeState.update((_) => credentials.type);
|
||||
|
||||
@@ -217,16 +226,19 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction {
|
||||
// If the popup uses its own instance of this service, this can be removed.
|
||||
const ownedCredentials = { ...credentials };
|
||||
|
||||
const result = await strategy.logIn(ownedCredentials as any);
|
||||
const result = await strategy?.logIn(ownedCredentials as any);
|
||||
|
||||
if (result != null && !result.requiresTwoFactor) {
|
||||
if (result != null && !result.requiresTwoFactor && !result.requiresDeviceVerification) {
|
||||
await this.clearCache();
|
||||
} else {
|
||||
// Cache the strategy data so we can attempt again later with 2fa. Cache supports different contexts
|
||||
await this.loginStrategyCacheState.update((_) => strategy.exportCache());
|
||||
// Cache the strategy data so we can attempt again later with 2fa or device verification
|
||||
await this.loginStrategyCacheState.update((_) => strategy?.exportCache() ?? null);
|
||||
await this.startSessionTimeout();
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
throw new Error("No auth result returned");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -260,9 +272,46 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a token request to the server with the provided device verification OTP.
|
||||
* Returns an error if no session data is found or if the current login strategy does not support device verification.
|
||||
* @param deviceVerificationOtp The OTP to send to the server for device verification.
|
||||
* @returns The result of the token request.
|
||||
*/
|
||||
async logInNewDeviceVerification(deviceVerificationOtp: string): Promise<AuthResult> {
|
||||
if (!(await this.isSessionValid())) {
|
||||
throw new Error(this.i18nService.t("sessionTimeout"));
|
||||
}
|
||||
|
||||
const strategy = await firstValueFrom(this.loginStrategy$);
|
||||
if (strategy == null) {
|
||||
throw new Error("No login strategy found.");
|
||||
}
|
||||
|
||||
if (!("logInNewDeviceVerification" in strategy)) {
|
||||
throw new Error("Current login strategy does not support device verification.");
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await strategy.logInNewDeviceVerification(deviceVerificationOtp);
|
||||
|
||||
// Only clear cache if device verification succeeds
|
||||
if (result !== null && !result.requiresDeviceVerification) {
|
||||
await this.clearCache();
|
||||
}
|
||||
return result;
|
||||
} catch (e) {
|
||||
// Clear the cache if there is an unhandled client-side error
|
||||
if (!(e instanceof ErrorResponse)) {
|
||||
await this.clearCache();
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async makePreloginKey(masterPassword: string, email: string): Promise<MasterKey> {
|
||||
email = email.trim().toLowerCase();
|
||||
let kdfConfig: KdfConfig = null;
|
||||
let kdfConfig: KdfConfig | undefined;
|
||||
try {
|
||||
const preloginResponse = await this.apiService.postPrelogin(new PreloginRequest(email));
|
||||
if (preloginResponse != null) {
|
||||
@@ -275,12 +324,15 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction {
|
||||
preloginResponse.kdfParallelism,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
} 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);
|
||||
@@ -289,7 +341,7 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction {
|
||||
private async clearCache(): Promise<void> {
|
||||
await this.currentAuthnTypeState.update((_) => null);
|
||||
await this.loginStrategyCacheState.update((_) => null);
|
||||
this.twoFactorTimeoutSubject.next(false);
|
||||
this.authenticationTimeoutSubject.next(false);
|
||||
await this.clearSessionTimeout();
|
||||
}
|
||||
|
||||
@@ -360,7 +412,7 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction {
|
||||
switch (strategy) {
|
||||
case AuthenticationType.Password:
|
||||
return new PasswordLoginStrategy(
|
||||
data?.password,
|
||||
data?.password ?? new PasswordLoginStrategyData(),
|
||||
this.passwordStrengthService,
|
||||
this.policyService,
|
||||
this,
|
||||
@@ -368,7 +420,7 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction {
|
||||
);
|
||||
case AuthenticationType.Sso:
|
||||
return new SsoLoginStrategy(
|
||||
data?.sso,
|
||||
data?.sso ?? new SsoLoginStrategyData(),
|
||||
this.keyConnectorService,
|
||||
this.deviceTrustService,
|
||||
this.authRequestService,
|
||||
@@ -377,19 +429,22 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction {
|
||||
);
|
||||
case AuthenticationType.UserApiKey:
|
||||
return new UserApiLoginStrategy(
|
||||
data?.userApiKey,
|
||||
data?.userApiKey ?? new UserApiLoginStrategyData(),
|
||||
this.environmentService,
|
||||
this.keyConnectorService,
|
||||
...sharedDeps,
|
||||
);
|
||||
case AuthenticationType.AuthRequest:
|
||||
return new AuthRequestLoginStrategy(
|
||||
data?.authRequest,
|
||||
data?.authRequest ?? new AuthRequestLoginStrategyData(),
|
||||
this.deviceTrustService,
|
||||
...sharedDeps,
|
||||
);
|
||||
case AuthenticationType.WebAuthn:
|
||||
return new WebAuthnLoginStrategy(data?.webAuthn, ...sharedDeps);
|
||||
return new WebAuthnLoginStrategy(
|
||||
data?.webAuthn ?? new WebAuthnLoginStrategyData(),
|
||||
...sharedDeps,
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user