presseportale/dev/sicherheit/ENTWICKLUNGSPLAN-SICHERHEIT-2026-10-07.md
2026-10-09 10:55:33 +00:00

32 KiB
Raw Permalink Blame History

Entwicklungsplan – Behebung Sicherheitsaudit vom 07.10.2026

Grundlage: SICHERHEITSAUDIT-2026-10-07.md, Repository-Stand 2215826

Stand: 08.10.2026 – Phasen 1–3 und Entscheidung 5 (Passkeys, Pflicht-Zweitfaktor für Admins) live seit v1.4.0 (07.10.2026), Hotfix v1.4.1; Abgleich der Firmenrechte ausgeführt (19.607 → 19.316, Wiederholung stabil). API-Zugang an Buchung, Proxy und HSTS live seit v1.5.0 (08.10.2026); restliche Phase 4 live seit v1.6.0 (08.10.2026, Abschnitt 6)

Wiedervorlage — nicht vergessen:

Wann Was Wie
sofort ✅ 08.10.2026: Imagick auf Live installiert (php -m zeigt imagick). Vorher liefen Herkunftsprüfung und KI-Vorab-Check der Bilder ins Leere. Nach v1.6.1 in der Betriebsüberwachung, Zeile „PHP-Erweiterung Imagick“, gegenprüfen (zeigt den Stand von PHP-FPM).
ab 15.10.2026 CSP-Meldungen auswerten (Melde-Modus seit v1.6.0). Auf Live tail -n 200 storage/logs/csp-*.log; fehlende Quellen in App\Support\ContentSecurityPolicy ergänzen, danach über das Scharfschalten entscheiden ('unsafe-inline'/'unsafe-eval' bleiben bis zu einer Nonce-Lösung).
nächstes Release Übergangs-Fallback der Sperrmarke entfernen. SessionRevocation::legacyCachedMark() und den Test „a revocation from before v1.6.0 …“ streichen.
nächstes Release Liste und Kennzahl „Aktive Sessions“ auf der Sicherheitsseite lesen die Tabelle sessions, Live speichert Sessions in Redis — dort immer leer. Korrigieren oder entfernen; der Knopf „Andere Geräte abmelden“ ist davon unabhängig.
nach Deploy Spamschutz ✅ 09.10.2026 (v1.7.0): Altbestand in die Quarantäne (152), Bot-Konten gelöscht, täglicher Lauf an. Offen: Abschnitt 6a der Datenschutzerklärung juristisch freigeben lassen. ENTWICKLUNGSPLAN-SPAMSCHUTZ-2026-10-09.md, Abschnitt 0.
ab 16.10.2026 Wirkung des Spamschutzes auswerten. Spam-Filter der Kontakt-Queue, storage/logs/spam-*.log, Betriebsüberwachung „Formulare ohne Bot-Prüfung“; Regeln nachschärfen oder lockern.
bei Gelegenheit Außerhalb des Audits: eigene MySQL-Benutzer je Projekt auf global-mysql, MySQL-Root-Passwort ändern (steht in der Git-Historie), lokalen APP_KEY reparieren. –

Ausführung: Befehle direkt im Dev-Container (php artisan …, ohne Sail – siehe .ai/rules/general.md)

0. Umsetzungsstand (07.10.2026)

Entscheidungen 1–4 wie empfohlen getroffen. Entscheidung 5 umgesetzt als Passkeys plus Pflicht-Zweitfaktor für Admins (siehe unten), Entscheidung 6 siehe unten.

ID Status Kern der Änderung Tests
S-01 ✅ umgesetzt LegacyInvoicePolicy::view() verlangt billing:manage statt canAccessAdmin() tests/Feature/Billing/LegacyInvoiceAccessTest.php
S-02 ✅ umgesetzt admin/companies/show: authorize('companies:write') in attachExistingContact(), $id #[Locked], Zuordnungs-UI nur mit Schreibrecht tests/Feature/Admin/EditorPermissionBoundaryTest.php
S-03 ✅ umgesetzt CompanyMembershipObserver (Contact/Company/User) ruft syncMembershipsForEmail() bei Adress-/Zuordnungsänderung, Löschung, Wiederherstellung; inaktive/unverifizierte Konten verlieren Auto-Rechte; Kommando memberships:resync-auto tests/Feature/Auth/AutoMembershipRevocationTest.php
S-04 ✅ umgesetzt PasswordChanger (alle Passwortwege, Remember-Token-Rotation, SessionRevocation::revokeOthersFor()), Listener RevokeAccessAfterPasswordReset (alle Sessions, offene Login-Links), Fortify-Route PUT /user/password abgeschaltet tests/Feature/Auth/PasswordChangeRevokesSessionsTest.php
S-05 A ✅ umgesetzt SocialAuthService::resolve(): email_verified Pflicht, Login zuerst über oauth_provider_id, keine stille Umverknüpfung tests/Feature/Auth/GoogleLoginTest.php
S-05 B ✅ umgesetzt SocialAccountLinker: Erstverknüpfung bestehender Konten außerhalb gmail/hd nur per Bestätigungslink, gebunden an die Browser-Session des Google-Logins tests/Feature/Auth/GoogleLoginTest.php
S-06 ✅ umgesetzt Trait ConfirmsIdentity + SecurityConfirmation (E-Mail-Code für passwortlose Konten, 15 min Gültigkeit) vor E-Mail-Wechsel, erster 2FA-Einrichtung und Token-Erzeugung; SecurityChangeNotice an die (bisherige) Adresse bei Passwort, Reset, E-Mail, 2FA an/aus, Google-Verknüpfung tests/Feature/CustomerProfileSecurityTest.php, tests/Feature/CustomerPortalTest.php

