0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-10 22:22:45 -05:00

feat(core,schemas): bind backup code (#4690)

This commit is contained in:
wangsijie 2023-10-23 12:12:39 +08:00 committed by GitHub
parent 735dcb67a8
commit 62367da5fc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 421 additions and 21 deletions

View file

@ -1,4 +1,4 @@
import { MfaFactor, type BindMfa, type BindWebAuthn } from '@logto/schemas';
import { MfaFactor, type BindMfa, type BindWebAuthn, type BindBackupCode } from '@logto/schemas';
export const mockTotpBind: BindMfa = {
type: MfaFactor.TOTP,
@ -13,3 +13,8 @@ export const mockWebAuthnBind: BindWebAuthn = {
agent: 'agent',
transports: [],
};
export const mockBackupCodeBind: BindBackupCode = {
type: MfaFactor.BackupCode,
codes: ['code'],
};

View file

@ -174,6 +174,35 @@ describe('submit action', () => {
['user']
);
});
it('should handle backup code', async () => {
const interaction: VerifiedRegisterInteractionResult = {
event: InteractionEvent.Register,
profile,
identifiers,
bindMfas: [{ type: MfaFactor.BackupCode, codes: ['code1', 'code2'] }],
};
await submitInteraction(interaction, ctx, tenant);
expect(generateUserId).toBeCalled();
expect(hasActiveUsers).not.toBeCalled();
expect(insertUser).toBeCalledWith(
{
id: 'uid',
mfaVerifications: [
{
type: MfaFactor.BackupCode,
codes: [{ code: 'code1' }, { code: 'code2' }],
id: 'uid',
createdAt: new Date(now).toISOString(),
},
],
...upsertProfile,
},
['user']
);
});
});
describe('sign in with bindMfa', () => {
@ -244,5 +273,42 @@ describe('submit action', () => {
login: { accountId: 'foo' },
});
});
it('should handle backup code', async () => {
getLogtoConnectorById.mockResolvedValueOnce({
metadata: { target: 'logto' },
dbEntry: { syncProfile: false },
});
const interaction: VerifiedSignInInteractionResult = {
event: InteractionEvent.SignIn,
accountId: 'foo',
identifiers,
bindMfas: [
{
type: MfaFactor.BackupCode,
codes: ['code'],
},
],
};
await submitInteraction(interaction, ctx, tenant);
expect(getLogtoConnectorById).toBeCalledWith('logto');
expect(updateUserById).toBeCalledWith('foo', {
mfaVerifications: [
{
type: MfaFactor.BackupCode,
codes: [{ code: 'code' }],
id: 'uid',
createdAt: new Date(now).toISOString(),
},
],
lastSignInAt: now,
});
expect(assignInteractionResults).toBeCalledWith(ctx, tenant.provider, {
login: { accountId: 'foo' },
});
});
});
});

View file

@ -52,7 +52,6 @@ const parseBindMfas = ({
};
}
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (bindMfa.type === MfaFactor.WebAuthn) {
return {
...bindMfa,
@ -61,8 +60,12 @@ const parseBindMfas = ({
};
}
// Not expected to happen, the above if statements should cover all cases
throw new Error('Unsupported MFA factor');
return {
id: generateStandardId(),
createdAt: new Date().toISOString(),
type: MfaFactor.BackupCode,
codes: bindMfa.codes.map((code) => ({ code })),
};
});
};

View file

