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

[PM-5499] auth request service migrations (#8597)

* move auth request storage to service

* create migrations for auth requests

* fix tests

* fix browser

* fix login strategy

* update migration

* use correct test descriptions in migration
This commit is contained in:
Jake Fink
2024-04-15 12:34:30 -04:00
committed by GitHub
parent d0bcc75721
commit 576431d29e
26 changed files with 503 additions and 120 deletions

View File

@@ -1,11 +1,7 @@
import { Jsonify } from "type-fest";
import { Utils } from "../../../platform/misc/utils";
// TODO: Tech Debt: potentially create a type Storage shape vs using a class here in the future
// type StorageShape {
// id: string;
// privateKey: string;
// }
// so we can get rid of the any type passed into fromJSON and coming out of ToJSON
export class AdminAuthRequestStorable {
id: string;
privateKey: Uint8Array;
@@ -23,7 +19,7 @@ export class AdminAuthRequestStorable {
};
}
static fromJSON(obj: any): AdminAuthRequestStorable {
static fromJSON(obj: Jsonify<AdminAuthRequestStorable>): AdminAuthRequestStorable {
if (obj == null) {
return null;
}

View File

@@ -1,6 +1,5 @@
import { Observable } from "rxjs";
import { AdminAuthRequestStorable } from "../../auth/models/domain/admin-auth-req-storable";
import { KdfConfig } from "../../auth/models/domain/kdf-config";
import { BiometricKey } from "../../auth/types/biometric-key";
import { GeneratorOptions } from "../../tools/generator/generator-options";
@@ -124,11 +123,6 @@ export abstract class StateService<T extends Account = Account> {
setDecryptedPinProtected: (value: EncString, options?: StorageOptions) => Promise<void>;
getDuckDuckGoSharedKey: (options?: StorageOptions) => Promise<string>;
setDuckDuckGoSharedKey: (value: string, options?: StorageOptions) => Promise<void>;
getAdminAuthRequest: (options?: StorageOptions) => Promise<AdminAuthRequestStorable | null>;
setAdminAuthRequest: (
adminAuthRequest: AdminAuthRequestStorable,
options?: StorageOptions,
) => Promise<void>;
getEmail: (options?: StorageOptions) => Promise<string>;
setEmail: (value: string, options?: StorageOptions) => Promise<void>;
getEmailVerified: (options?: StorageOptions) => Promise<boolean>;
@@ -207,7 +201,5 @@ export abstract class StateService<T extends Account = Account> {
setVaultTimeout: (value: number, options?: StorageOptions) => Promise<void>;
getVaultTimeoutAction: (options?: StorageOptions) => Promise<string>;
setVaultTimeoutAction: (value: string, options?: StorageOptions) => Promise<void>;
getApproveLoginRequests: (options?: StorageOptions) => Promise<boolean>;
setApproveLoginRequests: (value: boolean, options?: StorageOptions) => Promise<void>;
nextUpActiveUser: () => Promise<UserId>;
}

View File

@@ -1,6 +1,5 @@
import { Jsonify } from "type-fest";
import { AdminAuthRequestStorable } from "../../../auth/models/domain/admin-auth-req-storable";
import { UriMatchStrategySetting } from "../../../models/domain/domain-service";
import { GeneratorOptions } from "../../../tools/generator/generator-options";
import {
@@ -169,7 +168,6 @@ export class AccountSettings {
protectedPin?: string;
vaultTimeout?: number;
vaultTimeoutAction?: string = "lock";
approveLoginRequests?: boolean;
/** @deprecated July 2023, left for migration purposes*/
pinProtected?: EncryptionPair<string, EncString> = new EncryptionPair<string, EncString>();
@@ -206,7 +204,6 @@ export class Account {
profile?: AccountProfile = new AccountProfile();
settings?: AccountSettings = new AccountSettings();
tokens?: AccountTokens = new AccountTokens();
adminAuthRequest?: Jsonify<AdminAuthRequestStorable> = null;
constructor(init: Partial<Account>) {
Object.assign(this, {
@@ -230,7 +227,6 @@ export class Account {
...new AccountTokens(),
...init?.tokens,
},
adminAuthRequest: init?.adminAuthRequest,
});
}
@@ -245,7 +241,6 @@ export class Account {
profile: AccountProfile.fromJSON(json?.profile),
settings: AccountSettings.fromJSON(json?.settings),
tokens: AccountTokens.fromJSON(json?.tokens),
adminAuthRequest: AdminAuthRequestStorable.fromJSON(json?.adminAuthRequest),
});
}
}

View File

@@ -3,7 +3,6 @@ import { Jsonify, JsonValue } from "type-fest";
import { AccountService } from "../../auth/abstractions/account.service";
import { TokenService } from "../../auth/abstractions/token.service";
import { AdminAuthRequestStorable } from "../../auth/models/domain/admin-auth-req-storable";
import { KdfConfig } from "../../auth/models/domain/kdf-config";
import { BiometricKey } from "../../auth/types/biometric-key";
import { GeneratorOptions } from "../../tools/generator/generator-options";
@@ -548,37 +547,6 @@ export class StateService<
: await this.secureStorageService.save(DDG_SHARED_KEY, value, options);
}
async getAdminAuthRequest(options?: StorageOptions): Promise<AdminAuthRequestStorable | null> {
options = this.reconcileOptions(options, await this.defaultOnDiskLocalOptions());
if (options?.userId == null) {
return null;
}
const account = await this.getAccount(options);
return account?.adminAuthRequest
? AdminAuthRequestStorable.fromJSON(account.adminAuthRequest)
: null;
}
async setAdminAuthRequest(
adminAuthRequest: AdminAuthRequestStorable,
options?: StorageOptions,
): Promise<void> {
options = this.reconcileOptions(options, await this.defaultOnDiskLocalOptions());
if (options?.userId == null) {
return;
}
const account = await this.getAccount(options);
account.adminAuthRequest = adminAuthRequest?.toJSON();
await this.saveAccount(account, options);
}
async getEmail(options?: StorageOptions): Promise<string> {
return (
await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions()))
@@ -1032,24 +1000,6 @@ export class StateService<
);
}
async getApproveLoginRequests(options?: StorageOptions): Promise<boolean> {
const approveLoginRequests = (
await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()))
)?.settings?.approveLoginRequests;
return approveLoginRequests;
}
async setApproveLoginRequests(value: boolean, options?: StorageOptions): Promise<void> {
const account = await this.getAccount(
this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()),
);
account.settings.approveLoginRequests = value;
await this.saveAccount(
account,
this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()),
);
}
protected async getGlobals(options: StorageOptions): Promise<TGlobalState> {
let globals: TGlobalState;
if (this.useMemory(options.storageLocation)) {
@@ -1392,7 +1342,6 @@ export class StateService<
protected resetAccount(account: TAccount) {
const persistentAccountInformation = {
settings: account.settings,
adminAuthRequest: account.adminAuthRequest,
};
return Object.assign(this.createAccount(), persistentAccountInformation);
}

View File

@@ -45,6 +45,9 @@ export const LOGIN_EMAIL_DISK = new StateDefinition("loginEmail", "disk", {
web: "disk-local",
});
export const LOGIN_STRATEGY_MEMORY = new StateDefinition("loginStrategy", "memory");
export const AUTH_REQUEST_DISK_LOCAL = new StateDefinition("authRequestLocal", "disk", {
web: "disk-local",
});
export const SSO_DISK = new StateDefinition("ssoLogin", "disk");
export const TOKEN_DISK = new StateDefinition("token", "disk");
export const TOKEN_DISK_LOCAL = new StateDefinition("tokenDiskLocal", "disk", {

View File

@@ -2,6 +2,7 @@ import * as signalR from "@microsoft/signalr";
import * as signalRMsgPack from "@microsoft/signalr-protocol-msgpack";
import { firstValueFrom } from "rxjs";
import { AuthRequestServiceAbstraction } from "../../../auth/src/common/abstractions";
import { ApiService } from "../abstractions/api.service";
import { NotificationsService as NotificationsServiceAbstraction } from "../abstractions/notifications.service";
import { AuthService } from "../auth/abstractions/auth.service";
@@ -18,6 +19,7 @@ import { EnvironmentService } from "../platform/abstractions/environment.service
import { LogService } from "../platform/abstractions/log.service";
import { MessagingService } from "../platform/abstractions/messaging.service";
import { StateService } from "../platform/abstractions/state.service";
import { UserId } from "../types/guid";
import { SyncService } from "../vault/abstractions/sync/sync.service.abstraction";
export class NotificationsService implements NotificationsServiceAbstraction {
@@ -37,6 +39,7 @@ export class NotificationsService implements NotificationsServiceAbstraction {
private logoutCallback: (expired: boolean) => Promise<void>,
private stateService: StateService,
private authService: AuthService,
private authRequestService: AuthRequestServiceAbstraction,
private messagingService: MessagingService,
) {
this.environmentService.environment$.subscribe(() => {
@@ -199,10 +202,13 @@ export class NotificationsService implements NotificationsServiceAbstraction {
await this.syncService.syncDeleteSend(notification.payload as SyncSendNotification);
break;
case NotificationType.AuthRequest:
if (await this.stateService.getApproveLoginRequests()) {
this.messagingService.send("openLoginApproval", {
notificationId: notification.payload.id,
});
{
const userId = await this.stateService.getUserId();
if (await this.authRequestService.getAcceptAuthRequests(userId as UserId)) {
this.messagingService.send("openLoginApproval", {
notificationId: notification.payload.id,
});
}
}
break;
default:

View File

@@ -52,6 +52,7 @@ import { DeleteInstalledVersion } from "./migrations/52-delete-installed-version
import { DeviceTrustCryptoServiceStateProviderMigrator } from "./migrations/53-migrate-device-trust-crypto-svc-to-state-providers";
import { SendMigrator } from "./migrations/54-move-encrypted-sends";
import { MoveMasterKeyStateToProviderMigrator } from "./migrations/55-move-master-key-state-to-provider";
import { AuthRequestMigrator } from "./migrations/56-move-auth-requests";
import { RemoveLegacyEtmKeyMigrator } from "./migrations/6-remove-legacy-etm-key";
import { MoveBiometricAutoPromptToAccount } from "./migrations/7-move-biometric-auto-prompt-to-account";
import { MoveStateVersionMigrator } from "./migrations/8-move-state-version";
@@ -59,7 +60,7 @@ import { MoveBrowserSettingsToGlobal } from "./migrations/9-move-browser-setting
import { MinVersionMigrator } from "./migrations/min-version";
export const MIN_VERSION = 3;
export const CURRENT_VERSION = 55;
export const CURRENT_VERSION = 56;
export type MinVersion = typeof MIN_VERSION;
@@ -117,7 +118,8 @@ export function createMigrationBuilder() {
.with(DeleteInstalledVersion, 51, 52)
.with(DeviceTrustCryptoServiceStateProviderMigrator, 52, 53)
.with(SendMigrator, 53, 54)
.with(MoveMasterKeyStateToProviderMigrator, 54, CURRENT_VERSION);
.with(MoveMasterKeyStateToProviderMigrator, 54, 55)
.with(AuthRequestMigrator, 55, CURRENT_VERSION);
}
export async function currentVersion(

View File

@@ -0,0 +1,138 @@
import { MockProxy } from "jest-mock-extended";
import { KeyDefinitionLike, MigrationHelper } from "../migration-helper";
import { mockMigrationHelper } from "../migration-helper.spec";
import { AuthRequestMigrator } from "./56-move-auth-requests";
function exampleJSON() {
return {
global: {
otherStuff: "otherStuff1",
},
authenticatedAccounts: ["FirstAccount", "SecondAccount"],
FirstAccount: {
settings: {
otherStuff: "otherStuff2",
approveLoginRequests: true,
},
otherStuff: "otherStuff3",
adminAuthRequest: {
id: "id1",
privateKey: "privateKey1",
},
},
SecondAccount: {
settings: {
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
},
};
}
function rollbackJSON() {
return {
user_FirstAccount_authRequestLocal_adminAuthRequest: {
id: "id1",
privateKey: "privateKey1",
},
user_FirstAccount_authRequestLocal_acceptAuthRequests: true,
global: {
otherStuff: "otherStuff1",
},
authenticatedAccounts: ["FirstAccount", "SecondAccount"],
FirstAccount: {
settings: {
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
},
SecondAccount: {
settings: {
otherStuff: "otherStuff4",
},
otherStuff: "otherStuff5",
},
};
}
const ADMIN_AUTH_REQUEST_KEY: KeyDefinitionLike = {
stateDefinition: {
name: "authRequestLocal",
},
key: "adminAuthRequest",
};
const ACCEPT_AUTH_REQUESTS_KEY: KeyDefinitionLike = {
stateDefinition: {
name: "authRequestLocal",
},
key: "acceptAuthRequests",
};
describe("AuthRequestMigrator", () => {
let helper: MockProxy<MigrationHelper>;
let sut: AuthRequestMigrator;
describe("migrate", () => {
beforeEach(() => {
helper = mockMigrationHelper(exampleJSON(), 55);
sut = new AuthRequestMigrator(55, 56);
});
it("removes the existing adminAuthRequest and approveLoginRequests", async () => {
await sut.migrate(helper);
expect(helper.set).toHaveBeenCalledWith("FirstAccount", {
settings: {
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
});
expect(helper.set).not.toHaveBeenCalledWith("SecondAccount");
});
it("sets the adminAuthRequest and approveLoginRequests under the new key definitions", async () => {
await sut.migrate(helper);
expect(helper.setToUser).toHaveBeenCalledWith("FirstAccount", ADMIN_AUTH_REQUEST_KEY, {
id: "id1",
privateKey: "privateKey1",
});
expect(helper.setToUser).toHaveBeenCalledWith("FirstAccount", ACCEPT_AUTH_REQUESTS_KEY, true);
expect(helper.setToUser).not.toHaveBeenCalledWith("SecondAccount");
});
});
describe("rollback", () => {
beforeEach(() => {
helper = mockMigrationHelper(rollbackJSON(), 56);
sut = new AuthRequestMigrator(55, 56);
});
it("nulls the new adminAuthRequest and acceptAuthRequests values", async () => {
await sut.rollback(helper);
expect(helper.setToUser).toHaveBeenCalledWith("FirstAccount", ADMIN_AUTH_REQUEST_KEY, null);
expect(helper.setToUser).toHaveBeenCalledWith("FirstAccount", ACCEPT_AUTH_REQUESTS_KEY, null);
});
it("sets back the adminAuthRequest and approveLoginRequests under old account object", async () => {
await sut.rollback(helper);
expect(helper.set).toHaveBeenCalledWith("FirstAccount", {
adminAuthRequest: {
id: "id1",
privateKey: "privateKey1",
},
settings: {
otherStuff: "otherStuff2",
approveLoginRequests: true,
},
otherStuff: "otherStuff3",
});
});
});
});

View File

@@ -0,0 +1,104 @@
import { KeyDefinitionLike, MigrationHelper } from "../migration-helper";
import { Migrator } from "../migrator";
type AdminAuthRequestStorable = {
id: string;
privateKey: string;
};
type ExpectedAccountType = {
adminAuthRequest?: AdminAuthRequestStorable;
settings?: {
approveLoginRequests?: boolean;
};
};
const ADMIN_AUTH_REQUEST_KEY: KeyDefinitionLike = {
stateDefinition: {
name: "authRequestLocal",
},
key: "adminAuthRequest",
};
const ACCEPT_AUTH_REQUESTS_KEY: KeyDefinitionLike = {
stateDefinition: {
name: "authRequestLocal",
},
key: "acceptAuthRequests",
};
export class AuthRequestMigrator extends Migrator<55, 56> {
async migrate(helper: MigrationHelper): Promise<void> {
const accounts = await helper.getAccounts<ExpectedAccountType>();
async function migrateAccount(userId: string, account: ExpectedAccountType): Promise<void> {
let updatedAccount = false;
// Migrate admin auth request
const existingAdminAuthRequest = account?.adminAuthRequest;
if (existingAdminAuthRequest != null) {
await helper.setToUser(userId, ADMIN_AUTH_REQUEST_KEY, existingAdminAuthRequest);
delete account.adminAuthRequest;
updatedAccount = true;
}
// Migrate approve login requests
const existingApproveLoginRequests = account?.settings?.approveLoginRequests;
if (existingApproveLoginRequests != null) {
await helper.setToUser(userId, ACCEPT_AUTH_REQUESTS_KEY, existingApproveLoginRequests);
delete account.settings.approveLoginRequests;
updatedAccount = true;
}
if (updatedAccount) {
// Save the migrated account
await helper.set(userId, account);
}
}
await Promise.all([...accounts.map(({ userId, account }) => migrateAccount(userId, account))]);
}
async rollback(helper: MigrationHelper): Promise<void> {
const accounts = await helper.getAccounts<ExpectedAccountType>();
async function rollbackAccount(userId: string, account: ExpectedAccountType): Promise<void> {
let updatedAccount = false;
// Rollback admin auth request
const migratedAdminAuthRequest: AdminAuthRequestStorable = await helper.getFromUser(
userId,
ADMIN_AUTH_REQUEST_KEY,
);
if (migratedAdminAuthRequest != null) {
account.adminAuthRequest = migratedAdminAuthRequest;
updatedAccount = true;
}
await helper.setToUser(userId, ADMIN_AUTH_REQUEST_KEY, null);
// Rollback approve login requests
const migratedAcceptAuthRequest: boolean = await helper.getFromUser(
userId,
ACCEPT_AUTH_REQUESTS_KEY,
);
if (migratedAcceptAuthRequest != null) {
account.settings = Object.assign(account.settings ?? {}, {
approveLoginRequests: migratedAcceptAuthRequest,
});
updatedAccount = true;
}
await helper.setToUser(userId, ACCEPT_AUTH_REQUESTS_KEY, null);
if (updatedAccount) {
await helper.set(userId, account);
}
}
await Promise.all([...accounts.map(({ userId, account }) => rollbackAccount(userId, account))]);
}
}