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:
parent
735dcb67a8
commit
62367da5fc
17 changed files with 421 additions and 21 deletions
|
@ -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 = {
|
export const mockTotpBind: BindMfa = {
|
||||||
type: MfaFactor.TOTP,
|
type: MfaFactor.TOTP,
|
||||||
|
@ -13,3 +13,8 @@ export const mockWebAuthnBind: BindWebAuthn = {
|
||||||
agent: 'agent',
|
agent: 'agent',
|
||||||
transports: [],
|
transports: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const mockBackupCodeBind: BindBackupCode = {
|
||||||
|
type: MfaFactor.BackupCode,
|
||||||
|
codes: ['code'],
|
||||||
|
};
|
||||||
|
|
|
@ -174,6 +174,35 @@ describe('submit action', () => {
|
||||||
['user']
|
['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', () => {
|
describe('sign in with bindMfa', () => {
|
||||||
|
@ -244,5 +273,42 @@ describe('submit action', () => {
|
||||||
login: { accountId: 'foo' },
|
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' },
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -52,7 +52,6 @@ const parseBindMfas = ({
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
||||||
if (bindMfa.type === MfaFactor.WebAuthn) {
|
if (bindMfa.type === MfaFactor.WebAuthn) {
|
||||||
return {
|
return {
|
||||||
...bindMfa,
|
...bindMfa,
|
||||||
|
@ -61,8 +60,12 @@ const parseBindMfas = ({
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Not expected to happen, the above if statements should cover all cases
|
return {
|
||||||
throw new Error('Unsupported MFA factor');
|
id: generateStandardId(),
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
type: MfaFactor.BackupCode,
|
||||||
|
codes: bindMfa.codes.map((code) => ({ code })),
|
||||||
|
};
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -54,6 +54,7 @@ const {
|
||||||
verifyProfile,
|
verifyProfile,
|
||||||
validateMandatoryUserProfile,
|
validateMandatoryUserProfile,
|
||||||
validateMandatoryBindMfa,
|
validateMandatoryBindMfa,
|
||||||
|
validateBindMfaBackupCode,
|
||||||
verifyBindMfa,
|
verifyBindMfa,
|
||||||
verifyMfa,
|
verifyMfa,
|
||||||
} = await mockEsmWithActual('./verifications/index.js', () => ({
|
} = await mockEsmWithActual('./verifications/index.js', () => ({
|
||||||
|
@ -62,6 +63,7 @@ const {
|
||||||
verifyProfile: jest.fn(),
|
verifyProfile: jest.fn(),
|
||||||
validateMandatoryUserProfile: jest.fn(),
|
validateMandatoryUserProfile: jest.fn(),
|
||||||
validateMandatoryBindMfa: jest.fn(),
|
validateMandatoryBindMfa: jest.fn(),
|
||||||
|
validateBindMfaBackupCode: jest.fn(),
|
||||||
verifyBindMfa: jest.fn(),
|
verifyBindMfa: jest.fn(),
|
||||||
verifyMfa: jest.fn(),
|
verifyMfa: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
@ -186,6 +188,9 @@ describe('interaction routes', () => {
|
||||||
validateMandatoryUserProfile.mockReturnValueOnce({
|
validateMandatoryUserProfile.mockReturnValueOnce({
|
||||||
event: InteractionEvent.SignIn,
|
event: InteractionEvent.SignIn,
|
||||||
});
|
});
|
||||||
|
validateMandatoryBindMfa.mockReturnValueOnce({
|
||||||
|
event: InteractionEvent.SignIn,
|
||||||
|
});
|
||||||
verifyBindMfa.mockReturnValueOnce({
|
verifyBindMfa.mockReturnValueOnce({
|
||||||
event: InteractionEvent.SignIn,
|
event: InteractionEvent.SignIn,
|
||||||
});
|
});
|
||||||
|
@ -201,6 +206,7 @@ describe('interaction routes', () => {
|
||||||
expect(validateMandatoryUserProfile).toBeCalled();
|
expect(validateMandatoryUserProfile).toBeCalled();
|
||||||
expect(verifyBindMfa).toBeCalled();
|
expect(verifyBindMfa).toBeCalled();
|
||||||
expect(validateMandatoryBindMfa).toBeCalled();
|
expect(validateMandatoryBindMfa).toBeCalled();
|
||||||
|
expect(validateBindMfaBackupCode).toBeCalled();
|
||||||
expect(submitInteraction).toBeCalled();
|
expect(submitInteraction).toBeCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -244,6 +250,7 @@ describe('interaction routes', () => {
|
||||||
expect(verifyProfile).toBeCalled();
|
expect(verifyProfile).toBeCalled();
|
||||||
expect(validateMandatoryUserProfile).not.toBeCalled();
|
expect(validateMandatoryUserProfile).not.toBeCalled();
|
||||||
expect(validateMandatoryBindMfa).not.toBeCalled();
|
expect(validateMandatoryBindMfa).not.toBeCalled();
|
||||||
|
expect(validateBindMfaBackupCode).not.toBeCalled();
|
||||||
expect(submitInteraction).toBeCalled();
|
expect(submitInteraction).toBeCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -41,6 +41,7 @@ import {
|
||||||
validateMandatoryBindMfa,
|
validateMandatoryBindMfa,
|
||||||
verifyBindMfa,
|
verifyBindMfa,
|
||||||
verifyMfa,
|
verifyMfa,
|
||||||
|
validateBindMfaBackupCode,
|
||||||
} from './verifications/index.js';
|
} from './verifications/index.js';
|
||||||
|
|
||||||
export type RouterContext<T> = T extends Router<unknown, infer Context> ? Context : never;
|
export type RouterContext<T> = T extends Router<unknown, infer Context> ? Context : never;
|
||||||
|
@ -354,10 +355,16 @@ export default function interactionRoutes<T extends AnonymousRouter>(
|
||||||
? mandatoryProfileVerifiedInteraction
|
? mandatoryProfileVerifiedInteraction
|
||||||
: await verifyBindMfa(tenant, mandatoryProfileVerifiedInteraction);
|
: await verifyBindMfa(tenant, mandatoryProfileVerifiedInteraction);
|
||||||
|
|
||||||
const interaction = isForgotPasswordInteractionResult(bindMfaVerifiedInteraction)
|
const mandatoryMfaVerifiedInteraction = isForgotPasswordInteractionResult(
|
||||||
|
bindMfaVerifiedInteraction
|
||||||
|
)
|
||||||
? bindMfaVerifiedInteraction
|
? bindMfaVerifiedInteraction
|
||||||
: await validateMandatoryBindMfa(tenant, ctx, bindMfaVerifiedInteraction);
|
: await validateMandatoryBindMfa(tenant, ctx, bindMfaVerifiedInteraction);
|
||||||
|
|
||||||
|
const interaction = isForgotPasswordInteractionResult(mandatoryMfaVerifiedInteraction)
|
||||||
|
? mandatoryMfaVerifiedInteraction
|
||||||
|
: await validateBindMfaBackupCode(tenant, ctx, mandatoryMfaVerifiedInteraction, provider);
|
||||||
|
|
||||||
await submitInteraction(interaction, ctx, tenant, log);
|
await submitInteraction(interaction, ctx, tenant, log);
|
||||||
|
|
||||||
return next();
|
return next();
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { demoAppApplicationId, InteractionEvent, MfaFactor } from '@logto/schemas';
|
import { demoAppApplicationId, InteractionEvent, MfaFactor } from '@logto/schemas';
|
||||||
import { createMockUtils } from '@logto/shared/esm';
|
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 { mockSignInExperience } from '#src/__mocks__/sign-in-experience.js';
|
||||||
import { mockUserWithMfaVerifications } from '#src/__mocks__/user.js';
|
import { mockUserWithMfaVerifications } from '#src/__mocks__/user.js';
|
||||||
import type koaAuditLog from '#src/middleware/koa-audit-log.js';
|
import type koaAuditLog from '#src/middleware/koa-audit-log.js';
|
||||||
|
@ -116,6 +116,50 @@ describe('interaction routes (MFA verification)', () => {
|
||||||
expect.anything()
|
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', () => {
|
describe('PUT /interaction/mfa', () => {
|
||||||
|
|
|
@ -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 Router from 'koa-router';
|
||||||
import { type IRouterParamContext } from 'koa-router';
|
import { type IRouterParamContext } from 'koa-router';
|
||||||
|
|
||||||
|
@ -53,9 +58,15 @@ export default function mfaRoutes<T extends IRouterParamContext>(
|
||||||
}
|
}
|
||||||
|
|
||||||
const { bindMfas = [] } = interactionStorage;
|
const { bindMfas = [] } = interactionStorage;
|
||||||
// Only allow one factor for now,
|
|
||||||
// TODO @sijie: revisit when implementing backup code factor
|
if (bindMfaPayload.type === MfaFactor.BackupCode) {
|
||||||
assertThat(bindMfas.length === 0, 'session.mfa.bind_mfa_existed');
|
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 { hostname, origin } = EnvSet.values.endpoint;
|
||||||
const bindMfa = await bindMfaPayloadVerification(ctx, bindMfaPayload, interactionStorage, {
|
const bindMfa = await bindMfaPayloadVerification(ctx, bindMfaPayload, interactionStorage, {
|
||||||
|
|
|
@ -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}/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
|
@ -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;
|
||||||
|
};
|
|
@ -181,6 +181,39 @@ describe('bindMfaPayloadVerification', () => {
|
||||||
).rejects.toEqual(new RequestError('session.mfa.webauthn_verification_failed'));
|
).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', () => {
|
describe('verifyMfaPayloadVerification', () => {
|
||||||
|
|
|
@ -13,6 +13,8 @@ import {
|
||||||
type BindWebAuthnPayload,
|
type BindWebAuthnPayload,
|
||||||
type MfaVerifications,
|
type MfaVerifications,
|
||||||
type WebAuthnVerificationPayload,
|
type WebAuthnVerificationPayload,
|
||||||
|
type BindBackupCode,
|
||||||
|
type BindBackupCodePayload,
|
||||||
} from '@logto/schemas';
|
} from '@logto/schemas';
|
||||||
import { isoBase64URL } from '@simplewebauthn/server/helpers';
|
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(
|
export async function bindMfaPayloadVerification(
|
||||||
ctx: WithLogContext,
|
ctx: WithLogContext,
|
||||||
bindMfaPayload: BindMfaPayload,
|
bindMfaPayload: BindMfaPayload,
|
||||||
|
@ -127,7 +146,11 @@ export async function bindMfaPayloadVerification(
|
||||||
return verifyBindTotp(interactionStorage, bindMfaPayload, ctx);
|
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(
|
async function verifyWebAuthn(
|
||||||
|
|
|
@ -2,11 +2,14 @@ import crypto from 'node:crypto';
|
||||||
|
|
||||||
import { PasswordPolicyChecker } from '@logto/core-kit';
|
import { PasswordPolicyChecker } from '@logto/core-kit';
|
||||||
import { InteractionEvent, MfaFactor, MfaPolicy } from '@logto/schemas';
|
import { InteractionEvent, MfaFactor, MfaPolicy } from '@logto/schemas';
|
||||||
|
import { createMockUtils } from '@logto/shared/esm';
|
||||||
import type Provider from 'oidc-provider';
|
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 { mockSignInExperience } from '#src/__mocks__/sign-in-experience.js';
|
||||||
import { mockUser, mockUserWithMfaVerifications } from '#src/__mocks__/user.js';
|
import { mockUser, mockUserWithMfaVerifications } from '#src/__mocks__/user.js';
|
||||||
import RequestError from '#src/errors/RequestError/index.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 { MockTenant } from '#src/test-utils/tenant.js';
|
||||||
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
|
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
|
||||||
|
|
||||||
|
@ -16,6 +19,7 @@ import type {
|
||||||
} from '../types/index.js';
|
} from '../types/index.js';
|
||||||
|
|
||||||
const { jest } = import.meta;
|
const { jest } = import.meta;
|
||||||
|
const { mockEsmWithActual } = createMockUtils(jest);
|
||||||
|
|
||||||
const findUserById = jest.fn();
|
const findUserById = jest.fn();
|
||||||
|
|
||||||
|
@ -25,9 +29,17 @@ const tenantContext = new MockTenant(undefined, {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { validateMandatoryBindMfa, verifyBindMfa, verifyMfa } = await import(
|
const mockBackupCodes = ['foo'];
|
||||||
'./mfa-verification.js'
|
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 = {
|
const baseCtx = {
|
||||||
...createContextWithRouteParameters(),
|
...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 = {
|
const interaction: IdentifierVerifiedInteractionResult = {
|
||||||
event: InteractionEvent.Register,
|
event: InteractionEvent.Register,
|
||||||
identifiers: [{ key: 'accountId', value: 'foo' }],
|
identifiers: [{ key: 'accountId', value: 'foo' }],
|
||||||
|
@ -68,6 +91,8 @@ const signInInteraction: AccountVerifiedInteractionResult = {
|
||||||
accountId: 'foo',
|
accountId: 'foo',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const provider = createMockProvider();
|
||||||
|
|
||||||
describe('validateMandatoryBindMfa', () => {
|
describe('validateMandatoryBindMfa', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
findUserById.mockReset();
|
findUserById.mockReset();
|
||||||
|
@ -235,3 +260,69 @@ describe('verifyMfa', () => {
|
||||||
).rejects.toThrowError();
|
).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()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
@ -1,4 +1,6 @@
|
||||||
import { InteractionEvent, MfaFactor, MfaPolicy } from '@logto/schemas';
|
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 RequestError from '#src/errors/RequestError/index.js';
|
||||||
import type TenantContext from '#src/tenants/TenantContext.js';
|
import type TenantContext from '#src/tenants/TenantContext.js';
|
||||||
|
@ -12,6 +14,8 @@ import {
|
||||||
type VerifiedRegisterInteractionResult,
|
type VerifiedRegisterInteractionResult,
|
||||||
type AccountVerifiedInteractionResult,
|
type AccountVerifiedInteractionResult,
|
||||||
} from '../types/index.js';
|
} from '../types/index.js';
|
||||||
|
import { generateBackupCodes } from '../utils/backup-code-validation.js';
|
||||||
|
import { storeInteractionResult } from '../utils/interaction.js';
|
||||||
|
|
||||||
export const verifyBindMfa = async (
|
export const verifyBindMfa = async (
|
||||||
tenant: TenantContext,
|
tenant: TenantContext,
|
||||||
|
@ -121,3 +125,63 @@ export const validateMandatoryBindMfa = async (
|
||||||
|
|
||||||
return interaction;
|
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 }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
|
@ -7,10 +7,9 @@ export const transpileUserMfaVerifications = (
|
||||||
const { id, createdAt, type } = verification;
|
const { id, createdAt, type } = verification;
|
||||||
|
|
||||||
if (type === MfaFactor.BackupCode) {
|
if (type === MfaFactor.BackupCode) {
|
||||||
const { usedAt } = verification;
|
const { codes } = verification;
|
||||||
const used = Boolean(usedAt);
|
|
||||||
|
|
||||||
return { id, createdAt, type, used };
|
return { id, createdAt, type, remainCodes: codes.filter((code) => !code.usedAt).length };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === MfaFactor.WebAuthn) {
|
if (type === MfaFactor.WebAuthn) {
|
||||||
|
|
|
@ -51,8 +51,7 @@ export type MfaVerificationWebAuthn = z.infer<typeof mfaVerificationWebAuthn>;
|
||||||
export const mfaVerificationBackupCode = z.object({
|
export const mfaVerificationBackupCode = z.object({
|
||||||
type: z.literal(MfaFactor.BackupCode),
|
type: z.literal(MfaFactor.BackupCode),
|
||||||
...baseMfaVerification,
|
...baseMfaVerification,
|
||||||
code: z.string(),
|
codes: z.object({ code: z.string(), usedAt: z.string().optional() }).array(),
|
||||||
usedAt: z.string().optional(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type MfaVerificationBackupCode = z.infer<typeof mfaVerificationBackupCode>;
|
export type MfaVerificationBackupCode = z.infer<typeof mfaVerificationBackupCode>;
|
||||||
|
|
|
@ -141,9 +141,16 @@ export const bindWebAuthnPayloadGuard = z.object({
|
||||||
|
|
||||||
export type BindWebAuthnPayload = z.infer<typeof bindWebAuthnPayloadGuard>;
|
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', [
|
export const bindMfaPayloadGuard = z.discriminatedUnion('type', [
|
||||||
bindTotpPayloadGuard,
|
bindTotpPayloadGuard,
|
||||||
bindWebAuthnPayloadGuard,
|
bindWebAuthnPayloadGuard,
|
||||||
|
bindBackupCodePayloadGuard,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export type BindMfaPayload = z.infer<typeof bindMfaPayloadGuard>;
|
export type BindMfaPayload = z.infer<typeof bindMfaPayloadGuard>;
|
||||||
|
@ -186,11 +193,19 @@ export const pendingWebAuthnGuard = z.object({
|
||||||
|
|
||||||
export type PendingWebAuthn = z.infer<typeof pendingWebAuthnGuard>;
|
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
|
// Some information like TOTP secret should be generated in the backend
|
||||||
// and stored in the interaction temporarily.
|
// and stored in the interaction temporarily.
|
||||||
export const pendingMfaGuard = z.discriminatedUnion('type', [
|
export const pendingMfaGuard = z.discriminatedUnion('type', [
|
||||||
pendingTotpGuard,
|
pendingTotpGuard,
|
||||||
pendingWebAuthnGuard,
|
pendingWebAuthnGuard,
|
||||||
|
pendingBackupCodeGuard,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export type PendingMfa = z.infer<typeof pendingMfaGuard>;
|
export type PendingMfa = z.infer<typeof pendingMfaGuard>;
|
||||||
|
@ -210,8 +225,16 @@ export const bindWebAuthnGuard = z.object({
|
||||||
|
|
||||||
export type BindWebAuthn = z.infer<typeof bindWebAuthnGuard>;
|
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.
|
// 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>;
|
export type BindMfa = z.infer<typeof bindMfaGuard>;
|
||||||
|
|
||||||
|
|
|
@ -36,7 +36,7 @@ export const userMfaVerificationResponseGuard = z
|
||||||
createdAt: z.string(),
|
createdAt: z.string(),
|
||||||
type: z.nativeEnum(MfaFactor),
|
type: z.nativeEnum(MfaFactor),
|
||||||
agent: z.string().optional(),
|
agent: z.string().optional(),
|
||||||
used: z.boolean().optional(),
|
remainCodes: z.number().optional(),
|
||||||
})
|
})
|
||||||
.array();
|
.array();
|
||||||
|
|
||||||
|
|
Loading…
Add table
Reference in a new issue