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 = {
|
||||
type: MfaFactor.TOTP,
|
||||
|
@ -13,3 +13,8 @@ export const mockWebAuthnBind: BindWebAuthn = {
|
|||
agent: 'agent',
|
||||
transports: [],
|
||||
};
|
||||
|
||||
export const mockBackupCodeBind: BindBackupCode = {
|
||||
type: MfaFactor.BackupCode,
|
||||
codes: ['code'],
|
||||
};
|
||||
|
|
|
@ -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' },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 })),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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, {
|
||||
|
|
|
@ -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'));
|
||||
});
|
||||
});
|
||||
|
||||
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', () => {
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 }
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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>;
|
||||
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue