0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

feat(core): add mfa verification guard (#6262)

add mfa verification guard
This commit is contained in:
simeng-li 2024-07-19 18:01:58 +08:00 committed by GitHub
parent c93ffb4760
commit 7537c510f7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 299 additions and 18 deletions

View file

@ -1,5 +1,5 @@
import { type ToZodObject } from '@logto/connector-kit'; import { type ToZodObject } from '@logto/connector-kit';
import { InteractionEvent, type VerificationType } from '@logto/schemas'; import { InteractionEvent, type User, type VerificationType } from '@logto/schemas';
import { generateStandardId } from '@logto/shared'; import { generateStandardId } from '@logto/shared';
import { conditional } from '@silverhand/essentials'; import { conditional } from '@silverhand/essentials';
import { z } from 'zod'; import { z } from 'zod';
@ -17,6 +17,7 @@ import {
identifyUserByVerificationRecord, identifyUserByVerificationRecord,
toUserSocialIdentityData, toUserSocialIdentityData,
} from './utils.js'; } from './utils.js';
import { MfaValidator } from './validators/mfa-validator.js';
import { ProfileValidator } from './validators/profile-validator.js'; import { ProfileValidator } from './validators/profile-validator.js';
import { SignInExperienceValidator } from './validators/sign-in-experience-validator.js'; import { SignInExperienceValidator } from './validators/sign-in-experience-validator.js';
import { import {
@ -55,6 +56,7 @@ export default class ExperienceInteraction {
private readonly verificationRecords = new Map<VerificationType, VerificationRecord>(); private readonly verificationRecords = new Map<VerificationType, VerificationRecord>();
/** The userId of the user for the current interaction. Only available once the user is identified. */ /** The userId of the user for the current interaction. Only available once the user is identified. */
private userId?: string; private userId?: string;
private userCache?: User;
/** The user provided profile data in the current interaction that needs to be stored to database. */ /** The user provided profile data in the current interaction that needs to be stored to database. */
#profile?: InteractionProfile; #profile?: InteractionProfile;
/** The interaction event for the current interaction. */ /** The interaction event for the current interaction. */
@ -190,6 +192,27 @@ export default class ExperienceInteraction {
return this.verificationRecordsArray.find((record) => record.id === verificationId); return this.verificationRecordsArray.find((record) => record.id === verificationId);
} }
/**
* Validate the interaction verification records against the sign-in experience and user MFA settings.
*
* The interaction is verified if at least one user enabled MFA verification record is present and verified.
*/
public async guardMfaVerificationStatus() {
const user = await this.getIdentifiedUser();
const mfaSettings = await this.signInExperienceValidator.getMfaSettings();
const mfaValidator = new MfaValidator(mfaSettings, user);
const isVerified = mfaValidator.isMfaVerified(this.verificationRecordsArray);
assertThat(
isVerified,
new RequestError(
{ code: 'session.mfa.require_mfa_verification', status: 403 },
{ availableFactors: mfaValidator.availableMfaVerificationTypes }
)
);
}
/** Save the current interaction result. */ /** Save the current interaction result. */
public async save() { public async save() {
const { provider } = this.tenant; const { provider } = this.tenant;
@ -212,22 +235,30 @@ export default class ExperienceInteraction {
/** Submit the current interaction result to the OIDC provider and clear the interaction data */ /** Submit the current interaction result to the OIDC provider and clear the interaction data */
public async submit() { public async submit() {
assertThat(this.userId, 'session.verification_session_not_found');
const { const {
queries: { users: userQueries, userSsoIdentities: userSsoIdentitiesQueries }, queries: { users: userQueries, userSsoIdentities: userSsoIdentitiesQueries },
} = this.tenant; } = this.tenant;
const user = await userQueries.findUserById(this.userId); // Initiated
assertThat(
this.interactionEvent,
new RequestError({ code: 'session.interaction_not_found', status: 404 })
);
// TODO: mfa validation // Identified
// TODO: profile updates validation const user = await this.getIdentifiedUser();
// TODO: missing profile fields validation
// Verified
if (this.#interactionEvent !== InteractionEvent.ForgotPassword) {
await this.guardMfaVerificationStatus();
}
const { socialIdentity, enterpriseSsoIdentity, ...rest } = this.#profile ?? {}; const { socialIdentity, enterpriseSsoIdentity, ...rest } = this.#profile ?? {};
// TODO: profile updates validation
// Update user profile // Update user profile
await userQueries.updateUserById(this.userId, { await userQueries.updateUserById(user.id, {
...rest, ...rest,
...conditional( ...conditional(
socialIdentity && { socialIdentity && {
@ -240,6 +271,8 @@ export default class ExperienceInteraction {
lastSignInAt: Date.now(), lastSignInAt: Date.now(),
}); });
// TODO: missing profile fields validation
if (enterpriseSsoIdentity) { if (enterpriseSsoIdentity) {
await userSsoIdentitiesQueries.insert({ await userSsoIdentitiesQueries.insert({
id: generateStandardId(), id: generateStandardId(),
@ -254,7 +287,7 @@ export default class ExperienceInteraction {
const { provider } = this.tenant; const { provider } = this.tenant;
const redirectTo = await provider.interactionResult(this.ctx.req, this.ctx.res, { const redirectTo = await provider.interactionResult(this.ctx.req, this.ctx.res, {
login: { accountId: this.userId }, login: { accountId: user.id },
}); });
this.ctx.body = { redirectTo }; this.ctx.body = { redirectTo };
@ -377,4 +410,28 @@ export default class ExperienceInteraction {
...profile, ...profile,
}; };
} }
/**
* Assert the interaction is identified and return the identified user.
*
* @throws RequestError with 400 if the user is not identified
* @throws RequestError with 404 if the user is not found
*/
private async getIdentifiedUser(): Promise<User> {
if (this.userCache) {
return this.userCache;
}
// Identified
assertThat(this.userId, 'session.identifier_not_found');
const {
queries: { users: userQueries },
} = this.tenant;
const user = await userQueries.findUserById(this.userId);
this.userCache = user;
return this.userCache;
}
} }

View file

@ -0,0 +1,122 @@
import {
MfaFactor,
VerificationType,
type Mfa,
type MfaVerification,
type User,
} from '@logto/schemas';
import { type VerificationRecord } from '../verifications/index.js';
const mfaVerificationTypes = Object.freeze([
VerificationType.TOTP,
VerificationType.BackupCode,
VerificationType.WebAuthn,
]);
type MfaVerificationType =
| VerificationType.TOTP
| VerificationType.BackupCode
| VerificationType.WebAuthn;
const mfaVerificationTypeToMfaFactorMap = Object.freeze({
[VerificationType.TOTP]: MfaFactor.TOTP,
[VerificationType.BackupCode]: MfaFactor.BackupCode,
[VerificationType.WebAuthn]: MfaFactor.WebAuthn,
}) satisfies Record<MfaVerificationType, MfaFactor>;
const isMfaVerificationRecordType = (type: VerificationType): type is MfaVerificationType => {
return mfaVerificationTypes.includes(type);
};
export class MfaValidator {
constructor(
private readonly mfaSettings: Mfa,
private readonly user: User
) {}
/**
* Get the enabled MFA factors for the user
* - Filter out MFA factors that are not configured in the sign-in experience
*/
get userEnabledMfaVerifications() {
const { mfaVerifications } = this.user;
return mfaVerifications.filter((verification) =>
this.mfaSettings.factors.includes(verification.type)
);
}
/**
* For front-end display usage only
* Return the available MFA verifications for the user.
*/
get availableMfaVerificationTypes() {
return (
this.userEnabledMfaVerifications
// Filter out backup codes if all the codes are used
.filter((verification) => {
if (verification.type !== MfaFactor.BackupCode) {
return true;
}
return verification.codes.some((code) => !code.usedAt);
})
// Filter out duplicated verifications with the same type
.reduce<MfaVerification[]>((verifications, verification) => {
if (verifications.some(({ type }) => type === verification.type)) {
return verifications;
}
return [...verifications, verification];
}, [])
.slice()
// Sort by last used time, the latest used factor is the first one, backup code is always the last one
.sort((verificationA, verificationB) => {
if (verificationA.type === MfaFactor.BackupCode) {
return 1;
}
if (verificationB.type === MfaFactor.BackupCode) {
return -1;
}
return (
new Date(verificationB.lastUsedAt ?? 0).getTime() -
new Date(verificationA.lastUsedAt ?? 0).getTime()
);
})
.map(({ type }) => type)
);
}
get isMfaEnabled() {
return this.userEnabledMfaVerifications.length > 0;
}
isMfaVerified(verificationRecords: VerificationRecord[]) {
// MFA validation is not enabled
if (!this.isMfaEnabled) {
return true;
}
const mfaVerificationRecords = this.filterVerifiedMfaVerificationRecords(verificationRecords);
return mfaVerificationRecords.length > 0;
}
filterVerifiedMfaVerificationRecords(verificationRecords: VerificationRecord[]) {
const enabledMfaFactors = this.userEnabledMfaVerifications;
// Filter out the verified MFA verification records
const mfaVerificationRecords = verificationRecords.filter(({ type, isVerified }) => {
return (
isVerified &&
isMfaVerificationRecordType(type) &&
// Check if the verification type is enabled in the user's MFA settings
enabledMfaFactors.some((factor) => factor.type === mfaVerificationTypeToMfaFactorMap[type])
);
});
return mfaVerificationRecords;
}
}

View file

@ -111,6 +111,12 @@ export class SignInExperienceValidator {
return availableSsoConnectors.filter(({ domains }) => domains.includes(domain)); return availableSsoConnectors.filter(({ domains }) => domains.includes(domain));
} }
public async getMfaSettings() {
const { mfa } = await this.getSignInExperienceData();
return mfa;
}
public async getSignInExperienceData() { public async getSignInExperienceData() {
this.signInExperienceDataCache ||= this.signInExperienceDataCache ||=
await this.queries.signInExperiences.findDefaultSignInExperience(); await this.queries.signInExperiences.findDefaultSignInExperience();

View file

@ -124,6 +124,7 @@ export class TotpVerification implements VerificationRecord<VerificationType.TOT
const { mfaVerifications } = await findUserById(this.userId); const { mfaVerifications } = await findUserById(this.userId);
// User can only have one TOTP MFA record in the profile
const totpVerification = findUserTotp(mfaVerifications); const totpVerification = findUserTotp(mfaVerifications);
// Can not found totp verification, this is an invalid request, throw invalid code error anyway for security reason // Can not found totp verification, this is an invalid request, throw invalid code error anyway for security reason

View file

@ -128,7 +128,7 @@ export default function experienceApiRoutes<T extends AnonymousRouter>(
router.post( router.post(
`${experienceRoutes.prefix}/submit`, `${experienceRoutes.prefix}/submit`,
koaGuard({ koaGuard({
status: [200, 400], status: [200, 400, 403, 422],
response: z.object({ response: z.object({
redirectTo: z.string(), redirectTo: z.string(),
}), }),

View file

@ -32,8 +32,6 @@ export default function backupCodeVerificationRoutes<T extends WithLogContext>(
assertThat(experienceInteraction.identifiedUserId, 'session.identifier_not_found'); assertThat(experienceInteraction.identifiedUserId, 'session.identifier_not_found');
// TODO: Check if the MFA is enabled
const backupCodeVerificationRecord = BackupCodeVerification.create( const backupCodeVerificationRecord = BackupCodeVerification.create(
libraries, libraries,
queries, queries,
@ -49,6 +47,8 @@ export default function backupCodeVerificationRoutes<T extends WithLogContext>(
ctx.body = { ctx.body = {
verificationId: backupCodeVerificationRecord.id, verificationId: backupCodeVerificationRecord.id,
}; };
return next();
} }
); );
} }

View file

@ -16,10 +16,10 @@ import assertThat from '#src/utils/assert-that.js';
import { type WithInteractionDetailsContext } from '../middleware/koa-interaction-details.js'; import { type WithInteractionDetailsContext } from '../middleware/koa-interaction-details.js';
import { type WithInteractionSieContext } from '../middleware/koa-interaction-sie.js'; import { type WithInteractionSieContext } from '../middleware/koa-interaction-sie.js';
import { import {
type VerifiedSignInInteractionResult, type AccountVerifiedInteractionResult,
type VerifiedInteractionResult, type VerifiedInteractionResult,
type VerifiedRegisterInteractionResult, type VerifiedRegisterInteractionResult,
type AccountVerifiedInteractionResult, type VerifiedSignInInteractionResult,
} from '../types/index.js'; } from '../types/index.js';
import { generateBackupCodes } from '../utils/backup-code-validation.js'; import { generateBackupCodes } from '../utils/backup-code-validation.js';
import { storeInteractionResult } from '../utils/interaction.js'; import { storeInteractionResult } from '../utils/interaction.js';

View file

@ -10,7 +10,7 @@ export const successFullyCreateNewTotpSecret = async (client: ExperienceClient)
return { secret, secretQrCode, verificationId }; return { secret, secretQrCode, verificationId };
}; };
export const successFullyVerifyTotp = async ( export const successfullyVerifyTotp = async (
client: ExperienceClient, client: ExperienceClient,
payload: { payload: {
code: string; code: string;

View file

@ -0,0 +1,95 @@
import { MfaFactor } from '@logto/schemas';
import { authenticator } from 'otplib';
import { createUserMfaVerification } from '#src/api/admin-user.js';
import { initExperienceClient } from '#src/helpers/client.js';
import { identifyUserWithUsernamePassword } from '#src/helpers/experience/index.js';
import { successfullyVerifyTotp } from '#src/helpers/experience/totp-verification.js';
import { expectRejects } from '#src/helpers/index.js';
import {
enableAllPasswordSignInMethods,
enableMandatoryMfaWithTotpAndBackupCode,
} from '#src/helpers/sign-in-experience.js';
import { generateNewUserProfile, UserApiTest } from '#src/helpers/user.js';
import { devFeatureTest } from '#src/utils.js';
devFeatureTest.describe('mfa sign-in verification', () => {
const userApi = new UserApiTest();
beforeAll(async () => {
await enableAllPasswordSignInMethods();
});
afterAll(async () => {
await userApi.cleanUp();
});
describe('TOTP verification', () => {
beforeAll(async () => {
await enableMandatoryMfaWithTotpAndBackupCode();
});
it('should throw require_mfa_verification error when signing in without mfa verification', async () => {
const { username, password } = generateNewUserProfile({ username: true, password: true });
const user = await userApi.create({ username, password });
await createUserMfaVerification(user.id, MfaFactor.TOTP);
const client = await initExperienceClient();
await identifyUserWithUsernamePassword(client, username, password);
await expectRejects(client.submitInteraction(), {
code: 'session.mfa.require_mfa_verification',
status: 403,
});
});
it('should sign-in successfully with TOTP verification', async () => {
const { username, password } = generateNewUserProfile({ username: true, password: true });
const user = await userApi.create({ username, password });
const response = await createUserMfaVerification(user.id, MfaFactor.TOTP);
if (response.type !== MfaFactor.TOTP) {
throw new Error('unexpected mfa type');
}
const { secret } = response;
const client = await initExperienceClient();
await identifyUserWithUsernamePassword(client, username, password);
const code = authenticator.generate(secret);
await successfullyVerifyTotp(client, { code });
await expect(client.submitInteraction()).resolves.not.toThrow();
});
it('should sign-in successfully with backup code with both TOTP and backup code enabled', async () => {
const { username, password } = generateNewUserProfile({ username: true, password: true });
const user = await userApi.create({ username, password });
await createUserMfaVerification(user.id, MfaFactor.TOTP);
const response = await createUserMfaVerification(user.id, MfaFactor.BackupCode);
if (response.type !== MfaFactor.BackupCode) {
throw new Error('unexpected mfa type');
}
const { codes } = response;
const client = await initExperienceClient();
await identifyUserWithUsernamePassword(client, username, password);
const code = codes[0]!;
await client.verifyBackupCode({ code });
await expect(client.submitInteraction()).resolves.not.toThrow();
});
});
});

View file

@ -6,7 +6,7 @@ import { initExperienceClient } from '#src/helpers/client.js';
import { identifyUserWithUsernamePassword } from '#src/helpers/experience/index.js'; import { identifyUserWithUsernamePassword } from '#src/helpers/experience/index.js';
import { import {
successFullyCreateNewTotpSecret, successFullyCreateNewTotpSecret,
successFullyVerifyTotp, successfullyVerifyTotp,
} from '#src/helpers/experience/totp-verification.js'; } from '#src/helpers/experience/totp-verification.js';
import { expectRejects } from '#src/helpers/index.js'; import { expectRejects } from '#src/helpers/index.js';
import { enableAllPasswordSignInMethods } from '#src/helpers/sign-in-experience.js'; import { enableAllPasswordSignInMethods } from '#src/helpers/sign-in-experience.js';
@ -98,7 +98,7 @@ devFeatureTest.describe('TOTP verification APIs', () => {
const { verificationId, secret } = await successFullyCreateNewTotpSecret(client); const { verificationId, secret } = await successFullyCreateNewTotpSecret(client);
const code = authenticator.generate(secret); const code = authenticator.generate(secret);
await successFullyVerifyTotp(client, { code, verificationId }); await successfullyVerifyTotp(client, { code, verificationId });
}); });
}); });
@ -157,7 +157,7 @@ devFeatureTest.describe('TOTP verification APIs', () => {
const code = authenticator.generate(secret); const code = authenticator.generate(secret);
await successFullyVerifyTotp(client, { code }); await successfullyVerifyTotp(client, { code });
}); });
}); });
}); });