Audit: dev/sicherheit/SICHERHEITSAUDIT-2026-10-07.md, Plan und Entscheidungen: dev/sicherheit/ENTWICKLUNGSPLAN-SICHERHEIT-2026-10-07.md. - S-01 Legacy-Rechnungen fremder Kunden nur mit billing:manage (auch Kundenroute). - S-02 Kontaktzuordnung auf der Firmenanzeige verlangt companies:write, $id gesperrt. - S-03 CompanyMembershipObserver: automatische Firmenrechte werden bei jeder Adress-/Zuordnungsänderung sofort neu bewertet; memberships:resync-auto. - S-04 Passwortwechsel beendet alle anderen Sessions (PasswordChanger), Reset alle Sessions und offene Login-Links; Fortify PUT /user/password abgeschaltet. API-Tokens bleiben bestehen. - S-05 Google: email_verified Pflicht, feste Bindung über die Provider-ID; Erstverknüpfung bestehender Konten außerhalb gmail/hd nur per Bestätigungslink in derselben Browser-Session. - S-06 Frischer Identitätsnachweis (Passwort, App-Code, E-Mail-Code oder Passkey) vor E-Mail-Wechsel, erster 2FA-Einrichtung und Token-Erzeugung; Hinweis-Mails an die (bisherige) Kontoadresse. - Entscheidung 5: Passkeys (Fortify, @laravel/passkeys) für alle; Super-Admins und Admins bestätigen die Session für den Admin-Bereich per Passkey oder App-Code (EnsureStrongAdminAuthentication, Notfallschalter AUTH_REQUIRE_STRONG_ADMIN); Reset des zweiten Faktors durch Super-Admins. Neue Migration: create_passkeys_table. Suite 2.403 grün (1 übersprungen), Larastan ohne Meldung. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
28 KiB
Entwicklungsplan – Behebung Sicherheitsaudit vom 07.10.2026
Grundlage: SICHERHEITSAUDIT-2026-10-07.md, Repository-Stand 2215826
Stand: 07.10.2026 – Phasen 1–3 und Entscheidung 5 (Passkeys, Pflicht-Zweitfaktor für Admins) umgesetzt, noch nicht committet/deployt; übrige Phase 4 offen
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 customerintests/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, bindetlaravel/passkeys0.2 ein, also eine frühe Version). Neue Tabellepasskeys. Das User-Model implementiertPasskeyUser. - Domain: Gebunden an die Portal-Domain (
APP_PORTAL_NAME/APP_PORTAL_URL). Nur dort laufen Login und Sicherheitsseite. Für die Kennung lieber ein eigenesPASSKEYS_USER_HANDLE_SECRETsetzen; ohne Angabe giltAPP_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).PasskeyLoginResponseprotokolliert 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
EnsurePasskeyManagementIsConfirmedden 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.
- Hinzufügen und Entfernen nur mit frischem Nachweis (S-06). Bei der Registrierung prüft
- Admin-Pflicht:
EnsureStrongAdminAuthenticationan 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.
- Super-Admins und Admins bestätigen die Session einmal per Passkey oder Authenticator-Code auf
- Gerät verloren: Ein Super-Admin setzt Passkeys und App eines anderen Kontos in
admin/users/{id}/editzurü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):
mainmuss sauber sein, die volle Suite und Larastan grün. Vorher klären, was mit den unversionierten Ordnern.ai/unddev/2026-10/passiert (committen oder ignorieren).npm ci && npm run build, danachpublic/buildmitcommitten. Neu ist der Eintragpasskeys.js, außerdem kommt@laravel/passkeysinpackage.json/package-lock.jsondazu.git tag -a v1.4.0 -m "v1.4.0 — Sicherheitsaudit S-01–S-06, Passkeys, Zweitfaktor-Pflicht für Admins"und anschließendgit push origin main --follow-tagssowiegit push NAS main --follow-tags.
Vor dem Deploy auf Live (Abschnitt D):
- DB-Backup ziehen, weil die Migration
create_passkeys_tablemitkommt. Die neue Tabelle ist klein; der Fremdschlüssel aufusersbautusersnicht um. .envauf Live vorher ergänzen bzw. prüfen:APP_PORTAL_NAMEundAPP_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_ADMINnicht setzen (Standard: an). Es ist nur der Notfallschalter.
- Zahl der automatischen Firmenrechte festhalten (read-only):
SELECT COUNT(*) FROM company_user WHERE role='responsible' AND granted_via IN ('auto','auto-upgrade');
Deploy:
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) undqueue:restart. - Zum Schluss prüft es den Worker. Der wird gebraucht, weil Hinweis-Mails, Bestätigungscodes und Google-Verknüpfungslinks über die Queue laufen.
- Das Skript erledigt Migration,
Nach dem Deploy:
php artisan memberships:resync-auto. Die Ausgabe nennt die Zahl der automatischen Rechte vorher und nachher; sie mit Schritt 6 vergleichen.- Die üblichen Prüfungen aus Abschnitt D, Punkt 17.
- 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.
- Einen Eintrag im Protokoll von
dev/betrieb/git-deploy.mdergä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: lokale Datenbank geleert (nicht durch diese Umsetzung).
- Was das Binärlog zeigt: Um 09:44 UTC (
binlog.000009, Pos. 566161486) wurden alle Tabellen vonpressekontoaufglobal-mysqlgelöscht, durch einmigrate:fresh(RefreshDatabase) eines frischen Demo-Projekts. In diesem Projekt wurden die Vorlagen ausdev/git-deploy-monitoringgetestet (Commit2215826). - Ursache:
docker-compose.ymlsetzteDB_*als Container-Umgebungsvariablen. Die haben in Laravel Vorrang vor der.env. Diephpunit.xmldes Demo-Projekts erzwang kein SQLite. - Unsere Testsuite war nicht beteiligt: Nach 09:44 gibt es im Binärlog kein weiteres
DROPoderTRUNCATE. Die Gegenprobe mit gesetzten MySQL-Variablen bleibt auf SQLite. - Behoben:
DB_*ausdocker-compose.ymlentfernt; die Werte stehen identisch in der.env. Wirksam erst nach dem Neuerstellen des Containers.- In
dev/git-deploy-monitoringsteht jetzt „Testdatenbank absichern“ als Pflicht vorRefreshDatabase, mitphpunit.xml-Zeilen, Guard und Gegenprobe. - Agenten-Regel in
.ai/rules/general.mdundCLAUDE.mdergä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-mysqleigene MySQL-Benutzer je Projekt stattroot.
Offen geblieben:
- Phase 4 (Härtung) ohne Entscheidung 5: npm-Advisories, Proxy, HSTS, Passwortregeln, dauerhafte Widerrufsmarke, API-Token-Ablauf, Login-Limit, CSP.
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 mitcanAccessAdmin()frei, also auch Editoren. Die Admin-Route ist zwar durchcan:billing:managegeschützt, die Kundenroute/admin/me/legacy-invoices/{id}/pdfaber 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()inadmin/companies/showprüft keine Berechtigung. Zusätzlich ist$idnicht#[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 vergibtContactAccessService::linkMemberships()automatisch die Rolleresponsiblefü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.
- Gegencheck: Die anderen Admin-Seiten ohne
- 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=120Minuten 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 FortifysPUT /user/password, weilFeatures::updatePasswords()aktiv ist). Der passende Mechanismus existiert schon:SessionRevocationmit der MiddlewareEnsureSessionIsNotRevoked, die bereits in derweb-Gruppe hängt. Er wird nur nicht aufgerufen. Beim angemeldeten Passwortwechsel wird außerdem derremember_tokennicht rotiert. - S-05 – Bestätigt: Die Zuordnung läuft nur über die E-Mail,
email_verifiedwird nicht geprüft, und eine abweichendeoauth_provider_idwird 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, nurEmailChangeConfirmationan die neue Adresse.
Was weniger dringend ist als es klingt
- npm „kritisch“ (
shell-quote,source-map-js) – Beides sind reine Build-Werkzeuge:concurrentlyfürdev:allund PostCSS/Tailwind. Sie laufen nur beinpm ci/npm run buildim Deploy und sind aus dem Web nicht erreichbar. Ein schnelles Update lohnt sich trotzdem, ist aber kein Notfall. - Proxy-Vertrauen
at: '*'– Die Host-Whitelist inbootstrap/app.phpfängtX-Forwarded-Host-Missbrauch bereits ab. Offen ist nur die IP-Fälschung überX-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.
- 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.
- 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.
- Erstverknüpfung Google mit bestehendem Konto (S-05 Teil B):
Empfehlung: Bestätigungslink an die Adresse, außer bei
gmail.com/googlemail.comoder gesetztemhd. 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. - Fortify-Route
PUT /user/password: Sie wird von keiner Oberfläche benutzt. Empfehlung:Features::updatePasswords()abschalten, dann gibt es einen Passwortweg weniger. - 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: Inview()den AufrufcanAccessAdmin()durch$user->can(Permission::BillingManage)ersetzen, so wie esInvoicePolicybereits tut. Der Eigentümer-Zweig bleibt.- Weitere Stellen prüfen, die
view/downloadPdfdieser Policy nutzen (z. B. Rechnungsliste inadmin/users/show). Admins habenbilling:manage, dort ändert sich also nichts. - Tests (neu
tests/Feature/Billing/LegacyInvoiceAccessTest.phpoder Ergänzung intests/Feature/Admin/EditorPermissionBoundaryTest.php):- Editor erhält beim fremden PDF über
me.invoices.pdfden Status 403, überadmin.legacy-invoices.pdfebenfalls 403. - Kunde erhält das eigene PDF mit 200 und ein fremdes mit 403.
- Admin mit
billing:manageerhält das fremde PDF mit 200 auf beiden Routen.
- Editor erhält beim fremden PDF über
1.2 S-02 – Kontakt-Zuordnung auf der Firmenanzeige absichern
resources/views/livewire/admin/companies/show.blade.php:public int $idmit#[Locked]markieren.- Am Anfang von
attachExistingContact()den Aufruf$this->authorize('companies:write')einfügen. Damit ist auch der Weg überupdatedSelectedExistingContactId()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 setztset('selectedExistingContactId', …). Beides wird abgewiesen, undcompany_iddes Kontakts bleibt unverändert. Ein Admin darf die Aktion weiterhin ausführen. Einset('id', …)wirft eine Exception.
1.3 S-04 – Passwortänderung beendet fremde Sitzungen
app/Services/Auth/SessionRevocation.phpum eine neue MethoderevokeOthersFor(User $user, Session $current)ergänzen:revokeAllFor($user)aufrufen.- Danach die aktuelle Session mit
SESSION_KEY = revokedAt + 1neu stempeln undregenerate()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, rotiertremember_tokenund ruftrevokeOthersFor()auf.- Verwendet wird er in
customer/security.blade.phpundsettings/password.blade.phpsowie inUpdateUserPassword, falls die Fortify-Route bleibt (Entscheidung 4).
- Verwendet wird er in
- Für den Passwort-Reset einen Listener auf
Illuminate\Auth\Events\PasswordResetregistrieren, analog zustampLogininAppServiceProvider: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ürContactundCompanyinAppServiceProvider. - Wann er auslöst:
Contact::updated, wenn sichemailodercompany_idgeändert hat.Contact::deleted.Company::updated, wenn sichemailgeä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.
- Alle User mit
- Umsetzungsdetails:
- Den Observer
afterCommitausfü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 übercanAccessCustomer()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 stelltrevokeStaleAutomaticGrants()bereits sicher.
- Den Observer
- 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 zupress-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 aufmemberzurü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_verifiedaus$googleUser->userlesen. Ist der Wert nichttrue, wird der Login abgebrochen.SocialAuthService::resolveUser():- Zuerst nach
oauth_provider = googleundoauth_provider_idsuchen. - Erst danach über die E-Mail suchen.
- 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.
- Zuerst nach
- Den
hd-Claim (falls vorhanden) mitgeben, damit Teil B ihn nutzen kann. - Tests: Google-Mock mit
email_verified=falsebzw. 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_atin der Session. Für die nächsten 15 Minuten ist dann keine erneute Abfrage nötig, wie bei Laravelspassword.confirm. - Umsetzung als Trait bzw. Service, der
assertSecurityProof()incustomer/security.blade.phpersetzt.
3.2 S-06 – Einsatz der Sicherheitsbestätigung
- Pflicht vor:
- erstmaliger 2FA-Einrichtung (
enableTwoFactorAuthentication), - E-Mail-Wechsel (
updateEmail), - Erzeugung eines API-Tokens (
customer/tokens).
- erstmaliger 2FA-Einrichtung (
- 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.comnoch mithdversehen, 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_idgespeichert und angemeldet.
- Tests: externe Adresse ohne
hd→ keine Anmeldung, Mail versendet, nach Klick verknüpft. Beigmail.comoder gesetztemhd→ direkte Verknüpfung wie bisher.
6. Phase 4 – Härtung (nach Bedarf, einzeln einplanbar)
| Thema | Maßnahme | Aufwand |
|---|---|---|
| npm-Advisories | npm update shell-quote source-map-js (oder overrides in package.json), danach npm run build und npm audit --omit=dev. |
15 min |
| Proxy | Auf dem Server prüfen, dass der App-Container keinen veröffentlichten Port hat und nur über Traefik erreichbar ist. Dann trustProxies(at: …) auf das Docker-Netz begrenzen. |
30 min |
| HSTS | Prüfen, ob Traefik HSTS setzt (curl -sI https://pressekonto.de). Falls nicht, dort ergänzen. |
15 min |
| ✅ Umgesetzt als Entscheidung 5 (Passkeys oder App, Super-Admins und Admins), siehe Abschnitt 0. | – | |
| Passwortregeln | In AppServiceProvider Password::defaults(fn () => Password::min(12)->uncompromised()) nur in Produktion setzen. uncompromised() fragt HIBP per k-Anonymität ab. |
1 h |
| Widerruf dauerhaft | Die Marke aus SessionRevocation zusätzlich als Spalte users.sessions_revoked_at speichern, damit ein cache:clear widerrufene Sitzungen nicht wieder freigibt. users hat FULLTEXT, ist aber mit 25 MB klein (siehe CLAUDE.md). |
2 h |
| API-Tokens | Optionales Ablaufdatum bei der Erzeugung und Anzeige „zuletzt verwendet“. Kein globales expiration, sonst brechen Integrationen. |
2 h |
| Login-Limit | Zusätzliches IP-weites Limit (z. B. 50/min), gegen Password-Spraying. | 1 h |
| CSP | Content-Security-Policy-Report-Only passend zu Livewire/Alpine einführen, Verstöße in GlitchTip sammeln und später scharf schalten. |
1 Tag |
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.