mirror of
https://github.com/bitwarden/browser
synced 2025-12-20 18:23:31 +00:00
Merge branch 'main' into vault/pm-5273
# Conflicts: # apps/browser/src/popup/app.component.ts # libs/common/src/state-migrations/migrate.ts
This commit is contained in:
@@ -99,8 +99,7 @@ export class LoginViaAuthRequestComponent
|
||||
}
|
||||
|
||||
//gets signalR push notification
|
||||
this.loginStrategyService
|
||||
.getPushNotificationObs$()
|
||||
this.loginStrategyService.authRequestPushNotification$
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe((id) => {
|
||||
// Only fires on approval currently
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Directive, Inject, OnDestroy, OnInit } from "@angular/core";
|
||||
import { ActivatedRoute, NavigationExtras, Router } from "@angular/router";
|
||||
import * as DuoWebSDK from "duo_web_sdk";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
import { first } from "rxjs/operators";
|
||||
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
@@ -10,6 +11,7 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { LoginService } from "@bitwarden/common/auth/abstractions/login.service";
|
||||
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
|
||||
import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service";
|
||||
import { AuthenticationType } from "@bitwarden/common/auth/enums/authentication-type";
|
||||
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
|
||||
import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
|
||||
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
|
||||
@@ -92,7 +94,7 @@ export class TwoFactorComponent extends CaptchaProtectedComponent implements OnI
|
||||
}
|
||||
|
||||
async ngOnInit() {
|
||||
if (!this.authing || this.twoFactorService.getProviders() == null) {
|
||||
if (!(await this.authing()) || this.twoFactorService.getProviders() == null) {
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.router.navigate([this.loginRoute]);
|
||||
@@ -105,7 +107,7 @@ export class TwoFactorComponent extends CaptchaProtectedComponent implements OnI
|
||||
}
|
||||
});
|
||||
|
||||
if (this.needsLock) {
|
||||
if (await this.needsLock()) {
|
||||
this.successRoute = "lock";
|
||||
}
|
||||
|
||||
@@ -426,7 +428,7 @@ export class TwoFactorComponent extends CaptchaProtectedComponent implements OnI
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.loginStrategyService.email == null) {
|
||||
if ((await this.loginStrategyService.getEmail()) == null) {
|
||||
this.platformUtilsService.showToast(
|
||||
"error",
|
||||
this.i18nService.t("errorOccurred"),
|
||||
@@ -437,12 +439,13 @@ export class TwoFactorComponent extends CaptchaProtectedComponent implements OnI
|
||||
|
||||
try {
|
||||
const request = new TwoFactorEmailRequest();
|
||||
request.email = this.loginStrategyService.email;
|
||||
request.masterPasswordHash = this.loginStrategyService.masterPasswordHash;
|
||||
request.ssoEmail2FaSessionToken = this.loginStrategyService.ssoEmail2FaSessionToken;
|
||||
request.email = await this.loginStrategyService.getEmail();
|
||||
request.masterPasswordHash = await this.loginStrategyService.getMasterPasswordHash();
|
||||
request.ssoEmail2FaSessionToken =
|
||||
await this.loginStrategyService.getSsoEmail2FaSessionToken();
|
||||
request.deviceIdentifier = await this.appIdService.getAppId();
|
||||
request.authRequestAccessCode = this.loginStrategyService.accessCode;
|
||||
request.authRequestId = this.loginStrategyService.authRequestId;
|
||||
request.authRequestAccessCode = await this.loginStrategyService.getAccessCode();
|
||||
request.authRequestId = await this.loginStrategyService.getAuthRequestId();
|
||||
this.emailPromise = this.apiService.postTwoFactorEmail(request);
|
||||
await this.emailPromise;
|
||||
if (doToast) {
|
||||
@@ -476,20 +479,13 @@ export class TwoFactorComponent extends CaptchaProtectedComponent implements OnI
|
||||
}
|
||||
}
|
||||
|
||||
get authing(): boolean {
|
||||
return (
|
||||
this.loginStrategyService.authingWithPassword() ||
|
||||
this.loginStrategyService.authingWithSso() ||
|
||||
this.loginStrategyService.authingWithUserApiKey() ||
|
||||
this.loginStrategyService.authingWithPasswordless()
|
||||
);
|
||||
private async authing(): Promise<boolean> {
|
||||
return (await firstValueFrom(this.loginStrategyService.currentAuthType$)) !== null;
|
||||
}
|
||||
|
||||
get needsLock(): boolean {
|
||||
return (
|
||||
this.loginStrategyService.authingWithSso() ||
|
||||
this.loginStrategyService.authingWithUserApiKey()
|
||||
);
|
||||
private async needsLock(): Promise<boolean> {
|
||||
const authType = await firstValueFrom(this.loginStrategyService.currentAuthType$);
|
||||
return authType == AuthenticationType.Sso || authType == AuthenticationType.UserApiKey;
|
||||
}
|
||||
|
||||
// implemented in clients
|
||||
|
||||
@@ -90,6 +90,10 @@ import {
|
||||
BadgeSettingsServiceAbstraction,
|
||||
BadgeSettingsService,
|
||||
} from "@bitwarden/common/autofill/services/badge-settings.service";
|
||||
import {
|
||||
DomainSettingsService,
|
||||
DefaultDomainSettingsService,
|
||||
} from "@bitwarden/common/autofill/services/domain-settings.service";
|
||||
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billilng-api.service.abstraction";
|
||||
import { OrganizationBillingServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-billing.service";
|
||||
import { PaymentMethodWarningsServiceAbstraction } from "@bitwarden/common/billing/abstractions/payment-method-warnings-service.abstraction";
|
||||
@@ -290,7 +294,7 @@ import { ModalService } from "./modal.service";
|
||||
{
|
||||
provide: AppIdServiceAbstraction,
|
||||
useClass: AppIdService,
|
||||
deps: [AbstractStorageService],
|
||||
deps: [GlobalStateProvider],
|
||||
},
|
||||
{
|
||||
provide: AuditServiceAbstraction,
|
||||
@@ -328,6 +332,7 @@ import { ModalService } from "./modal.service";
|
||||
PolicyServiceAbstraction,
|
||||
DeviceTrustCryptoServiceAbstraction,
|
||||
AuthRequestServiceAbstraction,
|
||||
GlobalStateProvider,
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -350,6 +355,7 @@ import { ModalService } from "./modal.service";
|
||||
searchService: SearchServiceAbstraction,
|
||||
stateService: StateServiceAbstraction,
|
||||
autofillSettingsService: AutofillSettingsServiceAbstraction,
|
||||
domainSettingsService: DomainSettingsService,
|
||||
encryptService: EncryptService,
|
||||
fileUploadService: CipherFileUploadServiceAbstraction,
|
||||
configService: ConfigServiceAbstraction,
|
||||
@@ -357,7 +363,7 @@ import { ModalService } from "./modal.service";
|
||||
) =>
|
||||
new CipherService(
|
||||
cryptoService,
|
||||
settingsService,
|
||||
domainSettingsService,
|
||||
apiService,
|
||||
i18nService,
|
||||
searchService,
|
||||
@@ -739,7 +745,7 @@ import { ModalService } from "./modal.service";
|
||||
useClass: PasswordResetEnrollmentServiceImplementation,
|
||||
deps: [
|
||||
OrganizationApiServiceAbstraction,
|
||||
StateServiceAbstraction,
|
||||
AccountServiceAbstraction,
|
||||
CryptoServiceAbstraction,
|
||||
OrganizationUserService,
|
||||
I18nServiceAbstraction,
|
||||
@@ -964,6 +970,11 @@ import { ModalService } from "./modal.service";
|
||||
useClass: BadgeSettingsService,
|
||||
deps: [StateProvider],
|
||||
},
|
||||
{
|
||||
provide: DomainSettingsService,
|
||||
useClass: DefaultDomainSettingsService,
|
||||
deps: [StateProvider],
|
||||
},
|
||||
{
|
||||
provide: BiometricStateService,
|
||||
useClass: DefaultBiometricStateService,
|
||||
|
||||
@@ -13,6 +13,7 @@ import { OrganizationUserStatusType, PolicyType } from "@bitwarden/common/admin-
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { EventType } from "@bitwarden/common/enums";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { UriMatchStrategy } from "@bitwarden/common/models/domain/domain-service";
|
||||
import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
@@ -24,7 +25,7 @@ import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.s
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service";
|
||||
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
|
||||
import { CipherType, SecureNoteType, UriMatchType } from "@bitwarden/common/vault/enums";
|
||||
import { CipherType, SecureNoteType } from "@bitwarden/common/vault/enums";
|
||||
import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type";
|
||||
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
|
||||
import { CardView } from "@bitwarden/common/vault/models/view/card.view";
|
||||
@@ -164,12 +165,12 @@ export class AddEditComponent implements OnInit, OnDestroy {
|
||||
];
|
||||
this.uriMatchOptions = [
|
||||
{ name: i18nService.t("defaultMatchDetection"), value: null },
|
||||
{ name: i18nService.t("baseDomain"), value: UriMatchType.Domain },
|
||||
{ name: i18nService.t("host"), value: UriMatchType.Host },
|
||||
{ name: i18nService.t("startsWith"), value: UriMatchType.StartsWith },
|
||||
{ name: i18nService.t("regEx"), value: UriMatchType.RegularExpression },
|
||||
{ name: i18nService.t("exact"), value: UriMatchType.Exact },
|
||||
{ name: i18nService.t("never"), value: UriMatchType.Never },
|
||||
{ name: i18nService.t("baseDomain"), value: UriMatchStrategy.Domain },
|
||||
{ name: i18nService.t("host"), value: UriMatchStrategy.Host },
|
||||
{ name: i18nService.t("startsWith"), value: UriMatchStrategy.StartsWith },
|
||||
{ name: i18nService.t("regEx"), value: UriMatchStrategy.RegularExpression },
|
||||
{ name: i18nService.t("exact"), value: UriMatchStrategy.Exact },
|
||||
{ name: i18nService.t("never"), value: UriMatchStrategy.Never },
|
||||
];
|
||||
this.autofillOnPageLoadOptions = [
|
||||
{ name: i18nService.t("autoFillOnPageLoadUseDefault"), value: null },
|
||||
|
||||
@@ -10,7 +10,11 @@ module.exports = {
|
||||
displayName: "libs/auth tests",
|
||||
preset: "jest-preset-angular",
|
||||
setupFilesAfterEnv: ["<rootDir>/test.setup.ts"],
|
||||
moduleNameMapper: pathsToModuleNameMapper(compilerOptions?.paths || {}, {
|
||||
prefix: "<rootDir>/",
|
||||
}),
|
||||
moduleNameMapper: pathsToModuleNameMapper(
|
||||
// lets us use @bitwarden/common/spec in tests
|
||||
{ "@bitwarden/common/spec": ["../common/spec"], ...(compilerOptions?.paths ?? {}) },
|
||||
{
|
||||
prefix: "<rootDir>/",
|
||||
},
|
||||
),
|
||||
};
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { AuthenticationType } from "@bitwarden/common/auth/enums/authentication-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 { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response";
|
||||
import { AuthRequestPushNotification } from "@bitwarden/common/models/response/notification.response";
|
||||
import { MasterKey } from "@bitwarden/common/types/key";
|
||||
|
||||
@@ -14,12 +16,45 @@ import {
|
||||
} from "../models/domain/login-credentials";
|
||||
|
||||
export abstract class LoginStrategyServiceAbstraction {
|
||||
masterPasswordHash: string;
|
||||
email: string;
|
||||
accessCode: string;
|
||||
authRequestId: string;
|
||||
ssoEmail2FaSessionToken: string;
|
||||
/**
|
||||
* The current strategy being used to authenticate.
|
||||
* Emits null if the session has timed out.
|
||||
*/
|
||||
currentAuthType$: Observable<AuthenticationType | null>;
|
||||
/**
|
||||
* Emits when an auth request has been approved.
|
||||
*/
|
||||
authRequestPushNotification$: Observable<string>;
|
||||
/**
|
||||
* If the login strategy uses the email address of the user, this
|
||||
* will return it. Otherwise, it will return null.
|
||||
*/
|
||||
getEmail: () => Promise<string | null>;
|
||||
/**
|
||||
* If the user is logging in with a master password, this will return
|
||||
* the master password hash. Otherwise, it will return null.
|
||||
*/
|
||||
getMasterPasswordHash: () => Promise<string | null>;
|
||||
/**
|
||||
* If the user is logging in with SSO, this will return
|
||||
* the email auth token. Otherwise, it will return null.
|
||||
* @see {@link SsoLoginStrategyData.ssoEmail2FaSessionToken}
|
||||
*/
|
||||
getSsoEmail2FaSessionToken: () => Promise<string | null>;
|
||||
/**
|
||||
* Returns the access code if the user is logging in with an
|
||||
* Auth Request. Otherwise, it will return null.
|
||||
*/
|
||||
getAccessCode: () => Promise<string | null>;
|
||||
/**
|
||||
* Returns the auth request ID if the user is logging in with an
|
||||
* Auth Request. Otherwise, it will return null.
|
||||
*/
|
||||
getAuthRequestId: () => Promise<string | null>;
|
||||
|
||||
/**
|
||||
* Sends a token request to the server using the provided credentials.
|
||||
*/
|
||||
logIn: (
|
||||
credentials:
|
||||
| UserApiLoginCredentials
|
||||
@@ -28,15 +63,30 @@ export abstract class LoginStrategyServiceAbstraction {
|
||||
| AuthRequestLoginCredentials
|
||||
| WebAuthnLoginCredentials,
|
||||
) => Promise<AuthResult>;
|
||||
/**
|
||||
* Sends a token request to the server with the provided two factor token
|
||||
* and captcha response. This uses data stored from {@link LoginStrategyServiceAbstraction.logIn},
|
||||
* so that must be called first.
|
||||
* Returns an error if no session data is found.
|
||||
*/
|
||||
logInTwoFactor: (
|
||||
twoFactor: TokenTwoFactorRequest,
|
||||
captchaResponse: string,
|
||||
) => Promise<AuthResult>;
|
||||
/**
|
||||
* Creates a master key from the provided master password and email.
|
||||
*/
|
||||
makePreloginKey: (masterPassword: string, email: string) => Promise<MasterKey>;
|
||||
authingWithUserApiKey: () => boolean;
|
||||
authingWithSso: () => boolean;
|
||||
authingWithPassword: () => boolean;
|
||||
authingWithPasswordless: () => boolean;
|
||||
authResponsePushNotification: (notification: AuthRequestPushNotification) => Promise<any>;
|
||||
getPushNotificationObs$: () => Observable<any>;
|
||||
/**
|
||||
* Sends a notification to {@link LoginStrategyServiceAbstraction.authRequestPushNotification}
|
||||
*/
|
||||
sendAuthRequestPushNotification: (notification: AuthRequestPushNotification) => Promise<void>;
|
||||
/**
|
||||
* Sends a response to an auth request.
|
||||
*/
|
||||
passwordlessLogin: (
|
||||
id: string,
|
||||
key: string,
|
||||
requestApproved: boolean,
|
||||
) => Promise<AuthRequestResponse>;
|
||||
}
|
||||
|
||||
@@ -18,10 +18,15 @@ import { MasterKey, UserKey } from "@bitwarden/common/types/key";
|
||||
|
||||
import { AuthRequestLoginCredentials } from "../models/domain/login-credentials";
|
||||
|
||||
import { AuthRequestLoginStrategy } from "./auth-request-login.strategy";
|
||||
import {
|
||||
AuthRequestLoginStrategy,
|
||||
AuthRequestLoginStrategyData,
|
||||
} from "./auth-request-login.strategy";
|
||||
import { identityTokenResponseFactory } from "./login.strategy.spec";
|
||||
|
||||
describe("AuthRequestLoginStrategy", () => {
|
||||
let cache: AuthRequestLoginStrategyData;
|
||||
|
||||
let cryptoService: MockProxy<CryptoService>;
|
||||
let apiService: MockProxy<ApiService>;
|
||||
let tokenService: MockProxy<TokenService>;
|
||||
@@ -65,6 +70,7 @@ describe("AuthRequestLoginStrategy", () => {
|
||||
tokenService.decodeToken.mockResolvedValue({});
|
||||
|
||||
authRequestLoginStrategy = new AuthRequestLoginStrategy(
|
||||
cache,
|
||||
cryptoService,
|
||||
apiService,
|
||||
tokenService,
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { Observable, map, BehaviorSubject } from "rxjs";
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction";
|
||||
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
|
||||
@@ -14,26 +17,33 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
|
||||
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
|
||||
|
||||
import { AuthRequestLoginCredentials } from "../models/domain/login-credentials";
|
||||
import { CacheData } from "../services/login-strategies/login-strategy.state";
|
||||
|
||||
import { LoginStrategy } from "./login.strategy";
|
||||
import { LoginStrategy, LoginStrategyData } from "./login.strategy";
|
||||
|
||||
export class AuthRequestLoginStrategyData implements LoginStrategyData {
|
||||
tokenRequest: PasswordTokenRequest;
|
||||
captchaBypassToken: string;
|
||||
authRequestCredentials: AuthRequestLoginCredentials;
|
||||
|
||||
static fromJSON(obj: Jsonify<AuthRequestLoginStrategyData>): AuthRequestLoginStrategyData {
|
||||
const data = Object.assign(new AuthRequestLoginStrategyData(), obj, {
|
||||
tokenRequest: PasswordTokenRequest.fromJSON(obj.tokenRequest),
|
||||
authRequestCredentials: AuthRequestLoginCredentials.fromJSON(obj.authRequestCredentials),
|
||||
});
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
export class AuthRequestLoginStrategy extends LoginStrategy {
|
||||
get email() {
|
||||
return this.tokenRequest.email;
|
||||
}
|
||||
email$: Observable<string>;
|
||||
accessCode$: Observable<string>;
|
||||
authRequestId$: Observable<string>;
|
||||
|
||||
get accessCode() {
|
||||
return this.authRequestCredentials.accessCode;
|
||||
}
|
||||
|
||||
get authRequestId() {
|
||||
return this.authRequestCredentials.authRequestId;
|
||||
}
|
||||
|
||||
tokenRequest: PasswordTokenRequest;
|
||||
private authRequestCredentials: AuthRequestLoginCredentials;
|
||||
protected cache: BehaviorSubject<AuthRequestLoginStrategyData>;
|
||||
|
||||
constructor(
|
||||
data: AuthRequestLoginStrategyData,
|
||||
cryptoService: CryptoService,
|
||||
apiService: ApiService,
|
||||
tokenService: TokenService,
|
||||
@@ -56,22 +66,26 @@ export class AuthRequestLoginStrategy extends LoginStrategy {
|
||||
stateService,
|
||||
twoFactorService,
|
||||
);
|
||||
|
||||
this.cache = new BehaviorSubject(data);
|
||||
this.email$ = this.cache.pipe(map((data) => data.tokenRequest.email));
|
||||
this.accessCode$ = this.cache.pipe(map((data) => data.authRequestCredentials.accessCode));
|
||||
this.authRequestId$ = this.cache.pipe(map((data) => data.authRequestCredentials.authRequestId));
|
||||
}
|
||||
|
||||
override async logIn(credentials: AuthRequestLoginCredentials) {
|
||||
// NOTE: To avoid DeadObject references on Firefox, do not set the credentials object directly
|
||||
// Use deep copy in future if objects are added that were created in popup
|
||||
this.authRequestCredentials = { ...credentials };
|
||||
|
||||
this.tokenRequest = new PasswordTokenRequest(
|
||||
const data = new AuthRequestLoginStrategyData();
|
||||
data.tokenRequest = new PasswordTokenRequest(
|
||||
credentials.email,
|
||||
credentials.accessCode,
|
||||
null,
|
||||
await this.buildTwoFactor(credentials.twoFactor),
|
||||
await this.buildDeviceRequest(),
|
||||
);
|
||||
data.tokenRequest.setAuthRequestAccessCode(credentials.authRequestId);
|
||||
data.authRequestCredentials = credentials;
|
||||
this.cache.next(data);
|
||||
|
||||
this.tokenRequest.setAuthRequestAccessCode(credentials.authRequestId);
|
||||
const [authResult] = await this.startLogIn();
|
||||
return authResult;
|
||||
}
|
||||
@@ -80,27 +94,32 @@ export class AuthRequestLoginStrategy extends LoginStrategy {
|
||||
twoFactor: TokenTwoFactorRequest,
|
||||
captchaResponse: string,
|
||||
): Promise<AuthResult> {
|
||||
this.tokenRequest.captchaResponse = captchaResponse ?? this.captchaBypassToken;
|
||||
const data = this.cache.value;
|
||||
data.tokenRequest.captchaResponse = captchaResponse ?? data.captchaBypassToken;
|
||||
this.cache.next(data);
|
||||
|
||||
return super.logInTwoFactor(twoFactor);
|
||||
}
|
||||
|
||||
protected override async setMasterKey(response: IdentityTokenResponse) {
|
||||
const authRequestCredentials = this.cache.value.authRequestCredentials;
|
||||
if (
|
||||
this.authRequestCredentials.decryptedMasterKey &&
|
||||
this.authRequestCredentials.decryptedMasterKeyHash
|
||||
authRequestCredentials.decryptedMasterKey &&
|
||||
authRequestCredentials.decryptedMasterKeyHash
|
||||
) {
|
||||
await this.cryptoService.setMasterKey(this.authRequestCredentials.decryptedMasterKey);
|
||||
await this.cryptoService.setMasterKeyHash(this.authRequestCredentials.decryptedMasterKeyHash);
|
||||
await this.cryptoService.setMasterKey(authRequestCredentials.decryptedMasterKey);
|
||||
await this.cryptoService.setMasterKeyHash(authRequestCredentials.decryptedMasterKeyHash);
|
||||
}
|
||||
}
|
||||
|
||||
protected override async setUserKey(response: IdentityTokenResponse): Promise<void> {
|
||||
const authRequestCredentials = this.cache.value.authRequestCredentials;
|
||||
// User now may or may not have a master password
|
||||
// but set the master key encrypted user key if it exists regardless
|
||||
await this.cryptoService.setMasterKeyEncryptedUserKey(response.key);
|
||||
|
||||
if (this.authRequestCredentials.decryptedUserKey) {
|
||||
await this.cryptoService.setUserKey(this.authRequestCredentials.decryptedUserKey);
|
||||
if (authRequestCredentials.decryptedUserKey) {
|
||||
await this.cryptoService.setUserKey(authRequestCredentials.decryptedUserKey);
|
||||
} else {
|
||||
await this.trySetUserKeyWithMasterKey();
|
||||
// Establish trust if required after setting user key
|
||||
@@ -121,4 +140,10 @@ export class AuthRequestLoginStrategy extends LoginStrategy {
|
||||
response.privateKey ?? (await this.createKeyPairForOldAccount()),
|
||||
);
|
||||
}
|
||||
|
||||
exportCache(): CacheData {
|
||||
return {
|
||||
authRequest: this.cache.value,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ import { UserKey, MasterKey, DeviceKey } from "@bitwarden/common/types/key";
|
||||
import { LoginStrategyServiceAbstraction } from "../abstractions/login-strategy.service";
|
||||
import { PasswordLoginCredentials } from "../models/domain/login-credentials";
|
||||
|
||||
import { PasswordLoginStrategy } from "./password-login.strategy";
|
||||
import { PasswordLoginStrategy, PasswordLoginStrategyData } from "./password-login.strategy";
|
||||
|
||||
const email = "hello@world.com";
|
||||
const masterPassword = "password";
|
||||
@@ -94,6 +94,8 @@ export function identityTokenResponseFactory(
|
||||
|
||||
// TODO: add tests for latest changes to base class for TDE
|
||||
describe("LoginStrategy", () => {
|
||||
let cache: PasswordLoginStrategyData;
|
||||
|
||||
let loginStrategyService: MockProxy<LoginStrategyServiceAbstraction>;
|
||||
let cryptoService: MockProxy<CryptoService>;
|
||||
let apiService: MockProxy<ApiService>;
|
||||
@@ -129,6 +131,7 @@ describe("LoginStrategy", () => {
|
||||
|
||||
// The base class is abstract so we test it via PasswordLoginStrategy
|
||||
passwordLoginStrategy = new PasswordLoginStrategy(
|
||||
cache,
|
||||
cryptoService,
|
||||
apiService,
|
||||
tokenService,
|
||||
@@ -377,11 +380,23 @@ describe("LoginStrategy", () => {
|
||||
|
||||
it("sends 2FA token provided by user to server (two-step)", async () => {
|
||||
// Simulate a partially completed login
|
||||
passwordLoginStrategy.tokenRequest = new PasswordTokenRequest(
|
||||
email,
|
||||
masterPasswordHash,
|
||||
null,
|
||||
null,
|
||||
cache = new PasswordLoginStrategyData();
|
||||
cache.tokenRequest = new PasswordTokenRequest(email, masterPasswordHash, null, null);
|
||||
|
||||
passwordLoginStrategy = new PasswordLoginStrategy(
|
||||
cache,
|
||||
cryptoService,
|
||||
apiService,
|
||||
tokenService,
|
||||
appIdService,
|
||||
platformUtilsService,
|
||||
messagingService,
|
||||
logService,
|
||||
stateService,
|
||||
twoFactorService,
|
||||
passwordStrengthService,
|
||||
policyService,
|
||||
loginStrategyService,
|
||||
);
|
||||
|
||||
apiService.postIdentityToken.mockResolvedValue(identityTokenResponseFactory());
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
|
||||
import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service";
|
||||
@@ -36,16 +38,21 @@ import {
|
||||
AuthRequestLoginCredentials,
|
||||
WebAuthnLoginCredentials,
|
||||
} from "../models/domain/login-credentials";
|
||||
import { CacheData } from "../services/login-strategies/login-strategy.state";
|
||||
|
||||
type IdentityResponse = IdentityTokenResponse | IdentityTwoFactorResponse | IdentityCaptchaResponse;
|
||||
|
||||
export abstract class LoginStrategy {
|
||||
protected abstract tokenRequest:
|
||||
export abstract class LoginStrategyData {
|
||||
tokenRequest:
|
||||
| UserApiTokenRequest
|
||||
| PasswordTokenRequest
|
||||
| SsoTokenRequest
|
||||
| WebAuthnLoginTokenRequest;
|
||||
protected captchaBypassToken: string = null;
|
||||
captchaBypassToken?: string;
|
||||
}
|
||||
|
||||
export abstract class LoginStrategy {
|
||||
protected abstract cache: BehaviorSubject<LoginStrategyData>;
|
||||
|
||||
constructor(
|
||||
protected cryptoService: CryptoService,
|
||||
@@ -59,6 +66,8 @@ export abstract class LoginStrategy {
|
||||
protected twoFactorService: TwoFactorService,
|
||||
) {}
|
||||
|
||||
abstract exportCache(): CacheData;
|
||||
|
||||
abstract logIn(
|
||||
credentials:
|
||||
| UserApiLoginCredentials
|
||||
@@ -72,7 +81,9 @@ export abstract class LoginStrategy {
|
||||
twoFactor: TokenTwoFactorRequest,
|
||||
captchaResponse: string = null,
|
||||
): Promise<AuthResult> {
|
||||
this.tokenRequest.setTwoFactor(twoFactor);
|
||||
const data = this.cache.value;
|
||||
data.tokenRequest.setTwoFactor(twoFactor);
|
||||
this.cache.next(data);
|
||||
const [authResult] = await this.startLogIn();
|
||||
return authResult;
|
||||
}
|
||||
@@ -80,7 +91,8 @@ export abstract class LoginStrategy {
|
||||
protected async startLogIn(): Promise<[AuthResult, IdentityResponse]> {
|
||||
this.twoFactorService.clearSelectedProvider();
|
||||
|
||||
const response = await this.apiService.postIdentityToken(this.tokenRequest);
|
||||
const tokenRequest = this.cache.value.tokenRequest;
|
||||
const response = await this.apiService.postIdentityToken(tokenRequest);
|
||||
|
||||
if (response instanceof IdentityTwoFactorResponse) {
|
||||
return [await this.processTwoFactorResponse(response), response];
|
||||
@@ -195,9 +207,7 @@ export abstract class LoginStrategy {
|
||||
|
||||
// The keys comes from different sources depending on the login strategy
|
||||
protected abstract setMasterKey(response: IdentityTokenResponse): Promise<void>;
|
||||
|
||||
protected abstract setUserKey(response: IdentityTokenResponse): Promise<void>;
|
||||
|
||||
protected abstract setPrivateKey(response: IdentityTokenResponse): Promise<void>;
|
||||
|
||||
// Old accounts used master key for encryption. We are forcing migrations but only need to
|
||||
@@ -221,7 +231,7 @@ export abstract class LoginStrategy {
|
||||
result.twoFactorProviders = response.twoFactorProviders2;
|
||||
|
||||
this.twoFactorService.setProviders(response);
|
||||
this.captchaBypassToken = response.captchaToken ?? null;
|
||||
this.cache.next({ ...this.cache.value, captchaBypassToken: response.captchaToken ?? null });
|
||||
result.ssoEmail2FaSessionToken = response.ssoEmail2faSessionToken;
|
||||
result.email = response.email;
|
||||
return result;
|
||||
|
||||
@@ -29,7 +29,7 @@ import { LoginStrategyServiceAbstraction } from "../abstractions";
|
||||
import { PasswordLoginCredentials } from "../models/domain/login-credentials";
|
||||
|
||||
import { identityTokenResponseFactory } from "./login.strategy.spec";
|
||||
import { PasswordLoginStrategy } from "./password-login.strategy";
|
||||
import { PasswordLoginStrategy, PasswordLoginStrategyData } from "./password-login.strategy";
|
||||
|
||||
const email = "hello@world.com";
|
||||
const masterPassword = "password";
|
||||
@@ -47,6 +47,8 @@ const masterPasswordPolicy = new MasterPasswordPolicyResponse({
|
||||
});
|
||||
|
||||
describe("PasswordLoginStrategy", () => {
|
||||
let cache: PasswordLoginStrategyData;
|
||||
|
||||
let loginStrategyService: MockProxy<LoginStrategyServiceAbstraction>;
|
||||
let cryptoService: MockProxy<CryptoService>;
|
||||
let apiService: MockProxy<ApiService>;
|
||||
@@ -93,6 +95,7 @@ describe("PasswordLoginStrategy", () => {
|
||||
policyService.evaluateMasterPassword.mockReturnValue(true);
|
||||
|
||||
passwordLoginStrategy = new PasswordLoginStrategy(
|
||||
cache,
|
||||
cryptoService,
|
||||
apiService,
|
||||
tokenService,
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { BehaviorSubject, map, Observable } from "rxjs";
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
|
||||
@@ -17,35 +20,56 @@ 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 { HashPurpose } from "@bitwarden/common/platform/enums";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
|
||||
import { MasterKey } from "@bitwarden/common/types/key";
|
||||
|
||||
import { LoginStrategyServiceAbstraction } from "../abstractions";
|
||||
import { PasswordLoginCredentials } from "../models/domain/login-credentials";
|
||||
import { CacheData } from "../services/login-strategies/login-strategy.state";
|
||||
|
||||
import { LoginStrategy } from "./login.strategy";
|
||||
import { LoginStrategy, LoginStrategyData } from "./login.strategy";
|
||||
|
||||
export class PasswordLoginStrategyData implements LoginStrategyData {
|
||||
tokenRequest: PasswordTokenRequest;
|
||||
captchaBypassToken?: string;
|
||||
/**
|
||||
* The local version of the user's master key hash
|
||||
*/
|
||||
localMasterKeyHash: string;
|
||||
/**
|
||||
* The user's master key
|
||||
*/
|
||||
masterKey: MasterKey;
|
||||
/**
|
||||
* Tracks if the user needs to update their password due to
|
||||
* a password that does not meet an organization's master password policy.
|
||||
*/
|
||||
forcePasswordResetReason: ForceSetPasswordReason = ForceSetPasswordReason.None;
|
||||
|
||||
static fromJSON(obj: Jsonify<PasswordLoginStrategyData>): PasswordLoginStrategyData {
|
||||
const data = Object.assign(new PasswordLoginStrategyData(), obj, {
|
||||
tokenRequest: PasswordTokenRequest.fromJSON(obj.tokenRequest),
|
||||
masterKey: SymmetricCryptoKey.fromJSON(obj.masterKey),
|
||||
});
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
export class PasswordLoginStrategy extends LoginStrategy {
|
||||
get email() {
|
||||
return this.tokenRequest.email;
|
||||
}
|
||||
|
||||
get masterPasswordHash() {
|
||||
return this.tokenRequest.masterPasswordHash;
|
||||
}
|
||||
|
||||
tokenRequest: PasswordTokenRequest;
|
||||
|
||||
private localMasterKeyHash: string;
|
||||
private masterKey: MasterKey;
|
||||
|
||||
/**
|
||||
* Options to track if the user needs to update their password due to a password that does not meet an organization's
|
||||
* master password policy.
|
||||
* The email address of the user attempting to log in.
|
||||
*/
|
||||
private forcePasswordResetReason: ForceSetPasswordReason = ForceSetPasswordReason.None;
|
||||
email$: Observable<string>;
|
||||
/**
|
||||
* The master key hash of the user attempting to log in.
|
||||
*/
|
||||
masterKeyHash$: Observable<string | null>;
|
||||
|
||||
protected cache: BehaviorSubject<PasswordLoginStrategyData>;
|
||||
|
||||
constructor(
|
||||
data: PasswordLoginStrategyData,
|
||||
cryptoService: CryptoService,
|
||||
apiService: ApiService,
|
||||
tokenService: TokenService,
|
||||
@@ -70,42 +94,27 @@ export class PasswordLoginStrategy extends LoginStrategy {
|
||||
stateService,
|
||||
twoFactorService,
|
||||
);
|
||||
}
|
||||
|
||||
override async logInTwoFactor(
|
||||
twoFactor: TokenTwoFactorRequest,
|
||||
captchaResponse: string,
|
||||
): Promise<AuthResult> {
|
||||
this.tokenRequest.captchaResponse = captchaResponse ?? this.captchaBypassToken;
|
||||
const result = await super.logInTwoFactor(twoFactor);
|
||||
|
||||
// 2FA was successful, save the force update password options with the state service if defined
|
||||
if (
|
||||
!result.requiresTwoFactor &&
|
||||
!result.requiresCaptcha &&
|
||||
this.forcePasswordResetReason != ForceSetPasswordReason.None
|
||||
) {
|
||||
await this.stateService.setForceSetPasswordReason(this.forcePasswordResetReason);
|
||||
result.forcePasswordReset = this.forcePasswordResetReason;
|
||||
}
|
||||
|
||||
return result;
|
||||
this.cache = new BehaviorSubject(data);
|
||||
this.email$ = this.cache.pipe(map((state) => state.tokenRequest.email));
|
||||
this.masterKeyHash$ = this.cache.pipe(map((state) => state.localMasterKeyHash));
|
||||
}
|
||||
|
||||
override async logIn(credentials: PasswordLoginCredentials) {
|
||||
const { email, masterPassword, captchaToken, twoFactor } = credentials;
|
||||
|
||||
this.masterKey = await this.loginStrategyService.makePreloginKey(masterPassword, email);
|
||||
const data = new PasswordLoginStrategyData();
|
||||
data.masterKey = await this.loginStrategyService.makePreloginKey(masterPassword, email);
|
||||
|
||||
// Hash the password early (before authentication) so we don't persist it in memory in plaintext
|
||||
this.localMasterKeyHash = await this.cryptoService.hashMasterKey(
|
||||
data.localMasterKeyHash = await this.cryptoService.hashMasterKey(
|
||||
masterPassword,
|
||||
this.masterKey,
|
||||
data.masterKey,
|
||||
HashPurpose.LocalAuthorization,
|
||||
);
|
||||
const masterKeyHash = await this.cryptoService.hashMasterKey(masterPassword, this.masterKey);
|
||||
const masterKeyHash = await this.cryptoService.hashMasterKey(masterPassword, data.masterKey);
|
||||
|
||||
this.tokenRequest = new PasswordTokenRequest(
|
||||
data.tokenRequest = new PasswordTokenRequest(
|
||||
email,
|
||||
masterKeyHash,
|
||||
captchaToken,
|
||||
@@ -113,6 +122,8 @@ export class PasswordLoginStrategy extends LoginStrategy {
|
||||
await this.buildDeviceRequest(),
|
||||
);
|
||||
|
||||
this.cache.next(data);
|
||||
|
||||
const [authResult, identityResponse] = await this.startLogIn();
|
||||
|
||||
const masterPasswordPolicyOptions =
|
||||
@@ -129,7 +140,10 @@ export class PasswordLoginStrategy extends LoginStrategy {
|
||||
if (!meetsRequirements) {
|
||||
if (authResult.requiresCaptcha || authResult.requiresTwoFactor) {
|
||||
// Save the flag to this strategy for later use as the master password is about to pass out of scope
|
||||
this.forcePasswordResetReason = ForceSetPasswordReason.WeakMasterPassword;
|
||||
this.cache.next({
|
||||
...this.cache.value,
|
||||
forcePasswordResetReason: ForceSetPasswordReason.WeakMasterPassword,
|
||||
});
|
||||
} else {
|
||||
// Authentication was successful, save the force update password options with the state service
|
||||
await this.stateService.setForceSetPasswordReason(
|
||||
@@ -142,9 +156,34 @@ export class PasswordLoginStrategy extends LoginStrategy {
|
||||
return authResult;
|
||||
}
|
||||
|
||||
override async logInTwoFactor(
|
||||
twoFactor: TokenTwoFactorRequest,
|
||||
captchaResponse: string,
|
||||
): Promise<AuthResult> {
|
||||
this.cache.next({
|
||||
...this.cache.value,
|
||||
captchaBypassToken: captchaResponse ?? this.cache.value.captchaBypassToken,
|
||||
});
|
||||
const result = await super.logInTwoFactor(twoFactor);
|
||||
|
||||
// 2FA was successful, save the force update password options with the state service if defined
|
||||
const forcePasswordResetReason = this.cache.value.forcePasswordResetReason;
|
||||
if (
|
||||
!result.requiresTwoFactor &&
|
||||
!result.requiresCaptcha &&
|
||||
forcePasswordResetReason != ForceSetPasswordReason.None
|
||||
) {
|
||||
await this.stateService.setForceSetPasswordReason(forcePasswordResetReason);
|
||||
result.forcePasswordReset = forcePasswordResetReason;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
protected override async setMasterKey(response: IdentityTokenResponse) {
|
||||
await this.cryptoService.setMasterKey(this.masterKey);
|
||||
await this.cryptoService.setMasterKeyHash(this.localMasterKeyHash);
|
||||
const { masterKey, localMasterKeyHash } = this.cache.value;
|
||||
await this.cryptoService.setMasterKey(masterKey);
|
||||
await this.cryptoService.setMasterKeyHash(localMasterKeyHash);
|
||||
}
|
||||
|
||||
protected override async setUserKey(response: IdentityTokenResponse): Promise<void> {
|
||||
@@ -191,4 +230,10 @@ export class PasswordLoginStrategy extends LoginStrategy {
|
||||
|
||||
return this.policyService.evaluateMasterPassword(passwordStrength, masterPassword, options);
|
||||
}
|
||||
|
||||
exportCache(): CacheData {
|
||||
return {
|
||||
password: this.cache.value,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,11 @@ import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abst
|
||||
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 { AdminAuthRequestStorable } from "@bitwarden/common/auth/models/domain/admin-auth-req-storable";
|
||||
import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response";
|
||||
import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response";
|
||||
import { IUserDecryptionOptionsServerResponse } from "@bitwarden/common/auth/models/response/user-decryption-options/user-decryption-options.response";
|
||||
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
|
||||
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
|
||||
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
@@ -25,9 +28,6 @@ import { SsoLoginCredentials } from "../models/domain/login-credentials";
|
||||
import { identityTokenResponseFactory } from "./login.strategy.spec";
|
||||
import { SsoLoginStrategy } from "./sso-login.strategy";
|
||||
|
||||
// TODO: Add tests for new trySetUserKeyWithApprovedAdminRequestIfExists logic
|
||||
// https://bitwarden.atlassian.net/browse/PM-3339
|
||||
|
||||
describe("SsoLoginStrategy", () => {
|
||||
let cryptoService: MockProxy<CryptoService>;
|
||||
let apiService: MockProxy<ApiService>;
|
||||
@@ -74,6 +74,7 @@ describe("SsoLoginStrategy", () => {
|
||||
tokenService.decodeToken.mockResolvedValue({});
|
||||
|
||||
ssoLoginStrategy = new SsoLoginStrategy(
|
||||
null,
|
||||
cryptoService,
|
||||
apiService,
|
||||
tokenService,
|
||||
@@ -258,6 +259,114 @@ describe("SsoLoginStrategy", () => {
|
||||
// Assert
|
||||
expect(cryptoService.setUserKey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("AdminAuthRequest", () => {
|
||||
let tokenResponse: IdentityTokenResponse;
|
||||
|
||||
beforeEach(() => {
|
||||
tokenResponse = identityTokenResponseFactory(null, {
|
||||
HasMasterPassword: true,
|
||||
TrustedDeviceOption: {
|
||||
HasAdminApproval: true,
|
||||
HasLoginApprovingDevice: false,
|
||||
HasManageResetPasswordPermission: false,
|
||||
EncryptedPrivateKey: mockEncDevicePrivateKey,
|
||||
EncryptedUserKey: mockEncUserKey,
|
||||
},
|
||||
});
|
||||
|
||||
const adminAuthRequest = {
|
||||
id: "1",
|
||||
privateKey: "PRIVATE" as any,
|
||||
} as AdminAuthRequestStorable;
|
||||
stateService.getAdminAuthRequest.mockResolvedValue(
|
||||
new AdminAuthRequestStorable(adminAuthRequest),
|
||||
);
|
||||
});
|
||||
|
||||
it("sets the user key using master key and hash from approved admin request if exists", async () => {
|
||||
apiService.postIdentityToken.mockResolvedValue(tokenResponse);
|
||||
cryptoService.hasUserKey.mockResolvedValue(true);
|
||||
const adminAuthResponse = {
|
||||
id: "1",
|
||||
publicKey: "PRIVATE" as any,
|
||||
key: "KEY" as any,
|
||||
masterPasswordHash: "HASH" as any,
|
||||
requestApproved: true,
|
||||
};
|
||||
apiService.getAuthRequest.mockResolvedValue(adminAuthResponse as AuthRequestResponse);
|
||||
|
||||
await ssoLoginStrategy.logIn(credentials);
|
||||
|
||||
expect(authRequestService.setKeysAfterDecryptingSharedMasterKeyAndHash).toHaveBeenCalled();
|
||||
expect(deviceTrustCryptoService.decryptUserKeyWithDeviceKey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sets the user key from approved admin request if exists", async () => {
|
||||
apiService.postIdentityToken.mockResolvedValue(tokenResponse);
|
||||
cryptoService.hasUserKey.mockResolvedValue(true);
|
||||
const adminAuthResponse = {
|
||||
id: "1",
|
||||
publicKey: "PRIVATE" as any,
|
||||
key: "KEY" as any,
|
||||
requestApproved: true,
|
||||
};
|
||||
apiService.getAuthRequest.mockResolvedValue(adminAuthResponse as AuthRequestResponse);
|
||||
|
||||
await ssoLoginStrategy.logIn(credentials);
|
||||
|
||||
expect(authRequestService.setUserKeyAfterDecryptingSharedUserKey).toHaveBeenCalled();
|
||||
expect(deviceTrustCryptoService.decryptUserKeyWithDeviceKey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("attempts to establish a trusted device if successful", async () => {
|
||||
apiService.postIdentityToken.mockResolvedValue(tokenResponse);
|
||||
cryptoService.hasUserKey.mockResolvedValue(true);
|
||||
const adminAuthResponse = {
|
||||
id: "1",
|
||||
publicKey: "PRIVATE" as any,
|
||||
key: "KEY" as any,
|
||||
requestApproved: true,
|
||||
};
|
||||
apiService.getAuthRequest.mockResolvedValue(adminAuthResponse as AuthRequestResponse);
|
||||
|
||||
await ssoLoginStrategy.logIn(credentials);
|
||||
|
||||
expect(authRequestService.setUserKeyAfterDecryptingSharedUserKey).toHaveBeenCalled();
|
||||
expect(deviceTrustCryptoService.trustDeviceIfRequired).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clears the admin auth request if server returns a 404, meaning it was deleted", async () => {
|
||||
apiService.postIdentityToken.mockResolvedValue(tokenResponse);
|
||||
apiService.getAuthRequest.mockRejectedValue(new ErrorResponse(null, 404));
|
||||
|
||||
await ssoLoginStrategy.logIn(credentials);
|
||||
|
||||
expect(stateService.setAdminAuthRequest).toHaveBeenCalledWith(null);
|
||||
expect(
|
||||
authRequestService.setKeysAfterDecryptingSharedMasterKeyAndHash,
|
||||
).not.toHaveBeenCalled();
|
||||
expect(authRequestService.setUserKeyAfterDecryptingSharedUserKey).not.toHaveBeenCalled();
|
||||
expect(deviceTrustCryptoService.trustDeviceIfRequired).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("attempts to login with a trusted device if admin auth request isn't successful", async () => {
|
||||
apiService.postIdentityToken.mockResolvedValue(tokenResponse);
|
||||
const adminAuthResponse = {
|
||||
id: "1",
|
||||
publicKey: "PRIVATE" as any,
|
||||
key: "KEY" as any,
|
||||
requestApproved: true,
|
||||
};
|
||||
apiService.getAuthRequest.mockResolvedValue(adminAuthResponse as AuthRequestResponse);
|
||||
cryptoService.hasUserKey.mockResolvedValue(false);
|
||||
deviceTrustCryptoService.getDeviceKey.mockResolvedValue("DEVICE_KEY" as any);
|
||||
|
||||
await ssoLoginStrategy.logIn(credentials);
|
||||
|
||||
expect(deviceTrustCryptoService.decryptUserKeyWithDeviceKey).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Key Connector", () => {
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { Observable, map, BehaviorSubject } from "rxjs";
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction";
|
||||
import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service";
|
||||
@@ -19,20 +22,54 @@ import { StateService } from "@bitwarden/common/platform/abstractions/state.serv
|
||||
|
||||
import { AuthRequestServiceAbstraction } from "../abstractions";
|
||||
import { SsoLoginCredentials } from "../models/domain/login-credentials";
|
||||
import { CacheData } from "../services/login-strategies/login-strategy.state";
|
||||
|
||||
import { LoginStrategy } from "./login.strategy";
|
||||
import { LoginStrategyData, LoginStrategy } from "./login.strategy";
|
||||
|
||||
export class SsoLoginStrategyData implements LoginStrategyData {
|
||||
captchaBypassToken: string;
|
||||
tokenRequest: SsoTokenRequest;
|
||||
/**
|
||||
* User email address. Only available after authentication.
|
||||
*/
|
||||
email?: string;
|
||||
/**
|
||||
* The organization ID that the user is logging into. Used for Key Connector
|
||||
* purposes after authentication.
|
||||
*/
|
||||
orgId: string;
|
||||
/**
|
||||
* A token provided by the server as an authentication factor for sending
|
||||
* email OTPs to the user's configured 2FA email address. This is required
|
||||
* as we don't have a master password hash or other verifiable secret when using SSO.
|
||||
*/
|
||||
ssoEmail2FaSessionToken?: string;
|
||||
|
||||
static fromJSON(obj: Jsonify<SsoLoginStrategyData>): SsoLoginStrategyData {
|
||||
return Object.assign(new SsoLoginStrategyData(), obj, {
|
||||
tokenRequest: SsoTokenRequest.fromJSON(obj.tokenRequest),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class SsoLoginStrategy extends LoginStrategy {
|
||||
tokenRequest: SsoTokenRequest;
|
||||
orgId: string;
|
||||
/**
|
||||
* @see {@link SsoLoginStrategyData.email}
|
||||
*/
|
||||
email$: Observable<string | null>;
|
||||
/**
|
||||
* @see {@link SsoLoginStrategyData.orgId}
|
||||
*/
|
||||
orgId$: Observable<string>;
|
||||
/**
|
||||
* @see {@link SsoLoginStrategyData.ssoEmail2FaSessionToken}
|
||||
*/
|
||||
ssoEmail2FaSessionToken$: Observable<string | null>;
|
||||
|
||||
// A session token server side to serve as an authentication factor for the user
|
||||
// in order to send email OTPs to the user's configured 2FA email address
|
||||
// as we don't have a master password hash or other verifiable secret when using SSO.
|
||||
ssoEmail2FaSessionToken?: string;
|
||||
email?: string; // email not preserved through SSO process so get from server
|
||||
protected cache: BehaviorSubject<SsoLoginStrategyData>;
|
||||
|
||||
constructor(
|
||||
data: SsoLoginStrategyData,
|
||||
cryptoService: CryptoService,
|
||||
apiService: ApiService,
|
||||
tokenService: TokenService,
|
||||
@@ -58,11 +95,17 @@ export class SsoLoginStrategy extends LoginStrategy {
|
||||
stateService,
|
||||
twoFactorService,
|
||||
);
|
||||
|
||||
this.cache = new BehaviorSubject(data);
|
||||
this.email$ = this.cache.pipe(map((state) => state.email));
|
||||
this.orgId$ = this.cache.pipe(map((state) => state.orgId));
|
||||
this.ssoEmail2FaSessionToken$ = this.cache.pipe(map((state) => state.ssoEmail2FaSessionToken));
|
||||
}
|
||||
|
||||
async logIn(credentials: SsoLoginCredentials) {
|
||||
this.orgId = credentials.orgId;
|
||||
this.tokenRequest = new SsoTokenRequest(
|
||||
const data = new SsoLoginStrategyData();
|
||||
data.orgId = credentials.orgId;
|
||||
data.tokenRequest = new SsoTokenRequest(
|
||||
credentials.code,
|
||||
credentials.codeVerifier,
|
||||
credentials.redirectUrl,
|
||||
@@ -70,16 +113,24 @@ export class SsoLoginStrategy extends LoginStrategy {
|
||||
await this.buildDeviceRequest(),
|
||||
);
|
||||
|
||||
this.cache.next(data);
|
||||
|
||||
const [ssoAuthResult] = await this.startLogIn();
|
||||
|
||||
this.email = ssoAuthResult.email;
|
||||
this.ssoEmail2FaSessionToken = ssoAuthResult.ssoEmail2FaSessionToken;
|
||||
const email = ssoAuthResult.email;
|
||||
const ssoEmail2FaSessionToken = ssoAuthResult.ssoEmail2FaSessionToken;
|
||||
|
||||
// Auth guard currently handles redirects for this.
|
||||
if (ssoAuthResult.forcePasswordReset == ForceSetPasswordReason.AdminForcePasswordReset) {
|
||||
await this.stateService.setForceSetPasswordReason(ssoAuthResult.forcePasswordReset);
|
||||
}
|
||||
|
||||
this.cache.next({
|
||||
...this.cache.value,
|
||||
email,
|
||||
ssoEmail2FaSessionToken,
|
||||
});
|
||||
|
||||
return ssoAuthResult;
|
||||
}
|
||||
|
||||
@@ -92,7 +143,10 @@ export class SsoLoginStrategy extends LoginStrategy {
|
||||
// The presence of a masterKeyEncryptedUserKey indicates that the user has already been provisioned in Key Connector.
|
||||
const newSsoUser = tokenResponse.key == null;
|
||||
if (newSsoUser) {
|
||||
await this.keyConnectorService.convertNewSsoUserToKeyConnector(tokenResponse, this.orgId);
|
||||
await this.keyConnectorService.convertNewSsoUserToKeyConnector(
|
||||
tokenResponse,
|
||||
this.cache.value.orgId,
|
||||
);
|
||||
} else {
|
||||
const keyConnectorUrl = this.getKeyConnectorUrl(tokenResponse);
|
||||
await this.keyConnectorService.setMasterKeyFromUrl(keyConnectorUrl);
|
||||
@@ -272,4 +326,10 @@ export class SsoLoginStrategy extends LoginStrategy {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
exportCache(): CacheData {
|
||||
return {
|
||||
sso: this.cache.value,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,9 +19,11 @@ import { UserKey, MasterKey } from "@bitwarden/common/types/key";
|
||||
import { UserApiLoginCredentials } from "../models/domain/login-credentials";
|
||||
|
||||
import { identityTokenResponseFactory } from "./login.strategy.spec";
|
||||
import { UserApiLoginStrategy } from "./user-api-login.strategy";
|
||||
import { UserApiLoginStrategy, UserApiLoginStrategyData } from "./user-api-login.strategy";
|
||||
|
||||
describe("UserApiLoginStrategy", () => {
|
||||
let cache: UserApiLoginStrategyData;
|
||||
|
||||
let cryptoService: MockProxy<CryptoService>;
|
||||
let apiService: MockProxy<ApiService>;
|
||||
let tokenService: MockProxy<TokenService>;
|
||||
@@ -60,6 +62,7 @@ describe("UserApiLoginStrategy", () => {
|
||||
tokenService.decodeToken.mockResolvedValue({});
|
||||
|
||||
apiLogInStrategy = new UserApiLoginStrategy(
|
||||
cache,
|
||||
cryptoService,
|
||||
apiService,
|
||||
tokenService,
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service";
|
||||
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
|
||||
@@ -13,13 +16,26 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
|
||||
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
|
||||
|
||||
import { UserApiLoginCredentials } from "../models/domain/login-credentials";
|
||||
import { CacheData } from "../services/login-strategies/login-strategy.state";
|
||||
|
||||
import { LoginStrategy } from "./login.strategy";
|
||||
import { LoginStrategy, LoginStrategyData } from "./login.strategy";
|
||||
|
||||
export class UserApiLoginStrategyData implements LoginStrategyData {
|
||||
tokenRequest: UserApiTokenRequest;
|
||||
captchaBypassToken: string;
|
||||
|
||||
static fromJSON(obj: Jsonify<UserApiLoginStrategyData>): UserApiLoginStrategyData {
|
||||
return Object.assign(new UserApiLoginStrategyData(), obj, {
|
||||
tokenRequest: UserApiTokenRequest.fromJSON(obj.tokenRequest),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class UserApiLoginStrategy extends LoginStrategy {
|
||||
tokenRequest: UserApiTokenRequest;
|
||||
protected cache: BehaviorSubject<UserApiLoginStrategyData>;
|
||||
|
||||
constructor(
|
||||
data: UserApiLoginStrategyData,
|
||||
cryptoService: CryptoService,
|
||||
apiService: ApiService,
|
||||
tokenService: TokenService,
|
||||
@@ -43,15 +59,18 @@ export class UserApiLoginStrategy extends LoginStrategy {
|
||||
stateService,
|
||||
twoFactorService,
|
||||
);
|
||||
this.cache = new BehaviorSubject(data);
|
||||
}
|
||||
|
||||
override async logIn(credentials: UserApiLoginCredentials) {
|
||||
this.tokenRequest = new UserApiTokenRequest(
|
||||
const data = new UserApiLoginStrategyData();
|
||||
data.tokenRequest = new UserApiTokenRequest(
|
||||
credentials.clientId,
|
||||
credentials.clientSecret,
|
||||
await this.buildTwoFactor(),
|
||||
await this.buildDeviceRequest(),
|
||||
);
|
||||
this.cache.next(data);
|
||||
|
||||
const [authResult] = await this.startLogIn();
|
||||
return authResult;
|
||||
@@ -84,7 +103,15 @@ export class UserApiLoginStrategy extends LoginStrategy {
|
||||
|
||||
protected async saveAccountInformation(tokenResponse: IdentityTokenResponse) {
|
||||
await super.saveAccountInformation(tokenResponse);
|
||||
await this.stateService.setApiKeyClientId(this.tokenRequest.clientId);
|
||||
await this.stateService.setApiKeyClientSecret(this.tokenRequest.clientSecret);
|
||||
|
||||
const tokenRequest = this.cache.value.tokenRequest;
|
||||
await this.stateService.setApiKeyClientId(tokenRequest.clientId);
|
||||
await this.stateService.setApiKeyClientSecret(tokenRequest.clientSecret);
|
||||
}
|
||||
|
||||
exportCache(): CacheData {
|
||||
return {
|
||||
userApiKey: this.cache.value,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,9 +20,11 @@ import { PrfKey, UserKey } from "@bitwarden/common/types/key";
|
||||
import { WebAuthnLoginCredentials } from "../models/domain/login-credentials";
|
||||
|
||||
import { identityTokenResponseFactory } from "./login.strategy.spec";
|
||||
import { WebAuthnLoginStrategy } from "./webauthn-login.strategy";
|
||||
import { WebAuthnLoginStrategy, WebAuthnLoginStrategyData } from "./webauthn-login.strategy";
|
||||
|
||||
describe("WebAuthnLoginStrategy", () => {
|
||||
let cache: WebAuthnLoginStrategyData;
|
||||
|
||||
let cryptoService!: MockProxy<CryptoService>;
|
||||
let apiService!: MockProxy<ApiService>;
|
||||
let tokenService!: MockProxy<TokenService>;
|
||||
@@ -72,6 +74,7 @@ describe("WebAuthnLoginStrategy", () => {
|
||||
tokenService.decodeToken.mockResolvedValue({});
|
||||
|
||||
webAuthnLoginStrategy = new WebAuthnLoginStrategy(
|
||||
cache,
|
||||
cryptoService,
|
||||
apiService,
|
||||
tokenService,
|
||||
@@ -286,7 +289,7 @@ function randomBytes(length: number): Uint8Array {
|
||||
// AuthenticatorAssertionResponse && PublicKeyCredential are only available in secure contexts
|
||||
// so we need to mock them and assign them to the global object to make them available
|
||||
// for the tests
|
||||
class MockAuthenticatorAssertionResponse implements AuthenticatorAssertionResponse {
|
||||
export class MockAuthenticatorAssertionResponse implements AuthenticatorAssertionResponse {
|
||||
clientDataJSON: ArrayBuffer = randomBytes(32).buffer;
|
||||
authenticatorData: ArrayBuffer = randomBytes(196).buffer;
|
||||
signature: ArrayBuffer = randomBytes(72).buffer;
|
||||
@@ -298,7 +301,7 @@ class MockAuthenticatorAssertionResponse implements AuthenticatorAssertionRespon
|
||||
userHandleB64Str = Utils.fromBufferToUrlB64(this.userHandle);
|
||||
}
|
||||
|
||||
class MockPublicKeyCredential implements PublicKeyCredential {
|
||||
export class MockPublicKeyCredential implements PublicKeyCredential {
|
||||
authenticatorAttachment = "cross-platform";
|
||||
id = "mockCredentialId";
|
||||
type = "public-key";
|
||||
|
||||
@@ -1,16 +1,86 @@
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
|
||||
import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service";
|
||||
import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
|
||||
import { WebAuthnLoginTokenRequest } from "@bitwarden/common/auth/models/request/identity-token/webauthn-login-token.request";
|
||||
import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response";
|
||||
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
|
||||
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.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 { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { UserKey } from "@bitwarden/common/types/key";
|
||||
|
||||
import { WebAuthnLoginCredentials } from "../models/domain/login-credentials";
|
||||
import { CacheData } from "../services/login-strategies/login-strategy.state";
|
||||
|
||||
import { LoginStrategy } from "./login.strategy";
|
||||
import { LoginStrategy, LoginStrategyData } from "./login.strategy";
|
||||
|
||||
export class WebAuthnLoginStrategyData implements LoginStrategyData {
|
||||
tokenRequest: WebAuthnLoginTokenRequest;
|
||||
captchaBypassToken?: string;
|
||||
credentials: WebAuthnLoginCredentials;
|
||||
|
||||
static fromJSON(obj: Jsonify<WebAuthnLoginStrategyData>): WebAuthnLoginStrategyData {
|
||||
return Object.assign(new WebAuthnLoginStrategyData(), obj, {
|
||||
tokenRequest: WebAuthnLoginTokenRequest.fromJSON(obj.tokenRequest),
|
||||
credentials: WebAuthnLoginCredentials.fromJSON(obj.credentials),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class WebAuthnLoginStrategy extends LoginStrategy {
|
||||
tokenRequest: WebAuthnLoginTokenRequest;
|
||||
private credentials: WebAuthnLoginCredentials;
|
||||
protected cache: BehaviorSubject<WebAuthnLoginStrategyData>;
|
||||
|
||||
constructor(
|
||||
data: WebAuthnLoginStrategyData,
|
||||
cryptoService: CryptoService,
|
||||
apiService: ApiService,
|
||||
tokenService: TokenService,
|
||||
appIdService: AppIdService,
|
||||
platformUtilsService: PlatformUtilsService,
|
||||
messagingService: MessagingService,
|
||||
logService: LogService,
|
||||
stateService: StateService,
|
||||
twoFactorService: TwoFactorService,
|
||||
) {
|
||||
super(
|
||||
cryptoService,
|
||||
apiService,
|
||||
tokenService,
|
||||
appIdService,
|
||||
platformUtilsService,
|
||||
messagingService,
|
||||
logService,
|
||||
stateService,
|
||||
twoFactorService,
|
||||
);
|
||||
|
||||
this.cache = new BehaviorSubject(data);
|
||||
}
|
||||
|
||||
async logIn(credentials: WebAuthnLoginCredentials) {
|
||||
const data = new WebAuthnLoginStrategyData();
|
||||
data.credentials = credentials;
|
||||
data.tokenRequest = new WebAuthnLoginTokenRequest(
|
||||
credentials.token,
|
||||
credentials.deviceResponse,
|
||||
await this.buildDeviceRequest(),
|
||||
);
|
||||
this.cache.next(data);
|
||||
|
||||
const [authResult] = await this.startLogIn();
|
||||
return authResult;
|
||||
}
|
||||
|
||||
async logInTwoFactor(): Promise<AuthResult> {
|
||||
throw new Error("2FA not supported yet for WebAuthn Login.");
|
||||
}
|
||||
|
||||
protected override async setMasterKey() {
|
||||
return Promise.resolve();
|
||||
@@ -29,15 +99,16 @@ export class WebAuthnLoginStrategy extends LoginStrategy {
|
||||
if (userDecryptionOptions?.webAuthnPrfOption) {
|
||||
const webAuthnPrfOption = idTokenResponse.userDecryptionOptions?.webAuthnPrfOption;
|
||||
|
||||
const credentials = this.cache.value.credentials;
|
||||
// confirm we still have the prf key
|
||||
if (!this.credentials.prfKey) {
|
||||
if (!credentials.prfKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
// decrypt prf encrypted private key
|
||||
const privateKey = await this.cryptoService.decryptToBytes(
|
||||
webAuthnPrfOption.encryptedPrivateKey,
|
||||
this.credentials.prfKey,
|
||||
credentials.prfKey,
|
||||
);
|
||||
|
||||
// decrypt user key with private key
|
||||
@@ -58,22 +129,9 @@ export class WebAuthnLoginStrategy extends LoginStrategy {
|
||||
);
|
||||
}
|
||||
|
||||
async logInTwoFactor(): Promise<AuthResult> {
|
||||
throw new Error("2FA not supported yet for WebAuthn Login.");
|
||||
}
|
||||
|
||||
async logIn(credentials: WebAuthnLoginCredentials) {
|
||||
// NOTE: To avoid DeadObject references on Firefox, do not set the credentials object directly
|
||||
// Use deep copy in future if objects are added that were created in popup
|
||||
this.credentials = { ...credentials };
|
||||
|
||||
this.tokenRequest = new WebAuthnLoginTokenRequest(
|
||||
credentials.token,
|
||||
credentials.deviceResponse,
|
||||
await this.buildDeviceRequest(),
|
||||
);
|
||||
|
||||
const [authResult] = await this.startLogIn();
|
||||
return authResult;
|
||||
exportCache(): CacheData {
|
||||
return {
|
||||
webAuthn: this.cache.value,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { AuthenticationType } from "@bitwarden/common/auth/enums/authentication-type";
|
||||
import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/identity-token/token-two-factor.request";
|
||||
import { WebAuthnLoginAssertionResponseRequest } from "@bitwarden/common/auth/services/webauthn-login/request/webauthn-login-assertion-response.request";
|
||||
@@ -28,7 +30,7 @@ export class SsoLoginCredentials {
|
||||
}
|
||||
|
||||
export class UserApiLoginCredentials {
|
||||
readonly type = AuthenticationType.UserApi;
|
||||
readonly type = AuthenticationType.UserApiKey;
|
||||
|
||||
constructor(
|
||||
public clientId: string,
|
||||
@@ -48,6 +50,30 @@ export class AuthRequestLoginCredentials {
|
||||
public decryptedMasterKeyHash: string,
|
||||
public twoFactor?: TokenTwoFactorRequest,
|
||||
) {}
|
||||
|
||||
static fromJSON(json: Jsonify<AuthRequestLoginCredentials>) {
|
||||
return Object.assign(
|
||||
new AuthRequestLoginCredentials(
|
||||
json.email,
|
||||
json.accessCode,
|
||||
json.authRequestId,
|
||||
null,
|
||||
null,
|
||||
json.decryptedMasterKeyHash,
|
||||
json.twoFactor
|
||||
? new TokenTwoFactorRequest(
|
||||
json.twoFactor.provider,
|
||||
json.twoFactor.token,
|
||||
json.twoFactor.remember,
|
||||
)
|
||||
: json.twoFactor,
|
||||
),
|
||||
{
|
||||
decryptedUserKey: SymmetricCryptoKey.fromJSON(json.decryptedUserKey) as UserKey,
|
||||
decryptedMasterKey: SymmetricCryptoKey.fromJSON(json.decryptedMasterKey) as MasterKey,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class WebAuthnLoginCredentials {
|
||||
@@ -58,4 +84,15 @@ export class WebAuthnLoginCredentials {
|
||||
public deviceResponse: WebAuthnLoginAssertionResponseRequest,
|
||||
public prfKey?: SymmetricCryptoKey,
|
||||
) {}
|
||||
|
||||
static fromJSON(json: Jsonify<WebAuthnLoginCredentials>) {
|
||||
return new WebAuthnLoginCredentials(
|
||||
json.token,
|
||||
Object.assign(
|
||||
Object.create(WebAuthnLoginAssertionResponseRequest.prototype),
|
||||
json.deviceResponse,
|
||||
),
|
||||
SymmetricCryptoKey.fromJSON(json.prfKey),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -1,14 +1,8 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { AccountSettingsSettings } from "../platform/models/domain/account";
|
||||
|
||||
export abstract class SettingsService {
|
||||
settings$: Observable<AccountSettingsSettings>;
|
||||
disableFavicon$: Observable<boolean>;
|
||||
|
||||
setEquivalentDomains: (equivalentDomains: string[][]) => Promise<any>;
|
||||
getEquivalentDomains: (url: string) => Set<string>;
|
||||
setDisableFavicon: (value: boolean) => Promise<any>;
|
||||
getDisableFavicon: () => boolean;
|
||||
clear: (userId?: string) => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export enum AuthenticationType {
|
||||
Password = 0,
|
||||
Sso = 1,
|
||||
UserApi = 2,
|
||||
UserApiKey = 2,
|
||||
AuthRequest = 3,
|
||||
WebAuthn = 4,
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { DeviceType } from "../../../../enums";
|
||||
import { PlatformUtilsService } from "../../../../platform/abstractions/platform-utils.service";
|
||||
|
||||
@@ -13,4 +15,8 @@ export class DeviceRequest {
|
||||
this.identifier = appId;
|
||||
this.pushToken = null;
|
||||
}
|
||||
|
||||
static fromJSON(json: Jsonify<DeviceRequest>) {
|
||||
return Object.assign(Object.create(DeviceRequest.prototype), json);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,4 +34,13 @@ export class PasswordTokenRequest extends TokenRequest implements CaptchaProtect
|
||||
alterIdentityTokenHeaders(headers: Headers) {
|
||||
headers.set("Auth-Email", Utils.fromUtf8ToUrlB64(this.email));
|
||||
}
|
||||
|
||||
static fromJSON(json: any) {
|
||||
return Object.assign(Object.create(PasswordTokenRequest.prototype), json, {
|
||||
device: json.device ? DeviceRequest.fromJSON(json.device) : undefined,
|
||||
twoFactor: json.twoFactor
|
||||
? Object.assign(new TokenTwoFactorRequest(), json.twoFactor)
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,4 +23,13 @@ export class SsoTokenRequest extends TokenRequest {
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
static fromJSON(json: any) {
|
||||
return Object.assign(Object.create(SsoTokenRequest.prototype), json, {
|
||||
device: json.device ? DeviceRequest.fromJSON(json.device) : undefined,
|
||||
twoFactor: json.twoFactor
|
||||
? Object.assign(new TokenTwoFactorRequest(), json.twoFactor)
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,4 +21,13 @@ export class UserApiTokenRequest extends TokenRequest {
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
static fromJSON(json: any) {
|
||||
return Object.assign(Object.create(UserApiTokenRequest.prototype), json, {
|
||||
device: json.device ? DeviceRequest.fromJSON(json.device) : undefined,
|
||||
twoFactor: json.twoFactor
|
||||
? Object.assign(new TokenTwoFactorRequest(), json.twoFactor)
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { WebAuthnLoginAssertionResponseRequest } from "../../../services/webauthn-login/request/webauthn-login-assertion-response.request";
|
||||
|
||||
import { DeviceRequest } from "./device.request";
|
||||
import { TokenTwoFactorRequest } from "./token-two-factor.request";
|
||||
import { TokenRequest } from "./token.request";
|
||||
|
||||
export class WebAuthnLoginTokenRequest extends TokenRequest {
|
||||
@@ -22,4 +23,14 @@ export class WebAuthnLoginTokenRequest extends TokenRequest {
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
static fromJSON(json: any) {
|
||||
return Object.assign(Object.create(WebAuthnLoginTokenRequest.prototype), json, {
|
||||
deviceResponse: WebAuthnLoginAssertionResponseRequest.fromJSON(json.deviceResponse),
|
||||
device: json.device ? DeviceRequest.fromJSON(json.device) : undefined,
|
||||
twoFactor: json.twoFactor
|
||||
? Object.assign(new TokenTwoFactorRequest(), json.twoFactor)
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ export class AnonymousHubService implements AnonymousHubServiceAbstraction {
|
||||
}
|
||||
|
||||
private async ProcessNotification(notification: NotificationResponse) {
|
||||
await this.loginStrategyService.authResponsePushNotification(
|
||||
await this.loginStrategyService.sendAuthRequestPushNotification(
|
||||
notification.payload as AuthRequestPushNotification,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { UserId } from "../../../../common/src/types/guid";
|
||||
import { OrganizationApiServiceAbstraction } from "../../admin-console/abstractions/organization/organization-api.service.abstraction";
|
||||
import { OrganizationUserService } from "../../admin-console/abstractions/organization-user/organization-user.service";
|
||||
import { OrganizationAutoEnrollStatusResponse } from "../../admin-console/models/response/organization-auto-enroll-status.response";
|
||||
import { CryptoService } from "../../platform/abstractions/crypto.service";
|
||||
import { I18nService } from "../../platform/abstractions/i18n.service";
|
||||
import { StateService } from "../../platform/abstractions/state.service";
|
||||
import { AccountInfo, AccountService } from "../abstractions/account.service";
|
||||
import { AuthenticationStatus } from "../enums/authentication-status";
|
||||
|
||||
import { PasswordResetEnrollmentServiceImplementation } from "./password-reset-enrollment.service.implementation";
|
||||
|
||||
describe("PasswordResetEnrollmentServiceImplementation", () => {
|
||||
const activeAccountSubject = new BehaviorSubject<{ id: UserId } & AccountInfo>(null);
|
||||
|
||||
let organizationApiService: MockProxy<OrganizationApiServiceAbstraction>;
|
||||
let stateService: MockProxy<StateService>;
|
||||
let accountService: MockProxy<AccountService>;
|
||||
let cryptoService: MockProxy<CryptoService>;
|
||||
let organizationUserService: MockProxy<OrganizationUserService>;
|
||||
let i18nService: MockProxy<I18nService>;
|
||||
@@ -19,13 +24,14 @@ describe("PasswordResetEnrollmentServiceImplementation", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
organizationApiService = mock<OrganizationApiServiceAbstraction>();
|
||||
stateService = mock<StateService>();
|
||||
accountService = mock<AccountService>();
|
||||
accountService.activeAccount$ = activeAccountSubject;
|
||||
cryptoService = mock<CryptoService>();
|
||||
organizationUserService = mock<OrganizationUserService>();
|
||||
i18nService = mock<I18nService>();
|
||||
service = new PasswordResetEnrollmentServiceImplementation(
|
||||
organizationApiService,
|
||||
stateService,
|
||||
accountService,
|
||||
cryptoService,
|
||||
organizationUserService,
|
||||
i18nService,
|
||||
@@ -81,7 +87,14 @@ describe("PasswordResetEnrollmentServiceImplementation", () => {
|
||||
};
|
||||
const encryptedKey = { encryptedString: "encryptedString" };
|
||||
organizationApiService.getKeys.mockResolvedValue(orgKeyResponse as any);
|
||||
stateService.getUserId.mockResolvedValue("userId");
|
||||
|
||||
const user1AccountInfo: AccountInfo = {
|
||||
name: "Test User 1",
|
||||
email: "test1@email.com",
|
||||
status: AuthenticationStatus.Unlocked,
|
||||
};
|
||||
activeAccountSubject.next(Object.assign(user1AccountInfo, { id: "userId" as UserId }));
|
||||
|
||||
cryptoService.getUserKey.mockResolvedValue({ key: "key" } as any);
|
||||
cryptoService.rsaEncrypt.mockResolvedValue(encryptedKey as any);
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { firstValueFrom, map } from "rxjs";
|
||||
|
||||
import { OrganizationApiServiceAbstraction } from "../../admin-console/abstractions/organization/organization-api.service.abstraction";
|
||||
import { OrganizationUserService } from "../../admin-console/abstractions/organization-user/organization-user.service";
|
||||
import { OrganizationUserResetPasswordEnrollmentRequest } from "../../admin-console/abstractions/organization-user/requests";
|
||||
import { CryptoService } from "../../platform/abstractions/crypto.service";
|
||||
import { I18nService } from "../../platform/abstractions/i18n.service";
|
||||
import { StateService } from "../../platform/abstractions/state.service";
|
||||
import { Utils } from "../../platform/misc/utils";
|
||||
import { UserKey } from "../../types/key";
|
||||
import { AccountService } from "../abstractions/account.service";
|
||||
import { PasswordResetEnrollmentServiceAbstraction } from "../abstractions/password-reset-enrollment.service.abstraction";
|
||||
|
||||
export class PasswordResetEnrollmentServiceImplementation
|
||||
@@ -13,7 +15,7 @@ export class PasswordResetEnrollmentServiceImplementation
|
||||
{
|
||||
constructor(
|
||||
protected organizationApiService: OrganizationApiServiceAbstraction,
|
||||
protected stateService: StateService,
|
||||
protected accountService: AccountService,
|
||||
protected cryptoService: CryptoService,
|
||||
protected organizationUserService: OrganizationUserService,
|
||||
protected i18nService: I18nService,
|
||||
@@ -38,7 +40,8 @@ export class PasswordResetEnrollmentServiceImplementation
|
||||
|
||||
const orgPublicKey = Utils.fromB64ToArray(orgKeyResponse.publicKey);
|
||||
|
||||
userId = userId ?? (await this.stateService.getUserId());
|
||||
userId =
|
||||
userId ?? (await firstValueFrom(this.accountService.activeAccount$.pipe(map((a) => a?.id))));
|
||||
userKey = userKey ?? (await this.cryptoService.getUserKey(userId));
|
||||
// RSA Encrypt user's userKey.key with organization public key
|
||||
const encryptedKey = await this.cryptoService.rsaEncrypt(userKey.key, orgPublicKey);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { Utils } from "../../../../platform/misc/utils";
|
||||
|
||||
import { WebAuthnLoginResponseRequest } from "./webauthn-login-response.request";
|
||||
@@ -27,4 +29,8 @@ export class WebAuthnLoginAssertionResponseRequest extends WebAuthnLoginResponse
|
||||
userHandle: Utils.fromBufferToUrlB64(credential.response.userHandle),
|
||||
};
|
||||
}
|
||||
|
||||
static fromJSON(json: Jsonify<WebAuthnLoginAssertionResponseRequest>) {
|
||||
return Object.assign(Object.create(WebAuthnLoginAssertionResponseRequest.prototype), json);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { firstValueFrom, of } from "rxjs";
|
||||
|
||||
import { FakeStateProvider, FakeAccountService, mockAccountServiceWith } from "../../../spec";
|
||||
import { Utils } from "../../platform/misc/utils";
|
||||
import { UserId } from "../../types/guid";
|
||||
|
||||
import { DefaultDomainSettingsService, DomainSettingsService } from "./domain-settings.service";
|
||||
|
||||
describe("DefaultDomainSettingsService", () => {
|
||||
let domainSettingsService: DomainSettingsService;
|
||||
const mockUserId = Utils.newGuid() as UserId;
|
||||
const accountService: FakeAccountService = mockAccountServiceWith(mockUserId);
|
||||
const fakeStateProvider: FakeStateProvider = new FakeStateProvider(accountService);
|
||||
|
||||
const mockEquivalentDomains = [
|
||||
["example.com", "exampleapp.com", "example.co.uk", "ejemplo.es"],
|
||||
["bitwarden.com", "bitwarden.co.uk", "sm-bitwarden.com"],
|
||||
["example.co.uk", "exampleapp.co.uk"],
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
domainSettingsService = new DefaultDomainSettingsService(fakeStateProvider);
|
||||
|
||||
jest.spyOn(domainSettingsService, "getUrlEquivalentDomains");
|
||||
domainSettingsService.equivalentDomains$ = of(mockEquivalentDomains);
|
||||
});
|
||||
|
||||
describe("getUrlEquivalentDomains", () => {
|
||||
it("returns all equivalent domains for a URL", async () => {
|
||||
const expected = new Set([
|
||||
"example.com",
|
||||
"exampleapp.com",
|
||||
"example.co.uk",
|
||||
"ejemplo.es",
|
||||
"exampleapp.co.uk",
|
||||
]);
|
||||
|
||||
const actual = await firstValueFrom(
|
||||
domainSettingsService.getUrlEquivalentDomains("example.co.uk"),
|
||||
);
|
||||
|
||||
expect(domainSettingsService.getUrlEquivalentDomains).toHaveBeenCalledWith("example.co.uk");
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
|
||||
it("returns an empty set if there are no equivalent domains", async () => {
|
||||
const actual = await firstValueFrom(domainSettingsService.getUrlEquivalentDomains("asdf"));
|
||||
|
||||
expect(domainSettingsService.getUrlEquivalentDomains).toHaveBeenCalledWith("asdf");
|
||||
expect(actual).toEqual(new Set());
|
||||
});
|
||||
});
|
||||
});
|
||||
97
libs/common/src/autofill/services/domain-settings.service.ts
Normal file
97
libs/common/src/autofill/services/domain-settings.service.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { map, Observable } from "rxjs";
|
||||
|
||||
import {
|
||||
NeverDomains,
|
||||
EquivalentDomains,
|
||||
UriMatchStrategySetting,
|
||||
UriMatchStrategy,
|
||||
} from "../../models/domain/domain-service";
|
||||
import { Utils } from "../../platform/misc/utils";
|
||||
import {
|
||||
DOMAIN_SETTINGS_DISK,
|
||||
ActiveUserState,
|
||||
GlobalState,
|
||||
KeyDefinition,
|
||||
StateProvider,
|
||||
UserKeyDefinition,
|
||||
} from "../../platform/state";
|
||||
|
||||
const NEVER_DOMAINS = new KeyDefinition(DOMAIN_SETTINGS_DISK, "neverDomains", {
|
||||
deserializer: (value: NeverDomains) => value ?? null,
|
||||
});
|
||||
|
||||
const EQUIVALENT_DOMAINS = new UserKeyDefinition(DOMAIN_SETTINGS_DISK, "equivalentDomains", {
|
||||
deserializer: (value: EquivalentDomains) => value ?? null,
|
||||
clearOn: ["logout"],
|
||||
});
|
||||
|
||||
const DEFAULT_URI_MATCH_STRATEGY = new KeyDefinition(
|
||||
DOMAIN_SETTINGS_DISK,
|
||||
"defaultUriMatchStrategy",
|
||||
{
|
||||
deserializer: (value: UriMatchStrategySetting) => value ?? UriMatchStrategy.Domain,
|
||||
},
|
||||
);
|
||||
|
||||
export abstract class DomainSettingsService {
|
||||
neverDomains$: Observable<NeverDomains>;
|
||||
setNeverDomains: (newValue: NeverDomains) => Promise<void>;
|
||||
equivalentDomains$: Observable<EquivalentDomains>;
|
||||
setEquivalentDomains: (newValue: EquivalentDomains) => Promise<void>;
|
||||
defaultUriMatchStrategy$: Observable<UriMatchStrategySetting>;
|
||||
setDefaultUriMatchStrategy: (newValue: UriMatchStrategySetting) => Promise<void>;
|
||||
getUrlEquivalentDomains: (url: string) => Observable<Set<string>>;
|
||||
}
|
||||
|
||||
export class DefaultDomainSettingsService implements DomainSettingsService {
|
||||
private neverDomainsState: GlobalState<NeverDomains>;
|
||||
readonly neverDomains$: Observable<NeverDomains>;
|
||||
|
||||
private equivalentDomainsState: ActiveUserState<EquivalentDomains>;
|
||||
readonly equivalentDomains$: Observable<EquivalentDomains>;
|
||||
|
||||
private defaultUriMatchStrategyState: ActiveUserState<UriMatchStrategySetting>;
|
||||
readonly defaultUriMatchStrategy$: Observable<UriMatchStrategySetting>;
|
||||
|
||||
constructor(private stateProvider: StateProvider) {
|
||||
this.neverDomainsState = this.stateProvider.getGlobal(NEVER_DOMAINS);
|
||||
this.neverDomains$ = this.neverDomainsState.state$.pipe(map((x) => x ?? null));
|
||||
|
||||
this.equivalentDomainsState = this.stateProvider.getActive(EQUIVALENT_DOMAINS);
|
||||
this.equivalentDomains$ = this.equivalentDomainsState.state$.pipe(map((x) => x ?? null));
|
||||
|
||||
this.defaultUriMatchStrategyState = this.stateProvider.getActive(DEFAULT_URI_MATCH_STRATEGY);
|
||||
this.defaultUriMatchStrategy$ = this.defaultUriMatchStrategyState.state$.pipe(
|
||||
map((x) => x ?? UriMatchStrategy.Domain),
|
||||
);
|
||||
}
|
||||
|
||||
async setNeverDomains(newValue: NeverDomains): Promise<void> {
|
||||
await this.neverDomainsState.update(() => newValue);
|
||||
}
|
||||
|
||||
async setEquivalentDomains(newValue: EquivalentDomains): Promise<void> {
|
||||
await this.equivalentDomainsState.update(() => newValue);
|
||||
}
|
||||
|
||||
async setDefaultUriMatchStrategy(newValue: UriMatchStrategySetting): Promise<void> {
|
||||
await this.defaultUriMatchStrategyState.update(() => newValue);
|
||||
}
|
||||
|
||||
getUrlEquivalentDomains(url: string): Observable<Set<string>> {
|
||||
const domains$ = this.equivalentDomains$.pipe(
|
||||
map((equivalentDomains) => {
|
||||
const domain = Utils.getDomain(url);
|
||||
if (domain == null || equivalentDomains == null) {
|
||||
return new Set() as Set<string>;
|
||||
}
|
||||
|
||||
const equivalents = equivalentDomains.filter((ed) => ed.includes(domain)).flat();
|
||||
|
||||
return new Set(equivalents);
|
||||
}),
|
||||
);
|
||||
|
||||
return domains$;
|
||||
}
|
||||
}
|
||||
24
libs/common/src/models/domain/domain-service.ts
Normal file
24
libs/common/src/models/domain/domain-service.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
See full documentation at:
|
||||
https://bitwarden.com/help/uri-match-detection/#match-detection-options
|
||||
|
||||
Domain: "the top-level domain and second-level domain of the URI match the detected resource",
|
||||
Host: "the hostname and (if specified) port of the URI matches the detected resource",
|
||||
StartsWith: "the detected resource starts with the URI, regardless of what follows it",
|
||||
Exact: "the URI matches the detected resource exactly",
|
||||
RegularExpression: "the detected resource matches a specified regular expression",
|
||||
Never: "never offer auto-fill for the item",
|
||||
*/
|
||||
export const UriMatchStrategy = {
|
||||
Domain: 0,
|
||||
Host: 1,
|
||||
StartsWith: 2,
|
||||
Exact: 3,
|
||||
RegularExpression: 4,
|
||||
Never: 5,
|
||||
} as const;
|
||||
|
||||
export type UriMatchStrategySetting = (typeof UriMatchStrategy)[keyof typeof UriMatchStrategy];
|
||||
|
||||
export type NeverDomains = { [id: string]: unknown };
|
||||
export type EquivalentDomains = string[][];
|
||||
@@ -1,5 +1,5 @@
|
||||
import { UriMatchStrategySetting } from "../../models/domain/domain-service";
|
||||
import { EncString } from "../../platform/models/domain/enc-string";
|
||||
import { UriMatchType } from "../../vault/enums";
|
||||
import { LoginUri as LoginUriDomain } from "../../vault/models/domain/login-uri";
|
||||
import { LoginUriView } from "../../vault/models/view/login-uri.view";
|
||||
|
||||
@@ -26,7 +26,7 @@ export class LoginUriExport {
|
||||
|
||||
uri: string;
|
||||
uriChecksum: string | undefined;
|
||||
match: UriMatchType = null;
|
||||
match: UriMatchStrategySetting = null;
|
||||
|
||||
constructor(o?: LoginUriView | LoginUriDomain) {
|
||||
if (o == null) {
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
export abstract class AppIdService {
|
||||
appId$: Observable<string>;
|
||||
anonymousAppId$: Observable<string>;
|
||||
getAppId: () => Promise<string>;
|
||||
getAnonymousAppId: () => Promise<string>;
|
||||
}
|
||||
|
||||
@@ -14,17 +14,12 @@ import { SendData } from "../../tools/send/models/data/send.data";
|
||||
import { SendView } from "../../tools/send/models/view/send.view";
|
||||
import { UserId } from "../../types/guid";
|
||||
import { DeviceKey, MasterKey } from "../../types/key";
|
||||
import { UriMatchType } from "../../vault/enums";
|
||||
import { CipherData } from "../../vault/models/data/cipher.data";
|
||||
import { LocalData } from "../../vault/models/data/local.data";
|
||||
import { CipherView } from "../../vault/models/view/cipher.view";
|
||||
import { KdfType, ThemeType } from "../enums";
|
||||
import { ServerConfigData } from "../models/data/server-config.data";
|
||||
import {
|
||||
Account,
|
||||
AccountDecryptionOptions,
|
||||
AccountSettingsSettings,
|
||||
} from "../models/domain/account";
|
||||
import { Account, AccountDecryptionOptions } from "../models/domain/account";
|
||||
import { EncString } from "../models/domain/enc-string";
|
||||
import { StorageOptions } from "../models/domain/storage-options";
|
||||
import { SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key";
|
||||
@@ -184,8 +179,6 @@ export abstract class StateService<T extends Account = Account> {
|
||||
* @deprecated Do not call this directly, use SendService
|
||||
*/
|
||||
setDecryptedSends: (value: SendView[], options?: StorageOptions) => Promise<void>;
|
||||
getDefaultUriMatch: (options?: StorageOptions) => Promise<UriMatchType>;
|
||||
setDefaultUriMatch: (value: UriMatchType, options?: StorageOptions) => Promise<void>;
|
||||
/**
|
||||
* @deprecated Do not call this, use SettingsService
|
||||
*/
|
||||
@@ -272,8 +265,6 @@ export abstract class StateService<T extends Account = Account> {
|
||||
* @deprecated Do not call this directly, use SendService
|
||||
*/
|
||||
setEncryptedSends: (value: { [id: string]: SendData }, options?: StorageOptions) => Promise<void>;
|
||||
getEquivalentDomains: (options?: StorageOptions) => Promise<string[][]>;
|
||||
setEquivalentDomains: (value: string, options?: StorageOptions) => Promise<void>;
|
||||
getEventCollection: (options?: StorageOptions) => Promise<EventData[]>;
|
||||
setEventCollection: (value: EventData[], options?: StorageOptions) => Promise<void>;
|
||||
getEverBeenUnlocked: (options?: StorageOptions) => Promise<boolean>;
|
||||
@@ -307,8 +298,6 @@ export abstract class StateService<T extends Account = Account> {
|
||||
setMainWindowSize: (value: number, options?: StorageOptions) => Promise<void>;
|
||||
getMinimizeOnCopyToClipboard: (options?: StorageOptions) => Promise<boolean>;
|
||||
setMinimizeOnCopyToClipboard: (value: boolean, options?: StorageOptions) => Promise<void>;
|
||||
getNeverDomains: (options?: StorageOptions) => Promise<{ [id: string]: unknown }>;
|
||||
setNeverDomains: (value: { [id: string]: unknown }, options?: StorageOptions) => Promise<void>;
|
||||
getOpenAtLogin: (options?: StorageOptions) => Promise<boolean>;
|
||||
setOpenAtLogin: (value: boolean, options?: StorageOptions) => Promise<void>;
|
||||
getOrganizationInvitation: (options?: StorageOptions) => Promise<any>;
|
||||
@@ -350,14 +339,6 @@ export abstract class StateService<T extends Account = Account> {
|
||||
setRememberedEmail: (value: string, options?: StorageOptions) => Promise<void>;
|
||||
getSecurityStamp: (options?: StorageOptions) => Promise<string>;
|
||||
setSecurityStamp: (value: string, options?: StorageOptions) => Promise<void>;
|
||||
/**
|
||||
* @deprecated Do not call this directly, use SettingsService
|
||||
*/
|
||||
getSettings: (options?: StorageOptions) => Promise<AccountSettingsSettings>;
|
||||
/**
|
||||
* @deprecated Do not call this directly, use SettingsService
|
||||
*/
|
||||
setSettings: (value: AccountSettingsSettings, options?: StorageOptions) => Promise<void>;
|
||||
getTheme: (options?: StorageOptions) => Promise<ThemeType>;
|
||||
setTheme: (value: ThemeType, options?: StorageOptions) => Promise<void>;
|
||||
getTwoFactorToken: (options?: StorageOptions) => Promise<string>;
|
||||
|
||||
@@ -253,11 +253,10 @@ export class Utils {
|
||||
});
|
||||
}
|
||||
|
||||
static guidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
|
||||
static isGuid(id: string) {
|
||||
return RegExp(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
|
||||
"i",
|
||||
).test(id);
|
||||
return RegExp(Utils.guidRegex, "i").test(id);
|
||||
}
|
||||
|
||||
static getHostname(uriString: string): string {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { KeyConnectorUserDecryptionOption } from "../../../auth/models/domain/us
|
||||
import { TrustedDeviceUserDecryptionOption } from "../../../auth/models/domain/user-decryption-options/trusted-device-user-decryption-option";
|
||||
import { IdentityTokenResponse } from "../../../auth/models/response/identity-token.response";
|
||||
import { EventData } from "../../../models/data/event.data";
|
||||
import { UriMatchStrategySetting } from "../../../models/domain/domain-service";
|
||||
import { GeneratorOptions } from "../../../tools/generator/generator-options";
|
||||
import {
|
||||
GeneratedPasswordHistory,
|
||||
@@ -17,7 +18,6 @@ import { SendData } from "../../../tools/send/models/data/send.data";
|
||||
import { SendView } from "../../../tools/send/models/view/send.view";
|
||||
import { DeepJsonify } from "../../../types/deep-jsonify";
|
||||
import { MasterKey } from "../../../types/key";
|
||||
import { UriMatchType } from "../../../vault/enums";
|
||||
import { CipherData } from "../../../vault/models/data/cipher.data";
|
||||
import { CipherView } from "../../../vault/models/view/cipher.view";
|
||||
import { AddEditCipherInfo } from "../../../vault/types/add-edit-cipher-info";
|
||||
@@ -196,13 +196,12 @@ export class AccountProfile {
|
||||
|
||||
export class AccountSettings {
|
||||
autoConfirmFingerPrints?: boolean;
|
||||
defaultUriMatch?: UriMatchType;
|
||||
defaultUriMatch?: UriMatchStrategySetting;
|
||||
disableGa?: boolean;
|
||||
dontShowCardsCurrentTab?: boolean;
|
||||
dontShowIdentitiesCurrentTab?: boolean;
|
||||
enableAlwaysOnTop?: boolean;
|
||||
enableBiometric?: boolean;
|
||||
equivalentDomains?: any;
|
||||
minimizeOnCopyToClipboard?: boolean;
|
||||
passwordGenerationOptions?: PasswordGeneratorOptions;
|
||||
usernameGenerationOptions?: UsernameGeneratorOptions;
|
||||
@@ -210,7 +209,6 @@ export class AccountSettings {
|
||||
pinKeyEncryptedUserKey?: EncryptedString;
|
||||
pinKeyEncryptedUserKeyEphemeral?: EncryptedString;
|
||||
protectedPin?: string;
|
||||
settings?: AccountSettingsSettings; // TODO: Merge whatever is going on here into the AccountSettings model properly
|
||||
vaultTimeout?: number;
|
||||
vaultTimeoutAction?: string = "lock";
|
||||
serverConfig?: ServerConfigData;
|
||||
@@ -236,10 +234,6 @@ export class AccountSettings {
|
||||
}
|
||||
}
|
||||
|
||||
export type AccountSettingsSettings = {
|
||||
equivalentDomains?: string[][];
|
||||
};
|
||||
|
||||
export class AccountTokens {
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
|
||||
@@ -25,6 +25,5 @@ export class GlobalState {
|
||||
enableBrowserIntegration?: boolean;
|
||||
enableBrowserIntegrationFingerprint?: boolean;
|
||||
enableDuckDuckGoBrowserIntegration?: boolean;
|
||||
neverDomains?: { [id: string]: unknown };
|
||||
deepLinkRedirectUrl?: string;
|
||||
}
|
||||
|
||||
101
libs/common/src/platform/services/app-id.service.spec.ts
Normal file
101
libs/common/src/platform/services/app-id.service.spec.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { FakeGlobalStateProvider } from "../../../spec";
|
||||
import { Utils } from "../misc/utils";
|
||||
|
||||
import { ANONYMOUS_APP_ID_KEY, APP_ID_KEY, AppIdService } from "./app-id.service";
|
||||
|
||||
describe("AppIdService", () => {
|
||||
const globalStateProvider = new FakeGlobalStateProvider();
|
||||
const appIdState = globalStateProvider.getFake(APP_ID_KEY);
|
||||
const anonymousAppIdState = globalStateProvider.getFake(ANONYMOUS_APP_ID_KEY);
|
||||
let sut: AppIdService;
|
||||
|
||||
beforeEach(() => {
|
||||
sut = new AppIdService(globalStateProvider);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("getAppId", () => {
|
||||
it("returns the existing appId when it exists", async () => {
|
||||
appIdState.stateSubject.next("existingAppId");
|
||||
|
||||
const appId = await sut.getAppId();
|
||||
|
||||
expect(appId).toBe("existingAppId");
|
||||
});
|
||||
|
||||
it.each([null, undefined])(
|
||||
"uses the util function to create a new id when it AppId does not exist",
|
||||
async (value) => {
|
||||
appIdState.stateSubject.next(value);
|
||||
const spy = jest.spyOn(Utils, "newGuid");
|
||||
|
||||
await sut.getAppId();
|
||||
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([null, undefined])("returns a new appId when it does not exist", async (value) => {
|
||||
appIdState.stateSubject.next(value);
|
||||
|
||||
const appId = await sut.getAppId();
|
||||
|
||||
expect(appId).toMatch(Utils.guidRegex);
|
||||
});
|
||||
|
||||
it.each([null, undefined])(
|
||||
"stores the new guid when it an existing one is not found",
|
||||
async (value) => {
|
||||
appIdState.stateSubject.next(value);
|
||||
|
||||
const appId = await sut.getAppId();
|
||||
|
||||
expect(appIdState.nextMock).toHaveBeenCalledWith(appId);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("getAnonymousAppId", () => {
|
||||
it("returns the existing appId when it exists", async () => {
|
||||
anonymousAppIdState.stateSubject.next("existingAppId");
|
||||
|
||||
const appId = await sut.getAnonymousAppId();
|
||||
|
||||
expect(appId).toBe("existingAppId");
|
||||
});
|
||||
|
||||
it.each([null, undefined])(
|
||||
"uses the util function to create a new id when it AppId does not exist",
|
||||
async (value) => {
|
||||
anonymousAppIdState.stateSubject.next(value);
|
||||
const spy = jest.spyOn(Utils, "newGuid");
|
||||
|
||||
await sut.getAnonymousAppId();
|
||||
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([null, undefined])("returns a new appId when it does not exist", async (value) => {
|
||||
anonymousAppIdState.stateSubject.next(value);
|
||||
|
||||
const appId = await sut.getAnonymousAppId();
|
||||
|
||||
expect(appId).toMatch(Utils.guidRegex);
|
||||
});
|
||||
|
||||
it.each([null, undefined])(
|
||||
"stores the new guid when it an existing one is not found",
|
||||
async (value) => {
|
||||
anonymousAppIdState.stateSubject.next(value);
|
||||
|
||||
const appId = await sut.getAnonymousAppId();
|
||||
|
||||
expect(anonymousAppIdState.nextMock).toHaveBeenCalledWith(appId);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,31 +1,46 @@
|
||||
import { Observable, filter, firstValueFrom, tap } from "rxjs";
|
||||
|
||||
import { AppIdService as AppIdServiceAbstraction } from "../abstractions/app-id.service";
|
||||
import { AbstractStorageService } from "../abstractions/storage.service";
|
||||
import { HtmlStorageLocation } from "../enums";
|
||||
import { Utils } from "../misc/utils";
|
||||
import { APPLICATION_ID_DISK, GlobalStateProvider, KeyDefinition } from "../state";
|
||||
|
||||
export const APP_ID_KEY = new KeyDefinition(APPLICATION_ID_DISK, "appId", {
|
||||
deserializer: (value: string) => value,
|
||||
});
|
||||
export const ANONYMOUS_APP_ID_KEY = new KeyDefinition(APPLICATION_ID_DISK, "anonymousAppId", {
|
||||
deserializer: (value: string) => value,
|
||||
});
|
||||
|
||||
export class AppIdService implements AppIdServiceAbstraction {
|
||||
constructor(private storageService: AbstractStorageService) {}
|
||||
appId$: Observable<string>;
|
||||
anonymousAppId$: Observable<string>;
|
||||
|
||||
getAppId(): Promise<string> {
|
||||
return this.makeAndGetAppId("appId");
|
||||
constructor(globalStateProvider: GlobalStateProvider) {
|
||||
const appIdState = globalStateProvider.get(APP_ID_KEY);
|
||||
const anonymousAppIdState = globalStateProvider.get(ANONYMOUS_APP_ID_KEY);
|
||||
this.appId$ = appIdState.state$.pipe(
|
||||
tap(async (appId) => {
|
||||
if (!appId) {
|
||||
await appIdState.update(() => Utils.newGuid());
|
||||
}
|
||||
}),
|
||||
filter((appId) => !!appId),
|
||||
);
|
||||
this.anonymousAppId$ = anonymousAppIdState.state$.pipe(
|
||||
tap(async (appId) => {
|
||||
if (!appId) {
|
||||
await anonymousAppIdState.update(() => Utils.newGuid());
|
||||
}
|
||||
}),
|
||||
filter((appId) => !!appId),
|
||||
);
|
||||
}
|
||||
|
||||
getAnonymousAppId(): Promise<string> {
|
||||
return this.makeAndGetAppId("anonymousAppId");
|
||||
async getAppId(): Promise<string> {
|
||||
return await firstValueFrom(this.appId$);
|
||||
}
|
||||
|
||||
private async makeAndGetAppId(key: string) {
|
||||
const existingId = await this.storageService.get<string>(key, {
|
||||
htmlStorageLocation: HtmlStorageLocation.Local,
|
||||
});
|
||||
if (existingId != null) {
|
||||
return existingId;
|
||||
}
|
||||
|
||||
const guid = Utils.newGuid();
|
||||
await this.storageService.save(key, guid, {
|
||||
htmlStorageLocation: HtmlStorageLocation.Local,
|
||||
});
|
||||
return guid;
|
||||
async getAnonymousAppId(): Promise<string> {
|
||||
return await firstValueFrom(this.anonymousAppId$);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ import { SendData } from "../../tools/send/models/data/send.data";
|
||||
import { SendView } from "../../tools/send/models/view/send.view";
|
||||
import { UserId } from "../../types/guid";
|
||||
import { DeviceKey, MasterKey } from "../../types/key";
|
||||
import { UriMatchType } from "../../vault/enums";
|
||||
import { CipherData } from "../../vault/models/data/cipher.data";
|
||||
import { LocalData } from "../../vault/models/data/local.data";
|
||||
import { CipherView } from "../../vault/models/view/cipher.view";
|
||||
@@ -41,7 +40,6 @@ import {
|
||||
AccountData,
|
||||
AccountDecryptionOptions,
|
||||
AccountSettings,
|
||||
AccountSettingsSettings,
|
||||
} from "../models/domain/account";
|
||||
import { EncString } from "../models/domain/enc-string";
|
||||
import { GlobalState } from "../models/domain/global-state";
|
||||
@@ -786,23 +784,6 @@ export class StateService<
|
||||
);
|
||||
}
|
||||
|
||||
async getDefaultUriMatch(options?: StorageOptions): Promise<UriMatchType> {
|
||||
return (
|
||||
await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions()))
|
||||
)?.settings?.defaultUriMatch;
|
||||
}
|
||||
|
||||
async setDefaultUriMatch(value: UriMatchType, options?: StorageOptions): Promise<void> {
|
||||
const account = await this.getAccount(
|
||||
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
|
||||
);
|
||||
account.settings.defaultUriMatch = value;
|
||||
await this.saveAccount(
|
||||
account,
|
||||
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
|
||||
);
|
||||
}
|
||||
|
||||
async getDisableFavicon(options?: StorageOptions): Promise<boolean> {
|
||||
return (
|
||||
(
|
||||
@@ -1304,23 +1285,6 @@ export class StateService<
|
||||
);
|
||||
}
|
||||
|
||||
async getEquivalentDomains(options?: StorageOptions): Promise<string[][]> {
|
||||
return (
|
||||
await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions()))
|
||||
)?.settings?.equivalentDomains;
|
||||
}
|
||||
|
||||
async setEquivalentDomains(value: string, options?: StorageOptions): Promise<void> {
|
||||
const account = await this.getAccount(
|
||||
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
|
||||
);
|
||||
account.settings.equivalentDomains = value;
|
||||
await this.saveAccount(
|
||||
account,
|
||||
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
|
||||
);
|
||||
}
|
||||
|
||||
@withPrototypeForArrayMembers(EventData)
|
||||
async getEventCollection(options?: StorageOptions): Promise<EventData[]> {
|
||||
return (
|
||||
@@ -1580,23 +1544,6 @@ export class StateService<
|
||||
);
|
||||
}
|
||||
|
||||
async getNeverDomains(options?: StorageOptions): Promise<{ [id: string]: unknown }> {
|
||||
return (
|
||||
await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions()))
|
||||
)?.neverDomains;
|
||||
}
|
||||
|
||||
async setNeverDomains(value: { [id: string]: unknown }, options?: StorageOptions): Promise<void> {
|
||||
const globals = await this.getGlobals(
|
||||
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
|
||||
);
|
||||
globals.neverDomains = value;
|
||||
await this.saveGlobals(
|
||||
globals,
|
||||
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
|
||||
);
|
||||
}
|
||||
|
||||
async getOpenAtLogin(options?: StorageOptions): Promise<boolean> {
|
||||
return (
|
||||
(await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions())))
|
||||
@@ -1778,23 +1725,6 @@ export class StateService<
|
||||
);
|
||||
}
|
||||
|
||||
async getSettings(options?: StorageOptions): Promise<AccountSettingsSettings> {
|
||||
return (
|
||||
await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskMemoryOptions()))
|
||||
)?.settings?.settings;
|
||||
}
|
||||
|
||||
async setSettings(value: AccountSettingsSettings, options?: StorageOptions): Promise<void> {
|
||||
const account = await this.getAccount(
|
||||
this.reconcileOptions(options, await this.defaultOnDiskMemoryOptions()),
|
||||
);
|
||||
account.settings.settings = value;
|
||||
await this.saveAccount(
|
||||
account,
|
||||
this.reconcileOptions(options, await this.defaultOnDiskMemoryOptions()),
|
||||
);
|
||||
}
|
||||
|
||||
async getTheme(options?: StorageOptions): Promise<ThemeType> {
|
||||
return (
|
||||
await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()))
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface GlobalState<T> {
|
||||
* @param options.combineLatestWith An observable that you want to combine with the current state for callbacks. Defaults to null
|
||||
* @param options.msTimeout A timeout for how long you are willing to wait for a `combineLatestWith` option to complete. Defaults to 1000ms. Only applies if `combineLatestWith` is set.
|
||||
* @returns A promise that must be awaited before your next action to ensure the update has been written to state.
|
||||
* Resolves to the new state. If `shouldUpdate` returns false, the promise will resolve to the current state.
|
||||
*/
|
||||
update: <TCombine>(
|
||||
configureState: (state: T, dependency: TCombine) => T,
|
||||
|
||||
@@ -27,6 +27,7 @@ export const PROVIDERS_DISK = new StateDefinition("providers", "disk");
|
||||
|
||||
export const ACCOUNT_MEMORY = new StateDefinition("account", "memory");
|
||||
export const SSO_DISK = new StateDefinition("ssoLogin", "disk");
|
||||
export const LOGIN_STRATEGY_MEMORY = new StateDefinition("loginStrategy", "memory");
|
||||
|
||||
// Autofill
|
||||
|
||||
@@ -38,6 +39,8 @@ export const USER_NOTIFICATION_SETTINGS_DISK = new StateDefinition(
|
||||
|
||||
// Billing
|
||||
|
||||
export const DOMAIN_SETTINGS_DISK = new StateDefinition("domainSettings", "disk");
|
||||
|
||||
export const AUTOFILL_SETTINGS_DISK = new StateDefinition("autofillSettings", "disk");
|
||||
export const AUTOFILL_SETTINGS_DISK_LOCAL = new StateDefinition("autofillSettingsLocal", "disk", {
|
||||
web: "disk-local",
|
||||
@@ -52,6 +55,9 @@ export const NEW_WEB_LAYOUT_BANNER_DISK = new StateDefinition("newWebLayoutBanne
|
||||
|
||||
// Platform
|
||||
|
||||
export const APPLICATION_ID_DISK = new StateDefinition("applicationId", "disk", {
|
||||
web: "disk-local",
|
||||
});
|
||||
export const BIOMETRIC_SETTINGS_DISK = new StateDefinition("biometricSettings", "disk");
|
||||
export const CLEAR_EVENT_DISK = new StateDefinition("clearEvent", "disk");
|
||||
export const CRYPTO_DISK = new StateDefinition("crypto", "disk");
|
||||
|
||||
@@ -32,7 +32,8 @@ export interface ActiveUserState<T> extends UserState<T> {
|
||||
* @param options.combineLatestWith An observable that you want to combine with the current state for callbacks. Defaults to null
|
||||
* @param options.msTimeout A timeout for how long you are willing to wait for a `combineLatestWith` option to complete. Defaults to 1000ms. Only applies if `combineLatestWith` is set.
|
||||
|
||||
* @returns The new state
|
||||
* @returns A promise that must be awaited before your next action to ensure the update has been written to state.
|
||||
* Resolves to the new state. If `shouldUpdate` returns false, the promise will resolve to the current state.
|
||||
*/
|
||||
readonly update: <TCombine>(
|
||||
configureState: (state: T, dependencies: TCombine) => T,
|
||||
@@ -50,7 +51,8 @@ export interface SingleUserState<T> extends UserState<T> {
|
||||
* @param options.combineLatestWith An observable that you want to combine with the current state for callbacks. Defaults to null
|
||||
* @param options.msTimeout A timeout for how long you are willing to wait for a `combineLatestWith` option to complete. Defaults to 1000ms. Only applies if `combineLatestWith` is set.
|
||||
|
||||
* @returns The new state
|
||||
* @returns A promise that must be awaited before your next action to ensure the update has been written to state.
|
||||
* Resolves to the new state. If `shouldUpdate` returns false, the promise will resolve to the current state.
|
||||
*/
|
||||
readonly update: <TCombine>(
|
||||
configureState: (state: T, dependencies: TCombine) => T,
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import * as lunr from "lunr";
|
||||
|
||||
import { SearchService as SearchServiceAbstraction } from "../abstractions/search.service";
|
||||
import { UriMatchStrategy } from "../models/domain/domain-service";
|
||||
import { I18nService } from "../platform/abstractions/i18n.service";
|
||||
import { LogService } from "../platform/abstractions/log.service";
|
||||
import { SendView } from "../tools/send/models/view/send.view";
|
||||
import { FieldType, UriMatchType } from "../vault/enums";
|
||||
import { FieldType } from "../vault/enums";
|
||||
import { CipherType } from "../vault/enums/cipher-type";
|
||||
import { CipherView } from "../vault/models/view/cipher.view";
|
||||
|
||||
@@ -288,7 +289,7 @@ export class SearchService implements SearchServiceAbstraction {
|
||||
return;
|
||||
}
|
||||
let uri = u.uri;
|
||||
if (u.match !== UriMatchType.RegularExpression) {
|
||||
if (u.match !== UriMatchStrategy.RegularExpression) {
|
||||
const protocolIndex = uri.indexOf("://");
|
||||
if (protocolIndex > -1) {
|
||||
uri = uri.substr(protocolIndex + 3);
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { BehaviorSubject, firstValueFrom } from "rxjs";
|
||||
|
||||
import { CryptoService } from "../platform/abstractions/crypto.service";
|
||||
import { EncryptService } from "../platform/abstractions/encrypt.service";
|
||||
import { StateService } from "../platform/abstractions/state.service";
|
||||
import { ContainerService } from "../platform/services/container.service";
|
||||
|
||||
import { SettingsService } from "./settings.service";
|
||||
|
||||
describe("SettingsService", () => {
|
||||
let settingsService: SettingsService;
|
||||
|
||||
let cryptoService: MockProxy<CryptoService>;
|
||||
let encryptService: MockProxy<EncryptService>;
|
||||
let stateService: MockProxy<StateService>;
|
||||
let activeAccount: BehaviorSubject<string>;
|
||||
let activeAccountUnlocked: BehaviorSubject<boolean>;
|
||||
|
||||
const mockEquivalentDomains = [
|
||||
["example.com", "exampleapp.com", "example.co.uk", "ejemplo.es"],
|
||||
["bitwarden.com", "bitwarden.co.uk", "sm-bitwarden.com"],
|
||||
["example.co.uk", "exampleapp.co.uk"],
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
cryptoService = mock<CryptoService>();
|
||||
encryptService = mock<EncryptService>();
|
||||
stateService = mock<StateService>();
|
||||
activeAccount = new BehaviorSubject("123");
|
||||
activeAccountUnlocked = new BehaviorSubject(true);
|
||||
|
||||
stateService.getSettings.mockResolvedValue({ equivalentDomains: mockEquivalentDomains });
|
||||
stateService.activeAccount$ = activeAccount;
|
||||
stateService.activeAccountUnlocked$ = activeAccountUnlocked;
|
||||
(window as any).bitwardenContainerService = new ContainerService(cryptoService, encryptService);
|
||||
|
||||
settingsService = new SettingsService(stateService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
activeAccount.complete();
|
||||
activeAccountUnlocked.complete();
|
||||
});
|
||||
|
||||
describe("getEquivalentDomains", () => {
|
||||
it("returns all equivalent domains for a URL", async () => {
|
||||
const actual = settingsService.getEquivalentDomains("example.co.uk");
|
||||
const expected = new Set([
|
||||
"example.com",
|
||||
"exampleapp.com",
|
||||
"example.co.uk",
|
||||
"ejemplo.es",
|
||||
"exampleapp.co.uk",
|
||||
]);
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
|
||||
it("returns an empty set if there are no equivalent domains", () => {
|
||||
const actual = settingsService.getEquivalentDomains("asdf");
|
||||
expect(actual).toEqual(new Set());
|
||||
});
|
||||
});
|
||||
|
||||
it("setEquivalentDomains", async () => {
|
||||
await settingsService.setEquivalentDomains([["test2"], ["domains2"]]);
|
||||
|
||||
expect(stateService.setSettings).toBeCalledTimes(1);
|
||||
|
||||
expect((await firstValueFrom(settingsService.settings$)).equivalentDomains).toEqual([
|
||||
["test2"],
|
||||
["domains2"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("clear", async () => {
|
||||
await settingsService.clear();
|
||||
|
||||
expect(stateService.setSettings).toBeCalledTimes(1);
|
||||
|
||||
expect(await firstValueFrom(settingsService.settings$)).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -3,13 +3,10 @@ import { BehaviorSubject, concatMap } from "rxjs";
|
||||
import { SettingsService as SettingsServiceAbstraction } from "../abstractions/settings.service";
|
||||
import { StateService } from "../platform/abstractions/state.service";
|
||||
import { Utils } from "../platform/misc/utils";
|
||||
import { AccountSettingsSettings } from "../platform/models/domain/account";
|
||||
|
||||
export class SettingsService implements SettingsServiceAbstraction {
|
||||
protected _settings: BehaviorSubject<AccountSettingsSettings> = new BehaviorSubject({});
|
||||
protected _disableFavicon = new BehaviorSubject<boolean>(null);
|
||||
|
||||
settings$ = this._settings.asObservable();
|
||||
disableFavicon$ = this._disableFavicon.asObservable();
|
||||
|
||||
constructor(private stateService: StateService) {
|
||||
@@ -21,50 +18,17 @@ export class SettingsService implements SettingsServiceAbstraction {
|
||||
}
|
||||
|
||||
if (!unlocked) {
|
||||
this._settings.next({});
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await this.stateService.getSettings();
|
||||
const disableFavicon = await this.stateService.getDisableFavicon();
|
||||
|
||||
this._settings.next(data);
|
||||
this._disableFavicon.next(disableFavicon);
|
||||
}),
|
||||
)
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
async setEquivalentDomains(equivalentDomains: string[][]): Promise<void> {
|
||||
const settings = this._settings.getValue() ?? {};
|
||||
|
||||
settings.equivalentDomains = equivalentDomains;
|
||||
|
||||
this._settings.next(settings);
|
||||
await this.stateService.setSettings(settings);
|
||||
}
|
||||
|
||||
getEquivalentDomains(url: string): Set<string> {
|
||||
const domain = Utils.getDomain(url);
|
||||
if (domain == null) {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
const settings = this._settings.getValue();
|
||||
|
||||
let result: string[] = [];
|
||||
|
||||
if (settings?.equivalentDomains != null) {
|
||||
settings.equivalentDomains
|
||||
.filter((ed) => ed.length > 0 && ed.includes(domain))
|
||||
.forEach((ed) => {
|
||||
result = result.concat(ed);
|
||||
});
|
||||
}
|
||||
|
||||
return new Set(result);
|
||||
}
|
||||
|
||||
async setDisableFavicon(value: boolean) {
|
||||
this._disableFavicon.next(value);
|
||||
await this.stateService.setDisableFavicon(value);
|
||||
@@ -73,12 +37,4 @@ export class SettingsService implements SettingsServiceAbstraction {
|
||||
getDisableFavicon(): boolean {
|
||||
return this._disableFavicon.getValue();
|
||||
}
|
||||
|
||||
async clear(userId?: string): Promise<void> {
|
||||
if (userId == null || userId == (await this.stateService.getUserId())) {
|
||||
this._settings.next({});
|
||||
}
|
||||
|
||||
await this.stateService.setSettings(null, { userId: userId });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,9 @@ import { FixPremiumMigrator } from "./migrations/3-fix-premium";
|
||||
import { PolicyMigrator } from "./migrations/30-move-policy-state-to-state-provider";
|
||||
import { EnableContextMenuMigrator } from "./migrations/31-move-enable-context-menu-to-autofill-settings-state-provider";
|
||||
import { PreferredLanguageMigrator } from "./migrations/32-move-preferred-language";
|
||||
import { LocalDataMigrator } from "./migrations/33-move-local-data-to-state-provider";
|
||||
import { AppIdMigrator } from "./migrations/33-move-app-id-to-state-providers";
|
||||
import { DomainSettingsMigrator } from "./migrations/34-move-domain-settings-to-state-providers";
|
||||
import { LocalDataMigrator } from "./migrations/35-move-local-data-to-state-provider";
|
||||
import { RemoveEverBeenUnlockedMigrator } from "./migrations/4-remove-ever-been-unlocked";
|
||||
import { AddKeyTypeToOrgKeysMigrator } from "./migrations/5-add-key-type-to-org-keys";
|
||||
import { RemoveLegacyEtmKeyMigrator } from "./migrations/6-remove-legacy-etm-key";
|
||||
@@ -38,7 +40,7 @@ import { MoveBrowserSettingsToGlobal } from "./migrations/9-move-browser-setting
|
||||
import { MinVersionMigrator } from "./migrations/min-version";
|
||||
|
||||
export const MIN_VERSION = 2;
|
||||
export const CURRENT_VERSION = 33;
|
||||
export const CURRENT_VERSION = 35;
|
||||
export type MinVersion = typeof MIN_VERSION;
|
||||
|
||||
export function createMigrationBuilder() {
|
||||
@@ -74,7 +76,9 @@ export function createMigrationBuilder() {
|
||||
.with(PolicyMigrator, 29, 30)
|
||||
.with(EnableContextMenuMigrator, 30, 31)
|
||||
.with(PreferredLanguageMigrator, 31, 32)
|
||||
.with(LocalDataMigrator, 32, CURRENT_VERSION);
|
||||
.with(AppIdMigrator, 32, 33)
|
||||
.with(DomainSettingsMigrator, 33, 34)
|
||||
.with(LocalDataMigrator, 34, CURRENT_VERSION);
|
||||
}
|
||||
|
||||
export async function currentVersion(
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import { MockProxy, any } from "jest-mock-extended";
|
||||
|
||||
import { MigrationHelper } from "../migration-helper";
|
||||
import { mockMigrationHelper } from "../migration-helper.spec";
|
||||
|
||||
import {
|
||||
ANONYMOUS_APP_ID_KEY,
|
||||
APP_ID_KEY,
|
||||
AppIdMigrator,
|
||||
} from "./33-move-app-id-to-state-providers";
|
||||
|
||||
function exampleJSON() {
|
||||
return {
|
||||
appId: "appId",
|
||||
anonymousAppId: "anonymousAppId",
|
||||
otherStuff: "otherStuff1",
|
||||
};
|
||||
}
|
||||
|
||||
function missingAppIdJSON() {
|
||||
return {
|
||||
anonymousAppId: "anonymousAppId",
|
||||
otherStuff: "otherStuff1",
|
||||
};
|
||||
}
|
||||
|
||||
function missingAnonymousAppIdJSON() {
|
||||
return {
|
||||
appId: "appId",
|
||||
otherStuff: "otherStuff1",
|
||||
};
|
||||
}
|
||||
|
||||
function missingBothJSON() {
|
||||
return {
|
||||
otherStuff: "otherStuff1",
|
||||
};
|
||||
}
|
||||
|
||||
function rollbackJSON() {
|
||||
return {
|
||||
global_applicationId_appId: "appId",
|
||||
global_applicationId_anonymousAppId: "anonymousAppId",
|
||||
otherStuff: "otherStuff1",
|
||||
};
|
||||
}
|
||||
|
||||
describe("AppIdMigrator", () => {
|
||||
let helper: MockProxy<MigrationHelper>;
|
||||
let sut: AppIdMigrator;
|
||||
|
||||
describe("migrate with both ids", () => {
|
||||
beforeEach(() => {
|
||||
helper = mockMigrationHelper(exampleJSON(), 32);
|
||||
sut = new AppIdMigrator(32, 33);
|
||||
});
|
||||
|
||||
it("removes appId", async () => {
|
||||
await sut.migrate(helper);
|
||||
expect(helper.set).toHaveBeenCalledWith("appId", null);
|
||||
});
|
||||
|
||||
it("removes anonymousAppId", async () => {
|
||||
await sut.migrate(helper);
|
||||
expect(helper.set).toHaveBeenCalledWith("anonymousAppId", null);
|
||||
});
|
||||
|
||||
it("sets appId", async () => {
|
||||
await sut.migrate(helper);
|
||||
expect(helper.setToGlobal).toHaveBeenCalledWith(APP_ID_KEY, "appId");
|
||||
});
|
||||
|
||||
it("sets anonymousAppId", async () => {
|
||||
await sut.migrate(helper);
|
||||
expect(helper.setToGlobal).toHaveBeenCalledWith(ANONYMOUS_APP_ID_KEY, "anonymousAppId");
|
||||
});
|
||||
});
|
||||
|
||||
describe("migrate with missing appId", () => {
|
||||
beforeEach(() => {
|
||||
helper = mockMigrationHelper(missingAppIdJSON(), 32);
|
||||
sut = new AppIdMigrator(32, 33);
|
||||
});
|
||||
|
||||
it("does not set appId", async () => {
|
||||
await sut.migrate(helper);
|
||||
expect(helper.setToGlobal).not.toHaveBeenCalledWith(APP_ID_KEY, any());
|
||||
});
|
||||
|
||||
it("removes anonymousAppId", async () => {
|
||||
await sut.migrate(helper);
|
||||
expect(helper.set).toHaveBeenCalledWith("anonymousAppId", null);
|
||||
});
|
||||
|
||||
it("does not set appId", async () => {
|
||||
await sut.migrate(helper);
|
||||
expect(helper.setToGlobal).not.toHaveBeenCalledWith(APP_ID_KEY, any());
|
||||
});
|
||||
|
||||
it("sets anonymousAppId", async () => {
|
||||
await sut.migrate(helper);
|
||||
expect(helper.setToGlobal).toHaveBeenCalledWith(ANONYMOUS_APP_ID_KEY, "anonymousAppId");
|
||||
});
|
||||
});
|
||||
|
||||
describe("migrate with missing anonymousAppId", () => {
|
||||
beforeEach(() => {
|
||||
helper = mockMigrationHelper(missingAnonymousAppIdJSON(), 32);
|
||||
sut = new AppIdMigrator(32, 33);
|
||||
});
|
||||
|
||||
it("sets appId", async () => {
|
||||
await sut.migrate(helper);
|
||||
expect(helper.setToGlobal).toHaveBeenCalledWith(APP_ID_KEY, "appId");
|
||||
});
|
||||
|
||||
it("does not set anonymousAppId", async () => {
|
||||
await sut.migrate(helper);
|
||||
expect(helper.setToGlobal).not.toHaveBeenCalledWith(ANONYMOUS_APP_ID_KEY, any());
|
||||
});
|
||||
|
||||
it("removes appId", async () => {
|
||||
await sut.migrate(helper);
|
||||
expect(helper.set).toHaveBeenCalledWith("appId", null);
|
||||
});
|
||||
|
||||
it("does not remove anonymousAppId", async () => {
|
||||
await sut.migrate(helper);
|
||||
expect(helper.set).not.toHaveBeenCalledWith("anonymousAppId", any());
|
||||
});
|
||||
});
|
||||
|
||||
describe("migrate with missing appId and anonymousAppId", () => {
|
||||
beforeEach(() => {
|
||||
helper = mockMigrationHelper(missingBothJSON(), 32);
|
||||
sut = new AppIdMigrator(32, 33);
|
||||
});
|
||||
|
||||
it("does not set appId", async () => {
|
||||
await sut.migrate(helper);
|
||||
expect(helper.setToGlobal).not.toHaveBeenCalledWith(APP_ID_KEY, any());
|
||||
});
|
||||
|
||||
it("does not set anonymousAppId", async () => {
|
||||
await sut.migrate(helper);
|
||||
expect(helper.setToGlobal).not.toHaveBeenCalledWith(ANONYMOUS_APP_ID_KEY, any());
|
||||
});
|
||||
|
||||
it("does not remove appId", async () => {
|
||||
await sut.migrate(helper);
|
||||
expect(helper.set).not.toHaveBeenCalledWith("appId", any());
|
||||
});
|
||||
|
||||
it("does not remove anonymousAppId", async () => {
|
||||
await sut.migrate(helper);
|
||||
expect(helper.set).not.toHaveBeenCalledWith("anonymousAppId", any());
|
||||
});
|
||||
});
|
||||
|
||||
describe("rollback with both Ids", () => {
|
||||
beforeEach(() => {
|
||||
helper = mockMigrationHelper(rollbackJSON(), 33);
|
||||
sut = new AppIdMigrator(32, 33);
|
||||
});
|
||||
|
||||
it("removes appId", async () => {
|
||||
await sut.rollback(helper);
|
||||
expect(helper.setToGlobal).toHaveBeenCalledWith(APP_ID_KEY, null);
|
||||
});
|
||||
|
||||
it("sets appId", async () => {
|
||||
await sut.rollback(helper);
|
||||
expect(helper.set).toHaveBeenCalledWith("appId", "appId");
|
||||
});
|
||||
|
||||
it("removes anonymousAppId", async () => {
|
||||
await sut.rollback(helper);
|
||||
expect(helper.setToGlobal).toHaveBeenCalledWith(ANONYMOUS_APP_ID_KEY, null);
|
||||
});
|
||||
|
||||
it("sets anonymousAppId", async () => {
|
||||
await sut.rollback(helper);
|
||||
expect(helper.set).toHaveBeenCalledWith("anonymousAppId", "anonymousAppId");
|
||||
});
|
||||
});
|
||||
|
||||
describe("rollback missing both Ids", () => {
|
||||
beforeEach(() => {
|
||||
helper = mockMigrationHelper(missingBothJSON(), 33);
|
||||
sut = new AppIdMigrator(32, 33);
|
||||
});
|
||||
|
||||
it("does not set appId for providers", async () => {
|
||||
await sut.rollback(helper);
|
||||
expect(helper.setToGlobal).not.toHaveBeenCalledWith(APP_ID_KEY, any());
|
||||
});
|
||||
|
||||
it("does not set anonymousAppId for providers", async () => {
|
||||
await sut.rollback(helper);
|
||||
expect(helper.setToGlobal).not.toHaveBeenCalledWith(ANONYMOUS_APP_ID_KEY, any());
|
||||
});
|
||||
|
||||
it("does not revert appId", async () => {
|
||||
await sut.rollback(helper);
|
||||
expect(helper.set).not.toHaveBeenCalledWith("appId", any());
|
||||
});
|
||||
|
||||
it("does not revert anonymousAppId", async () => {
|
||||
await sut.rollback(helper);
|
||||
expect(helper.set).not.toHaveBeenCalledWith("anonymousAppId", any());
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { KeyDefinitionLike, MigrationHelper } from "../migration-helper";
|
||||
import { Migrator } from "../migrator";
|
||||
|
||||
export const APP_ID_STORAGE_KEY = "appId";
|
||||
export const ANONYMOUS_APP_ID_STORAGE_KEY = "anonymousAppId";
|
||||
|
||||
export const APP_ID_KEY: KeyDefinitionLike = {
|
||||
key: APP_ID_STORAGE_KEY,
|
||||
stateDefinition: { name: "applicationId" },
|
||||
};
|
||||
|
||||
export const ANONYMOUS_APP_ID_KEY: KeyDefinitionLike = {
|
||||
key: ANONYMOUS_APP_ID_STORAGE_KEY,
|
||||
stateDefinition: { name: "applicationId" },
|
||||
};
|
||||
|
||||
export class AppIdMigrator extends Migrator<32, 33> {
|
||||
async migrate(helper: MigrationHelper): Promise<void> {
|
||||
const appId = await helper.get<string>(APP_ID_STORAGE_KEY);
|
||||
const anonymousAppId = await helper.get<string>(ANONYMOUS_APP_ID_STORAGE_KEY);
|
||||
|
||||
if (appId != null) {
|
||||
await helper.setToGlobal(APP_ID_KEY, appId);
|
||||
await helper.set(APP_ID_STORAGE_KEY, null);
|
||||
}
|
||||
|
||||
if (anonymousAppId != null) {
|
||||
await helper.setToGlobal(ANONYMOUS_APP_ID_KEY, anonymousAppId);
|
||||
await helper.set(ANONYMOUS_APP_ID_STORAGE_KEY, null);
|
||||
}
|
||||
}
|
||||
|
||||
async rollback(helper: MigrationHelper): Promise<void> {
|
||||
const appId = await helper.getFromGlobal<string>(APP_ID_KEY);
|
||||
const anonymousAppId = await helper.getFromGlobal<string>(ANONYMOUS_APP_ID_KEY);
|
||||
|
||||
if (appId != null) {
|
||||
await helper.set(APP_ID_STORAGE_KEY, appId);
|
||||
await helper.setToGlobal(APP_ID_KEY, null);
|
||||
}
|
||||
if (anonymousAppId != null) {
|
||||
await helper.set(ANONYMOUS_APP_ID_STORAGE_KEY, anonymousAppId);
|
||||
await helper.setToGlobal(ANONYMOUS_APP_ID_KEY, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import { any, MockProxy } from "jest-mock-extended";
|
||||
|
||||
import { StateDefinitionLike, MigrationHelper } from "../migration-helper";
|
||||
import { mockMigrationHelper } from "../migration-helper.spec";
|
||||
|
||||
import { DomainSettingsMigrator } from "./34-move-domain-settings-to-state-providers";
|
||||
|
||||
const mockNeverDomains = { "bitwarden.test": null, locahost: null, "www.example.com": null } as {
|
||||
[key: string]: null;
|
||||
};
|
||||
|
||||
function exampleJSON() {
|
||||
return {
|
||||
global: {
|
||||
otherStuff: "otherStuff1",
|
||||
neverDomains: mockNeverDomains,
|
||||
},
|
||||
authenticatedAccounts: ["user-1", "user-2", "user-3"],
|
||||
"user-1": {
|
||||
settings: {
|
||||
defaultUriMatch: 3,
|
||||
settings: {
|
||||
equivalentDomains: [] as string[][],
|
||||
},
|
||||
otherStuff: "otherStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
},
|
||||
"user-2": {
|
||||
settings: {
|
||||
settings: {
|
||||
equivalentDomains: [["apple.com", "icloud.com"]],
|
||||
},
|
||||
otherStuff: "otherStuff4",
|
||||
},
|
||||
otherStuff: "otherStuff5",
|
||||
},
|
||||
"user-3": {
|
||||
settings: {
|
||||
defaultUriMatch: 1,
|
||||
otherStuff: "otherStuff6",
|
||||
},
|
||||
otherStuff: "otherStuff7",
|
||||
},
|
||||
"user-4": {
|
||||
settings: {
|
||||
otherStuff: "otherStuff8",
|
||||
},
|
||||
otherStuff: "otherStuff9",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function rollbackJSON() {
|
||||
return {
|
||||
global_domainSettings_neverDomains: mockNeverDomains,
|
||||
"user_user-1_domainSettings_defaultUriMatchStrategy": 3,
|
||||
"user_user-1_domainSettings_equivalentDomains": [] as string[][],
|
||||
"user_user-2_domainSettings_equivalentDomains": [["apple.com", "icloud.com"]],
|
||||
"user_user-3_domainSettings_defaultUriMatchStrategy": 1,
|
||||
global: {
|
||||
otherStuff: "otherStuff1",
|
||||
},
|
||||
authenticatedAccounts: ["user-1", "user-2", "user-3"],
|
||||
"user-1": {
|
||||
settings: {
|
||||
otherStuff: "otherStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
},
|
||||
"user-2": {
|
||||
settings: {
|
||||
otherStuff: "otherStuff4",
|
||||
},
|
||||
otherStuff: "otherStuff5",
|
||||
},
|
||||
"user-3": {
|
||||
settings: {
|
||||
otherStuff: "otherStuff6",
|
||||
},
|
||||
otherStuff: "otherStuff7",
|
||||
},
|
||||
"user-4": {
|
||||
settings: {
|
||||
otherStuff: "otherStuff8",
|
||||
},
|
||||
otherStuff: "otherStuff9",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const domainSettingsStateDefinition: {
|
||||
stateDefinition: StateDefinitionLike;
|
||||
} = {
|
||||
stateDefinition: {
|
||||
name: "domainSettings",
|
||||
},
|
||||
};
|
||||
|
||||
describe("DomainSettingsMigrator", () => {
|
||||
let helper: MockProxy<MigrationHelper>;
|
||||
let sut: DomainSettingsMigrator;
|
||||
|
||||
describe("migrate", () => {
|
||||
beforeEach(() => {
|
||||
helper = mockMigrationHelper(exampleJSON(), 33);
|
||||
sut = new DomainSettingsMigrator(33, 34);
|
||||
});
|
||||
|
||||
it("should remove global neverDomains and defaultUriMatch and equivalentDomains settings from all accounts", async () => {
|
||||
await sut.migrate(helper);
|
||||
expect(helper.set).toHaveBeenCalledTimes(4);
|
||||
expect(helper.set).toHaveBeenCalledWith("global", {
|
||||
otherStuff: "otherStuff1",
|
||||
});
|
||||
expect(helper.set).toHaveBeenCalledWith("user-1", {
|
||||
settings: {
|
||||
otherStuff: "otherStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
});
|
||||
expect(helper.set).toHaveBeenCalledWith("user-1", {
|
||||
settings: {
|
||||
otherStuff: "otherStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
});
|
||||
expect(helper.set).toHaveBeenCalledWith("user-2", {
|
||||
settings: {
|
||||
otherStuff: "otherStuff4",
|
||||
},
|
||||
otherStuff: "otherStuff5",
|
||||
});
|
||||
expect(helper.set).toHaveBeenCalledWith("user-3", {
|
||||
settings: {
|
||||
otherStuff: "otherStuff6",
|
||||
},
|
||||
otherStuff: "otherStuff7",
|
||||
});
|
||||
});
|
||||
|
||||
it("should set global neverDomains and defaultUriMatchStrategy and equivalentDomains setting values for each account", async () => {
|
||||
await sut.migrate(helper);
|
||||
|
||||
expect(helper.setToGlobal).toHaveBeenCalledTimes(1);
|
||||
expect(helper.setToGlobal).toHaveBeenCalledWith(
|
||||
{ ...domainSettingsStateDefinition, key: "neverDomains" },
|
||||
mockNeverDomains,
|
||||
);
|
||||
|
||||
expect(helper.setToUser).toHaveBeenCalledTimes(4);
|
||||
expect(helper.setToUser).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
{ ...domainSettingsStateDefinition, key: "defaultUriMatchStrategy" },
|
||||
3,
|
||||
);
|
||||
expect(helper.setToUser).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
{ ...domainSettingsStateDefinition, key: "equivalentDomains" },
|
||||
[],
|
||||
);
|
||||
expect(helper.setToUser).toHaveBeenCalledWith(
|
||||
"user-2",
|
||||
{ ...domainSettingsStateDefinition, key: "equivalentDomains" },
|
||||
[["apple.com", "icloud.com"]],
|
||||
);
|
||||
expect(helper.setToUser).toHaveBeenCalledWith(
|
||||
"user-3",
|
||||
{ ...domainSettingsStateDefinition, key: "defaultUriMatchStrategy" },
|
||||
1,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rollback", () => {
|
||||
beforeEach(() => {
|
||||
helper = mockMigrationHelper(rollbackJSON(), 34);
|
||||
sut = new DomainSettingsMigrator(33, 34);
|
||||
});
|
||||
|
||||
it("should null out new values globally and for each account", async () => {
|
||||
await sut.rollback(helper);
|
||||
|
||||
expect(helper.setToGlobal).toHaveBeenCalledTimes(1);
|
||||
expect(helper.setToGlobal).toHaveBeenCalledWith(
|
||||
{ ...domainSettingsStateDefinition, key: "neverDomains" },
|
||||
null,
|
||||
);
|
||||
|
||||
expect(helper.setToUser).toHaveBeenCalledTimes(4);
|
||||
expect(helper.setToUser).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
{ ...domainSettingsStateDefinition, key: "defaultUriMatchStrategy" },
|
||||
null,
|
||||
);
|
||||
expect(helper.setToUser).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
{ ...domainSettingsStateDefinition, key: "equivalentDomains" },
|
||||
null,
|
||||
);
|
||||
expect(helper.setToUser).toHaveBeenCalledWith(
|
||||
"user-2",
|
||||
{ ...domainSettingsStateDefinition, key: "equivalentDomains" },
|
||||
null,
|
||||
);
|
||||
expect(helper.setToUser).toHaveBeenCalledWith(
|
||||
"user-3",
|
||||
{ ...domainSettingsStateDefinition, key: "defaultUriMatchStrategy" },
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
it("should add explicit value back to accounts", async () => {
|
||||
await sut.rollback(helper);
|
||||
|
||||
expect(helper.set).toHaveBeenCalledTimes(4);
|
||||
expect(helper.set).toHaveBeenCalledWith("global", {
|
||||
neverDomains: mockNeverDomains,
|
||||
otherStuff: "otherStuff1",
|
||||
});
|
||||
expect(helper.set).toHaveBeenCalledWith("user-1", {
|
||||
settings: {
|
||||
defaultUriMatch: 3,
|
||||
settings: {
|
||||
equivalentDomains: [] as string[][],
|
||||
},
|
||||
otherStuff: "otherStuff2",
|
||||
},
|
||||
otherStuff: "otherStuff3",
|
||||
});
|
||||
expect(helper.set).toHaveBeenCalledWith("user-2", {
|
||||
settings: {
|
||||
settings: {
|
||||
equivalentDomains: [["apple.com", "icloud.com"]],
|
||||
},
|
||||
otherStuff: "otherStuff4",
|
||||
},
|
||||
otherStuff: "otherStuff5",
|
||||
});
|
||||
expect(helper.set).toHaveBeenCalledWith("user-3", {
|
||||
settings: {
|
||||
defaultUriMatch: 1,
|
||||
otherStuff: "otherStuff6",
|
||||
},
|
||||
otherStuff: "otherStuff7",
|
||||
});
|
||||
});
|
||||
|
||||
it("should not try to restore values to missing accounts", async () => {
|
||||
await sut.rollback(helper);
|
||||
|
||||
expect(helper.set).not.toHaveBeenCalledWith("user-4", any());
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
import { KeyDefinitionLike, MigrationHelper } from "../migration-helper";
|
||||
import { Migrator } from "../migrator";
|
||||
|
||||
const UriMatchStrategy = {
|
||||
Domain: 0,
|
||||
Host: 1,
|
||||
StartsWith: 2,
|
||||
Exact: 3,
|
||||
RegularExpression: 4,
|
||||
Never: 5,
|
||||
} as const;
|
||||
|
||||
type UriMatchStrategySetting = (typeof UriMatchStrategy)[keyof typeof UriMatchStrategy];
|
||||
|
||||
type ExpectedAccountState = {
|
||||
settings?: {
|
||||
defaultUriMatch?: UriMatchStrategySetting;
|
||||
settings?: {
|
||||
equivalentDomains?: string[][];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type ExpectedGlobalState = {
|
||||
neverDomains?: { [key: string]: null };
|
||||
};
|
||||
|
||||
const defaultUriMatchStrategyDefinition: KeyDefinitionLike = {
|
||||
stateDefinition: {
|
||||
name: "domainSettings",
|
||||
},
|
||||
key: "defaultUriMatchStrategy",
|
||||
};
|
||||
|
||||
const equivalentDomainsDefinition: KeyDefinitionLike = {
|
||||
stateDefinition: {
|
||||
name: "domainSettings",
|
||||
},
|
||||
key: "equivalentDomains",
|
||||
};
|
||||
|
||||
const neverDomainsDefinition: KeyDefinitionLike = {
|
||||
stateDefinition: {
|
||||
name: "domainSettings",
|
||||
},
|
||||
key: "neverDomains",
|
||||
};
|
||||
|
||||
export class DomainSettingsMigrator extends Migrator<33, 34> {
|
||||
async migrate(helper: MigrationHelper): Promise<void> {
|
||||
let updateAccount = false;
|
||||
|
||||
// global state ("neverDomains")
|
||||
const globalState = await helper.get<ExpectedGlobalState>("global");
|
||||
|
||||
if (globalState?.neverDomains != null) {
|
||||
await helper.setToGlobal(neverDomainsDefinition, globalState.neverDomains);
|
||||
|
||||
// delete `neverDomains` from state global
|
||||
delete globalState.neverDomains;
|
||||
|
||||
await helper.set<ExpectedGlobalState>("global", globalState);
|
||||
}
|
||||
|
||||
// account state ("defaultUriMatch" and "settings.equivalentDomains")
|
||||
const accounts = await helper.getAccounts<ExpectedAccountState>();
|
||||
|
||||
await Promise.all([...accounts.map(({ userId, account }) => migrateAccount(userId, account))]);
|
||||
|
||||
// migrate account state
|
||||
async function migrateAccount(userId: string, account: ExpectedAccountState): Promise<void> {
|
||||
const accountSettings = account?.settings;
|
||||
|
||||
if (accountSettings?.defaultUriMatch != undefined) {
|
||||
await helper.setToUser(
|
||||
userId,
|
||||
defaultUriMatchStrategyDefinition,
|
||||
accountSettings.defaultUriMatch,
|
||||
);
|
||||
delete account.settings.defaultUriMatch;
|
||||
|
||||
updateAccount = true;
|
||||
}
|
||||
|
||||
if (accountSettings?.settings?.equivalentDomains != undefined) {
|
||||
await helper.setToUser(
|
||||
userId,
|
||||
equivalentDomainsDefinition,
|
||||
accountSettings.settings.equivalentDomains,
|
||||
);
|
||||
delete account.settings.settings.equivalentDomains;
|
||||
delete account.settings.settings;
|
||||
|
||||
updateAccount = true;
|
||||
}
|
||||
|
||||
if (updateAccount) {
|
||||
// update the state account settings with the migrated values deleted
|
||||
await helper.set(userId, account);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async rollback(helper: MigrationHelper): Promise<void> {
|
||||
let updateAccount = false;
|
||||
|
||||
// global state ("neverDomains")
|
||||
const globalState = (await helper.get<ExpectedGlobalState>("global")) || {};
|
||||
const neverDomains: { [key: string]: null } =
|
||||
await helper.getFromGlobal(neverDomainsDefinition);
|
||||
|
||||
if (neverDomains != null) {
|
||||
await helper.set<ExpectedGlobalState>("global", {
|
||||
...globalState,
|
||||
neverDomains: neverDomains,
|
||||
});
|
||||
|
||||
// remove the global state provider framework key for `neverDomains`
|
||||
await helper.setToGlobal(neverDomainsDefinition, null);
|
||||
}
|
||||
|
||||
// account state ("defaultUriMatchStrategy" and "equivalentDomains")
|
||||
const accounts = await helper.getAccounts<ExpectedAccountState>();
|
||||
|
||||
await Promise.all([...accounts.map(({ userId, account }) => rollbackAccount(userId, account))]);
|
||||
|
||||
// rollback account state
|
||||
async function rollbackAccount(userId: string, account: ExpectedAccountState): Promise<void> {
|
||||
let settings = account?.settings || {};
|
||||
|
||||
const defaultUriMatchStrategy: UriMatchStrategySetting = await helper.getFromUser(
|
||||
userId,
|
||||
defaultUriMatchStrategyDefinition,
|
||||
);
|
||||
|
||||
const equivalentDomains: string[][] = await helper.getFromUser(
|
||||
userId,
|
||||
equivalentDomainsDefinition,
|
||||
);
|
||||
|
||||
// update new settings and remove the account state provider framework keys for the rolled back values
|
||||
if (defaultUriMatchStrategy != null) {
|
||||
settings = { ...settings, defaultUriMatch: defaultUriMatchStrategy };
|
||||
|
||||
await helper.setToUser(userId, defaultUriMatchStrategyDefinition, null);
|
||||
|
||||
updateAccount = true;
|
||||
}
|
||||
|
||||
if (equivalentDomains != null) {
|
||||
settings = { ...settings, settings: { equivalentDomains } };
|
||||
|
||||
await helper.setToUser(userId, equivalentDomainsDefinition, null);
|
||||
|
||||
updateAccount = true;
|
||||
}
|
||||
|
||||
// commit updated settings to state
|
||||
if (updateAccount) {
|
||||
await helper.set(userId, {
|
||||
...account,
|
||||
settings,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { MockProxy } from "jest-mock-extended";
|
||||
import { MigrationHelper } from "../migration-helper";
|
||||
import { mockMigrationHelper } from "../migration-helper.spec";
|
||||
|
||||
import { LocalDataMigrator } from "./33-move-local-data-to-state-provider";
|
||||
import { LocalDataMigrator } from "./35-move-local-data-to-state-provider";
|
||||
|
||||
function exampleJSON() {
|
||||
return {
|
||||
@@ -1,5 +1,5 @@
|
||||
import { UriMatchStrategySetting } from "../../models/domain/domain-service";
|
||||
import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key";
|
||||
import { UriMatchType } from "../enums";
|
||||
import { CipherType } from "../enums/cipher-type";
|
||||
import { CipherData } from "../models/data/cipher.data";
|
||||
import { Cipher } from "../models/domain/cipher";
|
||||
@@ -25,7 +25,7 @@ export abstract class CipherService {
|
||||
getAllDecryptedForUrl: (
|
||||
url: string,
|
||||
includeOtherTypes?: CipherType[],
|
||||
defaultMatch?: UriMatchType,
|
||||
defaultMatch?: UriMatchStrategySetting,
|
||||
) => Promise<CipherView[]>;
|
||||
getAllFromApiForOrganization: (organizationId: string) => Promise<CipherView[]>;
|
||||
/**
|
||||
|
||||
@@ -3,4 +3,3 @@ export * from "./cipher-type";
|
||||
export * from "./field-type.enum";
|
||||
export * from "./linked-id-type.enum";
|
||||
export * from "./secure-note-type.enum";
|
||||
export * from "./uri-match-type.enum";
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
export enum UriMatchType {
|
||||
Domain = 0,
|
||||
Host = 1,
|
||||
StartsWith = 2,
|
||||
Exact = 3,
|
||||
RegularExpression = 4,
|
||||
Never = 5,
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import { UriMatchStrategySetting } from "../../../models/domain/domain-service";
|
||||
import { BaseResponse } from "../../../models/response/base.response";
|
||||
import { UriMatchType } from "../../enums";
|
||||
|
||||
export class LoginUriApi extends BaseResponse {
|
||||
uri: string;
|
||||
uriChecksum: string;
|
||||
match: UriMatchType = null;
|
||||
match: UriMatchStrategySetting = null;
|
||||
|
||||
constructor(data: any = null) {
|
||||
super(data);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { UriMatchType } from "../../enums";
|
||||
import { UriMatchStrategySetting } from "../../../models/domain/domain-service";
|
||||
import { LoginUriApi } from "../api/login-uri.api";
|
||||
|
||||
export class LoginUriData {
|
||||
uri: string;
|
||||
uriChecksum: string;
|
||||
match: UriMatchType = null;
|
||||
match: UriMatchStrategySetting = null;
|
||||
|
||||
constructor(data?: LoginUriApi) {
|
||||
if (data == null) {
|
||||
|
||||
@@ -2,13 +2,14 @@ import { mock } from "jest-mock-extended";
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { makeStaticByteArray, mockEnc, mockFromJson } from "../../../../spec/utils";
|
||||
import { UriMatchStrategy } from "../../../models/domain/domain-service";
|
||||
import { CryptoService } from "../../../platform/abstractions/crypto.service";
|
||||
import { EncryptService } from "../../../platform/abstractions/encrypt.service";
|
||||
import { EncString } from "../../../platform/models/domain/enc-string";
|
||||
import { ContainerService } from "../../../platform/services/container.service";
|
||||
import { InitializerKey } from "../../../platform/services/cryptography/initializer-key";
|
||||
import { CipherService } from "../../abstractions/cipher.service";
|
||||
import { FieldType, SecureNoteType, UriMatchType } from "../../enums";
|
||||
import { FieldType, SecureNoteType } from "../../enums";
|
||||
import { CipherRepromptType } from "../../enums/cipher-reprompt-type";
|
||||
import { CipherType } from "../../enums/cipher-type";
|
||||
import { CipherData } from "../../models/data/cipher.data";
|
||||
@@ -76,7 +77,11 @@ describe("Cipher DTO", () => {
|
||||
key: "EncryptedString",
|
||||
login: {
|
||||
uris: [
|
||||
{ uri: "EncryptedString", uriChecksum: "EncryptedString", match: UriMatchType.Domain },
|
||||
{
|
||||
uri: "EncryptedString",
|
||||
uriChecksum: "EncryptedString",
|
||||
match: UriMatchStrategy.Domain,
|
||||
},
|
||||
],
|
||||
username: "EncryptedString",
|
||||
password: "EncryptedString",
|
||||
|
||||
@@ -2,9 +2,9 @@ import { MockProxy, mock } from "jest-mock-extended";
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { mockEnc, mockFromJson } from "../../../../spec";
|
||||
import { UriMatchStrategy } from "../../../models/domain/domain-service";
|
||||
import { EncryptService } from "../../../platform/abstractions/encrypt.service";
|
||||
import { EncString } from "../../../platform/models/domain/enc-string";
|
||||
import { UriMatchType } from "../../enums";
|
||||
import { LoginUriData } from "../data/login-uri.data";
|
||||
|
||||
import { LoginUri } from "./login-uri";
|
||||
@@ -16,7 +16,7 @@ describe("LoginUri", () => {
|
||||
data = {
|
||||
uri: "encUri",
|
||||
uriChecksum: "encUriChecksum",
|
||||
match: UriMatchType.Domain,
|
||||
match: UriMatchStrategy.Domain,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -48,7 +48,7 @@ describe("LoginUri", () => {
|
||||
|
||||
it("Decrypt", async () => {
|
||||
const loginUri = new LoginUri();
|
||||
loginUri.match = UriMatchType.Exact;
|
||||
loginUri.match = UriMatchStrategy.Exact;
|
||||
loginUri.uri = mockEnc("uri");
|
||||
|
||||
const view = await loginUri.decrypt(null);
|
||||
@@ -103,13 +103,13 @@ describe("LoginUri", () => {
|
||||
const actual = LoginUri.fromJSON({
|
||||
uri: "myUri",
|
||||
uriChecksum: "myUriChecksum",
|
||||
match: UriMatchType.Domain,
|
||||
match: UriMatchStrategy.Domain,
|
||||
} as Jsonify<LoginUri>);
|
||||
|
||||
expect(actual).toEqual({
|
||||
uri: "myUri_fromJSON",
|
||||
uriChecksum: "myUriChecksum_fromJSON",
|
||||
match: UriMatchType.Domain,
|
||||
match: UriMatchStrategy.Domain,
|
||||
});
|
||||
expect(actual).toBeInstanceOf(LoginUri);
|
||||
});
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { UriMatchStrategySetting } from "../../../models/domain/domain-service";
|
||||
import { Utils } from "../../../platform/misc/utils";
|
||||
import Domain from "../../../platform/models/domain/domain-base";
|
||||
import { EncString } from "../../../platform/models/domain/enc-string";
|
||||
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
|
||||
import { UriMatchType } from "../../enums";
|
||||
import { LoginUriData } from "../data/login-uri.data";
|
||||
import { LoginUriView } from "../view/login-uri.view";
|
||||
|
||||
export class LoginUri extends Domain {
|
||||
uri: EncString;
|
||||
uriChecksum: EncString | undefined;
|
||||
match: UriMatchType;
|
||||
match: UriMatchStrategySetting;
|
||||
|
||||
constructor(obj?: LoginUriData) {
|
||||
super();
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { MockProxy, mock } from "jest-mock-extended";
|
||||
|
||||
import { mockEnc, mockFromJson } from "../../../../spec";
|
||||
import { UriMatchStrategy, UriMatchStrategySetting } from "../../../models/domain/domain-service";
|
||||
import { EncryptedString, EncString } from "../../../platform/models/domain/enc-string";
|
||||
import { UriMatchType } from "../../enums";
|
||||
import { LoginData } from "../../models/data/login.data";
|
||||
import { Login } from "../../models/domain/login";
|
||||
import { LoginUri } from "../../models/domain/login-uri";
|
||||
@@ -30,7 +30,7 @@ describe("Login DTO", () => {
|
||||
it("Convert from full LoginData", () => {
|
||||
const fido2CredentialData = initializeFido2Credential(new Fido2CredentialData());
|
||||
const data: LoginData = {
|
||||
uris: [{ uri: "uri", uriChecksum: "checksum", match: UriMatchType.Domain }],
|
||||
uris: [{ uri: "uri", uriChecksum: "checksum", match: UriMatchStrategy.Domain }],
|
||||
username: "username",
|
||||
password: "password",
|
||||
passwordRevisionDate: "2022-01-31T12:00:00.000Z",
|
||||
@@ -82,7 +82,7 @@ describe("Login DTO", () => {
|
||||
totp: "encrypted totp",
|
||||
uris: [
|
||||
{
|
||||
match: null as UriMatchType,
|
||||
match: null as UriMatchStrategySetting,
|
||||
_uri: "decrypted uri",
|
||||
_domain: null as string,
|
||||
_hostname: null as string,
|
||||
@@ -123,7 +123,7 @@ describe("Login DTO", () => {
|
||||
|
||||
it("Converts from LoginData and back", () => {
|
||||
const data: LoginData = {
|
||||
uris: [{ uri: "uri", uriChecksum: "checksum", match: UriMatchType.Domain }],
|
||||
uris: [{ uri: "uri", uriChecksum: "checksum", match: UriMatchStrategy.Domain }],
|
||||
username: "username",
|
||||
password: "password",
|
||||
passwordRevisionDate: "2022-01-31T12:00:00.000Z",
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
import { UriMatchStrategy, UriMatchStrategySetting } from "../../../models/domain/domain-service";
|
||||
import { Utils } from "../../../platform/misc/utils";
|
||||
import { UriMatchType } from "../../enums";
|
||||
|
||||
import { LoginUriView } from "./login-uri.view";
|
||||
|
||||
const testData = [
|
||||
{
|
||||
match: UriMatchType.Host,
|
||||
match: UriMatchStrategy.Host,
|
||||
uri: "http://example.com/login",
|
||||
expected: "http://example.com/login",
|
||||
},
|
||||
{
|
||||
match: UriMatchType.Host,
|
||||
match: UriMatchStrategy.Host,
|
||||
uri: "bitwarden.com",
|
||||
expected: "http://bitwarden.com",
|
||||
},
|
||||
{
|
||||
match: UriMatchType.Host,
|
||||
match: UriMatchStrategy.Host,
|
||||
uri: "bitwarden.de",
|
||||
expected: "http://bitwarden.de",
|
||||
},
|
||||
{
|
||||
match: UriMatchType.Host,
|
||||
match: UriMatchStrategy.Host,
|
||||
uri: "bitwarden.br",
|
||||
expected: "http://bitwarden.br",
|
||||
},
|
||||
@@ -41,7 +41,7 @@ const exampleUris = {
|
||||
describe("LoginUriView", () => {
|
||||
it("isWebsite() given an invalid domain should return false", async () => {
|
||||
const uri = new LoginUriView();
|
||||
Object.assign(uri, { match: UriMatchType.Host, uri: "bit!:_&ward.com" });
|
||||
Object.assign(uri, { match: UriMatchStrategy.Host, uri: "bit!:_&ward.com" });
|
||||
expect(uri.isWebsite).toBe(false);
|
||||
});
|
||||
|
||||
@@ -67,32 +67,32 @@ describe("LoginUriView", () => {
|
||||
|
||||
it(`canLaunch should return false when MatchDetection is set to Regex`, async () => {
|
||||
const uri = new LoginUriView();
|
||||
Object.assign(uri, { match: UriMatchType.RegularExpression, uri: "bitwarden.com" });
|
||||
Object.assign(uri, { match: UriMatchStrategy.RegularExpression, uri: "bitwarden.com" });
|
||||
expect(uri.canLaunch).toBe(false);
|
||||
});
|
||||
|
||||
it(`canLaunch() should return false when the given protocol does not match CanLaunchWhiteList`, async () => {
|
||||
const uri = new LoginUriView();
|
||||
Object.assign(uri, { match: UriMatchType.Host, uri: "someprotocol://bitwarden.com" });
|
||||
Object.assign(uri, { match: UriMatchStrategy.Host, uri: "someprotocol://bitwarden.com" });
|
||||
expect(uri.canLaunch).toBe(false);
|
||||
});
|
||||
|
||||
describe("uri matching", () => {
|
||||
describe("using domain matching", () => {
|
||||
it("matches the same domain", () => {
|
||||
const uri = uriFactory(UriMatchType.Domain, exampleUris.standard);
|
||||
const uri = uriFactory(UriMatchStrategy.Domain, exampleUris.standard);
|
||||
const actual = uri.matchesUri(exampleUris.subdomain, exampleUris.noEquivalentDomains());
|
||||
expect(actual).toBe(true);
|
||||
});
|
||||
|
||||
it("matches equivalent domains", () => {
|
||||
const uri = uriFactory(UriMatchType.Domain, exampleUris.standard);
|
||||
const uri = uriFactory(UriMatchStrategy.Domain, exampleUris.standard);
|
||||
const actual = uri.matchesUri(exampleUris.differentDomain, exampleUris.equivalentDomains());
|
||||
expect(actual).toBe(true);
|
||||
});
|
||||
|
||||
it("does not match a different domain", () => {
|
||||
const uri = uriFactory(UriMatchType.Domain, exampleUris.standard);
|
||||
const uri = uriFactory(UriMatchStrategy.Domain, exampleUris.standard);
|
||||
const actual = uri.matchesUri(
|
||||
exampleUris.differentDomain,
|
||||
exampleUris.noEquivalentDomains(),
|
||||
@@ -103,7 +103,7 @@ describe("LoginUriView", () => {
|
||||
// Actual integration test with the real blacklist, not ideal
|
||||
it("does not match domains that are blacklisted", () => {
|
||||
const googleEquivalentDomains = new Set(["google.com", "script.google.com"]);
|
||||
const uri = uriFactory(UriMatchType.Domain, "google.com");
|
||||
const uri = uriFactory(UriMatchStrategy.Domain, "google.com");
|
||||
|
||||
const actual = uri.matchesUri("script.google.com", googleEquivalentDomains);
|
||||
|
||||
@@ -113,13 +113,13 @@ describe("LoginUriView", () => {
|
||||
|
||||
describe("using host matching", () => {
|
||||
it("matches the same host", () => {
|
||||
const uri = uriFactory(UriMatchType.Host, Utils.getHost(exampleUris.standard));
|
||||
const uri = uriFactory(UriMatchStrategy.Host, Utils.getHost(exampleUris.standard));
|
||||
const actual = uri.matchesUri(exampleUris.standard, exampleUris.noEquivalentDomains());
|
||||
expect(actual).toBe(true);
|
||||
});
|
||||
|
||||
it("does not match a different host", () => {
|
||||
const uri = uriFactory(UriMatchType.Host, Utils.getHost(exampleUris.differentDomain));
|
||||
const uri = uriFactory(UriMatchStrategy.Host, Utils.getHost(exampleUris.differentDomain));
|
||||
const actual = uri.matchesUri(exampleUris.standard, exampleUris.noEquivalentDomains());
|
||||
expect(actual).toBe(false);
|
||||
});
|
||||
@@ -127,13 +127,13 @@ describe("LoginUriView", () => {
|
||||
|
||||
describe("using exact matching", () => {
|
||||
it("matches if both uris are the same", () => {
|
||||
const uri = uriFactory(UriMatchType.Exact, exampleUris.standard);
|
||||
const uri = uriFactory(UriMatchStrategy.Exact, exampleUris.standard);
|
||||
const actual = uri.matchesUri(exampleUris.standard, exampleUris.noEquivalentDomains());
|
||||
expect(actual).toBe(true);
|
||||
});
|
||||
|
||||
it("does not match if the uris are different", () => {
|
||||
const uri = uriFactory(UriMatchType.Exact, exampleUris.standard);
|
||||
const uri = uriFactory(UriMatchStrategy.Exact, exampleUris.standard);
|
||||
const actual = uri.matchesUri(
|
||||
exampleUris.standard + "#",
|
||||
exampleUris.noEquivalentDomains(),
|
||||
@@ -144,7 +144,7 @@ describe("LoginUriView", () => {
|
||||
|
||||
describe("using startsWith matching", () => {
|
||||
it("matches if the target URI starts with the saved URI", () => {
|
||||
const uri = uriFactory(UriMatchType.StartsWith, exampleUris.standard);
|
||||
const uri = uriFactory(UriMatchStrategy.StartsWith, exampleUris.standard);
|
||||
const actual = uri.matchesUri(
|
||||
exampleUris.standard + "#bookmark",
|
||||
exampleUris.noEquivalentDomains(),
|
||||
@@ -153,7 +153,7 @@ describe("LoginUriView", () => {
|
||||
});
|
||||
|
||||
it("does not match if the start of the uri is not the same", () => {
|
||||
const uri = uriFactory(UriMatchType.StartsWith, exampleUris.standard);
|
||||
const uri = uriFactory(UriMatchStrategy.StartsWith, exampleUris.standard);
|
||||
const actual = uri.matchesUri(
|
||||
exampleUris.standard.slice(1),
|
||||
exampleUris.noEquivalentDomains(),
|
||||
@@ -164,13 +164,13 @@ describe("LoginUriView", () => {
|
||||
|
||||
describe("using regular expression matching", () => {
|
||||
it("matches if the regular expression matches", () => {
|
||||
const uri = uriFactory(UriMatchType.RegularExpression, exampleUris.standard);
|
||||
const uri = uriFactory(UriMatchStrategy.RegularExpression, exampleUris.standard);
|
||||
const actual = uri.matchesUri(exampleUris.standardRegex, exampleUris.noEquivalentDomains());
|
||||
expect(actual).toBe(false);
|
||||
});
|
||||
|
||||
it("does not match if the regular expression does not match", () => {
|
||||
const uri = uriFactory(UriMatchType.RegularExpression, exampleUris.standardNotMatching);
|
||||
const uri = uriFactory(UriMatchStrategy.RegularExpression, exampleUris.standardNotMatching);
|
||||
const actual = uri.matchesUri(exampleUris.standardRegex, exampleUris.noEquivalentDomains());
|
||||
expect(actual).toBe(false);
|
||||
});
|
||||
@@ -178,7 +178,7 @@ describe("LoginUriView", () => {
|
||||
|
||||
describe("using never matching", () => {
|
||||
it("does not match even if uris are identical", () => {
|
||||
const uri = uriFactory(UriMatchType.Never, exampleUris.standard);
|
||||
const uri = uriFactory(UriMatchStrategy.Never, exampleUris.standard);
|
||||
const actual = uri.matchesUri(exampleUris.standard, exampleUris.noEquivalentDomains());
|
||||
expect(actual).toBe(false);
|
||||
});
|
||||
@@ -186,7 +186,7 @@ describe("LoginUriView", () => {
|
||||
});
|
||||
});
|
||||
|
||||
function uriFactory(match: UriMatchType, uri: string) {
|
||||
function uriFactory(match: UriMatchStrategySetting, uri: string) {
|
||||
const loginUri = new LoginUriView();
|
||||
loginUri.match = match;
|
||||
loginUri.uri = uri;
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { UriMatchStrategy, UriMatchStrategySetting } from "../../../models/domain/domain-service";
|
||||
import { View } from "../../../models/view/view";
|
||||
import { SafeUrls } from "../../../platform/misc/safe-urls";
|
||||
import { Utils } from "../../../platform/misc/utils";
|
||||
import { UriMatchType } from "../../enums";
|
||||
import { LoginUri } from "../domain/login-uri";
|
||||
|
||||
export class LoginUriView implements View {
|
||||
match: UriMatchType = null;
|
||||
match: UriMatchStrategySetting = null;
|
||||
|
||||
private _uri: string = null;
|
||||
private _domain: string = null;
|
||||
@@ -44,7 +44,7 @@ export class LoginUriView implements View {
|
||||
}
|
||||
|
||||
get hostname(): string {
|
||||
if (this.match === UriMatchType.RegularExpression) {
|
||||
if (this.match === UriMatchStrategy.RegularExpression) {
|
||||
return null;
|
||||
}
|
||||
if (this._hostname == null && this.uri != null) {
|
||||
@@ -58,7 +58,7 @@ export class LoginUriView implements View {
|
||||
}
|
||||
|
||||
get host(): string {
|
||||
if (this.match === UriMatchType.RegularExpression) {
|
||||
if (this.match === UriMatchStrategy.RegularExpression) {
|
||||
return null;
|
||||
}
|
||||
if (this._host == null && this.uri != null) {
|
||||
@@ -92,7 +92,7 @@ export class LoginUriView implements View {
|
||||
if (this._canLaunch != null) {
|
||||
return this._canLaunch;
|
||||
}
|
||||
if (this.uri != null && this.match !== UriMatchType.RegularExpression) {
|
||||
if (this.uri != null && this.match !== UriMatchStrategy.RegularExpression) {
|
||||
this._canLaunch = SafeUrls.canLaunch(this.launchUri);
|
||||
} else {
|
||||
this._canLaunch = false;
|
||||
@@ -113,30 +113,30 @@ export class LoginUriView implements View {
|
||||
matchesUri(
|
||||
targetUri: string,
|
||||
equivalentDomains: Set<string>,
|
||||
defaultUriMatch: UriMatchType = null,
|
||||
defaultUriMatch: UriMatchStrategySetting = null,
|
||||
): boolean {
|
||||
if (!this.uri || !targetUri) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let matchType = this.match ?? defaultUriMatch;
|
||||
matchType ??= UriMatchType.Domain;
|
||||
matchType ??= UriMatchStrategy.Domain;
|
||||
|
||||
const targetDomain = Utils.getDomain(targetUri);
|
||||
const matchDomains = equivalentDomains.add(targetDomain);
|
||||
|
||||
switch (matchType) {
|
||||
case UriMatchType.Domain:
|
||||
case UriMatchStrategy.Domain:
|
||||
return this.matchesDomain(targetUri, matchDomains);
|
||||
case UriMatchType.Host: {
|
||||
case UriMatchStrategy.Host: {
|
||||
const urlHost = Utils.getHost(targetUri);
|
||||
return urlHost != null && urlHost === Utils.getHost(this.uri);
|
||||
}
|
||||
case UriMatchType.Exact:
|
||||
case UriMatchStrategy.Exact:
|
||||
return targetUri === this.uri;
|
||||
case UriMatchType.StartsWith:
|
||||
case UriMatchStrategy.StartsWith:
|
||||
return targetUri.startsWith(this.uri);
|
||||
case UriMatchType.RegularExpression:
|
||||
case UriMatchStrategy.RegularExpression:
|
||||
try {
|
||||
const regex = new RegExp(this.uri, "i");
|
||||
return regex.test(targetUri);
|
||||
@@ -144,7 +144,7 @@ export class LoginUriView implements View {
|
||||
// Invalid regex
|
||||
return false;
|
||||
}
|
||||
case UriMatchType.Never:
|
||||
case UriMatchStrategy.Never:
|
||||
return false;
|
||||
default:
|
||||
break;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { UriMatchStrategySetting } from "../../../models/domain/domain-service";
|
||||
import { Utils } from "../../../platform/misc/utils";
|
||||
import { DeepJsonify } from "../../../types/deep-jsonify";
|
||||
import { LoginLinkedId as LinkedId, UriMatchType } from "../../enums";
|
||||
import { LoginLinkedId as LinkedId } from "../../enums";
|
||||
import { linkedFieldOption } from "../../linked-field-option.decorator";
|
||||
import { Login } from "../domain/login";
|
||||
|
||||
@@ -71,7 +72,7 @@ export class LoginView extends ItemView {
|
||||
matchesUri(
|
||||
targetUri: string,
|
||||
equivalentDomains: Set<string>,
|
||||
defaultUriMatch: UriMatchType = null,
|
||||
defaultUriMatch: UriMatchStrategySetting = null,
|
||||
): boolean {
|
||||
if (this.uris == null) {
|
||||
return false;
|
||||
|
||||
@@ -6,8 +6,9 @@ import { FakeStateProvider } from "../../../spec/fake-state-provider";
|
||||
import { makeStaticByteArray } from "../../../spec/utils";
|
||||
import { ApiService } from "../../abstractions/api.service";
|
||||
import { SearchService } from "../../abstractions/search.service";
|
||||
import { SettingsService } from "../../abstractions/settings.service";
|
||||
import { AutofillSettingsService } from "../../autofill/services/autofill-settings.service";
|
||||
import { DomainSettingsService } from "../../autofill/services/domain-settings.service";
|
||||
import { UriMatchStrategy } from "../../models/domain/domain-service";
|
||||
import { ConfigServiceAbstraction } from "../../platform/abstractions/config/config.service.abstraction";
|
||||
import { CryptoService } from "../../platform/abstractions/crypto.service";
|
||||
import { EncryptService } from "../../platform/abstractions/encrypt.service";
|
||||
@@ -21,7 +22,7 @@ import { ContainerService } from "../../platform/services/container.service";
|
||||
import { UserId } from "../../types/guid";
|
||||
import { CipherKey, OrgKey } from "../../types/key";
|
||||
import { CipherFileUploadService } from "../abstractions/file-upload/cipher-file-upload.service";
|
||||
import { UriMatchType, FieldType } from "../enums";
|
||||
import { FieldType } from "../enums";
|
||||
import { CipherRepromptType } from "../enums/cipher-reprompt-type";
|
||||
import { CipherType } from "../enums/cipher-type";
|
||||
import { CipherData } from "../models/data/cipher.data";
|
||||
@@ -57,7 +58,9 @@ const cipherData: CipherData = {
|
||||
key: "EncKey",
|
||||
reprompt: CipherRepromptType.None,
|
||||
login: {
|
||||
uris: [{ uri: "EncryptedString", uriChecksum: "EncryptedString", match: UriMatchType.Domain }],
|
||||
uris: [
|
||||
{ uri: "EncryptedString", uriChecksum: "EncryptedString", match: UriMatchStrategy.Domain },
|
||||
],
|
||||
username: "EncryptedString",
|
||||
password: "EncryptedString",
|
||||
passwordRevisionDate: "2022-01-31T12:00:00.000Z",
|
||||
@@ -105,7 +108,7 @@ describe("Cipher Service", () => {
|
||||
const cryptoService = mock<CryptoService>();
|
||||
const stateService = mock<StateService>();
|
||||
const autofillSettingsService = mock<AutofillSettingsService>();
|
||||
const settingsService = mock<SettingsService>();
|
||||
const domainSettingsService = mock<DomainSettingsService>();
|
||||
const apiService = mock<ApiService>();
|
||||
const cipherFileUploadService = mock<CipherFileUploadService>();
|
||||
const i18nService = mock<I18nService>();
|
||||
@@ -126,7 +129,7 @@ describe("Cipher Service", () => {
|
||||
|
||||
cipherService = new CipherService(
|
||||
cryptoService,
|
||||
settingsService,
|
||||
domainSettingsService,
|
||||
apiService,
|
||||
i18nService,
|
||||
searchService,
|
||||
@@ -286,7 +289,7 @@ describe("Cipher Service", () => {
|
||||
it("should add a uri hash to login uris", async () => {
|
||||
encryptService.hash.mockImplementation((value) => Promise.resolve(`${value} hash`));
|
||||
cipherView.login.uris = [
|
||||
{ uri: "uri", match: UriMatchType.RegularExpression } as LoginUriView,
|
||||
{ uri: "uri", match: UriMatchStrategy.RegularExpression } as LoginUriView,
|
||||
];
|
||||
|
||||
const domain = await cipherService.encrypt(cipherView);
|
||||
@@ -295,7 +298,7 @@ describe("Cipher Service", () => {
|
||||
{
|
||||
uri: new EncString("uri has been encrypted"),
|
||||
uriChecksum: new EncString("uri hash has been encrypted"),
|
||||
match: UriMatchType.RegularExpression,
|
||||
match: UriMatchStrategy.RegularExpression,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -4,8 +4,9 @@ import { Jsonify } from "type-fest";
|
||||
|
||||
import { ApiService } from "../../abstractions/api.service";
|
||||
import { SearchService } from "../../abstractions/search.service";
|
||||
import { SettingsService } from "../../abstractions/settings.service";
|
||||
import { AutofillSettingsServiceAbstraction } from "../../autofill/services/autofill-settings.service";
|
||||
import { DomainSettingsService } from "../../autofill/services/domain-settings.service";
|
||||
import { UriMatchStrategySetting } from "../../models/domain/domain-service";
|
||||
import { ErrorResponse } from "../../models/response/error.response";
|
||||
import { ListResponse } from "../../models/response/list.response";
|
||||
import { View } from "../../models/view/view";
|
||||
@@ -32,7 +33,7 @@ import { CipherId } from "../../types/guid";
|
||||
import { UserKey, OrgKey } from "../../types/key";
|
||||
import { CipherService as CipherServiceAbstraction } from "../abstractions/cipher.service";
|
||||
import { CipherFileUploadService } from "../abstractions/file-upload/cipher-file-upload.service";
|
||||
import { FieldType, UriMatchType } from "../enums";
|
||||
import { FieldType } from "../enums";
|
||||
import { CipherType } from "../enums/cipher-type";
|
||||
import { CipherData } from "../models/data/cipher.data";
|
||||
import { LocalData } from "../models/data/local.data";
|
||||
@@ -94,7 +95,7 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
|
||||
constructor(
|
||||
private cryptoService: CryptoService,
|
||||
private settingsService: SettingsService,
|
||||
private domainSettingsService: DomainSettingsService,
|
||||
private apiService: ApiService,
|
||||
private i18nService: I18nService,
|
||||
private searchService: SearchService,
|
||||
@@ -410,15 +411,17 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
async getAllDecryptedForUrl(
|
||||
url: string,
|
||||
includeOtherTypes?: CipherType[],
|
||||
defaultMatch: UriMatchType = null,
|
||||
defaultMatch: UriMatchStrategySetting = null,
|
||||
): Promise<CipherView[]> {
|
||||
if (url == null && includeOtherTypes == null) {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
const equivalentDomains = this.settingsService.getEquivalentDomains(url);
|
||||
const equivalentDomains = await firstValueFrom(
|
||||
this.domainSettingsService.getUrlEquivalentDomains(url),
|
||||
);
|
||||
const ciphers = await this.getAllDecrypted();
|
||||
defaultMatch ??= await this.stateService.getDefaultUriMatch();
|
||||
defaultMatch ??= await firstValueFrom(this.domainSettingsService.defaultUriMatchStrategy$);
|
||||
|
||||
return ciphers.filter((cipher) => {
|
||||
const cipherIsLogin = cipher.type === CipherType.Login && cipher.login !== null;
|
||||
@@ -564,12 +567,12 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
return;
|
||||
}
|
||||
|
||||
let domains = await this.stateService.getNeverDomains();
|
||||
let domains = await firstValueFrom(this.domainSettingsService.neverDomains$);
|
||||
if (!domains) {
|
||||
domains = {};
|
||||
}
|
||||
domains[domain] = null;
|
||||
await this.stateService.setNeverDomains(domains);
|
||||
await this.domainSettingsService.setNeverDomains(domains);
|
||||
}
|
||||
|
||||
async createWithServer(cipher: Cipher, orgAdmin?: boolean): Promise<any> {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { of } from "rxjs";
|
||||
|
||||
import { AuthService } from "../../../auth/abstractions/auth.service";
|
||||
import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
|
||||
import { DomainSettingsService } from "../../../autofill/services/domain-settings.service";
|
||||
import { ConfigServiceAbstraction } from "../../../platform/abstractions/config/config.service.abstraction";
|
||||
import { StateService } from "../../../platform/abstractions/state.service";
|
||||
import { Utils } from "../../../platform/misc/utils";
|
||||
@@ -34,6 +35,7 @@ describe("FidoAuthenticatorService", () => {
|
||||
let authService!: MockProxy<AuthService>;
|
||||
let stateService!: MockProxy<StateService>;
|
||||
let vaultSettingsService: MockProxy<VaultSettingsService>;
|
||||
let domainSettingsService: MockProxy<DomainSettingsService>;
|
||||
let client!: Fido2ClientService;
|
||||
let tab!: chrome.tabs.Tab;
|
||||
|
||||
@@ -43,6 +45,7 @@ describe("FidoAuthenticatorService", () => {
|
||||
authService = mock<AuthService>();
|
||||
stateService = mock<StateService>();
|
||||
vaultSettingsService = mock<VaultSettingsService>();
|
||||
domainSettingsService = mock<DomainSettingsService>();
|
||||
|
||||
client = new Fido2ClientService(
|
||||
authenticator,
|
||||
@@ -50,9 +53,11 @@ describe("FidoAuthenticatorService", () => {
|
||||
authService,
|
||||
stateService,
|
||||
vaultSettingsService,
|
||||
domainSettingsService,
|
||||
);
|
||||
configService.serverConfig$ = of({ environment: { vault: VaultUrl } } as any);
|
||||
vaultSettingsService.enablePasskeys$ = of(true);
|
||||
domainSettingsService.neverDomains$ = of({});
|
||||
authService.getAuthStatus.mockResolvedValue(AuthenticationStatus.Unlocked);
|
||||
tab = { id: 123, windowId: 456 } as chrome.tabs.Tab;
|
||||
});
|
||||
@@ -130,7 +135,7 @@ describe("FidoAuthenticatorService", () => {
|
||||
origin: "https://bitwarden.com",
|
||||
rp: { id: "bitwarden.com", name: "Bitwarden" },
|
||||
});
|
||||
stateService.getNeverDomains.mockResolvedValue({ "bitwarden.com": null });
|
||||
domainSettingsService.neverDomains$ = of({ "bitwarden.com": null });
|
||||
|
||||
const result = async () => await client.createCredential(params, tab);
|
||||
|
||||
@@ -376,7 +381,8 @@ describe("FidoAuthenticatorService", () => {
|
||||
const params = createParams({
|
||||
origin: "https://bitwarden.com",
|
||||
});
|
||||
stateService.getNeverDomains.mockResolvedValue({ "bitwarden.com": null });
|
||||
|
||||
domainSettingsService.neverDomains$ = of({ "bitwarden.com": null });
|
||||
|
||||
const result = async () => await client.assertCredential(params, tab);
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { parse } from "tldts";
|
||||
|
||||
import { AuthService } from "../../../auth/abstractions/auth.service";
|
||||
import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
|
||||
import { DomainSettingsService } from "../../../autofill/services/domain-settings.service";
|
||||
import { ConfigServiceAbstraction } from "../../../platform/abstractions/config/config.service.abstraction";
|
||||
import { LogService } from "../../../platform/abstractions/log.service";
|
||||
import { StateService } from "../../../platform/abstractions/state.service";
|
||||
@@ -44,6 +45,7 @@ export class Fido2ClientService implements Fido2ClientServiceAbstraction {
|
||||
private authService: AuthService,
|
||||
private stateService: StateService,
|
||||
private vaultSettingsService: VaultSettingsService,
|
||||
private domainSettingsService: DomainSettingsService,
|
||||
private logService?: LogService,
|
||||
) {}
|
||||
|
||||
@@ -52,7 +54,8 @@ export class Fido2ClientService implements Fido2ClientServiceAbstraction {
|
||||
const isUserLoggedIn =
|
||||
(await this.authService.getAuthStatus()) !== AuthenticationStatus.LoggedOut;
|
||||
|
||||
const neverDomains = await this.stateService.getNeverDomains();
|
||||
const neverDomains = await firstValueFrom(this.domainSettingsService.neverDomains$);
|
||||
|
||||
const isExcludedDomain = neverDomains != null && hostname in neverDomains;
|
||||
|
||||
const serverConfig = await firstValueFrom(this.configService.serverConfig$);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { ApiService } from "../../../abstractions/api.service";
|
||||
import { SettingsService } from "../../../abstractions/settings.service";
|
||||
import { InternalOrganizationServiceAbstraction } from "../../../admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { InternalPolicyService } from "../../../admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { ProviderService } from "../../../admin-console/abstractions/provider.service";
|
||||
@@ -10,6 +9,7 @@ import { ProviderData } from "../../../admin-console/models/data/provider.data";
|
||||
import { PolicyResponse } from "../../../admin-console/models/response/policy.response";
|
||||
import { KeyConnectorService } from "../../../auth/abstractions/key-connector.service";
|
||||
import { ForceSetPasswordReason } from "../../../auth/models/domain/force-set-password-reason";
|
||||
import { DomainSettingsService } from "../../../autofill/services/domain-settings.service";
|
||||
import { DomainsResponse } from "../../../models/response/domains.response";
|
||||
import {
|
||||
SyncCipherNotification,
|
||||
@@ -44,7 +44,7 @@ export class SyncService implements SyncServiceAbstraction {
|
||||
|
||||
constructor(
|
||||
private apiService: ApiService,
|
||||
private settingsService: SettingsService,
|
||||
private domainSettingsService: DomainSettingsService,
|
||||
private folderService: InternalFolderService,
|
||||
private cipherService: CipherService,
|
||||
private cryptoService: CryptoService,
|
||||
@@ -457,7 +457,7 @@ export class SyncService implements SyncServiceAbstraction {
|
||||
});
|
||||
}
|
||||
|
||||
return this.settingsService.setEquivalentDomains(eqDomains);
|
||||
return this.domainSettingsService.setEquivalentDomains(eqDomains);
|
||||
}
|
||||
|
||||
private async syncPolicies(response: PolicyResponse[]) {
|
||||
|
||||
Reference in New Issue
Block a user