0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-30 20:33:54 -05:00

feat(core): initial implementation of user set mfa secrets / codes (#6654)

* feat(core): initial implementation of user set mfa secrets / codes

* refactor(core): move totp secret validation to util

* chore(core): fix openapi document

* chore(core): fix unit tests

---------

Co-authored-by: wangsijie <wangsijie@silverhand.io>
This commit is contained in:
Amin Tarbalouti 2024-10-29 08:59:13 +01:00 committed by GitHub
parent 7620479130
commit 60a6d677d7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 174 additions and 14 deletions

View file

@ -15,11 +15,39 @@
"content": {
"application/json": {
"schema": {
"properties": {
"type": {
"description": "The type of MFA verification to create."
"oneOf": [
{
"type": "object",
"properties": {
"type": {
"type": "string",
"description": "The type of MFA verification to create."
},
"secret": {
"type": "string",
"description": "The secret for the MFA verification, if not provided, a new secret will be generated."
}
},
"required": ["type"]
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"description": "The type of MFA verification to create."
},
"codes": {
"type": "array",
"items": {
"type": "string"
},
"description": "The backup codes for the MFA verification, if not provided, a new group of backup codes will be generated."
}
},
"required": ["type"]
}
}
]
}
}
}

View file

@ -59,6 +59,19 @@ const usersLibraries = {
),
} satisfies Partial<Libraries['users']>;
const codes = [
'd94c2f29ae',
'74fa801bb7',
'2cbcc9323c',
'87299f89aa',
'0d95df8598',
'78eedbf35d',
'0fa4c1fd19',
'7384b69eb5',
'7bf2481db7',
'f00febc9ae',
];
const adminUserRoutes = await pickDefault(import('./mfa-verifications.js'));
describe('adminUserRoutes', () => {
@ -103,6 +116,29 @@ describe('adminUserRoutes', () => {
});
expect(response.status).toEqual(422);
});
it('should fail for malformed secret', async () => {
findUserById.mockResolvedValueOnce(mockUser);
const response = await userRequest.post(`/users/${mockUser.id}/mfa-verifications`).send({
type: MfaFactor.TOTP,
secret: 'invalid_secret',
});
expect(response.status).toEqual(422);
});
it('should return supplied secret', async () => {
findUserById.mockResolvedValueOnce(mockUser);
const response = await userRequest.post(`/users/${mockUser.id}/mfa-verifications`).send({
type: MfaFactor.TOTP,
secret: 'JBSWY3DPEHPK3PXP',
});
expect(response.body).toMatchObject({
type: MfaFactor.TOTP,
secret: 'JBSWY3DPEHPK3PXP',
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
secretQrCode: expect.stringContaining('data:image'),
});
});
});
});
@ -142,6 +178,45 @@ describe('adminUserRoutes', () => {
});
expect(response.status).toEqual(422);
});
it('should fail for wrong length', async () => {
findUserById.mockResolvedValueOnce({
...mockUser,
mfaVerifications: [mockUserTotpMfaVerification],
});
const response = await userRequest.post(`/users/${mockUser.id}/mfa-verifications`).send({
type: MfaFactor.BackupCode,
codes: ['wrong-code'],
});
expect(response.status).toEqual(422);
});
it('should fail for wrong characters', async () => {
findUserById.mockResolvedValueOnce({
...mockUser,
mfaVerifications: [mockUserTotpMfaVerification],
});
const response = await userRequest.post(`/users/${mockUser.id}/mfa-verifications`).send({
type: MfaFactor.BackupCode,
codes: [...codes, '0fa4c1xd19'],
});
expect(response.status).toEqual(422);
});
it('should return the supplied codes', async () => {
findUserById.mockResolvedValueOnce({
...mockUser,
mfaVerifications: [mockUserTotpMfaVerification],
});
const response = await userRequest.post(`/users/${mockUser.id}/mfa-verifications`).send({
type: MfaFactor.BackupCode,
codes,
});
expect(response.body).toMatchObject({
type: MfaFactor.BackupCode,
codes,
});
});
});
it('DELETE /users/:userId/mfa-verifications/:verificationId', async () => {

View file

@ -9,8 +9,11 @@ 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 {
generateBackupCodes,
validateBackupCodes,
} from '../interaction/utils/backup-code-validation.js';
import { generateTotpSecret, validateTotpSecret } from '../interaction/utils/totp-validation.js';
import type { ManagementApiRouter, RouterInitArgs } from '../types.js';
export default function adminUserMfaVerificationsRoutes<T extends ManagementApiRouter>(
@ -47,9 +50,16 @@ export default function adminUserMfaVerificationsRoutes<T extends ManagementApiR
'/users/:userId/mfa-verifications',
koaGuard({
params: object({ userId: string() }),
body: z.object({
type: z.literal(MfaFactor.TOTP).or(z.literal(MfaFactor.BackupCode)),
}),
body: z.discriminatedUnion('type', [
z.object({
type: z.literal(MfaFactor.TOTP),
secret: z.string().optional(),
}),
z.object({
type: z.literal(MfaFactor.BackupCode),
codes: z.string().array().optional(),
}),
]),
response: z.discriminatedUnion('type', [
z.object({
type: z.literal(MfaFactor.TOTP),
@ -79,7 +89,17 @@ export default function adminUserMfaVerificationsRoutes<T extends ManagementApiR
})
);
const secret = generateTotpSecret();
if (ctx.guard.body.secret) {
assertThat(
validateTotpSecret(ctx.guard.body.secret),
new RequestError({
code: 'user.totp_secret_invalid',
status: 422,
})
);
}
const secret = ctx.guard.body.secret ?? generateTotpSecret();
const service = ctx.URL.hostname;
const user = getUserDisplayName({ username, primaryEmail, primaryPhone, name });
const keyUri = authenticator.keyuri(user ?? 'Unnamed User', service, secret);
@ -111,7 +131,16 @@ export default function adminUserMfaVerificationsRoutes<T extends ManagementApiR
status: 422,
})
);
const codes = generateBackupCodes();
if (ctx.guard.body.codes) {
assertThat(
validateBackupCodes(ctx.guard.body.codes),
new RequestError({
code: 'user.wrong_backup_code_format',
status: 422,
})
);
}
const codes = ctx.guard.body.codes ?? generateBackupCodes();
await addUserMfaVerification(id, { type: MfaFactor.BackupCode, codes });
ctx.body = { codes, type: MfaFactor.BackupCode };
return next();

View file

@ -8,6 +8,13 @@ const alphabet = '0123456789abcdef';
* The code is a 10-digit string of letters from 1 to f.
*/
export const generateBackupCodes = () => {
const codes = Array.from({ length: backupCodeCount }, () => customAlphabet(alphabet, 10)());
return codes;
return Array.from({ length: backupCodeCount }, () => customAlphabet(alphabet, 10)());
};
/**
* Validates a group of backup codes.
* @param codes
*/
export const validateBackupCodes = (codes: string[]) => {
return codes.length === backupCodeCount && codes.every((code) => /^[\da-f]{10}$/i.test(code));
};

View file

@ -1,6 +1,8 @@
const { jest } = import.meta;
const { generateTotpSecret, validateTotpToken } = await import('./totp-validation.js');
const { generateTotpSecret, validateTotpToken, validateTotpSecret } = await import(
'./totp-validation.js'
);
describe('generateTotpSecret', () => {
it('should generate a secret', () => {
@ -8,6 +10,16 @@ describe('generateTotpSecret', () => {
});
});
describe('validateTotpSecret', () => {
it('should return true on valid secret', () => {
expect(validateTotpSecret(generateTotpSecret())).toBe(true);
});
it('should return false on invalid secret', () => {
expect(validateTotpSecret('invalid')).toBe(false);
});
});
describe('validateTotpToken', () => {
beforeAll(() => {
jest.useFakeTimers();

View file

@ -12,6 +12,13 @@ authenticator.options = { window: 1 };
export const generateTotpSecret = () => authenticator.generateSecret();
export const validateTotpSecret = (secret: string) => {
const base32Regex =
/^(?:[2-7A-Z]{8})*(?:[2-7A-Z]{2}={6}|[2-7A-Z]{4}={4}|[2-7A-Z]{5}={3}|[2-7A-Z]{7}=)?$/;
return secret.length >= 16 && secret.length <= 32 && base32Regex.test(secret);
};
export const validateTotpToken = (secret: string, token: string) => {
return authenticator.check(token, secret);
};

View file

@ -35,6 +35,8 @@ const user = {
password_algorithm_required: 'Password algorithm is required.',
password_and_digest: 'You cannot set both plain text password and password digest.',
personal_access_token_name_exists: 'Personal access token name already exists.',
totp_secret_invalid: 'Invalid TOTP secret supplied.',
wrong_backup_code_format: 'Backup code format is invalid.',
};
export default Object.freeze(user);