mirror of
https://github.com/bitwarden/browser
synced 2026-01-07 11:03:30 +00:00
[PM-16541] Key rotation & enrollment trust for emergency access & organizations (#12655)
* Implement key rotation v2 * Pass through masterpassword hint * Properly split old and new code * Mark legacy rotation as deprecated * Throw when data is null * Cleanup * Add tests * Fix build * Update libs/key-management/src/key.service.spec.ts Co-authored-by: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com> * Update apps/web/src/app/auth/settings/change-password.component.ts Co-authored-by: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com> * Add documentation * Centralize loading logic * Implement trust dialogs * Fix build and clean up * Add tests for accept organization component * Fix enrollment * Update apps/web/src/app/admin-console/organizations/manage/organization-trust.component.html Co-authored-by: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com> * Cleanup according to feedback * Change div to ng-container * Init uninited strings * Fix type errors on dialog config * Fix typing * Fix build * Fix build * Update libs/key-management-ui/src/key-rotation/key-rotation-trust-info.component.ts Co-authored-by: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com> * Fix linting * Undo legacy component import change * Simplify dialog text --------- Co-authored-by: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
<form [formGroup]="confirmForm" [bitSubmit]="submit">
|
||||
<bit-dialog
|
||||
dialogSize="large"
|
||||
[loading]="loading"
|
||||
[title]="'trustOrganization' | i18n"
|
||||
[subtitle]="params.name"
|
||||
>
|
||||
<ng-container bitDialogContent>
|
||||
<bit-callout type="warning">{{ "orgTrustWarning" | i18n }}</bit-callout>
|
||||
<p bitTypography="body1">
|
||||
{{ "fingerprintPhrase" | i18n }} <code>{{ fingerprint }}</code>
|
||||
</p>
|
||||
</ng-container>
|
||||
<ng-container bitDialogFooter>
|
||||
<button type="submit" buttonType="primary" bitButton bitFormButton>
|
||||
<span>{{ "trust" | i18n }}</span>
|
||||
</button>
|
||||
<button bitButton bitFormButton buttonType="secondary" type="button" bitDialogClose>
|
||||
{{ "doNotTrust" | i18n }}
|
||||
</button>
|
||||
</ng-container>
|
||||
</bit-dialog>
|
||||
</form>
|
||||
@@ -0,0 +1,69 @@
|
||||
import { DialogRef, DIALOG_DATA } from "@angular/cdk/dialog";
|
||||
import { Component, OnInit, Inject } from "@angular/core";
|
||||
import { FormBuilder } from "@angular/forms";
|
||||
|
||||
import { OrganizationManagementPreferencesService } from "@bitwarden/common/admin-console/abstractions/organization-management-preferences/organization-management-preferences.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
|
||||
type OrganizationTrustDialogData = {
|
||||
/** display name of the organization */
|
||||
name: string;
|
||||
/** identifies the organization */
|
||||
orgId: string;
|
||||
/** org public key */
|
||||
publicKey: Uint8Array;
|
||||
};
|
||||
@Component({
|
||||
selector: "organization-trust",
|
||||
templateUrl: "organization-trust.component.html",
|
||||
})
|
||||
export class OrganizationTrustComponent implements OnInit {
|
||||
loading = true;
|
||||
fingerprint: string = "";
|
||||
confirmForm = this.formBuilder.group({});
|
||||
|
||||
constructor(
|
||||
@Inject(DIALOG_DATA) protected params: OrganizationTrustDialogData,
|
||||
private formBuilder: FormBuilder,
|
||||
private keyService: KeyService,
|
||||
protected organizationManagementPreferencesService: OrganizationManagementPreferencesService,
|
||||
private logService: LogService,
|
||||
private dialogRef: DialogRef<boolean>,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
try {
|
||||
const fingerprint = await this.keyService.getFingerprint(
|
||||
this.params.orgId,
|
||||
this.params.publicKey,
|
||||
);
|
||||
if (fingerprint != null) {
|
||||
this.fingerprint = fingerprint.join("-");
|
||||
}
|
||||
} catch (e) {
|
||||
this.logService.error(e);
|
||||
}
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
submit = async () => {
|
||||
if (this.loading) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.dialogRef.close(true);
|
||||
};
|
||||
|
||||
/**
|
||||
* Strongly typed helper to open a OrganizationTrustComponent
|
||||
* @param dialogService Instance of the dialog service that will be used to open the dialog
|
||||
* @param data The data to pass to the dialog
|
||||
*/
|
||||
static open(dialogService: DialogService, data: OrganizationTrustDialogData) {
|
||||
return dialogService.open<boolean, OrganizationTrustDialogData>(OrganizationTrustComponent, {
|
||||
data,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export class OrganizationUserResetPasswordEntry {
|
||||
orgId: string;
|
||||
publicKey: Uint8Array;
|
||||
orgName: string;
|
||||
|
||||
constructor(orgId: string, publicKey: Uint8Array, orgName: string) {
|
||||
this.orgId = orgId;
|
||||
this.publicKey = publicKey;
|
||||
this.orgName = orgName;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { of } from "rxjs";
|
||||
import { BehaviorSubject, of } from "rxjs";
|
||||
|
||||
import {
|
||||
OrganizationUserApiService,
|
||||
@@ -14,6 +14,7 @@ import { OrganizationApiService } from "@bitwarden/common/admin-console/services
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { EncryptionType } from "@bitwarden/common/platform/enums";
|
||||
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 { CsprngArray } from "@bitwarden/common/types/csprng";
|
||||
@@ -23,6 +24,9 @@ import { KdfType, KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import { OrganizationUserResetPasswordService } from "./organization-user-reset-password.service";
|
||||
|
||||
const mockUserKey = new SymmetricCryptoKey(new Uint8Array(64) as CsprngArray) as UserKey;
|
||||
const mockPublicKeys = [Utils.fromUtf8ToArray("test-public-key")];
|
||||
|
||||
describe("OrganizationUserResetPasswordService", () => {
|
||||
let sut: OrganizationUserResetPasswordService;
|
||||
|
||||
@@ -51,6 +55,21 @@ describe("OrganizationUserResetPasswordService", () => {
|
||||
);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
organizationService.organizations$.mockReturnValue(
|
||||
new BehaviorSubject([
|
||||
createOrganization("1", "org1", true),
|
||||
createOrganization("2", "org2", false),
|
||||
]),
|
||||
);
|
||||
organizationApiService.getKeys.mockResolvedValue(
|
||||
new OrganizationKeysResponse({
|
||||
privateKey: "privateKey",
|
||||
publicKey: "publicKey",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
@@ -59,55 +78,47 @@ describe("OrganizationUserResetPasswordService", () => {
|
||||
expect(sut).toBeTruthy();
|
||||
});
|
||||
|
||||
describe("getRecoveryKey", () => {
|
||||
describe("buildRecoveryKey", () => {
|
||||
const mockOrgId = "test-org-id";
|
||||
|
||||
beforeEach(() => {
|
||||
organizationApiService.getKeys.mockResolvedValue(
|
||||
new OrganizationKeysResponse({
|
||||
privateKey: "test-private-key",
|
||||
publicKey: "test-public-key",
|
||||
publicKey: Utils.fromUtf8ToArray("test-public-key"),
|
||||
}),
|
||||
);
|
||||
|
||||
const mockRandomBytes = new Uint8Array(64) as CsprngArray;
|
||||
const mockUserKey = new SymmetricCryptoKey(mockRandomBytes) as UserKey;
|
||||
keyService.getUserKey.mockResolvedValue(mockUserKey);
|
||||
|
||||
encryptService.rsaEncrypt.mockResolvedValue(
|
||||
new EncString(EncryptionType.Rsa2048_OaepSha1_B64, "mockEncryptedUserKey"),
|
||||
);
|
||||
});
|
||||
|
||||
it("should return an encrypted user key", async () => {
|
||||
const encryptedString = await sut.buildRecoveryKey(mockOrgId);
|
||||
const encryptedString = await sut.buildRecoveryKey(mockOrgId, mockUserKey, mockPublicKeys);
|
||||
expect(encryptedString).toBeDefined();
|
||||
});
|
||||
|
||||
it("should only use the user key from memory if one is not provided", async () => {
|
||||
const mockRandomBytes = new Uint8Array(64) as CsprngArray;
|
||||
const mockUserKey = new SymmetricCryptoKey(mockRandomBytes) as UserKey;
|
||||
|
||||
await sut.buildRecoveryKey(mockOrgId, mockUserKey);
|
||||
|
||||
expect(keyService.getUserKey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should throw an error if the organization keys are null", async () => {
|
||||
organizationApiService.getKeys.mockResolvedValue(null);
|
||||
await expect(sut.buildRecoveryKey(mockOrgId)).rejects.toThrow();
|
||||
await expect(sut.buildRecoveryKey(mockOrgId, mockUserKey, mockPublicKeys)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("should throw an error if the user key can't be found", async () => {
|
||||
keyService.getUserKey.mockResolvedValue(null);
|
||||
await expect(sut.buildRecoveryKey(mockOrgId)).rejects.toThrow();
|
||||
await expect(sut.buildRecoveryKey(mockOrgId, null, mockPublicKeys)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("should rsa encrypt the user key", async () => {
|
||||
await sut.buildRecoveryKey(mockOrgId);
|
||||
|
||||
await sut.buildRecoveryKey(mockOrgId, mockUserKey, mockPublicKeys);
|
||||
expect(encryptService.rsaEncrypt).toHaveBeenCalledWith(expect.anything(), expect.anything());
|
||||
});
|
||||
|
||||
it("should throw an error if the public key is not trusted", async () => {
|
||||
await expect(
|
||||
sut.buildRecoveryKey(mockOrgId, mockUserKey, [new Uint8Array(64)]),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resetMasterPassword", () => {
|
||||
@@ -163,6 +174,20 @@ describe("OrganizationUserResetPasswordService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPublicKeys", () => {
|
||||
it("should return public keys for organizations that have reset password enrolled", async () => {
|
||||
const result = await sut.getPublicKeys("userId" as UserId);
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("should result should contain the correct data for the org", async () => {
|
||||
const result = await sut.getPublicKeys("userId" as UserId);
|
||||
expect(result[0].orgId).toBe("1");
|
||||
expect(result[0].orgName).toBe("org1");
|
||||
expect(result[0].publicKey).toEqual(Utils.fromB64ToArray("publicKey"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRotatedData", () => {
|
||||
beforeEach(() => {
|
||||
organizationService.organizations$.mockReturnValue(
|
||||
@@ -171,7 +196,7 @@ describe("OrganizationUserResetPasswordService", () => {
|
||||
organizationApiService.getKeys.mockResolvedValue(
|
||||
new OrganizationKeysResponse({
|
||||
privateKey: "test-private-key",
|
||||
publicKey: "test-public-key",
|
||||
publicKey: Utils.fromUtf8ToArray("test-public-key"),
|
||||
}),
|
||||
);
|
||||
encryptService.rsaEncrypt.mockResolvedValue(
|
||||
@@ -182,7 +207,7 @@ describe("OrganizationUserResetPasswordService", () => {
|
||||
it("should return all re-encrypted account recovery keys", async () => {
|
||||
const result = await sut.getRotatedData(
|
||||
new SymmetricCryptoKey(new Uint8Array(64)) as UserKey,
|
||||
new SymmetricCryptoKey(new Uint8Array(64)) as UserKey,
|
||||
mockPublicKeys,
|
||||
"mockUserId" as UserId,
|
||||
);
|
||||
|
||||
@@ -191,22 +216,18 @@ describe("OrganizationUserResetPasswordService", () => {
|
||||
|
||||
it("throws if the new user key is null", async () => {
|
||||
await expect(
|
||||
sut.getRotatedData(
|
||||
new SymmetricCryptoKey(new Uint8Array(64)) as UserKey,
|
||||
null,
|
||||
"mockUserId" as UserId,
|
||||
),
|
||||
sut.getRotatedData(null, mockPublicKeys, "mockUserId" as UserId),
|
||||
).rejects.toThrow("New user key is required for rotation.");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function createOrganization(id: string, name: string) {
|
||||
function createOrganization(id: string, name: string, resetPasswordEnrolled = true): Organization {
|
||||
const org = new Organization();
|
||||
org.id = id;
|
||||
org.name = name;
|
||||
org.identifier = name;
|
||||
org.isMember = true;
|
||||
org.resetPasswordEnrolled = true;
|
||||
org.resetPasswordEnrolled = resetPasswordEnrolled;
|
||||
return org;
|
||||
}
|
||||
|
||||
@@ -21,16 +21,22 @@ import {
|
||||
Argon2KdfConfig,
|
||||
KdfConfig,
|
||||
PBKDF2KdfConfig,
|
||||
UserKeyRotationDataProvider,
|
||||
UserKeyRotationKeyRecoveryProvider,
|
||||
KeyService,
|
||||
KdfType,
|
||||
} from "@bitwarden/key-management";
|
||||
|
||||
import { OrganizationUserResetPasswordEntry } from "./organization-user-reset-password-entry";
|
||||
|
||||
@Injectable({
|
||||
providedIn: "root",
|
||||
})
|
||||
export class OrganizationUserResetPasswordService
|
||||
implements UserKeyRotationDataProvider<OrganizationUserResetPasswordWithIdRequest>
|
||||
implements
|
||||
UserKeyRotationKeyRecoveryProvider<
|
||||
OrganizationUserResetPasswordWithIdRequest,
|
||||
OrganizationUserResetPasswordEntry
|
||||
>
|
||||
{
|
||||
constructor(
|
||||
private keyService: KeyService,
|
||||
@@ -42,11 +48,17 @@ export class OrganizationUserResetPasswordService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Returns the user key encrypted by the organization's public key.
|
||||
* Intended for use in enrollment
|
||||
* Builds a recovery key for a user to recover their account.
|
||||
*
|
||||
* @param orgId desired organization
|
||||
* @param userKey user key
|
||||
* @param trustedPublicKeys public keys of organizations that the user trusts
|
||||
*/
|
||||
async buildRecoveryKey(orgId: string, userKey?: UserKey): Promise<EncryptedString> {
|
||||
async buildRecoveryKey(
|
||||
orgId: string,
|
||||
userKey: UserKey,
|
||||
trustedPublicKeys: Uint8Array[],
|
||||
): Promise<EncryptedString> {
|
||||
// Retrieve Public Key
|
||||
const orgKeys = await this.organizationApiService.getKeys(orgId);
|
||||
if (orgKeys == null) {
|
||||
@@ -55,13 +67,16 @@ export class OrganizationUserResetPasswordService
|
||||
|
||||
const publicKey = Utils.fromB64ToArray(orgKeys.publicKey);
|
||||
|
||||
// RSA Encrypt user key with organization's public key
|
||||
userKey ??= await this.keyService.getUserKey();
|
||||
if (userKey == null) {
|
||||
throw new Error("No user key found");
|
||||
if (
|
||||
!trustedPublicKeys.some(
|
||||
(key) => Utils.fromBufferToHex(key) === Utils.fromBufferToHex(publicKey),
|
||||
)
|
||||
) {
|
||||
throw new Error("Untrusted public key");
|
||||
}
|
||||
const encryptedKey = await this.encryptService.rsaEncrypt(userKey.key, publicKey);
|
||||
|
||||
// RSA Encrypt user key with organization's public key
|
||||
const encryptedKey = await this.encryptService.rsaEncrypt(userKey.key, publicKey);
|
||||
return encryptedKey.encryptedString;
|
||||
}
|
||||
|
||||
@@ -138,6 +153,21 @@ export class OrganizationUserResetPasswordService
|
||||
);
|
||||
}
|
||||
|
||||
async getPublicKeys(userId: UserId): Promise<OrganizationUserResetPasswordEntry[]> {
|
||||
const allOrgs = (await firstValueFrom(this.organizationService.organizations$(userId))).filter(
|
||||
(org) => org.resetPasswordEnrolled,
|
||||
);
|
||||
|
||||
const entries: OrganizationUserResetPasswordEntry[] = [];
|
||||
for (const org of allOrgs) {
|
||||
const publicKey = await this.organizationApiService.getKeys(org.id);
|
||||
const encodedPublicKey = Utils.fromB64ToArray(publicKey.publicKey);
|
||||
const entry = new OrganizationUserResetPasswordEntry(org.id, encodedPublicKey, org.name);
|
||||
entries.push(entry);
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns existing account recovery keys re-encrypted with the new user key.
|
||||
* @param originalUserKey the original user key
|
||||
@@ -147,8 +177,8 @@ export class OrganizationUserResetPasswordService
|
||||
* @returns a list of account recovery keys that have been re-encrypted with the new user key
|
||||
*/
|
||||
async getRotatedData(
|
||||
originalUserKey: UserKey,
|
||||
newUserKey: UserKey,
|
||||
trustedPublicKeys: Uint8Array[],
|
||||
userId: UserId,
|
||||
): Promise<OrganizationUserResetPasswordWithIdRequest[] | null> {
|
||||
if (newUserKey == null) {
|
||||
@@ -156,9 +186,8 @@ export class OrganizationUserResetPasswordService
|
||||
}
|
||||
|
||||
const allOrgs = await firstValueFrom(this.organizationService.organizations$(userId));
|
||||
|
||||
if (!allOrgs) {
|
||||
return;
|
||||
throw new Error("Could not get organizations");
|
||||
}
|
||||
|
||||
const requests: OrganizationUserResetPasswordWithIdRequest[] = [];
|
||||
@@ -169,7 +198,7 @@ export class OrganizationUserResetPasswordService
|
||||
}
|
||||
|
||||
// Re-enroll - encrypt user key with organization public key
|
||||
const encryptedKey = await this.buildRecoveryKey(org.id, newUserKey);
|
||||
const encryptedKey = await this.buildRecoveryKey(org.id, newUserKey, trustedPublicKeys);
|
||||
|
||||
// Create/Execute request
|
||||
const request = new OrganizationUserResetPasswordWithIdRequest();
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { firstValueFrom, lastValueFrom } from "rxjs";
|
||||
|
||||
import {
|
||||
OrganizationUserApiService,
|
||||
OrganizationUserResetPasswordEnrollmentRequest,
|
||||
} from "@bitwarden/admin-console/common";
|
||||
import { UserVerificationDialogComponent } from "@bitwarden/auth/angular";
|
||||
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
|
||||
import { VerificationWithSecret } from "@bitwarden/common/auth/types/verification";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
|
||||
import { DialogService, ToastService } from "@bitwarden/components";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import { OrganizationTrustComponent } from "../manage/organization-trust.component";
|
||||
import { OrganizationUserResetPasswordService } from "../members/services/organization-user-reset-password/organization-user-reset-password.service";
|
||||
|
||||
interface EnrollMasterPasswordResetData {
|
||||
@@ -28,12 +34,14 @@ export class EnrollMasterPasswordReset {
|
||||
data: EnrollMasterPasswordResetData,
|
||||
resetPasswordService: OrganizationUserResetPasswordService,
|
||||
organizationUserApiService: OrganizationUserApiService,
|
||||
platformUtilsService: PlatformUtilsService,
|
||||
i18nService: I18nService,
|
||||
syncService: SyncService,
|
||||
logService: LogService,
|
||||
userVerificationService: UserVerificationService,
|
||||
toastService: ToastService,
|
||||
keyService: KeyService,
|
||||
accountService: AccountService,
|
||||
organizationApiService: OrganizationApiServiceAbstraction,
|
||||
) {
|
||||
const result = await UserVerificationDialogComponent.open(dialogService, {
|
||||
title: "enrollAccountRecovery",
|
||||
@@ -44,12 +52,33 @@ export class EnrollMasterPasswordReset {
|
||||
verificationType: {
|
||||
type: "custom",
|
||||
verificationFn: async (secret: VerificationWithSecret) => {
|
||||
const activeUserId = (await firstValueFrom(accountService.activeAccount$)).id;
|
||||
|
||||
const publicKey = Utils.fromB64ToArray(
|
||||
(await organizationApiService.getKeys(data.organization.id)).publicKey,
|
||||
);
|
||||
|
||||
const request =
|
||||
await userVerificationService.buildRequest<OrganizationUserResetPasswordEnrollmentRequest>(
|
||||
secret,
|
||||
);
|
||||
const dialogRef = OrganizationTrustComponent.open(dialogService, {
|
||||
name: data.organization.name,
|
||||
orgId: data.organization.id,
|
||||
publicKey,
|
||||
});
|
||||
const result = await lastValueFrom(dialogRef.closed);
|
||||
if (result !== true) {
|
||||
throw new Error("Organization not trusted, aborting user key rotation");
|
||||
}
|
||||
|
||||
const trustedOrgPublicKeys = [publicKey];
|
||||
const userKey = await firstValueFrom(keyService.userKey$(activeUserId));
|
||||
|
||||
request.resetPasswordKey = await resetPasswordService.buildRecoveryKey(
|
||||
data.organization.id,
|
||||
userKey,
|
||||
trustedOrgPublicKeys,
|
||||
);
|
||||
|
||||
// Process the enrollment request, which is an endpoint that is
|
||||
|
||||
Reference in New Issue
Block a user