1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-15 07:43:35 +00:00

[PM-7564] Move 2fa and login strategy service to popup and add state providers to 2fa service (#8820)

* remove 2fa from main.background

* remove login strategy service from main.background

* move 2fa and login strategy service to popup, init in browser

* add state providers to 2fa service
- add deserializer helpers

* use key definitions for global state

* fix calls to 2fa service

* remove extra await

* add delay to wait for active account emission in popup

* add and fix tests

* fix cli

* really fix cli

* remove timeout and wait for active account

* verify expected user is active account

* fix tests

* address feedback
This commit is contained in:
Jake Fink
2024-04-25 16:45:23 -04:00
committed by GitHub
parent cbf7c292f3
commit 8afe915be1
27 changed files with 217 additions and 152 deletions

View File

@@ -12,12 +12,12 @@ export interface TwoFactorProviderDetails {
export abstract class TwoFactorService {
init: () => void;
getSupportedProviders: (win: Window) => TwoFactorProviderDetails[];
getDefaultProvider: (webAuthnSupported: boolean) => TwoFactorProviderType;
setSelectedProvider: (type: TwoFactorProviderType) => void;
clearSelectedProvider: () => void;
getSupportedProviders: (win: Window) => Promise<TwoFactorProviderDetails[]>;
getDefaultProvider: (webAuthnSupported: boolean) => Promise<TwoFactorProviderType>;
setSelectedProvider: (type: TwoFactorProviderType) => Promise<void>;
clearSelectedProvider: () => Promise<void>;
setProviders: (response: IdentityTwoFactorResponse) => void;
clearProviders: () => void;
getProviders: () => Map<TwoFactorProviderType, { [key: string]: string }>;
setProviders: (response: IdentityTwoFactorResponse) => Promise<void>;
clearProviders: () => Promise<void>;
getProviders: () => Promise<Map<TwoFactorProviderType, { [key: string]: string }>>;
}

View File

@@ -14,7 +14,7 @@ export class AuthResult {
resetMasterPassword = false;
forcePasswordReset: ForceSetPasswordReason = ForceSetPasswordReason.None;
twoFactorProviders: Map<TwoFactorProviderType, { [key: string]: string }> = null;
twoFactorProviders: Partial<Record<TwoFactorProviderType, Record<string, string>>> = null;
ssoEmail2FaSessionToken?: string;
email: string;
requiresEncryptionKeyMigration: boolean;

View File

@@ -4,8 +4,10 @@ import { TwoFactorProviderType } from "../../enums/two-factor-provider-type";
import { MasterPasswordPolicyResponse } from "./master-password-policy.response";
export class IdentityTwoFactorResponse extends BaseResponse {
// contains available two-factor providers
twoFactorProviders: TwoFactorProviderType[];
twoFactorProviders2 = new Map<TwoFactorProviderType, { [key: string]: string }>();
// a map of two-factor providers to necessary data for completion
twoFactorProviders2: Record<TwoFactorProviderType, Record<string, string>>;
captchaToken: string;
ssoEmail2faSessionToken: string;
email?: string;
@@ -15,15 +17,7 @@ export class IdentityTwoFactorResponse extends BaseResponse {
super(response);
this.captchaToken = this.getResponseProperty("CaptchaBypassToken");
this.twoFactorProviders = this.getResponseProperty("TwoFactorProviders");
const twoFactorProviders2 = this.getResponseProperty("TwoFactorProviders2");
if (twoFactorProviders2 != null) {
for (const prop in twoFactorProviders2) {
// eslint-disable-next-line
if (twoFactorProviders2.hasOwnProperty(prop)) {
this.twoFactorProviders2.set(parseInt(prop, null), twoFactorProviders2[prop]);
}
}
}
this.twoFactorProviders2 = this.getResponseProperty("TwoFactorProviders2");
this.masterPasswordPolicy = new MasterPasswordPolicyResponse(
this.getResponseProperty("MasterPasswordPolicy"),
);

View File

@@ -1,5 +1,9 @@
import { firstValueFrom, map } from "rxjs";
import { I18nService } from "../../platform/abstractions/i18n.service";
import { PlatformUtilsService } from "../../platform/abstractions/platform-utils.service";
import { Utils } from "../../platform/misc/utils";
import { GlobalStateProvider, KeyDefinition, TWO_FACTOR_MEMORY } from "../../platform/state";
import {
TwoFactorProviderDetails,
TwoFactorService as TwoFactorServiceAbstraction,
@@ -59,13 +63,36 @@ export const TwoFactorProviders: Partial<Record<TwoFactorProviderType, TwoFactor
},
};
// Memory storage as only required during authentication process
export const PROVIDERS = KeyDefinition.record<Record<string, string>, TwoFactorProviderType>(
TWO_FACTOR_MEMORY,
"providers",
{
deserializer: (obj) => obj,
},
);
// Memory storage as only required during authentication process
export const SELECTED_PROVIDER = new KeyDefinition<TwoFactorProviderType>(
TWO_FACTOR_MEMORY,
"selected",
{
deserializer: (obj) => obj,
},
);
export class TwoFactorService implements TwoFactorServiceAbstraction {
private twoFactorProvidersData: Map<TwoFactorProviderType, { [key: string]: string }>;
private selectedTwoFactorProviderType: TwoFactorProviderType = null;
private providersState = this.globalStateProvider.get(PROVIDERS);
private selectedState = this.globalStateProvider.get(SELECTED_PROVIDER);
readonly providers$ = this.providersState.state$.pipe(
map((providers) => Utils.recordToMap(providers)),
);
readonly selected$ = this.selectedState.state$;
constructor(
private i18nService: I18nService,
private platformUtilsService: PlatformUtilsService,
private globalStateProvider: GlobalStateProvider,
) {}
init() {
@@ -93,63 +120,60 @@ export class TwoFactorService implements TwoFactorServiceAbstraction {
this.i18nService.t("yubiKeyDesc");
}
getSupportedProviders(win: Window): TwoFactorProviderDetails[] {
async getSupportedProviders(win: Window): Promise<TwoFactorProviderDetails[]> {
const data = await firstValueFrom(this.providers$);
const providers: any[] = [];
if (this.twoFactorProvidersData == null) {
if (data == null) {
return providers;
}
if (
this.twoFactorProvidersData.has(TwoFactorProviderType.OrganizationDuo) &&
data.has(TwoFactorProviderType.OrganizationDuo) &&
this.platformUtilsService.supportsDuo()
) {
providers.push(TwoFactorProviders[TwoFactorProviderType.OrganizationDuo]);
}
if (this.twoFactorProvidersData.has(TwoFactorProviderType.Authenticator)) {
if (data.has(TwoFactorProviderType.Authenticator)) {
providers.push(TwoFactorProviders[TwoFactorProviderType.Authenticator]);
}
if (this.twoFactorProvidersData.has(TwoFactorProviderType.Yubikey)) {
if (data.has(TwoFactorProviderType.Yubikey)) {
providers.push(TwoFactorProviders[TwoFactorProviderType.Yubikey]);
}
if (
this.twoFactorProvidersData.has(TwoFactorProviderType.Duo) &&
this.platformUtilsService.supportsDuo()
) {
if (data.has(TwoFactorProviderType.Duo) && this.platformUtilsService.supportsDuo()) {
providers.push(TwoFactorProviders[TwoFactorProviderType.Duo]);
}
if (
this.twoFactorProvidersData.has(TwoFactorProviderType.WebAuthn) &&
data.has(TwoFactorProviderType.WebAuthn) &&
this.platformUtilsService.supportsWebAuthn(win)
) {
providers.push(TwoFactorProviders[TwoFactorProviderType.WebAuthn]);
}
if (this.twoFactorProvidersData.has(TwoFactorProviderType.Email)) {
if (data.has(TwoFactorProviderType.Email)) {
providers.push(TwoFactorProviders[TwoFactorProviderType.Email]);
}
return providers;
}
getDefaultProvider(webAuthnSupported: boolean): TwoFactorProviderType {
if (this.twoFactorProvidersData == null) {
async getDefaultProvider(webAuthnSupported: boolean): Promise<TwoFactorProviderType> {
const data = await firstValueFrom(this.providers$);
const selected = await firstValueFrom(this.selected$);
if (data == null) {
return null;
}
if (
this.selectedTwoFactorProviderType != null &&
this.twoFactorProvidersData.has(this.selectedTwoFactorProviderType)
) {
return this.selectedTwoFactorProviderType;
if (selected != null && data.has(selected)) {
return selected;
}
let providerType: TwoFactorProviderType = null;
let providerPriority = -1;
this.twoFactorProvidersData.forEach((_value, type) => {
data.forEach((_value, type) => {
const provider = (TwoFactorProviders as any)[type];
if (provider != null && provider.priority > providerPriority) {
if (type === TwoFactorProviderType.WebAuthn && !webAuthnSupported) {
@@ -164,23 +188,23 @@ export class TwoFactorService implements TwoFactorServiceAbstraction {
return providerType;
}
setSelectedProvider(type: TwoFactorProviderType) {
this.selectedTwoFactorProviderType = type;
async setSelectedProvider(type: TwoFactorProviderType): Promise<void> {
await this.selectedState.update(() => type);
}
clearSelectedProvider() {
this.selectedTwoFactorProviderType = null;
async clearSelectedProvider(): Promise<void> {
await this.selectedState.update(() => null);
}
setProviders(response: IdentityTwoFactorResponse) {
this.twoFactorProvidersData = response.twoFactorProviders2;
async setProviders(response: IdentityTwoFactorResponse): Promise<void> {
await this.providersState.update(() => response.twoFactorProviders2);
}
clearProviders() {
this.twoFactorProvidersData = null;
async clearProviders(): Promise<void> {
await this.providersState.update(() => null);
}
getProviders() {
return this.twoFactorProvidersData;
getProviders(): Promise<Map<TwoFactorProviderType, { [key: string]: string }>> {
return firstValueFrom(this.providers$);
}
}

View File

@@ -0,0 +1,25 @@
import { record } from "./deserialization-helpers";
describe("deserialization helpers", () => {
describe("record", () => {
it("deserializes a record when keys are strings", () => {
const deserializer = record((value: number) => value);
const input = {
a: 1,
b: 2,
};
const output = deserializer(input);
expect(output).toEqual(input);
});
it("deserializes a record when keys are numbers", () => {
const deserializer = record((value: number) => value);
const input = {
1: 1,
2: 2,
};
const output = deserializer(input);
expect(output).toEqual(input);
});
});
});

View File

@@ -21,7 +21,7 @@ export function array<T>(
*
* @param valueDeserializer
*/
export function record<T, TKey extends string = string>(
export function record<T, TKey extends string | number = string>(
valueDeserializer: (value: Jsonify<T>) => T,
): (record: Jsonify<Record<TKey, T>>) => Record<TKey, T> {
return (jsonValue: Jsonify<Record<TKey, T> | null>) => {
@@ -29,10 +29,10 @@ export function record<T, TKey extends string = string>(
return null;
}
const output: Record<string, T> = {};
for (const key in jsonValue) {
output[key] = valueDeserializer((jsonValue as Record<string, Jsonify<T>>)[key]);
}
const output: Record<TKey, T> = {} as any;
Object.entries(jsonValue).forEach(([key, value]) => {
output[key as TKey] = valueDeserializer(value);
});
return output;
};
}

View File

@@ -113,7 +113,7 @@ export class KeyDefinition<T> {
* });
* ```
*/
static record<T, TKey extends string = string>(
static record<T, TKey extends string | number = string>(
stateDefinition: StateDefinition,
key: string,
// We have them provide options for the value of the record, depending on future options we add, this could get a little weird.

View File

@@ -40,6 +40,7 @@ export const KEY_CONNECTOR_DISK = new StateDefinition("keyConnector", "disk");
export const ACCOUNT_MEMORY = new StateDefinition("account", "memory");
export const MASTER_PASSWORD_MEMORY = new StateDefinition("masterPassword", "memory");
export const MASTER_PASSWORD_DISK = new StateDefinition("masterPassword", "disk");
export const TWO_FACTOR_MEMORY = new StateDefinition("twoFactor", "memory");
export const AVATAR_DISK = new StateDefinition("avatar", "disk", { web: "disk-local" });
export const ROUTER_DISK = new StateDefinition("router", "disk");
export const LOGIN_EMAIL_DISK = new StateDefinition("loginEmail", "disk", {

View File

@@ -120,7 +120,7 @@ export class UserKeyDefinition<T> {
* });
* ```
*/
static record<T, TKey extends string = string>(
static record<T, TKey extends string | number = string>(
stateDefinition: StateDefinition,
key: string,
// We have them provide options for the value of the record, depending on future options we add, this could get a little weird.