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

@@ -44,6 +44,14 @@ export default defineEventHandler(async (event) => {
throw createError({ statusCode: 401, statusMessage: 'Passkey unbekannt' })
}
if (user.active === false) {
await writeAuditLog('auth.passkey.login.failed', { ip, userId: user.id, reason: 'inactive' })
throw createError({
statusCode: 403,
statusMessage: 'Ihr Konto wurde noch nicht freigeschaltet. Bitte warten Sie auf die Bestätigung des Vorstands.'
})
}
const { origin, rpId, requireUV } = getWebAuthnConfig()
const authenticator = {

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.' }
})

View File

@@ -0,0 +1,89 @@
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 }
})

View File

@@ -0,0 +1,100 @@
import nodemailer from 'nodemailer'
import { readUsers, writeUsers } from '../../../../utils/auth.js'
import { assertRateLimit, getClientIp, registerRateLimitFailure, registerRateLimitSuccess } from '../../../../utils/rate-limit.js'
import { generateRecoveryToken, hashRecoveryToken, pruneRecoveryTokens } from '../../../../utils/passkey-recovery.js'
import { writeAuditLog } from '../../../../utils/audit-log.js'
function isValidEmail(email) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(email || ''))
}
export default defineEventHandler(async (event) => {
const body = await readBody(event)
const email = String(body?.email || '').trim().toLowerCase()
if (!email || !isValidEmail(email)) {
// No enumeration; still 200
return { success: true, message: 'Falls ein Konto mit dieser E-Mail existiert, wurde eine E-Mail gesendet.' }
}
const ip = getClientIp(event)
assertRateLimit(event, {
name: 'auth:passkey-recovery:request:ip',
keyParts: [ip],
windowMs: 60 * 60 * 1000,
maxAttempts: 30,
lockoutMs: 30 * 60 * 1000
})
assertRateLimit(event, {
name: 'auth:passkey-recovery:request:email',
keyParts: [email],
windowMs: 60 * 60 * 1000,
maxAttempts: 5,
lockoutMs: 60 * 60 * 1000
})
const users = await readUsers()
const user = users.find(u => String(u.email || '').toLowerCase() === email)
// Always respond success
if (!user) {
await registerRateLimitFailure(event, { name: 'auth:passkey-recovery:request:ip', keyParts: [ip] })
await registerRateLimitFailure(event, { name: 'auth:passkey-recovery:request:email', keyParts: [email] })
await writeAuditLog('auth.passkey.recovery.request', { ip, email, userFound: false })
return { success: true, message: 'Falls ein Konto mit dieser E-Mail existiert, wurde eine E-Mail gesendet.' }
}
// Token erzeugen und (gehasht) am User speichern
const token = generateRecoveryToken()
const tokenHash = hashRecoveryToken(token)
const ttlMin = Number(process.env.PASSKEY_RECOVERY_TTL_MIN || 30)
const expiresAt = new Date(Date.now() + ttlMin * 60 * 1000).toISOString()
if (!Array.isArray(user.passkeyRecoveryTokens)) user.passkeyRecoveryTokens = []
user.passkeyRecoveryTokens.push({
tokenHash,
createdAt: new Date().toISOString(),
expiresAt,
usedAt: null
})
pruneRecoveryTokens(user)
const updated = users.map(u => (u.id === user.id ? user : u))
await writeUsers(updated)
registerRateLimitSuccess(event, { name: 'auth:passkey-recovery:request:email', keyParts: [email] })
await writeAuditLog('auth.passkey.recovery.request', { ip, email, userFound: true, userId: user.id })
// Mail senden (wenn SMTP konfiguriert)
const smtpUser = process.env.SMTP_USER
const smtpPass = process.env.SMTP_PASS
if (smtpUser && smtpPass) {
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST || 'smtp.gmail.com',
port: process.env.SMTP_PORT || 587,
secure: false,
auth: { user: smtpUser, pass: smtpPass }
})
const baseUrl = process.env.NUXT_PUBLIC_BASE_URL || 'http://localhost:3100'
const link = `${baseUrl}/passkey-wiederherstellen?token=${token}`
await transporter.sendMail({
from: process.env.SMTP_FROM || 'noreply@harheimertc.de',
to: user.email,
subject: 'Passkey wiederherstellen - Harheimer TC',
html: `
<h2>Passkey wiederherstellen</h2>
<p>Hallo ${user.name || ''},</p>
<p>Sie haben eine Anfrage gestellt, um einen neuen Passkey hinzuzufügen.</p>
<p>Bitte klicken Sie auf den folgenden Link (gültig für ${ttlMin} Minuten):</p>
<p><a href="${link}">Neuen Passkey hinzufügen</a></p>
<p>Wenn Sie das nicht waren, ignorieren Sie diese E-Mail.</p>
`
})
}
return { success: true, message: 'Falls ein Konto mit dieser E-Mail existiert, wurde eine E-Mail gesendet.' }
})

View File