Komplette Testsuite (inkl. Entscheidung 5): 2403 bestanden, 1 übersprungen. Angepasste Bestandstests: Google-Mocks liefern jetzt email_verified; die Tests für 2FA-Einrichtung, Token-Erzeugung und den E-Mail-Wechsel passwortloser Konten senden den neuen Nachweis mit.

Entscheidung 6 (07.10.2026): Neue Konten per Google ohne zusätzliche Mail-Bestätigung.

  • Wird ein neues Konto über Google angelegt, gilt die Bestätigung durch das Google-Konto. Das betrifft auch Adressen ohne gmail oder hd.
  • Das Konto erhält wie bisher direkt die Auto-Rechte an Firmen mit passender Kontakt- oder Firmenadresse.
  • Bewusst in Kauf genommenes Restrisiko: ein privates Google-Konto mit einer Firmenadresse, deren Postfach der Inhaber nicht mehr kontrolliert.
  • Dagegen wirkt weiterhin S-03: Entfernt die Firma den Kontakt, enden die Rechte sofort.
  • Bestehende Konten bleiben durch Entscheidung 3 geschützt.
  • Abgesichert durch den Bestandstest a new google user is created active, verified and as customer in tests/Feature/Auth/GoogleLoginTest.php.

Entscheidung 5 (07.10.2026): Passkeys und Pflicht-Zweitfaktor für Admins.

Bestätigt: offizielles npm-Paket @laravel/passkeys, Pflicht für Super-Admins und Admins (nicht für Editoren), Authenticator-App bleibt Alternative.

  • Grundlage: Fortify-Feature passkeys (Fortify 1.40, bindet laravel/passkeys 0.2 ein, also eine frühe Version). Neue Tabelle passkeys. Das User-Model implementiert PasskeyUser.
  • Domain: Gebunden an die Portal-Domain (APP_PORTAL_NAME/APP_PORTAL_URL). Nur dort laufen Login und Sicherheitsseite. Für die Kennung lieber ein eigenes PASSKEYS_USER_HANDLE_SECRET setzen; ohne Angabe gilt APP_KEY, und ein späterer Schlüsselwechsel würde die gespeicherten Passkeys entwerten.
  • Login: Button „Mit Passkey anmelden“ auf der Login-Seite. Die Benutzerverifizierung (Biometrie/PIN) ist Pflicht, ein Passkey ersetzt damit Passwort und App-Code. Nur aktive, verifizierte Konten (Passkeys::authorizeLoginUsing). PasskeyLoginResponse protokolliert den Login und leitet rollengerecht weiter.
  • Sicherheitsseite: Passkeys hinzufügen, ansehen und entfernen.
    • Hinzufügen und Entfernen nur mit frischem Nachweis (S-06). Bei der Registrierung prüft EnsurePasskeyManagementIsConfirmed den Nachweis zusätzlich an den Fortify-Routen; während einer Impersonation ist beides gesperrt.
    • Hinweis-Mail an die Kontoadresse.
    • Ein bestätigter Passkey zählt als Nachweis für Sicherheitsänderungen.
  • Admin-Pflicht: EnsureStrongAdminAuthentication an allen Admin-Routen und als persistente Livewire-Middleware.
    • Super-Admins und Admins bestätigen die Session einmal per Passkey oder Authenticator-Code auf admin/anmeldung-bestaetigen.
    • Magic Link, Google oder Passwort allein genügen nicht.
    • Ohne zweiten Faktor führt die Seite zur Einrichtung.
    • Notfallschalter: AUTH_REQUIRE_STRONG_ADMIN=false. In der Testsuite ist die Pflicht aus; ihre eigenen Tests schalten sie ein.
  • Gerät verloren: Ein Super-Admin setzt Passkeys und App eines anderen Kontos in admin/users/{id}/edit zurück („Zweiter Faktor“). Dabei enden alle Sessions des Kontos, und der Inhaber wird per Mail informiert.
  • Tests: tests/Feature/Auth/PasskeyAndStrongAdminAuthTest.php. Die WebAuthn-Zeremonie selbst ist nicht automatisiert getestet; das muss einmal im echten Browser geprüft werden.

