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

@@ -166,6 +166,16 @@
- `GET /api/auth/passkeys/list` - `GET /api/auth/passkeys/list`
- `POST /api/auth/passkeys/remove` - `POST /api/auth/passkeys/remove`
### Passkey-Recovery (E-Mail-Link):
- **Ziel**: Wenn der Passkey verloren geht (neues Gerät/Crash), kann ein neuer Passkey **hinzugefügt** werden.
- **Sicherheitsannahme**: Zugriff auf das E-Mail-Postfach gilt als Recovery-Faktor (Account-Übernahme möglich bei kompromittierter Mailbox).
- **Ablauf**:
- Request: `POST /api/auth/passkeys/recovery/request` (sendet Link; keine Account-Enumeration)
- Options: `GET /api/auth/passkeys/recovery/options?token=...`
- Complete: `POST /api/auth/passkeys/recovery/complete`
- **Eigenschaften**: Token ist kurzlebig (TTL), serverseitig nur gehasht gespeichert, one-time-use, rate-limited, audit-logged.
--- ---
## 🛡️ API-Endpunkte & Zugriffsschutz ## 🛡️ API-Endpunkte & Zugriffsschutz

View File

@@ -66,3 +66,7 @@ WEBAUTHN_RP_ID=
WEBAUTHN_RP_NAME=Harheimer TC WEBAUTHN_RP_NAME=Harheimer TC
# Optional: erzwingt User Verification (z.B. biometrisch/PIN am Gerät) # Optional: erzwingt User Verification (z.B. biometrisch/PIN am Gerät)
WEBAUTHN_REQUIRE_UV=false WEBAUTHN_REQUIRE_UV=false
# Passkey-Recovery (E-Mail-Link)
# TTL in Minuten (Standard: 30)
PASSKEY_RECOVERY_TTL_MIN=30

View File

@@ -115,13 +115,21 @@
</button> </button>
<!-- Forgot Password Link --> <!-- Forgot Password Link -->
<div class="text-center"> <div class="text-center space-y-2">
<NuxtLink <NuxtLink
to="/passwort-vergessen" to="/passwort-vergessen"
class="text-sm text-primary-600 hover:text-primary-700 font-medium" class="text-sm text-primary-600 hover:text-primary-700 font-medium"
> >
Passwort vergessen? Passwort vergessen?
</NuxtLink> </NuxtLink>
<div>
<NuxtLink
to="/passkey-wiederherstellen"
class="text-sm text-primary-600 hover:text-primary-700 font-medium"
>
Passkey verloren? Wiederherstellen
</NuxtLink>
</div>
</div> </div>
</form> </form>
</div> </div>

View File

