mirror of
https://github.com/logto-io/logto.git
synced 2025-04-14 23:11:31 -05:00
feat(core,phrases): add mfa verifications by api (#4765)
This commit is contained in:
parent
f2b3f39422
commit
8798432a24
24 changed files with 512 additions and 90 deletions
|
@ -1,4 +1,4 @@
|
|||
import { UsersPasswordEncryptionMethod } from '@logto/schemas';
|
||||
import { MfaFactor, UsersPasswordEncryptionMethod } from '@logto/schemas';
|
||||
|
||||
import { mockResource, mockAdminUserRole, mockScope } from '#src/__mocks__/index.js';
|
||||
import { mockUser } from '#src/__mocks__/user.js';
|
||||
|
@ -9,8 +9,9 @@ const { jest } = import.meta;
|
|||
const { encryptUserPassword, createUserLibrary } = await import('./user.js');
|
||||
|
||||
const hasUserWithId = jest.fn();
|
||||
const updateUserById = jest.fn();
|
||||
const queries = new MockQueries({
|
||||
users: { hasUserWithId },
|
||||
users: { hasUserWithId, findUserById: async () => mockUser, updateUserById },
|
||||
roles: { findRolesByRoleIds: async () => [mockAdminUserRole] },
|
||||
scopes: { findScopesByIdsAndResourceIndicator: async () => [mockScope] },
|
||||
usersRoles: { findUsersRolesByUserId: async () => [] },
|
||||
|
@ -84,3 +85,25 @@ describe('findUserRoles()', () => {
|
|||
await expect(findUserRoles(mockUser.id)).resolves.toEqual([mockAdminUserRole]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addUserMfaVerification()', () => {
|
||||
const createdAt = new Date().toISOString();
|
||||
const { addUserMfaVerification } = createUserLibrary(queries);
|
||||
|
||||
beforeAll(() => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date(createdAt));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('update user with new mfa verification', async () => {
|
||||
await addUserMfaVerification(mockUser.id, { type: MfaFactor.TOTP, secret: 'secret' });
|
||||
expect(updateUserById).toHaveBeenCalledWith(mockUser.id, {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
mfaVerifications: [{ type: MfaFactor.TOTP, key: 'secret', id: expect.anything(), createdAt }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import type { User, CreateUser, Scope } from '@logto/schemas';
|
||||
import { Users, UsersPasswordEncryptionMethod } from '@logto/schemas';
|
||||
import type { User, CreateUser, Scope, BindMfa, MfaVerification } from '@logto/schemas';
|
||||
import { MfaFactor, Users, UsersPasswordEncryptionMethod } from '@logto/schemas';
|
||||
import { generateStandardShortId, generateStandardId } from '@logto/shared';
|
||||
import type { OmitAutoSetFields } from '@logto/shared';
|
||||
import type { Nullable } from '@silverhand/essentials';
|
||||
|
@ -47,13 +47,64 @@ export const verifyUserPassword = async (user: Nullable<User>, password: string)
|
|||
return user;
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert bindMfa to mfaVerification, add common fields like "id" and "createdAt"
|
||||
* and transpile formats like "codes" to "code" for backup code
|
||||
*/
|
||||
const converBindMfaToMfaVerification = (bindMfa: BindMfa): MfaVerification => {
|
||||
const { type } = bindMfa;
|
||||
const base = {
|
||||
id: generateStandardId(),
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (type === MfaFactor.BackupCode) {
|
||||
const { codes } = bindMfa;
|
||||
|
||||
return {
|
||||
...base,
|
||||
type,
|
||||
codes: codes.map((code) => ({ code })),
|
||||
};
|
||||
}
|
||||
|
||||
if (type === MfaFactor.TOTP) {
|
||||
const { secret } = bindMfa;
|
||||
|
||||
return {
|
||||
...base,
|
||||
type,
|
||||
key: secret,
|
||||
};
|
||||
}
|
||||
|
||||
const { credentialId, counter, publicKey, transports, agent } = bindMfa;
|
||||
return {
|
||||
...base,
|
||||
type,
|
||||
credentialId,
|
||||
counter,
|
||||
publicKey,
|
||||
transports,
|
||||
agent,
|
||||
};
|
||||
};
|
||||
|
||||
export type UserLibrary = ReturnType<typeof createUserLibrary>;
|
||||
|
||||
export const createUserLibrary = (queries: Queries) => {
|
||||
const {
|
||||
pool,
|
||||
roles: { findRolesByRoleNames, findRoleByRoleName, findRolesByRoleIds },
|
||||
users: { hasUser, hasUserWithEmail, hasUserWithId, hasUserWithPhone, findUsersByIds },
|
||||
users: {
|
||||
hasUser,
|
||||
hasUserWithEmail,
|
||||
hasUserWithId,
|
||||
hasUserWithPhone,
|
||||
findUsersByIds,
|
||||
updateUserById,
|
||||
findUserById,
|
||||
},
|
||||
usersRoles: { findUsersRolesByRoleId, findUsersRolesByUserId },
|
||||
rolesScopes: { findRolesScopesByRoleIds },
|
||||
scopes: { findScopesByIdsAndResourceIndicator },
|
||||
|
@ -158,6 +209,14 @@ export const createUserLibrary = (queries: Queries) => {
|
|||
return roles;
|
||||
};
|
||||
|
||||
const addUserMfaVerification = async (userId: string, payload: BindMfa) => {
|
||||
// TODO @sijie use jsonb array append
|
||||
const { mfaVerifications } = await findUserById(userId);
|
||||
await updateUserById(userId, {
|
||||
mfaVerifications: [...mfaVerifications, converBindMfaToMfaVerification(payload)],
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
generateUserId,
|
||||
insertUser,
|
||||
|
@ -165,5 +224,6 @@ export const createUserLibrary = (queries: Queries) => {
|
|||
findUsersByRoleName,
|
||||
findUserScopesForResourceIndicator,
|
||||
findUserRoles,
|
||||
addUserMfaVerification,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -3,12 +3,7 @@ import { RoleType } from '@logto/schemas';
|
|||
import { createMockUtils, pickDefault } from '@logto/shared/esm';
|
||||
import { removeUndefinedKeys } from '@silverhand/essentials';
|
||||
|
||||
import {
|
||||
mockUser,
|
||||
mockUserTotpMfaVerification,
|
||||
mockUserResponse,
|
||||
mockUserWithMfaVerifications,
|
||||
} from '#src/__mocks__/index.js';
|
||||
import { mockUser, mockUserResponse } from '#src/__mocks__/index.js';
|
||||
import RequestError from '#src/errors/RequestError/index.js';
|
||||
import type Libraries from '#src/tenants/Libraries.js';
|
||||
import type Queries from '#src/tenants/Queries.js';
|
||||
|
@ -405,29 +400,4 @@ describe('adminUserRoutes', () => {
|
|||
500
|
||||
);
|
||||
});
|
||||
|
||||
it('GET /users/:userId/mfa-verifications', async () => {
|
||||
findUserById.mockImplementationOnce(async () => mockUserWithMfaVerifications);
|
||||
const response = await userRequest.get(`/users/${mockUser.id}/mfa-verifications`);
|
||||
const { id, type, createdAt } = mockUserTotpMfaVerification;
|
||||
expect(response.body).toMatchObject([{ id, type, createdAt }]);
|
||||
});
|
||||
|
||||
it('DELETE /users/:userId/mfa-verifications/:verificationId', async () => {
|
||||
findUserById.mockImplementationOnce(async () => mockUserWithMfaVerifications);
|
||||
const response = await userRequest.delete(
|
||||
`/users/${mockUser.id}/mfa-verifications/${mockUserTotpMfaVerification.id}`
|
||||
);
|
||||
expect(response.status).toEqual(204);
|
||||
expect(updateUserById).toHaveBeenCalledWith(mockUser.id, {
|
||||
mfaVerifications: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('DELETE /users/:userId/mfa-verifications/:verificationId should throw with wrong verification id', async () => {
|
||||
findUserById.mockImplementationOnce(async () => mockUserWithMfaVerifications);
|
||||
await expect(
|
||||
userRequest.delete(`/users/${mockUser.id}/mfa-verifications/wrong-verification-id`)
|
||||
).resolves.toHaveProperty('status', 404);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,10 +1,5 @@
|
|||
import { emailRegEx, phoneRegEx, usernameRegEx } from '@logto/core-kit';
|
||||
import {
|
||||
jsonObjectGuard,
|
||||
userInfoSelectFields,
|
||||
userMfaVerificationResponseGuard,
|
||||
userProfileResponseGuard,
|
||||
} from '@logto/schemas';
|
||||
import { jsonObjectGuard, userInfoSelectFields, userProfileResponseGuard } from '@logto/schemas';
|
||||
import { conditional, pick } from '@silverhand/essentials';
|
||||
import { boolean, literal, object, string } from 'zod';
|
||||
|
||||
|
@ -12,7 +7,6 @@ import RequestError from '#src/errors/RequestError/index.js';
|
|||
import { encryptUserPassword, verifyUserPassword } from '#src/libraries/user.js';
|
||||
import koaGuard from '#src/middleware/koa-guard.js';
|
||||
import assertThat from '#src/utils/assert-that.js';
|
||||
import { transpileUserMfaVerifications } from '#src/utils/user.js';
|
||||
|
||||
import type { AuthedRouter, RouterInitArgs } from '../types.js';
|
||||
|
||||
|
@ -53,20 +47,6 @@ export default function adminUserBasicsRoutes<T extends AuthedRouter>(...args: R
|
|||
}
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/users/:userId/mfa-verifications',
|
||||
koaGuard({
|
||||
params: object({ userId: string() }),
|
||||
response: userMfaVerificationResponseGuard,
|
||||
status: [200, 404],
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const user = await findUserById(ctx.guard.params.userId);
|
||||
ctx.body = transpileUserMfaVerifications(user.mfaVerifications);
|
||||
return next();
|
||||
}
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/users/:userId/custom-data',
|
||||
koaGuard({
|
||||
|
@ -320,36 +300,4 @@ export default function adminUserBasicsRoutes<T extends AuthedRouter>(...args: R
|
|||
return next();
|
||||
}
|
||||
);
|
||||
|
||||
router.delete(
|
||||
'/users/:userId/mfa-verifications/:verificationId',
|
||||
koaGuard({
|
||||
params: object({ userId: string() }),
|
||||
status: [204, 404],
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const {
|
||||
params: { userId },
|
||||
} = ctx.guard;
|
||||
|
||||
const { mfaVerifications } = await findUserById(userId);
|
||||
|
||||
const verification = mfaVerifications.find(({ id }) => id === ctx.params.verificationId);
|
||||
|
||||
if (!verification) {
|
||||
throw new RequestError({
|
||||
code: 'entity.not_found',
|
||||
status: 404,
|
||||
});
|
||||
}
|
||||
|
||||
await updateUserById(userId, {
|
||||
mfaVerifications: mfaVerifications.filter(({ id }) => id !== verification.id),
|
||||
});
|
||||
|
||||
ctx.status = 204;
|
||||
|
||||
return next();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import type { AuthedRouter, RouterInitArgs } from '../types.js';
|
||||
|
||||
import adminUserBasicsRoutes from './basics.js';
|
||||
import adminUserMfaVerificationsRoutes from './mfa-verifications.js';
|
||||
import adminUserOrganizationRoutes from './organization.js';
|
||||
import adminUserRoleRoutes from './role.js';
|
||||
import adminUserSearchRoutes from './search.js';
|
||||
|
@ -12,4 +13,5 @@ export default function adminUserRoutes<T extends AuthedRouter>(...args: RouterI
|
|||
adminUserSearchRoutes(...args);
|
||||
adminUserSocialRoutes(...args);
|
||||
adminUserOrganizationRoutes(...args);
|
||||
adminUserMfaVerificationsRoutes(...args);
|
||||
}
|
||||
|
|
167
packages/core/src/routes/admin-user/mfa-verifications.test.ts
Normal file
167
packages/core/src/routes/admin-user/mfa-verifications.test.ts
Normal file
|
@ -0,0 +1,167 @@
|
|||
import { MfaFactor, type CreateUser, type User } from '@logto/schemas';
|
||||
import { createMockUtils, pickDefault } from '@logto/shared/esm';
|
||||
import { removeUndefinedKeys } from '@silverhand/essentials';
|
||||
|
||||
import {
|
||||
mockUser,
|
||||
mockUserBackupCodeMfaVerification,
|
||||
mockUserTotpMfaVerification,
|
||||
mockUserWithMfaVerifications,
|
||||
} from '#src/__mocks__/index.js';
|
||||
import type Libraries from '#src/tenants/Libraries.js';
|
||||
import type Queries from '#src/tenants/Queries.js';
|
||||
import { MockTenant, type Partial2 } from '#src/test-utils/tenant.js';
|
||||
import { createRequester } from '#src/utils/test-utils.js';
|
||||
|
||||
const { jest } = import.meta;
|
||||
const { mockEsmWithActual } = createMockUtils(jest);
|
||||
|
||||
const mockedQueries = {
|
||||
users: {
|
||||
findUserById: jest.fn(async (id: string) => mockUser),
|
||||
hasUser: jest.fn(async () => mockHasUser()),
|
||||
hasUserWithEmail: jest.fn(async () => mockHasUserWithEmail()),
|
||||
hasUserWithPhone: jest.fn(async () => mockHasUserWithPhone()),
|
||||
updateUserById: jest.fn(
|
||||
async (_, data: Partial<CreateUser>): Promise<User> => ({
|
||||
...mockUser,
|
||||
...data,
|
||||
})
|
||||
),
|
||||
deleteUserById: jest.fn(),
|
||||
deleteUserIdentity: jest.fn(),
|
||||
},
|
||||
} satisfies Partial2<Queries>;
|
||||
|
||||
const mockHasUser = jest.fn(async () => false);
|
||||
const mockHasUserWithEmail = jest.fn(async () => false);
|
||||
const mockHasUserWithPhone = jest.fn(async () => false);
|
||||
|
||||
const { findUserById, updateUserById } = mockedQueries.users;
|
||||
|
||||
await mockEsmWithActual('../interaction/utils/totp-validation.js', () => ({
|
||||
generateTotpSecret: jest.fn().mockReturnValue('totp_secret'),
|
||||
}));
|
||||
await mockEsmWithActual('../interaction/utils/backup-code-validation.js', () => ({
|
||||
generateBackupCodes: jest.fn().mockReturnValue(['code']),
|
||||
}));
|
||||
|
||||
const usersLibraries = {
|
||||
generateUserId: jest.fn(async () => 'fooId'),
|
||||
insertUser: jest.fn(
|
||||
async (user: CreateUser): Promise<User> => ({
|
||||
...mockUser,
|
||||
...removeUndefinedKeys(user), // No undefined values will be returned from database
|
||||
})
|
||||
),
|
||||
} satisfies Partial<Libraries['users']>;
|
||||
|
||||
const adminUserRoutes = await pickDefault(import('./mfa-verifications.js'));
|
||||
|
||||
describe('adminUserRoutes', () => {
|
||||
const tenantContext = new MockTenant(undefined, mockedQueries, undefined, {
|
||||
users: usersLibraries,
|
||||
});
|
||||
const userRequest = createRequester({ authedRoutes: adminUserRoutes, tenantContext });
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('GET /users/:userId/mfa-verifications', async () => {
|
||||
findUserById.mockResolvedValueOnce(mockUserWithMfaVerifications);
|
||||
const response = await userRequest.get(`/users/${mockUser.id}/mfa-verifications`);
|
||||
const { id, type, createdAt } = mockUserTotpMfaVerification;
|
||||
expect(response.body).toMatchObject([{ id, type, createdAt }]);
|
||||
});
|
||||
|
||||
describe('POST /users/:userId/mfa-verifications', () => {
|
||||
describe('TOTP', () => {
|
||||
it('should bind and return totp secret', async () => {
|
||||
findUserById.mockResolvedValueOnce(mockUser);
|
||||
const response = await userRequest.post(`/users/${mockUser.id}/mfa-verifications`).send({
|
||||
type: MfaFactor.TOTP,
|
||||
});
|
||||
expect(response.body).toMatchObject({
|
||||
type: MfaFactor.TOTP,
|
||||
secret: 'totp_secret',
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
secretQrCode: expect.stringContaining('data:image'),
|
||||
});
|
||||
});
|
||||
|
||||
it('should fail for duplicate', async () => {
|
||||
findUserById.mockResolvedValueOnce({
|
||||
...mockUser,
|
||||
mfaVerifications: [mockUserTotpMfaVerification],
|
||||
});
|
||||
const response = await userRequest.post(`/users/${mockUser.id}/mfa-verifications`).send({
|
||||
type: MfaFactor.TOTP,
|
||||
});
|
||||
expect(response.status).toEqual(422);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Backup Code', () => {
|
||||
it('should bind and return codes', async () => {
|
||||
findUserById.mockResolvedValueOnce({
|
||||
...mockUser,
|
||||
mfaVerifications: [mockUserTotpMfaVerification],
|
||||
});
|
||||
const response = await userRequest.post(`/users/${mockUser.id}/mfa-verifications`).send({
|
||||
type: MfaFactor.BackupCode,
|
||||
});
|
||||
expect(response.body).toMatchObject({
|
||||
type: MfaFactor.BackupCode,
|
||||
codes: ['code'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should fail if backup code is to be the only one', async () => {
|
||||
findUserById.mockResolvedValueOnce({
|
||||
...mockUser,
|
||||
mfaVerifications: [],
|
||||
});
|
||||
const response = await userRequest.post(`/users/${mockUser.id}/mfa-verifications`).send({
|
||||
type: MfaFactor.BackupCode,
|
||||
});
|
||||
expect(response.status).toEqual(422);
|
||||
});
|
||||
|
||||
it('should fail for duplicate', async () => {
|
||||
findUserById.mockResolvedValueOnce({
|
||||
...mockUser,
|
||||
mfaVerifications: [mockUserBackupCodeMfaVerification, mockUserTotpMfaVerification],
|
||||
});
|
||||
const response = await userRequest.post(`/users/${mockUser.id}/mfa-verifications`).send({
|
||||
type: MfaFactor.BackupCode,
|
||||
});
|
||||
expect(response.status).toEqual(422);
|
||||
});
|
||||
});
|
||||
|
||||
it('DELETE /users/:userId/mfa-verifications/:verificationId', async () => {
|
||||
findUserById.mockResolvedValueOnce({
|
||||
...mockUser,
|
||||
mfaVerifications: [mockUserTotpMfaVerification],
|
||||
});
|
||||
const response = await userRequest.delete(
|
||||
`/users/${mockUser.id}/mfa-verifications/${mockUserTotpMfaVerification.id}`
|
||||
);
|
||||
expect(response.status).toEqual(204);
|
||||
expect(updateUserById).toHaveBeenCalledWith(mockUser.id, {
|
||||
mfaVerifications: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('DELETE /users/:userId/mfa-verifications/:verificationId should throw with wrong verification id', async () => {
|
||||
findUserById.mockResolvedValueOnce({
|
||||
...mockUser,
|
||||
mfaVerifications: [mockUserTotpMfaVerification],
|
||||
});
|
||||
await expect(
|
||||
userRequest.delete(`/users/${mockUser.id}/mfa-verifications/wrong-verification-id`)
|
||||
).resolves.toHaveProperty('status', 404);
|
||||
});
|
||||
});
|
152
packages/core/src/routes/admin-user/mfa-verifications.ts
Normal file
152
packages/core/src/routes/admin-user/mfa-verifications.ts
Normal file
|
@ -0,0 +1,152 @@
|
|||
import { MfaFactor, userMfaVerificationResponseGuard } from '@logto/schemas';
|
||||
import { getUserDisplayName } from '@logto/shared';
|
||||
import { authenticator } from 'otplib';
|
||||
import qrcode from 'qrcode';
|
||||
import { object, string, z } from 'zod';
|
||||
|
||||
import RequestError from '#src/errors/RequestError/index.js';
|
||||
import koaGuard from '#src/middleware/koa-guard.js';
|
||||
import assertThat from '#src/utils/assert-that.js';
|
||||
import { transpileUserMfaVerifications } from '#src/utils/user.js';
|
||||
|
||||
import { generateBackupCodes } from '../interaction/utils/backup-code-validation.js';
|
||||
import { generateTotpSecret } from '../interaction/utils/totp-validation.js';
|
||||
import type { AuthedRouter, RouterInitArgs } from '../types.js';
|
||||
|
||||
export default function adminUserMfaVerificationsRoutes<T extends AuthedRouter>(
|
||||
...args: RouterInitArgs<T>
|
||||
) {
|
||||
const [
|
||||
router,
|
||||
{
|
||||
queries,
|
||||
libraries: {
|
||||
users: { addUserMfaVerification },
|
||||
},
|
||||
},
|
||||
] = args;
|
||||
const {
|
||||
users: { findUserById, updateUserById },
|
||||
} = queries;
|
||||
|
||||
router.get(
|
||||
'/users/:userId/mfa-verifications',
|
||||
koaGuard({
|
||||
params: object({ userId: string() }),
|
||||
response: userMfaVerificationResponseGuard,
|
||||
status: [200, 404],
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const user = await findUserById(ctx.guard.params.userId);
|
||||
ctx.body = transpileUserMfaVerifications(user.mfaVerifications);
|
||||
return next();
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/users/:userId/mfa-verifications',
|
||||
koaGuard({
|
||||
params: object({ userId: string() }),
|
||||
body: z.object({
|
||||
type: z.literal(MfaFactor.TOTP).or(z.literal(MfaFactor.BackupCode)),
|
||||
}),
|
||||
response: z.discriminatedUnion('type', [
|
||||
z.object({
|
||||
type: z.literal(MfaFactor.TOTP),
|
||||
secret: z.string(),
|
||||
secretQrCode: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal(MfaFactor.BackupCode),
|
||||
codes: z.string().array(),
|
||||
}),
|
||||
]),
|
||||
status: [200, 404, 422],
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const { id, mfaVerifications, username, primaryEmail, primaryPhone, name } =
|
||||
await findUserById(ctx.guard.params.userId);
|
||||
|
||||
const { type } = ctx.guard.body;
|
||||
|
||||
if (type === MfaFactor.TOTP) {
|
||||
// A user can only bind one TOTP factor
|
||||
assertThat(
|
||||
mfaVerifications.every(({ type }) => type !== MfaFactor.TOTP),
|
||||
new RequestError({
|
||||
code: 'user.totp_already_in_use',
|
||||
status: 422,
|
||||
})
|
||||
);
|
||||
|
||||
const secret = generateTotpSecret();
|
||||
const service = ctx.URL.hostname;
|
||||
const user = getUserDisplayName({ username, primaryEmail, primaryPhone, name });
|
||||
const keyUri = authenticator.keyuri(user, service, secret);
|
||||
await addUserMfaVerification(id, { type: MfaFactor.TOTP, secret });
|
||||
ctx.body = {
|
||||
type: MfaFactor.TOTP,
|
||||
secret,
|
||||
secretQrCode: await qrcode.toDataURL(keyUri),
|
||||
};
|
||||
return next();
|
||||
}
|
||||
|
||||
// A user can only bind one available backup code factor
|
||||
assertThat(
|
||||
mfaVerifications.every(
|
||||
(verification) =>
|
||||
verification.type !== MfaFactor.BackupCode ||
|
||||
verification.codes.every(({ usedAt }) => usedAt)
|
||||
),
|
||||
new RequestError({
|
||||
code: 'user.backup_code_already_in_use',
|
||||
status: 422,
|
||||
})
|
||||
);
|
||||
assertThat(
|
||||
mfaVerifications.some(({ type }) => type !== MfaFactor.BackupCode),
|
||||
new RequestError({
|
||||
code: 'session.mfa.backup_code_can_not_be_alone',
|
||||
status: 422,
|
||||
})
|
||||
);
|
||||
const codes = generateBackupCodes();
|
||||
await addUserMfaVerification(id, { type: MfaFactor.BackupCode, codes });
|
||||
ctx.body = { codes, type: MfaFactor.BackupCode };
|
||||
return next();
|
||||
}
|
||||
);
|
||||
|
||||
router.delete(
|
||||
'/users/:userId/mfa-verifications/:verificationId',
|
||||
koaGuard({
|
||||
params: object({ userId: string(), verificationId: string() }),
|
||||
status: [204, 404],
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const {
|
||||
params: { userId, verificationId },
|
||||
} = ctx.guard;
|
||||
|
||||
const { mfaVerifications } = await findUserById(userId);
|
||||
|
||||
const verification = mfaVerifications.find(({ id }) => id === verificationId);
|
||||
|
||||
if (!verification) {
|
||||
throw new RequestError({
|
||||
code: 'entity.not_found',
|
||||
status: 404,
|
||||
});
|
||||
}
|
||||
|
||||
await updateUserById(userId, {
|
||||
mfaVerifications: mfaVerifications.filter(({ id }) => id !== verification.id),
|
||||
});
|
||||
|
||||
ctx.status = 204;
|
||||
|
||||
return next();
|
||||
}
|
||||
);
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
import type { Identities, Role, User } from '@logto/schemas';
|
||||
import type { Identities, MfaFactor, MfaVerification, Role, User } from '@logto/schemas';
|
||||
|
||||
import { authedAdminApi } from './api.js';
|
||||
|
||||
|
@ -70,3 +70,17 @@ export const postUserIdentity = async (
|
|||
|
||||
export const verifyUserPassword = async (userId: string, password: string) =>
|
||||
authedAdminApi.post(`users/${userId}/password/verify`, { json: { password } });
|
||||
|
||||
export const getUserMfaVerifications = async (userId: string) =>
|
||||
authedAdminApi.get(`users/${userId}/mfa-verifications`).json<MfaVerification[]>();
|
||||
|
||||
export const deleteUserMfaVerification = async (userId: string, mfaVerificationId: string) =>
|
||||
authedAdminApi.delete(`users/${userId}/mfa-verifications/${mfaVerificationId}`);
|
||||
|
||||
export const createUserMfaVerification = async (userId: string, type: MfaFactor) =>
|
||||
authedAdminApi
|
||||
.post(`users/${userId}/mfa-verifications`, { json: { type } })
|
||||
.json<
|
||||
| { type: MfaFactor.TOTP; secret: string; secretQrCode: string }
|
||||
| { type: MfaFactor.BackupCode; codes: string[] }
|
||||
>();
|
||||
|
|
|
@ -0,0 +1,57 @@
|
|||
import { MfaFactor } from '@logto/schemas';
|
||||
|
||||
import {
|
||||
createUserMfaVerification,
|
||||
deleteUser,
|
||||
deleteUserMfaVerification,
|
||||
getUserMfaVerifications,
|
||||
} from '#src/api/index.js';
|
||||
import { createUserByAdmin } from '#src/helpers/index.js';
|
||||
|
||||
describe('admin console user management (mfa verifications)', () => {
|
||||
it('should get empty list successfully', async () => {
|
||||
const user = await createUserByAdmin();
|
||||
|
||||
const mfaVerifications = await getUserMfaVerifications(user.id);
|
||||
expect(mfaVerifications.length).toBe(0);
|
||||
await deleteUser(user.id);
|
||||
});
|
||||
|
||||
it('should create TOTP', async () => {
|
||||
const user = await createUserByAdmin();
|
||||
|
||||
const response = await createUserMfaVerification(user.id, MfaFactor.TOTP);
|
||||
expect(response).toHaveProperty('secret');
|
||||
expect(response).toHaveProperty('secretQrCode');
|
||||
const mfaVerifications = await getUserMfaVerifications(user.id);
|
||||
expect(mfaVerifications.length).toBe(1);
|
||||
expect(mfaVerifications[0]).toHaveProperty('type', MfaFactor.TOTP);
|
||||
await deleteUser(user.id);
|
||||
});
|
||||
|
||||
it('should create backup code', async () => {
|
||||
const user = await createUserByAdmin();
|
||||
|
||||
await createUserMfaVerification(user.id, MfaFactor.TOTP);
|
||||
const response = await createUserMfaVerification(user.id, MfaFactor.BackupCode);
|
||||
expect(response).toHaveProperty('codes');
|
||||
const mfaVerifications = await getUserMfaVerifications(user.id);
|
||||
expect(mfaVerifications.length).toBe(2);
|
||||
expect(mfaVerifications.find(({ type }) => type === MfaFactor.BackupCode)).toBeDefined();
|
||||
await deleteUser(user.id);
|
||||
});
|
||||
|
||||
it('should delete verification successfully', async () => {
|
||||
const user = await createUserByAdmin();
|
||||
|
||||
await createUserMfaVerification(user.id, MfaFactor.TOTP);
|
||||
const mfaVerifications = await getUserMfaVerifications(user.id);
|
||||
expect(mfaVerifications.length).toBe(1);
|
||||
if (mfaVerifications[0]) {
|
||||
await deleteUserMfaVerification(user.id, mfaVerifications[0].id);
|
||||
const mfaVerifications2 = await getUserMfaVerifications(user.id);
|
||||
expect(mfaVerifications2.length).toBe(0);
|
||||
}
|
||||
await deleteUser(user.id);
|
||||
});
|
||||
});
|
|
@ -38,6 +38,8 @@ const user = {
|
|||
missing_mfa: 'You need to bind additional MFA before signing-in.',
|
||||
/** UNTRANSLATED */
|
||||
totp_already_in_use: 'TOTP is already in use.',
|
||||
/** UNTRANSLATED */
|
||||
backup_code_already_in_use: 'Backup code is already in use.',
|
||||
};
|
||||
|
||||
export default Object.freeze(user);
|
||||
|
|
|
@ -31,6 +31,7 @@ const user = {
|
|||
invalid_role_type: 'Invalid role type, can not assign machine-to-machine role to user.',
|
||||
missing_mfa: 'You need to bind additional MFA before signing-in.',
|
||||
totp_already_in_use: 'TOTP is already in use.',
|
||||
backup_code_already_in_use: 'Backup code is already in use.',
|
||||
};
|
||||
|
||||
export default Object.freeze(user);
|
||||
|
|
|
@ -35,6 +35,8 @@ const user = {
|
|||
missing_mfa: 'You need to bind additional MFA before signing-in.',
|
||||
/** UNTRANSLATED */
|
||||
totp_already_in_use: 'TOTP is already in use.',
|
||||
/** UNTRANSLATED */
|
||||
backup_code_already_in_use: 'Backup code is already in use.',
|
||||
};
|
||||
|
||||
export default Object.freeze(user);
|
||||
|
|
|
@ -34,6 +34,8 @@ const user = {
|
|||
missing_mfa: 'You need to bind additional MFA before signing-in.',
|
||||
/** UNTRANSLATED */
|
||||
totp_already_in_use: 'TOTP is already in use.',
|
||||
/** UNTRANSLATED */
|
||||
backup_code_already_in_use: 'Backup code is already in use.',
|
||||
};
|
||||
|
||||
export default Object.freeze(user);
|
||||
|
|
|
@ -34,6 +34,8 @@ const user = {
|
|||
missing_mfa: 'You need to bind additional MFA before signing-in.',
|
||||
/** UNTRANSLATED */
|
||||
totp_already_in_use: 'TOTP is already in use.',
|
||||
/** UNTRANSLATED */
|
||||
backup_code_already_in_use: 'Backup code is already in use.',
|
||||
};
|
||||
|
||||
export default Object.freeze(user);
|
||||
|
|
|
@ -34,6 +34,8 @@ const user = {
|
|||
missing_mfa: 'You need to bind additional MFA before signing-in.',
|
||||
/** UNTRANSLATED */
|
||||
totp_already_in_use: 'TOTP is already in use.',
|
||||
/** UNTRANSLATED */
|
||||
backup_code_already_in_use: 'Backup code is already in use.',
|
||||
};
|
||||
|
||||
export default Object.freeze(user);
|
||||
|
|
|
@ -33,6 +33,8 @@ const user = {
|
|||
missing_mfa: 'You need to bind additional MFA before signing-in.',
|
||||
/** UNTRANSLATED */
|
||||
totp_already_in_use: 'TOTP is already in use.',
|
||||
/** UNTRANSLATED */
|
||||
backup_code_already_in_use: 'Backup code is already in use.',
|
||||
};
|
||||
|
||||
export default Object.freeze(user);
|
||||
|
|
|
@ -34,6 +34,8 @@ const user = {
|
|||
missing_mfa: 'You need to bind additional MFA before signing-in.',
|
||||
/** UNTRANSLATED */
|
||||
totp_already_in_use: 'TOTP is already in use.',
|
||||
/** UNTRANSLATED */
|
||||
backup_code_already_in_use: 'Backup code is already in use.',
|
||||
};
|
||||
|
||||
export default Object.freeze(user);
|
||||
|
|
|
@ -34,6 +34,8 @@ const user = {
|
|||
missing_mfa: 'You need to bind additional MFA before signing-in.',
|
||||
/** UNTRANSLATED */
|
||||
totp_already_in_use: 'TOTP is already in use.',
|
||||
/** UNTRANSLATED */
|
||||
backup_code_already_in_use: 'Backup code is already in use.',
|
||||
};
|
||||
|
||||
export default Object.freeze(user);
|
||||
|
|
|
@ -34,6 +34,8 @@ const user = {
|
|||
missing_mfa: 'You need to bind additional MFA before signing-in.',
|
||||
/** UNTRANSLATED */
|
||||
totp_already_in_use: 'TOTP is already in use.',
|
||||
/** UNTRANSLATED */
|
||||
backup_code_already_in_use: 'Backup code is already in use.',
|
||||
};
|
||||
|
||||
export default Object.freeze(user);
|
||||
|
|
|
@ -34,6 +34,8 @@ const user = {
|
|||
missing_mfa: 'You need to bind additional MFA before signing-in.',
|
||||
/** UNTRANSLATED */
|
||||
totp_already_in_use: 'TOTP is already in use.',
|
||||
/** UNTRANSLATED */
|
||||
backup_code_already_in_use: 'Backup code is already in use.',
|
||||
};
|
||||
|
||||
export default Object.freeze(user);
|
||||
|
|
|
@ -33,6 +33,8 @@ const user = {
|
|||
missing_mfa: 'You need to bind additional MFA before signing-in.',
|
||||
/** UNTRANSLATED */
|
||||
totp_already_in_use: 'TOTP is already in use.',
|
||||
/** UNTRANSLATED */
|
||||
backup_code_already_in_use: 'Backup code is already in use.',
|
||||
};
|
||||
|
||||
export default Object.freeze(user);
|
||||
|
|
|
@ -32,6 +32,8 @@ const user = {
|
|||
missing_mfa: 'You need to bind additional MFA before signing-in.',
|
||||
/** UNTRANSLATED */
|
||||
totp_already_in_use: 'TOTP is already in use.',
|
||||
/** UNTRANSLATED */
|
||||
backup_code_already_in_use: 'Backup code is already in use.',
|
||||
};
|
||||
|
||||
export default Object.freeze(user);
|
||||
|
|
|
@ -32,6 +32,8 @@ const user = {
|
|||
missing_mfa: 'You need to bind additional MFA before signing-in.',
|
||||
/** UNTRANSLATED */
|
||||
totp_already_in_use: 'TOTP is already in use.',
|
||||
/** UNTRANSLATED */
|
||||
backup_code_already_in_use: 'Backup code is already in use.',
|
||||
};
|
||||
|
||||
export default Object.freeze(user);
|
||||
|
|
|
@ -32,6 +32,8 @@ const user = {
|
|||
missing_mfa: 'You need to bind additional MFA before signing-in.',
|
||||
/** UNTRANSLATED */
|
||||
totp_already_in_use: 'TOTP is already in use.',
|
||||
/** UNTRANSLATED */
|
||||
backup_code_already_in_use: 'Backup code is already in use.',
|
||||
};
|
||||
|
||||
export default Object.freeze(user);
|
||||
|
|
Loading…
Add table
Reference in a new issue