presseportale/docs/weiteres/Sicherheit & Deployment-Hinweise (Auth, Rollen, Verifizierung).md
Kevin Adametz 95007da826 WS-3: Recht & Compliance – Rechts-Kern (DSGVO/Persönlichkeitsrecht/Melden + Queue)
Launch-pflichtiger Compliance-Slice: öffentliche Anfrage zu einer PM speist eine
manuelle Admin-Queue (keine KI).

- Migration legal_requests + Model + Enums (Type: dsgvo/personal_rights/report,
  Status: open/in_progress/resolved/rejected) + Factory.
- Öffentliches Formular /release/{slug}/rechtliches (LegalRequestController +
  web/legal-request.blade.php): typ-abhängiger Hinweistext (Alpine), E-Mail bei
  DSGVO/Persönlichkeitsrecht erforderlich, zwei versteckte Honeypot-Felder,
  Rate-Limit + Bremse "1 offene Anfrage pro PM/Typ". Regeltexte als Entwurf mit
  TODO für rechtliche Finalisierung markiert.
- Routen bewusst in eigener routes/legal.php (entkoppelt vom laufenden Web-Umbau),
  host-agnostisch via domains.php eingebunden.
- Admin-Bereich "Recht & Compliance": Sidebar-Nav mit Offen-Zähler, Volt-Queue
  index/show (in Bearbeitung/erledigt/abgelehnt/wieder öffnen + interne Notiz).
- Tests: je Typ, Honeypots (Dataset), Bremse, Admin-Queue + Status-Übergänge.
- Doku: Detailplan WS-3-Status + Deployment-Migrationsreihenfolge ergänzt.

Hinweis: Der "Melden"-/E&F-Button auf der PM-Detailseite (release-detail.blade.php)
wird mit dem separaten Web-Frontend-Commit verdrahtet; Ziel ist legal-request.create.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 14:20:05 +00:00

134 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

