0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-02-17 22:04:19 -05:00

feat(core): add last use time to user mfa verifications (#4767)

This commit is contained in:
wangsijie 2023-10-31 10:40:01 +08:00 committed by GitHub
parent cee5717423
commit 34f4d47bc6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 111 additions and 9 deletions

View file

@ -65,6 +65,8 @@ const baseProviderMock = {
client_id: demoAppApplicationId,
};
const updateUserById = jest.fn();
const tenantContext = new MockTenant(
createMockProvider(jest.fn().mockResolvedValue(baseProviderMock)),
{
@ -73,6 +75,7 @@ const tenantContext = new MockTenant(
},
users: {
findUserById: jest.fn().mockResolvedValue(mockUserWithMfaVerifications),
updateUserById,
},
}
);
@ -216,6 +219,7 @@ describe('interaction routes (MFA verification)', () => {
expect(response.status).toEqual(204);
expect(getInteractionStorage).toBeCalled();
expect(verifyMfaPayloadVerification).toBeCalled();
expect(updateUserById).toBeCalled();
expect(storeInteractionResult).toBeCalledWith(
{
verifiedMfa: {

View file

@ -123,6 +123,21 @@ export default function mfaRoutes<T extends IRouterParamContext>(
{ accountId, rpId: hostname, origin }
);
// Update last used time
const user = await queries.users.findUserById(accountId);
await queries.users.updateUserById(accountId, {
mfaVerifications: user.mfaVerifications.map((mfa) => {
if (mfa.id !== verifiedMfa.id) {
return mfa;
}
return {
...mfa,
lastUsedAt: new Date().toISOString(),
};
}),
});
await storeInteractionResult({ verifiedMfa }, ctx, provider, true);
ctx.status = 204;

View file

@ -283,11 +283,15 @@ describe('verifyMfaPayloadVerification', () => {
});
describe('webauthn', () => {
beforeEach(() => {
updateUserById.mockClear();
});
it('should return result of VerifyMfaResult and update newCounter', async () => {
findUserById.mockResolvedValueOnce({
mfaVerifications: [mockUserWebAuthnMfaVerification],
});
const result = { type: MfaFactor.WebAuthn, id: 'id' };
const result = { type: MfaFactor.WebAuthn, id: mockUserWebAuthnMfaVerification.id };
verifyWebAuthnAuthentication.mockResolvedValueOnce({
result,
newCounter: 1,

View file

@ -232,7 +232,7 @@ export async function verifyMfaPayloadVerification(
// Update the authenticator's counter in the DB to the newest count in the authentication
await tenant.queries.users.updateUserById(accountId, {
mfaVerifications: user.mfaVerifications.map((mfa) => {
if (mfa.type !== MfaFactor.WebAuthn) {
if (mfa.type !== MfaFactor.WebAuthn || mfa.id !== result.id) {
return mfa;
}

View file

@ -10,6 +10,8 @@ import {
mockUser,
mockUserWebAuthnMfaVerification,
mockUserWithMfaVerifications,
mockUserTotpMfaVerification,
mockUserBackupCodeMfaVerification,
} from '#src/__mocks__/user.js';
import RequestError from '#src/errors/RequestError/index.js';
import { MockTenant } from '#src/test-utils/tenant.js';
@ -84,13 +86,13 @@ const mfaRequiredTotpOnlyCtx = {
},
};
const backupCodeEnabledCtx = {
const allFactorsEnabledCtx = {
...baseCtx,
signInExperience: {
...mockSignInExperience,
mfa: {
factors: [MfaFactor.TOTP, MfaFactor.WebAuthn, MfaFactor.BackupCode],
policy: MfaPolicy.Mandatory,
policy: MfaPolicy.UserControlled,
},
},
};
@ -348,4 +350,40 @@ describe('verifyMfa', () => {
})
).rejects.toThrowError();
});
it('should reject and sort availableFactors', async () => {
findUserById.mockResolvedValueOnce({
...mockUser,
mfaVerifications: [
{
...mockUserWebAuthnMfaVerification,
lastUsedAt: new Date('2021-01-01').toISOString(),
},
{
...mockUserBackupCodeMfaVerification,
lastUsedAt: new Date('2023-01-01').toISOString(),
},
{
...mockUserTotpMfaVerification,
lastUsedAt: new Date('2022-01-01').toISOString(),
},
],
});
await expect(
verifyMfa(allFactorsEnabledCtx, tenantContext, {
...signInInteraction,
verifiedMfa: undefined,
})
).rejects.toMatchError(
new RequestError(
{
code: 'session.mfa.require_mfa_verification',
status: 403,
},
{
availableFactors: [MfaFactor.TOTP, MfaFactor.WebAuthn, MfaFactor.BackupCode],
}
)
);
});
});

View file

@ -1,5 +1,10 @@
import { InteractionEvent, MfaFactor, MfaPolicy, type JsonObject } from '@logto/schemas';
import { deduplicate } from '@silverhand/essentials';
import {
InteractionEvent,
MfaFactor,
MfaPolicy,
type JsonObject,
type MfaVerification,
} from '@logto/schemas';
import { type Context } from 'koa';
import type Provider from 'oidc-provider';
import { z } from 'zod';
@ -61,8 +66,43 @@ export const verifyMfa = async (
const { accountId, verifiedMfa } = interaction;
const { mfaVerifications } = await tenant.queries.users.findUserById(accountId);
// Only allow MFA that is configured in sign-in experience
const availableUserVerifications = mfaVerifications.filter(({ type }) => factors.includes(type));
const availableUserVerifications = mfaVerifications
.filter((verification) => {
// Only allow MFA that is configured in sign-in experience
if (!factors.includes(verification.type)) {
return false;
}
if (verification.type !== MfaFactor.BackupCode) {
return true;
}
// Skip backup code if it is used
return verification.codes.some((code) => !code.usedAt);
})
.reduce<MfaVerification[]>((factors, verification) => {
// Ingnore duplicated verification
if (factors.some(({ type }) => type === verification.type)) {
return factors;
}
return [...factors, verification];
}, [])
.slice()
.sort((factorA, factorB) => {
// Sort by last used time, the latest used factor is the first one, backup code is always the last one
if (factorA.type === MfaFactor.BackupCode) {
return 1;
}
if (factorB.type === MfaFactor.BackupCode) {
return -1;
}
return (
new Date(factorB.lastUsedAt ?? 0).getTime() - new Date(factorA.lastUsedAt ?? 0).getTime()
);
});
if (availableUserVerifications.length > 0) {
assertThat(
@ -73,7 +113,7 @@ export const verifyMfa = async (
status: 403,
},
{
availableFactors: deduplicate(availableUserVerifications.map(({ type }) => type)),
availableFactors: availableUserVerifications.map(({ type }) => type),
}
)
);

View file

@ -16,6 +16,7 @@ export type Identities = z.infer<typeof identitiesGuard>;
export const baseMfaVerification = {
id: z.string(),
createdAt: z.string(),
lastUsedAt: z.string().optional(),
};
export const mfaVerificationTotp = z.object({