From 468ca7a17ec2bf245b3358ac7fbf1a840430b03b Mon Sep 17 00:00:00 2001 From: simeng-li Date: Wed, 17 Jul 2024 19:58:15 +0800 Subject: [PATCH] feat(core): add mfa verification guard add mfa verification guard --- .../classes/experience-interaction.ts | 75 +++++++++-- .../classes/validators/mfa-validator.ts | 122 ++++++++++++++++++ .../sign-in-experience-validator.ts | 6 + .../verifications/totp-verification.ts | 1 + packages/core/src/routes/experience/index.ts | 2 +- .../backup-code-verification.ts | 4 +- .../verifications/mfa-verification.ts | 4 +- .../helpers/experience/totp-verification.ts | 2 +- .../mfa-verification.test.ts | 95 ++++++++++++++ .../verifications/totp-verification.test.ts | 6 +- 10 files changed, 299 insertions(+), 18 deletions(-) create mode 100644 packages/core/src/routes/experience/classes/validators/mfa-validator.ts create mode 100644 packages/integration-tests/src/tests/api/experience-api/sign-in-interaction/mfa-verification.test.ts diff --git a/packages/core/src/routes/experience/classes/experience-interaction.ts b/packages/core/src/routes/experience/classes/experience-interaction.ts index f2e39cdffe4b..16754d0f679c 100644 --- a/packages/core/src/routes/experience/classes/experience-interaction.ts +++ b/packages/core/src/routes/experience/classes/experience-interaction.ts @@ -1,5 +1,5 @@ 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 { conditional } from '@silverhand/essentials'; import { z } from 'zod'; @@ -17,6 +17,7 @@ import { identifyUserByVerificationRecord, toUserSocialIdentityData, } from './utils.js'; +import { MfaValidator } from './validators/mfa-validator.js'; import { ProfileValidator } from './validators/profile-validator.js'; import { SignInExperienceValidator } from './validators/sign-in-experience-validator.js'; import { @@ -55,6 +56,7 @@ export default class ExperienceInteraction { private readonly verificationRecords = new Map(); /** The userId of the user for the current interaction. Only available once the user is identified. */ private userId?: string; + private userCache?: User; /** The user provided profile data in the current interaction that needs to be stored to database. */ #profile?: InteractionProfile; /** The interaction event for the current interaction. */ @@ -190,6 +192,27 @@ export default class ExperienceInteraction { 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. */ public async save() { 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 */ public async submit() { - assertThat(this.userId, 'session.verification_session_not_found'); - const { queries: { users: userQueries, userSsoIdentities: userSsoIdentitiesQueries }, } = 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 - // TODO: profile updates validation - // TODO: missing profile fields validation + // Identified + const user = await this.getIdentifiedUser(); + + // Verified + if (this.#interactionEvent !== InteractionEvent.ForgotPassword) { + await this.guardMfaVerificationStatus(); + } const { socialIdentity, enterpriseSsoIdentity, ...rest } = this.#profile ?? {}; + // TODO: profile updates validation + // Update user profile - await userQueries.updateUserById(this.userId, { + await userQueries.updateUserById(user.id, { ...rest, ...conditional( socialIdentity && { @@ -240,6 +271,8 @@ export default class ExperienceInteraction { lastSignInAt: Date.now(), }); + // TODO: missing profile fields validation + if (enterpriseSsoIdentity) { await userSsoIdentitiesQueries.insert({ id: generateStandardId(), @@ -254,7 +287,7 @@ export default class ExperienceInteraction { const { provider } = this.tenant; const redirectTo = await provider.interactionResult(this.ctx.req, this.ctx.res, { - login: { accountId: this.userId }, + login: { accountId: user.id }, }); this.ctx.body = { redirectTo }; @@ -377,4 +410,28 @@ export default class ExperienceInteraction { ...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 { + 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; + } } diff --git a/packages/core/src/routes/experience/classes/validators/mfa-validator.ts b/packages/core/src/routes/experience/classes/validators/mfa-validator.ts new file mode 100644 index 000000000000..c1882b1d1474 --- /dev/null +++ b/packages/core/src/routes/experience/classes/validators/mfa-validator.ts @@ -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; + +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((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; + } +} diff --git a/packages/core/src/routes/experience/classes/validators/sign-in-experience-validator.ts b/packages/core/src/routes/experience/classes/validators/sign-in-experience-validator.ts index 25de8357eaf1..9a22b63c21f1 100644 --- a/packages/core/src/routes/experience/classes/validators/sign-in-experience-validator.ts +++ b/packages/core/src/routes/experience/classes/validators/sign-in-experience-validator.ts @@ -111,6 +111,12 @@ export class SignInExperienceValidator { return availableSsoConnectors.filter(({ domains }) => domains.includes(domain)); } + public async getMfaSettings() { + const { mfa } = await this.getSignInExperienceData(); + + return mfa; + } + public async getSignInExperienceData() { this.signInExperienceDataCache ||= await this.queries.signInExperiences.findDefaultSignInExperience(); diff --git a/packages/core/src/routes/experience/classes/verifications/totp-verification.ts b/packages/core/src/routes/experience/classes/verifications/totp-verification.ts index 9cf65977ebfa..d2a2134aff0d 100644 --- a/packages/core/src/routes/experience/classes/verifications/totp-verification.ts +++ b/packages/core/src/routes/experience/classes/verifications/totp-verification.ts @@ -124,6 +124,7 @@ export class TotpVerification implements VerificationRecord( router.post( `${experienceRoutes.prefix}/submit`, koaGuard({ - status: [200, 400], + status: [200, 400, 403, 422], response: z.object({ redirectTo: z.string(), }), diff --git a/packages/core/src/routes/experience/verification-routes/backup-code-verification.ts b/packages/core/src/routes/experience/verification-routes/backup-code-verification.ts index b38b72cdb4e0..413681e2dced 100644 --- a/packages/core/src/routes/experience/verification-routes/backup-code-verification.ts +++ b/packages/core/src/routes/experience/verification-routes/backup-code-verification.ts @@ -32,8 +32,6 @@ export default function backupCodeVerificationRoutes( assertThat(experienceInteraction.identifiedUserId, 'session.identifier_not_found'); - // TODO: Check if the MFA is enabled - const backupCodeVerificationRecord = BackupCodeVerification.create( libraries, queries, @@ -49,6 +47,8 @@ export default function backupCodeVerificationRoutes( ctx.body = { verificationId: backupCodeVerificationRecord.id, }; + + return next(); } ); } diff --git a/packages/core/src/routes/interaction/verifications/mfa-verification.ts b/packages/core/src/routes/interaction/verifications/mfa-verification.ts index 51e83ded21aa..2504b6256c4d 100644 --- a/packages/core/src/routes/interaction/verifications/mfa-verification.ts +++ b/packages/core/src/routes/interaction/verifications/mfa-verification.ts @@ -16,10 +16,10 @@ import assertThat from '#src/utils/assert-that.js'; import { type WithInteractionDetailsContext } from '../middleware/koa-interaction-details.js'; import { type WithInteractionSieContext } from '../middleware/koa-interaction-sie.js'; import { - type VerifiedSignInInteractionResult, + type AccountVerifiedInteractionResult, type VerifiedInteractionResult, type VerifiedRegisterInteractionResult, - type AccountVerifiedInteractionResult, + type VerifiedSignInInteractionResult, } from '../types/index.js'; import { generateBackupCodes } from '../utils/backup-code-validation.js'; import { storeInteractionResult } from '../utils/interaction.js'; diff --git a/packages/integration-tests/src/helpers/experience/totp-verification.ts b/packages/integration-tests/src/helpers/experience/totp-verification.ts index f5fea42e3fff..ced40ce9aabd 100644 --- a/packages/integration-tests/src/helpers/experience/totp-verification.ts +++ b/packages/integration-tests/src/helpers/experience/totp-verification.ts @@ -10,7 +10,7 @@ export const successFullyCreateNewTotpSecret = async (client: ExperienceClient) return { secret, secretQrCode, verificationId }; }; -export const successFullyVerifyTotp = async ( +export const successfullyVerifyTotp = async ( client: ExperienceClient, payload: { code: string; diff --git a/packages/integration-tests/src/tests/api/experience-api/sign-in-interaction/mfa-verification.test.ts b/packages/integration-tests/src/tests/api/experience-api/sign-in-interaction/mfa-verification.test.ts new file mode 100644 index 000000000000..3de9e1e6be17 --- /dev/null +++ b/packages/integration-tests/src/tests/api/experience-api/sign-in-interaction/mfa-verification.test.ts @@ -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(); + }); + }); +}); diff --git a/packages/integration-tests/src/tests/api/experience-api/verifications/totp-verification.test.ts b/packages/integration-tests/src/tests/api/experience-api/verifications/totp-verification.test.ts index c1fa2a76b38a..fc1a3cbf2019 100644 --- a/packages/integration-tests/src/tests/api/experience-api/verifications/totp-verification.test.ts +++ b/packages/integration-tests/src/tests/api/experience-api/verifications/totp-verification.test.ts @@ -6,7 +6,7 @@ import { initExperienceClient } from '#src/helpers/client.js'; import { identifyUserWithUsernamePassword } from '#src/helpers/experience/index.js'; import { successFullyCreateNewTotpSecret, - successFullyVerifyTotp, + successfullyVerifyTotp, } from '#src/helpers/experience/totp-verification.js'; import { expectRejects } from '#src/helpers/index.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 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); - await successFullyVerifyTotp(client, { code }); + await successfullyVerifyTotp(client, { code }); }); }); });