101 lines
3.5 KiB
JavaScript
101 lines
3.5 KiB
JavaScript
import { verifyAuthenticationResponse } from '@simplewebauthn/server'
|
|
import { createSession, generateToken, migrateUserRoles, readUsers, writeUsers } from '../../../utils/auth.js'
|
|
import { getWebAuthnConfig } from '../../../utils/webauthn-config.js'
|
|
import { consumeAuthChallenge } from '../../../utils/webauthn-challenges.js'
|
|
import { fromBase64Url, parseClientDataJSON } from '../../../utils/webauthn-encoding.js'
|
|
import { getAuthCookieOptions } from '../../../utils/cookies.js'
|
|
import { writeAuditLog } from '../../../utils/audit-log.js'
|
|
import { getClientIp } from '../../../utils/rate-limit.js'
|
|
|
|
function findUserByCredentialId(users, credentialId) {
|
|
const cid = String(credentialId || '')
|
|
for (const u of users) {
|
|
const pks = Array.isArray(u.passkeys) ? u.passkeys : []
|
|
const match = pks.find(pk => pk && pk.credentialId === cid)
|
|
if (match) return { user: u, passkey: match }
|
|
}
|
|
return { user: null, passkey: null }
|
|
}
|
|
|
|
export default defineEventHandler(async (event) => {
|
|
const ip = getClientIp(event)
|
|
const body = await readBody(event)
|
|
const response = body?.credential
|
|
if (!response) {
|
|
throw createError({ statusCode: 400, statusMessage: 'Credential fehlt' })
|
|
}
|
|
|
|
// Challenge aus clientDataJSON holen
|
|
const clientData = parseClientDataJSON(response.response?.clientDataJSON)
|
|
const challenge = clientData?.challenge
|
|
if (!challenge) {
|
|
throw createError({ statusCode: 400, statusMessage: 'Ungültige Passkey-Antwort (Challenge fehlt)' })
|
|
}
|
|
|
|
if (!consumeAuthChallenge(challenge)) {
|
|
await writeAuditLog('auth.passkey.login.failed', { ip, reason: 'unknown_or_expired_challenge' })
|
|
throw createError({ statusCode: 400, statusMessage: 'Login-Session abgelaufen. Bitte erneut versuchen.' })
|
|
}
|
|
|
|
const users = await readUsers()
|
|
const { user, passkey } = findUserByCredentialId(users, response.id)
|
|
if (!user || !passkey) {
|
|
await writeAuditLog('auth.passkey.login.failed', { ip, reason: 'credential_not_found' })
|
|
throw createError({ statusCode: 401, statusMessage: 'Passkey unbekannt' })
|
|
}
|
|
|
|
const { origin, rpId, requireUV } = getWebAuthnConfig()
|
|
|
|
const authenticator = {
|
|
credentialID: fromBase64Url(passkey.credentialId),
|
|
credentialPublicKey: fromBase64Url(passkey.publicKey),
|
|
counter: Number(passkey.counter) || 0,
|
|
transports: passkey.transports || undefined
|
|
}
|
|
|
|
const verification = await verifyAuthenticationResponse({
|
|
response,
|
|
expectedChallenge: challenge,
|
|
expectedOrigin: origin,
|
|
expectedRPID: rpId,
|
|
authenticator,
|
|
requireUserVerification: requireUV
|
|
})
|
|
|
|
if (!verification.verified) {
|
|
await writeAuditLog('auth.passkey.login.failed', { ip, userId: user.id, reason: 'verification_failed' })
|
|
throw createError({ statusCode: 401, statusMessage: 'Passkey-Login fehlgeschlagen' })
|
|
}
|
|
|
|
// Counter/lastUsed aktualisieren
|
|
passkey.counter = verification.authenticationInfo?.newCounter ?? passkey.counter
|
|
passkey.lastUsedAt = new Date().toISOString()
|
|
await writeUsers(users)
|
|
|
|
const token = generateToken(user)
|
|
await createSession(user.id, token)
|
|
|
|
setCookie(event, 'auth_token', token, { ...getAuthCookieOptions() })
|
|
|
|
await writeAuditLog('auth.passkey.login.success', { ip, userId: user.id })
|
|
|
|
const migratedUser = migrateUserRoles({ ...user })
|
|
const roles = Array.isArray(migratedUser.roles)
|
|
? migratedUser.roles
|
|
: (migratedUser.role ? [migratedUser.role] : ['mitglied'])
|
|
|
|
return {
|
|
success: true,
|
|
token,
|
|
user: {
|
|
id: user.id,
|
|
email: user.email,
|
|
name: user.name,
|
|
roles
|
|
},
|
|
role: roles[0] || 'mitglied'
|
|
}
|
|
})
|
|
|
|
|