diff --git a/packages/core/src/routes/experience/classes/experience-interaction.ts b/packages/core/src/routes/experience/classes/experience-interaction.ts index 5004f7c35b6b..a55ebd65a5bc 100644 --- a/packages/core/src/routes/experience/classes/experience-interaction.ts +++ b/packages/core/src/routes/experience/classes/experience-interaction.ts @@ -1,6 +1,8 @@ +/* eslint-disable max-lines */ import { type ToZodObject } from '@logto/connector-kit'; import { InteractionEvent, + MfaFactor, VerificationType, type UpdateProfileApiPayload, type User, @@ -18,7 +20,9 @@ import { interactionProfileGuard, type Interaction, type InteractionProfile } fr import { getNewUserProfileFromVerificationRecord, identifyUserByVerificationRecord, + mergeUserMfaVerifications, } from './helpers.js'; +import { Mfa, mfaDataGuard, userMfaDataKey, type MfaData } from './mfa.js'; import { Profile } from './profile.js'; import { toUserSocialIdentityData } from './utils.js'; import { MfaValidator } from './validators/mfa-validator.js'; @@ -37,6 +41,7 @@ type InteractionStorage = { interactionEvent?: InteractionEvent; userId?: string; profile?: InteractionProfile; + bindMfa?: MfaData; verificationRecords?: VerificationRecordData[]; }; @@ -44,6 +49,7 @@ const interactionStorageGuard = z.object({ interactionEvent: z.nativeEnum(InteractionEvent).optional(), userId: z.string().optional(), profile: interactionProfileGuard.optional(), + bindMfa: mfaDataGuard.optional(), verificationRecords: verificationRecordDataGuard.array().optional(), }) satisfies ToZodObject; @@ -64,6 +70,7 @@ export default class ExperienceInteraction { private userCache?: User; /** The user provided profile data in the current interaction that needs to be stored to database. */ readonly #profile: Profile; + readonly #bindMfa: Mfa; /** The interaction event for the current interaction. */ #interactionEvent?: InteractionEvent; @@ -85,6 +92,7 @@ export default class ExperienceInteraction { if (!interactionDetails) { this.#profile = new Profile(libraries, queries, {}, async () => this.getIdentifiedUser()); + this.#bindMfa = new Mfa(libraries, queries, {}, async () => this.getIdentifiedUser()); return; } @@ -96,11 +104,18 @@ export default class ExperienceInteraction { new RequestError({ code: 'session.interaction_not_found', status: 404 }) ); - const { verificationRecords = [], profile = {}, userId, interactionEvent } = result.data; + const { + verificationRecords = [], + profile = {}, + bindMfa = {}, + userId, + interactionEvent, + } = result.data; this.#interactionEvent = interactionEvent; this.userId = userId; this.#profile = new Profile(libraries, queries, profile, async () => this.getIdentifiedUser()); + this.#bindMfa = new Mfa(libraries, queries, bindMfa, async () => this.getIdentifiedUser()); for (const record of verificationRecords) { const instance = buildVerificationRecord(libraries, queries, record); @@ -188,6 +203,9 @@ export default class ExperienceInteraction { this.verificationRecords.setValue(record); } + /** + * @throws {RequestError} with 404 if the verification record is not found + */ public getVerificationRecordByTypeAndId( type: K, verificationId: string @@ -202,10 +220,11 @@ export default class ExperienceInteraction { return record; } + /** + * @throws {RequestError} with 404 if the verification record is not found + * @throws {RequestError} with 422 if the profile is invalid + */ public async addUserProfile({ email, phone, username, password }: UpdateProfileApiPayload) { - // Guard current interaction event is MFA verified - await this.guardMfaVerificationStatus(); - const primaryEmail = email && this.getVerificationRecordByTypeAndId( @@ -233,11 +252,53 @@ export default class ExperienceInteraction { } } + /** + * @throws {RequestError} with 404 if the interaction is not identified + * @throws {RequestError} with 422 if the password is invalid + */ public async resetPassword(password: string) { - await this.getIdentifiedUser(); await this.#profile.setPasswordDigest(password, true); } + public async skipMfa() { + await this.#bindMfa.skip(); + } + + /** + * @throws {RequestError} with 404 if the verification record is not found + * @throws {RequestError} with 422 if the mfa factor is not allowed + */ + public async bindMfaByVerificationRecord( + type: MfaFactor.TOTP | MfaFactor.WebAuthn, + verificationRecordId: string + ) { + switch (type) { + case MfaFactor.TOTP: { + const verificationRecord = this.getVerificationRecordByTypeAndId( + VerificationType.TOTP, + verificationRecordId + ); + await this.#bindMfa.addTotp(verificationRecord.toBindMfa()); + break; + } + case MfaFactor.WebAuthn: { + const verificationRecord = this.getVerificationRecordByTypeAndId( + VerificationType.WebAuthn, + verificationRecordId + ); + await this.#bindMfa.addWebAuthn(verificationRecord.toBindMfa()); + break; + } + } + } + + /** + * @throws {RequestError} with 422 if the backup codes is not allowed + */ + public async generateBackupCodes() { + return this.#bindMfa.addBackupCodes(); + } + /** * 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. @@ -256,7 +317,7 @@ export default class ExperienceInteraction { isVerified, new RequestError( { code: 'session.mfa.require_mfa_verification', status: 403 }, - { availableFactors: mfaValidator.availableMfaVerificationTypes } + { availableFactors: mfaValidator.availableUserMfaVerificationTypes } ) ); } @@ -293,7 +354,7 @@ export default class ExperienceInteraction { **/ public async submit() { const { - queries: { users: userQueries, userSsoIdentities: userSsoIdentitiesQueries }, + queries: { users: userQueries }, } = this.tenant; // Initiated @@ -335,7 +396,14 @@ export default class ExperienceInteraction { // Profile fulfilled await this.#profile.assertUserMandatoryProfileFulfilled(); + // Revalidate the new MFA data if any + await this.#bindMfa.checkAvailability(); + + // MFA fulfilled + await this.#bindMfa.assertUserMandatoryMfaFulfilled(); + const { socialIdentity, enterpriseSsoIdentity, ...rest } = this.#profile.data; + const { mfaSkipped, mfaVerifications } = this.#bindMfa.toUserMfaVerifications(); // Update user profile await userQueries.updateUserById(user.id, { @@ -348,6 +416,21 @@ export default class ExperienceInteraction { }, } ), + ...conditional( + mfaVerifications.length > 0 && { + mfaVerifications: mergeUserMfaVerifications(user.mfaVerifications, mfaVerifications), + } + ), + ...conditional( + mfaSkipped && { + logtoConfig: { + ...user.logtoConfig, + [userMfaDataKey]: { + skipped: true, + }, + }, + } + ), lastSignInAt: Date.now(), }); @@ -374,6 +457,7 @@ export default class ExperienceInteraction { interactionEvent, userId, profile: this.#profile.data, + bindMfa: this.#bindMfa.data, verificationRecords: this.verificationRecordsArray.map((record) => record.toJson()), }; } @@ -481,3 +565,4 @@ export default class ExperienceInteraction { await provider.interactionResult(this.ctx.req, this.ctx.res, {}); } } +/* eslint-enable max-lines */ diff --git a/packages/core/src/routes/experience/classes/helpers.ts b/packages/core/src/routes/experience/classes/helpers.ts index 29fa65ad0aa4..b08d8ff6f63d 100644 --- a/packages/core/src/routes/experience/classes/helpers.ts +++ b/packages/core/src/routes/experience/classes/helpers.ts @@ -5,7 +5,7 @@ * we have moved some of the standalone functions into this file. */ -import { VerificationType, type User } from '@logto/schemas'; +import { MfaFactor, VerificationType, type User } from '@logto/schemas'; import { conditional } from '@silverhand/essentials'; import RequestError from '#src/errors/RequestError/index.js'; @@ -112,3 +112,20 @@ export const identifyUserByVerificationRecord = async ( } } }; + +/** + * Should remove the old backup codes verification if the user is binding a new one + */ +export const mergeUserMfaVerifications = ( + userMfaVerifications: User['mfaVerifications'], + newMfaVerifications: User['mfaVerifications'] +): User['mfaVerifications'] => { + if (newMfaVerifications.some((verification) => verification.type === MfaFactor.BackupCode)) { + const filteredMfaVerifications = userMfaVerifications.filter(({ type }) => { + return type !== MfaFactor.BackupCode; + }); + return [...filteredMfaVerifications, ...newMfaVerifications]; + } + + return [...userMfaVerifications, ...newMfaVerifications]; +}; diff --git a/packages/core/src/routes/experience/classes/mfa.ts b/packages/core/src/routes/experience/classes/mfa.ts new file mode 100644 index 000000000000..2ce962493470 --- /dev/null +++ b/packages/core/src/routes/experience/classes/mfa.ts @@ -0,0 +1,307 @@ +import { type ToZodObject } from '@logto/connector-kit'; +import { + type BindBackupCode, + type BindMfa, + bindMfaGuard, + type BindTotp, + type BindWebAuthn, + type JsonObject, + MfaFactor, + MfaPolicy, + type User, +} from '@logto/schemas'; +import { generateStandardId } from '@logto/shared'; +import { deduplicate } from '@silverhand/essentials'; +import { z } from 'zod'; + +import RequestError from '#src/errors/RequestError/index.js'; +import { generateBackupCodes } from '#src/routes/interaction/utils/backup-code-validation.js'; +import type Libraries from '#src/tenants/Libraries.js'; +import type Queries from '#src/tenants/Queries.js'; +import assertThat from '#src/utils/assert-that.js'; + +import { SignInExperienceValidator } from './validators/sign-in-experience-validator.js'; + +export type MfaData = { + mfaSkipped?: boolean; + bindMfaFactorsArray?: BindMfa[]; +}; + +export const mfaDataGuard = z.object({ + mfaSkipped: z.boolean().optional(), + bindMfaFactorsArray: bindMfaGuard.array().optional(), +}) satisfies ToZodObject; + +export const userMfaDataKey = 'mfa'; + +/** + * Check if the user has skipped MFA binding + */ +const isMfaSkipped = (logtoConfig: JsonObject): boolean => { + const userMfaDataGuard = z.object({ + skipped: z.boolean().optional(), + }); + + const parsed = z.object({ [userMfaDataKey]: userMfaDataGuard }).safeParse(logtoConfig); + + return parsed.success ? parsed.data[userMfaDataKey].skipped === true : false; +}; + +/** + * Filter out backup codes mfa verifications that have been used + */ +const filterOutEmptyBackupCodes = ( + mfaVerifications: User['mfaVerifications'] +): User['mfaVerifications'] => + mfaVerifications.filter((mfa) => { + if (mfa.type === MfaFactor.BackupCode) { + return mfa.codes.some((code) => !code.usedAt); + } + return true; + }); + +/** + * This class stores all the pending new MFA settings for a user. + */ +export class Mfa { + private readonly signInExperienceValidator: SignInExperienceValidator; + #mfaSkipped?: boolean; + #totp?: BindTotp; + #webAuthn?: BindWebAuthn[]; + #backupCode?: BindBackupCode; + + constructor( + private readonly libraries: Libraries, + queries: Queries, + data: MfaData, + private readonly getUserFromContext: () => Promise + ) { + this.signInExperienceValidator = new SignInExperienceValidator(libraries, queries); + + this.provisionMfaData(data); + } + + get mfaSkipped() { + return this.#mfaSkipped; + } + + get bindMfaFactorsArray(): BindMfa[] { + return [this.#totp, ...(this.#webAuthn ?? []), this.#backupCode].filter(Boolean); + } + + /** + * Format the MFA verifications data to be updated in the user account + */ + toUserMfaVerifications(): { + mfaSkipped?: boolean; + mfaVerifications: User['mfaVerifications']; + } { + const verificationSet = new Set(); + + if (this.#totp) { + verificationSet.add({ + type: MfaFactor.TOTP, + key: this.#totp.secret, + id: generateStandardId(), + createdAt: new Date().toISOString(), + }); + } + + if (this.#webAuthn) { + for (const webAuthn of this.#webAuthn) { + verificationSet.add({ + ...webAuthn, + id: generateStandardId(), + createdAt: new Date().toISOString(), + }); + } + } + + if (this.#backupCode) { + verificationSet.add({ + id: generateStandardId(), + createdAt: new Date().toISOString(), + type: MfaFactor.BackupCode, + codes: this.#backupCode.codes.map((code) => ({ code })), + }); + } + + return { + mfaSkipped: this.mfaSkipped, + mfaVerifications: [...verificationSet], + }; + } + + /** + * @throws {RequestError} with status 422 if the MFA policy is not user controlled + */ + async skip() { + const { policy } = await this.signInExperienceValidator.getMfaSettings(); + + assertThat( + policy === MfaPolicy.UserControlled, + new RequestError({ + code: 'session.mfa.mfa_policy_not_user_controlled', + status: 422, + }) + ); + + this.#mfaSkipped = true; + } + + /** + * @throws {RequestError} with status 422 if TOTP is not enabled in the sign-in experience + * @throws {RequestError} with status 422 if the user already has a TOTP factor + * + * - Any existing TOTP factor will be replaced with the new one. + */ + async addTotp(data: BindTotp) { + await this.checkMfaFactorsEnabledInSignInExperience([MfaFactor.TOTP]); + const { mfaVerifications } = await this.getUserFromContext(); + + // A user can only bind one TOTP factor + assertThat( + mfaVerifications.every(({ type }) => type !== MfaFactor.TOTP), + new RequestError({ + code: 'user.totp_already_in_use', + status: 422, + }) + ); + + this.#totp = data; + } + + /** + * @throws {RequestError} with status 422 if WebAuthn is not enabled in the sign-in experience + */ + async addWebAuthn(data: BindWebAuthn) { + await this.checkMfaFactorsEnabledInSignInExperience([MfaFactor.WebAuthn]); + this.#webAuthn = [...(this.#webAuthn ?? []), data]; + } + + /** + * + * Generates new backup codes for the user. + * + * @throws {RequestError} with status 422 if Backup Code is not enabled in the sign-in experience + * @throws {RequestError} with status 422 if the backup code is the only MFA factor + * + * - Any existing backup code factor will be replaced with the new one. + */ + async addBackupCodes() { + await this.checkMfaFactorsEnabledInSignInExperience([MfaFactor.BackupCode]); + const { mfaVerifications } = await this.getUserFromContext(); + + const userHasOtherMfa = mfaVerifications.some((mfa) => mfa.type !== MfaFactor.BackupCode); + const hasOtherNewMfa = Boolean(this.#totp ?? this.#webAuthn?.length); + + assertThat( + userHasOtherMfa || hasOtherNewMfa, + new RequestError({ + code: 'session.mfa.backup_code_can_not_be_alone', + status: 422, + }) + ); + + const codes = generateBackupCodes(); + + this.#backupCode = { + type: MfaFactor.BackupCode, + codes, + }; + + return codes; + } + + /** + * @throws {RequestError} with status 422 if the mfa factors are not enabled in the sign-in experience + */ + async checkAvailability() { + const newBindMfaFactors = deduplicate(this.bindMfaFactorsArray.map(({ type }) => type)); + await this.checkMfaFactorsEnabledInSignInExperience(newBindMfaFactors); + } + + /** + * @throws {RequestError} with status 422 if the user has not bound the required MFA factors + * @throws {RequestError} with status 422 if the user has not bound the backup code but enabled in the sign-in experience + * @throws {RequestError} with status 422 if the user existing backup codes is empty, new backup codes is required + */ + async assertUserMandatoryMfaFulfilled() { + const { factors, policy } = await this.signInExperienceValidator.getMfaSettings(); + + // If there are no factors, then there is nothing to check + if (factors.length === 0) { + return; + } + + const { mfaVerifications, logtoConfig } = await this.getUserFromContext(); + + // If the policy is user controlled and the user has skipped MFA, then there is nothing to check + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + if ((policy === MfaPolicy.UserControlled && this.#mfaSkipped) || isMfaSkipped(logtoConfig)) { + return; + } + + const requiredFactors = factors.filter((factor) => factor !== MfaFactor.BackupCode); + + const factorsInUser = filterOutEmptyBackupCodes(mfaVerifications).map(({ type }) => type); + const factorsInBind = this.bindMfaFactorsArray.map(({ type }) => type); + const availableFactors = deduplicate([...factorsInUser, ...factorsInBind]); + + // Assert that the user has at least one of the required factors bound + assertThat( + requiredFactors.some((factor) => availableFactors.includes(factor)), + new RequestError( + { code: 'user.missing_mfa', status: 422 }, + policy === MfaPolicy.Mandatory + ? { availableFactors } + : { availableFactors, skippable: true } + ) + ); + + // Assert backup code + assertThat( + !factors.includes(MfaFactor.BackupCode) || availableFactors.includes(MfaFactor.BackupCode), + new RequestError({ + code: 'session.mfa.backup_code_required', + status: 422, + }) + ); + } + + get data(): MfaData { + return { + mfaSkipped: this.mfaSkipped, + bindMfaFactorsArray: this.bindMfaFactorsArray, + }; + } + + private provisionMfaData({ mfaSkipped, bindMfaFactorsArray = [] }: MfaData) { + this.#mfaSkipped = mfaSkipped; + + for (const mfaFactor of bindMfaFactorsArray) { + switch (mfaFactor.type) { + case MfaFactor.TOTP: { + this.#totp = mfaFactor; + break; + } + case MfaFactor.WebAuthn: { + this.#webAuthn = [...(this.#webAuthn ?? []), mfaFactor]; + break; + } + case MfaFactor.BackupCode: { + this.#backupCode = mfaFactor; + break; + } + } + } + } + + private async checkMfaFactorsEnabledInSignInExperience(factors: MfaFactor[]) { + const { factors: enabledFactors } = await this.signInExperienceValidator.getMfaSettings(); + + const isFactorsEnabled = factors.every((factor) => enabledFactors.includes(factor)); + + assertThat(isFactorsEnabled, new RequestError({ code: 'session.mfa.mfa_factor_not_enabled' })); + } +} diff --git a/packages/core/src/routes/experience/classes/validators/mfa-validator.ts b/packages/core/src/routes/experience/classes/validators/mfa-validator.ts index 9f31ff3f0fba..1bab279786db 100644 --- a/packages/core/src/routes/experience/classes/validators/mfa-validator.ts +++ b/packages/core/src/routes/experience/classes/validators/mfa-validator.ts @@ -6,7 +6,10 @@ import { type User, } from '@logto/schemas'; +import { type BackupCodeVerification } from '../verifications/backup-code-verification.js'; import { type VerificationRecord } from '../verifications/index.js'; +import { type TotpVerification } from '../verifications/totp-verification.js'; +import { type WebAuthnVerification } from '../verifications/web-authn-verification.js'; const mfaVerificationTypes = Object.freeze([ VerificationType.TOTP, @@ -25,8 +28,30 @@ const mfaVerificationTypeToMfaFactorMap = Object.freeze({ [VerificationType.WebAuthn]: MfaFactor.WebAuthn, }) satisfies Record; -const isMfaVerificationRecordType = (type: VerificationType): type is MfaVerificationType => { - return mfaVerificationTypes.includes(type); +type MfaVerificationRecord = TotpVerification | WebAuthnVerification | BackupCodeVerification; + +const isMfaVerificationRecord = ( + verification: VerificationRecord +): verification is MfaVerificationRecord => { + return mfaVerificationTypes.includes(verification.type); +}; + +/** + * Check if the MFA verification record is a new bind MFA verification. + * New bind MFA verification can only be used for binding new MFA factors. + */ +const isNewBindMfaVerification = (verification: MfaVerificationRecord) => { + switch (verification.type) { + case VerificationType.TOTP: { + return Boolean(verification.secret); + } + case VerificationType.WebAuthn: { + return Boolean(verification.registrationInfo); + } + case VerificationType.BackupCode: { + return false; + } + } }; export class MfaValidator { @@ -37,6 +62,7 @@ export class MfaValidator { /** * Get the enabled MFA factors for the user + * * - Filter out MFA factors that are not configured in the sign-in experience */ get userEnabledMfaVerifications() { @@ -48,10 +74,14 @@ export class MfaValidator { } /** - * For front-end display usage only - * Return the available MFA verifications for the user. + * For front-end display usage only. + * Returns all the available MFA verifications for the user that can be used for verification. + * + * - Filter out backup codes if all the codes are used + * - Filter out duplicated verifications with the same type + * - Sort by last used time, the latest used factor is the first one, backup code is always the last one */ - get availableMfaVerificationTypes() { + get availableUserMfaVerificationTypes() { return ( this.userEnabledMfaVerifications // Filter out backup codes if all the codes are used @@ -89,6 +119,9 @@ export class MfaValidator { ); } + /** + * Check if the user has enabled MFA verifications, if true, MFA verification records are required. + */ get isMfaEnabled() { return this.userEnabledMfaVerifications.length > 0; } @@ -99,24 +132,18 @@ export class MfaValidator { 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 ( - isMfaVerificationRecordType(type) && - isVerified && + const verifiedMfaVerificationRecords = verificationRecords.filter( + (verification) => + isMfaVerificationRecord(verification) && + verification.isVerified && + // New bind MFA verification can not be used for verification + !isNewBindMfaVerification(verification) && // Check if the verification type is enabled in the user's MFA settings - enabledMfaFactors.some((factor) => factor.type === mfaVerificationTypeToMfaFactorMap[type]) - ); - }); + this.userEnabledMfaVerifications.some( + (factor) => factor.type === mfaVerificationTypeToMfaFactorMap[verification.type] + ) + ); - return mfaVerificationRecords; + return verifiedMfaVerificationRecords.length > 0; } } diff --git a/packages/core/src/routes/experience/classes/verifications/index.ts b/packages/core/src/routes/experience/classes/verifications/index.ts index 6175bc8d0e19..126b618f3136 100644 --- a/packages/core/src/routes/experience/classes/verifications/index.ts +++ b/packages/core/src/routes/experience/classes/verifications/index.ts @@ -46,7 +46,7 @@ import { WebAuthnVerification, webAuthnVerificationRecordDataGuard, type WebAuthnVerificationRecordData, -} from './web-authn.js'; +} from './web-authn-verification.js'; export type VerificationRecordData = | PasswordVerificationRecordData 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 d2a2134aff0d..bc52feb780ab 100644 --- a/packages/core/src/routes/experience/classes/verifications/totp-verification.ts +++ b/packages/core/src/routes/experience/classes/verifications/totp-verification.ts @@ -1,5 +1,11 @@ import { type ToZodObject } from '@logto/connector-kit'; -import { MfaFactor, VerificationType, type MfaVerificationTotp, type User } from '@logto/schemas'; +import { + MfaFactor, + VerificationType, + type BindTotp, + type MfaVerificationTotp, + type User, +} from '@logto/schemas'; import { generateStandardId, getUserDisplayName } from '@logto/shared'; import { authenticator } from 'otplib'; import qrcode from 'qrcode'; @@ -60,8 +66,8 @@ export class TotpVerification implements VerificationRecord { - this.secret = generateTotpSecret(); + this.#secret = generateTotpSecret(); const { hostname } = ctx.URL; const secretQrCode = await this.generateSecretQrCode(hostname); return { - secret: this.secret, + secret: this.#secret, secretQrCode, }; } @@ -105,7 +115,7 @@ export class TotpVerification implements VerificationRecord( }) ); - const profilePayload = guard.body; + // Guard current interaction event is identified and MFA verified + await experienceInteraction.guardMfaVerificationStatus(); + const profilePayload = guard.body; await experienceInteraction.addUserProfile(profilePayload); await experienceInteraction.save(); @@ -71,4 +73,97 @@ export default function interactionProfileRoutes( return next(); } ); + + router.post( + `${experienceRoutes.mfa}/mfa-skipped`, + koaGuard({ status: [204, 400, 403, 404, 422] }), + async (ctx, next) => { + const { experienceInteraction, guard } = ctx; + + // Guard current interaction event is not ForgotPassword + assertThat( + experienceInteraction.interactionEvent !== InteractionEvent.ForgotPassword, + new RequestError({ + code: 'session.not_supported_for_forgot_password', + statue: 400, + }) + ); + + // Guard current interaction event is identified and MFA verified + await experienceInteraction.guardMfaVerificationStatus(); + + await experienceInteraction.skipMfa(); + await experienceInteraction.save(); + + ctx.status = 204; + + return next(); + } + ); + + router.post( + `${experienceRoutes.mfa}`, + koaGuard({ + body: z.object({ + type: z.literal(MfaFactor.TOTP).or(z.literal(MfaFactor.WebAuthn)), + verificationId: z.string(), + }), + status: [204, 400, 403, 404, 422], + }), + async (ctx, next) => { + const { experienceInteraction, guard } = ctx; + const { type, verificationId } = guard.body; + + // Guard current interaction event is not ForgotPassword + assertThat( + experienceInteraction.interactionEvent !== InteractionEvent.ForgotPassword, + new RequestError({ + code: 'session.not_supported_for_forgot_password', + statue: 400, + }) + ); + + // Guard current interaction event is identified and MFA verified + await experienceInteraction.guardMfaVerificationStatus(); + + await experienceInteraction.bindMfaByVerificationRecord(type, verificationId); + await experienceInteraction.save(); + + ctx.status = 204; + + return next(); + } + ); + + router.post( + `${experienceRoutes.mfa}/backup-codes`, + koaGuard({ + status: [200, 400, 403, 404, 422], + response: z.object({ + codes: z.array(z.string()), + }), + }), + async (ctx, next) => { + const { experienceInteraction } = ctx; + + // Guard current interaction event is not ForgotPassword + assertThat( + experienceInteraction.interactionEvent !== InteractionEvent.ForgotPassword, + new RequestError({ + code: 'session.not_supported_for_forgot_password', + statue: 400, + }) + ); + + // Guard current interaction event is identified and MFA verified + await experienceInteraction.guardMfaVerificationStatus(); + + const backupCodes = await experienceInteraction.generateBackupCodes(); + await experienceInteraction.save(); + + ctx.body = { codes: backupCodes }; + + return next(); + } + ); } diff --git a/packages/core/src/routes/experience/verification-routes/web-authn-verification.ts b/packages/core/src/routes/experience/verification-routes/web-authn-verification.ts index 313663329f8e..4c8cdcf3eece 100644 --- a/packages/core/src/routes/experience/verification-routes/web-authn-verification.ts +++ b/packages/core/src/routes/experience/verification-routes/web-authn-verification.ts @@ -13,7 +13,7 @@ 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 { WebAuthnVerification } from '../classes/verifications/web-authn.js'; +import { WebAuthnVerification } from '../classes/verifications/web-authn-verification.js'; import { experienceRoutes } from '../const.js'; import { type WithExperienceInteractionContext } from '../middleware/koa-experience-interaction.js'; diff --git a/packages/phrases/src/locales/en/errors/session.ts b/packages/phrases/src/locales/en/errors/session.ts index 1be79523d350..f4c8943cc172 100644 --- a/packages/phrases/src/locales/en/errors/session.ts +++ b/packages/phrases/src/locales/en/errors/session.ts @@ -39,6 +39,7 @@ const session = { backup_code_required: 'Backup code is required.', invalid_backup_code: 'Invalid backup code.', mfa_policy_not_user_controlled: 'MFA policy is not user controlled.', + mfa_factor_not_enabled: 'MFA factor is not enabled.', }, sso_enabled: 'Single sign on is enabled for this given email. Please sign in with SSO.', };