0
Fork 0
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:
wangsijie 2023-10-23 16:00:58 +08:00 committed by GitHub
parent 87df417d1a
commit a20e9a2641
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 271 additions and 9 deletions

View file

@ -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],

View file

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

View file

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

View file

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

View file

@ -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) => {

View file

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

View file

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

View file

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