Release und Deploy (Git-Deploy, siehe dev/betrieb/git-deploy.md):

Alles zusammen geht als ein Release raus. Vorgeschlagen ist v1.4.0: neue Funktion und eine Migration.

Release lokal (Abschnitt C):

  1. main muss sauber sein, die volle Suite und Larastan grün. Vorher klären, was mit den unversionierten Ordnern .ai/ und dev/2026-10/ passiert (committen oder ignorieren).
  2. npm ci && npm run build, danach public/build mitcommitten. Neu ist der Eintrag passkeys.js, außerdem kommt @laravel/passkeys in package.json/package-lock.json dazu.
  3. git tag -a v1.4.0 -m "v1.4.0 — Sicherheitsaudit S-01–S-06, Passkeys, Zweitfaktor-Pflicht für Admins" und anschließend git push origin main --follow-tags sowie git push NAS main --follow-tags.

Vor dem Deploy auf Live (Abschnitt D):

  1. DB-Backup ziehen, weil die Migration create_passkeys_table mitkommt. Die neue Tabelle ist klein; der Fremdschlüssel auf users baut users nicht um.
  2. .env auf Live vorher ergänzen bzw. prüfen:
    • APP_PORTAL_NAME und APP_PORTAL_URL (mit https): An diese Domain sind die Passkeys gebunden.
    • PASSKEYS_USER_HANDLE_SECRET: ein eigener Zufallswert, gesetzt vor dem ersten Passkey.
    • AUTH_REQUIRE_STRONG_ADMIN nicht setzen (Standard: an). Es ist nur der Notfallschalter.
  3. Zahl der automatischen Firmenrechte festhalten (read-only): SELECT COUNT(*) FROM company_user WHERE role='responsible' AND granted_via IN ('auto','auto-upgrade');

Deploy:

  1. bash dev/betrieb/deploy.sh v1.4.0 --migrations.
    • Das Skript erledigt Migration, config:cache (neue Fortify- und Auth-Einstellungen), event:cache (die neuen Listener werden darüber gefunden) und queue:restart.
    • Zum Schluss prüft es den Worker. Der wird gebraucht, weil Hinweis-Mails, Bestätigungscodes und Google-Verknüpfungslinks über die Queue laufen.

Nach dem Deploy:

  1. Erst php artisan memberships:resync-auto --dry-run, danach ohne Option. Zu erwarten sind laut Probelauf mit der Live-Kopie rund 291 von 19.607 Rechten. Seit v1.4.1 rollt das Kommando zurück, wenn mehr als 5 % (mindestens 50) wegfallen würden; dann gilt: nicht mit --force erzwingen, sondern die Ursache klären.
  2. Die üblichen Prüfungen aus Abschnitt D, Punkt 17.
  3. Smoke-Test der Neuerungen:
    • Als Admin anmelden. Es erscheint „Anmeldung bestätigen“; dort einen Passkey bzw. die App einrichten oder bestätigen.
    • Abmelden und „Mit Passkey anmelden“ ausprobieren.
    • Unter „Sicherheit“ einen zweiten Passkey anlegen und wieder entfernen. Dabei kommen die Hinweis-Mails an.
  4. Einen Eintrag im Protokoll von dev/betrieb/git-deploy.md ergänzen.

Rollback (Abschnitt E): bash dev/betrieb/deploy.sh v1.3.2. Die Tabelle passkeys bleibt dabei stehen; der alte Code nutzt sie nicht. Ein Rollback entfernt auch die Admin-Pflicht. Soll nur die Pflicht weg, reicht der Notfallschalter AUTH_REQUIRE_STRONG_ADMIN=false mit anschließendem php artisan config:cache.

Vorfall 07.10.2026: Abgleich mit v1.4.0 entzieht 19.598 begründete Firmenrechte (behoben in v1.4.1).

  • Was passiert ist: memberships:resync-auto meldete auf Live „vorher 19607, nachher 9“.
  • Ursache: syncMembershipsForEmail() entzog in v1.4.0 Konten ohne email_verified_at pauschal alle automatischen Rechte. 19.597 der 19.607 Rechte gehören solchen Bestandskonten aus dem Altsystem. Die Tests deckten nur verifizierte Konten ab.
  • Fix v1.4.1:
    • Entzogen wird nur, was keine E-Mail-Grundlage mehr hat, unabhängig vom Kontostatus. Neue Rechte gibt es weiterhin nur für aktive, verifizierte Konten.
    • Das Kommando läuft in einer Transaktion, hat --dry-run und rollt bei auffällig vielen Entzügen zurück.
    • Regressionstests sind ergänzt.
    • Probelauf gegen die Live-Kopie: 19.607 → 19.316.
  • Wiederherstellung: storage/app/private/restore-company-user-auto-2026-10-07.sql, erzeugt aus dem Live-Backup vor dem Deploy. Sie spielt nur INSERT IGNORE der 19.607 automatischen Rechte ein und darf mehrfach laufen. Lokal in einer zurückgerollten Transaktion geprüft: 19.607 wiederhergestellt, zweiter Lauf 0.

