Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 53s
Update the CROSS_DEVICE_PROBLEM_ZUSAMMENFASSUNG.md to clarify the role of tunnel servers in the Cross-Device authentication process and outline troubleshooting steps. Additionally, enhance the registrieren.vue component with detailed information about the FIDO Cross-Device flow, including QR-Code format, connection requirements, and potential issues. Improve the register-passkey-options API documentation to reflect the use of tunnel servers, ensuring better understanding and support for Cross-Device functionality.
213 lines
8.2 KiB
JavaScript
213 lines
8.2 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 { setPreRegistration } from '../../utils/webauthn-challenges.js'
|
|
import { writeAuditLog } from '../../utils/audit-log.js'
|
|
|
|
function isValidEmail(email) {
|
|
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(email || ''))
|
|
}
|
|
|
|
export default defineEventHandler(async (event) => {
|
|
const requestStart = Date.now()
|
|
const requestOrigin = getHeader(event, 'origin')
|
|
const userAgent = getHeader(event, 'user-agent')
|
|
const nodeEnv = process.env.NODE_ENV || 'development'
|
|
|
|
// Debug-Ausgaben immer ausgeben (nicht nur in dev)
|
|
console.log('')
|
|
console.log('='.repeat(80))
|
|
console.log('[DEBUG] 📋 ===== REGISTRATION OPTIONS REQUEST RECEIVED =====')
|
|
console.log('='.repeat(80))
|
|
console.log('[DEBUG] Request Details:', {
|
|
origin: requestOrigin || 'none',
|
|
userAgent: userAgent?.substring(0, 150) || 'none',
|
|
method: getMethod(event),
|
|
timestamp: new Date().toISOString(),
|
|
nodeEnv: nodeEnv,
|
|
pid: process.pid,
|
|
ip: getHeader(event, 'x-forwarded-for') || getHeader(event, 'x-real-ip') || 'unknown',
|
|
note: 'Dieser Request kommt vom Desktop-Browser, wenn der QR-Code generiert wird'
|
|
})
|
|
console.log('[DEBUG] User-Agent Analysis:', {
|
|
isMobile: /Mobile|Android|iPhone|iPad/i.test(userAgent || ''),
|
|
isDesktop: !/Mobile|Android|iPhone|iPad/i.test(userAgent || ''),
|
|
browser: userAgent?.substring(0, 100)
|
|
})
|
|
console.log('='.repeat(80))
|
|
console.log('')
|
|
|
|
const body = await readBody(event)
|
|
const name = String(body?.name || '').trim()
|
|
const email = String(body?.email || '').trim().toLowerCase()
|
|
const phone = String(body?.phone || '').trim()
|
|
|
|
console.log('[DEBUG] Request body parsed', {
|
|
hasName: !!name,
|
|
hasEmail: !!email,
|
|
hasPhone: !!phone,
|
|
email: email.substring(0, 10) + '...'
|
|
})
|
|
|
|
if (!name || !email) {
|
|
console.error('[DEBUG] Validation failed: missing name or email')
|
|
throw createError({ statusCode: 400, message: 'Name und E-Mail sind erforderlich' })
|
|
}
|
|
if (!isValidEmail(email)) {
|
|
console.error('[DEBUG] Validation failed: invalid email format')
|
|
throw createError({ statusCode: 400, message: 'Ungültige E-Mail-Adresse' })
|
|
}
|
|
|
|
const users = await readUsers()
|
|
if (users.some(u => String(u.email || '').toLowerCase() === email)) {
|
|
throw createError({ statusCode: 409, message: 'Ein Benutzer mit dieser E-Mail-Adresse existiert bereits' })
|
|
}
|
|
|
|
const { rpId, rpName, origin: webauthnOrigin } = getWebAuthnConfig()
|
|
|
|
console.log('[DEBUG] WebAuthn config', {
|
|
rpId,
|
|
rpName,
|
|
webauthnOrigin,
|
|
requestOrigin,
|
|
webauthnOriginEnv: process.env.WEBAUTHN_ORIGIN,
|
|
baseUrlEnv: process.env.NUXT_PUBLIC_BASE_URL
|
|
})
|
|
|
|
// WICHTIG: Sicherstellen, dass die Origin KEINEN Port hat
|
|
if (webauthnOrigin.includes(':3100')) {
|
|
console.error('[DEBUG] ERROR: webauthnOrigin contains port 3100! This will cause verification to fail.')
|
|
console.error('[DEBUG] Fix: Set WEBAUTHN_ORIGIN=https://harheimertc.tsschulz.de (without port) in .env')
|
|
}
|
|
|
|
const userId = crypto.randomUUID()
|
|
const registrationId = crypto.randomBytes(16).toString('hex')
|
|
|
|
console.log('[DEBUG] Generated IDs', {
|
|
userId,
|
|
registrationId
|
|
})
|
|
|
|
console.log('[DEBUG] Generating registration options...')
|
|
const optionsStart = Date.now()
|
|
|
|
const options = await generateRegistrationOptions({
|
|
rpName,
|
|
rpID: rpId,
|
|
userID: new TextEncoder().encode(String(userId)),
|
|
userName: email,
|
|
userDisplayName: name,
|
|
attestationType: 'none',
|
|
authenticatorSelection: {
|
|
residentKey: 'preferred',
|
|
userVerification: 'preferred'
|
|
},
|
|
// Timeout erhöhen für Cross-Device (Standard: 60s, hier: 5 Minuten)
|
|
timeout: 300000
|
|
// HINWEIS: FIDO Cross-Device verwendet Tunnel-Server (z.B. cable.ua5v.com von Google)
|
|
// Der Browser verwaltet den Tunnel automatisch - der Server muss nichts konfigurieren
|
|
// Für Cross-Device funktioniert es so:
|
|
// 1. Desktop-Browser generiert QR-Code mit öffentlichem Schlüssel (FIDO-URI)
|
|
// 2. Desktop-Browser registriert sich beim Tunnel-Server (cable.ua5v.com)
|
|
// 3. Smartphone scannt QR-Code
|
|
// 4. Smartphone verbindet sich über Tunnel-Server mit Desktop-Browser
|
|
// 5. Desktop-Browser leitet Credential-Response an den Server weiter
|
|
// Voraussetzungen:
|
|
// - Beide Geräte müssen Internetverbindung haben
|
|
// - Tunnel-Server müssen erreichbar sein (cable.ua5v.com, cable.auth.com)
|
|
// - Bluetooth kann für physische Nähe-Bestätigung verwendet werden (abhängig vom Browser/Gerät)
|
|
})
|
|
|
|
const optionsDuration = Date.now() - optionsStart
|
|
console.log(`[DEBUG] Registration options generated (${optionsDuration}ms)`, {
|
|
hasChallenge: !!options.challenge,
|
|
challengeLength: options.challenge?.length,
|
|
rpId: options.rp?.id,
|
|
rpName: options.rp?.name,
|
|
userId: options.user?.id ? 'present' : 'missing',
|
|
userName: options.user?.name,
|
|
userDisplayName: options.user?.displayName,
|
|
timeout: options.timeout,
|
|
pubKeyCredParamsCount: options.pubKeyCredParams?.length,
|
|
authenticatorSelection: options.authenticatorSelection,
|
|
excludeCredentialsCount: options.excludeCredentials?.length || 0
|
|
})
|
|
|
|
setPreRegistration(registrationId, {
|
|
challenge: options.challenge,
|
|
userId,
|
|
name,
|
|
email,
|
|
phone
|
|
})
|
|
|
|
console.log('[DEBUG] Pre-registration stored', {
|
|
registrationId,
|
|
challengeStored: !!options.challenge
|
|
})
|
|
|
|
await writeAuditLog('auth.passkey.prereg.options', { email })
|
|
|
|
// CORS-Header für Cross-Device Authentication
|
|
// WICHTIG: Für Cross-Device muss CORS korrekt konfiguriert sein
|
|
// OPTIONS-Requests werden von .options.js behandelt
|
|
const allowedOrigin = requestOrigin || webauthnOrigin
|
|
|
|
if (allowedOrigin) {
|
|
setHeader(event, 'Access-Control-Allow-Origin', allowedOrigin)
|
|
setHeader(event, 'Access-Control-Allow-Credentials', 'true')
|
|
setHeader(event, 'Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
|
|
setHeader(event, 'Access-Control-Allow-Headers', 'Content-Type, Authorization, Origin, X-Requested-With')
|
|
setHeader(event, 'Access-Control-Max-Age', '86400') // 24 Stunden Cache für Preflight
|
|
console.log('[DEBUG] CORS headers set for POST', {
|
|
origin: allowedOrigin,
|
|
requestOrigin,
|
|
webauthnOrigin
|
|
})
|
|
}
|
|
|
|
// Options direkt zurückgeben (wie in passkeys/registration-options.post.js)
|
|
// @simplewebauthn/server gibt bereits korrekt formatierte Options zurück
|
|
const totalDuration = Date.now() - requestStart
|
|
|
|
// Debug: Prüfe die vollständige Options-Struktur
|
|
console.log(`[DEBUG] Returning options (total: ${totalDuration}ms)`, {
|
|
registrationId,
|
|
optionsKeys: Object.keys(options),
|
|
challengeLength: options.challenge?.length,
|
|
challengeType: typeof options.challenge,
|
|
rpId: options.rp?.id,
|
|
rpName: options.rp?.name,
|
|
userIdType: typeof options.user?.id,
|
|
userName: options.user?.name,
|
|
userDisplayName: options.user?.displayName,
|
|
timeout: options.timeout,
|
|
timeoutType: typeof options.timeout,
|
|
pubKeyCredParamsCount: options.pubKeyCredParams?.length,
|
|
authenticatorSelection: options.authenticatorSelection,
|
|
hasExtensions: !!options.extensions,
|
|
hasHints: !!options.hints,
|
|
excludeCredentialsCount: options.excludeCredentials?.length || 0
|
|
})
|
|
|
|
// WICHTIG: Prüfe, ob die Options für Cross-Device korrekt sind
|
|
// Für Cross-Device muss die Challenge ein String sein (Base64URL)
|
|
if (typeof options.challenge !== 'string') {
|
|
console.error('[DEBUG] ERROR: Challenge is not a string!', typeof options.challenge, options.challenge)
|
|
}
|
|
|
|
// Prüfe, ob user.id ein Uint8Array ist (wird zu Base64URL konvertiert)
|
|
if (options.user?.id instanceof Uint8Array) {
|
|
console.log('[DEBUG] user.id is Uint8Array (will be converted to Base64URL by browser)')
|
|
} else {
|
|
console.log('[DEBUG] user.id type:', typeof options.user?.id, 'value:', options.user?.id?.substring?.(0, 20))
|
|
}
|
|
|
|
// WICHTIG: Options direkt zurückgeben, keine manuelle Serialisierung
|
|
// Die Options von @simplewebauthn/server sind bereits korrekt formatiert
|
|
// Nuxt/Nitro serialisiert automatisch zu JSON
|
|
return { success: true, registrationId, options }
|
|
})
|
|
|