0
Fork 0
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:
wangsijie 2023-09-27 09:12:25 +08:00 committed by GitHub
parent acf460290c
commit 6a32f50d15
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
32 changed files with 592 additions and 81 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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({

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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', {

View file

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

View file

@ -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 */

View file

@ -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.',
},

View file

@ -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 */

View file

@ -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 */

View file

@ -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 */

View file

@ -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 */

View file

@ -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 */

View file

@ -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 */

View file

@ -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 */

View file

@ -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 */

View file

@ -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 */

View file

@ -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 */

View file

@ -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 */

View file

@ -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 */

View file

@ -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 */

View file

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

View file

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

View file

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