Vorfall 07.10.2026: lokale Datenbank geleert (nicht durch diese Umsetzung).

  • Was das Binärlog zeigt: Um 09:44 UTC (binlog.000009, Pos. 566161486) wurden alle Tabellen von pressekonto auf global-mysql gelöscht, durch ein migrate:fresh (RefreshDatabase) eines frischen Demo-Projekts. In diesem Projekt wurden die Vorlagen aus dev/git-deploy-monitoring getestet (Commit 2215826).
  • Ursache: docker-compose.yml setzte DB_* als Container-Umgebungsvariablen. Die haben in Laravel Vorrang vor der .env. Die phpunit.xml des Demo-Projekts erzwang kein SQLite.
  • Unsere Testsuite war nicht beteiligt: Nach 09:44 gibt es im Binärlog kein weiteres DROP oder TRUNCATE. Die Gegenprobe mit gesetzten MySQL-Variablen bleibt auf SQLite.
  • Behoben:
    • DB_* aus docker-compose.yml entfernt; die Werte stehen identisch in der .env. Wirksam erst nach dem Neuerstellen des Containers.
    • In dev/git-deploy-monitoring steht jetzt „Testdatenbank absichern“ als Pflicht vor RefreshDatabase, mit phpunit.xml-Zeilen, Guard und Gegenprobe.
    • Agenten-Regel in .ai/rules/general.md und CLAUDE.md ergänzt.
  • Wiederherstellung: aus einem Backup des Live-Systems (Kevin). Alternativ wäre ein Rückspielen aus dem Binärlog möglich gewesen: Import ab binlog.000003, Pos. 380456111, bis zum Löschen. Das Binärlog wird nach 30 Tagen gelöscht.
  • Empfehlung: auf global-mysql eigene MySQL-Benutzer je Projekt statt root.

Offen geblieben:

  • Phase 4 (Härtung): inzwischen erledigt, Stand in Abschnitt 6.
  • Wiedervorlage der Restpunkte: Tabelle oben unter „Stand“.

1. Nachprüfung des Audits

Alle sechs Befunde wurden am aktuellen Code nachvollzogen. Keiner ist ein Fehlalarm. Bei der Gewichtung gibt es aber Unterschiede zum Audit, weil die Frage „wer kann das überhaupt ausnutzen?“ stärker berücksichtigt wird.

ID Audit Neu bewertet Wer kann es ausnutzen Aufwand Phase
S-01 Hoch Hoch Interne Editoren ~1 h 1
S-02 Hoch Hoch (zusammen mit S-03) Interne Editoren ~1 h 1
S-04 Hoch Hoch Jeder mit bestehender Sitzung (z. B. gestohlenes Gerät) ~3 h 1
S-03 Hoch Hoch Ausgeschiedene Mitarbeiter von Kundenfirmen ~4 h 2
S-05 Hoch Mittel (Teil A schnell, Teil B Entscheidung) Inhaber eines alten Google-Kontos mit fremder Firmenadresse A ~2 h, B ~4 h 2 / 3
S-06 Mittel Mittel Jeder mit bestehender Sitzung ~5 h 3