@ -54,6 +54,7 @@ const {
verifyProfile,
validateMandatoryUserProfile,
validateMandatoryBindMfa,
validateBindMfaBackupCode,
verifyBindMfa,
verifyMfa,
} = await mockEsmWithActual('./verifications/index.js', () => ({
@ -62,6 +63,7 @@ const {
verifyProfile: jest.fn(),
validateMandatoryUserProfile: jest.fn(),
validateMandatoryBindMfa: jest.fn(),
validateBindMfaBackupCode: jest.fn(),
verifyBindMfa: jest.fn(),
verifyMfa: jest.fn(),
}));
@ -186,6 +188,9 @@ describe('interaction routes', () => {
validateMandatoryUserProfile.mockReturnValueOnce({
event: InteractionEvent.SignIn,
});
validateMandatoryBindMfa.mockReturnValueOnce({
event: InteractionEvent.SignIn,
});
verifyBindMfa.mockReturnValueOnce({
event: InteractionEvent.SignIn,
});
@ -201,6 +206,7 @@ describe('interaction routes', () => {
expect(validateMandatoryUserProfile).toBeCalled();
expect(verifyBindMfa).toBeCalled();
expect(validateMandatoryBindMfa).toBeCalled();
expect(validateBindMfaBackupCode).toBeCalled();
expect(submitInteraction).toBeCalled();
});
@ -244,6 +250,7 @@ describe('interaction routes', () => {
expect(verifyProfile).toBeCalled();
expect(validateMandatoryUserProfile).not.toBeCalled();
expect(validateMandatoryBindMfa).not.toBeCalled();
expect(validateBindMfaBackupCode).not.toBeCalled();
expect(submitInteraction).toBeCalled();
});
});

View file

@ -41,6 +41,7 @@ import {
validateMandatoryBindMfa,
verifyBindMfa,
verifyMfa,
validateBindMfaBackupCode,
} from './verifications/index.js';
export type RouterContext<T> = T extends Router<unknown, infer Context> ? Context : never;
@ -354,10 +355,16 @@ export default function interactionRoutes<T extends AnonymousRouter>(
? mandatoryProfileVerifiedInteraction
: await verifyBindMfa(tenant, mandatoryProfileVerifiedInteraction);
const interaction = isForgotPasswordInteractionResult(bindMfaVerifiedInteraction)
const mandatoryMfaVerifiedInteraction = isForgotPasswordInteractionResult(
bindMfaVerifiedInteraction
)
? bindMfaVerifiedInteraction
: await validateMandatoryBindMfa(tenant, ctx, bindMfaVerifiedInteraction);
const interaction = isForgotPasswordInteractionResult(mandatoryMfaVerifiedInteraction)
? mandatoryMfaVerifiedInteraction
: await validateBindMfaBackupCode(tenant, ctx, mandatoryMfaVerifiedInteraction, provider);
await submitInteraction(interaction, ctx, tenant, log);
return next();

View file

@ -1,7 +1,7 @@
import { demoAppApplicationId, InteractionEvent, MfaFactor } from '@logto/schemas';
import { createMockUtils } from '@logto/shared/esm';
import { mockTotpBind } from '#src/__mocks__/mfa-verification.js';
import { mockBackupCodeBind, mockTotpBind } from '#src/__mocks__/mfa-verification.js';
import { mockSignInExperience } from '#src/__mocks__/sign-in-experience.js';
import { mockUserWithMfaVerifications } from '#src/__mocks__/user.js';
import type koaAuditLog from '#src/middleware/koa-audit-log.js';
@ -116,6 +116,50 @@ describe('interaction routes (MFA verification)', () => {
expect.anything()
);
});
it('should throw for multiple non-backup-code', async () => {
getInteractionStorage.mockReturnValueOnce({
event: InteractionEvent.SignIn,
bindMfas: [mockTotpBind],
});
const body = {
type: MfaFactor.TOTP,
code: '123456',
};
const response = await sessionRequest.post(path).send(body);
expect(response.status).toEqual(400);
});
it('should throw when backup code is the only item', async () => {
getInteractionStorage.mockReturnValueOnce({
event: InteractionEvent.SignIn,
bindMfas: [],
});
const body = {
type: MfaFactor.BackupCode,
};
const response = await sessionRequest.post(path).send(body);
expect(response.status).toEqual(400);
});
it('should return 204 for totp and backup code combination', async () => {
getInteractionStorage.mockReturnValueOnce({
event: InteractionEvent.SignIn,
bindMfas: [mockTotpBind],
});
bindMfaPayloadVerification.mockResolvedValueOnce(mockBackupCodeBind);
const body = {
type: MfaFactor.BackupCode,
};
const response = await sessionRequest.post(path).send(body);
expect(response.status).toEqual(204);
});
});
describe('PUT /interaction/mfa', () => {

View file

@ -1,4 +1,9 @@
import { InteractionEvent, bindMfaPayloadGuard, verifyMfaPayloadGuard } from '@logto/schemas';
import {
InteractionEvent,
MfaFactor,
bindMfaPayloadGuard,
verifyMfaPayloadGuard,
} from '@logto/schemas';
import type Router from 'koa-router';
import { type IRouterParamContext } from 'koa-router';
@ -53,9 +58,15 @@ export default function mfaRoutes<T extends IRouterParamContext>(
}
const { bindMfas = [] } = interactionStorage;
// Only allow one factor for now,
// TODO @sijie: revisit when implementing backup code factor
assertThat(bindMfas.length === 0, 'session.mfa.bind_mfa_existed');
if (bindMfaPayload.type === MfaFactor.BackupCode) {
assertThat(
bindMfas.some(({ type }) => type !== MfaFactor.BackupCode),
'session.mfa.backup_code_can_not_be_alone'
);
} else {
assertThat(bindMfas.length === 0, 'session.mfa.bind_mfa_existed');
}
const { hostname, origin } = EnvSet.values.endpoint;
const bindMfa = await bindMfaPayloadVerification(ctx, bindMfaPayload, interactionStorage, {

View file

@ -0,0 +1,12 @@
import { generateBackupCodes } from './backup-code-validation.js';
describe('generateBackupCodes()', () => {
it('should generate a group of random backup codes', () => {
const codes = generateBackupCodes();
expect(codes.length).toEqual(10);
for (const code of codes) {
expect(code.length).toEqual(10);
expect(code).toMatch(/[\da-f]{10}/);
}
});
});

View file

@ -0,0 +1,13 @@
import { customAlphabet } from 'nanoid';
const backupCodeCount = 10;
const alphabet = '0123456789abcdef' as const;
/**
* Generates a group of random backup codes.
* 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;
};

View file

@ -181,6 +181,39 @@ describe('bindMfaPayloadVerification', () => {
).rejects.toEqual(new RequestError('session.mfa.webauthn_verification_failed'));
});
});
describe('backup code', () => {
it('should return result of BindMfa', async () => {
await expect(
bindMfaPayloadVerification(
baseCtx,
{ type: MfaFactor.BackupCode },
{
...interaction,
pendingMfa: {
type: MfaFactor.BackupCode,
codes: ['code'],
},
},
additionalParameters
)
).resolves.toMatchObject({
type: MfaFactor.BackupCode,
codes: ['code'],
});
});
it('should reject when pendingMfa is missing', async () => {
await expect(
bindMfaPayloadVerification(
baseCtx,
{ type: MfaFactor.BackupCode },
interaction,
additionalParameters
)
).rejects.toEqual(new RequestError('session.mfa.pending_info_not_found'));
});
});
});
describe('verifyMfaPayloadVerification', () => {

View file

@ -13,6 +13,8 @@ import {
type BindWebAuthnPayload,
type MfaVerifications,
type WebAuthnVerificationPayload,
type BindBackupCode,
type BindBackupCodePayload,
} from '@logto/schemas';
import { isoBase64URL } from '@simplewebauthn/server/helpers';
@ -109,6 +111,23 @@ const verifyBindWebAuthn = async (
};
};
const verifyBindBackupCode = async (
interactionStorage: AnonymousInteractionResult,
payload: BindBackupCodePayload,
ctx: WithLogContext
): Promise<BindBackupCode> => {
const { event, pendingMfa } = interactionStorage;
ctx.createLog(`Interaction.${event}.BindMfa.BackupCode.Submit`);
assertThat(pendingMfa, 'session.mfa.pending_info_not_found');
assertThat(pendingMfa.type === MfaFactor.BackupCode, 'session.mfa.pending_info_not_found');
const { type } = payload;
const { codes } = pendingMfa;
return { type, codes };
};
export async function bindMfaPayloadVerification(
ctx: WithLogContext,
bindMfaPayload: BindMfaPayload,
@ -127,7 +146,11 @@ export async function bindMfaPayloadVerification(
return verifyBindTotp(interactionStorage, bindMfaPayload, ctx);
}
return verifyBindWebAuthn(interactionStorage, bindMfaPayload, ctx, { rpId, userAgent, origin });
if (bindMfaPayload.type === MfaFactor.WebAuthn) {
return verifyBindWebAuthn(interactionStorage, bindMfaPayload, ctx, { rpId, userAgent, origin });
}
return verifyBindBackupCode(interactionStorage, bindMfaPayload, ctx);
}
async function verifyWebAuthn(

View file

@ -2,11 +2,14 @@ import crypto from 'node:crypto';
import { PasswordPolicyChecker } from '@logto/core-kit';
import { InteractionEvent, MfaFactor, MfaPolicy } from '@logto/schemas';
import { createMockUtils } from '@logto/shared/esm';
import type Provider from 'oidc-provider';
import { mockBackupCodeBind, mockTotpBind } from '#src/__mocks__/mfa-verification.js';
import { mockSignInExperience } from '#src/__mocks__/sign-in-experience.js';
import { mockUser, mockUserWithMfaVerifications } from '#src/__mocks__/user.js';
import RequestError from '#src/errors/RequestError/index.js';
import { createMockProvider } from '#src/test-utils/oidc-provider.js';
import { MockTenant } from '#src/test-utils/tenant.js';
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
@ -16,6 +19,7 @@ import type {
} from '../types/index.js';
const { jest } = import.meta;
const { mockEsmWithActual } = createMockUtils(jest);
const findUserById = jest.fn();
@ -25,9 +29,17 @@ const tenantContext = new MockTenant(undefined, {
},
});
const { validateMandatoryBindMfa, verifyBindMfa, verifyMfa } = await import(
'./mfa-verification.js'
);
const mockBackupCodes = ['foo'];
await mockEsmWithActual('../utils/backup-code-validation.js', () => ({
generateBackupCodes: jest.fn().mockReturnValue(mockBackupCodes),
}));
const { storeInteractionResult } = await mockEsmWithActual('../utils/interaction.js', () => ({
storeInteractionResult: jest.fn(),
}));
const { validateMandatoryBindMfa, verifyBindMfa, verifyMfa, validateBindMfaBackupCode } =
await import('./mfa-verification.js');
const baseCtx = {
...createContextWithRouteParameters(),
@ -57,6 +69,17 @@ const mfaRequiredCtx = {
},
};
const backupCodeEnabledCtx = {
...baseCtx,
signInExperience: {
...mockSignInExperience,
mfa: {
factors: [MfaFactor.TOTP, MfaFactor.WebAuthn, MfaFactor.BackupCode],
policy: MfaPolicy.Mandatory,
},
},
};
const interaction: IdentifierVerifiedInteractionResult = {
event: InteractionEvent.Register,
identifiers: [{ key: 'accountId', value: 'foo' }],
@ -68,6 +91,8 @@ const signInInteraction: AccountVerifiedInteractionResult = {
accountId: 'foo',
};
const provider = createMockProvider();
describe('validateMandatoryBindMfa', () => {
afterEach(() => {
findUserById.mockReset();
@ -235,3 +260,69 @@ describe('verifyMfa', () => {
).rejects.toThrowError();
});
});
describe('validateBindMfaBackupCode', () => {
it('should pass if bindMfas is empty', async () => {
await expect(
validateBindMfaBackupCode(tenantContext, baseCtx, signInInteraction, provider)
).resolves.not.toThrow();
});
it('should pass if backup code is not enabled', async () => {
await expect(
validateBindMfaBackupCode(
tenantContext,
mfaRequiredCtx,
{
...signInInteraction,
bindMfas: [mockTotpBind],
},
provider
)
).resolves.not.toThrow();
});
it('should pass if backup code is set', async () => {
await expect(
validateBindMfaBackupCode(
tenantContext,
backupCodeEnabledCtx,
{
...signInInteraction,
bindMfas: [mockTotpBind, mockBackupCodeBind],
},
provider
)
).resolves.not.toThrow();
});
it('should reject if backup code is not set', async () => {
findUserById.mockResolvedValueOnce(mockUserWithMfaVerifications);
await expect(
validateBindMfaBackupCode(
tenantContext,
backupCodeEnabledCtx,
{
...signInInteraction,
bindMfas: [mockTotpBind],
},
provider
)
).rejects.toThrowError(
new RequestError(
{ code: 'session.mfa.backup_code_required', status: 422 },
{ codes: mockBackupCodes }
)
);
expect(storeInteractionResult).toHaveBeenCalledWith(
{
pendingMfa: { type: MfaFactor.BackupCode, codes: mockBackupCodes },
},
expect.anything(),
expect.anything(),
expect.anything()
);
});
});

View file

@ -1,4 +1,6 @@
import { InteractionEvent, MfaFactor, MfaPolicy } from '@logto/schemas';
import { type Context } from 'koa';
import type Provider from 'oidc-provider';
import RequestError from '#src/errors/RequestError/index.js';
import type TenantContext from '#src/tenants/TenantContext.js';
@ -12,6 +14,8 @@ import {
type VerifiedRegisterInteractionResult,
type AccountVerifiedInteractionResult,
} from '../types/index.js';
import { generateBackupCodes } from '../utils/backup-code-validation.js';
import { storeInteractionResult } from '../utils/interaction.js';
export const verifyBindMfa = async (
tenant: TenantContext,
@ -121,3 +125,63 @@ export const validateMandatoryBindMfa = async (
return interaction;
};
/**
* Check if backup code is configured, if backup code is enabled in sign-in experience,
* and at least one MFA is configured, then backup code is required.
*/
export const validateBindMfaBackupCode = async (
tenant: TenantContext,
ctx: Context & WithInteractionSieContext & WithInteractionDetailsContext,
interaction: VerifiedSignInInteractionResult | VerifiedRegisterInteractionResult,
provider: Provider
): Promise<VerifiedInteractionResult> => {
const {
mfa: { factors },
} = ctx.signInExperience;
const { bindMfas = [], event } = interaction;
if (
!factors.includes(MfaFactor.BackupCode) ||
bindMfas.length === 0 ||
bindMfas.some(({ type }) => type === MfaFactor.BackupCode)
) {
return interaction;
}
if (event === InteractionEvent.SignIn) {
const { accountId } = interaction;
const { mfaVerifications } = await tenant.queries.users.findUserById(accountId);
if (
mfaVerifications.some((verification) => {
return (
verification.type === MfaFactor.BackupCode &&
verification.codes.some((code) => !code.usedAt)
);
})
) {
// Skip check if there is a backup code that is not used
return interaction;
}
}
const codes = generateBackupCodes();
await storeInteractionResult(
{
pendingMfa: { type: MfaFactor.BackupCode, codes },
},
ctx,
provider,
true
);
throw new RequestError(
{
code: 'session.mfa.backup_code_required',
},
// Send backup codes to client, so that user can download them
{ codes }
);
};

View file

@ -7,10 +7,9 @@ export const transpileUserMfaVerifications = (
const { id, createdAt, type } = verification;
if (type === MfaFactor.BackupCode) {
const { usedAt } = verification;
const used = Boolean(usedAt);
const { codes } = verification;
return { id, createdAt, type, used };
return { id, createdAt, type, remainCodes: codes.filter((code) => !code.usedAt).length };
}
if (type === MfaFactor.WebAuthn) {

View file

@ -51,8 +51,7 @@ export type MfaVerificationWebAuthn = z.infer<typeof mfaVerificationWebAuthn>;
export const mfaVerificationBackupCode = z.object({
type: z.literal(MfaFactor.BackupCode),
...baseMfaVerification,
code: z.string(),
usedAt: z.string().optional(),
codes: z.object({ code: z.string(), usedAt: z.string().optional() }).array(),
});
export type MfaVerificationBackupCode = z.infer<typeof mfaVerificationBackupCode>;

View file

@ -141,9 +141,16 @@ export const bindWebAuthnPayloadGuard = z.object({
export type BindWebAuthnPayload = z.infer<typeof bindWebAuthnPayloadGuard>;
export const bindBackupCodePayloadGuard = z.object({
type: z.literal(MfaFactor.BackupCode),
});
export type BindBackupCodePayload = z.infer<typeof bindBackupCodePayloadGuard>;
export const bindMfaPayloadGuard = z.discriminatedUnion('type', [
bindTotpPayloadGuard,
bindWebAuthnPayloadGuard,
bindBackupCodePayloadGuard,
]);
export type BindMfaPayload = z.infer<typeof bindMfaPayloadGuard>;
@ -186,11 +193,19 @@ export const pendingWebAuthnGuard = z.object({
export type PendingWebAuthn = z.infer<typeof pendingWebAuthnGuard>;
export const pendingBackupCodeGuard = z.object({
type: z.literal(MfaFactor.BackupCode),
codes: z.array(z.string()),
});
export type PendingBackupCode = z.infer<typeof pendingBackupCodeGuard>;
// Some information like TOTP secret should be generated in the backend
// and stored in the interaction temporarily.
export const pendingMfaGuard = z.discriminatedUnion('type', [
pendingTotpGuard,
pendingWebAuthnGuard,
pendingBackupCodeGuard,
]);
export type PendingMfa = z.infer<typeof pendingMfaGuard>;
@ -210,8 +225,16 @@ export const bindWebAuthnGuard = z.object({
export type BindWebAuthn = z.infer<typeof bindWebAuthnGuard>;
export const bindBackupCodeGuard = pendingBackupCodeGuard;
export type BindBackupCode = z.infer<typeof bindBackupCodeGuard>;
// The type for binding new mfa verification to a user, not always equals to the pending type.
export const bindMfaGuard = z.discriminatedUnion('type', [bindTotpGuard, bindWebAuthnGuard]);
export const bindMfaGuard = z.discriminatedUnion('type', [
bindTotpGuard,
bindWebAuthnGuard,
bindBackupCodeGuard,
]);
export type BindMfa = z.infer<typeof bindMfaGuard>;

View file

@ -36,7 +36,7 @@ export const userMfaVerificationResponseGuard = z
createdAt: z.string(),
type: z.nativeEnum(MfaFactor),
agent: z.string().optional(),
used: z.boolean().optional(),
remainCodes: z.number().optional(),
})
.array();