Files
harheimertc/server/api/auth/passkeys/login.post.js
Torsten Schulz (local) 0528334eb4
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 5m10s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m14s
feat: replace success modal with non-blocking toast notification
feat: add global event listener for mannschaften updates in Navigation component

feat: notify app of mannschaften changes after CSV save and handle visibility changes

refactor: remove unused anlagen page

fix: update CmsMannschaften reference in sportbetrieb page for reactivity

fix: enhance authentication token retrieval in passkey API endpoints

feat: implement refresh session and access token generation for Android clients in passkey login

fix: unify token retrieval method across passkey API endpoints

feat: add MediaTypes utility for JSON content type in Android app

feat: create PasskeyRepository for handling passkey authentication and registration in Android app

feat: add validated text field and rich text components for Android UI

feat: implement newsletter subscription and unsubscription screens in Android app

feat: create public pages including Impressum with dynamic content loading
2026-05-28 08:33:28 +02:00

147 lines
5.5 KiB
JavaScript

import { verifyAuthenticationResponse } from '@simplewebauthn/server'
import { createRefreshSession, createSession, generateAndroidAccessToken, 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'
// Local fallback for Nitro globals when lint/run env doesn't provide them
const getMethod = globalThis.getMethod ?? ((e) => (e?.req?.method || e?.method || 'GET'))
const getRequestURL = globalThis.getRequestURL ?? ((e) => {
try { return new URL(e?.req?.url, 'http://localhost') } catch { return { href: String(e?.req?.url || ''), pathname: String(e?.req?.url || '').split('?')[0] || '' } }
})
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) => {
// CORS-Header für Cross-Device Authentication
const requestOrigin = getHeader(event, 'origin')
if (requestOrigin) {
setHeader(event, 'Access-Control-Allow-Origin', requestOrigin)
setHeader(event, 'Access-Control-Allow-Credentials', 'true')
setHeader(event, 'Access-Control-Allow-Methods', 'POST, OPTIONS')
setHeader(event, 'Access-Control-Allow-Headers', 'Content-Type, Authorization')
}
if (getMethod(event) === 'OPTIONS') {
return { success: true }
}
const ip = getClientIp(event)
const body = await readBody(event)
const isAndroidClient = body?.client === 'android'
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' })
}
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 { origins, rpId, requireUV } = getWebAuthnConfig()
const authenticator = {
credentialID: fromBase64Url(passkey.credentialId),
credentialPublicKey: fromBase64Url(passkey.publicKey),
counter: Number(passkey.counter) || 0,
transports: passkey.transports || undefined
}
let verification
try {
verification = await verifyAuthenticationResponse({
response,
expectedChallenge: challenge,
expectedOrigin: origins,
expectedRPID: rpId,
authenticator,
requireUserVerification: requireUV
})
} catch {
await writeAuditLog('auth.passkey.login.failed', { ip, userId: user.id, reason: 'verification_error' })
throw createError({ statusCode: 401, statusMessage: 'Passkey-Login fehlgeschlagen' })
}
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)
let token
let refreshSession = null
if (isAndroidClient) {
refreshSession = await createRefreshSession(user.id, body?.deviceName || 'Harheimer TC Android-App')
token = generateAndroidAccessToken(user, refreshSession.session.id)
} else {
token = generateToken(user)
await createSession(user.id, token)
}
if (isAndroidClient) {
deleteCookie(event, 'auth_token')
} else {
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,
accessToken: isAndroidClient ? token : undefined,
refreshToken: refreshSession?.refreshToken,
sessionId: refreshSession?.session.id,
user: {
id: user.id,
email: user.email,
name: user.name,
roles
},
role: roles[0] || 'mitglied'
}
})