0
Fork 0
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:
wangsijie 2023-10-28 15:00:24 +08:00 committed by GitHub
parent f2b3f39422
commit 8798432a24
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 512 additions and 90 deletions

View file

@ -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 }],
});
});
});

View file

@ -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,
};
};

View file

@ -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);
});
});

View file

@ -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();
}
);
}

View file

@ -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);
}

View 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);
});
});

View 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();
}
);
}

View file

@ -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[] }
>();

View file

@ -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);
});
});

View file

@ -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);

View file

@ -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);

View file

@ -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);

View file

@ -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);

View file

@ -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);

View file

@ -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);

View file

@ -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);

View file

@ -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);

View file

@ -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);

View file

@ -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);

View file

@ -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);

View file

@ -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);

View file

@ -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);

View file

@ -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);

View file

@ -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);