From f8f34f0e878f987c9ce1654a766e3c8e2db37b5b Mon Sep 17 00:00:00 2001 From: simeng-li Date: Thu, 25 Jul 2024 17:35:19 +0800 Subject: [PATCH] feat(core): implement the WebAuthn verification (#6308) feat(core): implement the webauthn verification implement the webauthn verification --- .../classes/libraries/mfa-validator.ts | 2 +- .../experience/classes/verifications/index.ts | 11 + .../classes/verifications/web-authn.ts | 271 ++++++++++++++++++ packages/core/src/routes/experience/index.ts | 2 + .../verification-routes/totp-verification.ts | 3 - .../web-authn-verification.ts | 192 +++++++++++++ .../verifications/mfa-payload-verification.ts | 28 +- 7 files changed, 491 insertions(+), 18 deletions(-) create mode 100644 packages/core/src/routes/experience/classes/verifications/web-authn.ts create mode 100644 packages/core/src/routes/experience/verification-routes/web-authn-verification.ts diff --git a/packages/core/src/routes/experience/classes/libraries/mfa-validator.ts b/packages/core/src/routes/experience/classes/libraries/mfa-validator.ts index c1882b1d1..9f31ff3f0 100644 --- a/packages/core/src/routes/experience/classes/libraries/mfa-validator.ts +++ b/packages/core/src/routes/experience/classes/libraries/mfa-validator.ts @@ -110,8 +110,8 @@ export class MfaValidator { // Filter out the verified MFA verification records const mfaVerificationRecords = verificationRecords.filter(({ type, isVerified }) => { return ( - isVerified && isMfaVerificationRecordType(type) && + isVerified && // Check if the verification type is enabled in the user's MFA settings enabledMfaFactors.some((factor) => factor.type === mfaVerificationTypeToMfaFactorMap[type]) ); diff --git a/packages/core/src/routes/experience/classes/verifications/index.ts b/packages/core/src/routes/experience/classes/verifications/index.ts index db9f601d5..6175bc8d0 100644 --- a/packages/core/src/routes/experience/classes/verifications/index.ts +++ b/packages/core/src/routes/experience/classes/verifications/index.ts @@ -42,6 +42,11 @@ import { type TotpVerificationRecordData, } from './totp-verification.js'; import { type VerificationRecord as GenericVerificationRecord } from './verification-record.js'; +import { + WebAuthnVerification, + webAuthnVerificationRecordDataGuard, + type WebAuthnVerificationRecordData, +} from './web-authn.js'; export type VerificationRecordData = | PasswordVerificationRecordData @@ -51,6 +56,7 @@ export type VerificationRecordData = | EnterpriseSsoVerificationRecordData | TotpVerificationRecordData | BackupCodeVerificationRecordData + | WebAuthnVerificationRecordData | NewPasswordIdentityVerificationRecordData; // This is to ensure the keys of the map are the same as the type of the verification record @@ -67,6 +73,7 @@ export type VerificationRecordMap = AssertVerificationMap<{ [VerificationType.EnterpriseSso]: EnterpriseSsoVerification; [VerificationType.TOTP]: TotpVerification; [VerificationType.BackupCode]: BackupCodeVerification; + [VerificationType.WebAuthn]: WebAuthnVerification; [VerificationType.NewPasswordIdentity]: NewPasswordIdentityVerification; }>; @@ -89,6 +96,7 @@ export const verificationRecordDataGuard = z.discriminatedUnion('type', [ enterPriseSsoVerificationRecordDataGuard, totpVerificationRecordDataGuard, backupCodeVerificationRecordDataGuard, + webAuthnVerificationRecordDataGuard, newPasswordIdentityVerificationRecordDataGuard, ]); @@ -122,6 +130,9 @@ export const buildVerificationRecord = ( case VerificationType.BackupCode: { return new BackupCodeVerification(libraries, queries, data); } + case VerificationType.WebAuthn: { + return new WebAuthnVerification(libraries, queries, data); + } case VerificationType.NewPasswordIdentity: { return new NewPasswordIdentityVerification(libraries, queries, data); } diff --git a/packages/core/src/routes/experience/classes/verifications/web-authn.ts b/packages/core/src/routes/experience/classes/verifications/web-authn.ts new file mode 100644 index 000000000..d3d2d2bcc --- /dev/null +++ b/packages/core/src/routes/experience/classes/verifications/web-authn.ts @@ -0,0 +1,271 @@ +import { type ToZodObject } from '@logto/connector-kit'; +import { + type BindWebAuthn, + bindWebAuthnGuard, + type BindWebAuthnPayload, + MfaFactor, + VerificationType, + type WebAuthnRegistrationOptions, + type WebAuthnVerificationPayload, +} from '@logto/schemas'; +import { generateStandardId } from '@logto/shared'; +import { conditional } from '@silverhand/essentials'; +import { isoBase64URL } from '@simplewebauthn/server/helpers'; +import { type PublicKeyCredentialRequestOptionsJSON } from 'node_modules/@simplewebauthn/server/esm/deps.js'; +import { z } from 'zod'; + +import { type WithLogContext } from '#src/middleware/koa-audit-log.js'; +import { + generateWebAuthnAuthenticationOptions, + generateWebAuthnRegistrationOptions, + verifyWebAuthnAuthentication, + verifyWebAuthnRegistration, +} from '#src/routes/interaction/utils/webauthn.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'; + +export type WebAuthnVerificationRecordData = { + id: string; + type: VerificationType.WebAuthn; + /** UserId is required for verifying or binding new TOTP */ + userId: string; + verified: boolean; + /** The challenge generated for the WebAuthn registration */ + registrationChallenge?: string; + /** The challenge generated for the WebAuthn authentication */ + authenticationChallenge?: string; + registrationInfo?: BindWebAuthn; +}; + +export const webAuthnVerificationRecordDataGuard = z.object({ + id: z.string(), + type: z.literal(VerificationType.WebAuthn), + userId: z.string(), + verified: z.boolean(), + registrationChallenge: z.string().optional(), + authenticationChallenge: z.string().optional(), + registrationInfo: bindWebAuthnGuard.optional(), +}) satisfies ToZodObject; + +export class WebAuthnVerification implements VerificationRecord { + /** + * Factory method to create a new WebAuthnVerification instance + * + * @param userId The user id is required for generating and verifying WebAuthn options. + * A WebAuthnVerification instance can only be created if the interaction is identified. + */ + static create(libraries: Libraries, queries: Queries, userId: string) { + return new WebAuthnVerification(libraries, queries, { + id: generateStandardId(), + type: VerificationType.WebAuthn, + verified: false, + userId, + }); + } + + readonly id; + readonly type = VerificationType.WebAuthn; + readonly userId; + private verified; + private registrationChallenge?: string; + private readonly authenticationChallenge?: string; + private registrationInfo?: BindWebAuthn; + + constructor( + private readonly libraries: Libraries, + private readonly queries: Queries, + data: WebAuthnVerificationRecordData + ) { + const { + id, + userId, + verified, + registrationChallenge, + authenticationChallenge, + registrationInfo, + } = webAuthnVerificationRecordDataGuard.parse(data); + + this.id = id; + this.userId = userId; + this.verified = verified; + this.registrationChallenge = registrationChallenge; + this.authenticationChallenge = authenticationChallenge; + this.registrationInfo = registrationInfo; + } + + get isVerified() { + return this.verified; + } + + /** + * @remarks + * This method is used to generate the WebAuthn registration options for the user. + * The WebAuthn registration options is used to register a new WebAuthn credential for the user. + * + * Refers to the {@link generateWebAuthnRegistrationOptions} function in `interaction/utils/webauthn.ts` file. + * Keep it as the single source of truth for generating the WebAuthn registration options. + * TODO: Consider relocating the function under a shared folder + */ + async generateWebAuthnRegistrationOptions( + ctx: WithLogContext + ): Promise { + const { hostname } = ctx.URL; + const user = await this.findUser(); + + const registrationOptions = await generateWebAuthnRegistrationOptions({ + user, + rpId: hostname, + }); + + this.registrationChallenge = registrationOptions.challenge; + + return registrationOptions; + } + + /** + * @remarks + * This method is used to verify the WebAuthn registration for the user. + * This method will verify the WebAuthn registration response and store the registration information in the instance. + * Refers to the {@link verifyBindWebAuthn} function in `interaction/verifications/mfa-payload-verification.ts` file. + * + * @throw {RequestError} with status 400, if no pending WebAuthn registration challenge is found. + * @throw {RequestError} with status 400, if the WebAuthn registration verification failed or the registration information is not found. + */ + async verifyWebAuthnRegistration( + ctx: WithLogContext, + payload: Omit + ) { + const { hostname, origin } = ctx.URL; + const { + request: { + headers: { 'user-agent': userAgent = '' }, + }, + } = ctx; + + assertThat(this.registrationChallenge, 'session.mfa.pending_info_not_found'); + + const { verified, registrationInfo } = await verifyWebAuthnRegistration( + payload, + this.registrationChallenge, + hostname, + origin + ); + + assertThat(verified, 'session.mfa.webauthn_verification_failed'); + assertThat(registrationInfo, 'session.mfa.webauthn_verification_failed'); + + const { credentialID, credentialPublicKey, counter } = registrationInfo; + + this.verified = true; + + this.registrationInfo = { + type: MfaFactor.WebAuthn, + credentialId: credentialID, + publicKey: isoBase64URL.fromBuffer(credentialPublicKey), + counter, + agent: userAgent, + transports: [], + }; + } + + /** + * @remarks + * This method is used to generate the WebAuthn authentication options for the user. + * The WebAuthn authentication options is used to authenticate the user using existing WebAuthn credentials. + * + * Refers to the {@link generateWebAuthnAuthenticationOptions} function in `interaction/utils/webauthn.ts` file. + * Keep it as the single source of truth for generating the WebAuthn authentication options. + * TODO: Consider relocating the function under a shared folder + * + * @throws {RequestError} with status 400, if no WebAuthn credentials are found for the user. + */ + async generateWebAuthnAuthenticationOptions( + ctx: WithLogContext + ): Promise { + const { hostname } = ctx.URL; + const { mfaVerifications } = await this.findUser(); + + const authenticationOptions = await generateWebAuthnAuthenticationOptions({ + mfaVerifications, + rpId: hostname, + }); + + return authenticationOptions; + } + + /** + * @remarks + * This method is used to verify the WebAuthn authentication for the user. + * Refers to the {@link verifyMfaPayloadVerification} function in `interaction/verifications/mfa-payload-verification.ts` file. + * + * @throws {RequestError} with status 400, if no pending WebAuthn authentication challenge is found. + * @throws {RequestError} with status 400, if the WebAuthn authentication verification failed. + */ + async verifyWebAuthnAuthentication( + ctx: WithLogContext, + payload: Omit + ) { + const { hostname, origin } = ctx.URL; + const { mfaVerifications } = await this.findUser(); + + assertThat(this.authenticationChallenge, 'session.mfa.pending_info_not_found'); + + const { result, newCounter } = await verifyWebAuthnAuthentication({ + payload, + challenge: this.authenticationChallenge, + rpId: hostname, + origin, + mfaVerifications, + }); + + assertThat(result, 'session.mfa.webauthn_verification_failed'); + + this.verified = true; + + // Update the counter and last used time + const { updateUserById } = this.queries.users; + await updateUserById(this.userId, { + mfaVerifications: mfaVerifications.map((mfa) => { + if (mfa.type !== MfaFactor.WebAuthn || mfa.id !== result.id) { + return mfa; + } + + return { + ...mfa, + lastUsedAt: new Date().toISOString(), + ...conditional(newCounter !== undefined && { counter: newCounter }), + }; + }), + }); + } + + toJson(): WebAuthnVerificationRecordData { + const { + id, + userId, + verified, + type, + registrationChallenge, + authenticationChallenge, + registrationInfo, + } = this; + + return { + id, + type, + userId, + verified, + registrationChallenge, + authenticationChallenge, + registrationInfo, + }; + } + + private async findUser() { + const { findUserById } = this.queries.users; + return findUserById(this.userId); + } +} diff --git a/packages/core/src/routes/experience/index.ts b/packages/core/src/routes/experience/index.ts index a7d4125ee..c452102b4 100644 --- a/packages/core/src/routes/experience/index.ts +++ b/packages/core/src/routes/experience/index.ts @@ -32,6 +32,7 @@ import passwordVerificationRoutes from './verification-routes/password-verificat import socialVerificationRoutes from './verification-routes/social-verification.js'; import totpVerificationRoutes from './verification-routes/totp-verification.js'; import verificationCodeRoutes from './verification-routes/verification-code.js'; +import webAuthnVerificationRoute from './verification-routes/web-authn-verification.js'; type RouterContext = T extends Router ? Context : never; @@ -148,6 +149,7 @@ export default function experienceApiRoutes( socialVerificationRoutes(router, tenant); enterpriseSsoVerificationRoutes(router, tenant); totpVerificationRoutes(router, tenant); + webAuthnVerificationRoute(router, tenant); backupCodeVerificationRoutes(router, tenant); newPasswordIdentityVerificationRoutes(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 index ba875a88e..ed826e223 100644 --- a/packages/core/src/routes/experience/verification-routes/totp-verification.ts +++ b/packages/core/src/routes/experience/verification-routes/totp-verification.ts @@ -33,9 +33,6 @@ export default function totpVerificationRoutes( assertThat(experienceInteraction.identifiedUserId, 'session.identifier_not_found'); - // TODO: Check if the MFA is enabled - // TODO: Check if the interaction is fully verified - const totpVerification = TotpVerification.create( libraries, queries, 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 new file mode 100644 index 000000000..313663329 --- /dev/null +++ b/packages/core/src/routes/experience/verification-routes/web-authn-verification.ts @@ -0,0 +1,192 @@ +import { + bindWebAuthnPayloadGuard, + VerificationType, + webAuthnRegistrationOptionsGuard, + webAuthnVerificationPayloadGuard, +} 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 { WebAuthnVerification } from '../classes/verifications/web-authn.js'; +import { experienceRoutes } from '../const.js'; +import { type WithExperienceInteractionContext } from '../middleware/koa-experience-interaction.js'; + +export default function webAuthnVerificationRoute( + router: Router>, + tenantContext: TenantContext +) { + const { libraries, queries } = tenantContext; + + router.post( + `${experienceRoutes.verification}/web-authn/registration`, + koaGuard({ + response: z.object({ + verificationId: z.string(), + registrationOptions: webAuthnRegistrationOptionsGuard, + }), + status: [200, 400, 404], + }), + async (ctx, next) => { + const { experienceInteraction } = ctx; + + assertThat(experienceInteraction.identifiedUserId, 'session.identifier_not_found'); + + const webAuthnVerification = WebAuthnVerification.create( + libraries, + queries, + experienceInteraction.identifiedUserId + ); + + const registrationOptions = await webAuthnVerification.generateWebAuthnRegistrationOptions( + ctx + ); + + experienceInteraction.setVerificationRecord(webAuthnVerification); + + await experienceInteraction.save(); + + ctx.body = { + verificationId: webAuthnVerification.id, + registrationOptions, + }; + + ctx.status = 200; + + return next(); + } + ); + + router.post( + `${experienceRoutes.verification}/web-authn/registration/verify`, + koaGuard({ + body: z.object({ + verificationId: z.string(), + payload: bindWebAuthnPayloadGuard, + }), + response: z.object({ + verificationId: z.string(), + }), + status: [200, 400, 404], + }), + async (ctx, next) => { + const { experienceInteraction } = ctx; + const { verificationId, payload } = ctx.guard.body; + + assertThat(experienceInteraction.identifiedUserId, 'session.identifier_not_found'); + + const webAuthnVerification = experienceInteraction.getVerificationRecordByTypeAndId( + VerificationType.WebAuthn, + verificationId + ); + + assertThat( + webAuthnVerification.userId === experienceInteraction.identifiedUserId, + new RequestError({ + code: 'session.verification_session_not_found', + status: 404, + }) + ); + + await webAuthnVerification.verifyWebAuthnRegistration(ctx, payload); + + await experienceInteraction.save(); + + ctx.body = { + verificationId: webAuthnVerification.id, + }; + + ctx.status = 200; + + return next(); + } + ); + + router.post( + `${experienceRoutes.verification}/web-authn/authentication`, + koaGuard({ + response: z.object({ + verificationId: z.string(), + authenticationOptions: webAuthnRegistrationOptionsGuard, + }), + status: [200, 400, 404], + }), + async (ctx, next) => { + const { experienceInteraction } = ctx; + + assertThat(experienceInteraction.identifiedUserId, 'session.identifier_not_found'); + + const webAuthnVerification = WebAuthnVerification.create( + libraries, + queries, + experienceInteraction.identifiedUserId + ); + + const authenticationOptions = + await webAuthnVerification.generateWebAuthnAuthenticationOptions(ctx); + + experienceInteraction.setVerificationRecord(webAuthnVerification); + + await experienceInteraction.save(); + + ctx.body = { + verificationId: webAuthnVerification.id, + authenticationOptions, + }; + + ctx.status = 200; + + return next(); + } + ); + + router.post( + `${experienceRoutes.verification}/web-authn/authentication/verify`, + koaGuard({ + body: z.object({ + verificationId: z.string(), + payload: webAuthnVerificationPayloadGuard, + }), + response: z.object({ + verificationId: z.string(), + }), + status: [200, 400, 404], + }), + async (ctx, next) => { + const { experienceInteraction } = ctx; + const { verificationId, payload } = ctx.guard.body; + + assertThat(experienceInteraction.identifiedUserId, 'session.identifier_not_found'); + + const webAuthnVerification = experienceInteraction.getVerificationRecordByTypeAndId( + VerificationType.WebAuthn, + verificationId + ); + + assertThat( + webAuthnVerification.userId === experienceInteraction.identifiedUserId, + new RequestError({ + code: 'session.verification_session_not_found', + status: 404, + }) + ); + + await webAuthnVerification.verifyWebAuthnAuthentication(ctx, payload); + + await experienceInteraction.save(); + + ctx.body = { + verificationId: webAuthnVerification.id, + }; + + ctx.status = 200; + + return next(); + } + ); +} diff --git a/packages/core/src/routes/interaction/verifications/mfa-payload-verification.ts b/packages/core/src/routes/interaction/verifications/mfa-payload-verification.ts index 4fb697516..460716e25 100644 --- a/packages/core/src/routes/interaction/verifications/mfa-payload-verification.ts +++ b/packages/core/src/routes/interaction/verifications/mfa-payload-verification.ts @@ -1,22 +1,22 @@ import { MfaFactor, - type BindTotp, - type BindTotpPayload, - type BindMfaPayload, - type BindMfa, - type TotpVerificationPayload, - type User, - type MfaVerificationTotp, - type VerifyMfaPayload, - type VerifyMfaResult, - type BindWebAuthn, - type BindWebAuthnPayload, - type MfaVerifications, - type WebAuthnVerificationPayload, + type BackupCodeVerificationPayload, type BindBackupCode, type BindBackupCodePayload, + type BindMfa, + type BindMfaPayload, + type BindTotp, + type BindTotpPayload, + type BindWebAuthn, + type BindWebAuthnPayload, type MfaVerificationBackupCode, - type BackupCodeVerificationPayload, + type MfaVerifications, + type MfaVerificationTotp, + type TotpVerificationPayload, + type User, + type VerifyMfaPayload, + type VerifyMfaResult, + type WebAuthnVerificationPayload, } from '@logto/schemas'; import { pick } from '@silverhand/essentials'; import { isoBase64URL } from '@simplewebauthn/server/helpers';