@@ -0,0 +1,190 @@
<template>
<div class="min-h-full flex items-center justify-center py-16 px-4 sm:px-6 lg:px-8 bg-gray-50">
<div class="max-w-md w-full space-y-8">
<div class="text-center">
<h2 class="text-3xl font-display font-bold text-gray-900">
Passkey wiederherstellen
</h2>
<p class="mt-2 text-sm text-gray-600">
Fügen Sie einen neuen Passkey hinzu, wenn Sie Ihr Gerät gewechselt haben.
</p>
</div>
<div class="bg-white rounded-xl shadow-lg p-8">
<!-- Token Flow -->
<div v-if="token" class="space-y-4">
<div
v-if="errorMessage"
class="bg-red-50 border border-red-200 rounded-lg p-4"
>
<p class="text-sm text-red-800 flex items-center">
<AlertCircle :size="18" class="mr-2" />
{{ errorMessage }}
</p>
</div>
<div
v-if="successMessage"
class="bg-green-50 border border-green-200 rounded-lg p-4"
>
<p class="text-sm text-green-800 flex items-center">
<Check :size="18" class="mr-2" />
{{ successMessage }}
</p>
</div>
<button
type="button"
class="w-full px-6 py-3 bg-gray-900 hover:bg-gray-800 disabled:bg-gray-400 text-white font-semibold rounded-lg transition-colors flex items-center justify-center"
:disabled="isLoading || !isPasskeySupported"
@click="addPasskeyViaToken"
>
<Loader2 v-if="isLoading" :size="20" class="mr-2 animate-spin" />
<span>
{{ isLoading ? 'Wird vorbereitet...' : (isPasskeySupported ? 'Neuen Passkey hinzufügen' : 'Passkeys nicht verfügbar') }}
</span>
</button>
<div class="text-center">
<NuxtLink to="/login" class="text-sm text-primary-600 hover:text-primary-700 font-medium">
Zurück zum Login
</NuxtLink>
</div>
</div>
<!-- Request Link Flow -->
<form v-else class="space-y-6" @submit.prevent="requestLink">
<div>
<label for="email" class="block text-sm font-medium text-gray-700 mb-2">
E-Mail-Adresse
</label>
<input
id="email"
v-model="email"
type="email"
required
autocomplete="email"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-600 focus:border-transparent transition-all"
placeholder="ihre-email@example.com"
>
</div>
<div
v-if="errorMessage"
class="bg-red-50 border border-red-200 rounded-lg p-4"
>
<p class="text-sm text-red-800 flex items-center">
<AlertCircle :size="18" class="mr-2" />
{{ errorMessage }}
</p>
</div>
<div
v-if="successMessage"
class="bg-green-50 border border-green-200 rounded-lg p-4"
>
<p class="text-sm text-green-800 flex items-center">
<Check :size="18" class="mr-2" />
{{ successMessage }}
</p>
</div>
<button
type="submit"
class="w-full px-6 py-3 bg-primary-600 hover:bg-primary-700 disabled:bg-gray-400 text-white font-semibold rounded-lg transition-colors flex items-center justify-center"
:disabled="isLoading"
>
<Loader2 v-if="isLoading" :size="20" class="mr-2 animate-spin" />
<span>{{ isLoading ? 'Wird gesendet...' : 'Recovery-Link per E-Mail senden' }}</span>
</button>
<div class="text-center">
<NuxtLink to="/login" class="text-sm text-primary-600 hover:text-primary-700 font-medium">
Zurück zum Login
</NuxtLink>
</div>
</form>
</div>
<div class="bg-primary-50 border border-primary-100 rounded-lg p-4">
<p class="text-sm text-primary-800">
<Info :size="16" class="inline mr-1" />
Wir schicken immer die gleiche Rückmeldung, egal ob ein Konto existiert.
</p>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useRoute } from 'vue-router'
import { AlertCircle, Check, Loader2, Info } from 'lucide-vue-next'
const route = useRoute()
const token = ref(String(route.query.token || ''))
const email = ref('')
const isLoading = ref(false)
const errorMessage = ref('')
const successMessage = ref('')
const isPasskeySupported = ref(false)
if (process.client) {
isPasskeySupported.value = !!window.PublicKeyCredential
}
const requestLink = async () => {
errorMessage.value = ''
successMessage.value = ''
isLoading.value = true
try {
const res = await $fetch('/api/auth/passkeys/recovery/request', {
method: 'POST',
body: { email: email.value }
})
successMessage.value = res.message || 'Falls ein Konto existiert, wurde eine E-Mail gesendet.'
} catch (e) {
errorMessage.value = e?.data?.message || 'Fehler beim Senden der E-Mail.'
} finally {
isLoading.value = false
}
}
const addPasskeyViaToken = async () => {
errorMessage.value = ''
successMessage.value = ''
if (!isPasskeySupported.value) {
errorMessage.value = 'Passkeys sind in diesem Browser/unter dieser URL nicht verfügbar (HTTPS erforderlich).'
return
}
isLoading.value = true
try {
const opts = await $fetch('/api/auth/passkeys/recovery/options', {
method: 'GET',
query: { token: token.value }
})
const mod = await import('@simplewebauthn/browser')
const credential = await mod.startRegistration(opts.options)
const res = await $fetch('/api/auth/passkeys/recovery/complete', {
method: 'POST',
body: {
recoveryId: opts.recoveryId,
credential
}
})
successMessage.value = res.message || 'Passkey hinzugefügt.'
} catch (e) {
errorMessage.value = e?.data?.message || e?.message || 'Passkey konnte nicht hinzugefügt werden.'
} finally {
isLoading.value = false
}
}
useHead({ title: 'Passkey wiederherstellen - Harheimer TC' })
</script>

