import crypto from 'crypto' import { generateRegistrationOptions } from '@simplewebauthn/server' import { readUsers } from '../../../../utils/auth.js' import { getWebAuthnConfig } from '../../../../utils/webauthn-config.js' import { hashRecoveryToken } from '../../../../utils/passkey-recovery.js' import { setPreRegistration } from '../../../../utils/webauthn-challenges.js' import { assertRateLimit, getClientIp } from '../../../../utils/rate-limit.js' import { writeAuditLog } from '../../../../utils/audit-log.js' function findUserByTokenHash(users, tokenHash) { const now = Date.now() for (const u of users) { const list = Array.isArray(u.passkeyRecoveryTokens) ? u.passkeyRecoveryTokens : [] const match = list.find(t => t && t.tokenHash === tokenHash && !t.usedAt && t.expiresAt && new Date(t.expiresAt).getTime() > now ) if (match) return { user: u, tokenEntry: match } } return { user: null, tokenEntry: null } } export default defineEventHandler(async (event) => { const query = getQuery(event) const token = String(query?.token || '') if (!token) { throw createError({ statusCode: 400, statusMessage: 'Token fehlt' }) } const ip = getClientIp(event) assertRateLimit(event, { name: 'auth:passkey-recovery:options:ip', keyParts: [ip], windowMs: 10 * 60 * 1000, maxAttempts: 60, lockoutMs: 10 * 60 * 1000 }) const tokenHash = hashRecoveryToken(token) assertRateLimit(event, { name: 'auth:passkey-recovery:options:token', keyParts: [tokenHash], windowMs: 10 * 60 * 1000, maxAttempts: 10, lockoutMs: 30 * 60 * 1000 }) const users = await readUsers() const { user } = findUserByTokenHash(users, tokenHash) if (!user) { await writeAuditLog('auth.passkey.recovery.options.failed', { ip, reason: 'invalid_token' }) throw createError({ statusCode: 400, statusMessage: 'Ungültiger oder abgelaufener Token' }) } const { rpId, rpName } = getWebAuthnConfig() const excludeCredentials = (Array.isArray(user.passkeys) ? user.passkeys : []) .filter(pk => pk && pk.credentialId) .map(pk => ({ id: pk.credentialId, type: 'public-key', transports: pk.transports || undefined })) const options = await generateRegistrationOptions({ rpName, rpID: rpId, userID: new TextEncoder().encode(String(user.id)), userName: user.email, userDisplayName: user.name || user.email, attestationType: 'none', authenticatorSelection: { residentKey: 'preferred', userVerification: 'preferred' }, excludeCredentials }) // Opaques recoveryId für Complete-Request const recoveryId = crypto.randomBytes(16).toString('hex') setPreRegistration(recoveryId, { challenge: options.challenge, tokenHash, userId: user.id }) await writeAuditLog('auth.passkey.recovery.options.issued', { ip, userId: user.id }) return { success: true, recoveryId, options } })