diff --git a/AUTH_README.md b/AUTH_README.md index bf01fcc..3f211c7 100644 --- a/AUTH_README.md +++ b/AUTH_README.md @@ -43,7 +43,7 @@ Oder mit einem Online-Tool: https://bcrypt-generator.com/ (Rounds: 10) ## Dateien -- `server/data/users.json` - Benutzerdaten (verschlüsselte Passwörter) +- `server/data/users.json` - Benutzerdaten (Datei verschlüsselt; Passwörter sind bcrypt-Hashes) - `server/data/members.json` - Mitgliederdaten (Telefon, E-Mail, etc.) - `server/data/sessions.json` - Aktive Sessions @@ -52,6 +52,7 @@ Oder mit einem Online-Tool: https://bcrypt-generator.com/ (Rounds: 10) - Passwörter werden mit bcrypt gehasht (Rounds: 10) - JWT-Tokens für Sessions (7 Tage gültig) - HTTP-Only Cookies +- Optional: Passwort-Prüfung gegen HaveIBeenPwned (k-Anonymity) via `HIBP_ENABLED=true` - Geschützte API-Routen - Middleware für geschützte Seiten diff --git a/DATENSCHUTZ.md b/DATENSCHUTZ.md index 902b61e..bf6d00c 100644 --- a/DATENSCHUTZ.md +++ b/DATENSCHUTZ.md @@ -17,8 +17,8 @@ ### **Verschlüsselung:** - **Algorithmus:** AES-256-GCM - **Schlüsselableitung:** PBKDF2 mit 100.000 Iterationen -- **Salt:** 64 Bytes zufällig generiert -- **IV:** 16 Bytes zufällig generiert +- **Salt:** 32 Bytes zufällig generiert +- **IV:** 12 Bytes zufällig generiert (GCM Best Practice) - **Auth-Tag:** 16 Bytes für Integrität ### **Passwort-Sicherheit:** diff --git a/DATENSCHUTZ_UEBERSICHT.md b/DATENSCHUTZ_UEBERSICHT.md index 929abe9..0d6c3b4 100644 --- a/DATENSCHUTZ_UEBERSICHT.md +++ b/DATENSCHUTZ_UEBERSICHT.md @@ -77,12 +77,14 @@ ### Algorithmus & Konfiguration: -- **Algorithmus**: AES-256-CBC +- **Algorithmus**: AES-256-GCM (AEAD, Stand der Technik) - **Schlüsselableitung**: PBKDF2 mit SHA-512 - **Iterationen**: 100.000 Runden - **Salt-Länge**: 32 Bytes (zufällig pro Verschlüsselung) -- **IV-Länge**: 16 Bytes (zufällig pro Verschlüsselung) -- **Format**: Base64-kodiert (Salt + IV + verschlüsselter Text) +- **IV-Länge**: 12 Bytes (zufällig pro Verschlüsselung) +- **Auth-Tag**: 16 Bytes (GCM) +- **Format**: `v2:` + Base64-kodiert (Salt + IV + Auth-Tag + Ciphertext) +- **Rückwärtskompatibilität**: bestehende, ältere Daten ohne Prefix werden weiterhin entschlüsselt (legacy AES-256-CBC) ### Verschlüsselungsschlüssel: @@ -123,10 +125,26 @@ - **Name**: `auth_token` - **HttpOnly**: `true` (verhindert JavaScript-Zugriff) -- **Secure**: `false` (in Production sollte dies `true` sein, wenn HTTPS verwendet wird) -- **SameSite**: `lax` +- **Secure**: `true` in Produktion (konfigurierbar via `COOKIE_SECURE`) +- **SameSite**: `strict` in Produktion (konfigurierbar via `COOKIE_SAMESITE`) - **MaxAge**: 7 Tage (604.800 Sekunden) +### Rate Limiting / Brute-Force-Schutz: + +- **Login**: IP- und Account-basiertes Rate Limiting mit Lockout + Backoff (`/api/auth/login`) +- **Passwort-Reset**: IP- und Account-basiertes Rate Limiting (`/api/auth/reset-password`) +- **Newsletter**: Rate Limits für Subscribe/Unsubscribe/Check (`/api/newsletter/*`) + +### Passwortprüfung (HaveIBeenPwned): + +- Optional aktivierbar via `HIBP_ENABLED=true` +- Implementiert per k-Anonymity (Pwned Passwords „range“ API) + +### Audit-Logging (Security Operations): + +- Audit-Events werden als JSONL in `server/data/audit.log.jsonl` geschrieben (abschaltbar via `AUDIT_LOG_ENABLED=false`) +- Enthält u.a. Login-Erfolg/Fehlschlag, Passwort-Reset Requests, Rollenänderungen, User-Freischaltung/Deaktivierung/Ablehnung + ### Authentifizierungs-Endpunkte: - `POST /api/auth/login` - Login mit E-Mail/Passwort @@ -135,6 +153,19 @@ - `POST /api/auth/reset-password` - Passwort-Reset - `GET /api/auth/status` - Prüft aktuellen Auth-Status +### Passkeys (WebAuthn): + +- **Ziel**: Passwortlose Anmeldung ohne zweiten Faktor (Passkey = starker Login am Gerät) +- **Registration** (nur für eingeloggte Nutzer im Profil): + - `POST /api/auth/passkeys/registration-options` + - `POST /api/auth/passkeys/register` +- **Login** (öffentlich): + - `POST /api/auth/passkeys/authentication-options` + - `POST /api/auth/passkeys/login` +- **Verwaltung**: + - `GET /api/auth/passkeys/list` + - `POST /api/auth/passkeys/remove` + --- ## 🛡️ API-Endpunkte & Zugriffsschutz diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 727881a..ae148a2 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -123,3 +123,30 @@ pm2 save - **Bei Deployment:** Immer Backup → Build → Restore - **Bei Problemen:** Script verwenden oder manuell Daten sichern +--- + +## Security Operations (Empfehlungen) + +### Sicherheitsupdates (OS/Libs) + +- **OS Updates**: mindestens monatlich (besser: automatisiert via unattended-upgrades) + Reboot-Fenster einplanen +- **Node/NPM Dependencies**: Renovate ist vorhanden (`renovate.json`). Zusätzlich regelmäßig `npm audit` prüfen und Updates einspielen. + +### Backup / Wiederherstellung + +- **Backup-Inhalt**: mindestens `server/data/` + `public/data/` (+ optional `public/uploads/` / `server/data/galerie/`) +- **Frequenz**: täglich (inkrementell) + wöchentlich (voll) als Richtwert +- **Ablage**: getrennt vom Server (Offsite), z.B. verschlüsselt (restic/borg) auf Storage +- **Restore-Drill**: mindestens quartalsweise Wiederherstellung testen + +### SSH-Härtung + +- **Nur Key-Auth** (PasswordAuthentication=no), Root-Login deaktivieren (PermitRootLogin=no) +- **2FA/MFA** optional via SSH-CA / PAM / Bastion Host +- **Fail2ban** oder äquivalente Schutzmaßnahmen für SSH in Betracht ziehen + +### Logging / Audit + +- Audit-Log (JSONL) liegt unter `server/data/audit.log.jsonl` (abschaltbar via `AUDIT_LOG_ENABLED=false`) +- Empfehlung: Log-Rotation + gesicherte Aufbewahrung (z.B. journald/rotate + offsite) + diff --git a/apache-ssl-config.conf b/apache-ssl-config.conf index 746c6b9..23d4ac6 100644 --- a/apache-ssl-config.conf +++ b/apache-ssl-config.conf @@ -21,11 +21,14 @@ # Security Headers Header always set Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" - Header always set X-Frame-Options DENY + Header always set X-Frame-Options SAMEORIGIN # X-Content-Type-Options wird vom Nuxt-Server gesetzt Header always set Referrer-Policy "strict-origin-when-cross-origin" Header always set Permissions-Policy "geolocation=(), microphone=(), camera=()" + # Optional: Content Security Policy (zuerst Report-Only testen) + # Header always set Content-Security-Policy-Report-Only "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'self'; font-src 'self' https://fonts.gstatic.com data:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; script-src 'self'; img-src 'self' data: blob:; connect-src 'self'" + # Proxy alle Anfragen an Nuxt Server (Port 3100) ProxyPreserveHost On ProxyPass / http://localhost:3100/ diff --git a/apache-static.conf b/apache-static.conf index 10613c7..61768cf 100644 --- a/apache-static.conf +++ b/apache-static.conf @@ -23,10 +23,13 @@ # Security Headers Header always set Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" - Header always set X-Frame-Options DENY + Header always set X-Frame-Options SAMEORIGIN Header always set X-Content-Type-Options nosniff Header always set Referrer-Policy "strict-origin-when-cross-origin" + # Optional: Content Security Policy (zuerst Report-Only testen) + # Header always set Content-Security-Policy-Report-Only "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'self'; font-src 'self' https://fonts.gstatic.com data:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; script-src 'self'; img-src 'self' data: blob:; connect-src 'self'" + # SPA Fallback für Nuxt.js Options Indexes FollowSymLinks diff --git a/env.example b/env.example index ff2f80d..5754d62 100644 --- a/env.example +++ b/env.example @@ -30,3 +30,39 @@ DATA_PATH=server/data # Debug-Modi DEBUG=true VERBOSE_LOGGING=true + +# Cookies (Security Hardening) +# - In Produktion sollte COOKIE_SECURE=true sein (Browser sendet Cookie nur über HTTPS) +# - SameSite=Strict ist am restriktivsten (kann iFrame/3rd-party Flows einschränken) +COOKIE_SECURE= +COOKIE_SAMESITE= + +# Security Headers / CSP +# - CSP_ENABLED: aktiviert CSP-Header (empfohlen zuerst im Report-Only Modus testen) +# - CSP_REPORT_ONLY: wenn true (Default), setzt Content-Security-Policy-Report-Only statt CSP enforcing +# - CSP_VALUE: optional eigene Policy +CSP_ENABLED=false +CSP_REPORT_ONLY=true +CSP_VALUE= + +# HaveIBeenPwned (Pwned Passwords, k-Anonymity) +HIBP_ENABLED=false +HIBP_USER_AGENT=harheimertc +HIBP_TIMEOUT_MS=4000 +HIBP_CACHE_TTL_MS=21600000 +# Wenn true: bei HIBP-Fehlern (Timeout/5xx) wird Registrierung/Passwortwechsel abgelehnt +HIBP_FAIL_CLOSED=false + +# Audit Logging (JSONL unter server/data/audit.log.jsonl) +AUDIT_LOG_ENABLED=true + +# WebAuthn / Passkeys +# Für Produktion unbedingt setzen: +# - WEBAUTHN_ORIGIN z.B. https://harheimertc.tsschulz.de +# - WEBAUTHN_RP_ID z.B. harheimertc.tsschulz.de +# - WEBAUTHN_RP_NAME frei wählbar (Anzeige im Browser) +WEBAUTHN_ORIGIN= +WEBAUTHN_RP_ID= +WEBAUTHN_RP_NAME=Harheimer TC +# Optional: erzwingt User Verification (z.B. biometrisch/PIN am Gerät) +WEBAUTHN_REQUIRE_UV=false diff --git a/package-lock.json b/package-lock.json index a474906..afea33f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,8 @@ "hasInstallScript": true, "dependencies": { "@pinia/nuxt": "^0.11.2", + "@simplewebauthn/browser": "^13.2.2", + "@simplewebauthn/server": "^13.2.2", "@tinymce/tinymce-vue": "^6.3.0", "bcryptjs": "^2.4.3", "dompurify": "^3.3.1", @@ -1196,6 +1198,12 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@hexagon/base64": { + "version": "1.1.28", + "resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz", + "integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==", + "license": "MIT" + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1839,6 +1847,12 @@ "integrity": "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==", "license": "MIT" }, + "node_modules/@levischuck/tiny-cbor": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.11.tgz", + "integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==", + "license": "MIT" + }, "node_modules/@mapbox/node-pre-gyp": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-2.0.0.tgz", @@ -3522,6 +3536,165 @@ "pako": "^1.0.10" } }, + "node_modules/@peculiar/asn1-android": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.6.0.tgz", + "integrity": "sha512-cBRCKtYPF7vJGN76/yG8VbxRcHLPF3HnkoHhKOZeHpoVtbMYfY9ROKtH3DtYUY9m8uI1Mh47PRhHf2hSK3xcSQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.6.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-cms": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.6.0.tgz", + "integrity": "sha512-2uZqP+ggSncESeUF/9Su8rWqGclEfEiz1SyU02WX5fUONFfkjzS2Z/F1Li0ofSmf4JqYXIOdCAZqIXAIBAT1OA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.0", + "@peculiar/asn1-x509-attr": "^2.6.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-csr": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.6.0.tgz", + "integrity": "sha512-BeWIu5VpTIhfRysfEp73SGbwjjoLL/JWXhJ/9mo4vXnz3tRGm+NGm3KNcRzQ9VMVqwYS2RHlolz21svzRXIHPQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-ecc": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.6.0.tgz", + "integrity": "sha512-FF3LMGq6SfAOwUG2sKpPXblibn6XnEIKa+SryvUl5Pik+WR9rmRA3OCiwz8R3lVXnYnyRkSZsSLdml8H3UiOcw==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pfx": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.6.0.tgz", + "integrity": "sha512-rtUvtf+tyKGgokHHmZzeUojRZJYPxoD/jaN1+VAB4kKR7tXrnDCA/RAWXAIhMJJC+7W27IIRGe9djvxKgsldCQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.6.0", + "@peculiar/asn1-pkcs8": "^2.6.0", + "@peculiar/asn1-rsa": "^2.6.0", + "@peculiar/asn1-schema": "^2.6.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pkcs8": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.6.0.tgz", + "integrity": "sha512-KyQ4D8G/NrS7Fw3XCJrngxmjwO/3htnA0lL9gDICvEQ+GJ+EPFqldcJQTwPIdvx98Tua+WjkdKHSC0/Km7T+lA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pkcs9": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.6.0.tgz", + "integrity": "sha512-b78OQ6OciW0aqZxdzliXGYHASeCvvw5caqidbpQRYW2mBtXIX2WhofNXTEe7NyxTb0P6J62kAAWLwn0HuMF1Fw==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.6.0", + "@peculiar/asn1-pfx": "^2.6.0", + "@peculiar/asn1-pkcs8": "^2.6.0", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.0", + "@peculiar/asn1-x509-attr": "^2.6.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-rsa": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.6.0.tgz", + "integrity": "sha512-Nu4C19tsrTsCp9fDrH+sdcOKoVfdfoQQ7S3VqjJU6vedR7tY3RLkQ5oguOIB3zFW33USDUuYZnPEQYySlgha4w==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.6.0.tgz", + "integrity": "sha512-xNLYLBFTBKkCzEZIw842BxytQQATQv+lDTCEMZ8C196iJcJJMBUZxrhSTxLaohMyKK8QlzRNTRkUmanucnDSqg==", + "license": "MIT", + "dependencies": { + "asn1js": "^3.0.6", + "pvtsutils": "^1.3.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-x509": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.6.0.tgz", + "integrity": "sha512-uzYbPEpoQiBoTq0/+jZtpM6Gq6zADBx+JNFP3yqRgziWBxQ/Dt/HcuvRfm9zJTPdRcBqPNdaRHTVwpyiq6iNMA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.6.0", + "asn1js": "^3.0.6", + "pvtsutils": "^1.3.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-x509-attr": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.6.0.tgz", + "integrity": "sha512-MuIAXFX3/dc8gmoZBkwJWxUWOSvG4MMDntXhrOZpJVMkYX+MYc/rUAU2uJOved9iJEoiUx7//3D8oG83a78UJA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/x509": { + "version": "1.14.2", + "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.2.tgz", + "integrity": "sha512-r2w1Hg6pODDs0zfAKHkSS5HLkOLSeburtcgwvlLLWWCixw+MmW3U6kD5ddyvc2Y2YdbGuVwCF2S2ASoU1cFAag==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.6.0", + "@peculiar/asn1-csr": "^2.6.0", + "@peculiar/asn1-ecc": "^2.6.0", + "@peculiar/asn1-pkcs9": "^2.6.0", + "@peculiar/asn1-rsa": "^2.6.0", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.0", + "pvtsutils": "^1.3.6", + "reflect-metadata": "^0.2.2", + "tslib": "^2.8.1", + "tsyringe": "^4.10.0" + }, + "engines": { + "node": ">=22.0.0" + } + }, "node_modules/@pinia/nuxt": { "version": "0.11.3", "resolved": "https://registry.npmjs.org/@pinia/nuxt/-/nuxt-0.11.3.tgz", @@ -4121,6 +4294,31 @@ "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", "license": "MIT" }, + "node_modules/@simplewebauthn/browser": { + "version": "13.2.2", + "resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.2.2.tgz", + "integrity": "sha512-FNW1oLQpTJyqG5kkDg5ZsotvWgmBaC6jCHR7Ej0qUNep36Wl9tj2eZu7J5rP+uhXgHaLk+QQ3lqcw2vS5MX1IA==", + "license": "MIT" + }, + "node_modules/@simplewebauthn/server": { + "version": "13.2.2", + "resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-13.2.2.tgz", + "integrity": "sha512-HcWLW28yTMGXpwE9VLx9J+N2KEUaELadLrkPEEI9tpI5la70xNEVEsu/C+m3u7uoq4FulLqZQhgBCzR9IZhFpA==", + "license": "MIT", + "dependencies": { + "@hexagon/base64": "^1.1.27", + "@levischuck/tiny-cbor": "^0.2.2", + "@peculiar/asn1-android": "^2.3.10", + "@peculiar/asn1-ecc": "^2.3.8", + "@peculiar/asn1-rsa": "^2.3.8", + "@peculiar/asn1-schema": "^2.3.8", + "@peculiar/asn1-x509": "^2.3.8", + "@peculiar/x509": "^1.13.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@sindresorhus/is": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.1.0.tgz", @@ -4991,6 +5189,20 @@ "dev": true, "license": "MIT" }, + "node_modules/asn1js": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.7.tgz", + "integrity": "sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ==", + "license": "BSD-3-Clause", + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.3", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -11143,6 +11355,24 @@ "node": ">=6" } }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvutils": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz", + "integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", @@ -11339,6 +11569,12 @@ "node": ">=4" } }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "license": "Apache-2.0" + }, "node_modules/regexp-tree": { "version": "0.1.27", "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.27.tgz", @@ -12851,8 +13087,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD", - "optional": true + "license": "0BSD" }, "node_modules/tsscmp": { "version": "1.0.6", @@ -12864,6 +13099,24 @@ "node": ">=0.6.x" } }, + "node_modules/tsyringe": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz", + "integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==", + "license": "MIT", + "dependencies": { + "tslib": "^1.9.3" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/tsyringe/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/package.json b/package.json index 61cdbbd..c490213 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,8 @@ }, "dependencies": { "@pinia/nuxt": "^0.11.2", + "@simplewebauthn/browser": "^13.2.2", + "@simplewebauthn/server": "^13.2.2", "@tinymce/tinymce-vue": "^6.3.0", "bcryptjs": "^2.4.3", "dompurify": "^3.3.1", diff --git a/pages/login.vue b/pages/login.vue index 7ba4309..f8f0dc0 100644 --- a/pages/login.vue +++ b/pages/login.vue @@ -97,6 +97,23 @@ {{ isLoading ? 'Anmeldung läuft...' : 'Anmelden' }} + + +
{ isLoading.value = true errorMessage.value = '' @@ -168,6 +191,31 @@ const handleLogin = async () => { } } +const handlePasskeyLogin = async () => { + isPasskeyLoading.value = true + errorMessage.value = '' + successMessage.value = '' + + try { + const response = await authStore.passkeyLogin() + if (response.success) { + successMessage.value = 'Anmeldung per Passkey erfolgreich! Sie werden weitergeleitet...' + setTimeout(() => { + const roles = response.user.roles || (response.user.role ? [response.user.role] : []) + if (roles.includes('admin') || roles.includes('vorstand') || roles.includes('newsletter')) { + router.push('/cms') + } else { + router.push('/mitgliederbereich') + } + }, 800) + } + } catch (error) { + errorMessage.value = error?.data?.message || error?.message || 'Passkey-Login fehlgeschlagen.' + } finally { + isPasskeyLoading.value = false + } +} + definePageMeta({ layout: 'default' }) diff --git a/pages/mitgliederbereich/profil.vue b/pages/mitgliederbereich/profil.vue index 4c71f6d..8d17e16 100644 --- a/pages/mitgliederbereich/profil.vue +++ b/pages/mitgliederbereich/profil.vue @@ -134,6 +134,72 @@
+ +
+

