90 lines
2.9 KiB
JavaScript
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 }
|
|
})
|
|
|