import { verifyRegistrationResponse } from '@simplewebauthn/server' import { readUsers, writeUsers } from '../../../../utils/auth.js' import { consumePreRegistration } from '../../../../utils/webauthn-challenges.js' import { getWebAuthnConfig } from '../../../../utils/webauthn-config.js' import { toBase64Url } from '../../../../utils/webauthn-encoding.js' import { assertRateLimit, getClientIp } from '../../../../utils/rate-limit.js' import { writeAuditLog } from '../../../../utils/audit-log.js' function findUserAndToken(users, userId, tokenHash) { const now = Date.now() const idx = users.findIndex(u => String(u.id) === String(userId)) if (idx === -1) return { idx: -1, tokenEntry: null } const user = users[idx] const list = Array.isArray(user.passkeyRecoveryTokens) ? user.passkeyRecoveryTokens : [] const tokenEntry = list.find(t => t && t.tokenHash === tokenHash && !t.usedAt && t.expiresAt && new Date(t.expiresAt).getTime() > now ) return { idx, tokenEntry } } export default defineEventHandler(async (event) => { const ip = getClientIp(event) const body = await readBody(event) const recoveryId = String(body?.recoveryId || '') const response = body?.credential const passkeyName = body?.name ? String(body.name).slice(0, 80) : 'Passkey' if (!recoveryId || !response) { throw createError({ statusCode: 400, statusMessage: 'Ungültige Anfrage' }) } assertRateLimit(event, { name: 'auth:passkey-recovery:complete:ip', keyParts: [ip], windowMs: 10 * 60 * 1000, maxAttempts: 30, lockoutMs: 10 * 60 * 1000 }) const pre = consumePreRegistration(recoveryId) if (!pre) { throw createError({ statusCode: 400, statusMessage: 'Recovery-Session abgelaufen. Bitte erneut versuchen.' }) } const { challenge, tokenHash, userId } = pre assertRateLimit(event, { name: 'auth:passkey-recovery:complete:token', keyParts: [tokenHash], windowMs: 10 * 60 * 1000, maxAttempts: 10, lockoutMs: 30 * 60 * 1000 }) const users = await readUsers() const { idx, tokenEntry } = findUserAndToken(users, userId, tokenHash) if (idx === -1 || !tokenEntry) { await writeAuditLog('auth.passkey.recovery.complete.failed', { ip, reason: 'invalid_token', userId }) throw createError({ statusCode: 400, statusMessage: 'Ungültiger oder abgelaufener Token' }) } const { origin, rpId, requireUV } = getWebAuthnConfig() const verification = await verifyRegistrationResponse({ response, expectedChallenge: challenge, expectedOrigin: origin, expectedRPID: rpId, requireUserVerification: requireUV }) const { verified, registrationInfo } = verification if (!verified || !registrationInfo) { await writeAuditLog('auth.passkey.recovery.complete.failed', { ip, reason: 'verification_failed', userId }) throw createError({ statusCode: 400, statusMessage: 'Passkey-Registrierung fehlgeschlagen' }) } const { credentialID, credentialPublicKey, counter, credentialDeviceType, credentialBackedUp } = registrationInfo const credentialId = toBase64Url(credentialID) const publicKey = toBase64Url(credentialPublicKey) const user = users[idx] if (!Array.isArray(user.passkeys)) user.passkeys = [] // Duplikate verhindern if (!user.passkeys.some(pk => pk.credentialId === credentialId)) { user.passkeys.push({ id: `${Date.now()}`, credentialId, publicKey, counter: Number(counter) || 0, transports: Array.isArray(response.transports) ? response.transports : undefined, deviceType: credentialDeviceType, backedUp: !!credentialBackedUp, createdAt: new Date().toISOString(), lastUsedAt: null, name: passkeyName }) } // Token als benutzt markieren (one-time) tokenEntry.usedAt = new Date().toISOString() users[idx] = user await writeUsers(users) await writeAuditLog('auth.passkey.recovery.complete.success', { ip, userId: user.id }) return { success: true, message: 'Passkey hinzugefügt. Sie können sich jetzt mit dem neuen Passkey anmelden.' } })