@@ -0,0 +1,60 @@
import crypto from 'crypto'
import { generateRegistrationOptions } from '@simplewebauthn/server'
import { readUsers } from '../../utils/auth.js'
import { getWebAuthnConfig } from '../../utils/webauthn-config.js'
import { setPreRegistration } from '../../utils/webauthn-challenges.js'
import { writeAuditLog } from '../../utils/audit-log.js'
function isValidEmail(email) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(email || ''))
}
export default defineEventHandler(async (event) => {
const body = await readBody(event)
const name = String(body?.name || '').trim()
const email = String(body?.email || '').trim().toLowerCase()
const phone = String(body?.phone || '').trim()
if (!name || !email) {
throw createError({ statusCode: 400, message: 'Name und E-Mail sind erforderlich' })
}
if (!isValidEmail(email)) {
throw createError({ statusCode: 400, message: 'Ungültige E-Mail-Adresse' })
}
const users = await readUsers()
if (users.some(u => String(u.email || '').toLowerCase() === email)) {
throw createError({ statusCode: 409, message: 'Ein Benutzer mit dieser E-Mail-Adresse existiert bereits' })
}
const { rpId, rpName } = getWebAuthnConfig()
const userId = crypto.randomUUID()
const registrationId = crypto.randomBytes(16).toString('hex')
const options = await generateRegistrationOptions({
rpName,
rpID: rpId,
userID: new TextEncoder().encode(String(userId)),
userName: email,
userDisplayName: name,
attestationType: 'none',
authenticatorSelection: {
residentKey: 'preferred',
userVerification: 'preferred'
}
})
setPreRegistration(registrationId, {
challenge: options.challenge,
userId,
name,
email,
phone
})
await writeAuditLog('auth.passkey.prereg.options', { email })
return { success: true, registrationId, options }
})

View File

@@ -0,0 +1,147 @@
import { verifyRegistrationResponse } from '@simplewebauthn/server'
import crypto from 'crypto'
import bcrypt from 'bcryptjs'
import nodemailer from 'nodemailer'
import { readUsers, writeUsers } from '../../utils/auth.js'
import { getWebAuthnConfig } from '../../utils/webauthn-config.js'
import { consumePreRegistration } from '../../utils/webauthn-challenges.js'
import { toBase64Url } from '../../utils/webauthn-encoding.js'
import { writeAuditLog } from '../../utils/audit-log.js'
export default defineEventHandler(async (event) => {
const body = await readBody(event)
const registrationId = String(body?.registrationId || '')
const response = body?.credential
if (!registrationId || !response) {
throw createError({ statusCode: 400, statusMessage: 'Ungültige Anfrage' })
}
const pre = consumePreRegistration(registrationId)
if (!pre) {
throw createError({ statusCode: 400, statusMessage: 'Registrierungs-Session abgelaufen. Bitte erneut versuchen.' })
}
const { challenge, userId, name, email, phone } = pre
const users = await readUsers()
if (users.some(u => String(u.email || '').toLowerCase() === String(email).toLowerCase())) {
throw createError({ statusCode: 409, message: 'Ein Benutzer mit dieser E-Mail-Adresse existiert bereits' })
}
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.prereg.failed', { email })
throw createError({ statusCode: 400, statusMessage: 'Passkey-Registrierung fehlgeschlagen' })
}
const {
credentialID,
credentialPublicKey,
counter,
credentialDeviceType,
credentialBackedUp
} = registrationInfo
const credentialId = toBase64Url(credentialID)
const publicKey = toBase64Url(credentialPublicKey)
// Dummy password hash (login via password isn't intended)
const salt = await bcrypt.genSalt(10)
const hashedPassword = await bcrypt.hash(crypto.randomBytes(32).toString('hex'), salt)
const newUser = {
id: String(userId),
email: String(email).toLowerCase(),
password: hashedPassword,
name,
phone: phone || '',
role: 'mitglied',
active: false,
created: new Date().toISOString(),
lastLogin: null,
passkeys: [
{
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: 'Passkey'
}
]
}
users.push(newUser)
await writeUsers(users)
await writeAuditLog('auth.passkey.prereg.success', { email, userId: newUser.id })
// Send notification emails (same behavior as password registration)
try {
const smtpUser = process.env.SMTP_USER
const smtpPass = process.env.SMTP_PASS
if (smtpUser && smtpPass) {
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST || 'smtp.gmail.com',
port: process.env.SMTP_PORT || 587,
secure: false,
auth: { user: smtpUser, pass: smtpPass }
})
await transporter.sendMail({
from: process.env.SMTP_FROM || 'noreply@harheimertc.de',
to: process.env.SMTP_ADMIN || 'j.dichmann@gmx.de',
subject: 'Neue Registrierung (Passkey) - Harheimer TC',
html: `
<h2>Neue Registrierung (Passkey)</h2>
<p>Ein neuer Benutzer hat sich registriert und wartet auf Freigabe:</p>
<ul>
<li><strong>Name:</strong> ${name}</li>
<li><strong>E-Mail:</strong> ${email}</li>
<li><strong>Telefon:</strong> ${phone || 'Nicht angegeben'}</li>
<li><strong>Login:</strong> Passkey (ohne Passwort)</li>
</ul>
<p>Bitte prüfen Sie die Registrierung im CMS.</p>
`
})
await transporter.sendMail({
from: process.env.SMTP_FROM || 'noreply@harheimertc.de',
to: email,
subject: 'Registrierung erhalten - Harheimer TC',
html: `
<h2>Registrierung erhalten</h2>
<p>Hallo ${name},</p>
<p>vielen Dank für Ihre Registrierung beim Harheimer TC!</p>
<p>Ihre Anfrage wird vom Vorstand geprüft. Sie erhalten eine E-Mail, sobald Ihr Zugang freigeschaltet wurde.</p>
<br>
<p>Mit sportlichen Grüßen,<br>Ihr Harheimer TC</p>
`
})
}
} catch (emailError) {
console.error('E-Mail-Versand fehlgeschlagen:', emailError)
}
return {
success: true,
message: 'Registrierung erfolgreich. Sie erhalten eine E-Mail, sobald Ihr Zugang freigeschaltet wurde.'
}
})