security: Sicherheitsaudit 07.10.2026 (S-01–S-06) behoben, Passkeys und Zweitfaktor-Pflicht für Admins

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>
This commit is contained in:
Kevin Adametz 2026-10-07 16:09:35 +00:00
parent fd8dd74d5f
commit bae8347f6c
66 changed files with 4035 additions and 144 deletions

View file

@ -0,0 +1,105 @@
<?php
namespace App\Observers;
use App\Models\Company;
use App\Models\Contact;
use App\Models\User;
use App\Services\Auth\ContactAccessService;
use App\Support\EmailQuery;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;
/**
* Bewertet automatisch vergebene Firmenrechte neu, sobald sich ihre
* Grundlage ändert (Sicherheitsaudit 07.10.2026, S-03): Kontakt- oder
* Firmenadresse geändert, Kontakt in eine andere Firma verschoben, gelöscht
* oder wiederhergestellt, Login-Adresse eines Users geändert.
*
* Bisher geschah das nur beim Login — ein aktiver Nutzer behielt entzogene
* Rechte so lange, wie seine Session lief. Am Model statt in den
* Formularen: Kunden-Newsroom, Admin-CRM, API und Importer schreiben dieselben
* Felder. Läuft synchron in derselben Transaktion; ein Rollback nimmt die
* Rechteänderung mit zurück. Massen-Updates per Query-Builder lösen keine
* Events aus — dafür `php artisan memberships:resync-auto`.
*/
class CompanyMembershipObserver
{
public function __construct(private readonly ContactAccessService $contactAccess) {}
public function updated(Model $model): void
{
if ($model instanceof User) {
if ($model->wasChanged('email')) {
$this->contactAccess->syncMembershipsForEmail($model);
}
return;
}
$relevant = $model instanceof Contact ? ['email', 'company_id'] : ['email'];
if (! $model->wasChanged($relevant)) {
return;
}
$this->resync(
emails: [$model->getOriginal('email'), $model->getAttribute('email')],
companyIds: $this->companyIdsOf($model, withOriginal: true),
);
}
public function deleted(Model $model): void
{
if ($model instanceof User) {
return;
}
$this->resync([$model->getAttribute('email')], $this->companyIdsOf($model));
}
public function restored(Model $model): void
{
$this->deleted($model);
}
/**
* @return list<int>
*/
private function companyIdsOf(Company|Contact|Model $model, bool $withOriginal = false): array
{
$ids = $model instanceof Contact
? [$model->getAttribute('company_id'), $withOriginal ? $model->getOriginal('company_id') : null]
: [$model->getKey()];
return array_values(array_unique(array_map('intval', array_filter($ids))));
}
/**
* Alle User, deren automatische Rechte an den betroffenen Firmen hängen,
* sowie alle User mit einer der betroffenen Adressen.
*
* @param list<string|null> $emails
* @param list<int> $companyIds
*/
private function resync(array $emails, array $companyIds): void
{
$userIds = $companyIds === [] ? collect() : DB::table('company_user')
->whereIn('company_id', $companyIds)
->whereIn('granted_via', ['auto', 'auto-upgrade'])
->pluck('user_id');
/** @var Collection<int, User> $users */
$users = User::query()->whereIn('id', $userIds->all())->get();
foreach (array_unique(array_filter(array_map(
fn (?string $email): string => mb_strtolower(trim((string) $email)),
$emails,
))) as $email) {
$users = $users->merge(EmailQuery::whereEmail(User::query(), $email)->get());
}
$users->unique('id')->each(fn (User $user) => $this->contactAccess->syncMembershipsForEmail($user));
}
}