Live-Portal-Domain ist pressekonto.com (nicht .de) – Callback-URL in .env.example und Deployment-Doku entsprechend gesetzt. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
133 lines
13 KiB
Markdown
133 lines
13 KiB
Markdown
**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.
|
||
|
||
**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.
|