121 lines
4.0 KiB
JavaScript
121 lines
4.0 KiB
JavaScript
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.' }
|
|
})
|
|
|