1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-10 21:33:27 +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:
Alec Rippberger
2025-01-23 12:57:48 -06:00
committed by GitHub
parent f50f5ef70b
commit aa1c0ca0ee
35 changed files with 852 additions and 86 deletions

View File

@@ -0,0 +1,18 @@
import { svgIcon } from "@bitwarden/components";
export const DeviceVerificationIcon = svgIcon`
<svg viewBox="0 0 98 95" fill="none" xmlns="http://www.w3.org/2000/svg">
<path class="tw-stroke-art-primary" d="M12.1759 27.7453L2.54349 34.9329C1.57215 35.6577 1 36.7986 1 38.0105V89.6281C1 91.7489 2.71922 93.4681 4.84 93.4681H93.16C95.2808 93.4681 97 91.7489 97 89.6281V38.0276C97 36.806 96.4188 35.6574 95.4347 34.9338L85.6576 27.7453M61.8791 10.2622L50.9367 2.2168C49.5753 1.21588 47.7197 1.22245 46.3655 2.23297L35.6054 10.2622" stroke-width="1.92"/>
<path class="tw-stroke-art-primary" d="M85.7661 45.4682V12.1542C85.7661 11.0938 84.9064 10.2342 83.8461 10.2342H14.1541C13.0937 10.2342 12.2341 11.0938 12.2341 12.1542V45.4682" stroke-width="1.92" stroke-linecap="round"/>
<path class="tw-stroke-art-primary" d="M95.7335 92.1003L62.3151 61.2912C61.2514 60.3106 59.8576 59.7661 58.4109 59.7661H38.043C36.5571 59.7661 35.1286 60.3404 34.0562 61.3689L2.01148 92.1003" stroke-width="1.92"/>
<line class="tw-stroke-art-primary" x1="96.157" y1="39.125" x2="61.0395" y2="60.0979" stroke-width="1.92" stroke-linecap="round"/>
<path class="tw-stroke-art-primary" d="M1.84229 39.1248L36.673 59.7488" stroke-width="1.92" stroke-linecap="round"/>
<rect class="tw-stroke-art-accent" x="23.0046" y="25.5344" width="51.925" height="17.4487" rx="8.72434" stroke-width="0.96"/>
<circle class="tw-fill-art-accent" cx="30.2299" cy="34.2588" r="2.24846"/>
<circle class="tw-fill-art-accent" cx="45.2196" cy="34.2587" r="2.24846"/>
<circle class="tw-fill-art-accent" cx="60.2094" cy="34.2587" r="2.24846"/>
<circle class="tw-fill-art-accent" cx="37.7248" cy="34.2587" r="2.24846"/>
<circle class="tw-fill-art-accent" cx="52.7145" cy="34.2587" r="2.24846"/>
<circle class="tw-fill-art-accent" cx="67.704" cy="34.2587" r="2.24846"/>
</svg>
`;

View File

@@ -12,3 +12,4 @@ export * from "./registration-lock-alt.icon";
export * from "./registration-expired-link.icon";
export * from "./sso-key.icon";
export * from "./two-factor-timeout.icon";
export * from "./device-verification.icon";

View File

@@ -71,3 +71,6 @@ export * from "./self-hosted-env-config-dialog/self-hosted-env-config-dialog.com
// login approval
export * from "./login-approval/login-approval.component";
export * from "./login-approval/default-login-approval-component.service";
// device verification
export * from "./new-device-verification/new-device-verification.component";

View File

