1
0
mirror of https://github.com/bitwarden/browser synced 2026-01-09 12:03:33 +00:00

[PM-5255, PM-3339] Refactor login strategy to use state providers (#7821)

* add key definition and StrategyData classes

* use state providers for login strategies

* serialize login data for cache

* use state providers for auth request notification

* fix registrations

* add docs to abstraction

* fix sso strategy

* fix password login strategy tests

* fix base login strategy tests

* fix user api login strategy tests

* PM-3339 add tests for admin auth request in sso strategy

* fix auth request login strategy tests

* fix webauthn login strategy tests

* create login strategy state

* use barrel file in common/spec

* test login strategy cache deserialization

* use global state provider

* add test for login strategy service

* fix auth request storage

* add recursive prototype checking and json deserializers to nested objects

* fix CLI

* Create wrapper for login strategy cache

* use behavior subjects in strategies instead of global state

* rename userApi to userApiKey

* pr feedback

* fix tests

* fix deserialization tests

* fix tests

---------

Co-authored-by: rr-bw <102181210+rr-bw@users.noreply.github.com>
This commit is contained in:
Jake Fink
2024-03-12 14:19:50 -04:00
committed by GitHub
parent 6b1da67f3a
commit a0e0637bb6
35 changed files with 1414 additions and 362 deletions

View File

@@ -0,0 +1,201 @@
import { MockProxy, mock } from "jest-mock-extended";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction";
import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.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";
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 { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response";
import { IdentityTwoFactorResponse } from "@bitwarden/common/auth/models/response/identity-two-factor.response";
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { 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 { KdfType } from "@bitwarden/common/platform/enums";
import { FakeGlobalState, FakeGlobalStateProvider } from "@bitwarden/common/spec";
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
import { AuthRequestServiceAbstraction } from "../../abstractions";
import { PasswordLoginCredentials } from "../../models";
import { LoginStrategyService } from "./login-strategy.service";
import { CACHE_EXPIRATION_KEY } from "./login-strategy.state";
describe("LoginStrategyService", () => {
let sut: LoginStrategyService;
let cryptoService: MockProxy<CryptoService>;
let apiService: MockProxy<ApiService>;
let tokenService: MockProxy<TokenService>;
let appIdService: MockProxy<AppIdService>;
let platformUtilsService: MockProxy<PlatformUtilsService>;
let messagingService: MockProxy<MessagingService>;
let logService: MockProxy<LogService>;
let keyConnectorService: MockProxy<KeyConnectorService>;
let environmentService: MockProxy<EnvironmentService>;
let stateService: MockProxy<StateService>;
let twoFactorService: MockProxy<TwoFactorService>;
let i18nService: MockProxy<I18nService>;
let encryptService: MockProxy<EncryptService>;
let passwordStrengthService: MockProxy<PasswordStrengthServiceAbstraction>;
let policyService: MockProxy<PolicyService>;
let deviceTrustCryptoService: MockProxy<DeviceTrustCryptoServiceAbstraction>;
let authRequestService: MockProxy<AuthRequestServiceAbstraction>;
let stateProvider: FakeGlobalStateProvider;
let loginStrategyCacheExpirationState: FakeGlobalState<Date | null>;
beforeEach(() => {
cryptoService = mock<CryptoService>();
apiService = mock<ApiService>();
tokenService = mock<TokenService>();
appIdService = mock<AppIdService>();
platformUtilsService = mock<PlatformUtilsService>();
messagingService = mock<MessagingService>();
logService = mock<LogService>();
keyConnectorService = mock<KeyConnectorService>();
environmentService = mock<EnvironmentService>();
stateService = mock<StateService>();
twoFactorService = mock<TwoFactorService>();
i18nService = mock<I18nService>();
encryptService = mock<EncryptService>();
passwordStrengthService = mock<PasswordStrengthServiceAbstraction>();
policyService = mock<PolicyService>();
deviceTrustCryptoService = mock<DeviceTrustCryptoServiceAbstraction>();
authRequestService = mock<AuthRequestServiceAbstraction>();
stateProvider = new FakeGlobalStateProvider();
sut = new LoginStrategyService(
cryptoService,
apiService,
tokenService,
appIdService,
platformUtilsService,
messagingService,
logService,
keyConnectorService,
environmentService,
stateService,
twoFactorService,
i18nService,
encryptService,
passwordStrengthService,
policyService,
deviceTrustCryptoService,
authRequestService,
stateProvider,
);
loginStrategyCacheExpirationState = stateProvider.getFake(CACHE_EXPIRATION_KEY);
});
it("should return an AuthResult on successful login", async () => {
const credentials = new PasswordLoginCredentials("EMAIL", "MASTER_PASSWORD");
apiService.postIdentityToken.mockResolvedValue(
new IdentityTokenResponse({
ForcePasswordReset: false,
Kdf: KdfType.Argon2id,
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.decodeToken.calledWith("ACCESS_TOKEN").mockResolvedValue({
sub: "USER_ID",
name: "NAME",
email: "EMAIL",
premium: false,
});
const result = await sut.logIn(credentials);
expect(result).toBeInstanceOf(AuthResult);
});
it("should return an AuthResult on successful 2fa login", async () => {
const credentials = new PasswordLoginCredentials("EMAIL", "MASTER_PASSWORD");
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);
const twoFactorToken = new TokenTwoFactorRequest(
TwoFactorProviderType.Authenticator,
"TWO_FACTOR_TOKEN",
true,
);
apiService.postIdentityToken.mockResolvedValue(
new IdentityTokenResponse({
ForcePasswordReset: false,
Kdf: KdfType.Argon2id,
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.decodeToken.calledWith("ACCESS_TOKEN").mockResolvedValue({
sub: "USER_ID",
name: "NAME",
email: "EMAIL",
premium: false,
});
const result = await sut.logInTwoFactor(twoFactorToken, "CAPTCHA");
expect(result).toBeInstanceOf(AuthResult);
});
it("should clear the cache if more than 2 mins have passed since expiration date", async () => {
const credentials = new PasswordLoginCredentials("EMAIL", "MASTER_PASSWORD");
apiService.postIdentityToken.mockResolvedValue(
new IdentityTwoFactorResponse({
TwoFactorProviders: ["0"],
TwoFactorProviders2: { 0: null },
error: "invalid_grant",
error_description: "Two factor required.",
email: undefined,
ssoEmail2faSessionToken: undefined,
}),
);
await sut.logIn(credentials);
loginStrategyCacheExpirationState.stateSubject.next(new Date(Date.now() - 1000 * 60 * 5));
const twoFactorToken = new TokenTwoFactorRequest(
TwoFactorProviderType.Authenticator,
"TWO_FACTOR_TOKEN",
true,
);
await expect(sut.logInTwoFactor(twoFactorToken, "CAPTCHA")).rejects.toThrow();
});
});

View File

@@ -1,4 +1,12 @@
import { Observable, Subject } from "rxjs";
import {
combineLatestWith,
distinctUntilChanged,
filter,
firstValueFrom,
map,
Observable,
shareReplay,
} from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
@@ -10,6 +18,8 @@ import { AuthenticationType } from "@bitwarden/common/auth/enums/authentication-
import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
import { KdfConfig } from "@bitwarden/common/auth/models/domain/kdf-config";
import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/identity-token/token-two-factor.request";
import { PasswordlessAuthRequest } from "@bitwarden/common/auth/models/request/passwordless-auth.request";
import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response";
import { PreloginRequest } from "@bitwarden/common/models/request/prelogin.request";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { AuthRequestPushNotification } from "@bitwarden/common/models/response/notification.response";
@@ -23,6 +33,8 @@ import { MessagingService } from "@bitwarden/common/platform/abstractions/messag
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { KdfType } from "@bitwarden/common/platform/enums";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { GlobalState, GlobalStateProvider } from "@bitwarden/common/platform/state";
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
import { MasterKey } from "@bitwarden/common/types/key";
@@ -40,54 +52,35 @@ import {
WebAuthnLoginCredentials,
} from "../../models";
import {
AUTH_REQUEST_PUSH_NOTIFICATION_KEY,
CURRENT_LOGIN_STRATEGY_KEY,
CacheData,
CACHE_EXPIRATION_KEY,
CACHE_KEY,
} from "./login-strategy.state";
const sessionTimeoutLength = 2 * 60 * 1000; // 2 minutes
export class LoginStrategyService implements LoginStrategyServiceAbstraction {
get email(): string {
if (
this.logInStrategy instanceof PasswordLoginStrategy ||
this.logInStrategy instanceof AuthRequestLoginStrategy ||
this.logInStrategy instanceof SsoLoginStrategy
) {
return this.logInStrategy.email;
}
private sessionTimeout: unknown;
private currentAuthnTypeState: GlobalState<AuthenticationType | null>;
private loginStrategyCacheState: GlobalState<CacheData | null>;
private loginStrategyCacheExpirationState: GlobalState<Date | null>;
private authRequestPushNotificationState: GlobalState<string>;
return null;
}
get masterPasswordHash(): string {
return this.logInStrategy instanceof PasswordLoginStrategy
? this.logInStrategy.masterPasswordHash
: null;
}
get accessCode(): string {
return this.logInStrategy instanceof AuthRequestLoginStrategy
? this.logInStrategy.accessCode
: null;
}
get authRequestId(): string {
return this.logInStrategy instanceof AuthRequestLoginStrategy
? this.logInStrategy.authRequestId
: null;
}
get ssoEmail2FaSessionToken(): string {
return this.logInStrategy instanceof SsoLoginStrategy
? this.logInStrategy.ssoEmail2FaSessionToken
: null;
}
private logInStrategy:
private loginStrategy$: Observable<
| UserApiLoginStrategy
| PasswordLoginStrategy
| SsoLoginStrategy
| AuthRequestLoginStrategy
| WebAuthnLoginStrategy;
private sessionTimeout: any;
| WebAuthnLoginStrategy
| null
>;
private pushNotificationSubject = new Subject<string>();
currentAuthType$: Observable<AuthenticationType | null>;
// TODO: move to auth request service
authRequestPushNotification$: Observable<string>;
constructor(
protected cryptoService: CryptoService,
@@ -107,7 +100,71 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction {
protected policyService: PolicyService,
protected deviceTrustCryptoService: DeviceTrustCryptoServiceAbstraction,
protected authRequestService: AuthRequestServiceAbstraction,
) {}
protected stateProvider: GlobalStateProvider,
) {
this.currentAuthnTypeState = this.stateProvider.get(CURRENT_LOGIN_STRATEGY_KEY);
this.loginStrategyCacheState = this.stateProvider.get(CACHE_KEY);
this.loginStrategyCacheExpirationState = this.stateProvider.get(CACHE_EXPIRATION_KEY);
this.authRequestPushNotificationState = this.stateProvider.get(
AUTH_REQUEST_PUSH_NOTIFICATION_KEY,
);
this.currentAuthType$ = this.currentAuthnTypeState.state$;
this.authRequestPushNotification$ = this.authRequestPushNotificationState.state$.pipe(
filter((id) => id != null),
);
this.loginStrategy$ = this.currentAuthnTypeState.state$.pipe(
distinctUntilChanged(),
combineLatestWith(this.loginStrategyCacheState.state$),
this.initializeLoginStrategy.bind(this),
shareReplay({ refCount: true, bufferSize: 1 }),
);
}
async getEmail(): Promise<string | null> {
const strategy = await firstValueFrom(this.loginStrategy$);
if ("email$" in strategy) {
return await firstValueFrom(strategy.email$);
}
return null;
}
async getMasterPasswordHash(): Promise<string | null> {
const strategy = await firstValueFrom(this.loginStrategy$);
if ("masterKeyHash$" in strategy) {
return await firstValueFrom(strategy.masterKeyHash$);
}
return null;
}
async getSsoEmail2FaSessionToken(): Promise<string | null> {
const strategy = await firstValueFrom(this.loginStrategy$);
if ("ssoEmail2FaSessionToken$" in strategy) {
return await firstValueFrom(strategy.ssoEmail2FaSessionToken$);
}
return null;
}
async getAccessCode(): Promise<string | null> {
const strategy = await firstValueFrom(this.loginStrategy$);
if ("accessCode$" in strategy) {
return await firstValueFrom(strategy.accessCode$);
}
return null;
}
async getAuthRequestId(): Promise<string | null> {
const strategy = await firstValueFrom(this.loginStrategy$);
if ("authRequestId$" in strategy) {
return await firstValueFrom(strategy.authRequestId$);
}
return null;
}
async logIn(
credentials:
@@ -117,99 +174,27 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction {
| AuthRequestLoginCredentials
| WebAuthnLoginCredentials,
): Promise<AuthResult> {
this.clearState();
await this.clearCache();
let strategy:
| UserApiLoginStrategy
| PasswordLoginStrategy
| SsoLoginStrategy
| AuthRequestLoginStrategy
| WebAuthnLoginStrategy;
await this.currentAuthnTypeState.update((_) => credentials.type);
switch (credentials.type) {
case AuthenticationType.Password:
strategy = new PasswordLoginStrategy(
this.cryptoService,
this.apiService,
this.tokenService,
this.appIdService,
this.platformUtilsService,
this.messagingService,
this.logService,
this.stateService,
this.twoFactorService,
this.passwordStrengthService,
this.policyService,
this,
);
break;
case AuthenticationType.Sso:
strategy = new SsoLoginStrategy(
this.cryptoService,
this.apiService,
this.tokenService,
this.appIdService,
this.platformUtilsService,
this.messagingService,
this.logService,
this.stateService,
this.twoFactorService,
this.keyConnectorService,
this.deviceTrustCryptoService,
this.authRequestService,
this.i18nService,
);
break;
case AuthenticationType.UserApi:
strategy = new UserApiLoginStrategy(
this.cryptoService,
this.apiService,
this.tokenService,
this.appIdService,
this.platformUtilsService,
this.messagingService,
this.logService,
this.stateService,
this.twoFactorService,
this.environmentService,
this.keyConnectorService,
);
break;
case AuthenticationType.AuthRequest:
strategy = new AuthRequestLoginStrategy(
this.cryptoService,
this.apiService,
this.tokenService,
this.appIdService,
this.platformUtilsService,
this.messagingService,
this.logService,
this.stateService,
this.twoFactorService,
this.deviceTrustCryptoService,
);
break;
case AuthenticationType.WebAuthn:
strategy = new WebAuthnLoginStrategy(
this.cryptoService,
this.apiService,
this.tokenService,
this.appIdService,
this.platformUtilsService,
this.messagingService,
this.logService,
this.stateService,
this.twoFactorService,
);
break;
}
const strategy = await firstValueFrom(this.loginStrategy$);
// Note: Do not set the credentials object directly on the strategy. They are
// Note: We aren't passing the credentials directly to the strategy since they are
// created in the popup and can cause DeadObject references on Firefox.
const result = await strategy.logIn(credentials as any);
// This is a shallow copy, but use deep copy in future if objects are added to credentials
// that were created in popup.
// If the popup uses its own instance of this service, this can be removed.
const ownedCredentials = { ...credentials };
if (result?.requiresTwoFactor) {
this.saveState(strategy);
const result = await strategy.logIn(ownedCredentials as any);
if (result != null && !result.requiresTwoFactor) {
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());
await this.startSessionTimeout();
}
return result;
@@ -219,43 +204,32 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction {
twoFactor: TokenTwoFactorRequest,
captchaResponse: string,
): Promise<AuthResult> {
if (this.logInStrategy == null) {
if (!(await this.isSessionValid())) {
throw new Error(this.i18nService.t("sessionTimeout"));
}
try {
const result = await this.logInStrategy.logInTwoFactor(twoFactor, captchaResponse);
const strategy = await firstValueFrom(this.loginStrategy$);
if (strategy == null) {
throw new Error("No login strategy found.");
}
// Only clear state if 2FA token has been accepted, otherwise we need to be able to try again
if (!result.requiresTwoFactor && !result.requiresCaptcha) {
this.clearState();
try {
const result = await strategy.logInTwoFactor(twoFactor, captchaResponse);
// Only clear cache if 2FA token has been accepted, otherwise we need to be able to try again
if (result != null && !result.requiresTwoFactor && !result.requiresCaptcha) {
await this.clearCache();
}
return result;
} catch (e) {
// API exceptions are okay, but if there are any unhandled client-side errors then clear state to be safe
// API exceptions are okay, but if there are any unhandled client-side errors then clear cache to be safe
if (!(e instanceof ErrorResponse)) {
this.clearState();
await this.clearCache();
}
throw e;
}
}
authingWithUserApiKey(): boolean {
return this.logInStrategy instanceof UserApiLoginStrategy;
}
authingWithSso(): boolean {
return this.logInStrategy instanceof SsoLoginStrategy;
}
authingWithPassword(): boolean {
return this.logInStrategy instanceof PasswordLoginStrategy;
}
authingWithPasswordless(): boolean {
return this.logInStrategy instanceof AuthRequestLoginStrategy;
}
async makePreloginKey(masterPassword: string, email: string): Promise<MasterKey> {
email = email.trim().toLowerCase();
let kdf: KdfType = null;
@@ -278,39 +252,171 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction {
return await this.cryptoService.makeMasterKey(masterPassword, email, kdf, kdfConfig);
}
async authResponsePushNotification(notification: AuthRequestPushNotification): Promise<any> {
this.pushNotificationSubject.next(notification.id);
}
getPushNotificationObs$(): Observable<any> {
return this.pushNotificationSubject.asObservable();
}
private saveState(
strategy:
| UserApiLoginStrategy
| PasswordLoginStrategy
| SsoLoginStrategy
| AuthRequestLoginStrategy
| WebAuthnLoginStrategy,
) {
this.logInStrategy = strategy;
this.startSessionTimeout();
}
private clearState() {
this.logInStrategy = null;
this.clearSessionTimeout();
}
private startSessionTimeout() {
this.clearSessionTimeout();
this.sessionTimeout = setTimeout(() => this.clearState(), sessionTimeoutLength);
}
private clearSessionTimeout() {
if (this.sessionTimeout != null) {
clearTimeout(this.sessionTimeout);
// TODO move to auth request service
async sendAuthRequestPushNotification(notification: AuthRequestPushNotification): Promise<void> {
if (notification.id != null) {
await this.authRequestPushNotificationState.update((_) => notification.id);
}
}
// TODO: move to auth request service
async passwordlessLogin(
id: string,
key: string,
requestApproved: boolean,
): Promise<AuthRequestResponse> {
const pubKey = Utils.fromB64ToArray(key);
const masterKey = await this.cryptoService.getMasterKey();
let keyToEncrypt;
let encryptedMasterKeyHash = null;
if (masterKey) {
keyToEncrypt = masterKey.encKey;
// Only encrypt the master password hash if masterKey exists as
// we won't have a masterKeyHash without a masterKey
const masterKeyHash = await this.stateService.getKeyHash();
if (masterKeyHash != null) {
encryptedMasterKeyHash = await this.cryptoService.rsaEncrypt(
Utils.fromUtf8ToArray(masterKeyHash),
pubKey,
);
}
} else {
const userKey = await this.cryptoService.getUserKey();
keyToEncrypt = userKey.key;
}
const encryptedKey = await this.cryptoService.rsaEncrypt(keyToEncrypt, pubKey);
const request = new PasswordlessAuthRequest(
encryptedKey.encryptedString,
encryptedMasterKeyHash?.encryptedString,
await this.appIdService.getAppId(),
requestApproved,
);
return await this.apiService.putAuthRequest(id, request);
}
private async clearCache(): Promise<void> {
await this.currentAuthnTypeState.update((_) => null);
await this.loginStrategyCacheState.update((_) => null);
await this.clearSessionTimeout();
}
private async startSessionTimeout(): Promise<void> {
await this.clearSessionTimeout();
await this.loginStrategyCacheExpirationState.update(
(_) => new Date(Date.now() + sessionTimeoutLength),
);
this.sessionTimeout = setTimeout(() => this.clearCache(), sessionTimeoutLength);
}
private async clearSessionTimeout(): Promise<void> {
await this.loginStrategyCacheExpirationState.update((_) => null);
this.sessionTimeout = null;
}
private async isSessionValid(): Promise<boolean> {
const cache = await firstValueFrom(this.loginStrategyCacheState.state$);
if (cache == null) {
return false;
}
const expiration = await firstValueFrom(this.loginStrategyCacheExpirationState.state$);
if (expiration != null && expiration < new Date()) {
await this.clearCache();
return false;
}
return true;
}
private initializeLoginStrategy(
source: Observable<[AuthenticationType | null, CacheData | null]>,
) {
return source.pipe(
map(([strategy, data]) => {
if (strategy == null) {
return null;
}
switch (strategy) {
case AuthenticationType.Password:
return new PasswordLoginStrategy(
data?.password,
this.cryptoService,
this.apiService,
this.tokenService,
this.appIdService,
this.platformUtilsService,
this.messagingService,
this.logService,
this.stateService,
this.twoFactorService,
this.passwordStrengthService,
this.policyService,
this,
);
case AuthenticationType.Sso:
return new SsoLoginStrategy(
data?.sso,
this.cryptoService,
this.apiService,
this.tokenService,
this.appIdService,
this.platformUtilsService,
this.messagingService,
this.logService,
this.stateService,
this.twoFactorService,
this.keyConnectorService,
this.deviceTrustCryptoService,
this.authRequestService,
this.i18nService,
);
case AuthenticationType.UserApiKey:
return new UserApiLoginStrategy(
data?.userApiKey,
this.cryptoService,
this.apiService,
this.tokenService,
this.appIdService,
this.platformUtilsService,
this.messagingService,
this.logService,
this.stateService,
this.twoFactorService,
this.environmentService,
this.keyConnectorService,
);
case AuthenticationType.AuthRequest:
return new AuthRequestLoginStrategy(
data?.authRequest,
this.cryptoService,
this.apiService,
this.tokenService,
this.appIdService,
this.platformUtilsService,
this.messagingService,
this.logService,
this.stateService,
this.twoFactorService,
this.deviceTrustCryptoService,
);
case AuthenticationType.WebAuthn:
return new WebAuthnLoginStrategy(
data?.webAuthn,
this.cryptoService,
this.apiService,
this.tokenService,
this.appIdService,
this.platformUtilsService,
this.messagingService,
this.logService,
this.stateService,
this.twoFactorService,
);
}
}),
);
}
}

View File

@@ -0,0 +1,155 @@
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
import { DeviceRequest } from "@bitwarden/common/auth/models/request/identity-token/device.request";
import { PasswordTokenRequest } from "@bitwarden/common/auth/models/request/identity-token/password-token.request";
import { SsoTokenRequest } from "@bitwarden/common/auth/models/request/identity-token/sso-token.request";
import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/identity-token/token-two-factor.request";
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 { WebAuthnLoginAssertionResponseRequest } from "@bitwarden/common/auth/services/webauthn-login/request/webauthn-login-assertion-response.request";
import { DeviceType } from "@bitwarden/common/enums";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { MasterKey, PrfKey, UserKey } from "@bitwarden/common/types/key";
import { AuthRequestLoginStrategyData } from "../../login-strategies/auth-request-login.strategy";
import { PasswordLoginStrategyData } from "../../login-strategies/password-login.strategy";
import { SsoLoginStrategyData } from "../../login-strategies/sso-login.strategy";
import { UserApiLoginStrategyData } from "../../login-strategies/user-api-login.strategy";
import { WebAuthnLoginStrategyData } from "../../login-strategies/webauthn-login.strategy";
import {
MockAuthenticatorAssertionResponse,
MockPublicKeyCredential,
} from "../../login-strategies/webauthn-login.strategy.spec";
import { AuthRequestLoginCredentials, WebAuthnLoginCredentials } from "../../models";
import { CACHE_KEY } from "./login-strategy.state";
describe("LOGIN_STRATEGY_CACHE_KEY", () => {
const sut = CACHE_KEY;
let deviceRequest: DeviceRequest;
let twoFactorRequest: TokenTwoFactorRequest;
beforeEach(() => {
deviceRequest = Object.assign(Object.create(DeviceRequest.prototype), {
type: DeviceType.ChromeBrowser,
name: "DEVICE_NAME",
identifier: "DEVICE_IDENTIFIER",
pushToken: "PUSH_TOKEN",
});
twoFactorRequest = new TokenTwoFactorRequest(TwoFactorProviderType.Email, "TOKEN", false);
});
it("should correctly deserialize PasswordLoginStrategyData", () => {
const actual = {
password: new PasswordLoginStrategyData(),
};
actual.password.tokenRequest = new PasswordTokenRequest(
"EMAIL",
"LOCAL_PASSWORD_HASH",
"CAPTCHA_TOKEN",
twoFactorRequest,
deviceRequest,
);
actual.password.masterKey = new SymmetricCryptoKey(new Uint8Array(64)) as MasterKey;
actual.password.localMasterKeyHash = "LOCAL_MASTER_KEY_HASH";
const result = sut.deserializer(JSON.parse(JSON.stringify(actual)));
expect(result.password).toBeInstanceOf(PasswordLoginStrategyData);
verifyPropertyPrototypes(result, actual);
});
it("should correctly deserialize SsoLoginStrategyData", () => {
const actual = { sso: new SsoLoginStrategyData() };
actual.sso.tokenRequest = new SsoTokenRequest(
"CODE",
"CODE_VERIFIER",
"REDIRECT_URI",
twoFactorRequest,
deviceRequest,
);
const result = sut.deserializer(JSON.parse(JSON.stringify(actual)));
expect(result.sso).toBeInstanceOf(SsoLoginStrategyData);
verifyPropertyPrototypes(result, actual);
});
it("should correctly deserialize UserApiLoginStrategyData", () => {
const actual = { userApiKey: new UserApiLoginStrategyData() };
actual.userApiKey.tokenRequest = new UserApiTokenRequest("CLIENT_ID", "CLIENT_SECRET", null);
const result = sut.deserializer(JSON.parse(JSON.stringify(actual)));
expect(result.userApiKey).toBeInstanceOf(UserApiLoginStrategyData);
verifyPropertyPrototypes(result, actual);
});
it("should correctly deserialize AuthRequestLoginStrategyData", () => {
const actual = { authRequest: new AuthRequestLoginStrategyData() };
actual.authRequest.tokenRequest = new PasswordTokenRequest("EMAIL", "ACCESS_CODE", null, null);
actual.authRequest.authRequestCredentials = new AuthRequestLoginCredentials(
"EMAIL",
"ACCESS_CODE",
"AUTH_REQUEST_ID",
new SymmetricCryptoKey(new Uint8Array(64)) as UserKey,
new SymmetricCryptoKey(new Uint8Array(64)) as MasterKey,
"MASTER_KEY_HASH",
);
const result = sut.deserializer(JSON.parse(JSON.stringify(actual)));
expect(result.authRequest).toBeInstanceOf(AuthRequestLoginStrategyData);
verifyPropertyPrototypes(result, actual);
});
it("should correctly deserialize WebAuthnLoginStrategyData", () => {
global.AuthenticatorAssertionResponse = MockAuthenticatorAssertionResponse;
const actual = { webAuthn: new WebAuthnLoginStrategyData() };
const publicKeyCredential = new MockPublicKeyCredential();
const deviceResponse = new WebAuthnLoginAssertionResponseRequest(publicKeyCredential);
const prfKey = new SymmetricCryptoKey(new Uint8Array(64)) as PrfKey;
actual.webAuthn.credentials = new WebAuthnLoginCredentials("TOKEN", deviceResponse, prfKey);
actual.webAuthn.tokenRequest = new WebAuthnLoginTokenRequest(
"TOKEN",
deviceResponse,
deviceRequest,
);
actual.webAuthn.captchaBypassToken = "CAPTCHA_BYPASS_TOKEN";
actual.webAuthn.tokenRequest.setTwoFactor(
new TokenTwoFactorRequest(TwoFactorProviderType.Email, "TOKEN", false),
);
const result = sut.deserializer(JSON.parse(JSON.stringify(actual)));
expect(result.webAuthn).toBeInstanceOf(WebAuthnLoginStrategyData);
verifyPropertyPrototypes(result, actual);
});
});
/**
* Recursively verifies the prototypes of all objects in the deserialized object.
* It is important that the concrete object has the correct prototypes for
* comparison.
* @param deserialized the deserialized object
* @param concrete the object stored in state
*/
function verifyPropertyPrototypes(deserialized: object, concrete: object) {
for (const key of Object.keys(deserialized)) {
const deserializedProperty = (deserialized as any)[key];
if (deserializedProperty === undefined) {
continue;
}
const realProperty = (concrete as any)[key];
if (realProperty === undefined) {
throw new Error(`Expected ${key} to be defined in ${concrete.constructor.name}`);
}
// we only care about checking prototypes of objects
if (typeof realProperty === "object" && realProperty !== null) {
const realProto = Object.getPrototypeOf(realProperty);
expect(deserializedProperty).toBeInstanceOf(realProto.constructor);
verifyPropertyPrototypes(deserializedProperty, realProperty);
}
}
}

View File

@@ -0,0 +1,80 @@
import { AuthenticationType } from "@bitwarden/common/auth/enums/authentication-type";
import { KeyDefinition, LOGIN_STRATEGY_MEMORY } from "@bitwarden/common/platform/state";
import { AuthRequestLoginStrategyData } from "../../login-strategies/auth-request-login.strategy";
import { PasswordLoginStrategyData } from "../../login-strategies/password-login.strategy";
import { SsoLoginStrategyData } from "../../login-strategies/sso-login.strategy";
import { UserApiLoginStrategyData } from "../../login-strategies/user-api-login.strategy";
import { WebAuthnLoginStrategyData } from "../../login-strategies/webauthn-login.strategy";
/**
* The current login strategy in use.
*/
export const CURRENT_LOGIN_STRATEGY_KEY = new KeyDefinition<AuthenticationType | null>(
LOGIN_STRATEGY_MEMORY,
"currentLoginStrategy",
{
deserializer: (data) => data,
},
);
/**
* The expiration date for the login strategy cache.
* Used as a backup to the timer set on the service.
*/
export const CACHE_EXPIRATION_KEY = new KeyDefinition<Date | null>(
LOGIN_STRATEGY_MEMORY,
"loginStrategyCacheExpiration",
{
deserializer: (data) => (data ? null : new Date(data)),
},
);
/**
* Auth Request notification for all instances of the login strategy service.
* Note: this isn't an ideal approach, but allows both a background and
* foreground instance to send out the notification.
* TODO: Move to Auth Request service.
*/
export const AUTH_REQUEST_PUSH_NOTIFICATION_KEY = new KeyDefinition<string>(
LOGIN_STRATEGY_MEMORY,
"authRequestPushNotification",
{
deserializer: (data) => data,
},
);
export type CacheData = {
password?: PasswordLoginStrategyData;
sso?: SsoLoginStrategyData;
userApiKey?: UserApiLoginStrategyData;
authRequest?: AuthRequestLoginStrategyData;
webAuthn?: WebAuthnLoginStrategyData;
};
/**
* A cache for login strategies to use for data persistence through
* the login process.
*/
export const CACHE_KEY = new KeyDefinition<CacheData | null>(
LOGIN_STRATEGY_MEMORY,
"loginStrategyCache",
{
deserializer: (data) => {
if (data == null) {
return null;
}
return {
password: data.password ? PasswordLoginStrategyData.fromJSON(data.password) : undefined,
sso: data.sso ? SsoLoginStrategyData.fromJSON(data.sso) : undefined,
userApiKey: data.userApiKey
? UserApiLoginStrategyData.fromJSON(data.userApiKey)
: undefined,
authRequest: data.authRequest
? AuthRequestLoginStrategyData.fromJSON(data.authRequest)
: undefined,
webAuthn: data.webAuthn ? WebAuthnLoginStrategyData.fromJSON(data.webAuthn) : undefined,
};
},
},
);