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
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 48s
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
190
pages/passkey-wiederherstellen.vue
Normal file
190
pages/passkey-wiederherstellen.vue
Normal 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>
|
||||||
|
|
||||||
@@ -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',
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
120
server/api/auth/passkeys/recovery/complete.post.js
Normal file
120
server/api/auth/passkeys/recovery/complete.post.js
Normal 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.' }
|
||||||
|
})
|
||||||
|
|
||||||
89
server/api/auth/passkeys/recovery/options.get.js
Normal file
89
server/api/auth/passkeys/recovery/options.get.js
Normal 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 }
|
||||||
|
})
|
||||||
|
|
||||||
100
server/api/auth/passkeys/recovery/request.post.js
Normal file
100
server/api/auth/passkeys/recovery/request.post.js
Normal 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.' }
|
||||||
|
})
|
||||||
|
|
||||||
60
server/api/auth/register-passkey-options.post.js
Normal file
60
server/api/auth/register-passkey-options.post.js
Normal 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 }
|
||||||
|
})
|
||||||
|
|
||||||
147
server/api/auth/register-passkey.post.js
Normal file
147
server/api/auth/register-passkey.post.js
Normal 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.'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
21
server/utils/passkey-recovery.js
Normal file
21
server/utils/passkey-recovery.js
Normal 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
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user