0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-10 22:22:45 -05:00

feat(core): implement the WebAuthn verification (#6308)

feat(core): implement the webauthn verification

implement the webauthn verification
This commit is contained in:
simeng-li 2024-07-25 17:35:19 +08:00 committed by GitHub
parent f76252e0d2
commit f8f34f0e87
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 491 additions and 18 deletions

View file

@ -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])
);

View file

@ -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);
}

View file

@ -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<WebAuthnVerificationRecordData>;
export class WebAuthnVerification implements VerificationRecord<VerificationType.WebAuthn> {
/**
* 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<WebAuthnRegistrationOptions> {
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<BindWebAuthnPayload, 'type'>
) {
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<PublicKeyCredentialRequestOptionsJSON> {
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<WebAuthnVerificationPayload, 'type'>
) {
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);
}
}

View file

@ -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> = T extends Router<unknown, infer Context> ? Context : never;
@ -148,6 +149,7 @@ export default function experienceApiRoutes<T extends AnonymousRouter>(
socialVerificationRoutes(router, tenant);
enterpriseSsoVerificationRoutes(router, tenant);
totpVerificationRoutes(router, tenant);
webAuthnVerificationRoute(router, tenant);
backupCodeVerificationRoutes(router, tenant);
newPasswordIdentityVerificationRoutes(router, tenant);

View file

@ -33,9 +33,6 @@ export default function totpVerificationRoutes<T extends WithLogContext>(
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,

View file

@ -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<T extends WithLogContext>(
router: Router<unknown, WithExperienceInteractionContext<T>>,
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();
}
);
}

View file

@ -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';