Was sich bei der Nachprüfung gezeigt hat

  • S-01 – LegacyInvoicePolicy::view() gibt allen mit canAccessAdmin() frei, also auch Editoren. Die Admin-Route ist zwar durch can:billing:manage geschützt, die Kundenroute /admin/me/legacy-invoices/{id}/pdf aber nicht. Die IDs sind fortlaufend und lassen sich deshalb durchzählen. Die Policy für neue Rechnungen (InvoicePolicy) macht es bereits richtig.
  • S-02 – attachExistingContact() in admin/companies/show prüft keine Berechtigung. Zusätzlich ist $id nicht #[Locked]. Ein Editor kann damit Kontakte in beliebige Firmen verschieben. Zusammen mit S-03 wird daraus eine Rechteausweitung: Ein Kontakt mit einer Adresse, die der Editor selbst kontrolliert, wird in eine fremde Firma verschoben. Beim nächsten Login vergibt ContactAccessService::linkMemberships() automatisch die Rolle responsible für diese Firma.
    • Gegencheck: Die anderen Admin-Seiten ohne can:-Routengruppe (users/create, users/{id}/edit, roles/*, contacts/index) prüfen ihre Rechte in der Komponente selbst. S-02 ist also ein Einzelfall, kein flächiges Muster.
  • S-03 – syncMembershipsForEmail() widerruft veraltete Auto-Rechte korrekt. Aufgerufen wird es aber nur beim Login und bei der Bestätigung einer E-Mail-Änderung. Die Sitzung läuft gleitend (SESSION_LIFETIME=120 Minuten Inaktivität). Ein aktiver Nutzer behält die Rechte deshalb praktisch unbegrenzt. Das typische Szenario: Eine Firma löscht den Pressekontakt einer ausgeschiedenen Mitarbeiterin, und diese arbeitet in ihrer offenen Sitzung weiter in der Firma. Betroffen sind alle Wege, auf denen Kontakte oder Firmenadressen geändert werden: Kunden-Newsroom, Admin-CRM, API und S-02.
  • S-04 – Bestätigt für alle drei Wege: Passwort-Reset, Kunden-Sicherheitsseite und settings/password (zusätzlich Fortifys PUT /user/password, weil Features::updatePasswords() aktiv ist). Der passende Mechanismus existiert schon: SessionRevocation mit der Middleware EnsureSessionIsNotRevoked, die bereits in der web-Gruppe hängt. Er wird nur nicht aufgerufen. Beim angemeldeten Passwortwechsel wird außerdem der remember_token nicht rotiert.
  • S-05 – Bestätigt: Die Zuordnung läuft nur über die E-Mail, email_verified wird nicht geprüft, und eine abweichende oauth_provider_id wird still überschrieben. Herabgestuft auf Mittel. Wer das Postfach tatsächlich kontrolliert (z. B. nach einer neu registrierten Domain), kommt ohnehin per Magic Link hinein; Google ist dann kein zusätzlicher Weg. Ein echtes Mehrrisiko besteht nur in einem Fall: Ein privates Google-Konto wurde mit einer Firmenadresse angelegt, und das Firmenpostfach gibt es nicht mehr. Ein typisches Beispiel ist eine ehemalige PR-Mitarbeiterin.
  • S-06 – Bestätigt. Bei Konten ohne Passwort und ohne 2FA prüft assertSecurityProof() gar nichts. Wer eine fremde Sitzung hat, kann die Login-Adresse auf ein eigenes Postfach umstellen und das Konto dauerhaft übernehmen. Die Bestätigung geht dabei nur an die neue Adresse. Die bisherige Adresse erfährt davon nichts: Es gibt keine Hinweis-Mail, nur EmailChangeConfirmation an die neue Adresse.

Was weniger dringend ist als es klingt

  • npm „kritisch“ (shell-quote, source-map-js) – Beides sind reine Build-Werkzeuge: concurrently für dev:all und PostCSS/Tailwind. Sie laufen nur bei npm ci / npm run build im Deploy und sind aus dem Web nicht erreichbar. Ein schnelles Update lohnt sich trotzdem, ist aber kein Notfall.
  • Proxy-Vertrauen at: '*' – Die Host-Whitelist in bootstrap/app.php fängt X-Forwarded-Host-Missbrauch bereits ab. Offen ist nur die IP-Fälschung über X-Forwarded-For. Die ist harmlos, solange der App-Container ausschließlich über Traefik erreichbar ist. Das muss einmal auf dem Server geprüft werden; Code ist dafür vorerst nicht nötig.
  • CSP, Sanctum-Token-Ablauf, Login-Rate-Limit pro IP – Sinnvolle Härtung, aber keine akute Lücke (Phase 4).

2. Entscheidungen vorab (Kevin)

Diese Punkte bestimmen die Umsetzung von Phase 1 bis 3 mit. Empfehlung jeweils fett.

  1. API-Tokens beim Passwort-Reset (S-04): automatisch widerrufen oder bestehen lassen? Empfehlung: bestehen lassen. Kunden-Integrationen würden sonst ohne Vorwarnung brechen. Die Erfolgsmeldung und die Hinweis-Mail nennen dafür ausdrücklich „API-Tokens unter Sicherheit prüfen“. Offene Magic Links werden dagegen entwertet.
  2. Angemeldeter Passwortwechsel (S-04): andere Sitzungen immer beenden oder nur auf Wunsch? Empfehlung: immer. Das ist der erwartbare Standard und spart eine zusätzliche Option in der Oberfläche.
  3. Erstverknüpfung Google mit bestehendem Konto (S-05 Teil B): Empfehlung: Bestätigungslink an die Adresse, außer bei gmail.com/googlemail.com oder gesetztem hd. In diesen Fällen ist Google selbst für das Postfach zuständig. Eine regelmäßige Neubestätigung bereits verknüpfter Konten wird nicht empfohlen: Der Aufwand steht in keinem Verhältnis, und Firmenrechte entzieht ohnehin S-03.
  4. Fortify-Route PUT /user/password: Sie wird von keiner Oberfläche benutzt. Empfehlung: Features::updatePasswords() abschalten, dann gibt es einen Passwortweg weniger.
  5. Pflicht-2FA für Admins/Super-Admins (Phase 4): ja/nein? Empfehlung: ja, zuerst für Super-Admins.

3. Phase 1 – Sofort (Rollengrenzen und Kontowiederherstellung)

Kleine, gut eingrenzbare Änderungen. Zusammen ungefähr ein Arbeitstag mit Tests, als ein Deploy.

1.1 S-01 – Legacy-Rechnungen nur mit billing:manage

  • app/Policies/LegacyInvoicePolicy.php: In view() den Aufruf canAccessAdmin() durch $user->can(Permission::BillingManage) ersetzen, so wie es InvoicePolicy bereits tut. Der Eigentümer-Zweig bleibt.
  • Weitere Stellen prüfen, die view/downloadPdf dieser Policy nutzen (z. B. Rechnungsliste in admin/users/show). Admins haben billing:manage, dort ändert sich also nichts.
  • Tests (neu tests/Feature/Billing/LegacyInvoiceAccessTest.php oder Ergänzung in tests/Feature/Admin/EditorPermissionBoundaryTest.php):
    • Editor erhält beim fremden PDF über me.invoices.pdf den Status 403, über admin.legacy-invoices.pdf ebenfalls 403.
    • Kunde erhält das eigene PDF mit 200 und ein fremdes mit 403.
    • Admin mit billing:manage erhält das fremde PDF mit 200 auf beiden Routen.

1.2 S-02 – Kontakt-Zuordnung auf der Firmenanzeige absichern

  • resources/views/livewire/admin/companies/show.blade.php:
    • public int $id mit #[Locked] markieren.
    • Am Anfang von attachExistingContact() den Aufruf $this->authorize('companies:write') einfügen. Damit ist auch der Weg über updatedSelectedExistingContactId() abgedeckt.
    • Das Zuordnungs-UI im Template mit @can('companies:write') umschließen; das dient nur der Optik, die eigentliche Sperre ist der Server.
  • Nach dem Umhängen wirkt der S-03-Observer aus Phase 2 automatisch.
  • Tests: Ein Editor ruft call('attachExistingContact') auf und setzt set('selectedExistingContactId', …). Beides wird abgewiesen, und company_id des Kontakts bleibt unverändert. Ein Admin darf die Aktion weiterhin ausführen. Ein set('id', …) wirft eine Exception.

1.3 S-04 – Passwortänderung beendet fremde Sitzungen

  • app/Services/Auth/SessionRevocation.php um eine neue Methode revokeOthersFor(User $user, Session $current) ergänzen:
    • revokeAllFor($user) aufrufen.
    • Danach die aktuelle Session mit SESSION_KEY = revokedAt + 1 neu stempeln und regenerate() aufrufen.
    • Damit bleibt die eigene Sitzung gültig, alle anderen fliegen beim nächsten Request raus. Das gilt auch für Livewire-Requests, weil die Middleware in der web-Gruppe hängt.
  • Einen zentralen Service für den angemeldeten Passwortwechsel anlegen (z. B. app/Services/Auth/PasswordChanger.php). Er setzt den Hash, rotiert remember_token und ruft revokeOthersFor() auf.
    • Verwendet wird er in customer/security.blade.php und settings/password.blade.php sowie in UpdateUserPassword, falls die Fortify-Route bleibt (Entscheidung 4).
  • Für den Passwort-Reset einen Listener auf Illuminate\Auth\Events\PasswordReset registrieren, analog zu stampLogin in AppServiceProvider:
    • revokeAllFor($user) aufrufen; der Reset läuft als Gast, deshalb gibt es keine eigene Sitzung zu erhalten.
    • Offene, unbenutzte MagicLink-Datensätze des Kontos entwerten.
    • Der Listener greift auch beim Go-Live-Reset (SendGoLiveMails).
  • Tests (neu tests/Feature/Auth/PasswordChangeRevokesSessionsTest.php):
    • Zwei Sitzungen mit eigenem Login-Stempel aufbauen und in der ersten das Passwort ändern.
    • Die zweite Sitzung wird auf einer geschützten Seite und bei einem Livewire-Update abgemeldet.
    • Die erste Sitzung bleibt angemeldet; das Ganze für alle Passwortwege prüfen.
    • Nach dem Reset sind beide Sitzungen ungültig und der offene Magic Link ist nicht mehr einlösbar.
    • API-Tokens bleiben bestehen, falls Entscheidung 1 so ausfällt.

Deploy Phase 1: keine Migration und kein Nachlauf.

4. Phase 2 – Rechteentzug wirksam machen

2.1 S-03 – Auto-Mitgliedschaften bei jeder Adress- und Zuordnungsänderung neu bewerten

Ansatz: ein Observer statt Aufrufen an jeder einzelnen Stelle. So sind Kunden-Newsroom, Admin-CRM, API, Importer und S-02 auf einmal abgedeckt.

  • Neuer Observer app/Observers/CompanyMembershipObserver.php, registriert für Contact und Company in AppServiceProvider.
  • Wann er auslöst:
    • Contact::updated, wenn sich email oder company_id geändert hat.
    • Contact::deleted.
    • Company::updated, wenn sich email geändert hat.
    • Company::deleted.
  • Wen er neu bewertet:
    • Alle User mit company_user.granted_via IN ('auto','auto-upgrade') für die alte Firma.
    • Zusätzlich User, deren E-Mail der alten oder neuen Adresse entspricht.
    • Für jeden davon ContactAccessService::syncMembershipsForEmail() aufrufen.
  • Umsetzungsdetails:
    • Den Observer afterCommit ausführen und synchron arbeiten lassen. Die Menge ist klein (Benutzer einer Firma), eine Queue-Verzögerung würde den Entzug wieder aufweichen.
    • syncMembershipsForEmail() bricht bei inaktiven oder unverifizierten Konten früh ab. Für den Entzug ist das unkritisch, weil diese Konten über canAccessCustomer() sowieso gesperrt sind. Trotzdem prüfen, ob der Widerruf (revokeStaleAutomaticGrants) vor diese Prüfung gezogen werden sollte.
    • Manuelle (manual) und API-Mitgliedschaften (api) bleiben unberührt; das stellt revokeStaleAutomaticGrants() bereits sicher.
  • Bulk-Updates über den Query Builder lösen keine Model-Events aus. Dafür ein neues Kommando php artisan memberships:resync-auto [--user=]. Es synchronisiert alle User mit Auto-Mitgliedschaften neu und dient als Reparatur, analog zu press-releases:reindex-search.
  • Tests (neu tests/Feature/Auth/AutoMembershipRevocationTest.php):
    • Kontakt löschen, Kontakt-E-Mail ändern, Kontakt in eine andere Firma verschieben und Firmen-E-Mail ändern.
    • In jedem dieser Fälle sieht der angemeldete User in derselben Sitzung die Firma bzw. ihre PMs nicht mehr (403 oder nicht in der Liste).
    • Eine manuelle Mitgliedschaft bleibt bestehen.
    • Eine auto-upgrade-Mitgliedschaft fällt auf member zurück.
    • Gibt es eine zweite gültige Grundlage, also einen weiteren Kontakt mit derselben Adresse, bleibt das Recht erhalten.

Deploy Phase 2: Nach dem Deploy einmal php artisan memberships:resync-auto ausführen. Das bereinigt den Altbestand, also Auto-Rechte, deren Grundlage schon heute fehlt. Vorher die Zahl der betroffenen Pivots per Read-only-Query festhalten.

2.2 S-05 Teil A – Google-Identität fest binden (ohne Produktentscheidung)

  • GoogleController::callback(): email_verified aus $googleUser->user lesen. Ist der Wert nicht true, wird der Login abgebrochen.
  • SocialAuthService::resolveUser():
    1. Zuerst nach oauth_provider = google und oauth_provider_id suchen.
    2. Erst danach über die E-Mail suchen.
    3. Hat das gefundene Konto bereits eine andere Google-ID, wird nicht überschrieben. Der Login wird mit einem Hinweis abgewiesen („Bitte per E-Mail-Link anmelden“), das Ereignis wird ohne personenbezogene Details protokolliert.
  • Den hd-Claim (falls vorhanden) mitgeben, damit Teil B ihn nutzen kann.
  • Tests: Google-Mock mit email_verified=false bzw. ohne den Claim wird abgewiesen. Eine abweichende Provider-ID wird abgewiesen, und die gespeicherte ID bleibt unverändert. Die bekannte Provider-ID meldet an, auch wenn sich die Google-Mail geändert hat. Dabei wird die lokale E-Mail nicht automatisch übernommen.

5. Phase 3 – Frischer Identitätsnachweis

3.1 Gemeinsamer Baustein „Sicherheitsbestätigung“

Er wird von S-05 Teil B und von S-06 gebraucht, deshalb einmal sauber bauen:

  • Nachweis je nach Kontoart:
    • Konto mit 2FA: TOTP- oder Recovery-Code (wie heute).
    • Konto mit Passwort: Passwort.
    • Konto ohne beides: 6-stelliger Code per Mail an die aktuelle Adresse. Er gilt 10 Minuten, wird gehasht im Cache gespeichert und ist auf 5 Versuche begrenzt.
  • Nach erfolgreichem Nachweis setzt der Baustein auth.security_confirmed_at in der Session. Für die nächsten 15 Minuten ist dann keine erneute Abfrage nötig, wie bei Laravels password.confirm.
  • Umsetzung als Trait bzw. Service, der assertSecurityProof() in customer/security.blade.php ersetzt.

3.2 S-06 – Einsatz der Sicherheitsbestätigung

  • Pflicht vor:
    • erstmaliger 2FA-Einrichtung (enableTwoFactorAuthentication),
    • E-Mail-Wechsel (updateEmail),
    • Erzeugung eines API-Tokens (customer/tokens).
  • Neue Hinweis-Mail an die bisherige Adresse (z. B. app/Mail/SecurityChangeNotice.php) bei:
    • bestätigtem E-Mail-Wechsel,
    • aktivierter oder deaktivierter 2FA,
    • geändertem oder zurückgesetztem Passwort.
  • Die Hinweis-Mail enthält Zeitpunkt und Art der Änderung sowie „Waren Sie das nicht? → Passwort zurücksetzen / Support“. Sie enthält keine Codes oder Links mit Anmeldewirkung.
  • Tests:
    • Ohne frische Bestätigung schlagen 2FA-Einrichtung und E-Mail-Wechsel eines passwortlosen Kontos fehl.
    • Mit gültigem Mail-Code klappt beides.
    • Ein abgelaufener oder falscher Code und der 6. Versuch werden abgewiesen.
    • Die Hinweis-Mail geht an die alte Adresse.

3.3 S-05 Teil B – Erstverknüpfung bestätigen (nach Entscheidung 3)

  • Existiert ein verifiziertes Konto ohne Google-Verknüpfung und ist die Adresse weder gmail.com/googlemail.com noch mit hd versehen, gilt:
    • Die Google-ID wird nur vorgemerkt (Cache, signierter Link, Gültigkeit 30 Minuten).
    • An die Adresse geht ein Link „Google-Konto verknüpfen“.
    • Erst nach dem Klick wird oauth_provider_id gespeichert und angemeldet.
  • Tests: externe Adresse ohne hd → keine Anmeldung, Mail versendet, nach Klick verknüpft. Bei gmail.com oder gesetztem hd → direkte Verknüpfung wie bisher.

6. Phase 4 – Härtung (nach Bedarf, einzeln einplanbar)

Thema Maßnahme Aufwand
npm-Advisories ✅ v1.6.0: source-map-js 1.2.2, shell-quote per overrides auf ^1.11.0 (1.12.0) — concurrently legt 1.9.0 fest, npm audit fix stufte nur herab. npm audit: 0. –
Proxy ✅ v1.5.0: Live hat keinen Proxy (nginx direkt). config/trustedproxy.php: lokal * (Traefik), sonst keinem; ein CDN später über TRUSTED_PROXIES. –
HSTS ✅ v1.5.0: SecurityHeaders setzt max-age=31536000 in Produktion auf HTTPS (ohne includeSubDomains/preload). –
Pflicht-2FA Admins ✅ Umgesetzt als Entscheidung 5 (Passkeys oder App, Super-Admins und Admins), siehe Abschnitt 0. –
Passwortregeln ✅ v1.6.0: mindestens 12 Zeichen für neue und geänderte Passwörter, in Produktion zusätzlich uncompromised(). Bestehende Passwörter bleiben gültig. –
Widerruf dauerhaft ✅ v1.6.0: Spalte users.sessions_revoked_at_ms (Millisekunden wie der Session-Stempel), Middleware liest sie am geladenen User. Übergangsweise zählt die alte Cache-Marke mit — im Release danach entfernen. Zusätzlich Knopf „Andere Geräte abmelden“ auf der Sicherheitsseite. –
API-Tokens ✅ v1.5.0: optionales Ablaufdatum, Zugang endet mit der Buchung (403), sanctum:prune-expired täglich. –
Login-Limit ✅ v1.6.0: LoginIpThrottle, 20 Fehlversuche je IP in 15 Minuten über alle Konten; zählt per Failed-Event für Livewire-Login und POST /login. –
CSP (Stufe 1) ✅ v1.6.0: Content-Security-Policy-Report-Only in Produktion, Meldungen über /csp-report nach storage/logs/csp-*.log (je Meldung höchstens einmal pro Stunde, ohne Query und Tokens). Offen: nach einigen Tagen auswerten, Liste ergänzen, dann scharf schalten; 'unsafe-inline'/'unsafe-eval' bleiben bis zu einer Nonce-Lösung. später

7. Ablauf und Abnahme

Die Phasen 1–3 und Entscheidung 5 sind zusammen umgesetzt und gehen als ein Release per Git-Deploy raus. Release, Deploy, Nachlauf und Rollback stehen in Abschnitt 0 unter „Release und Deploy“. Die restlichen Punkte aus Phase 4 folgen als eigene Releases nach Kapazität.

Vor jedem Release: volle Suite (php artisan test --compact --parallel), Larastan, vendor/bin/pint --dirty --format agent.

Nach dem Deploy im Audit-Dokument je Befund den Release-Tag nachtragen.