View File

@@ -13,8 +13,20 @@
<div class="bg-white rounded-xl shadow-lg p-8"> <div class="bg-white rounded-xl shadow-lg p-8">
<form <form
class="space-y-6" class="space-y-6"
@submit.prevent="handleRegister" @submit.prevent="usePasskey ? handleRegisterWithPasskey() : handleRegister()"
> >
<!-- Registration Mode -->
<div class="flex items-center justify-between bg-gray-50 border border-gray-200 rounded-lg p-3">
<div class="text-sm text-gray-700">
<div class="font-medium">Registrierungsmethode</div>
<div class="text-xs text-gray-600">Passkey = Anmeldung ohne Passwort (z.B. FaceID/TouchID/Windows Hello)</div>
</div>
<label class="flex items-center gap-2 text-sm font-medium text-gray-800">
<input v-model="usePasskey" type="checkbox" class="h-4 w-4" :disabled="isLoading || !isPasskeySupported">
Mit Passkey
</label>
</div>
<!-- Name --> <!-- Name -->
<div> <div>
<label <label
@@ -72,7 +84,7 @@
</div> </div>
<!-- Password --> <!-- Password -->
<div> <div v-if="!usePasskey">
<label <label
for="password" for="password"
class="block text-sm font-medium text-gray-700 mb-2" class="block text-sm font-medium text-gray-700 mb-2"
@@ -94,7 +106,7 @@
</div> </div>
<!-- Confirm Password --> <!-- Confirm Password -->
<div> <div v-if="!usePasskey">
<label <label
for="confirmPassword" for="confirmPassword"
class="block text-sm font-medium text-gray-700 mb-2" class="block text-sm font-medium text-gray-700 mb-2"
@@ -151,7 +163,7 @@
:size="20" :size="20"
class="mr-2 animate-spin" class="mr-2 animate-spin"
/> />
<span>{{ isLoading ? 'Wird gesendet...' : 'Registrierung beantragen' }}</span> <span>{{ isLoading ? 'Wird gesendet...' : (usePasskey ? 'Mit Passkey registrieren' : 'Registrierung beantragen') }}</span>
</button> </button>
<!-- Back to Login --> <!-- Back to Login -->
@@ -196,6 +208,11 @@ const formData = ref({
const isLoading = ref(false) const isLoading = ref(false)
const errorMessage = ref('') const errorMessage = ref('')
const successMessage = ref('') const successMessage = ref('')
const usePasskey = ref(false)
const isPasskeySupported = ref(false)
if (process.client) {
isPasskeySupported.value = !!window.PublicKeyCredential
}
const handleRegister = async () => { const handleRegister = async () => {
errorMessage.value = '' errorMessage.value = ''
@@ -249,6 +266,54 @@ const handleRegister = async () => {
} }
} }
const handleRegisterWithPasskey = async () => {
errorMessage.value = ''
successMessage.value = ''
if (!isPasskeySupported.value) {
errorMessage.value = 'Passkeys sind in diesem Browser/unter dieser URL nicht verfügbar (HTTPS erforderlich).'
return
}
if (!formData.value.name || !formData.value.email) {
errorMessage.value = 'Bitte Name und E-Mail ausfüllen.'
return
}
isLoading.value = true
try {
const pre = await $fetch('/api/auth/register-passkey-options', {
method: 'POST',
body: {
name: formData.value.name,
email: formData.value.email,
phone: formData.value.phone
}
})
const mod = await import('@simplewebauthn/browser')
const credential = await mod.startRegistration(pre.options)
const response = await $fetch('/api/auth/register-passkey', {
method: 'POST',
body: {
registrationId: pre.registrationId,
credential
}
})
if (response.success) {
successMessage.value = 'Registrierung erfolgreich! Sie erhalten eine E-Mail, sobald Ihr Zugang freigeschaltet wurde.'
formData.value = { name: '', email: '', phone: '', password: '', confirmPassword: '' }
setTimeout(() => navigateTo('/login'), 3000)
}
} catch (error) {
errorMessage.value = error.data?.message || error?.message || 'Registrierung mit Passkey fehlgeschlagen.'
} finally {
isLoading.value = false
}
}
useHead({ useHead({
title: 'Registrierung - Harheimer TC', title: 'Registrierung - Harheimer TC',
}) })

View File

@@ -44,6 +44,14 @@ export default defineEventHandler(async (event) => {
throw createError({ statusCode: 401, statusMessage: 'Passkey unbekannt' }) 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 { origin, rpId, requireUV } = getWebAuthnConfig()
const authenticator = { 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.'
}
})

View File

@@ -0,0 +1,21 @@
import crypto from 'crypto'
export function hashRecoveryToken(token) {
return crypto.createHash('sha256').update(String(token), 'utf8').digest('hex')
}
export function generateRecoveryToken() {
// URL-safe (hex)
return crypto.randomBytes(32).toString('hex')
}
export function pruneRecoveryTokens(user, maxTokens = 10) {
const list = Array.isArray(user.passkeyRecoveryTokens) ? user.passkeyRecoveryTokens : []
const now = Date.now()
const filtered = list.filter(t => t && t.tokenHash && t.expiresAt && new Date(t.expiresAt).getTime() > now)
// keep newest first
filtered.sort((a, b) => new Date(b.createdAt || 0) - new Date(a.createdAt || 0))
user.passkeyRecoveryTokens = filtered.slice(0, maxTokens)
return user.passkeyRecoveryTokens
}

View File

@@ -1,7 +1,9 @@
const regChallenges = globalThis.__HTC_WEBAUTHN_REG_CHALLENGES__ || new Map() const regChallenges = globalThis.__HTC_WEBAUTHN_REG_CHALLENGES__ || new Map()
const authChallenges = globalThis.__HTC_WEBAUTHN_AUTH_CHALLENGES__ || new Map() const authChallenges = globalThis.__HTC_WEBAUTHN_AUTH_CHALLENGES__ || new Map()
const preRegChallenges = globalThis.__HTC_WEBAUTHN_PRE_REG__ || new Map()
globalThis.__HTC_WEBAUTHN_REG_CHALLENGES__ = regChallenges globalThis.__HTC_WEBAUTHN_REG_CHALLENGES__ = regChallenges
globalThis.__HTC_WEBAUTHN_AUTH_CHALLENGES__ = authChallenges globalThis.__HTC_WEBAUTHN_AUTH_CHALLENGES__ = authChallenges
globalThis.__HTC_WEBAUTHN_PRE_REG__ = preRegChallenges
function nowMs() { function nowMs() {
return Date.now() return Date.now()
@@ -43,4 +45,18 @@ export function consumeAuthChallenge(challenge) {
return true return true
} }
export function setPreRegistration(registrationId, payload, ttlMs = 10 * 60 * 1000) {
cleanup(preRegChallenges)
preRegChallenges.set(String(registrationId), { payload, expiresAt: nowMs() + ttlMs })
}
export function consumePreRegistration(registrationId) {
cleanup(preRegChallenges)
const key = String(registrationId)
const v = preRegChallenges.get(key)
if (!v) return null
preRegChallenges.delete(key)
return v.payload || null
}