diff --git a/packages/core/src/routes/experience/classes/experience-interaction.ts b/packages/core/src/routes/experience/classes/experience-interaction.ts index 0ae537b5e..a5dd4c913 100644 --- a/packages/core/src/routes/experience/classes/experience-interaction.ts +++ b/packages/core/src/routes/experience/classes/experience-interaction.ts @@ -84,6 +84,10 @@ export default class ExperienceInteraction { } } + get identifiedUserId() { + return this.userId; + } + /** Set the interaction event for the current interaction */ public setInteractionEvent(interactionEvent: InteractionEvent) { // TODO: conflict event check (e.g. reset password session can't be used for sign in) @@ -145,6 +149,10 @@ export default class ExperienceInteraction { this.userId = id; break; } + default: { + // Unsupported verification type for identification, such as MFA verification. + throw new RequestError({ code: 'session.verification_failed', status: 400 }); + } } } diff --git a/packages/core/src/routes/experience/classes/verifications/index.ts b/packages/core/src/routes/experience/classes/verifications/index.ts index 29fb4aa08..1c77768f3 100644 --- a/packages/core/src/routes/experience/classes/verifications/index.ts +++ b/packages/core/src/routes/experience/classes/verifications/index.ts @@ -24,12 +24,18 @@ import { socialVerificationRecordDataGuard, type SocialVerificationRecordData, } from './social-verification.js'; +import { + TotpVerification, + totpVerificationRecordDataGuard, + type TotpVerificationRecordData, +} from './totp-verification.js'; export type VerificationRecordData = | PasswordVerificationRecordData | CodeVerificationRecordData | SocialVerificationRecordData - | EnterpriseSsoVerificationRecordData; + | EnterpriseSsoVerificationRecordData + | TotpVerificationRecordData; /** * Union type for all verification record types @@ -43,13 +49,15 @@ export type VerificationRecord = | PasswordVerification | CodeVerification | SocialVerification - | EnterpriseSsoVerification; + | EnterpriseSsoVerification + | TotpVerification; export const verificationRecordDataGuard = z.discriminatedUnion('type', [ passwordVerificationRecordDataGuard, codeVerificationRecordDataGuard, socialVerificationRecordDataGuard, enterPriseSsoVerificationRecordDataGuard, + totpVerificationRecordDataGuard, ]); /** @@ -73,5 +81,8 @@ export const buildVerificationRecord = ( case VerificationType.EnterpriseSso: { return new EnterpriseSsoVerification(libraries, queries, data); } + case VerificationType.TOTP: { + return new TotpVerification(libraries, queries, data); + } } }; diff --git a/packages/core/src/routes/experience/classes/verifications/totp-verification.ts b/packages/core/src/routes/experience/classes/verifications/totp-verification.ts new file mode 100644 index 000000000..9cf65977e --- /dev/null +++ b/packages/core/src/routes/experience/classes/verifications/totp-verification.ts @@ -0,0 +1,183 @@ +import { type ToZodObject } from '@logto/connector-kit'; +import { MfaFactor, VerificationType, type MfaVerificationTotp, type User } from '@logto/schemas'; +import { generateStandardId, getUserDisplayName } from '@logto/shared'; +import { authenticator } from 'otplib'; +import qrcode from 'qrcode'; +import { z } from 'zod'; + +import { type WithLogContext } from '#src/middleware/koa-audit-log.js'; +import { + generateTotpSecret, + validateTotpToken, +} from '#src/routes/interaction/utils/totp-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 { type VerificationRecord } from './verification-record.js'; + +const defaultDisplayName = 'Unnamed User'; + +// Type assertion for the user's TOTP mfa verification settings +const findUserTotp = ( + mfaVerifications: User['mfaVerifications'] +): MfaVerificationTotp | undefined => + mfaVerifications.find((mfa): mfa is MfaVerificationTotp => mfa.type === MfaFactor.TOTP); + +export type TotpVerificationRecordData = { + id: string; + type: VerificationType.TOTP; + /** UserId is required for verifying or binding new TOTP */ + userId: string; + secret?: string; + verified: boolean; +}; + +export const totpVerificationRecordDataGuard = z.object({ + id: z.string(), + type: z.literal(VerificationType.TOTP), + userId: z.string(), + secret: z.string().optional(), + verified: z.boolean(), +}) satisfies ToZodObject; + +export class TotpVerification implements VerificationRecord { + /** + * Factory method to create a new TotpVerification instance + * + * @param userId The user id is required for verifying or binding new TOTP. + * A TotpVerification instance can only be created if the interaction is identified. + */ + static create(libraries: Libraries, queries: Queries, userId: string) { + return new TotpVerification(libraries, queries, { + id: generateStandardId(), + type: VerificationType.TOTP, + verified: false, + userId, + }); + } + + public readonly id: string; + public readonly type = VerificationType.TOTP; + public readonly userId: string; + private secret?: string; + private verified: boolean; + + constructor( + private readonly libraries: Libraries, + private readonly queries: Queries, + data: TotpVerificationRecordData + ) { + const { id, userId, secret, verified } = totpVerificationRecordDataGuard.parse(data); + + this.id = id; + this.userId = userId; + this.secret = secret; + this.verified = verified; + } + + get isVerified() { + return this.verified; + } + + /** + * Create a new TOTP secret and QR code for the user. + * The secret will be stored in the instance and can be used for verifying the TOTP. + * + * @returns The TOTP secret and QR code as a base64 encoded image. + */ + async generateNewSecret(ctx: WithLogContext): Promise<{ secret: string; secretQrCode: string }> { + this.secret = generateTotpSecret(); + + const { hostname } = ctx.URL; + const secretQrCode = await this.generateSecretQrCode(hostname); + + return { + secret: this.secret, + secretQrCode, + }; + } + + /** + * Verify the new created TOTP secret. + * + * @throws RequestError with 400, if the TOTP secret is not found in the current record or the code is invalid. + */ + verifyNewTotpSecret(code: string) { + assertThat( + this.secret && validateTotpToken(this.secret, code), + 'session.mfa.invalid_totp_code' + ); + + this.verified = true; + } + + /** + * Verify the user's existing TOTP secret. + * + * @throws RequestError with 400, if the TOTP secret is not found or the code is invalid. + */ + async verifyUserExistingTotp(code: string) { + const { + users: { findUserById, updateUserById }, + } = this.queries; + + const { mfaVerifications } = await findUserById(this.userId); + + const totpVerification = findUserTotp(mfaVerifications); + + // Can not found totp verification, this is an invalid request, throw invalid code error anyway for security reason + assertThat(totpVerification, 'session.mfa.invalid_totp_code'); + + assertThat(validateTotpToken(totpVerification.key, code), 'session.mfa.invalid_totp_code'); + + this.verified = true; + + // Update last used time + await updateUserById(this.userId, { + mfaVerifications: mfaVerifications.map((mfa) => { + if (mfa.id !== totpVerification.id) { + return mfa; + } + + return { + ...mfa, + lastUsedAt: new Date().toISOString(), + }; + }), + }); + } + + toJson(): TotpVerificationRecordData { + const { id, type, secret, verified, userId } = this; + + return { + id, + type, + userId, + secret, + verified, + }; + } + + /** + * The QR code is generated using the secret, request hostname, and user information. + * The QR code can be used to bind the TOTP secret to the user's authenticator app. + * The QR code is returned as a base64 encoded image. + */ + private async generateSecretQrCode(service: string) { + const { secret, userId } = this; + + const { + users: { findUserById }, + } = this.queries; + + assertThat(secret, 'session.mfa.pending_info_not_found'); + + const { username, primaryEmail, primaryPhone, name } = await findUserById(userId); + const displayName = getUserDisplayName({ username, primaryEmail, primaryPhone, name }); + const keyUri = authenticator.keyuri(displayName ?? defaultDisplayName, service, secret); + + return qrcode.toDataURL(keyUri); + } +} diff --git a/packages/core/src/routes/experience/index.ts b/packages/core/src/routes/experience/index.ts index 83c48301b..f44c74e47 100644 --- a/packages/core/src/routes/experience/index.ts +++ b/packages/core/src/routes/experience/index.ts @@ -26,6 +26,7 @@ import koaExperienceInteraction, { import enterpriseSsoVerificationRoutes from './verification-routes/enterprise-sso-verification.js'; import passwordVerificationRoutes from './verification-routes/password-verification.js'; import socialVerificationRoutes from './verification-routes/social-verification.js'; +import totpVerificationRoutes from './verification-routes/totp-verification.js'; import verificationCodeRoutes from './verification-routes/verification-code.js'; type RouterContext = T extends Router ? Context : never; @@ -84,4 +85,5 @@ export default function experienceApiRoutes( verificationCodeRoutes(router, tenant); socialVerificationRoutes(router, tenant); enterpriseSsoVerificationRoutes(router, tenant); + totpVerificationRoutes(router, tenant); } diff --git a/packages/core/src/routes/experience/verification-routes/totp-verification.ts b/packages/core/src/routes/experience/verification-routes/totp-verification.ts new file mode 100644 index 000000000..d564a7bca --- /dev/null +++ b/packages/core/src/routes/experience/verification-routes/totp-verification.ts @@ -0,0 +1,122 @@ +import { VerificationType, totpVerificationVerifyPayloadGuard } from '@logto/schemas'; +import type Router from 'koa-router'; +import { z } from 'zod'; + +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 { TotpVerification } from '../classes/verifications/totp-verification.js'; +import { experienceRoutes } from '../const.js'; +import { type WithExperienceInteractionContext } from '../middleware/koa-experience-interaction.js'; + +export default function totpVerificationRoutes( + router: Router>, + tenantContext: TenantContext +) { + const { libraries, queries } = tenantContext; + + router.post( + `${experienceRoutes.verification}/totp/secret`, + koaGuard({ + response: z.object({ + verificationId: z.string(), + secret: z.string(), + secretQrCode: z.string(), + }), + status: [200, 400, 404], + }), + async (ctx, next) => { + const { experienceInteraction } = ctx; + + assertThat(experienceInteraction.identifiedUserId, 'session.not_identified'); + + // TODO: Check if the MFA is enabled + // TODO: Check if the interaction is fully verified + + const totpVerification = TotpVerification.create( + libraries, + queries, + experienceInteraction.identifiedUserId + ); + + const { secret, secretQrCode } = await totpVerification.generateNewSecret(ctx); + + ctx.experienceInteraction.setVerificationRecord(totpVerification); + + await ctx.experienceInteraction.save(); + + ctx.body = { + verificationId: totpVerification.id, + secret, + secretQrCode, + }; + + return next(); + } + ); + + router.post( + `${experienceRoutes.verification}/totp/verify`, + koaGuard({ + body: totpVerificationVerifyPayloadGuard, + response: z.object({ + verificationId: z.string(), + }), + status: [200, 400, 404], + }), + async (ctx, next) => { + const { experienceInteraction } = ctx; + const { verificationId, code } = ctx.guard.body; + + assertThat(experienceInteraction.identifiedUserId, 'session.not_identified'); + + // Verify new generated secret + if (verificationId) { + const totpVerificationRecord = + experienceInteraction.getVerificationRecordById(verificationId); + + assertThat( + totpVerificationRecord && + totpVerificationRecord.type === VerificationType.TOTP && + totpVerificationRecord.userId === experienceInteraction.identifiedUserId, + new RequestError({ + code: 'session.verification_session_not_found', + status: 404, + }) + ); + + totpVerificationRecord.verifyNewTotpSecret(code); + + await ctx.experienceInteraction.save(); + + ctx.body = { + verificationId: totpVerificationRecord.id, + }; + + return next(); + } + + // Verify existing totp record + const totpVerificationRecord = TotpVerification.create( + libraries, + queries, + experienceInteraction.identifiedUserId + ); + + await totpVerificationRecord.verifyUserExistingTotp(code); + + ctx.experienceInteraction.setVerificationRecord(totpVerificationRecord); + + await ctx.experienceInteraction.save(); + + ctx.body = { + verificationId: totpVerificationRecord.id, + }; + + return next(); + } + ); +} diff --git a/packages/core/src/routes/interaction/additional.ts b/packages/core/src/routes/interaction/additional.ts index 2f3cf7968..dc128cb72 100644 --- a/packages/core/src/routes/interaction/additional.ts +++ b/packages/core/src/routes/interaction/additional.ts @@ -2,8 +2,8 @@ import { InteractionEvent, MfaFactor, requestVerificationCodePayloadGuard, - webAuthnRegistrationOptionsGuard, webAuthnAuthenticationOptionsGuard, + webAuthnRegistrationOptionsGuard, } from '@logto/schemas'; import { getUserDisplayName } from '@logto/shared'; import type Router from 'koa-router'; diff --git a/packages/integration-tests/src/client/experience/index.ts b/packages/integration-tests/src/client/experience/index.ts index e0d44f45f..9e6fcffd4 100644 --- a/packages/integration-tests/src/client/experience/index.ts +++ b/packages/integration-tests/src/client/experience/index.ts @@ -132,4 +132,21 @@ export class ExperienceClient extends MockClient { }) .json<{ verificationId: string }>(); } + + public async createTotpSecret() { + return api + .post(`${experienceRoutes.verification}/totp/secret`, { + headers: { cookie: this.interactionCookie }, + }) + .json<{ verificationId: string; secret: string; secretQrCode: string }>(); + } + + public async verifyTotp(payload: { verificationId?: string; code: string }) { + return api + .post(`${experienceRoutes.verification}/totp/verify`, { + headers: { cookie: this.interactionCookie }, + json: payload, + }) + .json<{ verificationId: string }>(); + } } diff --git a/packages/integration-tests/src/helpers/experience/totp-verification.ts b/packages/integration-tests/src/helpers/experience/totp-verification.ts new file mode 100644 index 000000000..f5fea42e3 --- /dev/null +++ b/packages/integration-tests/src/helpers/experience/totp-verification.ts @@ -0,0 +1,25 @@ +import { type ExperienceClient } from '#src/client/experience/index.js'; + +export const successFullyCreateNewTotpSecret = async (client: ExperienceClient) => { + const { secret, secretQrCode, verificationId } = await client.createTotpSecret(); + + expect(secret).toBeTruthy(); + expect(secretQrCode).toBeTruthy(); + expect(verificationId).toBeTruthy(); + + return { secret, secretQrCode, verificationId }; +}; + +export const successFullyVerifyTotp = async ( + client: ExperienceClient, + payload: { + code: string; + verificationId?: string; + } +) => { + const { verificationId } = await client.verifyTotp(payload); + + expect(verificationId).toBeTruthy(); + + return verificationId; +}; 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 new file mode 100644 index 000000000..9e64e3744 --- /dev/null +++ b/packages/integration-tests/src/tests/api/experience-api/verifications/totp-verification.test.ts @@ -0,0 +1,181 @@ +import { InteractionEvent, InteractionIdentifierType, MfaFactor } from '@logto/schemas'; +import { authenticator } from 'otplib'; + +import { createUserMfaVerification } from '#src/api/admin-user.js'; +import { type ExperienceClient } from '#src/client/experience/index.js'; +import { initExperienceClient } from '#src/helpers/client.js'; +import { + successFullyCreateNewTotpSecret, + successFullyVerifyTotp, +} from '#src/helpers/experience/totp-verification.js'; +import { expectRejects } from '#src/helpers/index.js'; +import { enableAllPasswordSignInMethods } from '#src/helpers/sign-in-experience.js'; +import { UserApiTest, generateNewUserProfile } from '#src/helpers/user.js'; +import { devFeatureTest } from '#src/utils.js'; + +const identifyUserWithPassword = async ( + client: ExperienceClient, + username: string, + password: string +) => { + const { verificationId } = await client.verifyPassword({ + identifier: { + type: InteractionIdentifierType.Username, + value: username, + }, + password, + }); + + await client.identifyUser({ interactionEvent: InteractionEvent.SignIn, verificationId }); + + return { verificationId }; +}; + +devFeatureTest.describe('TOTP verification APIs', () => { + const { username, password } = generateNewUserProfile({ username: true, password: true }); + const userApi = new UserApiTest(); + + beforeAll(async () => { + await enableAllPasswordSignInMethods(); + await userApi.create({ username, password }); + }); + + afterAll(async () => { + await userApi.cleanUp(); + }); + + describe('Create new TOTP secret', () => { + it('should throw 400 if the user is not identified', async () => { + const client = await initExperienceClient(); + + await expectRejects(client.createTotpSecret(), { + code: 'session.not_identified', + status: 400, + }); + }); + + it('should create a new TOTP secret successfully', async () => { + const client = await initExperienceClient(); + + await identifyUserWithPassword(client, username, password); + + await successFullyCreateNewTotpSecret(client); + }); + }); + + describe('Verify new TOTP secret', () => { + it('should throw 400 if the user is not identified', async () => { + const client = await initExperienceClient(); + + await expectRejects(client.verifyTotp({ code: '1234' }), { + code: 'session.not_identified', + status: 400, + }); + }); + + it('should throw 400 if the verification record is not found', async () => { + const client = await initExperienceClient(); + + await identifyUserWithPassword(client, username, password); + + await expectRejects(client.verifyTotp({ code: '1234', verificationId: 'invalid_id' }), { + code: 'session.verification_session_not_found', + status: 404, + }); + }); + + it('should throw 400 if the verification record is not a TOTP verification', async () => { + const client = await initExperienceClient(); + + const { verificationId } = await identifyUserWithPassword(client, username, password); + + await expectRejects(client.verifyTotp({ code: '1234', verificationId }), { + code: 'session.verification_session_not_found', + status: 404, + }); + }); + + it('should throw 400 if the code is invalid', async () => { + const client = await initExperienceClient(); + + await identifyUserWithPassword(client, username, password); + + const { verificationId } = await successFullyCreateNewTotpSecret(client); + + await expectRejects(client.verifyTotp({ code: '1234', verificationId }), { + code: 'session.mfa.invalid_totp_code', + status: 400, + }); + }); + + it('should verify the new TOTP secret successfully', async () => { + const client = await initExperienceClient(); + + await identifyUserWithPassword(client, username, password); + + const { verificationId, secret } = await successFullyCreateNewTotpSecret(client); + const code = authenticator.generate(secret); + + await successFullyVerifyTotp(client, { code, verificationId }); + }); + }); + + describe('Verify existing TOTP secret', () => { + it('should throw 400 if the user is not identified', async () => { + const client = await initExperienceClient(); + + await expectRejects(client.verifyTotp({ code: '1234' }), { + code: 'session.not_identified', + status: 400, + }); + }); + + it('should throw 400 if the user does not have TOTP verification', async () => { + const client = await initExperienceClient(); + + await identifyUserWithPassword(client, username, password); + + await expectRejects(client.verifyTotp({ code: '1234' }), { + code: 'session.mfa.invalid_totp_code', + status: 400, + }); + }); + + it('should throw 400 if the code is invalid', 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 identifyUserWithPassword(client, username, password); + + await expectRejects(client.verifyTotp({ code: '1234' }), { + code: 'session.mfa.invalid_totp_code', + status: 400, + }); + }); + + it('should verify the existing TOTP secret successfully', 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('Invalid response'); + } + + const { secret } = response; + + const client = await initExperienceClient(); + + await identifyUserWithPassword(client, username, password); + + const code = authenticator.generate(secret); + + await successFullyVerifyTotp(client, { code }); + }); + }); +}); diff --git a/packages/integration-tests/src/tests/api/interaction/mfa/totp.test.ts b/packages/integration-tests/src/tests/api/interaction/mfa/totp.test.ts index db4619779..c29c749a3 100644 --- a/packages/integration-tests/src/tests/api/interaction/mfa/totp.test.ts +++ b/packages/integration-tests/src/tests/api/interaction/mfa/totp.test.ts @@ -2,14 +2,14 @@ import { InteractionEvent, MfaFactor, SignInIdentifier } from '@logto/schemas'; import { authenticator } from 'otplib'; import { - putInteraction, deleteUser, initTotp, postInteractionBindMfa, + putInteraction, putInteractionMfa, skipMfaBinding, } from '#src/api/index.js'; -import { initClient, processSession, logoutClient } from '#src/helpers/client.js'; +import { initClient, logoutClient, processSession } from '#src/helpers/client.js'; import { expectRejects } from '#src/helpers/index.js'; import { enableAllPasswordSignInMethods, diff --git a/packages/phrases/src/locales/en/errors/session.ts b/packages/phrases/src/locales/en/errors/session.ts index ab6e00e11..34c58ac1f 100644 --- a/packages/phrases/src/locales/en/errors/session.ts +++ b/packages/phrases/src/locales/en/errors/session.ts @@ -23,6 +23,7 @@ const session = { interaction_not_found: 'Interaction session not found. Please go back and start the session again.', not_supported_for_forgot_password: 'This operation is not supported for forgot password.', + not_identified: 'User not identified. Please sign in first.', identity_conflict: 'Identity mismatch detected. Please initiate a new session to proceed with a different identity.', mfa: { diff --git a/packages/schemas/src/types/interactions.ts b/packages/schemas/src/types/interactions.ts index 4991b7738..3993daecc 100644 --- a/packages/schemas/src/types/interactions.ts +++ b/packages/schemas/src/types/interactions.ts @@ -65,7 +65,7 @@ export enum VerificationType { // REMARK: API payload guard -/** Payload type for `POST /api/experience/verification/social/:connectorId/authorization-uri`. */ +/** Payload type for `POST /api/experience/verification/{social|sso}/:connectorId/authorization-uri`. */ export type SocialAuthorizationUrlPayload = { state: string; redirectUri: string; @@ -75,7 +75,7 @@ export const socialAuthorizationUrlPayloadGuard = z.object({ redirectUri: z.string(), }) satisfies ToZodObject; -/** Payload type for `POST /api/experience/verification/social/:connectorId/verify`. */ +/** Payload type for `POST /api/experience/verification/{social|sso}/:connectorId/verify`. */ export type SocialVerificationCallbackPayload = { /** The callback data from the social connector. */ connectorData: Record; @@ -92,12 +92,21 @@ export type PasswordVerificationPayload = { identifier: InteractionIdentifier; password: string; }; - export const passwordVerificationPayloadGuard = z.object({ identifier: interactionIdentifierGuard, password: z.string().min(1), }) satisfies ToZodObject; +/** Payload type for `POST /api/experience/verification/totp/verify`. */ +export type TotpVerificationVerifyPayload = { + code: string; + verificationId?: string; +}; +export const totpVerificationVerifyPayloadGuard = z.object({ + code: z.string().min(1), + verificationId: z.string().optional(), +}) satisfies ToZodObject; + /** Payload type for `POST /api/experience/identification`. */ export type IdentificationApiPayload = { interactionEvent: InteractionEvent; @@ -257,8 +266,10 @@ export const bindMfaPayloadGuard = z.discriminatedUnion('type', [ export type BindMfaPayload = z.infer; +/** @deprecated Legacy interaction API use only */ export const totpVerificationPayloadGuard = bindTotpPayloadGuard; +/** @deprecated Legacy interaction API use only */ export type TotpVerificationPayload = z.infer; export const webAuthnVerificationPayloadGuard = bindWebAuthnPayloadGuard