@@ -275,6 +275,12 @@ export class LoginComponent implements OnInit, OnDestroy {
return;
}
// Redirect to device verification if this is an unknown device
if (authResult.requiresDeviceVerification) {
await this.router.navigate(["device-verification"]);
return;
}
await this.loginSuccessHandlerService.run(authResult.userId);
if (authResult.forcePasswordReset != ForceSetPasswordReason.None) {

View File

@@ -0,0 +1,36 @@
<form [formGroup]="formGroup" [bitSubmit]="submit">
<bit-form-field class="!tw-mb-1">
<bit-label>{{ "verificationCode" | i18n }}</bit-label>
<input
bitInput
type="text"
id="verificationCode"
name="verificationCode"
formControlName="code"
appInputVerbatim
/>
</bit-form-field>
<button
bitLink
type="button"
linkType="primary"
(click)="resendOTP()"
[disabled]="disableRequestOTP"
class="tw-text-sm"
>
{{ "resendCode" | i18n }}
</button>
<div class="tw-flex tw-mt-4">
<button
bitButton
buttonType="primary"
type="submit"
[block]="true"
[disabled]="formGroup.invalid"
>
{{ "continueLoggingIn" | i18n }}
</button>
</div>
</form>

View File

@@ -0,0 +1,163 @@
import { CommonModule } from "@angular/common";
import { Component, OnDestroy, OnInit } from "@angular/core";
import { FormBuilder, ReactiveFormsModule, Validators } from "@angular/forms";
import { Router } from "@angular/router";
import { Subject, takeUntil } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import {
AsyncActionsModule,
ButtonModule,
FormFieldModule,
IconButtonModule,
LinkModule,
ToastService,
} from "@bitwarden/components";
import { LoginEmailServiceAbstraction } from "../../common/abstractions/login-email.service";
import { LoginStrategyServiceAbstraction } from "../../common/abstractions/login-strategy.service";
import { PasswordLoginStrategy } from "../../common/login-strategies/password-login.strategy";
/**
* Component for verifying a new device via a one-time password (OTP).
*/
@Component({
standalone: true,
selector: "app-new-device-verification",
templateUrl: "./new-device-verification.component.html",
imports: [
CommonModule,
ReactiveFormsModule,
AsyncActionsModule,
JslibModule,
ButtonModule,
FormFieldModule,
IconButtonModule,
LinkModule,
],
})
export class NewDeviceVerificationComponent implements OnInit, OnDestroy {
formGroup = this.formBuilder.group({
code: [
"",
{
validators: [Validators.required],
updateOn: "change",
},
],
});
protected disableRequestOTP = false;
private destroy$ = new Subject<void>();
protected authenticationSessionTimeoutRoute = "/authentication-timeout";
constructor(
private router: Router,
private formBuilder: FormBuilder,
private passwordLoginStrategy: PasswordLoginStrategy,
private apiService: ApiService,
private loginStrategyService: LoginStrategyServiceAbstraction,
private logService: LogService,
private toastService: ToastService,
private i18nService: I18nService,
private syncService: SyncService,
private loginEmailService: LoginEmailServiceAbstraction,
) {}
async ngOnInit() {
// Redirect to timeout route if session expires
this.loginStrategyService.authenticationSessionTimeout$
.pipe(takeUntil(this.destroy$))
.subscribe((expired) => {
if (!expired) {
return;
}
try {
void this.router.navigate([this.authenticationSessionTimeoutRoute]);
} catch (err) {
this.logService.error(
`Failed to navigate to ${this.authenticationSessionTimeoutRoute} route`,
err,
);
}
});
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
/**
* Resends the OTP for device verification.
*/
async resendOTP() {
this.disableRequestOTP = true;
try {
const email = await this.loginStrategyService.getEmail();
const masterPasswordHash = await this.loginStrategyService.getMasterPasswordHash();
if (!email || !masterPasswordHash) {
throw new Error("Missing email or master password hash");
}
await this.apiService.send(
"POST",
"/accounts/resend-new-device-otp",
{
email: email,
masterPasswordHash: masterPasswordHash,
},
false,
false,
);
} catch (e) {
this.logService.error(e);
} finally {
this.disableRequestOTP = false;
}
}
/**
* Submits the OTP for device verification.
*/
submit = async (): Promise<void> => {
const codeControl = this.formGroup.get("code");
if (!codeControl || !codeControl.value) {
return;
}
try {
const authResult = await this.loginStrategyService.logInNewDeviceVerification(
codeControl.value,
);
if (authResult.requiresTwoFactor) {
await this.router.navigate(["/2fa"]);
return;
}
if (authResult.forcePasswordReset) {
await this.router.navigate(["/update-temp-password"]);
return;
}
this.loginEmailService.clearValues();
await this.syncService.fullSync(true);
// If verification succeeds, navigate to vault
await this.router.navigate(["/vault"]);
} catch (e) {
this.logService.error(e);
const errorMessage =
(e as any)?.response?.error_description ?? this.i18nService.t("errorOccurred");
codeControl.setErrors({ serverError: { message: errorMessage } });
}
};
}

View File

@@ -47,7 +47,6 @@ export abstract class LoginStrategyServiceAbstraction {
* Auth Request. Otherwise, it will return null.
*/
getAuthRequestId: () => Promise<string | null>;
/**
* Sends a token request to the server using the provided credentials.
*/
@@ -74,7 +73,11 @@ export abstract class LoginStrategyServiceAbstraction {
*/
makePreloginKey: (masterPassword: string, email: string) => Promise<MasterKey>;
/**
* Emits true if the two factor session has expired.
* Emits true if the authentication session has expired.
*/
twoFactorTimeout$: Observable<boolean>;
authenticationSessionTimeout$: Observable<boolean>;
/**
* Sends a token request to the server with the provided device verification OTP.
*/
logInNewDeviceVerification: (deviceVerificationOtp: string) => Promise<AuthResult>;
}

View File

@@ -6,3 +6,4 @@ export * from "./models";
export * from "./types";
export * from "./services";
export * from "./utilities";
export * from "./login-strategies";

View File

@@ -0,0 +1 @@
export { PasswordLoginStrategy, PasswordLoginStrategyData } from "./password-login.strategy";

View File

@@ -4,6 +4,7 @@ import { BehaviorSubject } from "rxjs";
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 { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service";
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
@@ -12,6 +13,7 @@ import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/for
import { PasswordTokenRequest } from "@bitwarden/common/auth/models/request/identity-token/password-token.request";
import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/identity-token/token-two-factor.request";
import { IdentityCaptchaResponse } from "@bitwarden/common/auth/models/response/identity-captcha.response";
import { IdentityDeviceVerificationResponse } from "@bitwarden/common/auth/models/response/identity-device-verification.response";
import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response";
import { IdentityTwoFactorResponse } from "@bitwarden/common/auth/models/response/identity-two-factor.response";
import { MasterPasswordPolicyResponse } from "@bitwarden/common/auth/models/response/master-password-policy.response";
@@ -76,8 +78,8 @@ const twoFactorToken = "TWO_FACTOR_TOKEN";
const twoFactorRemember = true;
export function identityTokenResponseFactory(
masterPasswordPolicyResponse: MasterPasswordPolicyResponse = null,
userDecryptionOptions: IUserDecryptionOptionsServerResponse = null,
masterPasswordPolicyResponse: MasterPasswordPolicyResponse | undefined = undefined,
userDecryptionOptions: IUserDecryptionOptionsServerResponse | undefined = undefined,
) {
return new IdentityTokenResponse({
ForcePasswordReset: false,
@@ -155,7 +157,7 @@ describe("LoginStrategy", () => {
passwordStrengthService,
policyService,
loginStrategyService,
accountService,
accountService as unknown as AccountService,
masterPasswordService,
keyService,
encryptService,
@@ -286,13 +288,16 @@ describe("LoginStrategy", () => {
const result = await passwordLoginStrategy.logIn(credentials);
expect(result).toEqual({
userId: userId,
forcePasswordReset: ForceSetPasswordReason.AdminForcePasswordReset,
resetMasterPassword: true,
twoFactorProviders: null,
captchaSiteKey: "",
} as AuthResult);
const expected = new AuthResult();
expected.userId = userId;
expected.forcePasswordReset = ForceSetPasswordReason.AdminForcePasswordReset;
expected.resetMasterPassword = true;
expected.twoFactorProviders = {} as Partial<
Record<TwoFactorProviderType, Record<string, string>>
>;
expected.captchaSiteKey = "";
expected.twoFactorProviders = null;
expect(result).toEqual(expected);
});
it("rejects login if CAPTCHA is required", async () => {
@@ -377,10 +382,11 @@ describe("LoginStrategy", () => {
expect(tokenService.clearTwoFactorToken).toHaveBeenCalled();
const expected = new AuthResult();
expected.twoFactorProviders = { 0: null } as Record<
TwoFactorProviderType,
Record<string, string>
expected.twoFactorProviders = { 0: null } as unknown as Partial<
Record<TwoFactorProviderType, Record<string, string>>
>;
expected.email = "";
expected.ssoEmail2FaSessionToken = undefined;
expect(result).toEqual(expected);
});
@@ -460,14 +466,19 @@ describe("LoginStrategy", () => {
it("sends 2FA token provided by user to server (two-step)", async () => {
// Simulate a partially completed login
cache = new PasswordLoginStrategyData();
cache.tokenRequest = new PasswordTokenRequest(email, masterPasswordHash, null, null);
cache.tokenRequest = new PasswordTokenRequest(
email,
masterPasswordHash,
"",
new TokenTwoFactorRequest(),
);
passwordLoginStrategy = new PasswordLoginStrategy(
cache,
passwordStrengthService,
policyService,
loginStrategyService,
accountService,
accountService as AccountService,
masterPasswordService,
keyService,
encryptService,
@@ -489,7 +500,7 @@ describe("LoginStrategy", () => {
await passwordLoginStrategy.logInTwoFactor(
new TokenTwoFactorRequest(twoFactorProviderType, twoFactorToken, twoFactorRemember),
null,
"",
);
expect(apiService.postIdentityToken).toHaveBeenCalledWith(
@@ -503,4 +514,54 @@ describe("LoginStrategy", () => {
);
});
});
describe("Device verification", () => {
it("processes device verification response", async () => {
const captchaToken = "test-captcha-token";
const deviceVerificationResponse = new IdentityDeviceVerificationResponse({
error: "invalid_grant",
error_description: "Device verification required.",
email: "test@bitwarden.com",
deviceVerificationRequest: true,
captchaToken: captchaToken,
});
apiService.postIdentityToken.mockResolvedValue(deviceVerificationResponse);
cache = new PasswordLoginStrategyData();
cache.tokenRequest = new PasswordTokenRequest(
email,
masterPasswordHash,
"",
new TokenTwoFactorRequest(),
);
passwordLoginStrategy = new PasswordLoginStrategy(
cache,
passwordStrengthService,
policyService,
loginStrategyService,
accountService as AccountService,
masterPasswordService,
keyService,
encryptService,
apiService,
tokenService,
appIdService,
platformUtilsService,
messagingService,
logService,
stateService,
twoFactorService,
userDecryptionOptionsService,
billingAccountProfileStateService,
vaultTimeoutSettingsService,
kdfConfigService,
);
const result = await passwordLoginStrategy.logIn(credentials);
expect(result.requiresDeviceVerification).toBe(true);
});
});
});

View File

@@ -1,6 +1,4 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { BehaviorSubject, filter, firstValueFrom, timeout } from "rxjs";
import { BehaviorSubject, filter, firstValueFrom, timeout, Observable } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service";
@@ -18,6 +16,7 @@ import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/ide
import { UserApiTokenRequest } from "@bitwarden/common/auth/models/request/identity-token/user-api-token.request";
import { WebAuthnLoginTokenRequest } from "@bitwarden/common/auth/models/request/identity-token/webauthn-login-token.request";
import { IdentityCaptchaResponse } from "@bitwarden/common/auth/models/response/identity-captcha.response";
import { IdentityDeviceVerificationResponse } from "@bitwarden/common/auth/models/response/identity-device-verification.response";
import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response";
import { IdentityTwoFactorResponse } from "@bitwarden/common/auth/models/response/identity-two-factor.response";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
@@ -51,14 +50,19 @@ import {
import { UserDecryptionOptions } from "../models/domain/user-decryption-options";
import { CacheData } from "../services/login-strategies/login-strategy.state";
type IdentityResponse = IdentityTokenResponse | IdentityTwoFactorResponse | IdentityCaptchaResponse;
type IdentityResponse =
| IdentityTokenResponse
| IdentityTwoFactorResponse
| IdentityCaptchaResponse
| IdentityDeviceVerificationResponse;
export abstract class LoginStrategyData {
tokenRequest:
| UserApiTokenRequest
| PasswordTokenRequest
| SsoTokenRequest
| WebAuthnLoginTokenRequest;
| WebAuthnLoginTokenRequest
| undefined;
captchaBypassToken?: string;
/** User's entered email obtained pre-login. */
@@ -67,6 +71,8 @@ export abstract class LoginStrategyData {
export abstract class LoginStrategy {
protected abstract cache: BehaviorSubject<LoginStrategyData>;
protected sessionTimeoutSubject = new BehaviorSubject<boolean>(false);
sessionTimeout$: Observable<boolean> = this.sessionTimeoutSubject.asObservable();
constructor(
protected accountService: AccountService,
@@ -100,9 +106,12 @@ export abstract class LoginStrategy {
async logInTwoFactor(
twoFactor: TokenTwoFactorRequest,
captchaResponse: string = null,
captchaResponse: string | null = null,
): Promise<AuthResult> {
const data = this.cache.value;
if (!data.tokenRequest) {
throw new Error("Token request is undefined");
}
data.tokenRequest.setTwoFactor(twoFactor);
this.cache.next(data);
const [authResult] = await this.startLogIn();
@@ -113,6 +122,9 @@ export abstract class LoginStrategy {
await this.twoFactorService.clearSelectedProvider();
const tokenRequest = this.cache.value.tokenRequest;
if (!tokenRequest) {
throw new Error("Token request is undefined");
}
const response = await this.apiService.postIdentityToken(tokenRequest);
if (response instanceof IdentityTwoFactorResponse) {
@@ -121,6 +133,8 @@ export abstract class LoginStrategy {
return [await this.processCaptchaResponse(response), response];
} else if (response instanceof IdentityTokenResponse) {
return [await this.processTokenResponse(response), response];
} else if (response instanceof IdentityDeviceVerificationResponse) {
return [await this.processDeviceVerificationResponse(response), response];
}
throw new Error("Invalid response object.");
@@ -176,8 +190,8 @@ export abstract class LoginStrategy {
await this.accountService.addAccount(userId, {
name: accountInformation.name,
email: accountInformation.email,
emailVerified: accountInformation.email_verified,
email: accountInformation.email ?? "",
emailVerified: accountInformation.email_verified ?? false,
});
await this.accountService.switchAccount(userId);
@@ -230,7 +244,7 @@ export abstract class LoginStrategy {
);
await this.billingAccountProfileStateService.setHasPremium(
accountInformation.premium,
accountInformation.premium ?? false,
false,
userId,
);
@@ -291,6 +305,9 @@ export abstract class LoginStrategy {
try {
const userKey = await this.keyService.getUserKeyWithLegacySupport(userId);
const [publicKey, privateKey] = await this.keyService.makeKeyPair(userKey);
if (!privateKey.encryptedString) {
throw new Error("Failed to create encrypted private key");
}
await this.apiService.postAccountKeys(new KeysRequest(publicKey, privateKey.encryptedString));
return privateKey.encryptedString;
} catch (e) {
@@ -316,7 +333,8 @@ export abstract class LoginStrategy {
await this.twoFactorService.setProviders(response);
this.cache.next({ ...this.cache.value, captchaBypassToken: response.captchaToken ?? null });
result.ssoEmail2FaSessionToken = response.ssoEmail2faSessionToken;
result.email = response.email;
result.email = response.email ?? "";
return result;
}
@@ -355,4 +373,22 @@ export abstract class LoginStrategy {
),
);
}
/**
* Handles the response from the server when a device verification is required.
* It sets the requiresDeviceVerification flag to true and caches the captcha token if it came back.
*
* @param {IdentityDeviceVerificationResponse} response - The response from the server indicating that device verification is required.
* @returns {Promise<AuthResult>} - A promise that resolves to an AuthResult object
*/
protected async processDeviceVerificationResponse(
response: IdentityDeviceVerificationResponse,
): Promise<AuthResult> {
const result = new AuthResult();
result.requiresDeviceVerification = true;
// Extend cached data with captcha bypass token if it came back.
this.cache.next({ ...this.cache.value, captchaBypassToken: response.captchaToken ?? null });
return result;
}
}

View File

@@ -276,4 +276,24 @@ describe("PasswordLoginStrategy", () => {
);
expect(secondResult.forcePasswordReset).toEqual(ForceSetPasswordReason.WeakMasterPassword);
});
it("handles new device verification login with OTP", async () => {
const deviceVerificationOtp = "123456";
const tokenResponse = identityTokenResponseFactory();
apiService.postIdentityToken.mockResolvedValueOnce(tokenResponse);
tokenService.decodeAccessToken.mockResolvedValue({ sub: userId });
await passwordLoginStrategy.logIn(credentials);
const result = await passwordLoginStrategy.logInNewDeviceVerification(deviceVerificationOtp);
expect(apiService.postIdentityToken).toHaveBeenCalledWith(
expect.objectContaining({
newDeviceOtp: deviceVerificationOtp,
}),
);
expect(result.forcePasswordReset).toBe(ForceSetPasswordReason.None);
expect(result.resetMasterPassword).toBe(false);
expect(result.userId).toBe(userId);
});
});

View File

@@ -10,6 +10,7 @@ import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/for
import { PasswordTokenRequest } from "@bitwarden/common/auth/models/request/identity-token/password-token.request";
import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/identity-token/token-two-factor.request";
import { IdentityCaptchaResponse } from "@bitwarden/common/auth/models/response/identity-captcha.response";
import { IdentityDeviceVerificationResponse } from "@bitwarden/common/auth/models/response/identity-device-verification.response";
import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response";
import { IdentityTwoFactorResponse } from "@bitwarden/common/auth/models/response/identity-two-factor.response";
import { HashPurpose } from "@bitwarden/common/platform/enums";
@@ -208,9 +209,12 @@ export class PasswordLoginStrategy extends LoginStrategy {
}
private getMasterPasswordPolicyOptionsFromResponse(
response: IdentityTokenResponse | IdentityTwoFactorResponse,
response:
| IdentityTokenResponse
| IdentityTwoFactorResponse
| IdentityDeviceVerificationResponse,
): MasterPasswordPolicyOptions {
if (response == null) {
if (response == null || response instanceof IdentityDeviceVerificationResponse) {
return null;
}
return MasterPasswordPolicyOptions.fromResponse(response.masterPasswordPolicy);
@@ -233,4 +237,13 @@ export class PasswordLoginStrategy extends LoginStrategy {
password: this.cache.value,
};
}
async logInNewDeviceVerification(deviceVerificationOtp: string): Promise<AuthResult> {
const data = this.cache.value;
data.tokenRequest.newDeviceOtp = deviceVerificationOtp;
this.cache.next(data);
const [authResult] = await this.startLogIn();
return authResult;
}
}

View File

@@ -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,
}),
);
});
});

View File

@@ -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,
);
}
}),
);