mirror of
https://github.com/logto-io/logto.git
synced 2025-03-31 22:51:25 -05:00
feat(core,schemas): verify backup code (#4695)
This commit is contained in:
parent
87df417d1a
commit
a20e9a2641
8 changed files with 271 additions and 9 deletions
|
@ -40,6 +40,13 @@ export const mockUserWebAuthnMfaVerification = {
|
|||
agent: 'agent',
|
||||
} satisfies User['mfaVerifications'][number];
|
||||
|
||||
export const mockUserBackupCodeMfaVerification = {
|
||||
id: 'fake_backup_code_id',
|
||||
type: MfaFactor.BackupCode,
|
||||
createdAt: new Date().toISOString(),
|
||||
codes: [{ code: 'code' }],
|
||||
} satisfies User['mfaVerifications'][number];
|
||||
|
||||
export const mockUserWithMfaVerifications: User = {
|
||||
...mockUser,
|
||||
mfaVerifications: [mockUserTotpMfaVerification],
|
||||
|
|
|
@ -2,7 +2,10 @@ import { InteractionEvent, MfaFactor } from '@logto/schemas';
|
|||
import { createMockUtils } from '@logto/shared/esm';
|
||||
import type Provider from 'oidc-provider';
|
||||
|
||||
import { mockUserWebAuthnMfaVerification } from '#src/__mocks__/user.js';
|
||||
import {
|
||||
mockUserBackupCodeMfaVerification,
|
||||
mockUserWebAuthnMfaVerification,
|
||||
} from '#src/__mocks__/user.js';
|
||||
import {
|
||||
mockBindWebAuthn,
|
||||
mockBindWebAuthnPayload,
|
||||
|
@ -342,4 +345,79 @@ describe('verifyMfaPayloadVerification', () => {
|
|||
).rejects.toEqual(new RequestError('session.mfa.webauthn_verification_failed'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('backup code', () => {
|
||||
it('should return result of VerifyMfaResult and mark as used', async () => {
|
||||
findUserById.mockResolvedValueOnce({
|
||||
mfaVerifications: [mockUserBackupCodeMfaVerification],
|
||||
});
|
||||
|
||||
await expect(
|
||||
verifyMfaPayloadVerification(
|
||||
tenantContext,
|
||||
{
|
||||
type: MfaFactor.BackupCode,
|
||||
code: 'code',
|
||||
},
|
||||
{ event: InteractionEvent.SignIn },
|
||||
{ rpId: 'rpId', origin: 'origin', accountId: 'accountId' }
|
||||
)
|
||||
).resolves.toMatchObject({
|
||||
type: MfaFactor.BackupCode,
|
||||
id: mockUserBackupCodeMfaVerification.id,
|
||||
});
|
||||
|
||||
expect(findUserById).toHaveBeenCalled();
|
||||
expect(updateUserById).toHaveBeenCalledWith('accountId', {
|
||||
mfaVerifications: [
|
||||
{
|
||||
...mockUserBackupCodeMfaVerification,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
codes: [{ code: 'code', usedAt: expect.anything() }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject when backup code can not be found in user mfaVerifications', async () => {
|
||||
findUserById.mockResolvedValueOnce({
|
||||
mfaVerifications: [],
|
||||
});
|
||||
await expect(
|
||||
verifyMfaPayloadVerification(
|
||||
tenantContext,
|
||||
{
|
||||
type: MfaFactor.BackupCode,
|
||||
code: 'code',
|
||||
},
|
||||
{ event: InteractionEvent.SignIn },
|
||||
{ rpId: 'rpId', origin: 'origin', accountId: 'accountId' }
|
||||
)
|
||||
).rejects.toEqual(new RequestError('session.mfa.invalid_backup_code'));
|
||||
});
|
||||
|
||||
it('should reject when code is used', async () => {
|
||||
findUserById.mockResolvedValueOnce({
|
||||
mfaVerifications: [
|
||||
{
|
||||
...mockUserBackupCodeMfaVerification,
|
||||
codes: [{ code: 'code', usedAt: new Date().toISOString() }],
|
||||
},
|
||||
],
|
||||
});
|
||||
validateTotpToken.mockReturnValueOnce(false);
|
||||
|
||||
await expect(
|
||||
verifyMfaPayloadVerification(
|
||||
tenantContext,
|
||||
{
|
||||
type: MfaFactor.BackupCode,
|
||||
code: 'code',
|
||||
},
|
||||
{ event: InteractionEvent.SignIn },
|
||||
{ rpId: 'rpId', origin: 'origin', accountId: 'accountId' }
|
||||
)
|
||||
).rejects.toEqual(new RequestError('session.mfa.invalid_backup_code'));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -15,7 +15,10 @@ import {
|
|||
type WebAuthnVerificationPayload,
|
||||
type BindBackupCode,
|
||||
type BindBackupCodePayload,
|
||||
type MfaVerificationBackupCode,
|
||||
type BackupCodeVerificationPayload,
|
||||
} from '@logto/schemas';
|
||||
import { pick } from '@silverhand/essentials';
|
||||
import { isoBase64URL } from '@simplewebauthn/server/helpers';
|
||||
|
||||
import type { WithLogContext } from '#src/middleware/koa-audit-log.js';
|
||||
|
@ -128,6 +131,24 @@ const verifyBindBackupCode = async (
|
|||
return { type, codes };
|
||||
};
|
||||
|
||||
const verifyBackupCode = async (
|
||||
mfaVerifications: User['mfaVerifications'],
|
||||
payload: BackupCodeVerificationPayload
|
||||
): Promise<VerifyMfaResult> => {
|
||||
const backupCode = mfaVerifications.find(
|
||||
(mfa): mfa is MfaVerificationBackupCode => mfa.type === MfaFactor.BackupCode
|
||||
);
|
||||
|
||||
// To make Typescript happy, have to split into 2 assertions, otherwise `backupCode` can be undefined
|
||||
assertThat(backupCode, 'session.mfa.invalid_backup_code');
|
||||
assertThat(
|
||||
backupCode.codes.some((code) => code.code === payload.code && !code.usedAt),
|
||||
'session.mfa.invalid_backup_code'
|
||||
);
|
||||
|
||||
return pick(backupCode, 'id', 'type');
|
||||
};
|
||||
|
||||
export async function bindMfaPayloadVerification(
|
||||
ctx: WithLogContext,
|
||||
bindMfaPayload: BindMfaPayload,
|
||||
|
@ -200,7 +221,6 @@ export async function verifyMfaPayloadVerification(
|
|||
return verifyTotp(user.mfaVerifications, verifyMfaPayload);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (verifyMfaPayload.type === MfaFactor.WebAuthn) {
|
||||
const { result, newCounter } = await verifyWebAuthn(interactionStorage, user.mfaVerifications, {
|
||||
payload: verifyMfaPayload,
|
||||
|
@ -227,5 +247,30 @@ export async function verifyMfaPayloadVerification(
|
|||
return result;
|
||||
}
|
||||
|
||||
throw new Error('Unsupported MFA type');
|
||||
const { id, type } = await verifyBackupCode(user.mfaVerifications, verifyMfaPayload);
|
||||
|
||||
// Mark the backup code as used
|
||||
await tenant.queries.users.updateUserById(accountId, {
|
||||
mfaVerifications: user.mfaVerifications.map((mfa) => {
|
||||
if (mfa.id !== id || mfa.type !== MfaFactor.BackupCode) {
|
||||
return mfa;
|
||||
}
|
||||
|
||||
return {
|
||||
...mfa,
|
||||
codes: mfa.codes.map((code) => {
|
||||
if (code.code !== verifyMfaPayload.code) {
|
||||
return code;
|
||||
}
|
||||
|
||||
return {
|
||||
...code,
|
||||
usedAt: new Date().toISOString(),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
return { id, type };
|
||||
}
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { InteractionEvent, MfaFactor, MfaPolicy } from '@logto/schemas';
|
||||
import { deduplicate } from '@silverhand/essentials';
|
||||
import { type Context } from 'koa';
|
||||
import type Provider from 'oidc-provider';
|
||||
|
||||
|
@ -63,7 +64,7 @@ export const verifyMfa = async (
|
|||
status: 403,
|
||||
},
|
||||
{
|
||||
availableFactors: mfaVerifications.map(({ type }) => type),
|
||||
availableFactors: deduplicate(mfaVerifications.map(({ type }) => type)),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
|
|
@ -56,19 +56,20 @@ type ExpectedErrorInfo = {
|
|||
messageIncludes?: string;
|
||||
};
|
||||
|
||||
export const expectRejects = async (promise: Promise<unknown>, expected: ExpectedErrorInfo) => {
|
||||
export const expectRejects = async <T = void>(
|
||||
promise: Promise<unknown>,
|
||||
expected: ExpectedErrorInfo
|
||||
) => {
|
||||
try {
|
||||
await promise;
|
||||
} catch (error: unknown) {
|
||||
expectRequestError(error, expected);
|
||||
|
||||
return;
|
||||
return expectRequestError<T>(error, expected);
|
||||
}
|
||||
|
||||
fail();
|
||||
};
|
||||
|
||||
export const expectRequestError = (error: unknown, expected: ExpectedErrorInfo) => {
|
||||
export const expectRequestError = <T = void>(error: unknown, expected: ExpectedErrorInfo) => {
|
||||
const { code, statusCode, messageIncludes } = expected;
|
||||
|
||||
if (!(error instanceof RequestError)) {
|
||||
|
@ -80,6 +81,7 @@ export const expectRequestError = (error: unknown, expected: ExpectedErrorInfo)
|
|||
const body = JSON.parse(String(error.response?.body)) as {
|
||||
code: string;
|
||||
message: string;
|
||||
data: T;
|
||||
};
|
||||
|
||||
expect(body.code).toEqual(code);
|
||||
|
@ -89,6 +91,8 @@ export const expectRequestError = (error: unknown, expected: ExpectedErrorInfo)
|
|||
if (messageIncludes) {
|
||||
expect(body.message.includes(messageIncludes)).toBeTruthy();
|
||||
}
|
||||
|
||||
return body.data;
|
||||
};
|
||||
|
||||
const defaultRequestListener: RequestListener = (request, response) => {
|
||||
|
|
|
@ -91,5 +91,13 @@ export const enableMandatoryMfaWithWebAuthn = async () =>
|
|||
},
|
||||
});
|
||||
|
||||
export const enableMandatoryMfaWithTotpAndBackupCode = async () =>
|
||||
updateSignInExperience({
|
||||
mfa: {
|
||||
factors: [MfaFactor.TOTP, MfaFactor.BackupCode],
|
||||
policy: MfaPolicy.Mandatory,
|
||||
},
|
||||
});
|
||||
|
||||
export const resetMfaSettings = async () =>
|
||||
updateSignInExperience({ mfa: { policy: MfaPolicy.UserControlled, factors: [] } });
|
||||
|
|
|
@ -0,0 +1,111 @@
|
|||
import { InteractionEvent, MfaFactor, SignInIdentifier } from '@logto/schemas';
|
||||
import { assert } from '@silverhand/essentials';
|
||||
import { authenticator } from 'otplib';
|
||||
|
||||
import {
|
||||
putInteraction,
|
||||
deleteUser,
|
||||
initTotp,
|
||||
postInteractionBindMfa,
|
||||
putInteractionMfa,
|
||||
} from '#src/api/index.js';
|
||||
import { initClient, processSession, logoutClient } from '#src/helpers/client.js';
|
||||
import { expectRejects } from '#src/helpers/index.js';
|
||||
import {
|
||||
enableAllPasswordSignInMethods,
|
||||
enableMandatoryMfaWithTotpAndBackupCode,
|
||||
} from '#src/helpers/sign-in-experience.js';
|
||||
import { generateNewUserProfile } from '#src/helpers/user.js';
|
||||
|
||||
const registerWithMfa = async () => {
|
||||
const { username, password } = generateNewUserProfile({ username: true, password: true });
|
||||
const client = await initClient();
|
||||
|
||||
await client.send(putInteraction, {
|
||||
event: InteractionEvent.Register,
|
||||
profile: {
|
||||
username,
|
||||
password,
|
||||
},
|
||||
});
|
||||
|
||||
const { secret } = await client.send(initTotp);
|
||||
const code = authenticator.generate(secret);
|
||||
|
||||
await client.send(postInteractionBindMfa, {
|
||||
type: MfaFactor.TOTP,
|
||||
code,
|
||||
});
|
||||
|
||||
const { codes } = await expectRejects<{ codes: string[] }>(client.submitInteraction(), {
|
||||
code: 'session.mfa.backup_code_required',
|
||||
statusCode: 400,
|
||||
});
|
||||
|
||||
await client.send(postInteractionBindMfa, {
|
||||
type: MfaFactor.BackupCode,
|
||||
});
|
||||
|
||||
const { redirectTo } = await client.submitInteraction();
|
||||
|
||||
const id = await processSession(client, redirectTo);
|
||||
await logoutClient(client);
|
||||
|
||||
const backupCode = codes[0];
|
||||
assert(backupCode, new Error('can not find backup code'));
|
||||
|
||||
return { id, username, password, backupCode };
|
||||
};
|
||||
|
||||
describe('sign in and verify mfa (Backup Code)', () => {
|
||||
beforeAll(async () => {
|
||||
await enableAllPasswordSignInMethods({
|
||||
identifiers: [SignInIdentifier.Username],
|
||||
password: true,
|
||||
verify: false,
|
||||
});
|
||||
await enableMandatoryMfaWithTotpAndBackupCode();
|
||||
});
|
||||
|
||||
it('should fail with missing_mfa error for normal sign in', async () => {
|
||||
const { id, username, password } = await registerWithMfa();
|
||||
const client = await initClient();
|
||||
|
||||
await client.successSend(putInteraction, {
|
||||
event: InteractionEvent.SignIn,
|
||||
identifier: {
|
||||
username,
|
||||
password,
|
||||
},
|
||||
});
|
||||
|
||||
await expectRejects(client.submitInteraction(), {
|
||||
code: 'session.mfa.require_mfa_verification',
|
||||
statusCode: 403,
|
||||
});
|
||||
|
||||
await deleteUser(id);
|
||||
});
|
||||
|
||||
it('should sign in successfully', async () => {
|
||||
const { id, username, password, backupCode } = await registerWithMfa();
|
||||
const client = await initClient();
|
||||
|
||||
await client.successSend(putInteraction, {
|
||||
event: InteractionEvent.SignIn,
|
||||
identifier: {
|
||||
username,
|
||||
password,
|
||||
},
|
||||
});
|
||||
|
||||
await client.successSend(putInteractionMfa, {
|
||||
type: MfaFactor.BackupCode,
|
||||
code: backupCode,
|
||||
});
|
||||
|
||||
await client.submitInteraction();
|
||||
|
||||
await deleteUser(id);
|
||||
});
|
||||
});
|
|
@ -172,9 +172,17 @@ export const webAuthnVerificationPayloadGuard = bindWebAuthnPayloadGuard
|
|||
|
||||
export type WebAuthnVerificationPayload = z.infer<typeof webAuthnVerificationPayloadGuard>;
|
||||
|
||||
export const backupCodeVerificationPayloadGuard = z.object({
|
||||
type: z.literal(MfaFactor.BackupCode),
|
||||
code: z.string(),
|
||||
});
|
||||
|
||||
export type BackupCodeVerificationPayload = z.infer<typeof backupCodeVerificationPayloadGuard>;
|
||||
|
||||
export const verifyMfaPayloadGuard = z.discriminatedUnion('type', [
|
||||
totpVerificationPayloadGuard,
|
||||
webAuthnVerificationPayloadGuard,
|
||||
backupCodeVerificationPayloadGuard,
|
||||
]);
|
||||
|
||||
export type VerifyMfaPayload = z.infer<typeof verifyMfaPayloadGuard>;
|
||||
|
|
Loading…
Add table
Reference in a new issue