**Version:** Juni 2026 · **Datum:** 16.06.2026 · **Status:** Umgesetzt
**Scope:** Sicherheits- und deployment-kritische Punkte aus der Auth-/Rollen-/Verifizierungs-Phase (WS-6 & WS-2). Dieses Dokument bündelt, was beim Go-Live zwingend beachtet werden muss. Feed für die spätere Live-Deployment-Checkliste (WS-7).
---
## 1. ⚠️ SICHERHEIT — Legacy-Rollen-Überberechtigung (behoben)
**Befund:** Der Legacy-Import (`app/Services/Import/UserImporter.php`) mappte die Alt-Gruppe 2 auf die neue Rolle `editor`. Die Alt-Gruppe 2 war jedoch die normale **Self-Publisher-Masse** (65.950 von 66.151 Usern) – keine Redaktions-Mitarbeiter. Im neuen Rollenmodell gewährt `editor` **Admin-Panel-Zugriff** (`User::canAccessAdmin()` prüft `hasRole(['admin','editor'])`).
**Auswirkung:** Praktisch jeder Legacy-Kunde konnte sich – insbesondere über den Magic-Link-Login – ins **komplette Admin-Panel** einloggen.
**Fix:**
- Mapping korrigiert: Alt-Gruppe 2 → `customer` (statt `editor`). `editor` wird ab jetzt ausschließlich manuell an echtes Personal vergeben.
- Daten-Migration `2026_06_16_080913_downgrade_legacy_editor_users_to_customer` stuft die bestehenden Legacy-`editor` (alle mit `legacy_portal`) auf `customer` herab. Chunked (Placeholder-Limit), nicht umkehrbar.
- Ergebnis nach Migration: `editor = 0`, `customer ≈ 65.952`.
**Deployment / Nachkontrolle:**
- Migration MUSS auf Prod laufen.
- Es bleiben **4 Legacy-Admins** (Alt-Gruppe 1) – **manuell prüfen**, ob das wirklich Personal ist.
- Kontrolle nach Deploy: `Spatie\Permission\Models\Role::findByName('editor')->users()->whereNotNull('legacy_portal')->count()` muss 0 sein.
---
## 2. ⚠️ DEPLOYMENT-KRITISCH — E-Mail-Verifizierung scharf
`User` implementiert jetzt `MustVerifyEmail`; die `verified`-Middleware auf den Panel-Routen greift damit real (war vorher wirkungslos).
**Lockout-Gefahr:** Ohne Vorsorge würden ALLE Bestands-User mit `email_verified_at = NULL` (alle 66.151, davon ~59.403 aktiv) ausgesperrt.
**Absicherung:** Migration `2026_06_15_101337_backfill_email_verified_at_for_existing_users` setzt `email_verified_at` für alle bestehenden User.
**Deployment:** Diese Migration MUSS mit dem Deploy laufen, **bevor** echter Traffic auf die geschützten Routen kommt. Neue Registrierungen (nach Deploy) bleiben absichtlich `NULL`, bis verifiziert.
**Registrierungs-Verhalten (Entscheidung 15.06.):**
- Selbst-Registrierung legt ein **inaktives, rollenloses** Konto an → Danke-/Notice-Seite → Verifizierungsmail.
- Bestätigter Link → `is_active = true` + `customer`-Rolle (`App\Listeners\ActivateUserAfterVerification`).
- **Ausnahme-Kanäle** (gelten als verifiziert, kein E-Mail-Schritt): Magic-Link, Kontaktzugang (WS-2), Google-Login (geplant).
---
## 3. VERHALTENSÄNDERUNG — Firmen-Scope für Pressemitteilungen
PM-Zugriff war hart an `user_id` (Autor) gebunden. Jetzt **additiv**: Zugriff = **Autor ODER Mitglied der zugeordneten Firma** (Owner via `owner_user_id` oder `company_user`-Pivot).
- Betroffen: `PressReleasePolicy::canManage()` (view/update/submitForReview/delete) sowie die Queries der Kunden-Komponenten (Liste/Show/Edit) und `User::accessibleCompanyIds()`.
- **Solo-Owner:** keine sichtbare Änderung (sie sind Autor ihrer PMs).
- **Firmen mit mehreren Mitgliedern:** Mitglieder sehen/bearbeiten jetzt **alle** PMs der Firma (gewollt – „gesamte Firma").
---
## 4. Anmeldung per E-Mail-Link – zusammengeführt (WS-2)
Eine einzige öffentliche Seite `/anmeldelink` (`auth.magic-link`) deckt **beide** Magic-Link-Fälle ab; das frühere Login-Modal und die separate Pressekontakt-Seite sind aufgelöst (war nicht selbsterklärend, zwei fast identische Wege). Die alte URL `/pressekontakt-zugang` leitet per Redirect auf `/anmeldelink`.
- **Enumeration-sicher** (neutrale Antwort unabhängig vom Treffer), **Honeypot** + **Rate-Limit** (pro E-Mail+IP 3/h, pro IP 15/h).
- Einheitlicher Service-Eintritt `ContactAccessService::requestMagicAccess()`. Berücksichtigt **jede** Stelle, an der eine E-Mail hinterlegt ist – an einer **Firma** (`companies.email`) **und** an einem **Pressekontakt** (`contacts.email`), **portalübergreifend** (Abfragen ohne `PortalScope`):
- **Bestehendes aktives Konto** → Login-Magic-Link (auch ohne Hinterlegung – ersetzt das alte Login-Modal).
- **Hinterlegte Firma/Kontakt(e) ohne Konto** → **lazy** angelegter, per E-Mail **de-duplizierter** `customer`-Account (aktiv, verifiziert).
- **Mehrfach hinterlegt** → **genau ein** Konto je E-Mail, dem **alle** zugehörigen Firmen und Pressekontakte zugeordnet werden – auch über Portalgrenzen hinweg.
- **Pivot-Rolle `responsible`** („Verantwortlich"): Wer seine E-Mail an einer Firma/einem Pressekontakt hinterlegt hat, erhält **Schreibzugriff** auf die gesamte Firma (Stammdaten, Pressekontakte, Pressemitteilungen inkl. Terminierung/„Kalender") – siehe `CompanyPolicy::update()`. Owner (`owner_user_id`) bleiben unangetastet; ein bestehender reiner Lese-Pivot (`member`) wird beim nächsten Login/Magic-Access auf `responsible` hochgestuft. Greift über den Login-Re-Sync auf **allen** Anmeldewegen.
- **Beides zugleich** → Konto wiederverwenden, Scope ergänzen, Link.
- **Deaktiviertes Bestandskonto / unbekannte E-Mail** → keine Aktion, gleiche neutrale Meldung.
- Versand über den **bestehenden Login-Magic-Link** (`MagicLinkGenerator` + `MagicLinkConsumeController`) – keine Schema-Änderung.
**Design-Nuance (bewusst):** Der Pressekontakt-Account wird beim **Absenden der Anfrage** angelegt (nicht erst beim Klick), um eine fragile `magic_links`-Schema-Änderung (ENUM/FK über SQLite-Tests + MySQL-Prod) zu vermeiden. Er ist „lazy" (nur für real existierende Kontakt-Mails) und ohne den per Mail versendeten Token wertlos. Die ältere Methode `requestAccess()` (nur Pressekontakte) bleibt für gezielte Aufrufe/Tests erhalten.
**Re-Sync bei jedem Login:** `App\Listeners\SyncCompanyMembershipsOnLogin` hört auf das `Login`-Event und ruft `ContactAccessService::syncMembershipsForEmail()` – greift damit bei **allen** Anmeldewegen (Magic-Link, Passwort, Google). Nachträglich (z. B. per API) hinzugekommene Firmen/Kontakte mit derselben E-Mail werden so beim nächsten Login automatisch zugeordnet, unabhängig vom Login-Weg. Rein additiv (kein Konto, keine Mail), bestehende Mitgliedschaften/Owner bleiben unangetastet.
- **Passwort-Login für Magic-Link-Accounts:** Lazy angelegte Accounts haben `password = null` und können sich (korrekt) nicht per Passwort anmelden, bis sie über **„Passwort vergessen"** eins setzen. Danach ist auch der reguläre Passwort-Login möglich.
- **Performance-Hinweis:** Der Re-Sync fragt `companies`/`contacts` per `LOWER(email)` ab (case-insensitive, SQLite-Tests + MySQL-Prod). Bei sehr großen Tabellen pro Login besser über einen lowercased/funktionalen Index auf `email` absichern (Follow-up, noch nicht angelegt).
---
## 5. Guest-Redirect rollenbewusst (403-Sackgasse behoben)
`bootstrap/app.php` → `redirectUsersTo`: eingeloggte User auf Gast-Routen (`/login`, `/register`) werden rollen-/verifizierungsbewusst geleitet (Admin → `/dashboard`, Customer → `/admin/me`, unverifiziert → Notice). Verhindert, dass ein Customer auf das feste `/dashboard` (403) läuft und festsitzt.
Ergänzend: Magic-Link-Consume nutzt einen rollensicheren Redirect **ohne** `intended()` (eine als Gast besuchte Admin-URL darf einen Customer nicht in den 403-Bereich schicken).
---
## 6. Login-/Magic-Link-Härtung (Security-Review 16.06.)
Aus einer gezielten Auth-Prüfung umgesetzt:
- **Magic-Link-Versand rate-limited** (vorher ungedrosselt): pro E-Mail+IP (3/h) und zusätzlich pro IP (15/h) – verhindert Mail-Fluten aktiver Accounts und das laufende Entwerten alter Links. Der Pressekontakt-Zugang war bereits limitiert.
- **Inaktive (aber verifizierte) User werden beim Passwort-Login zentral blockiert** (`Auth::logout()` + Fehler), analog zum Magic-Link-Consume. Schließt nur-`auth`+`verified`-Routen ohne zusätzlichen `is_active`-Check ab.
- **Rollensicherer Login-Redirect**: eine gemerkte `intended`-Admin-URL (z. B. `/admin/users`) schickt einen Customer nicht mehr in den 403 – inaktzessible Admin-Pfade fallen auf das rollengerechte Ziel zurück.
- **ContactAccess mutiert keine deaktivierten Bestands-Accounts** mehr: der `is_active`-Check läuft vor jeder Firmenzuordnung/jedem Linkversand.
- **Magic-Link-Verbrauch ist atomar** (konditionales `UPDATE … whereNull(consumed_at)`): Single-Use auch bei parallelen Requests garantiert.
**Captcha (dokumentiert, vorerst NICHT eingebaut):** Beim Pressekontakt-Zugang greifen aktuell Honeypot + Rate-Limit. Ein echtes Captcha (Cloudflare Turnstile / hCaptcha) ist sinnvoll, wird aber erst eingebaut, falls im Livebetrieb zu viel Missbrauch auftritt (Entscheidung 16.06.) – braucht Provider-Entscheidung, Keys und ein Paket.
## 6b. 2FA-Bypass behoben & Login-Pfade konsolidiert (Review 16.06., Teil 2)
**Befund:** Der sichtbare Volt-Login machte direkt `Auth::attempt()` und umging damit Fortifys 2FA-Pipeline (`RedirectIfTwoFactorAuthenticatable`) – für Accounts mit aktivem 2FA faktisch ein **2FA-Bypass**. Zusätzlich existierte der Fortify-POST `/login` parallel mit schwächeren Post-Login-Regeln (kein `is_active`-Block, `resolveTarget` neutralisierte nur `/` und `/dashboard`).
**Fix (Volt-nativ):**
- Der Volt-Login prüft die Zugangsdaten jetzt OHNE sofortiges Einloggen; bei aktivem 2FA (`hasEnabledTwoFactorAuthentication()`) wird der Session-Vertrag `login.id`/`login.remember` gesetzt und auf eine neue **Volt-2FA-Challenge-Seite** (`/two-factor-challenge`) geleitet. Diese postet an Fortifys bestehenden `two-factor-challenge`-Controller (TOTP + Recovery-Code).
- Gemeinsame Post-Login-Logik in `App\Support\LoginRedirect` (rollengerechtes Home + 403-sicherer intended-Redirect), genutzt von Volt-Login UND der Fortify-Response.
- `RoleAwareLoginResponse` implementiert jetzt **beide** Contracts (`LoginResponse` **und** `TwoFactorLoginResponse`) und erzwingt einheitlich: unverifiziert → Notice, verifiziert-inaktiv → Logout+Fehler, sonst 403-sicherer Redirect. Damit ist auch der direkte Fortify-POST-Pfad gehärtet.
**Hinweis:** `fortify.views => false` bleibt; die Challenge wird vom Volt-Frontend bereitgestellt, die Verifizierung übernimmt Fortify.
**Nachschärfung (Review 16.06., Teil 3):** Der `wantsJson()`-Kurzschluss in `RoleAwareLoginResponse` lief zuvor VOR den Sicherheitschecks – ein XHR/JSON-Login eines verifiziert-inaktiven Accounts erhielt so eine Session ohne Logout. Jetzt laufen die Prüfungen zuerst: verifiziert-inaktiv → `Auth::logout()` + Session-Invalidate + **403** (JSON) bzw. Login mit Fehler (HTML); unverifiziert → 204 (JSON) bzw. Notice (HTML). Erst danach der 204-/Redirect-Erfolgsfall.
---
## 6c. Google-Login (Socialite, WS-6)
`/auth/google/redirect` → Google, `/auth/google/callback` → `GoogleController`. Auflösung in `App\Services\Auth\SocialAuthService`:
- **De-Dup über E-Mail:** bestehende E-Mail wird wiederverwendet (keine Dubletten), nur `oauth_provider`/`oauth_provider_id` werden verknüpft.
- **Neuer User:** aktiv + verifiziert + `customer` (Verifizierung gilt über den Google-Kanal als erfüllt).
- **Offener Selbst-Registrierer (unverifiziert):** wird über Google onboardet (verifiziert + aktiv + customer).
- **Deaktivierter, verifizierter Account:** wird **nicht** reaktiviert → Login blockiert.
- Abschluss über dieselbe `LoginRedirect`-Logik (rollengerecht, 403-sicher).
**Deployment-Voraussetzungen:**
- `composer install` (neue Abhängigkeit `laravel/socialite`).
- Migration `add_oauth_provider_columns_to_users` (siehe unten).
- ENV: `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `GOOGLE_REDIRECT_URI`. Die Redirect-URI muss in der Google Cloud Console **exakt** der Callback-URL der **Portal-Domain** entsprechen – Prod: `https://pressekonto.com/auth/google/callback` (der Login läuft auf dem Portal-Host, nicht zwingend `APP_URL`).
- **Lokales Testen:** Google akzeptiert keine `.test`-Domains als Redirect-URI (nur `localhost`/`127.0.0.1` oder echte https-Domains). Der echte Google-Round-Trip wird daher auf **Staging/Live** mit der echten https-Domain getestet; lokal ist die Login-Logik über `tests/Feature/Auth/GoogleLoginTest.php` (gemockter Socialite-Provider) abgesichert. Wer lokal end-to-end testen will: gesamten Flow auf `http://localhost` legen (Portal-Host temporär auf localhost) oder einen https-Tunnel als Portal-Host verwenden – sonst bricht die host-gebundene Session beim Wechsel `.test`→`localhost`.
## 7. Deployment-Reihenfolge (Migrationen dieser Phase)
1. `2026_06_15_101337_backfill_email_verified_at_for_existing_users` — verhindert Lockout.
2. `2026_06_16_080913_downgrade_legacy_editor_users_to_customer` — schließt die Admin-Überberechtigung.
3. `2026_06_16_103238_add_oauth_provider_columns_to_users` — Provider-Verknüpfung für Google-Login.
4. `2026_06_16_134458_create_legal_requests_table` — Queue für Recht & Compliance (WS-3: DSGVO/Persönlichkeitsrecht/Meldungen).
**Nach Deploy prüfen:**
- `editor`-Rolle hat keine Legacy-User mehr.
- Niemand ungewollt ausgesperrt (Stichprobe aktiver Legacy-User → Login/Panel erreichbar).
- Die 4 verbliebenen Legacy-Admins sind legitimes Personal.