mirror of
https://github.com/bitwarden/browser
synced 2025-12-16 08:13:42 +00:00
[AC-2302] Extract device approve/deny logic into a service (#8818)
* [AC-2302] Move organization-auth-request.service to bit-common folder * [AC-2302] Rename organization-auth-request.service to organization-auth-request-api.service * [AC-2302] Move logic from component to organization-auth-request.service * [AC-2302] Fix import path in OrganizationAuthRequestService * [AC-2302] Move imports to OrganizationsModule and delete unused CoreOrganizationModule * [AC-2302] Move the call to get userResetPasswordDetails into OrganizationAuthRequestService * [AC-2302] Remove @Injectable() and manually configure dependencies * [AC-2302] Add OrganizationAuthRequestService unit tests first draft * [AC-2302] Refactor device-approvals.component.ts to remove unused imports * [AC-2302] Set up jest on bit-common and add unit tests for OrganizationAuthRequestService * [AC-2302] Add bit-common to jest.config.js * [AC-2302] Update organizations.module.ts to include safeProviders declared in variable * [AC-2302] Remove services and views folders from bit-common * [AC-2302] Define path mapping * Adjust an import path The import path of `PendingAuthRequestView` in `OrganizationAuthRequestApiService` was pointing to the wrong place. I think this file was just recently moved, and the import didn't get updated. * Get paths working * Fix import * Update jest config to use ts-jest adn jsdom * Copy-paste path mappings from bit-web * Remove unnecessary test setup file * Undo unnecessary change * Fix remaining path mappings * Remove Bitwarden License mapping from OSS code * Fix bit-web so it uses its own tsconfig * Fix import path * Remove web-bit entrypoint from OSS tsconfig * Make DeviceApprovalsComponent standalone * Remove organization-auth-request-api.service export * Remove OrganizationsRoutingModule from DeviceApprovalsComponent imports * Remove CoreOrganizationModule from OrganizationsModule imports * Remove NoItemsModule from OrganizationsModule imports * Use ApiService from JslibServicesModule * Update providers in device-approvals.component.ts --------- Co-authored-by: Addison Beck <hello@addisonbeck.com> Co-authored-by: Thomas Rittson <trittson@bitwarden.com> Co-authored-by: Thomas Rittson <31796059+eliykat@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
export class AdminAuthRequestUpdateRequest {
|
||||
/**
|
||||
*
|
||||
* @param requestApproved - Whether the request was approved/denied. If true, the key must be provided.
|
||||
* @param encryptedUserKey The user key that has been encrypted with a device public key if the request was approved.
|
||||
*/
|
||||
constructor(
|
||||
public requestApproved: boolean,
|
||||
public encryptedUserKey?: string,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export class BulkDenyAuthRequestsRequest {
|
||||
private ids: string[];
|
||||
constructor(authRequestIds: string[]) {
|
||||
this.ids = authRequestIds;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./pending-organization-auth-request.response";
|
||||
export * from "./organization-auth-request.service";
|
||||
@@ -0,0 +1,50 @@
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { ListResponse } from "@bitwarden/common/models/response/list.response";
|
||||
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
|
||||
|
||||
import { AdminAuthRequestUpdateRequest } from "./admin-auth-request-update.request";
|
||||
import { BulkDenyAuthRequestsRequest } from "./bulk-deny-auth-requests.request";
|
||||
import { PendingAuthRequestView } from "./pending-auth-request.view";
|
||||
import { PendingOrganizationAuthRequestResponse } from "./pending-organization-auth-request.response";
|
||||
|
||||
export class OrganizationAuthRequestApiService {
|
||||
constructor(private apiService: ApiService) {}
|
||||
|
||||
async listPendingRequests(organizationId: string): Promise<PendingAuthRequestView[]> {
|
||||
const r = await this.apiService.send(
|
||||
"GET",
|
||||
`/organizations/${organizationId}/auth-requests`,
|
||||
null,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
|
||||
const listResponse = new ListResponse(r, PendingOrganizationAuthRequestResponse);
|
||||
|
||||
return listResponse.data.map((ar) => PendingAuthRequestView.fromResponse(ar));
|
||||
}
|
||||
|
||||
async denyPendingRequests(organizationId: string, ...requestIds: string[]): Promise<void> {
|
||||
await this.apiService.send(
|
||||
"POST",
|
||||
`/organizations/${organizationId}/auth-requests/deny`,
|
||||
new BulkDenyAuthRequestsRequest(requestIds),
|
||||
true,
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
async approvePendingRequest(
|
||||
organizationId: string,
|
||||
requestId: string,
|
||||
encryptedKey: EncString,
|
||||
): Promise<void> {
|
||||
await this.apiService.send(
|
||||
"POST",
|
||||
`/organizations/${organizationId}/auth-requests/${requestId}`,
|
||||
new AdminAuthRequestUpdateRequest(true, encryptedKey.encryptedString),
|
||||
true,
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { MockProxy, mock } from "jest-mock-extended";
|
||||
|
||||
import { OrganizationUserService } from "@bitwarden/common/admin-console/abstractions/organization-user/organization-user.service";
|
||||
import { OrganizationUserResetPasswordDetailsResponse } from "@bitwarden/common/admin-console/abstractions/organization-user/responses";
|
||||
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
|
||||
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
|
||||
|
||||
import { OrganizationAuthRequestApiService } from "./organization-auth-request-api.service";
|
||||
import { OrganizationAuthRequestService } from "./organization-auth-request.service";
|
||||
import { PendingAuthRequestView } from "./pending-auth-request.view";
|
||||
|
||||
describe("OrganizationAuthRequestService", () => {
|
||||
let organizationAuthRequestApiService: MockProxy<OrganizationAuthRequestApiService>;
|
||||
let cryptoService: MockProxy<CryptoService>;
|
||||
let organizationUserService: MockProxy<OrganizationUserService>;
|
||||
let organizationAuthRequestService: OrganizationAuthRequestService;
|
||||
|
||||
beforeEach(() => {
|
||||
organizationAuthRequestApiService = mock<OrganizationAuthRequestApiService>();
|
||||
cryptoService = mock<CryptoService>();
|
||||
organizationUserService = mock<OrganizationUserService>();
|
||||
organizationAuthRequestService = new OrganizationAuthRequestService(
|
||||
organizationAuthRequestApiService,
|
||||
cryptoService,
|
||||
organizationUserService,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("listPendingRequests", () => {
|
||||
it("should return a list of pending auth requests", async () => {
|
||||
jest.spyOn(organizationAuthRequestApiService, "listPendingRequests");
|
||||
|
||||
const organizationId = "organizationId";
|
||||
|
||||
const pendingAuthRequest = new PendingAuthRequestView();
|
||||
pendingAuthRequest.id = "requestId1";
|
||||
pendingAuthRequest.userId = "userId1";
|
||||
pendingAuthRequest.organizationUserId = "userId1";
|
||||
pendingAuthRequest.email = "email1";
|
||||
pendingAuthRequest.publicKey = "publicKey1";
|
||||
pendingAuthRequest.requestDeviceIdentifier = "requestDeviceIdentifier1";
|
||||
pendingAuthRequest.requestDeviceType = "requestDeviceType1";
|
||||
pendingAuthRequest.requestIpAddress = "requestIpAddress1";
|
||||
pendingAuthRequest.creationDate = new Date();
|
||||
const mockPendingAuthRequests = [pendingAuthRequest];
|
||||
organizationAuthRequestApiService.listPendingRequests
|
||||
.calledWith(organizationId)
|
||||
.mockResolvedValue(mockPendingAuthRequests);
|
||||
|
||||
const result = await organizationAuthRequestService.listPendingRequests(organizationId);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result).toEqual(mockPendingAuthRequests);
|
||||
expect(organizationAuthRequestApiService.listPendingRequests).toHaveBeenCalledWith(
|
||||
organizationId,
|
||||
);
|
||||
});
|
||||
|
||||
it("should return an empty list", async () => {
|
||||
jest.spyOn(organizationAuthRequestApiService, "listPendingRequests");
|
||||
|
||||
const invalidOrganizationId = "invalidOrganizationId";
|
||||
const result =
|
||||
await organizationAuthRequestService.listPendingRequests("invalidOrganizationId");
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(organizationAuthRequestApiService.listPendingRequests).toHaveBeenCalledWith(
|
||||
invalidOrganizationId,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("denyPendingRequests", () => {
|
||||
it("should deny the specified pending auth requests", async () => {
|
||||
jest.spyOn(organizationAuthRequestApiService, "denyPendingRequests");
|
||||
|
||||
await organizationAuthRequestService.denyPendingRequests(
|
||||
"organizationId",
|
||||
"requestId1",
|
||||
"requestId2",
|
||||
);
|
||||
|
||||
expect(organizationAuthRequestApiService.denyPendingRequests).toHaveBeenCalledWith(
|
||||
"organizationId",
|
||||
"requestId1",
|
||||
"requestId2",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("approvePendingRequest", () => {
|
||||
it("should approve the specified pending auth request", async () => {
|
||||
jest.spyOn(organizationAuthRequestApiService, "approvePendingRequest");
|
||||
|
||||
const organizationId = "organizationId";
|
||||
|
||||
const organizationUserResetPasswordDetailsResponse =
|
||||
new OrganizationUserResetPasswordDetailsResponse({
|
||||
resetPasswordKey: "resetPasswordKey",
|
||||
encryptedPrivateKey: "encryptedPrivateKey",
|
||||
});
|
||||
|
||||
organizationUserService.getOrganizationUserResetPasswordDetails.mockResolvedValue(
|
||||
organizationUserResetPasswordDetailsResponse,
|
||||
);
|
||||
|
||||
const encryptedUserKey = new EncString("encryptedUserKey");
|
||||
cryptoService.rsaDecrypt.mockResolvedValue(new Uint8Array(32));
|
||||
cryptoService.rsaEncrypt.mockResolvedValue(encryptedUserKey);
|
||||
|
||||
const mockPendingAuthRequest = new PendingAuthRequestView();
|
||||
mockPendingAuthRequest.id = "requestId1";
|
||||
mockPendingAuthRequest.organizationUserId = "organizationUserId1";
|
||||
mockPendingAuthRequest.publicKey = "publicKey1";
|
||||
|
||||
await organizationAuthRequestService.approvePendingRequest(
|
||||
organizationId,
|
||||
mockPendingAuthRequest,
|
||||
);
|
||||
|
||||
expect(organizationAuthRequestApiService.approvePendingRequest).toHaveBeenCalledWith(
|
||||
organizationId,
|
||||
mockPendingAuthRequest.id,
|
||||
encryptedUserKey,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { OrganizationUserService } from "@bitwarden/common/admin-console/abstractions/organization-user/organization-user.service";
|
||||
import { OrganizationUserResetPasswordDetailsResponse } from "@bitwarden/common/admin-console/abstractions/organization-user/responses";
|
||||
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
|
||||
import { OrganizationAuthRequestApiService } from "./organization-auth-request-api.service";
|
||||
import { PendingAuthRequestView } from "./pending-auth-request.view";
|
||||
|
||||
export class OrganizationAuthRequestService {
|
||||
constructor(
|
||||
private organizationAuthRequestApiService: OrganizationAuthRequestApiService,
|
||||
private cryptoService: CryptoService,
|
||||
private organizationUserService: OrganizationUserService,
|
||||
) {}
|
||||
|
||||
async listPendingRequests(organizationId: string): Promise<PendingAuthRequestView[]> {
|
||||
return await this.organizationAuthRequestApiService.listPendingRequests(organizationId);
|
||||
}
|
||||
|
||||
async denyPendingRequests(organizationId: string, ...requestIds: string[]): Promise<void> {
|
||||
await this.organizationAuthRequestApiService.denyPendingRequests(organizationId, ...requestIds);
|
||||
}
|
||||
|
||||
async approvePendingRequest(organizationId: string, authRequest: PendingAuthRequestView) {
|
||||
const details = await this.organizationUserService.getOrganizationUserResetPasswordDetails(
|
||||
organizationId,
|
||||
authRequest.organizationUserId,
|
||||
);
|
||||
|
||||
if (details == null || details.resetPasswordKey == null) {
|
||||
throw new Error(
|
||||
"The user must be enrolled in account recovery (password reset) in order for the request to be approved.",
|
||||
);
|
||||
}
|
||||
|
||||
const encryptedKey = await this.getEncryptedUserKey(
|
||||
organizationId,
|
||||
authRequest.publicKey,
|
||||
details,
|
||||
);
|
||||
|
||||
await this.organizationAuthRequestApiService.approvePendingRequest(
|
||||
organizationId,
|
||||
authRequest.id,
|
||||
encryptedKey,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a copy of the user key that has been encrypted with the provided device's public key.
|
||||
* @param organizationId
|
||||
* @param devicePublicKey
|
||||
* @param resetPasswordDetails
|
||||
* @private
|
||||
*/
|
||||
private async getEncryptedUserKey(
|
||||
organizationId: string,
|
||||
devicePublicKey: string,
|
||||
resetPasswordDetails: OrganizationUserResetPasswordDetailsResponse,
|
||||
): Promise<EncString> {
|
||||
const encryptedUserKey = resetPasswordDetails.resetPasswordKey;
|
||||
const encryptedOrgPrivateKey = resetPasswordDetails.encryptedPrivateKey;
|
||||
const devicePubKey = Utils.fromB64ToArray(devicePublicKey);
|
||||
|
||||
// Decrypt Organization's encrypted Private Key with org key
|
||||
const orgSymKey = await this.cryptoService.getOrgKey(organizationId);
|
||||
const decOrgPrivateKey = await this.cryptoService.decryptToBytes(
|
||||
new EncString(encryptedOrgPrivateKey),
|
||||
orgSymKey,
|
||||
);
|
||||
|
||||
// Decrypt user key with decrypted org private key
|
||||
const decValue = await this.cryptoService.rsaDecrypt(encryptedUserKey, decOrgPrivateKey);
|
||||
const userKey = new SymmetricCryptoKey(decValue);
|
||||
|
||||
// Re-encrypt user Key with the Device Public Key
|
||||
return await this.cryptoService.rsaEncrypt(userKey.key, devicePubKey);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { View } from "@bitwarden/common/models/view/view";
|
||||
|
||||
import { PendingOrganizationAuthRequestResponse } from ".";
|
||||
|
||||
export class PendingAuthRequestView implements View {
|
||||
id: string;
|
||||
userId: string;
|
||||
organizationUserId: string;
|
||||
email: string;
|
||||
publicKey: string;
|
||||
requestDeviceIdentifier: string;
|
||||
requestDeviceType: string;
|
||||
requestIpAddress: string;
|
||||
creationDate: Date;
|
||||
|
||||
static fromResponse(response: PendingOrganizationAuthRequestResponse): PendingAuthRequestView {
|
||||
const view = Object.assign(new PendingAuthRequestView(), response) as PendingAuthRequestView;
|
||||
|
||||
view.creationDate = new Date(response.creationDate);
|
||||
|
||||
return view;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
|
||||
|
||||
export class PendingOrganizationAuthRequestResponse extends BaseResponse {
|
||||
id: string;
|
||||
userId: string;
|
||||
organizationUserId: string;
|
||||
email: string;
|
||||
publicKey: string;
|
||||
requestDeviceIdentifier: string;
|
||||
requestDeviceType: string;
|
||||
requestIpAddress: string;
|
||||
creationDate: string;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
this.id = this.getResponseProperty("Id");
|
||||
this.userId = this.getResponseProperty("UserId");
|
||||
this.organizationUserId = this.getResponseProperty("OrganizationUserId");
|
||||
this.email = this.getResponseProperty("Email");
|
||||
this.publicKey = this.getResponseProperty("PublicKey");
|
||||
this.requestDeviceIdentifier = this.getResponseProperty("RequestDeviceIdentifier");
|
||||
this.requestDeviceType = this.getResponseProperty("RequestDeviceType");
|
||||
this.requestIpAddress = this.getResponseProperty("RequestIpAddress");
|
||||
this.creationDate = this.getResponseProperty("CreationDate");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user