presseportale/docs/weiteres/Sicherheit & Deployment-Hinweise (Auth, Rollen, Verifizierung).md
Kevin Adametz 9bce9afe0b 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

13 KiB
Raw Blame History

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").

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).


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.