-
{{ isLoading ? 'Wird gesendet...' : 'Registrierung beantragen' }}
+
{{ isLoading ? 'Wird gesendet...' : (usePasskey ? 'Mit Passkey registrieren' : 'Registrierung beantragen') }}
@@ -196,6 +208,11 @@ const formData = ref({
const isLoading = ref(false)
const errorMessage = ref('')
const successMessage = ref('')
+const usePasskey = ref(false)
+const isPasskeySupported = ref(false)
+if (process.client) {
+ isPasskeySupported.value = !!window.PublicKeyCredential
+}
const handleRegister = async () => {
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({
title: 'Registrierung - Harheimer TC',
})
diff --git a/server/api/auth/passkeys/login.post.js b/server/api/auth/passkeys/login.post.js
index e5ac11d..3316d1a 100644
--- a/server/api/auth/passkeys/login.post.js
+++ b/server/api/auth/passkeys/login.post.js
@@ -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 = {
diff --git a/server/api/auth/passkeys/recovery/complete.post.js b/server/api/auth/passkeys/recovery/complete.post.js
new file mode 100644
index 0000000..948fa29
--- /dev/null
+++ b/server/api/auth/passkeys/recovery/complete.post.js
@@ -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.' }
+})
+
diff --git a/server/api/auth/passkeys/recovery/options.get.js b/server/api/auth/passkeys/recovery/options.get.js
new file mode 100644
index 0000000..718e875
--- /dev/null
+++ b/server/api/auth/passkeys/recovery/options.get.js
@@ -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 }
+})
+
diff --git a/server/api/auth/passkeys/recovery/request.post.js b/server/api/auth/passkeys/recovery/request.post.js
new file mode 100644
index 0000000..da6540a
--- /dev/null
+++ b/server/api/auth/passkeys/recovery/request.post.js
@@ -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: `
+
Passkey wiederherstellen
+
Hallo ${user.name || ''},
+
Sie haben eine Anfrage gestellt, um einen neuen Passkey hinzuzufügen.
+
Bitte klicken Sie auf den folgenden Link (gültig für ${ttlMin} Minuten):
+
Neuen Passkey hinzufügen
+
Wenn Sie das nicht waren, ignorieren Sie diese E-Mail.
+ `
+ })
+ }
+
+ return { success: true, message: 'Falls ein Konto mit dieser E-Mail existiert, wurde eine E-Mail gesendet.' }
+})
+
diff --git a/server/api/auth/register-passkey-options.post.js b/server/api/auth/register-passkey-options.post.js
new file mode 100644
index 0000000..5357df1
--- /dev/null
+++ b/server/api/auth/register-passkey-options.post.js
@@ -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 }
+})
+
diff --git a/server/api/auth/register-passkey.post.js b/server/api/auth/register-passkey.post.js
new file mode 100644
index 0000000..ff3ec78
--- /dev/null
+++ b/server/api/auth/register-passkey.post.js
@@ -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: `
+
Neue Registrierung (Passkey)
+
Ein neuer Benutzer hat sich registriert und wartet auf Freigabe:
+
+ - Name: ${name}
+ - E-Mail: ${email}
+ - Telefon: ${phone || 'Nicht angegeben'}
+ - Login: Passkey (ohne Passwort)
+
+
Bitte prüfen Sie die Registrierung im CMS.
+ `
+ })
+
+ await transporter.sendMail({
+ from: process.env.SMTP_FROM || 'noreply@harheimertc.de',
+ to: email,
+ subject: 'Registrierung erhalten - Harheimer TC',
+ html: `
+
Registrierung erhalten
+
Hallo ${name},
+
vielen Dank für Ihre Registrierung beim Harheimer TC!
+
Ihre Anfrage wird vom Vorstand geprüft. Sie erhalten eine E-Mail, sobald Ihr Zugang freigeschaltet wurde.
+
+
Mit sportlichen Grüßen,
Ihr Harheimer TC
+ `
+ })
+ }
+ } 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.'
+ }
+})
+
diff --git a/server/utils/passkey-recovery.js b/server/utils/passkey-recovery.js
new file mode 100644
index 0000000..db4c904
--- /dev/null
+++ b/server/utils/passkey-recovery.js
@@ -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
+}
+
diff --git a/server/utils/webauthn-challenges.js b/server/utils/webauthn-challenges.js
index aef4afa..9ec4b2d 100644
--- a/server/utils/webauthn-challenges.js
+++ b/server/utils/webauthn-challenges.js
@@ -1,7 +1,9 @@
const regChallenges = globalThis.__HTC_WEBAUTHN_REG_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_AUTH_CHALLENGES__ = authChallenges
+globalThis.__HTC_WEBAUTHN_PRE_REG__ = preRegChallenges
function nowMs() {
return Date.now()
@@ -43,4 +45,18 @@ export function consumeAuthChallenge(challenge) {
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
+}
+