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:
parent
f76252e0d2
commit
f8f34f0e87
7 changed files with 491 additions and 18 deletions
|
@ -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])
|
||||
);
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
);
|
||||
}
|
|
@ -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';
|
||||
|
|
Loading…
Add table
Reference in a new issue