mirror of
https://github.com/logto-io/logto.git
synced 2025-03-10 22:22:45 -05:00
feat(core,schemas,phrases): verify totp to sign in (#4570)
This commit is contained in:
parent
acf460290c
commit
6a32f50d15
32 changed files with 592 additions and 81 deletions
|
@ -64,6 +64,12 @@ export const auditLogEventTitle: Record<string, Optional<string>> &
|
|||
'Interaction.SignIn.BindMfa.BackupCode.Submit': undefined,
|
||||
'Interaction.SignIn.BindMfa.WebAuthn.Create': undefined,
|
||||
'Interaction.SignIn.BindMfa.WebAuthn.Submit': undefined,
|
||||
'Interaction.SignIn.Mfa.Totp.Create': undefined,
|
||||
'Interaction.SignIn.Mfa.Totp.Submit': undefined,
|
||||
'Interaction.SignIn.Mfa.BackupCode.Create': undefined,
|
||||
'Interaction.SignIn.Mfa.BackupCode.Submit': undefined,
|
||||
'Interaction.SignIn.Mfa.WebAuthn.Create': undefined,
|
||||
'Interaction.SignIn.Mfa.WebAuthn.Submit': undefined,
|
||||
RevokeToken: undefined,
|
||||
Unknown: undefined,
|
||||
});
|
||||
|
|
|
@ -55,6 +55,7 @@ const {
|
|||
validateMandatoryUserProfile,
|
||||
validateMandatoryBindMfa,
|
||||
verifyBindMfa,
|
||||
verifyMfa,
|
||||
} = await mockEsmWithActual('./verifications/index.js', () => ({
|
||||
verifyIdentifierPayload: jest.fn(),
|
||||
verifyIdentifier: jest.fn().mockResolvedValue({}),
|
||||
|
@ -62,6 +63,7 @@ const {
|
|||
validateMandatoryUserProfile: jest.fn(),
|
||||
validateMandatoryBindMfa: jest.fn(),
|
||||
verifyBindMfa: jest.fn(),
|
||||
verifyMfa: jest.fn(),
|
||||
}));
|
||||
|
||||
const { storeInteractionResult, mergeIdentifiers, getInteractionStorage } = await mockEsmWithActual(
|
||||
|
@ -174,7 +176,10 @@ describe('interaction routes', () => {
|
|||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should call identifier, profile and bindMfa verification properly', async () => {
|
||||
it('should call identifier, verifyMfa, profile and bindMfa verification properly', async () => {
|
||||
verifyIdentifier.mockReturnValueOnce({
|
||||
event: InteractionEvent.SignIn,
|
||||
});
|
||||
verifyProfile.mockReturnValueOnce({
|
||||
event: InteractionEvent.SignIn,
|
||||
});
|
||||
|
@ -184,10 +189,14 @@ describe('interaction routes', () => {
|
|||
verifyBindMfa.mockReturnValueOnce({
|
||||
event: InteractionEvent.SignIn,
|
||||
});
|
||||
verifyMfa.mockReturnValueOnce({
|
||||
event: InteractionEvent.SignIn,
|
||||
});
|
||||
|
||||
await sessionRequest.post(path).send();
|
||||
expect(getInteractionStorage).toBeCalled();
|
||||
expect(verifyIdentifier).toBeCalled();
|
||||
expect(verifyMfa).toBeCalled();
|
||||
expect(verifyProfile).toBeCalled();
|
||||
expect(validateMandatoryUserProfile).toBeCalled();
|
||||
expect(verifyBindMfa).toBeCalled();
|
||||
|
@ -195,6 +204,25 @@ describe('interaction routes', () => {
|
|||
expect(submitInteraction).toBeCalled();
|
||||
});
|
||||
|
||||
it('should not call verifyMfa for register request', async () => {
|
||||
getInteractionStorage.mockReturnValue({
|
||||
event: InteractionEvent.Register,
|
||||
});
|
||||
|
||||
verifyProfile.mockReturnValueOnce({
|
||||
event: InteractionEvent.Register,
|
||||
});
|
||||
validateMandatoryUserProfile.mockReturnValueOnce({
|
||||
event: InteractionEvent.Register,
|
||||
});
|
||||
verifyBindMfa.mockReturnValueOnce({
|
||||
event: InteractionEvent.Register,
|
||||
});
|
||||
|
||||
await sessionRequest.post(path).send();
|
||||
expect(verifyMfa).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should not call validateMandatoryUserProfile and validateMandatoryBindMfa for forgot password request', async () => {
|
||||
getInteractionStorage.mockReturnValue({
|
||||
event: InteractionEvent.ForgotPassword,
|
||||
|
|
|
@ -25,6 +25,7 @@ import {
|
|||
storeInteractionResult,
|
||||
mergeIdentifiers,
|
||||
isForgotPasswordInteractionResult,
|
||||
isSignInInteractionResult,
|
||||
} from './utils/interaction.js';
|
||||
import {
|
||||
verifySignInModeSettings,
|
||||
|
@ -39,6 +40,7 @@ import {
|
|||
validateMandatoryUserProfile,
|
||||
validateMandatoryBindMfa,
|
||||
verifyBindMfa,
|
||||
verifyMfa,
|
||||
} from './verifications/index.js';
|
||||
|
||||
export type RouterContext<T> = T extends Router<unknown, infer Context> ? Context : never;
|
||||
|
@ -333,7 +335,11 @@ export default function interactionRoutes<T extends AnonymousRouter>(
|
|||
|
||||
const accountVerifiedInteraction = await verifyIdentifier(ctx, tenant, interactionStorage);
|
||||
|
||||
const profileVerifiedInteraction = await verifyProfile(tenant, accountVerifiedInteraction);
|
||||
const mfaVerifiedInteraction = isSignInInteractionResult(accountVerifiedInteraction)
|
||||
? await verifyMfa(tenant, accountVerifiedInteraction)
|
||||
: accountVerifiedInteraction;
|
||||
|
||||
const profileVerifiedInteraction = await verifyProfile(tenant, mfaVerifiedInteraction);
|
||||
|
||||
// TODO @simeng-li: make all these verification steps in a middleware.
|
||||
const mandatoryProfileVerifiedInteraction = isForgotPasswordInteractionResult(
|
||||
|
|
|
@ -3,6 +3,7 @@ import { createMockUtils } from '@logto/shared/esm';
|
|||
|
||||
import { 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';
|
||||
import { createMockLogContext } from '#src/test-utils/koa-audit-log.js';
|
||||
import { createMockProvider } from '#src/test-utils/oidc-provider.js';
|
||||
|
@ -46,13 +47,18 @@ const { verifyMfaSettings } = await mockEsmWithActual(
|
|||
})
|
||||
);
|
||||
|
||||
const { bindMfaPayloadVerification } = await mockEsmWithActual(
|
||||
const { bindMfaPayloadVerification, verifyMfaPayloadVerification } = await mockEsmWithActual(
|
||||
'./verifications/mfa-payload-verification.js',
|
||||
() => ({
|
||||
bindMfaPayloadVerification: jest.fn(),
|
||||
verifyMfaPayloadVerification: jest.fn(),
|
||||
})
|
||||
);
|
||||
|
||||
const { verifyIdentifier } = await mockEsmWithActual('./verifications/index.js', () => ({
|
||||
verifyIdentifier: jest.fn(),
|
||||
}));
|
||||
|
||||
const baseProviderMock = {
|
||||
params: {},
|
||||
jti: 'jti',
|
||||
|
@ -65,6 +71,9 @@ const tenantContext = new MockTenant(
|
|||
signInExperiences: {
|
||||
findDefaultSignInExperience: jest.fn().mockResolvedValue(mockSignInExperience),
|
||||
},
|
||||
users: {
|
||||
findUserById: jest.fn().mockResolvedValue(mockUserWithMfaVerifications),
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
|
@ -108,4 +117,72 @@ describe('interaction routes (MFA verification)', () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /interaction/mfa', () => {
|
||||
const path = `${interactionPrefix}/mfa`;
|
||||
|
||||
it('should throw for register event', async () => {
|
||||
getInteractionStorage.mockReturnValue({
|
||||
event: InteractionEvent.Register,
|
||||
});
|
||||
|
||||
const response = await sessionRequest.put(path).send({
|
||||
type: MfaFactor.TOTP,
|
||||
code: '123456',
|
||||
});
|
||||
|
||||
expect(response.status).toEqual(400);
|
||||
});
|
||||
|
||||
it('should throw when account id is empty', async () => {
|
||||
getInteractionStorage.mockReturnValue({
|
||||
event: InteractionEvent.SignIn,
|
||||
});
|
||||
verifyIdentifier.mockResolvedValue({
|
||||
event: InteractionEvent.SignIn,
|
||||
});
|
||||
|
||||
const response = await sessionRequest.put(path).send({
|
||||
type: MfaFactor.TOTP,
|
||||
code: '123456',
|
||||
});
|
||||
|
||||
expect(response.status).toEqual(400);
|
||||
});
|
||||
|
||||
it('should return 204 and store results in session', async () => {
|
||||
getInteractionStorage.mockReturnValue({
|
||||
event: InteractionEvent.SignIn,
|
||||
});
|
||||
verifyIdentifier.mockResolvedValue({
|
||||
event: InteractionEvent.SignIn,
|
||||
accountId: 'accountId',
|
||||
});
|
||||
verifyMfaPayloadVerification.mockResolvedValue({
|
||||
type: MfaFactor.TOTP,
|
||||
id: 'id',
|
||||
});
|
||||
|
||||
const body = {
|
||||
type: MfaFactor.TOTP,
|
||||
code: '123456',
|
||||
};
|
||||
|
||||
const response = await sessionRequest.put(path).send(body);
|
||||
expect(response.status).toEqual(204);
|
||||
expect(getInteractionStorage).toBeCalled();
|
||||
expect(verifyMfaPayloadVerification).toBeCalled();
|
||||
expect(storeInteractionResult).toBeCalledWith(
|
||||
{
|
||||
verifiedMfa: {
|
||||
type: MfaFactor.TOTP,
|
||||
id: 'id',
|
||||
},
|
||||
},
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,22 +1,30 @@
|
|||
import { InteractionEvent, bindMfaPayloadGuard } from '@logto/schemas';
|
||||
import { InteractionEvent, bindMfaPayloadGuard, verifyMfaPayloadGuard } from '@logto/schemas';
|
||||
import type Router from 'koa-router';
|
||||
import { type IRouterParamContext } from 'koa-router';
|
||||
|
||||
import RequestError from '#src/errors/RequestError/index.js';
|
||||
import { type WithLogContext } from '#src/middleware/koa-audit-log.js';
|
||||
import koaGuard from '#src/middleware/koa-guard.js';
|
||||
import type TenantContext from '#src/tenants/TenantContext.js';
|
||||
import assertThat from '#src/utils/assert-that.js';
|
||||
|
||||
import { interactionPrefix } from './const.js';
|
||||
import type { WithInteractionDetailsContext } from './middleware/koa-interaction-details.js';
|
||||
import koaInteractionSie from './middleware/koa-interaction-sie.js';
|
||||
import { getInteractionStorage, storeInteractionResult } from './utils/interaction.js';
|
||||
import { verifyMfaSettings } from './utils/sign-in-experience-validation.js';
|
||||
import { bindMfaPayloadVerification } from './verifications/mfa-payload-verification.js';
|
||||
import { verifyIdentifier } from './verifications/index.js';
|
||||
import {
|
||||
bindMfaPayloadVerification,
|
||||
verifyMfaPayloadVerification,
|
||||
} from './verifications/mfa-payload-verification.js';
|
||||
|
||||
export default function mfaRoutes<T extends IRouterParamContext>(
|
||||
router: Router<unknown, WithInteractionDetailsContext<WithLogContext<T>>>,
|
||||
{ provider, queries }: TenantContext
|
||||
tenant: TenantContext
|
||||
) {
|
||||
const { provider, queries } = tenant;
|
||||
|
||||
// Update New MFA
|
||||
router.put(
|
||||
`${interactionPrefix}/bind-mfa`,
|
||||
|
@ -47,4 +55,47 @@ export default function mfaRoutes<T extends IRouterParamContext>(
|
|||
return next();
|
||||
}
|
||||
);
|
||||
|
||||
// Update MFA
|
||||
router.put(
|
||||
`${interactionPrefix}/mfa`,
|
||||
koaGuard({
|
||||
body: verifyMfaPayloadGuard,
|
||||
status: [204, 400, 422],
|
||||
}),
|
||||
koaInteractionSie(queries),
|
||||
async (ctx, next) => {
|
||||
const verifyMfaPayloadGuard = ctx.guard.body;
|
||||
const { interactionDetails, createLog } = ctx;
|
||||
const interactionStorage = getInteractionStorage(interactionDetails.result);
|
||||
|
||||
assertThat(
|
||||
interactionStorage.event === InteractionEvent.SignIn,
|
||||
new RequestError({
|
||||
code: 'session.mfa.mfa_sign_in_only',
|
||||
})
|
||||
);
|
||||
createLog(`Interaction.${interactionStorage.event}.Mfa.Totp.Submit`);
|
||||
|
||||
const { accountId } = await verifyIdentifier(ctx, tenant, interactionStorage);
|
||||
assertThat(
|
||||
accountId,
|
||||
new RequestError({
|
||||
code: 'session.mfa.mfa_sign_in_only',
|
||||
})
|
||||
);
|
||||
|
||||
const verifiedMfa = await verifyMfaPayloadVerification(
|
||||
tenant,
|
||||
accountId,
|
||||
verifyMfaPayloadGuard
|
||||
);
|
||||
|
||||
await storeInteractionResult({ verifiedMfa }, ctx, provider, true);
|
||||
|
||||
ctx.status = 204;
|
||||
|
||||
return next();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,6 +1,12 @@
|
|||
import { socialUserInfoGuard } from '@logto/connector-kit';
|
||||
import { validateRedirectUrl } from '@logto/core-kit';
|
||||
import { bindMfaGuard, eventGuard, pendingMfaGuard, profileGuard } from '@logto/schemas';
|
||||
import {
|
||||
bindMfaGuard,
|
||||
eventGuard,
|
||||
verifyMfaResultGuard,
|
||||
pendingMfaGuard,
|
||||
profileGuard,
|
||||
} from '@logto/schemas';
|
||||
import { z } from 'zod';
|
||||
|
||||
// Social Authorization Uri Route Payload Guard
|
||||
|
@ -48,6 +54,8 @@ export const anonymousInteractionResultGuard = z.object({
|
|||
bindMfa: bindMfaGuard.optional(),
|
||||
// The pending mfa info, such as secret of TOTP
|
||||
pendingMfa: pendingMfaGuard.optional(),
|
||||
// The verified mfa
|
||||
verifiedMfa: verifyMfaResultGuard.optional(),
|
||||
});
|
||||
|
||||
export const forgotPasswordProfileGuard = z.object({
|
||||
|
|
|
@ -8,6 +8,7 @@ import type {
|
|||
SocialPhonePayload,
|
||||
Profile,
|
||||
BindMfa,
|
||||
VerifyMfaResult,
|
||||
} from '@logto/schemas';
|
||||
import type { z } from 'zod';
|
||||
|
||||
|
@ -86,6 +87,7 @@ export type VerifiedSignInInteractionResult = {
|
|||
identifiers: Identifier[];
|
||||
profile?: Profile;
|
||||
bindMfa?: BindMfa;
|
||||
verifiedMfa?: VerifyMfaResult;
|
||||
};
|
||||
|
||||
export type VerifiedForgotPasswordInteractionResult = {
|
||||
|
|
|
@ -15,6 +15,8 @@ import type {
|
|||
AnonymousInteractionResult,
|
||||
VerifiedForgotPasswordInteractionResult,
|
||||
VerifiedInteractionResult,
|
||||
RegisterInteractionResult,
|
||||
AccountVerifiedInteractionResult,
|
||||
} from '../types/index.js';
|
||||
|
||||
const isProfileIdentifier = (identifier: Identifier, profile?: Profile) => {
|
||||
|
@ -86,6 +88,10 @@ export const isForgotPasswordInteractionResult = (
|
|||
): interaction is VerifiedForgotPasswordInteractionResult =>
|
||||
interaction.event === InteractionEvent.ForgotPassword;
|
||||
|
||||
export const isSignInInteractionResult = (
|
||||
interaction: RegisterInteractionResult | AccountVerifiedInteractionResult
|
||||
): interaction is AccountVerifiedInteractionResult => interaction.event === InteractionEvent.SignIn;
|
||||
|
||||
export const storeInteractionResult = async (
|
||||
interaction: Omit<AnonymousInteractionResult, 'event'> & { event?: InteractionEvent },
|
||||
ctx: Context,
|
||||
|
|
|
@ -4,6 +4,7 @@ import type Provider from 'oidc-provider';
|
|||
|
||||
import RequestError from '#src/errors/RequestError/index.js';
|
||||
import { createMockLogContext } from '#src/test-utils/koa-audit-log.js';
|
||||
import { MockTenant } from '#src/test-utils/tenant.js';
|
||||
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
|
||||
|
||||
import type { IdentifierVerifiedInteractionResult } from '../types/index.js';
|
||||
|
@ -11,11 +12,21 @@ import type { IdentifierVerifiedInteractionResult } from '../types/index.js';
|
|||
const { jest } = import.meta;
|
||||
const { mockEsm } = createMockUtils(jest);
|
||||
|
||||
const findUserById = jest.fn();
|
||||
|
||||
const tenantContext = new MockTenant(undefined, {
|
||||
users: {
|
||||
findUserById,
|
||||
},
|
||||
});
|
||||
|
||||
const { validateTotpToken } = mockEsm('../utils/totp-validation.js', () => ({
|
||||
validateTotpToken: jest.fn().mockReturnValue(true),
|
||||
}));
|
||||
|
||||
const { bindMfaPayloadVerification } = await import('./mfa-payload-verification.js');
|
||||
const { bindMfaPayloadVerification, verifyMfaPayloadVerification } = await import(
|
||||
'./mfa-payload-verification.js'
|
||||
);
|
||||
|
||||
describe('bindMfaPayloadVerification', () => {
|
||||
const baseCtx = {
|
||||
|
@ -78,3 +89,52 @@ describe('bindMfaPayloadVerification', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyMfaPayloadVerification', () => {
|
||||
describe('totp', () => {
|
||||
it('should return result of VerifyMfaResult', async () => {
|
||||
findUserById.mockResolvedValueOnce({
|
||||
mfaVerifications: [{ type: MfaFactor.TOTP, key: 'key', id: 'id' }],
|
||||
});
|
||||
|
||||
await expect(
|
||||
verifyMfaPayloadVerification(tenantContext, 'accountId', {
|
||||
type: MfaFactor.TOTP,
|
||||
code: '123456',
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
type: MfaFactor.TOTP,
|
||||
id: 'id',
|
||||
});
|
||||
|
||||
expect(findUserById).toHaveBeenCalled();
|
||||
expect(validateTotpToken).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject when totp can not be found in user mfaVerifications', async () => {
|
||||
findUserById.mockResolvedValueOnce({
|
||||
mfaVerifications: [],
|
||||
});
|
||||
await expect(
|
||||
verifyMfaPayloadVerification(tenantContext, 'accountId', {
|
||||
type: MfaFactor.TOTP,
|
||||
code: '123456',
|
||||
})
|
||||
).rejects.toEqual(new RequestError('session.mfa.invalid_totp_code'));
|
||||
});
|
||||
|
||||
it('should reject when code is invalid', async () => {
|
||||
findUserById.mockResolvedValueOnce({
|
||||
mfaVerifications: [{ type: MfaFactor.TOTP, key: 'key', id: 'id' }],
|
||||
});
|
||||
validateTotpToken.mockReturnValueOnce(false);
|
||||
|
||||
await expect(
|
||||
verifyMfaPayloadVerification(tenantContext, 'accountId', {
|
||||
type: MfaFactor.TOTP,
|
||||
code: '123456',
|
||||
})
|
||||
).rejects.toEqual(new RequestError('session.mfa.invalid_totp_code'));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -4,9 +4,15 @@ import {
|
|||
type BindTotpPayload,
|
||||
type BindMfaPayload,
|
||||
type BindMfa,
|
||||
type TotpVerificationPayload,
|
||||
type User,
|
||||
type MfaVerificationTotp,
|
||||
type VerifyMfaPayload,
|
||||
type VerifyMfaResult,
|
||||
} from '@logto/schemas';
|
||||
|
||||
import type { WithLogContext } from '#src/middleware/koa-audit-log.js';
|
||||
import type TenantContext from '#src/tenants/TenantContext.js';
|
||||
import assertThat from '#src/utils/assert-that.js';
|
||||
|
||||
import type { AnonymousInteractionResult } from '../types/index.js';
|
||||
|
@ -33,6 +39,28 @@ const verifyBindTotp = async (
|
|||
return { type, secret };
|
||||
};
|
||||
|
||||
const findUserTotp = (
|
||||
mfaVerifications: User['mfaVerifications']
|
||||
): MfaVerificationTotp | undefined =>
|
||||
mfaVerifications.find((mfa): mfa is MfaVerificationTotp => mfa.type === MfaFactor.TOTP);
|
||||
|
||||
const verifyTotp = async (
|
||||
mfaVerifications: User['mfaVerifications'],
|
||||
payload: TotpVerificationPayload
|
||||
): Promise<VerifyMfaResult> => {
|
||||
const totp = findUserTotp(mfaVerifications);
|
||||
|
||||
// Can not found totp verification, this is an invalid request, throw invalid code error anyway for security reason
|
||||
assertThat(totp, 'session.mfa.invalid_totp_code');
|
||||
|
||||
const { code } = payload;
|
||||
const { key, id, type } = totp;
|
||||
|
||||
assertThat(validateTotpToken(key, code), 'session.mfa.invalid_totp_code');
|
||||
|
||||
return { type, id };
|
||||
};
|
||||
|
||||
export async function bindMfaPayloadVerification(
|
||||
ctx: WithLogContext,
|
||||
bindMfaPayload: BindMfaPayload,
|
||||
|
@ -40,3 +68,13 @@ export async function bindMfaPayloadVerification(
|
|||
): Promise<BindMfa> {
|
||||
return verifyBindTotp(interactionStorage, bindMfaPayload, ctx);
|
||||
}
|
||||
|
||||
export async function verifyMfaPayloadVerification(
|
||||
tenant: TenantContext,
|
||||
accountId: string,
|
||||
verifyMfaPayload: VerifyMfaPayload
|
||||
): Promise<VerifyMfaResult> {
|
||||
const user = await tenant.queries.users.findUserById(accountId);
|
||||
|
||||
return verifyTotp(user.mfaVerifications, verifyMfaPayload);
|
||||
}
|
||||
|
|
|
@ -5,14 +5,15 @@ import { InteractionEvent, MfaFactor, MfaPolicy } from '@logto/schemas';
|
|||
import type Provider from 'oidc-provider';
|
||||
|
||||
import { mockSignInExperience } from '#src/__mocks__/sign-in-experience.js';
|
||||
import { mockUser } from '#src/__mocks__/user.js';
|
||||
import { mockUser, mockUserWithMfaVerifications } from '#src/__mocks__/user.js';
|
||||
import RequestError from '#src/errors/RequestError/index.js';
|
||||
import { MockTenant } from '#src/test-utils/tenant.js';
|
||||
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
|
||||
|
||||
import type { IdentifierVerifiedInteractionResult } from '../types/index.js';
|
||||
|
||||
import { verifyBindMfa } from './mfa-verification.js';
|
||||
import type {
|
||||
AccountVerifiedInteractionResult,
|
||||
IdentifierVerifiedInteractionResult,
|
||||
} from '../types/index.js';
|
||||
|
||||
const { jest } = import.meta;
|
||||
|
||||
|
@ -24,7 +25,9 @@ const tenantContext = new MockTenant(undefined, {
|
|||
},
|
||||
});
|
||||
|
||||
const { validateMandatoryBindMfa } = await import('./mfa-verification.js');
|
||||
const { validateMandatoryBindMfa, verifyBindMfa, verifyMfa } = await import(
|
||||
'./mfa-verification.js'
|
||||
);
|
||||
|
||||
const baseCtx = {
|
||||
...createContextWithRouteParameters(),
|
||||
|
@ -59,18 +62,27 @@ const interaction: IdentifierVerifiedInteractionResult = {
|
|||
identifiers: [{ key: 'accountId', value: 'foo' }],
|
||||
};
|
||||
|
||||
const signInInteraction: IdentifierVerifiedInteractionResult = {
|
||||
const signInInteraction: AccountVerifiedInteractionResult = {
|
||||
event: InteractionEvent.SignIn,
|
||||
identifiers: [{ key: 'accountId', value: 'foo' }],
|
||||
accountId: 'foo',
|
||||
};
|
||||
|
||||
describe('validateMandatoryBindMfa', () => {
|
||||
afterEach(() => {
|
||||
findUserById.mockReset();
|
||||
});
|
||||
|
||||
describe('register', () => {
|
||||
it('bindMfa missing but required should throw', async () => {
|
||||
await expect(
|
||||
validateMandatoryBindMfa(tenantContext, mfaRequiredCtx, interaction)
|
||||
).rejects.toMatchError(new RequestError({ code: 'user.missing_mfa', status: 422 }));
|
||||
).rejects.toMatchError(
|
||||
new RequestError(
|
||||
{ code: 'user.missing_mfa', status: 422 },
|
||||
{ missingFactors: [MfaFactor.TOTP] }
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it('bindMfa exists should pass', async () => {
|
||||
|
@ -97,7 +109,12 @@ describe('validateMandatoryBindMfa', () => {
|
|||
findUserById.mockResolvedValueOnce(mockUser);
|
||||
await expect(
|
||||
validateMandatoryBindMfa(tenantContext, mfaRequiredCtx, signInInteraction)
|
||||
).rejects.toMatchError(new RequestError({ code: 'user.missing_mfa', status: 422 }));
|
||||
).rejects.toMatchError(
|
||||
new RequestError(
|
||||
{ code: 'user.missing_mfa', status: 422 },
|
||||
{ missingFactors: [MfaFactor.TOTP] }
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it('user mfaVerifications and bindMfa missing and not required should pass', async () => {
|
||||
|
@ -121,15 +138,7 @@ describe('validateMandatoryBindMfa', () => {
|
|||
});
|
||||
|
||||
it('user mfaVerifications existing, bindMfa missing and required should pass', async () => {
|
||||
findUserById.mockResolvedValueOnce({
|
||||
...mockUser,
|
||||
mfaVerifications: [
|
||||
{
|
||||
type: MfaFactor.TOTP,
|
||||
secret: 'secret',
|
||||
},
|
||||
],
|
||||
});
|
||||
findUserById.mockResolvedValueOnce(mockUserWithMfaVerifications);
|
||||
await expect(
|
||||
validateMandatoryBindMfa(tenantContext, baseCtx, signInInteraction)
|
||||
).resolves.not.toThrow();
|
||||
|
@ -168,15 +177,7 @@ describe('verifyBindMfa', () => {
|
|||
});
|
||||
|
||||
it('should reject if the user already has a TOTP factor', async () => {
|
||||
findUserById.mockResolvedValueOnce({
|
||||
...mockUser,
|
||||
mfaVerifications: [
|
||||
{
|
||||
type: MfaFactor.TOTP,
|
||||
secret: 'secret',
|
||||
},
|
||||
],
|
||||
});
|
||||
findUserById.mockResolvedValueOnce(mockUserWithMfaVerifications);
|
||||
await expect(
|
||||
verifyBindMfa(tenantContext, {
|
||||
...signInInteraction,
|
||||
|
@ -188,3 +189,33 @@ describe('verifyBindMfa', () => {
|
|||
).rejects.toMatchError(new RequestError({ code: 'user.totp_already_in_use', status: 422 }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyMfa', () => {
|
||||
it('should pass if user mfaVerifications is empty', async () => {
|
||||
findUserById.mockResolvedValueOnce(mockUser);
|
||||
await expect(verifyMfa(tenantContext, signInInteraction)).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('should pass if verifiedMfa exists', async () => {
|
||||
findUserById.mockResolvedValueOnce(mockUserWithMfaVerifications);
|
||||
await expect(
|
||||
verifyMfa(tenantContext, {
|
||||
...signInInteraction,
|
||||
verifiedMfa: {
|
||||
type: MfaFactor.TOTP,
|
||||
id: 'id',
|
||||
},
|
||||
})
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('should reject if verifiedMfa can not be found', async () => {
|
||||
findUserById.mockResolvedValueOnce(mockUserWithMfaVerifications);
|
||||
await expect(
|
||||
verifyMfa(tenantContext, {
|
||||
...signInInteraction,
|
||||
verifiedMfa: undefined,
|
||||
})
|
||||
).rejects.toThrowError();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -10,6 +10,7 @@ import {
|
|||
type VerifiedSignInInteractionResult,
|
||||
type VerifiedInteractionResult,
|
||||
type VerifiedRegisterInteractionResult,
|
||||
type AccountVerifiedInteractionResult,
|
||||
} from '../types/index.js';
|
||||
|
||||
export const verifyBindMfa = async (
|
||||
|
@ -43,6 +44,32 @@ export const verifyBindMfa = async (
|
|||
return interaction;
|
||||
};
|
||||
|
||||
export const verifyMfa = async (
|
||||
tenant: TenantContext,
|
||||
interaction: AccountVerifiedInteractionResult
|
||||
): Promise<AccountVerifiedInteractionResult> => {
|
||||
const { accountId, verifiedMfa } = interaction;
|
||||
|
||||
const { mfaVerifications } = await tenant.queries.users.findUserById(accountId);
|
||||
|
||||
if (mfaVerifications.length > 0) {
|
||||
assertThat(
|
||||
verifiedMfa,
|
||||
new RequestError(
|
||||
{
|
||||
code: 'session.mfa.require_mfa_verification',
|
||||
status: 403,
|
||||
},
|
||||
{
|
||||
availableFactors: mfaVerifications.map(({ type }) => type),
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return interaction;
|
||||
};
|
||||
|
||||
export const validateMandatoryBindMfa = async (
|
||||
tenant: TenantContext,
|
||||
ctx: WithInteractionSieContext & WithInteractionDetailsContext,
|
||||
|
@ -57,28 +84,35 @@ export const validateMandatoryBindMfa = async (
|
|||
return interaction;
|
||||
}
|
||||
|
||||
const hasEnoughBindFactor = Boolean(bindMfa && factors.includes(bindMfa.type));
|
||||
|
||||
if (event === InteractionEvent.Register) {
|
||||
const missingFactors = factors.filter((factor) => factor !== bindMfa?.type);
|
||||
assertThat(
|
||||
hasEnoughBindFactor,
|
||||
new RequestError({
|
||||
code: 'user.missing_mfa',
|
||||
status: 422,
|
||||
})
|
||||
missingFactors.length === 0,
|
||||
new RequestError(
|
||||
{
|
||||
code: 'user.missing_mfa',
|
||||
status: 422,
|
||||
},
|
||||
{ missingFactors }
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (event === InteractionEvent.SignIn) {
|
||||
const { accountId } = interaction;
|
||||
const { mfaVerifications } = await tenant.queries.users.findUserById(accountId);
|
||||
const missingFactors = factors.filter(
|
||||
(factor) => factor !== bindMfa?.type && !mfaVerifications.some(({ type }) => type === factor)
|
||||
);
|
||||
assertThat(
|
||||
hasEnoughBindFactor ||
|
||||
factors.some((factor) => mfaVerifications.some(({ type }) => type === factor)),
|
||||
new RequestError({
|
||||
code: 'user.missing_mfa',
|
||||
status: 422,
|
||||
})
|
||||
missingFactors.length === 0,
|
||||
new RequestError(
|
||||
{
|
||||
code: 'user.missing_mfa',
|
||||
status: 422,
|
||||
},
|
||||
{ missingFactors }
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -4,6 +4,7 @@ import type {
|
|||
Profile,
|
||||
RequestVerificationCodePayload,
|
||||
BindMfaPayload,
|
||||
VerifyMfaPayload,
|
||||
} from '@logto/schemas';
|
||||
import type { Got } from 'got';
|
||||
|
||||
|
@ -77,6 +78,15 @@ export const putInteractionBindMfa = async (cookie: string, payload: BindMfaPayl
|
|||
})
|
||||
.json();
|
||||
|
||||
export const putInteractionMfa = async (cookie: string, payload: VerifyMfaPayload) =>
|
||||
api
|
||||
.put('interaction/mfa', {
|
||||
headers: { cookie },
|
||||
json: payload,
|
||||
followRedirect: false,
|
||||
})
|
||||
.json();
|
||||
|
||||
export const deleteInteractionProfile = async (cookie: string) =>
|
||||
api
|
||||
.delete('interaction/profile', {
|
||||
|
|
|
@ -1,7 +1,13 @@
|
|||
import { InteractionEvent, MfaFactor, SignInIdentifier } from '@logto/schemas';
|
||||
import { authenticator } from 'otplib';
|
||||
|
||||
import { putInteraction, deleteUser, initTotp, putInteractionBindMfa } from '#src/api/index.js';
|
||||
import {
|
||||
putInteraction,
|
||||
deleteUser,
|
||||
initTotp,
|
||||
putInteractionBindMfa,
|
||||
putInteractionMfa,
|
||||
} from '#src/api/index.js';
|
||||
import { initClient, processSession, logoutClient } from '#src/helpers/client.js';
|
||||
import { expectRejects } from '#src/helpers/index.js';
|
||||
import {
|
||||
|
@ -10,6 +16,34 @@ import {
|
|||
} from '#src/helpers/sign-in-experience.js';
|
||||
import { generateNewUser, 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(putInteractionBindMfa, {
|
||||
type: MfaFactor.TOTP,
|
||||
code,
|
||||
});
|
||||
|
||||
const { redirectTo } = await client.submitInteraction();
|
||||
|
||||
const id = await processSession(client, redirectTo);
|
||||
await logoutClient(client);
|
||||
|
||||
return { id, username, password, secret };
|
||||
};
|
||||
|
||||
describe('register with mfa (mandatory TOTP)', () => {
|
||||
beforeAll(async () => {
|
||||
await enableAllPasswordSignInMethods({
|
||||
|
@ -122,29 +156,60 @@ describe('sign in and fulfill mfa (mandatory TOTP)', () => {
|
|||
});
|
||||
|
||||
it('should sign in and fulfill totp', async () => {
|
||||
const { userProfile, user } = await generateNewUser({ username: true, password: true });
|
||||
const { id } = await registerWithMfa();
|
||||
await deleteUser(id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sign in and verify mfa (TOTP)', () => {
|
||||
beforeAll(async () => {
|
||||
await enableAllPasswordSignInMethods({
|
||||
identifiers: [SignInIdentifier.Username],
|
||||
password: true,
|
||||
verify: false,
|
||||
});
|
||||
await enableMandatoryMfaWithTotp();
|
||||
});
|
||||
|
||||
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: userProfile.username,
|
||||
password: userProfile.password,
|
||||
username,
|
||||
password,
|
||||
},
|
||||
});
|
||||
|
||||
const { secret } = await client.send(initTotp);
|
||||
const code = authenticator.generate(secret);
|
||||
|
||||
await client.send(putInteractionBindMfa, {
|
||||
type: MfaFactor.TOTP,
|
||||
code,
|
||||
await expectRejects(client.submitInteraction(), {
|
||||
code: 'session.mfa.require_mfa_verification',
|
||||
statusCode: 403,
|
||||
});
|
||||
|
||||
const { redirectTo } = await client.submitInteraction();
|
||||
await deleteUser(id);
|
||||
});
|
||||
|
||||
await processSession(client, redirectTo);
|
||||
await logoutClient(client);
|
||||
await deleteUser(user.id);
|
||||
it('should sign in successfully', async () => {
|
||||
const { id, username, password, secret } = await registerWithMfa();
|
||||
const client = await initClient();
|
||||
|
||||
await client.successSend(putInteraction, {
|
||||
event: InteractionEvent.SignIn,
|
||||
identifier: {
|
||||
username,
|
||||
password,
|
||||
},
|
||||
});
|
||||
|
||||
await client.successSend(putInteractionMfa, {
|
||||
type: MfaFactor.TOTP,
|
||||
code: authenticator.generate(secret),
|
||||
});
|
||||
|
||||
await client.submitInteraction();
|
||||
|
||||
await deleteUser(id);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -25,6 +25,10 @@ const session = {
|
|||
interaction_not_found:
|
||||
'Interaktionssitzung nicht gefunden. Bitte gehen Sie zurück und starten Sie die Sitzung erneut.',
|
||||
mfa: {
|
||||
/** UNTRANSLATED */
|
||||
require_mfa_verification: 'Mfa verification is required to sign in.',
|
||||
/** UNTRANSLATED */
|
||||
mfa_sign_in_only: 'Mfa is only available for sign-in interaction.',
|
||||
/** UNTRANSLATED */
|
||||
pending_info_not_found: 'Pending MFA info not found, please initiate MFA first.',
|
||||
/** UNTRANSLATED */
|
||||
|
|
|
@ -22,6 +22,8 @@ const session = {
|
|||
interaction_not_found:
|
||||
'Interaction session not found. Please go back and start the session again.',
|
||||
mfa: {
|
||||
require_mfa_verification: 'Mfa verification is required to sign in.',
|
||||
mfa_sign_in_only: 'Mfa is only available for sign-in interaction.',
|
||||
pending_info_not_found: 'Pending MFA info not found, please initiate MFA first.',
|
||||
invalid_totp_code: 'Invalid TOTP code.',
|
||||
},
|
||||
|
|
|
@ -26,6 +26,10 @@ const session = {
|
|||
interaction_not_found:
|
||||
'No se encuentra la sesión de interacción. Vuelva atrás y vuelva a iniciar la sesión.',
|
||||
mfa: {
|
||||
/** UNTRANSLATED */
|
||||
require_mfa_verification: 'Mfa verification is required to sign in.',
|
||||
/** UNTRANSLATED */
|
||||
mfa_sign_in_only: 'Mfa is only available for sign-in interaction.',
|
||||
/** UNTRANSLATED */
|
||||
pending_info_not_found: 'Pending MFA info not found, please initiate MFA first.',
|
||||
/** UNTRANSLATED */
|
||||
|
|
|
@ -27,6 +27,10 @@ const session = {
|
|||
interaction_not_found:
|
||||
"Session d'interaction introuvable. Veuillez retourner en arrière et recommencer la session.",
|
||||
mfa: {
|
||||
/** UNTRANSLATED */
|
||||
require_mfa_verification: 'Mfa verification is required to sign in.',
|
||||
/** UNTRANSLATED */
|
||||
mfa_sign_in_only: 'Mfa is only available for sign-in interaction.',
|
||||
/** UNTRANSLATED */
|
||||
pending_info_not_found: 'Pending MFA info not found, please initiate MFA first.',
|
||||
/** UNTRANSLATED */
|
||||
|
|
|
@ -25,6 +25,10 @@ const session = {
|
|||
interaction_not_found:
|
||||
'Sessione di interazione non trovata. Torna indietro e avvia la sessione nuovamente.',
|
||||
mfa: {
|
||||
/** UNTRANSLATED */
|
||||
require_mfa_verification: 'Mfa verification is required to sign in.',
|
||||
/** UNTRANSLATED */
|
||||
mfa_sign_in_only: 'Mfa is only available for sign-in interaction.',
|
||||
/** UNTRANSLATED */
|
||||
pending_info_not_found: 'Pending MFA info not found, please initiate MFA first.',
|
||||
/** UNTRANSLATED */
|
||||
|
|
|
@ -23,6 +23,10 @@ const session = {
|
|||
interaction_not_found:
|
||||
'インタラクションセッションが見つかりません。戻ってセッションを開始してください。',
|
||||
mfa: {
|
||||
/** UNTRANSLATED */
|
||||
require_mfa_verification: 'Mfa verification is required to sign in.',
|
||||
/** UNTRANSLATED */
|
||||
mfa_sign_in_only: 'Mfa is only available for sign-in interaction.',
|
||||
/** UNTRANSLATED */
|
||||
pending_info_not_found: 'Pending MFA info not found, please initiate MFA first.',
|
||||
/** UNTRANSLATED */
|
||||
|
|
|
@ -22,6 +22,10 @@ const session = {
|
|||
identifier_not_found: '사용자 식별자를 찾을 수 없어요. 처음부터 다시 로그인을 시도해 주세요.',
|
||||
interaction_not_found: '인터렉션 세션을 찾을 수 없어요. 처음부터 다시 세션을 시작해 주세요.',
|
||||
mfa: {
|
||||
/** UNTRANSLATED */
|
||||
require_mfa_verification: 'Mfa verification is required to sign in.',
|
||||
/** UNTRANSLATED */
|
||||
mfa_sign_in_only: 'Mfa is only available for sign-in interaction.',
|
||||
/** UNTRANSLATED */
|
||||
pending_info_not_found: 'Pending MFA info not found, please initiate MFA first.',
|
||||
/** UNTRANSLATED */
|
||||
|
|
|
@ -24,6 +24,10 @@ const session = {
|
|||
interaction_not_found:
|
||||
'Nie znaleziono sesji interakcji. Proszę wróć i rozpocznij sesję ponownie.',
|
||||
mfa: {
|
||||
/** UNTRANSLATED */
|
||||
require_mfa_verification: 'Mfa verification is required to sign in.',
|
||||
/** UNTRANSLATED */
|
||||
mfa_sign_in_only: 'Mfa is only available for sign-in interaction.',
|
||||
/** UNTRANSLATED */
|
||||
pending_info_not_found: 'Pending MFA info not found, please initiate MFA first.',
|
||||
/** UNTRANSLATED */
|
||||
|
|
|
@ -25,6 +25,10 @@ const session = {
|
|||
interaction_not_found:
|
||||
'Sessão de interação não encontrada. Por favor, volte e inicie a sessão novamente.',
|
||||
mfa: {
|
||||
/** UNTRANSLATED */
|
||||
require_mfa_verification: 'Mfa verification is required to sign in.',
|
||||
/** UNTRANSLATED */
|
||||
mfa_sign_in_only: 'Mfa is only available for sign-in interaction.',
|
||||
/** UNTRANSLATED */
|
||||
pending_info_not_found: 'Pending MFA info not found, please initiate MFA first.',
|
||||
/** UNTRANSLATED */
|
||||
|
|
|
@ -27,6 +27,10 @@ const session = {
|
|||
interaction_not_found:
|
||||
'Sessão de interação não encontrada. Por favor, volte e inicie a sessão novamente.',
|
||||
mfa: {
|
||||
/** UNTRANSLATED */
|
||||
require_mfa_verification: 'Mfa verification is required to sign in.',
|
||||
/** UNTRANSLATED */
|
||||
mfa_sign_in_only: 'Mfa is only available for sign-in interaction.',
|
||||
/** UNTRANSLATED */
|
||||
pending_info_not_found: 'Pending MFA info not found, please initiate MFA first.',
|
||||
/** UNTRANSLATED */
|
||||
|
|
|
@ -23,6 +23,10 @@ const session = {
|
|||
'Идентификатор пользователя не найден. Вернитесь и войдите в систему снова.',
|
||||
interaction_not_found: 'Сессия взаимодействия не найдена. Вернитесь и начните сессию заново.',
|
||||
mfa: {
|
||||
/** UNTRANSLATED */
|
||||
require_mfa_verification: 'Mfa verification is required to sign in.',
|
||||
/** UNTRANSLATED */
|
||||
mfa_sign_in_only: 'Mfa is only available for sign-in interaction.',
|
||||
/** UNTRANSLATED */
|
||||
pending_info_not_found: 'Pending MFA info not found, please initiate MFA first.',
|
||||
/** UNTRANSLATED */
|
||||
|
|
|
@ -24,6 +24,10 @@ const session = {
|
|||
interaction_not_found:
|
||||
'Etkileşim oturumu bulunamadı. Lütfen geri gidin ve oturumu yeniden başlatın.',
|
||||
mfa: {
|
||||
/** UNTRANSLATED */
|
||||
require_mfa_verification: 'Mfa verification is required to sign in.',
|
||||
/** UNTRANSLATED */
|
||||
mfa_sign_in_only: 'Mfa is only available for sign-in interaction.',
|
||||
/** UNTRANSLATED */
|
||||
pending_info_not_found: 'Pending MFA info not found, please initiate MFA first.',
|
||||
/** UNTRANSLATED */
|
||||
|
|
|
@ -19,6 +19,10 @@ const session = {
|
|||
identifier_not_found: '找不到用户标识符。请返回并重新登录。',
|
||||
interaction_not_found: '找不到交互会话。请返回并重新开始会话。',
|
||||
mfa: {
|
||||
/** UNTRANSLATED */
|
||||
require_mfa_verification: 'Mfa verification is required to sign in.',
|
||||
/** UNTRANSLATED */
|
||||
mfa_sign_in_only: 'Mfa is only available for sign-in interaction.',
|
||||
/** UNTRANSLATED */
|
||||
pending_info_not_found: 'Pending MFA info not found, please initiate MFA first.',
|
||||
/** UNTRANSLATED */
|
||||
|
|
|
@ -19,6 +19,10 @@ const session = {
|
|||
identifier_not_found: '找不到用戶標識符。請返回並重新登錄。',
|
||||
interaction_not_found: '找不到互動會話。請返回並重新開始會話。',
|
||||
mfa: {
|
||||
/** UNTRANSLATED */
|
||||
require_mfa_verification: 'Mfa verification is required to sign in.',
|
||||
/** UNTRANSLATED */
|
||||
mfa_sign_in_only: 'Mfa is only available for sign-in interaction.',
|
||||
/** UNTRANSLATED */
|
||||
pending_info_not_found: 'Pending MFA info not found, please initiate MFA first.',
|
||||
/** UNTRANSLATED */
|
||||
|
|
|
@ -19,6 +19,10 @@ const session = {
|
|||
identifier_not_found: '找不到使用者標識符。請返回並重新登錄。',
|
||||
interaction_not_found: '找不到交互會話。請返回並重新開始會話。',
|
||||
mfa: {
|
||||
/** UNTRANSLATED */
|
||||
require_mfa_verification: 'Mfa verification is required to sign in.',
|
||||
/** UNTRANSLATED */
|
||||
mfa_sign_in_only: 'Mfa is only available for sign-in interaction.',
|
||||
/** UNTRANSLATED */
|
||||
pending_info_not_found: 'Pending MFA info not found, please initiate MFA first.',
|
||||
/** UNTRANSLATED */
|
||||
|
|
|
@ -191,26 +191,38 @@ export const baseMfaVerification = {
|
|||
createdAt: z.string(),
|
||||
};
|
||||
|
||||
export const mfaVerificationTotp = z.object({
|
||||
type: z.literal(MfaFactor.TOTP),
|
||||
...baseMfaVerification,
|
||||
key: z.string(),
|
||||
});
|
||||
|
||||
export type MfaVerificationTotp = z.infer<typeof mfaVerificationTotp>;
|
||||
|
||||
export const mfaVerificationWebAuthn = z.object({
|
||||
type: z.literal(MfaFactor.WebAuthn),
|
||||
...baseMfaVerification,
|
||||
credentialId: z.string(),
|
||||
publicKey: z.string(),
|
||||
counter: z.number(),
|
||||
agent: z.string(),
|
||||
});
|
||||
|
||||
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(),
|
||||
});
|
||||
|
||||
export type MfaVerificationBackupCode = z.infer<typeof mfaVerificationBackupCode>;
|
||||
|
||||
export const mfaVerificationGuard = z.discriminatedUnion('type', [
|
||||
z.object({
|
||||
type: z.literal(MfaFactor.TOTP),
|
||||
...baseMfaVerification,
|
||||
key: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal(MfaFactor.WebAuthn),
|
||||
...baseMfaVerification,
|
||||
credentialId: z.string(),
|
||||
publicKey: z.string(),
|
||||
counter: z.number(),
|
||||
agent: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal(MfaFactor.BackupCode),
|
||||
...baseMfaVerification,
|
||||
code: z.string(),
|
||||
usedAt: z.date().optional(),
|
||||
}),
|
||||
mfaVerificationTotp,
|
||||
mfaVerificationWebAuthn,
|
||||
mfaVerificationBackupCode,
|
||||
]);
|
||||
|
||||
export type MfaVerification = z.infer<typeof mfaVerificationGuard>;
|
||||
|
|
|
@ -114,6 +114,14 @@ export const bindMfaPayloadGuard = bindTotpPayloadGuard;
|
|||
|
||||
export type BindMfaPayload = z.infer<typeof bindMfaPayloadGuard>;
|
||||
|
||||
export const totpVerificationPayloadGuard = bindTotpPayloadGuard;
|
||||
|
||||
export type TotpVerificationPayload = z.infer<typeof totpVerificationPayloadGuard>;
|
||||
|
||||
export const verifyMfaPayloadGuard = totpVerificationPayloadGuard;
|
||||
|
||||
export type VerifyMfaPayload = z.infer<typeof verifyMfaPayloadGuard>;
|
||||
|
||||
export const pendingTotpGuard = z.object({
|
||||
type: z.literal(MfaFactor.TOTP),
|
||||
secret: z.string(),
|
||||
|
@ -135,3 +143,10 @@ export type BindTotp = z.infer<typeof bindTotpGuard>;
|
|||
export const bindMfaGuard = bindTotpGuard;
|
||||
|
||||
export type BindMfa = z.infer<typeof bindMfaGuard>;
|
||||
|
||||
export const verifyMfaResultGuard = z.object({
|
||||
type: z.nativeEnum(MfaFactor),
|
||||
id: z.string(),
|
||||
});
|
||||
|
||||
export type VerifyMfaResult = z.infer<typeof verifyMfaResultGuard>;
|
||||
|
|
|
@ -11,6 +11,7 @@ export enum Field {
|
|||
Identifier = 'Identifier',
|
||||
Profile = 'Profile',
|
||||
BindMfa = 'BindMfa',
|
||||
Mfa = 'Mfa',
|
||||
}
|
||||
|
||||
/** Method to verify the identifier */
|
||||
|
@ -84,4 +85,7 @@ export type LogKey =
|
|||
Method,
|
||||
Method.VerificationCode | Method.Social
|
||||
>}.${Action.Submit}`
|
||||
| `${Prefix}.${InteractionEvent}.${Field.BindMfa}.${MfaFactor}.${Action.Submit | Action.Create}`;
|
||||
| `${Prefix}.${InteractionEvent}.${Field.BindMfa}.${MfaFactor}.${Action.Submit | Action.Create}`
|
||||
| `${Prefix}.${InteractionEvent.SignIn}.${Field.Mfa}.${MfaFactor}.${
|
||||
| Action.Submit
|
||||
| Action.Create}`;
|
||||
|
|
Loading…
Add table
Reference in a new issue