Files

90 lines
2.9 KiB
JavaScript

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