+ Passkeys +

+

+ Passkeys erlauben eine Anmeldung ohne Passwort (z.B. per Fingerabdruck/FaceID/Windows Hello). +

+ +
+ + {{ passkeyError }} +
+ +
+ + +
+ +
+ Noch keine Passkeys hinterlegt. +
+ + +
+
{ } } +const loadPasskeys = async () => { + passkeyError.value = '' + try { + const res = await $fetch('/api/auth/passkeys/list') + passkeys.value = res.passkeys || [] + } catch (e) { + passkeyError.value = e?.data?.message || 'Fehler beim Laden der Passkeys.' + } +} + +const addPasskey = async () => { + passkeyError.value = '' + passkeyLoading.value = true + try { + const name = window.prompt('Name für den Passkey (z.B. "iPhone", "Laptop"):', 'Passkey') || 'Passkey' + const res = await $fetch('/api/auth/passkeys/registration-options', { method: 'POST' }) + const mod = await import('@simplewebauthn/browser') + const credential = await mod.startRegistration(res.options) + await $fetch('/api/auth/passkeys/register', { + method: 'POST', + body: { credential, name } + }) + await loadPasskeys() + successMessage.value = 'Passkey hinzugefügt.' + } catch (e) { + passkeyError.value = e?.data?.message || e?.message || 'Passkey konnte nicht hinzugefügt werden.' + } finally { + passkeyLoading.value = false + } +} + +const removePasskey = async (credentialId) => { + passkeyError.value = '' + passkeyLoading.value = true + try { + await $fetch('/api/auth/passkeys/remove', { + method: 'POST', + body: { credentialId } + }) + await loadPasskeys() + successMessage.value = 'Passkey entfernt.' + } catch (e) { + passkeyError.value = e?.data?.message || 'Passkey konnte nicht entfernt werden.' + } finally { + passkeyLoading.value = false + } +} + +const formatDate = (iso) => { + if (!iso) return '—' + try { + return new Date(iso).toLocaleString('de-DE') + } catch { + return iso + } +} + const handleSave = async () => { isSaving.value = true errorMessage.value = '' @@ -292,6 +423,7 @@ const handleSave = async () => { onMounted(() => { loadProfile() + loadPasskeys() }) definePageMeta({ diff --git a/server/api/auth/login.post.js b/server/api/auth/login.post.js index e54200b..f9e1d58 100644 --- a/server/api/auth/login.post.js +++ b/server/api/auth/login.post.js @@ -1,4 +1,7 @@ import { readUsers, writeUsers, verifyPassword, generateToken, createSession, migrateUserRoles } from '../../utils/auth.js' +import { assertRateLimit, getClientIp, registerRateLimitFailure, registerRateLimitSuccess } from '../../utils/rate-limit.js' +import { getAuthCookieOptions } from '../../utils/cookies.js' +import { writeAuditLog } from '../../utils/audit-log.js' export default defineEventHandler(async (event) => { try { @@ -12,11 +15,33 @@ export default defineEventHandler(async (event) => { }) } + const ip = getClientIp(event) + const emailKey = String(email || '').trim().toLowerCase() + + // Rate Limiting (IP + Account) + assertRateLimit(event, { + name: 'auth:login:ip', + keyParts: [ip], + windowMs: 10 * 60 * 1000, + maxAttempts: 30, + lockoutMs: 15 * 60 * 1000 + }) + assertRateLimit(event, { + name: 'auth:login:account', + keyParts: [emailKey], + windowMs: 10 * 60 * 1000, + maxAttempts: 10, + lockoutMs: 30 * 60 * 1000 + }) + // Find user const users = await readUsers() const user = users.find(u => u.email.toLowerCase() === email.toLowerCase()) if (!user) { + await registerRateLimitFailure(event, { name: 'auth:login:ip', keyParts: [ip] }) + await registerRateLimitFailure(event, { name: 'auth:login:account', keyParts: [emailKey] }) + await writeAuditLog('auth.login.failed', { ip, email: emailKey, reason: 'user_not_found' }) throw createError({ statusCode: 401, message: 'Ungültige Anmeldedaten' @@ -34,18 +59,27 @@ export default defineEventHandler(async (event) => { // Verify password const isValid = await verifyPassword(password, user.password) if (!isValid) { + await registerRateLimitFailure(event, { name: 'auth:login:ip', keyParts: [ip] }) + await registerRateLimitFailure(event, { name: 'auth:login:account', keyParts: [emailKey] }) + await writeAuditLog('auth.login.failed', { ip, email: emailKey, userId: user.id, reason: 'bad_password' }) throw createError({ statusCode: 401, message: 'Ungültige Anmeldedaten' }) } + // Erfolg: Limiter zurücksetzen + registerRateLimitSuccess(event, { name: 'auth:login:ip', keyParts: [ip] }) + registerRateLimitSuccess(event, { name: 'auth:login:account', keyParts: [emailKey] }) + // Generate token const token = generateToken(user) // Create session await createSession(user.id, token) + await writeAuditLog('auth.login.success', { ip, email: emailKey, userId: user.id }) + // Update last login user.lastLogin = new Date().toISOString() const updatedUsers = users.map(u => u.id === user.id ? user : u) @@ -53,10 +87,7 @@ export default defineEventHandler(async (event) => { // Set cookie setCookie(event, 'auth_token', token, { - httpOnly: true, - secure: false, // Auch in Production false, da wir HTTPS über Apache terminieren - sameSite: 'lax', - maxAge: 60 * 60 * 24 * 7 // 7 days + ...getAuthCookieOptions() }) // Migriere Rollen falls nötig diff --git a/server/api/auth/passkeys/authentication-options.post.js b/server/api/auth/passkeys/authentication-options.post.js new file mode 100644 index 0000000..63ee93f --- /dev/null +++ b/server/api/auth/passkeys/authentication-options.post.js @@ -0,0 +1,19 @@ +import { generateAuthenticationOptions } from '@simplewebauthn/server' +import { getWebAuthnConfig } from '../../../utils/webauthn-config.js' +import { setAuthChallenge } from '../../../utils/webauthn-challenges.js' + +export default defineEventHandler(async (_event) => { + const { rpId } = getWebAuthnConfig() + + // Username-less / discoverable credentials: allowCredentials absichtlich leer + const options = await generateAuthenticationOptions({ + rpID: rpId, + userVerification: 'preferred' + }) + + setAuthChallenge(options.challenge) + + return { success: true, options } +}) + + diff --git a/server/api/auth/passkeys/list.get.js b/server/api/auth/passkeys/list.get.js new file mode 100644 index 0000000..a2db2a5 --- /dev/null +++ b/server/api/auth/passkeys/list.get.js @@ -0,0 +1,27 @@ +import { getUserFromToken } from '../../../utils/auth.js' + +export default defineEventHandler(async (event) => { + const token = getCookie(event, 'auth_token') + const user = token ? await getUserFromToken(token) : null + + if (!user) { + throw createError({ statusCode: 401, statusMessage: 'Nicht authentifiziert' }) + } + + const passkeys = Array.isArray(user.passkeys) ? user.passkeys : [] + + return { + success: true, + passkeys: passkeys.map(pk => ({ + id: pk.id, + name: pk.name || 'Passkey', + credentialId: pk.credentialId, + createdAt: pk.createdAt || null, + lastUsedAt: pk.lastUsedAt || null, + deviceType: pk.deviceType || null, + backedUp: pk.backedUp ?? null + })) + } +}) + + diff --git a/server/api/auth/passkeys/login.post.js b/server/api/auth/passkeys/login.post.js new file mode 100644 index 0000000..e5ac11d --- /dev/null +++ b/server/api/auth/passkeys/login.post.js @@ -0,0 +1,100 @@ +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' + } +}) + + diff --git a/server/api/auth/passkeys/register.post.js b/server/api/auth/passkeys/register.post.js new file mode 100644 index 0000000..2c49a01 --- /dev/null +++ b/server/api/auth/passkeys/register.post.js @@ -0,0 +1,93 @@ +import { verifyRegistrationResponse } from '@simplewebauthn/server' +import { getUserFromToken, readUsers, writeUsers } from '../../../utils/auth.js' +import { getWebAuthnConfig } from '../../../utils/webauthn-config.js' +import { clearRegistrationChallenge, getRegistrationChallenge } from '../../../utils/webauthn-challenges.js' +import { toBase64Url } from '../../../utils/webauthn-encoding.js' +import { writeAuditLog } from '../../../utils/audit-log.js' + +export default defineEventHandler(async (event) => { + const token = getCookie(event, 'auth_token') + const user = token ? await getUserFromToken(token) : null + + if (!user) { + throw createError({ statusCode: 401, statusMessage: 'Nicht authentifiziert' }) + } + + const body = await readBody(event) + const response = body?.credential + if (!response) { + throw createError({ statusCode: 400, statusMessage: 'Credential fehlt' }) + } + + const expectedChallenge = getRegistrationChallenge(user.id) + if (!expectedChallenge) { + throw createError({ statusCode: 400, statusMessage: 'Registrierungs-Session abgelaufen. Bitte erneut versuchen.' }) + } + + const { origin, rpId, requireUV } = getWebAuthnConfig() + + let verification + try { + verification = await verifyRegistrationResponse({ + response, + expectedChallenge, + expectedOrigin: origin, + expectedRPID: rpId, + requireUserVerification: requireUV + }) + } finally { + clearRegistrationChallenge(user.id) + } + + const { verified, registrationInfo } = verification + if (!verified || !registrationInfo) { + await writeAuditLog('auth.passkey.registration.failed', { userId: user.id }) + throw createError({ statusCode: 400, statusMessage: 'Passkey-Registrierung fehlgeschlagen' }) + } + + const { + credentialID, + credentialPublicKey, + counter, + credentialDeviceType, + credentialBackedUp + } = registrationInfo + + const credentialId = toBase64Url(credentialID) + const publicKey = toBase64Url(credentialPublicKey) + + const users = await readUsers() + const idx = users.findIndex(u => u.id === user.id) + if (idx === -1) { + throw createError({ statusCode: 404, statusMessage: 'Benutzer nicht gefunden' }) + } + + const u = users[idx] + if (!Array.isArray(u.passkeys)) u.passkeys = [] + + // Duplikate verhindern + if (u.passkeys.some(pk => pk.credentialId === credentialId)) { + return { success: true, message: 'Passkey ist bereits registriert.' } + } + + u.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: body?.name ? String(body.name).slice(0, 80) : 'Passkey' + }) + + users[idx] = u + await writeUsers(users) + + await writeAuditLog('auth.passkey.registered', { userId: user.id }) + return { success: true, message: 'Passkey hinzugefügt.' } +}) + + diff --git a/server/api/auth/passkeys/registration-options.post.js b/server/api/auth/passkeys/registration-options.post.js new file mode 100644 index 0000000..208674c --- /dev/null +++ b/server/api/auth/passkeys/registration-options.post.js @@ -0,0 +1,51 @@ +import { generateRegistrationOptions } from '@simplewebauthn/server' +import { getUserFromToken, hasAnyRole } from '../../../utils/auth.js' +import { getWebAuthnConfig } from '../../../utils/webauthn-config.js' +import { setRegistrationChallenge } from '../../../utils/webauthn-challenges.js' +import { writeAuditLog } from '../../../utils/audit-log.js' + +export default defineEventHandler(async (event) => { + const token = getCookie(event, 'auth_token') + const user = token ? await getUserFromToken(token) : null + + if (!user) { + throw createError({ statusCode: 401, statusMessage: 'Nicht authentifiziert' }) + } + + // Mindestens für Admin/Vorstand anbieten (und auch für Mitglieder ok) + if (!hasAnyRole(user, 'admin', 'vorstand', 'mitglied', 'newsletter')) { + throw createError({ statusCode: 403, statusMessage: 'Keine Berechtigung' }) + } + + const { rpId, rpName } = getWebAuthnConfig() + + const existing = Array.isArray(user.passkeys) ? user.passkeys : [] + const excludeCredentials = existing + .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: String(user.id), + userName: user.email, + // Keine Attestation-Daten speichern + attestationType: 'none', + authenticatorSelection: { + residentKey: 'preferred', + userVerification: 'preferred' + }, + excludeCredentials + }) + + setRegistrationChallenge(user.id, options.challenge) + await writeAuditLog('auth.passkey.registration.options', { userId: user.id }) + + return { success: true, options } +}) + + diff --git a/server/api/auth/passkeys/remove.post.js b/server/api/auth/passkeys/remove.post.js new file mode 100644 index 0000000..53ad72c --- /dev/null +++ b/server/api/auth/passkeys/remove.post.js @@ -0,0 +1,39 @@ +import { getUserFromToken, readUsers, writeUsers } from '../../../utils/auth.js' +import { writeAuditLog } from '../../../utils/audit-log.js' + +export default defineEventHandler(async (event) => { + const token = getCookie(event, 'auth_token') + const currentUser = token ? await getUserFromToken(token) : null + + if (!currentUser) { + throw createError({ statusCode: 401, statusMessage: 'Nicht authentifiziert' }) + } + + const body = await readBody(event) + const credentialId = String(body?.credentialId || '') + if (!credentialId) { + throw createError({ statusCode: 400, statusMessage: 'credentialId fehlt' }) + } + + const users = await readUsers() + const idx = users.findIndex(u => u.id === currentUser.id) + if (idx === -1) { + throw createError({ statusCode: 404, statusMessage: 'Benutzer nicht gefunden' }) + } + + const user = users[idx] + const before = Array.isArray(user.passkeys) ? user.passkeys.length : 0 + user.passkeys = (Array.isArray(user.passkeys) ? user.passkeys : []).filter(pk => pk.credentialId !== credentialId) + const after = user.passkeys.length + users[idx] = user + await writeUsers(users) + + await writeAuditLog('auth.passkey.removed', { userId: currentUser.id }) + + return { + success: true, + removed: before !== after + } +}) + + diff --git a/server/api/auth/register.post.js b/server/api/auth/register.post.js index cfb287f..6a90b58 100644 --- a/server/api/auth/register.post.js +++ b/server/api/auth/register.post.js @@ -1,5 +1,6 @@ import { readUsers, writeUsers, hashPassword } from '../../utils/auth.js' import nodemailer from 'nodemailer' +import { assertPasswordNotPwned } from '../../utils/hibp.js' export default defineEventHandler(async (event) => { try { @@ -21,6 +22,9 @@ export default defineEventHandler(async (event) => { }) } + // Optional: Passwort gegen HIBP (k-Anonymity) prüfen + await assertPasswordNotPwned(password) + // Check if user already exists const users = await readUsers() const existingUser = users.find(u => u.email.toLowerCase() === email.toLowerCase()) diff --git a/server/api/auth/reset-password.post.js b/server/api/auth/reset-password.post.js index 997975e..4cb4aef 100644 --- a/server/api/auth/reset-password.post.js +++ b/server/api/auth/reset-password.post.js @@ -1,6 +1,8 @@ import { readUsers, hashPassword, writeUsers } from '../../utils/auth.js' import nodemailer from 'nodemailer' import crypto from 'crypto' +import { assertRateLimit, getClientIp, registerRateLimitFailure, registerRateLimitSuccess } from '../../utils/rate-limit.js' +import { writeAuditLog } from '../../utils/audit-log.js' export default defineEventHandler(async (event) => { try { @@ -14,12 +16,34 @@ export default defineEventHandler(async (event) => { }) } + const ip = getClientIp(event) + const emailKey = String(email || '').trim().toLowerCase() + + // Rate Limiting (IP + Account) + assertRateLimit(event, { + name: 'auth:reset:ip', + keyParts: [ip], + windowMs: 60 * 60 * 1000, + maxAttempts: 20, + lockoutMs: 30 * 60 * 1000 + }) + assertRateLimit(event, { + name: 'auth:reset:account', + keyParts: [emailKey], + windowMs: 60 * 60 * 1000, + maxAttempts: 5, + lockoutMs: 60 * 60 * 1000 + }) + // Find user const users = await readUsers() const user = users.find(u => u.email.toLowerCase() === email.toLowerCase()) // Always return success (security: don't reveal if email exists) if (!user) { + await registerRateLimitFailure(event, { name: 'auth:reset:ip', keyParts: [ip] }) + await registerRateLimitFailure(event, { name: 'auth:reset:account', keyParts: [emailKey] }) + await writeAuditLog('auth.reset.request', { ip, email: emailKey, userFound: false }) return { success: true, message: 'Falls ein Konto mit dieser E-Mail existiert, wurde eine E-Mail gesendet.' @@ -36,6 +60,9 @@ export default defineEventHandler(async (event) => { const updatedUsers = users.map(u => u.id === user.id ? user : u) await writeUsers(updatedUsers) + registerRateLimitSuccess(event, { name: 'auth:reset:account', keyParts: [emailKey] }) + await writeAuditLog('auth.reset.request', { ip, email: emailKey, userFound: true, userId: user.id }) + // Send email with temporary password const smtpUser = process.env.SMTP_USER const smtpPass = process.env.SMTP_PASS diff --git a/server/api/cms/satzung-upload.post.js b/server/api/cms/satzung-upload.post.js index 9fa7b1c..737e7ee 100644 --- a/server/api/cms/satzung-upload.post.js +++ b/server/api/cms/satzung-upload.post.js @@ -4,6 +4,7 @@ import path from 'path' import { exec } from 'child_process' import { promisify } from 'util' import { getUserFromToken, hasAnyRole } from '../../utils/auth.js' +import { assertPdfMagicHeader } from '../../utils/upload-validation.js' const execAsync = promisify(exec) @@ -74,6 +75,8 @@ export default defineEventHandler(async (event) => { } try { + await fs.mkdir(path.join(process.cwd(), 'public', 'documents'), { recursive: true }) + // Multer-Middleware für File-Upload await new Promise((resolve, reject) => { upload.single('pdf')(event.node.req, event.node.res, (err) => { @@ -90,6 +93,9 @@ export default defineEventHandler(async (event) => { }) } + // Zusätzliche Validierung: Magic-Bytes prüfen (mimetype kann gespooft sein) + await assertPdfMagicHeader(file.path) + // PDF-Text extrahieren mit pdftotext (falls verfügbar) oder Fallback let extractedText = '' diff --git a/server/api/cms/upload-spielplan-pdf.post.js b/server/api/cms/upload-spielplan-pdf.post.js index dfa8352..d4df552 100644 --- a/server/api/cms/upload-spielplan-pdf.post.js +++ b/server/api/cms/upload-spielplan-pdf.post.js @@ -2,6 +2,7 @@ import multer from 'multer' import fs from 'fs/promises' import path from 'path' import { getUserFromToken, hasAnyRole } from '../../utils/auth.js' +import { assertPdfMagicHeader } from '../../utils/upload-validation.js' // Multer-Konfiguration für PDF-Uploads const storage = multer.diskStorage({ @@ -10,8 +11,10 @@ const storage = multer.diskStorage({ cb(null, uploadPath) }, filename: (req, file, cb) => { - const type = req.body.type - const filename = `spielplan_${type}.pdf` + // WICHTIG: Validieren, bevor der Dateiname gebaut wird (sonst Pfad-/Filename-Injection möglich) + const type = String(req.body?.type || '').trim() + const safeType = ['gesamt', 'erwachsene', 'nachwuchs'].includes(type) ? type : 'invalid' + const filename = `spielplan_${safeType}.pdf` cb(null, filename) } }) @@ -85,6 +88,8 @@ export default defineEventHandler(async (event) => { }) } + await assertPdfMagicHeader(file.path) + if (!type || !['gesamt', 'erwachsene', 'nachwuchs'].includes(type)) { // Lösche die hochgeladene Datei await fs.unlink(file.path) diff --git a/server/api/cms/users/approve.post.js b/server/api/cms/users/approve.post.js index 0cf92d1..abfff72 100644 --- a/server/api/cms/users/approve.post.js +++ b/server/api/cms/users/approve.post.js @@ -1,5 +1,6 @@ import { getUserFromToken, readUsers, writeUsers, hasAnyRole, migrateUserRoles } from '../../../utils/auth.js' import nodemailer from 'nodemailer' +import { writeAuditLog } from '../../../utils/audit-log.js' export default defineEventHandler(async (event) => { try { @@ -41,6 +42,12 @@ export default defineEventHandler(async (event) => { const updatedUsers = users.map(u => u.id === userId ? user : u) await writeUsers(updatedUsers) + await writeAuditLog('cms.user.approved', { + actorUserId: currentUser.id, + targetUserId: userId, + roles: user.roles + }) + // Send approval email try { const smtpUser = process.env.SMTP_USER diff --git a/server/api/cms/users/deactivate.post.js b/server/api/cms/users/deactivate.post.js index 21edec0..0e0760b 100644 --- a/server/api/cms/users/deactivate.post.js +++ b/server/api/cms/users/deactivate.post.js @@ -1,4 +1,5 @@ import { getUserFromToken, readUsers, writeUsers, hasAnyRole } from '../../../utils/auth.js' +import { writeAuditLog } from '../../../utils/audit-log.js' export default defineEventHandler(async (event) => { try { @@ -36,6 +37,11 @@ export default defineEventHandler(async (event) => { const updatedUsers = users.map(u => u.id === userId ? user : u) await writeUsers(updatedUsers) + await writeAuditLog('cms.user.deactivated', { + actorUserId: currentUser.id, + targetUserId: userId + }) + return { success: true, message: 'Benutzer wurde deaktiviert' diff --git a/server/api/cms/users/reject.post.js b/server/api/cms/users/reject.post.js index c6bfdf3..f2830a5 100644 --- a/server/api/cms/users/reject.post.js +++ b/server/api/cms/users/reject.post.js @@ -1,4 +1,5 @@ import { getUserFromToken, readUsers, writeUsers, hasAnyRole } from '../../../utils/auth.js' +import { writeAuditLog } from '../../../utils/audit-log.js' export default defineEventHandler(async (event) => { try { @@ -20,6 +21,11 @@ export default defineEventHandler(async (event) => { await writeUsers(updatedUsers) + await writeAuditLog('cms.user.rejected', { + actorUserId: currentUser.id, + targetUserId: userId + }) + return { success: true, message: 'Registrierung wurde abgelehnt und gelöscht' diff --git a/server/api/cms/users/update-role.post.js b/server/api/cms/users/update-role.post.js index 7f63303..c6fe6d8 100644 --- a/server/api/cms/users/update-role.post.js +++ b/server/api/cms/users/update-role.post.js @@ -1,4 +1,5 @@ import { getUserFromToken, readUsers, writeUsers, hasAnyRole, migrateUserRoles } from '../../../utils/auth.js' +import { writeAuditLog } from '../../../utils/audit-log.js' export default defineEventHandler(async (event) => { try { @@ -39,10 +40,18 @@ export default defineEventHandler(async (event) => { migrateUserRoles(user) // Setze Rollen + const oldRoles = Array.isArray(user.roles) ? [...user.roles] : [] user.roles = rolesArray const updatedUsers = users.map(u => u.id === userId ? user : u) await writeUsers(updatedUsers) + await writeAuditLog('cms.user.roles.updated', { + actorUserId: currentUser.id, + targetUserId: userId, + oldRoles, + newRoles: rolesArray + }) + return { success: true, message: 'Rolle wurde aktualisiert' diff --git a/server/api/galerie/upload.post.js b/server/api/galerie/upload.post.js index fa7be19..3a569da 100644 --- a/server/api/galerie/upload.post.js +++ b/server/api/galerie/upload.post.js @@ -4,6 +4,7 @@ import path from 'path' import sharp from 'sharp' import { getUserFromToken, verifyToken, hasAnyRole } from '../../utils/auth.js' import { randomUUID } from 'crypto' +import { clamp } from '../../utils/upload-validation.js' // Handle both dev and production paths // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal @@ -155,10 +156,36 @@ export default defineEventHandler(async (event) => { const filename = `${titleSlug}_${randomUUID().substring(0, 8)}${ext}` const previewFilename = `preview_${filename}` - // Verschiebe die Datei zum neuen Namen + // Originalbild verarbeiten: re-encode (EXIF entfernen) + Pixel/DIM-Limit // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal const originalPath = path.join(GALERIE_DIR, 'originals', filename) - await fs.rename(file.path, originalPath) + + const maxPixels = Number(process.env.IMAGE_MAX_PIXELS || 20_000_000) // 20MP + const maxDim = Number(process.env.IMAGE_MAX_DIM || 4000) // px + + const meta = await sharp(file.path).metadata() + const w = meta.width || 0 + const h = meta.height || 0 + + let pipeline = sharp(file.path).rotate() + + if (w > 0 && h > 0) { + const pixels = w * h + if (pixels > maxPixels) { + const scale = Math.sqrt(maxPixels / pixels) + const nw = clamp(Math.floor(w * scale), 1, maxDim) + const nh = clamp(Math.floor(h * scale), 1, maxDim) + pipeline = pipeline.resize(nw, nh, { fit: 'inside', withoutEnlargement: true }) + } else if (w > maxDim || h > maxDim) { + pipeline = pipeline.resize(maxDim, maxDim, { fit: 'inside', withoutEnlargement: true }) + } + } else { + pipeline = pipeline.resize(maxDim, maxDim, { fit: 'inside', withoutEnlargement: true }) + } + + await pipeline.toFile(originalPath) + // Temp-Datei löschen + await fs.unlink(file.path).catch(() => {}) // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal const previewPath = path.join(GALERIE_DIR, 'previews', previewFilename) diff --git a/server/api/membership/generate-pdf.post.js b/server/api/membership/generate-pdf.post.js index 53bab59..b7c36f0 100644 --- a/server/api/membership/generate-pdf.post.js +++ b/server/api/membership/generate-pdf.post.js @@ -4,6 +4,7 @@ import { promisify } from 'util' import fs from 'fs/promises' import path from 'path' import { StandardFonts } from 'pdf-lib' +import { getDownloadCookieOptionsWithMaxAge } from '../../utils/cookies.js' // const require = createRequire(import.meta.url) // Nicht verwendet const execAsync = promisify(exec) @@ -702,10 +703,7 @@ export default defineEventHandler(async (event) => { // Download-Token setzen const downloadToken = Buffer.from(`${filename}:${Date.now()}`).toString('base64') setCookie(event, 'download_token', downloadToken, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'strict', - maxAge: 60 * 60 * 24 // 24 Stunden + ...getDownloadCookieOptionsWithMaxAge(60 * 60 * 24) }) return { @@ -775,10 +773,7 @@ export default defineEventHandler(async (event) => { // Download-Berechtigung für den Antragsteller setzen const downloadToken = Buffer.from(`${filename}:${Date.now()}`).toString('base64') setCookie(event, 'download_token', downloadToken, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'strict', - maxAge: 60 * 60 * 24 // 24 Stunden + ...getDownloadCookieOptionsWithMaxAge(60 * 60 * 24) }) return { diff --git a/server/api/newsletter/check-subscription.get.js b/server/api/newsletter/check-subscription.get.js index d2039d9..88b8de6 100644 --- a/server/api/newsletter/check-subscription.get.js +++ b/server/api/newsletter/check-subscription.get.js @@ -1,4 +1,5 @@ import { readSubscribers } from '../../utils/newsletter.js' +import { assertRateLimit, getClientIp } from '../../utils/rate-limit.js' export default defineEventHandler(async (event) => { try { @@ -12,6 +13,23 @@ export default defineEventHandler(async (event) => { }) } + const ip = getClientIp(event) + const emailKey = String(email || '').trim().toLowerCase() + assertRateLimit(event, { + name: 'newsletter:check:ip', + keyParts: [ip], + windowMs: 10 * 60 * 1000, + maxAttempts: 60, + lockoutMs: 10 * 60 * 1000 + }) + assertRateLimit(event, { + name: 'newsletter:check:email', + keyParts: [emailKey], + windowMs: 10 * 60 * 1000, + maxAttempts: 30, + lockoutMs: 10 * 60 * 1000 + }) + const subscribers = await readSubscribers() const emailLower = email.toLowerCase() diff --git a/server/api/newsletter/subscribe.post.js b/server/api/newsletter/subscribe.post.js index c399c00..99a0b8b 100644 --- a/server/api/newsletter/subscribe.post.js +++ b/server/api/newsletter/subscribe.post.js @@ -4,6 +4,7 @@ import nodemailer from 'nodemailer' import crypto from 'crypto' import fs from 'fs/promises' import path from 'path' +import { assertRateLimit, getClientIp, registerRateLimitFailure, registerRateLimitSuccess } from '../../utils/rate-limit.js' // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal // filename is always a hardcoded constant (e.g., 'newsletter-subscribers.json'), never user input @@ -43,6 +44,23 @@ export default defineEventHandler(async (event) => { }) } + const ip = getClientIp(event) + const emailKey = String(email || '').trim().toLowerCase() + assertRateLimit(event, { + name: 'newsletter:subscribe:ip', + keyParts: [ip], + windowMs: 10 * 60 * 1000, + maxAttempts: 30, + lockoutMs: 15 * 60 * 1000 + }) + assertRateLimit(event, { + name: 'newsletter:subscribe:email', + keyParts: [emailKey], + windowMs: 10 * 60 * 1000, + maxAttempts: 8, + lockoutMs: 30 * 60 * 1000 + }) + if (!groupId) { throw createError({ statusCode: 400, @@ -79,6 +97,8 @@ export default defineEventHandler(async (event) => { if (existing) { if (existing.confirmed) { + await registerRateLimitFailure(event, { name: 'newsletter:subscribe:ip', keyParts: [ip] }) + await registerRateLimitFailure(event, { name: 'newsletter:subscribe:email', keyParts: [emailKey] }) throw createError({ statusCode: 409, statusMessage: 'Sie sind bereits für diesen Newsletter angemeldet' @@ -86,6 +106,7 @@ export default defineEventHandler(async (event) => { } else { // Bestätigungsmail erneut senden await sendConfirmationEmail(existing.email, existing.name || name, existing.confirmationToken, group.name) + registerRateLimitSuccess(event, { name: 'newsletter:subscribe:email', keyParts: [emailKey] }) return { success: true, message: 'Eine Bestätigungsmail wurde an Ihre E-Mail-Adresse gesendet' @@ -105,6 +126,8 @@ export default defineEventHandler(async (event) => { if (existingEmail.groupIds.includes(groupId)) { // Bereits für diese Gruppe angemeldet if (existingEmail.confirmed) { + await registerRateLimitFailure(event, { name: 'newsletter:subscribe:ip', keyParts: [ip] }) + await registerRateLimitFailure(event, { name: 'newsletter:subscribe:email', keyParts: [emailKey] }) throw createError({ statusCode: 409, statusMessage: 'Sie sind bereits für diesen Newsletter angemeldet' @@ -112,6 +135,7 @@ export default defineEventHandler(async (event) => { } else { // Bestätigungsmail erneut senden await sendConfirmationEmail(existingEmail.email, existingEmail.name || name, existingEmail.confirmationToken, group.name) + registerRateLimitSuccess(event, { name: 'newsletter:subscribe:email', keyParts: [emailKey] }) return { success: true, message: 'Eine Bestätigungsmail wurde an Ihre E-Mail-Adresse gesendet' @@ -131,6 +155,7 @@ export default defineEventHandler(async (event) => { if (existingEmail.confirmed) { // Bereits bestätigt - sofort aktiviert + registerRateLimitSuccess(event, { name: 'newsletter:subscribe:email', keyParts: [emailKey] }) return { success: true, message: `Sie wurden erfolgreich für den Newsletter "${group.name}" angemeldet` @@ -138,6 +163,7 @@ export default defineEventHandler(async (event) => { } else { // Bestätigungsmail senden await sendConfirmationEmail(existingEmail.email, existingEmail.name, existingEmail.confirmationToken, group.name) + registerRateLimitSuccess(event, { name: 'newsletter:subscribe:email', keyParts: [emailKey] }) return { success: true, message: 'Eine Bestätigungsmail wurde an Ihre E-Mail-Adresse gesendet. Bitte bestätigen Sie Ihre Anmeldung.' @@ -167,6 +193,7 @@ export default defineEventHandler(async (event) => { // Bestätigungsmail senden await sendConfirmationEmail(email, name, confirmationToken, group.name) + registerRateLimitSuccess(event, { name: 'newsletter:subscribe:email', keyParts: [emailKey] }) return { success: true, message: 'Eine Bestätigungsmail wurde an Ihre E-Mail-Adresse gesendet. Bitte bestätigen Sie Ihre Anmeldung.' diff --git a/server/api/newsletter/unsubscribe-by-email.post.js b/server/api/newsletter/unsubscribe-by-email.post.js index 4494fc0..3812515 100644 --- a/server/api/newsletter/unsubscribe-by-email.post.js +++ b/server/api/newsletter/unsubscribe-by-email.post.js @@ -1,6 +1,7 @@ import { readSubscribers, writeSubscribers } from '../../utils/newsletter.js' import fs from 'fs/promises' import path from 'path' +import { assertRateLimit, getClientIp, registerRateLimitFailure, registerRateLimitSuccess } from '../../utils/rate-limit.js' // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal // filename is always a hardcoded constant (e.g., 'newsletter-subscribers.json'), never user input @@ -40,6 +41,23 @@ export default defineEventHandler(async (event) => { }) } + const ip = getClientIp(event) + const emailKey = String(email || '').trim().toLowerCase() + assertRateLimit(event, { + name: 'newsletter:unsubscribe:ip', + keyParts: [ip], + windowMs: 10 * 60 * 1000, + maxAttempts: 30, + lockoutMs: 15 * 60 * 1000 + }) + assertRateLimit(event, { + name: 'newsletter:unsubscribe:email', + keyParts: [emailKey], + windowMs: 10 * 60 * 1000, + maxAttempts: 8, + lockoutMs: 30 * 60 * 1000 + }) + if (!groupId) { throw createError({ statusCode: 400, @@ -75,6 +93,8 @@ export default defineEventHandler(async (event) => { if (!subscriber) { // Nicht gefunden - aber trotzdem Erfolg zurückgeben (keine Information preisgeben) + await registerRateLimitFailure(event, { name: 'newsletter:unsubscribe:ip', keyParts: [ip] }) + await registerRateLimitFailure(event, { name: 'newsletter:unsubscribe:email', keyParts: [emailKey] }) return { success: true, message: 'Sie wurden erfolgreich vom Newsletter abgemeldet' @@ -89,6 +109,8 @@ export default defineEventHandler(async (event) => { // Prüfe ob für diese Gruppe angemeldet if (!subscriber.groupIds.includes(groupId)) { // Nicht für diese Gruppe angemeldet - aber trotzdem Erfolg zurückgeben + await registerRateLimitFailure(event, { name: 'newsletter:unsubscribe:ip', keyParts: [ip] }) + await registerRateLimitFailure(event, { name: 'newsletter:unsubscribe:email', keyParts: [emailKey] }) return { success: true, message: 'Sie wurden erfolgreich vom Newsletter abgemeldet' @@ -107,6 +129,7 @@ export default defineEventHandler(async (event) => { await writeSubscribers(subscribers) + registerRateLimitSuccess(event, { name: 'newsletter:unsubscribe:email', keyParts: [emailKey] }) return { success: true, message: 'Sie wurden erfolgreich vom Newsletter abgemeldet' diff --git a/server/api/newsletter/unsubscribe.get.js b/server/api/newsletter/unsubscribe.get.js index 629fec4..75ce850 100644 --- a/server/api/newsletter/unsubscribe.get.js +++ b/server/api/newsletter/unsubscribe.get.js @@ -1,4 +1,5 @@ import { readSubscribers, writeSubscribers } from '../../utils/newsletter.js' +import { assertRateLimit, getClientIp, registerRateLimitFailure, registerRateLimitSuccess } from '../../utils/rate-limit.js' export default defineEventHandler(async (event) => { try { @@ -12,10 +13,28 @@ export default defineEventHandler(async (event) => { }) } + const ip = getClientIp(event) + const tokenKey = String(token || '').trim() + assertRateLimit(event, { + name: 'newsletter:unsubscribe-token:ip', + keyParts: [ip], + windowMs: 10 * 60 * 1000, + maxAttempts: 60, + lockoutMs: 10 * 60 * 1000 + }) + assertRateLimit(event, { + name: 'newsletter:unsubscribe-token:token', + keyParts: [tokenKey], + windowMs: 10 * 60 * 1000, + maxAttempts: 10, + lockoutMs: 30 * 60 * 1000 + }) + const subscribers = await readSubscribers() const subscriber = subscribers.find(s => s.unsubscribeToken === token) if (!subscriber) { + await registerRateLimitFailure(event, { name: 'newsletter:unsubscribe-token:token', keyParts: [tokenKey] }) throw createError({ statusCode: 404, statusMessage: 'Ungültiger Abmeldetoken' @@ -40,6 +59,7 @@ export default defineEventHandler(async (event) => { await writeSubscribers(subscribers) + registerRateLimitSuccess(event, { name: 'newsletter:unsubscribe-token:token', keyParts: [tokenKey] }) // Weiterleitung zur Abmelde-Bestätigungsseite return sendRedirect(event, '/newsletter/unsubscribed') } catch (error) { diff --git a/server/api/personen/upload.post.js b/server/api/personen/upload.post.js index 886341f..81a2bb6 100644 --- a/server/api/personen/upload.post.js +++ b/server/api/personen/upload.post.js @@ -4,6 +4,7 @@ import path from 'path' import sharp from 'sharp' import { getUserFromToken, verifyToken, hasAnyRole } from '../../utils/auth.js' import { randomUUID } from 'crypto' +import { clamp } from '../../utils/upload-validation.js' // Handle both dev and production paths // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal @@ -125,9 +126,33 @@ export default defineEventHandler(async (event) => { const newPath = path.join(PERSONEN_DIR, sanitizedFilename) // Bild verarbeiten: EXIF-Orientierung korrigieren - await sharp(originalPath) - .rotate() - .toFile(newPath) + const maxPixels = Number(process.env.IMAGE_MAX_PIXELS || 20_000_000) // 20MP + const maxDim = Number(process.env.IMAGE_MAX_DIM || 2500) // px + + const meta = await sharp(originalPath).metadata() + const w = meta.width || 0 + const h = meta.height || 0 + + let pipeline = sharp(originalPath).rotate() + + if (w > 0 && h > 0) { + const pixels = w * h + // Falls extrem groß: auf maxPixels runter skalieren + if (pixels > maxPixels) { + const scale = Math.sqrt(maxPixels / pixels) + const nw = clamp(Math.floor(w * scale), 1, maxDim) + const nh = clamp(Math.floor(h * scale), 1, maxDim) + pipeline = pipeline.resize(nw, nh, { fit: 'inside', withoutEnlargement: true }) + } else if (w > maxDim || h > maxDim) { + pipeline = pipeline.resize(maxDim, maxDim, { fit: 'inside', withoutEnlargement: true }) + } + } else { + // Unbekannte Dimensionen: dennoch hartes Größenlimit + pipeline = pipeline.resize(maxDim, maxDim, { fit: 'inside', withoutEnlargement: true }) + } + + // toFile re-encodiert => EXIF/Metadata wird entfernt (sofern nicht withMetadata() genutzt wird) + await pipeline.toFile(newPath) // Temporäre Datei löschen await fs.unlink(originalPath).catch(() => { diff --git a/server/api/profile.put.js b/server/api/profile.put.js index fb520f9..27b7238 100644 --- a/server/api/profile.put.js +++ b/server/api/profile.put.js @@ -1,4 +1,5 @@ import { verifyToken, readUsers, writeUsers, verifyPassword, hashPassword, migrateUserRoles } from '../utils/auth.js' +import { assertPasswordNotPwned } from '../utils/hibp.js' export default defineEventHandler(async (event) => { try { @@ -75,6 +76,7 @@ export default defineEventHandler(async (event) => { }) } + await assertPasswordNotPwned(newPassword) user.password = await hashPassword(newPassword) } diff --git a/server/api/spielplan/pdf.get.js b/server/api/spielplan/pdf.get.js index 90fb70e..d92867a 100644 --- a/server/api/spielplan/pdf.get.js +++ b/server/api/spielplan/pdf.get.js @@ -394,10 +394,10 @@ ${hallenListe.map(halle => { setHeader(event, 'Content-Disposition', `attachment; filename="spielplan_${team}.pdf"`) setHeader(event, 'Content-Length', pdfBuffer.length.toString()) - // Füge Sicherheits-Header hinzu + // Füge Sicherheits-Header hinzu (global auch via server/middleware/security-headers.js) setHeader(event, 'X-Content-Type-Options', 'nosniff') - setHeader(event, 'X-Frame-Options', 'DENY') - setHeader(event, 'X-XSS-Protection', '1; mode=block') + setHeader(event, 'X-Frame-Options', 'SAMEORIGIN') + setHeader(event, 'X-XSS-Protection', '0') setHeader(event, 'Cache-Control', 'no-cache, no-store, must-revalidate') setHeader(event, 'Pragma', 'no-cache') setHeader(event, 'Expires', '0') diff --git a/server/middleware/security-headers.js b/server/middleware/security-headers.js new file mode 100644 index 0000000..7e4b9e0 --- /dev/null +++ b/server/middleware/security-headers.js @@ -0,0 +1,50 @@ +/** + * Globale Security-Header für Nitro (Nuxt 3). + * + * Apache setzt ggf. bereits Header – diese Middleware dient als "Default", + * damit die App auch ohne Reverse-Proxy sauber gehärtet ist. + * + * CSP ist optional und sollte zuerst im Report-Only Modus getestet werden. + * Siehe ENV: + * - CSP_ENABLED=true|false + * - CSP_REPORT_ONLY=true|false + * - CSP_VALUE="..." + */ + +export default defineEventHandler((event) => { + // Grundsätzlich sinnvolle Header + setHeader(event, 'X-Content-Type-Options', 'nosniff') + setHeader(event, 'Referrer-Policy', 'strict-origin-when-cross-origin') + setHeader(event, 'Permissions-Policy', 'geolocation=(), microphone=(), camera=()') + + // X-Frame-Options: SAMEORIGIN (DENY wäre strenger, verhindert aber iFrames komplett) + setHeader(event, 'X-Frame-Options', 'SAMEORIGIN') + + // Legacy-Header (optional; moderne Browser verlassen sich primär auf CSP) + setHeader(event, 'X-XSS-Protection', '0') + + // Optional: CSP + const cspEnabled = (process.env.CSP_ENABLED || '').toLowerCase() === 'true' + if (cspEnabled) { + const reportOnly = (process.env.CSP_REPORT_ONLY || 'true').toLowerCase() !== 'false' + const cspValue = + process.env.CSP_VALUE || + [ + "default-src 'self'", + "base-uri 'self'", + "object-src 'none'", + "frame-ancestors 'self'", + // Nuxt lädt Fonts ggf. von Google (siehe nuxt.config.js) + "font-src 'self' https://fonts.gstatic.com data:", + "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com", + // Script: Nuxt kann in Dev eval nutzen; diese CSP ist primär für Produktion gedacht. + "script-src 'self'", + "img-src 'self' data: blob:", + "connect-src 'self'" + ].join('; ') + + setHeader(event, reportOnly ? 'Content-Security-Policy-Report-Only' : 'Content-Security-Policy', cspValue) + } +}) + + diff --git a/server/utils/audit-log.js b/server/utils/audit-log.js new file mode 100644 index 0000000..07974af --- /dev/null +++ b/server/utils/audit-log.js @@ -0,0 +1,35 @@ +import fs from 'fs/promises' +import path from 'path' + +// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal +const getDataPath = (filename) => { + const cwd = process.cwd() + if (cwd.endsWith('.output')) { + // nosemgrep + return path.join(cwd, '../server/data', filename) + } + // nosemgrep + return path.join(cwd, 'server/data', filename) +} + +const AUDIT_LOG_FILE = getDataPath('audit.log.jsonl') + +function safeStr(v, max = 500) { + return String(v == null ? '' : v).slice(0, max) +} + +export async function writeAuditLog(eventType, data = {}) { + const enabled = (process.env.AUDIT_LOG_ENABLED || 'true').toLowerCase() !== 'false' + if (!enabled) return + + const entry = { + ts: new Date().toISOString(), + type: safeStr(eventType, 100), + data + } + + await fs.mkdir(path.dirname(AUDIT_LOG_FILE), { recursive: true }) + await fs.appendFile(AUDIT_LOG_FILE, JSON.stringify(entry) + '\n', 'utf-8') +} + + diff --git a/server/utils/auth.js b/server/utils/auth.js index f25c371..e698615 100644 --- a/server/utils/auth.js +++ b/server/utils/auth.js @@ -117,7 +117,7 @@ export async function readUsers() { } return users - } catch (_error) { + } catch (error) { if (error.code === 'ENOENT') { return [] } @@ -133,7 +133,7 @@ export async function writeUsers(users) { const encryptedData = encryptObject(users, encryptionKey) await fs.writeFile(USERS_FILE, encryptedData, 'utf-8') return true - } catch (_error) { + } catch (error) { console.error('Fehler beim Schreiben der Benutzerdaten:', error) return false } @@ -183,7 +183,7 @@ export async function readSessions() { await writeSessions(sessions) return sessions } - } catch (_error) { + } catch (error) { if (error.code === 'ENOENT') { return [] } @@ -199,7 +199,7 @@ export async function writeSessions(sessions) { const encryptedData = encryptObject(sessions, encryptionKey) await fs.writeFile(SESSIONS_FILE, encryptedData, 'utf-8') return true - } catch (_error) { + } catch (error) { console.error('Fehler beim Schreiben der Sessions:', error) return false } diff --git a/server/utils/cookies.js b/server/utils/cookies.js new file mode 100644 index 0000000..c4c9bf6 --- /dev/null +++ b/server/utils/cookies.js @@ -0,0 +1,46 @@ +function isProduction() { + return process.env.NODE_ENV === 'production' +} + +export function getCookieSecureDefault() { + // In Produktion: immer Secure (auch wenn HTTPS via Apache terminiert). + // In Dev: default false, damit Login über http://localhost funktioniert. + if (process.env.COOKIE_SECURE === 'true') return true + if (process.env.COOKIE_SECURE === 'false') return false + return isProduction() +} + +export function getSameSiteDefault() { + // Erwartung aus Security-Feedback: Strict. In Dev ggf. Lax, damit SSO/Flows nicht nerven. + const v = (process.env.COOKIE_SAMESITE || '').toLowerCase().trim() + if (v === 'strict' || v === 'lax' || v === 'none') return v + return isProduction() ? 'strict' : 'lax' +} + +export function getAuthCookieOptions() { + return { + httpOnly: true, + secure: getCookieSecureDefault(), + sameSite: getSameSiteDefault(), + maxAge: 60 * 60 * 24 * 7 // 7 days + } +} + +export function getDownloadCookieOptions() { + // Download-Token ist kurzlebig; SameSite strict ist ok. + return { + httpOnly: true, + secure: getCookieSecureDefault(), + sameSite: 'strict', + maxAge: 60 * 60 * 24 // 24 Stunden + } +} + +export function getDownloadCookieOptionsWithMaxAge(maxAgeSeconds) { + return { + ...getDownloadCookieOptions(), + maxAge: Number(maxAgeSeconds) || getDownloadCookieOptions().maxAge + } +} + + diff --git a/server/utils/encryption.js b/server/utils/encryption.js index dd0ef3d..739ca51 100644 --- a/server/utils/encryption.js +++ b/server/utils/encryption.js @@ -1,9 +1,16 @@ import crypto from 'crypto' // Verschlüsselungskonfiguration -const ALGORITHM = 'aes-256-cbc' -const IV_LENGTH = 16 +// v1 (legacy): aes-256-cbc (ohne Authentizitätsschutz) +const LEGACY_ALGORITHM = 'aes-256-cbc' +const LEGACY_IV_LENGTH = 16 + +// v2 (default): aes-256-gcm (AEAD, state-of-the-art) +const ALGORITHM = 'aes-256-gcm' +const IV_LENGTH = 12 +const AUTH_TAG_LENGTH = 16 const SALT_LENGTH = 32 +const VERSION_PREFIX = 'v2:' /** * Generiert einen Schlüssel aus einem Passwort und Salt @@ -12,35 +19,87 @@ function deriveKey(password, salt) { return crypto.pbkdf2Sync(password, salt, 100000, 32, 'sha512') } +function encryptV2GCM(text, password) { + // Salt generieren + const salt = crypto.randomBytes(SALT_LENGTH) + + // Schlüssel ableiten + const key = deriveKey(password, salt) + + // IV generieren (12 bytes ist Best Practice für GCM) + const iv = crypto.randomBytes(IV_LENGTH) + + // Cipher erstellen + const cipher = crypto.createCipheriv(ALGORITHM, key, iv) + + // Verschlüsseln + const encrypted = Buffer.concat([ + cipher.update(text, 'utf8'), + cipher.final() + ]) + + // Auth-Tag holen + const tag = cipher.getAuthTag() + + // Salt + IV + Tag + Ciphertext kombinieren + const combined = Buffer.concat([salt, iv, tag, encrypted]) + + return `${VERSION_PREFIX}${combined.toString('base64')}` +} + +function decryptLegacyCBC(encryptedData, password) { + // Base64 dekodieren + const combined = Buffer.from(encryptedData, 'base64') + + // Komponenten extrahieren (v1: salt(32) + iv(16) + ciphertext) + const salt = combined.subarray(0, SALT_LENGTH) + const iv = combined.subarray(SALT_LENGTH, SALT_LENGTH + LEGACY_IV_LENGTH) + const encrypted = combined.subarray(SALT_LENGTH + LEGACY_IV_LENGTH) + + // Schlüssel ableiten + const key = deriveKey(password, salt) + + // Decipher erstellen + const decipher = crypto.createDecipheriv(LEGACY_ALGORITHM, key, iv) + + // Entschlüsseln + const decrypted = Buffer.concat([ + decipher.update(encrypted), + decipher.final() + ]) + + return decrypted.toString('utf8') +} + +function decryptV2GCM(encryptedData, password) { + const b64 = encryptedData.slice(VERSION_PREFIX.length) + const combined = Buffer.from(b64, 'base64') + + // v2: salt(32) + iv(12) + tag(16) + ciphertext + const salt = combined.subarray(0, SALT_LENGTH) + const iv = combined.subarray(SALT_LENGTH, SALT_LENGTH + IV_LENGTH) + const tagStart = SALT_LENGTH + IV_LENGTH + const tag = combined.subarray(tagStart, tagStart + AUTH_TAG_LENGTH) + const encrypted = combined.subarray(tagStart + AUTH_TAG_LENGTH) + + const key = deriveKey(password, salt) + const decipher = crypto.createDecipheriv(ALGORITHM, key, iv) + decipher.setAuthTag(tag) + + const decrypted = Buffer.concat([ + decipher.update(encrypted), + decipher.final() + ]) + + return decrypted.toString('utf8') +} + /** * Verschlüsselt einen Text */ export function encrypt(text, password) { try { - // Salt generieren - const salt = crypto.randomBytes(SALT_LENGTH) - - // Schlüssel ableiten - const key = deriveKey(password, salt) - - // IV generieren - const iv = crypto.randomBytes(IV_LENGTH) - - // Cipher erstellen - const cipher = crypto.createCipheriv(ALGORITHM, key, iv) - - // Verschlüsseln - let encrypted = cipher.update(text, 'utf8', 'hex') - encrypted += cipher.final('hex') - - // Salt + IV + Verschlüsselter Text kombinieren - const combined = Buffer.concat([ - salt, - iv, - Buffer.from(encrypted, 'hex') - ]) - - return combined.toString('base64') + return encryptV2GCM(text, password) } catch (error) { console.error('Verschlüsselungsfehler:', error) throw new Error('Fehler beim Verschlüsseln der Daten') @@ -52,25 +111,12 @@ export function encrypt(text, password) { */ export function decrypt(encryptedData, password) { try { - // Base64 dekodieren - const combined = Buffer.from(encryptedData, 'base64') - - // Komponenten extrahieren - const salt = combined.subarray(0, SALT_LENGTH) - const iv = combined.subarray(SALT_LENGTH, SALT_LENGTH + IV_LENGTH) - const encrypted = combined.subarray(SALT_LENGTH + IV_LENGTH) - - // Schlüssel ableiten - const key = deriveKey(password, salt) - - // Decipher erstellen - const decipher = crypto.createDecipheriv(ALGORITHM, key, iv) - - // Entschlüsseln - let decrypted = decipher.update(encrypted, null, 'utf8') - decrypted += decipher.final('utf8') - - return decrypted + if (typeof encryptedData === 'string' && encryptedData.startsWith(VERSION_PREFIX)) { + return decryptV2GCM(encryptedData, password) + } + + // Fallback: legacy CBC ohne Prefix + return decryptLegacyCBC(encryptedData, password) } catch (error) { console.error('Entschlüsselungsfehler:', error) throw new Error('Fehler beim Entschlüsseln der Daten') diff --git a/server/utils/hibp.js b/server/utils/hibp.js new file mode 100644 index 0000000..5917ea7 --- /dev/null +++ b/server/utils/hibp.js @@ -0,0 +1,95 @@ +import crypto from 'crypto' + +const cache = globalThis.__HTC_HIBP_CACHE__ || new Map() +globalThis.__HTC_HIBP_CACHE__ = cache + +function nowMs() { + return Date.now() +} + +function sha1UpperHex(input) { + return crypto.createHash('sha1').update(String(input), 'utf8').digest('hex').toUpperCase() +} + +function parseRangeResponse(text) { + // Format: "SUFFIX:COUNT" per line + const map = new Map() + for (const line of String(text || '').split('\n')) { + const trimmed = line.trim() + if (!trimmed) continue + const [suffix, count] = trimmed.split(':') + if (suffix && count) map.set(suffix.trim().toUpperCase(), Number(count.trim()) || 0) + } + return map +} + +async function fetchWithTimeout(url, { timeoutMs = 4000, headers = {} } = {}) { + const ctrl = new AbortController() + const t = setTimeout(() => ctrl.abort(), timeoutMs) + try { + return await fetch(url, { headers, signal: ctrl.signal }) + } finally { + clearTimeout(t) + } +} + +/** + * Prüft Passwort gegen HIBP Pwned Passwords (k-Anonymity). + * Gibt zurück: { pwned: boolean, count: number } + */ +export async function checkPasswordPwned(password) { + const enabled = (process.env.HIBP_ENABLED || '').toLowerCase() === 'true' + if (!enabled) return { pwned: false, count: 0 } + + const hash = sha1UpperHex(password) + const prefix = hash.slice(0, 5) + const suffix = hash.slice(5) + + // Cache pro Prefix (TTL) + const ttlMs = Number(process.env.HIBP_CACHE_TTL_MS || 6 * 60 * 60 * 1000) // 6h + const cached = cache.get(prefix) + const now = nowMs() + if (cached && cached.expiresAt > now && cached.map) { + const count = cached.map.get(suffix) || 0 + return { pwned: count > 0, count } + } + + const ua = process.env.HIBP_USER_AGENT || 'harheimertc' + const url = `https://api.pwnedpasswords.com/range/${prefix}` + const res = await fetchWithTimeout(url, { + timeoutMs: Number(process.env.HIBP_TIMEOUT_MS || 4000), + headers: { + 'User-Agent': ua, + // HIBP empfiehlt optional diesen Header für Padding; wir schalten ihn per default ein. + 'Add-Padding': 'true' + } + }) + + if (!res.ok) { + const failClosed = (process.env.HIBP_FAIL_CLOSED || '').toLowerCase() === 'true' + if (failClosed) { + throw createError({ statusCode: 503, statusMessage: 'Passwortprüfung derzeit nicht verfügbar. Bitte später erneut versuchen.' }) + } + // fail-open + return { pwned: false, count: 0 } + } + + const text = await res.text() + const map = parseRangeResponse(text) + cache.set(prefix, { expiresAt: now + ttlMs, map }) + + const count = map.get(suffix) || 0 + return { pwned: count > 0, count } +} + +export async function assertPasswordNotPwned(password) { + const { pwned } = await checkPasswordPwned(password) + if (pwned) { + throw createError({ + statusCode: 400, + message: 'Dieses Passwort wurde in bekannten Datenleaks gefunden. Bitte wählen Sie ein anderes Passwort.' + }) + } +} + + diff --git a/server/utils/rate-limit.js b/server/utils/rate-limit.js new file mode 100644 index 0000000..1be13bd --- /dev/null +++ b/server/utils/rate-limit.js @@ -0,0 +1,131 @@ +/** + * Sehr einfache In-Memory Rate-Limits für Nitro/h3. + * + * Hinweis: In-Memory ist pro Prozess/Instance. Für horizontale Skalierung + * sollte das auf Redis o.ä. umgestellt werden (siehe Doku). + */ + +const buckets = globalThis.__HTC_RATE_LIMIT_BUCKETS__ || new Map() +// Persist across hot reloads +globalThis.__HTC_RATE_LIMIT_BUCKETS__ = buckets + +function nowMs() { + return Date.now() +} + +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)) +} + +export function getClientIp(event) { + const xff = getHeader(event, 'x-forwarded-for') + if (xff) { + // First IP in list is original client + const first = xff.split(',')[0]?.trim() + if (first) return first + } + + const realIp = getHeader(event, 'x-real-ip') + if (realIp) return realIp.trim() + + return event?.node?.req?.socket?.remoteAddress || 'unknown' +} + +function getBucket(key) { + let b = buckets.get(key) + if (!b) { + b = { + windowStart: nowMs(), + count: 0, + consecutiveFails: 0, + lockedUntil: 0 + } + buckets.set(key, b) + } + return b +} + +function normalizeKeyPart(part) { + return String(part || '') + .trim() + .toLowerCase() + .replace(/\s+/g, ' ') + .slice(0, 200) +} + +function buildKey(name, keyParts) { + const parts = (Array.isArray(keyParts) ? keyParts : [keyParts]).map(normalizeKeyPart) + return `${name}:${parts.join(':')}` +} + +function resetWindowIfNeeded(bucket, windowMs, now) { + if (now - bucket.windowStart >= windowMs) { + bucket.windowStart = now + bucket.count = 0 + // consecutiveFails bleibt bewusst erhalten (Backoff für "nervige" Clients) + } +} + +export function assertRateLimit(event, options) { + const { + name, + keyParts, + windowMs = 10 * 60 * 1000, + maxAttempts = 10, + lockoutMs = 15 * 60 * 1000, + statusCode = 429, + message = 'Zu viele Versuche. Bitte später erneut versuchen.' + } = options || {} + + const key = buildKey(name, keyParts) + const bucket = getBucket(key) + const now = nowMs() + + if (bucket.lockedUntil && bucket.lockedUntil > now) { + const retryAfterSec = Math.ceil((bucket.lockedUntil - now) / 1000) + setHeader(event, 'Retry-After', String(retryAfterSec)) + throw createError({ statusCode, statusMessage: message }) + } + + resetWindowIfNeeded(bucket, windowMs, now) + + if (bucket.count >= maxAttempts) { + bucket.lockedUntil = now + lockoutMs + const retryAfterSec = Math.ceil(lockoutMs / 1000) + setHeader(event, 'Retry-After', String(retryAfterSec)) + throw createError({ statusCode, statusMessage: message }) + } + + // Count the attempt + bucket.count += 1 +} + +export async function registerRateLimitFailure(event, options) { + const { + name, + keyParts, + delayBaseMs = 300, + delayMaxMs = 5000 + } = options || {} + + const key = buildKey(name, keyParts) + const bucket = getBucket(key) + bucket.consecutiveFails = Math.min((bucket.consecutiveFails || 0) + 1, 30) + + // Exponential backoff: base * 2^(n-1) + const delay = Math.min(delayBaseMs * Math.pow(2, bucket.consecutiveFails - 1), delayMaxMs) + await sleep(delay) +} + +export function registerRateLimitSuccess(_event, options) { + const { name, keyParts } = options || {} + const key = buildKey(name, keyParts) + const bucket = getBucket(key) + bucket.consecutiveFails = 0 + // Nach Erfolg darf es "frisch" starten + bucket.count = 0 + bucket.windowStart = nowMs() + bucket.lockedUntil = 0 +} + + diff --git a/server/utils/upload-validation.js b/server/utils/upload-validation.js new file mode 100644 index 0000000..fe94ece --- /dev/null +++ b/server/utils/upload-validation.js @@ -0,0 +1,21 @@ +import fs from 'fs/promises' + +export async function assertPdfMagicHeader(filePath) { + const fh = await fs.open(filePath, 'r') + try { + const buf = Buffer.alloc(5) + await fh.read(buf, 0, 5, 0) + const header = buf.toString('utf8') + if (header !== '%PDF-') { + throw createError({ statusCode: 400, statusMessage: 'Ungültige Datei: kein PDF' }) + } + } finally { + await fh.close() + } +} + +export function clamp(n, min, max) { + return Math.max(min, Math.min(max, n)) +} + + diff --git a/server/utils/webauthn-challenges.js b/server/utils/webauthn-challenges.js new file mode 100644 index 0000000..aef4afa --- /dev/null +++ b/server/utils/webauthn-challenges.js @@ -0,0 +1,46 @@ +const regChallenges = globalThis.__HTC_WEBAUTHN_REG_CHALLENGES__ || new Map() +const authChallenges = globalThis.__HTC_WEBAUTHN_AUTH_CHALLENGES__ || new Map() +globalThis.__HTC_WEBAUTHN_REG_CHALLENGES__ = regChallenges +globalThis.__HTC_WEBAUTHN_AUTH_CHALLENGES__ = authChallenges + +function nowMs() { + return Date.now() +} + +function cleanup(map) { + const now = nowMs() + for (const [k, v] of map.entries()) { + if (!v || !v.expiresAt || v.expiresAt <= now) map.delete(k) + } +} + +export function setRegistrationChallenge(userId, challenge, ttlMs = 5 * 60 * 1000) { + cleanup(regChallenges) + regChallenges.set(String(userId), { challenge, expiresAt: nowMs() + ttlMs }) +} + +export function getRegistrationChallenge(userId) { + cleanup(regChallenges) + const v = regChallenges.get(String(userId)) + return v?.challenge || null +} + +export function clearRegistrationChallenge(userId) { + regChallenges.delete(String(userId)) +} + +export function setAuthChallenge(challenge, ttlMs = 5 * 60 * 1000) { + cleanup(authChallenges) + authChallenges.set(String(challenge), { expiresAt: nowMs() + ttlMs }) +} + +export function consumeAuthChallenge(challenge) { + cleanup(authChallenges) + const key = String(challenge) + const v = authChallenges.get(key) + if (!v) return false + authChallenges.delete(key) + return true +} + + diff --git a/server/utils/webauthn-config.js b/server/utils/webauthn-config.js new file mode 100644 index 0000000..b4cfa57 --- /dev/null +++ b/server/utils/webauthn-config.js @@ -0,0 +1,26 @@ +function deriveFromBaseUrl() { + const base = process.env.NUXT_PUBLIC_BASE_URL || 'http://localhost:3100' + try { + const u = new URL(base) + return { + origin: `${u.protocol}//${u.host}`, + rpId: u.hostname + } + } catch { + return { origin: 'http://localhost:3100', rpId: 'localhost' } + } +} + +export function getWebAuthnConfig() { + const derived = deriveFromBaseUrl() + + const rpId = process.env.WEBAUTHN_RP_ID || derived.rpId + const rpName = process.env.WEBAUTHN_RP_NAME || 'Harheimer TC' + const origin = process.env.WEBAUTHN_ORIGIN || derived.origin + + const requireUV = (process.env.WEBAUTHN_REQUIRE_UV || '').toLowerCase() === 'true' + + return { rpId, rpName, origin, requireUV } +} + + diff --git a/server/utils/webauthn-encoding.js b/server/utils/webauthn-encoding.js new file mode 100644 index 0000000..8ea2134 --- /dev/null +++ b/server/utils/webauthn-encoding.js @@ -0,0 +1,34 @@ +export function toBase64Url(buf) { + if (buf == null) return '' + if (typeof buf === 'string') return buf + const b = Buffer.isBuffer(buf) ? buf : Buffer.from(buf) + // Node supports 'base64url' on recent versions; keep fallback for safety. + try { + return b.toString('base64url') + } catch { + return b + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/g, '') + } +} + +export function fromBase64Url(s) { + if (!s) return Buffer.alloc(0) + // Node supports 'base64url' on recent versions; keep fallback for safety. + try { + return Buffer.from(String(s), 'base64url') + } catch { + let v = String(s).replace(/-/g, '+').replace(/_/g, '/') + while (v.length % 4) v += '=' + return Buffer.from(v, 'base64') + } +} + +export function parseClientDataJSON(clientDataJSONB64Url) { + const json = fromBase64Url(clientDataJSONB64Url).toString('utf8') + return JSON.parse(json) +} + + diff --git a/stores/auth.js b/stores/auth.js index 5d42c24..e913a67 100644 --- a/stores/auth.js +++ b/stores/auth.js @@ -54,6 +54,28 @@ export const useAuthStore = defineStore('auth', { return response }, + async passkeyLogin() { + // Client-only + if (typeof window === 'undefined' || !window.PublicKeyCredential) { + throw new Error('Passkeys werden von diesem Browser nicht unterstützt.') + } + + const { options } = await $fetch('/api/auth/passkeys/authentication-options', { method: 'POST' }) + const mod = await import('@simplewebauthn/browser') + const credential = await mod.startAuthentication(options) + + const response = await $fetch('/api/auth/passkeys/login', { + method: 'POST', + body: { credential } + }) + + if (response.success) { + await this.checkAuth() + } + + return response + }, + async logout() { try { await $fetch('/api/auth/logout', { method: 'POST' })