Implement passkey recovery feature, including email link requests and registration options. Update login and registration pages to support passkey authentication, with UI enhancements for user experience. Add server-side handling for passkey registration and login, including account activation checks. Update environment configuration for passkey recovery TTL settings.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 48s

This commit is contained in:
Torsten Schulz (local)
2026-01-07 18:37:01 +01:00
parent a8423f9c39
commit fde25d92c5
13 changed files with 843 additions and 5 deletions

View File

@@ -0,0 +1,120 @@
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.' }
})