From 1ccb4a8479f12eeff43867bf4f84242c127fc857 Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Fri, 2 Oct 2026 13:09:00 +0000 Subject: [PATCH] =?UTF-8?q?auth/security:=20API-Firmenzuordnung=20nur=20f?= =?UTF-8?q?=C3=BCr=20Mitglieder=20oder=20Allowlist,=20Google-Vorab=C3=BCbe?= =?UTF-8?q?rnahme=20geschlossen,=20gemeinsamer=20Login-Abschluss=20(Umsetz?= =?UTF-8?q?ungsaudit=2029.09.:=20R-01,=20K-04,=20K-07,=20K-08,=20B-10)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - R-01: ApiNewsroomAssignment hängt an eine bestehende Firma nur noch Owner, Mitglieder oder Nutzer aus NEWSROOM_API_ASSIGNMENT_USER_IDS; Pivot mit granted_via = api; Befehl company-memberships:api-assignments (Liste, --remove mit Vorschau) - K-04: OAuth-Verifikation eines offenen Kontos verwirft Passwort, remember_token und Sanctum-Tokens; SessionRevocation + Middleware EnsureSessionIsNotRevoked widerruft alte Sessions (Redis-tauglich) - K-08: LoginCompletion für Passwort, Magic Link und Google — last_login_at und Anmeldemethode auch nach 2FA, Landeziel über die Challenge, Google ohne erzwungenes remember, magic-login ohne guest für eigene Links - B-10: eigener Magic-Link-Zweck image_confirmation (Migration) - K-07: legalAcceptanceVersions #[Locked], Versionswechsel beim Absenden wird abgelehnt, Gate-Abfragen je Request zwischengespeichert Co-Authored-By: Claude Opus 5.5 --- .../Commands/ReviewApiCompanyAssignments.php | 224 ++++++++++++++++++ .../Controllers/Auth/GoogleController.php | 24 +- .../Auth/MagicLinkConsumeController.php | 83 +++++-- .../Middleware/EnsureSessionIsNotRevoked.php | 52 ++++ app/Http/Responses/RoleAwareLoginResponse.php | 7 + app/Listeners/RecordAuthActivity.php | 9 +- .../Concerns/InteractsWithAiDeclaration.php | 108 ++++++++- app/Models/MagicLink.php | 16 ++ app/Providers/AppServiceProvider.php | 5 + app/Services/Auth/ContactAccessService.php | 4 +- app/Services/Auth/LoginCompletion.php | 98 ++++++++ app/Services/Auth/MagicLinkGenerator.php | 47 ++++ app/Services/Auth/SessionRevocation.php | 75 ++++++ app/Services/Auth/SocialAuthService.php | 16 +- .../Company/ApiNewsroomAssignment.php | 43 +++- .../Conversion/ConversionRecorder.php | 2 +- bootstrap/app.php | 6 + config/newsroom.php | 29 ++- ...firmation_purpose_to_magic_links_table.php | 46 ++++ .../press-release-submit-modal.blade.php | 20 +- resources/views/livewire/auth/login.blade.php | 11 +- .../customer/press-releases/create.blade.php | 3 +- .../customer/press-releases/edit.blade.php | 3 +- .../customer/press-releases/index.blade.php | 3 +- routes/auth.php | 5 +- .../Feature/Api/ApiNewsroomAssignmentTest.php | 99 +++++++- tests/Feature/Auth/GoogleLoginTest.php | 55 +++++ tests/Feature/Auth/LoginCompletionTest.php | 165 +++++++++++++ .../Feature/Legal/LegalAcceptanceGateTest.php | 102 ++++++++ 29 files changed, 1287 insertions(+), 73 deletions(-) create mode 100644 app/Console/Commands/ReviewApiCompanyAssignments.php create mode 100644 app/Http/Middleware/EnsureSessionIsNotRevoked.php create mode 100644 app/Services/Auth/LoginCompletion.php create mode 100644 app/Services/Auth/SessionRevocation.php create mode 100644 database/migrations/2026_09_29_180200_add_image_confirmation_purpose_to_magic_links_table.php create mode 100644 tests/Feature/Auth/LoginCompletionTest.php diff --git a/app/Console/Commands/ReviewApiCompanyAssignments.php b/app/Console/Commands/ReviewApiCompanyAssignments.php new file mode 100644 index 00000000..86be45d3 --- /dev/null +++ b/app/Console/Commands/ReviewApiCompanyAssignments.php @@ -0,0 +1,224 @@ +assignmentRows(Carbon::parse((string) $this->option('since'))->startOfDay()); + $foreign = $rows->reject(fn (object $row): bool => $row->allowed)->values(); + + $this->info(sprintf( + '%d über die API-Zuordnung entstandene Mitgliedschaft(en), davon %d ohne Freischaltung.', + $rows->count(), + $foreign->count(), + )); + + if ($rows->isNotEmpty()) { + $this->table( + ['Firma', 'Firmenname', 'User', 'E-Mail', 'Herkunft', 'Angelegt', 'Freigeschaltet'], + $rows->take(100)->map(fn (object $row): array => $this->tableRow($row))->all(), + ); + } + + if (! $this->option('remove')) { + if ($foreign->isNotEmpty()) { + $this->line('Entfernen mit --remove (zeigt vorher die betroffenen Zeilen).'); + } + + return self::SUCCESS; + } + + if ($foreign->isEmpty()) { + $this->info('Nichts zu entfernen.'); + + return self::SUCCESS; + } + + $this->warn(sprintf('Zu entfernen (%d):', $foreign->count())); + $this->table( + ['Firma', 'Firmenname', 'User', 'E-Mail', 'Herkunft', 'Angelegt', 'Freigeschaltet'], + $foreign->map(fn (object $row): array => $this->tableRow($row))->all(), + ); + + if (! $this->option('force') && ! $this->confirm(sprintf('%d Mitgliedschaft(en) entfernen?', $foreign->count()))) { + $this->line('Abgebrochen, nichts geändert.'); + + return self::SUCCESS; + } + + $removed = 0; + + foreach ($foreign as $row) { + $deleted = DB::table('company_user') + ->where('company_id', $row->company_id) + ->where('user_id', $row->user_id) + ->where('role', 'member') + ->delete(); + + if ($deleted === 0) { + continue; + } + + $removed++; + + $activityLog->record( + ActivityType::Correction, + sprintf('API-Mitgliedschaft von User #%d (%s) an Firma #%d „%s“ entfernt (Audit R-01)', $row->user_id, $row->email, $row->company_id, $row->company_name), + subject: Company::withoutGlobalScope(PortalScope::class)->find($row->company_id), + meta: ['api_assignment_removed' => true, 'user_id' => $row->user_id, 'granted_via' => $row->granted_via], + actorLabel: 'console', + ); + } + + $this->info(sprintf('%d Mitgliedschaft(en) entfernt.', $removed)); + + return self::SUCCESS; + } + + /** + * Pivots aus der API-Zuordnung: aus dem Aktivitäts-Log (Altfälle ohne + * Herkunft) und mit `granted_via = api` (ab dem Fix). + * + * @return Collection + */ + private function assignmentRows(Carbon $since): Collection + { + /** @var array $firstEvents "company:user" => erste Zuordnung */ + $firstEvents = []; + + ActivityEvent::query() + ->where('type', ActivityType::Correction->value) + ->where('subject_type', (new Company)->getMorphClass()) + ->where('description', 'like', 'API-Firma %') + ->where('occurred_at', '>=', $since) + ->whereNotNull('user_id') + ->orderBy('id') + ->select(['id', 'user_id', 'subject_id', 'occurred_at']) + ->chunkById(1000, function (Collection $events) use (&$firstEvents): void { + foreach ($events as $event) { + $key = $event->subject_id.':'.$event->user_id; + $firstEvents[$key] ??= $event->occurred_at; + } + }); + + $pairs = collect(array_keys($firstEvents))->map(fn (string $key): array => array_map('intval', explode(':', $key))); + + $rows = collect(); + + foreach ($pairs->chunk(500) as $chunk) { + $rows = $rows->merge($this->pivotQuery() + ->where(function ($query) use ($chunk): void { + foreach ($chunk as [$companyId, $userId]) { + $query->orWhere(fn ($pair) => $pair->where('company_user.company_id', $companyId)->where('company_user.user_id', $userId)); + } + }) + ->get()); + } + + $rows = $rows->merge($this->pivotQuery()->where('company_user.granted_via', ApiNewsroomAssignment::GRANTED_VIA)->get()); + + return $rows + ->unique(fn (object $row): string => $row->company_id.':'.$row->user_id) + ->filter(function (object $row) use ($firstEvents): bool { + if ((int) $row->owner_user_id === (int) $row->user_id) { + return false; + } + + if ($row->granted_via === ApiNewsroomAssignment::GRANTED_VIA) { + return true; + } + + // Schon vor der ersten Zuordnung Mitglied: nicht durch die API entstanden. + $first = $firstEvents[$row->company_id.':'.$row->user_id] ?? null; + + return $first !== null + && $row->created_at !== null + && Carbon::parse($row->created_at)->greaterThanOrEqualTo($first->copy()->subSeconds(self::EVENT_TOLERANCE_SECONDS)); + }) + ->map(function (object $row): object { + $row->allowed = ApiNewsroomAssignment::isAllowed((int) $row->user_id); + + return $row; + }) + ->sortBy(['company_id', 'user_id']) + ->values(); + } + + /** + * `member`-Pivots samt Firma und Nutzer — `responsible`/`owner` stammen + * nie aus der API-Zuordnung (eine Hochstufung hat eine E-Mail-Grundlage). + */ + private function pivotQuery(): Builder + { + return DB::table('company_user') + ->join('companies', 'companies.id', '=', 'company_user.company_id') + ->join('users', 'users.id', '=', 'company_user.user_id') + ->where('company_user.role', 'member') + ->select([ + 'company_user.company_id', 'company_user.user_id', 'company_user.granted_via', 'company_user.created_at', + 'companies.name as company_name', 'companies.owner_user_id', 'users.email', + ]); + } + + /** + * @return list + */ + private function tableRow(object $row): array + { + return [ + (string) $row->company_id, + mb_strimwidth((string) $row->company_name, 0, 40, '…'), + (string) $row->user_id, + (string) $row->email, + (string) $row->granted_via, + (string) $row->created_at, + $row->allowed ? 'ja' : 'nein', + ]; + } +} diff --git a/app/Http/Controllers/Auth/GoogleController.php b/app/Http/Controllers/Auth/GoogleController.php index a223770d..f2df20c3 100644 --- a/app/Http/Controllers/Auth/GoogleController.php +++ b/app/Http/Controllers/Auth/GoogleController.php @@ -3,12 +3,11 @@ namespace App\Http\Controllers\Auth; use App\Http\Controllers\Controller; -use App\Listeners\RecordAuthActivity; +use App\Services\Auth\LoginCompletion; use App\Services\Auth\SocialAuthService; use App\Support\LoginRedirect; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; -use Illuminate\Support\Facades\Auth; use Laravel\Socialite\Facades\Socialite; /** @@ -23,7 +22,7 @@ class GoogleController extends Controller return Socialite::driver('google')->redirect(); } - public function callback(Request $request, SocialAuthService $social): RedirectResponse + public function callback(Request $request, SocialAuthService $social, LoginCompletion $completion): RedirectResponse { try { $googleUser = Socialite::driver('google')->user(); @@ -49,25 +48,16 @@ class GoogleController extends Controller // 2FA aktiv: wie Passwort- und Magic-Link-Login nicht direkt einloggen, // sondern in Fortifys Challenge übergeben (Audit F02). Google belegt // den Besitz des Postfachs, nicht den zweiten Faktor. + // + // Kein erzwungenes „Angemeldet bleiben" mehr (Audit K-08): wie beim + // Magic Link nur die Sitzung, siehe LoginCompletion. if ($user->hasEnabledTwoFactorAuthentication()) { - $request->session()->put([ - 'login.id' => $user->getKey(), - 'login.remember' => true, - ]); - - $request->session()->regenerate(); + $completion->handOffToTwoFactor($request, $user, 'google'); return redirect()->route('two-factor.challenge'); } - $request->attributes->set(RecordAuthActivity::AUTH_METHOD_ATTRIBUTE, 'google'); - Auth::login($user, remember: true); - $request->session()->regenerate(); - - $user->forceFill([ - 'last_login_at' => now(), - 'last_login_ip' => $request->ip(), - ])->save(); + $completion->login($request, $user, 'google'); return redirect(LoginRedirect::safeTarget( $user, diff --git a/app/Http/Controllers/Auth/MagicLinkConsumeController.php b/app/Http/Controllers/Auth/MagicLinkConsumeController.php index 03833be8..24df8dad 100644 --- a/app/Http/Controllers/Auth/MagicLinkConsumeController.php +++ b/app/Http/Controllers/Auth/MagicLinkConsumeController.php @@ -4,12 +4,14 @@ namespace App\Http\Controllers\Auth; use App\Enums\ConversionEventType; use App\Http\Controllers\Controller; -use App\Listeners\RecordAuthActivity; use App\Models\MagicLink; use App\Models\PressRelease; +use App\Models\User; use App\Scopes\PortalScope; +use App\Services\Auth\LoginCompletion; use App\Services\Conversion\ConversionRecorder; use App\Services\Customer\CustomerCompanyContext; +use App\Support\LoginRedirect; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; @@ -19,6 +21,7 @@ class MagicLinkConsumeController extends Controller public function __construct( private readonly ConversionRecorder $recorder, private readonly CustomerCompanyContext $companies, + private readonly LoginCompletion $completion, ) {} public function __invoke(Request $request, string $token): RedirectResponse @@ -26,9 +29,25 @@ class MagicLinkConsumeController extends Controller $magicLink = MagicLink::query() ->with('user') ->where('token_hash', hash('sha256', $token)) - ->where('purpose', 'login') + // Login- und Bildbestätigungslinks melden gleich an (B-10). + ->whereIn('purpose', MagicLink::LOGIN_PURPOSES) ->first(); + // Bereits angemeldet (Audit K-08): Die Route liegt nicht mehr hinter + // `guest`. Öffnet ein eingeloggter Kunde seinen EIGENEN Link (etwa die + // Bildbestätigung), landet er auf dem Ziel statt auf seinem Dashboard. + // Jeder andere Link verhält sich wie früher unter `guest`: zurück + // nach Hause, der Link bleibt unberührt. + $current = Auth::guard('web')->user(); + + if ($current instanceof User) { + if (! $magicLink || $magicLink->user_id !== $current->getKey()) { + return redirect(! $current->hasVerifiedEmail() ? route('verification.notice') : LoginRedirect::homeFor($current)); + } + + return $this->openOwnLinkWhileLoggedIn($request, $magicLink, $current); + } + if (! $magicLink || ! $magicLink->user) { return redirect()->route('login')->with('status', __('The magic login link is invalid.')); } @@ -71,30 +90,56 @@ class MagicLinkConsumeController extends Controller // ersetzt nur das Passwort, nicht den zweiten Faktor. Der Link ist bereits // atomar verbraucht; bricht der Nutzer die Challenge ab, fordert er einen // neuen an. + // + // Das Landeziel geht als `url.intended` mit durch die Challenge + // (Audit K-08); RoleAwareLoginResponse übernimmt es nur, wenn der + // Nutzer es erreichen darf. if ($user->hasEnabledTwoFactorAuthentication()) { - $request->session()->put([ - 'login.id' => $user->getKey(), - 'login.remember' => false, - ]); - - $request->session()->regenerate(); + $this->completion->handOffToTwoFactor($request, $user, 'magic-link', intended: $this->landingFor($user, $magicLink)); return redirect()->route('two-factor.challenge'); } - $user->update([ - 'last_login_at' => now(), - 'last_login_ip' => $request->ip(), - ]); + $this->completion->login($request, $user, 'magic-link'); - $request->attributes->set(RecordAuthActivity::AUTH_METHOD_ATTRIBUTE, 'magic-link'); - Auth::guard('web')->login($user); - $request->session()->regenerate(); + return redirect($this->landingFor($user, $magicLink)); + } - // Rollensicherer Redirect ohne intended(): eine als Gast besuchte - // Admin-URL (z. B. /dashboard) darf einen Customer nach dem - // Magic-Link-Login nicht in den 403-Admin-Bereich schicken. + /** + * Eigener Link bei bestehender Anmeldung: kein erneuter Login. Ein noch + * gültiger Link wird wie sonst verbraucht (Einmal-Nutzung), ein schon + * verbrauchter oder abgelaufener führt trotzdem zum Ziel — der Nutzer + * ist ja angemeldet. + */ + private function openOwnLinkWhileLoggedIn(Request $request, MagicLink $magicLink, User $user): RedirectResponse + { + if ($magicLink->consumed_at === null && ! $magicLink->expires_at->isPast()) { + $claimed = MagicLink::query() + ->whereKey($magicLink->id) + ->whereNull('consumed_at') + ->update([ + 'consumed_at' => now(), + 'ip_consumed' => $request->ip(), + ]); + if ($claimed > 0) { + $this->recorder->record( + ConversionEventType::MagicLinkConsumed, + $this->recorder->sourceOf($magicLink), + ); + } + } + + return redirect($this->landingFor($user, $magicLink)); + } + + /** + * Rollensicheres Landeziel ohne intended(): eine als Gast besuchte + * Admin-URL (z. B. /dashboard) darf einen Customer nach dem + * Magic-Link-Login nicht in den 403-Admin-Bereich schicken. + */ + private function landingFor(User $user, MagicLink $magicLink): string + { $home = $user->canAccessAdmin() ? route('dashboard', absolute: false) : ($user->canAccessCustomer() @@ -122,6 +167,6 @@ class MagicLinkConsumeController extends Controller } } - return redirect($home); + return $home; } } diff --git a/app/Http/Middleware/EnsureSessionIsNotRevoked.php b/app/Http/Middleware/EnsureSessionIsNotRevoked.php new file mode 100644 index 00000000..10b61adf --- /dev/null +++ b/app/Http/Middleware/EnsureSessionIsNotRevoked.php @@ -0,0 +1,52 @@ +hasSession()) { + return $next($request); + } + + /** @var SessionGuard $guard */ + $guard = Auth::guard('web'); + + if (! $guard->hasUser() && ! $request->session()->has($guard->getName())) { + return $next($request); + } + + $user = $guard->user(); + + if ($user === null || ! $this->revocation->isRevoked((int) $user->getAuthIdentifier(), $request->session())) { + return $next($request); + } + + // Ohne logout(): das würde remember_token erneut rotieren und damit + // auch die gerade gültige Anmeldung des Kontoinhabers treffen. + $guard->logoutCurrentDevice(); + $request->session()->invalidate(); + $request->session()->regenerateToken(); + + if ($request->expectsJson()) { + abort(401); + } + + return redirect()->guest(route('login')); + } +} diff --git a/app/Http/Responses/RoleAwareLoginResponse.php b/app/Http/Responses/RoleAwareLoginResponse.php index 28b3bd9e..6fef0c29 100644 --- a/app/Http/Responses/RoleAwareLoginResponse.php +++ b/app/Http/Responses/RoleAwareLoginResponse.php @@ -2,6 +2,7 @@ namespace App\Http\Responses; +use App\Services\Auth\LoginCompletion; use App\Support\LoginRedirect; use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; @@ -49,6 +50,12 @@ class RoleAwareLoginResponse implements LoginResponseContract, TwoFactorLoginRes : redirect()->route('verification.notice'); } + // Login-Abschluss auch nach der 2FA-Challenge (Audit K-08) — die + // direkten Wege setzen ihn in LoginCompletion::login(). + if ($user) { + app(LoginCompletion::class)->recordLogin($user, $request); + } + if ($wantsJson) { return new JsonResponse('', 204); } diff --git a/app/Listeners/RecordAuthActivity.php b/app/Listeners/RecordAuthActivity.php index 01eaeae4..93621d5c 100644 --- a/app/Listeners/RecordAuthActivity.php +++ b/app/Listeners/RecordAuthActivity.php @@ -7,6 +7,7 @@ use App\Enums\ConversionEventType; use App\Enums\ConversionSource; use App\Models\User; use App\Services\ActivityLog; +use App\Services\Auth\LoginCompletion; use App\Services\Conversion\ConversionRecorder; use Illuminate\Auth\Events\Login; use Illuminate\Auth\Events\Registered; @@ -15,8 +16,9 @@ use Illuminate\Auth\Events\Registered; * Protokolliert Registrierungen und Logins im Aktivitäts-Verlauf. * * Der Anmeldeweg kommt als Request-Attribut `auth_method` von der jeweils - * einloggenden Stelle (Magic-Link-/Google-Controller, Impersonation); - * ohne Attribut ist es der Fortify-Passwort-Login. + * einloggenden Stelle (Magic-Link-/Google-Controller, Impersonation), nach + * einer 2FA-Challenge aus der Session (LoginCompletion); ohne beides ist es + * der Passwort-Login. * * Die Methoden heißen bewusst NICHT `handle*`: Laravels Event-Discovery * würde sie sonst zusätzlich zur manuellen Verdrahtung im @@ -67,7 +69,8 @@ class RecordAuthActivity return; } - $method = (string) request()->attributes->get(self::AUTH_METHOD_ATTRIBUTE, 'password'); + // Auch nach der 2FA-Challenge der ursprüngliche Weg (Audit K-08). + $method = LoginCompletion::methodOf(request()); $this->activityLog->record( ActivityType::UserLoggedIn, diff --git a/app/Livewire/Concerns/InteractsWithAiDeclaration.php b/app/Livewire/Concerns/InteractsWithAiDeclaration.php index 61f47257..610d6951 100644 --- a/app/Livewire/Concerns/InteractsWithAiDeclaration.php +++ b/app/Livewire/Concerns/InteractsWithAiDeclaration.php @@ -3,11 +3,15 @@ namespace App\Livewire\Concerns; use App\Enums\AiTextDeclaration; +use App\Models\LegalDocument; use App\Models\LegalDocumentAcceptance; use App\Services\Legal\LegalAcceptanceGate; use Illuminate\Support\Carbon; +use Illuminate\Support\Collection; use Illuminate\Validation\Rule; use Illuminate\Validation\ValidationException; +use Livewire\Attributes\Locked; +use RuntimeException; /** * KI-Kennzeichnung im Submit-Flow (Art. 50 KI-VO, Decision-Update 08.07.2026). @@ -35,20 +39,74 @@ trait InteractsWithAiDeclaration * Fassungen (type => version), die beim letzten Rendern im Submit-Modal * standen. Der Schalter bestätigt genau diese; wechselt zwischen Anzeige * und Absenden die Fassung, wird die Annahme abgelehnt und der neue - * Wortlaut gezeigt (Umsetzungsaudit 25.09.2026, N06). Livewire signiert - * den Zustand, der Browser kann die Werte nicht verändern. + * Wortlaut gezeigt (Umsetzungsaudit 25.09.2026, N06). `#[Locked]`: der + * Browser kann die Werte nicht setzen (Umsetzungsaudit 29.09.2026, K-07). * * @var array */ + #[Locked] public array $legalAcceptanceVersions = []; + /** + * Offene Fassungen für die Anzeige, einmal je Request ermittelt (OPT-7): + * Rendering-Hook und jedes Submit-Modal der Seite (die Liste hat eines + * je Zeile) teilen sich das Ergebnis. Nicht öffentlich, also nie Teil + * des Livewire-Zustands. + * + * @var Collection|null + */ + private ?Collection $legalDocumentsForDisplay = null; + /** * Livewire-Trait-Hook: läuft nach der Aktion, unmittelbar vor dem * Rendern — die Property trägt danach exakt die gezeigten Fassungen. */ public function renderingInteractsWithAiDeclaration(): void { - $this->legalAcceptanceVersions = LegalAcceptanceGate::versionsOf(app(LegalAcceptanceGate::class)->requiredFor(auth()->user())); + $this->legalAcceptanceVersions = LegalAcceptanceGate::versionsOf($this->legalDocumentsToAccept()); + } + + /** + * Offene Fassungen, wie sie das Submit-Modal zeigt (Beschreibungsform + * aus LegalAcceptanceGate::describe()). Wird von den Seiten an + * `` übergeben. + * + * @return Collection + */ + protected function legalDocumentsForSubmit(): Collection + { + $gate = app(LegalAcceptanceGate::class); + + return $this->legalDocumentsToAccept()->map(fn (LegalDocument $document): array => $gate->describe($document))->values(); + } + + /** + * Ausgeschaltetes Gate: keine Abfrage. Fehlt bei aktivem Gate ein + * Pflichtdokument, zeigt die Seite trotzdem an (K-07) — die + * Konfigurationslücke wird gemeldet und erst das Absenden scheitert + * (validateAiDeclaration()). + * + * @return Collection + */ + private function legalDocumentsToAccept(): Collection + { + if ($this->legalDocumentsForDisplay !== null) { + return $this->legalDocumentsForDisplay; + } + + $gate = app(LegalAcceptanceGate::class); + + if (! $gate->isEnabled()) { + return $this->legalDocumentsForDisplay = collect(); + } + + try { + return $this->legalDocumentsForDisplay = $gate->requiredFor(auth()->user()); + } catch (RuntimeException $exception) { + report($exception); + + return $this->legalDocumentsForDisplay = collect(); + } } /** @@ -66,7 +124,7 @@ trait InteractsWithAiDeclaration 'aiDeclarationConfirmed.accepted' => __('Bitte bestätigen, dass die Angaben zum KI-Einsatz vollständig und wahrheitsgemäß sind.'), ]; - $required = app(LegalAcceptanceGate::class)->requiredFor(auth()->user()); + $required = $this->requiredLegalDocumentsForSubmit(); if ($required->isNotEmpty()) { $rules['legalAcceptanceConfirmed'] = ['accepted']; @@ -87,20 +145,54 @@ trait InteractsWithAiDeclaration /** * Annahme protokollieren (nach validateAiDeclaration, vor dem Einreichen). * Ohne Gate oder ohne offene Fassung passiert nichts. + * + * Wechselt die Fassung zwischen Validierung und Speichern, scheitert das + * Einreichen wie in validateAiDeclaration() — nicht still ohne Annahme + * weiter (K-07). */ protected function persistLegalAcceptances(): void { - $gate = app(LegalAcceptanceGate::class); - $required = $gate->requiredFor(auth()->user()); + $required = $this->requiredLegalDocumentsForSubmit(); - if ($required->isEmpty() || ! $this->legalAcceptanceConfirmed || LegalAcceptanceGate::versionsOf($required) !== $this->legalAcceptanceVersions) { + if ($required->isEmpty()) { return; } - $gate->accept(auth()->user(), $required, LegalDocumentAcceptance::CONTEXT_SUBMIT, request()->ip(), request()->userAgent()); + if (! $this->legalAcceptanceConfirmed || LegalAcceptanceGate::versionsOf($required) !== $this->legalAcceptanceVersions) { + $this->legalAcceptanceConfirmed = false; + $this->legalDocumentsForDisplay = null; + + throw ValidationException::withMessages([ + 'legalAcceptanceConfirmed' => __('Die Bedingungen wurden zwischenzeitlich aktualisiert. Bitte lesen Sie die neue Fassung und nehmen Sie sie erneut an.'), + ]); + } + + app(LegalAcceptanceGate::class)->accept(auth()->user(), $required, LegalDocumentAcceptance::CONTEXT_SUBMIT, request()->ip(), request()->userAgent()); $this->legalAcceptanceConfirmed = false; } + /** + * Verbindlicher, ungecachter Stand fürs Absenden. Ein fehlendes + * Pflichtdokument (Konfigurationsfehler) wird gemeldet und verhindert + * das Einreichen mit einer verständlichen Meldung statt eines 500ers. + * + * @return Collection + * + * @throws ValidationException + */ + private function requiredLegalDocumentsForSubmit(): Collection + { + try { + return app(LegalAcceptanceGate::class)->requiredFor(auth()->user()); + } catch (RuntimeException $exception) { + report($exception); + + throw ValidationException::withMessages([ + 'legalAcceptanceConfirmed' => __('Das Einreichen ist gerade nicht möglich, weil die aktuellen Bedingungen nicht geladen werden konnten. Bitte versuchen Sie es später erneut.'), + ]); + } + } + /** * Spaltenwerte für die PM-Persistenz. Der Bestätigungszeitpunkt wird nur * gestempelt, wenn die Checkbox tatsächlich gesetzt wurde. diff --git a/app/Models/MagicLink.php b/app/Models/MagicLink.php index 609dfa0b..6e7c2d5d 100644 --- a/app/Models/MagicLink.php +++ b/app/Models/MagicLink.php @@ -7,6 +7,22 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; class MagicLink extends Model { + public const PURPOSE_LOGIN = 'login'; + + /** + * Link „Bild bestätigen" (Decision-Update E10): meldet an wie ein + * Login-Link, wird aber von späteren Login-Links nicht entwertet + * (Umsetzungsaudit 29.09.2026, B-10). + */ + public const PURPOSE_IMAGE_CONFIRMATION = 'image_confirmation'; + + /** + * Zwecke, die der Endpunkt magic-login einlöst. + * + * @var list + */ + public const LOGIN_PURPOSES = [self::PURPOSE_LOGIN, self::PURPOSE_IMAGE_CONFIRMATION]; + protected $fillable = [ 'user_id', 'token_hash', diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index bfd8dfc4..8d8bb58d 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -36,6 +36,7 @@ use App\Observers\EmailDomainObserver; use App\Observers\ImageRightsConfirmationObserver; use App\Observers\PressReleaseSearchObserver; use App\Services\Admin\AdminRequestPerformanceMetrics; +use App\Services\Auth\SessionRevocation; use App\Services\Mail\DnsResolver; use App\Services\Mail\SystemDnsResolver; use App\Services\Newsletter\NullNewsletterSyncClient; @@ -166,6 +167,10 @@ class AppServiceProvider extends ServiceProvider Event::listen(Registered::class, [RecordAuthActivity::class, 'recordRegistered']); Event::listen(Login::class, [RecordAuthActivity::class, 'recordLogin']); + // Anmeldezeitpunkt in der Session — Grundlage für das Beenden fremder + // Sessions nach einer Kontoübernahme (Audit K-04, SessionRevocation). + Event::listen(Login::class, [SessionRevocation::class, 'stampLogin']); + // Das eine Gate vor jeder ausgehenden Mail: Suppression, Return-Path, // Circuit Breaker (Roadmap 1b.6). Gibt false zurück → Mail geht nicht raus. Event::listen(MessageSending::class, [GateOutgoingMail::class, 'gate']); diff --git a/app/Services/Auth/ContactAccessService.php b/app/Services/Auth/ContactAccessService.php index 4d2e3d0d..012573cf 100644 --- a/app/Services/Auth/ContactAccessService.php +++ b/app/Services/Auth/ContactAccessService.php @@ -116,7 +116,9 @@ class ContactAccessService * auf alle Firmen-PMs. Eigene PMs bleiben über die Autorenschaft. Eine * automatisch HOCHGESTUFTE manuelle Mitgliedschaft (`auto-upgrade`) fällt * auf `member` zurück. Manuell oder aus dem Altbestand vergebene Rechte - * (`granted_via = manual`) bleiben. + * (`granted_via = manual`) bleiben, ebenso Mitgliedschaften aus der + * API-Zuordnung (`granted_via = api`, Audit R-01) — ihre Grundlage ist + * die Freischaltung des Einlieferers, nicht die E-Mail. */ public function syncMembershipsForEmail(User $user): void { diff --git a/app/Services/Auth/LoginCompletion.php b/app/Services/Auth/LoginCompletion.php new file mode 100644 index 00000000..a4509c05 --- /dev/null +++ b/app/Services/Auth/LoginCompletion.php @@ -0,0 +1,98 @@ +attributes->set(RecordAuthActivity::AUTH_METHOD_ATTRIBUTE, $method); + Session::forget(self::METHOD_SESSION_KEY); + + Auth::guard('web')->login($user, $remember); + Session::regenerate(); + + $this->recordLogin($user, $request); + } + + /** + * Übergabe an Fortifys 2FA-Challenge statt eines direkten Logins. + * + * @param string|null $intended Landeziel nach bestandener Challenge + * (wird von RoleAwareLoginResponse nur + * übernommen, wenn der Nutzer es + * erreichen darf). + */ + public function handOffToTwoFactor(Request $request, User $user, string $method, bool $remember = false, ?string $intended = null): void + { + // Session-Fassade statt $request->session(): auch aus Livewire-Aktionen + // (Volt-Login) nutzbar, die denselben Store verwenden. + Session::put([ + 'login.id' => $user->getKey(), + 'login.remember' => $remember, + self::METHOD_SESSION_KEY => $method, + ]); + + if ($intended !== null) { + Session::put('url.intended', $intended); + } + + Session::regenerate(); + } + + public function recordLogin(User $user, Request $request): void + { + $user->forceFill([ + 'last_login_at' => now(), + 'last_login_ip' => $request->ip(), + ])->save(); + } + + /** + * Anmeldeweg des gerade laufenden Logins: vom einloggenden Code gesetzt, + * beim Abschluss der 2FA-Challenge aus der Session, sonst Passwort + * (Fortify-/Volt-Login). + */ + public static function methodOf(Request $request): string + { + $method = $request->attributes->get(RecordAuthActivity::AUTH_METHOD_ATTRIBUTE); + + if (is_string($method) && $method !== '') { + return $method; + } + + if ($request->routeIs('two-factor.login.store')) { + return (string) Session::pull(self::METHOD_SESSION_KEY, 'password'); + } + + return 'password'; + } +} diff --git a/app/Services/Auth/MagicLinkGenerator.php b/app/Services/Auth/MagicLinkGenerator.php index f55cc6f6..1e031f63 100644 --- a/app/Services/Auth/MagicLinkGenerator.php +++ b/app/Services/Auth/MagicLinkGenerator.php @@ -54,6 +54,53 @@ class MagicLinkGenerator ]; } + /** + * Link „Bild bestätigen" für den Kontoinhaber (Decision-Update E10, + * Umsetzungsaudit 29.09.2026 B-10): meldet wie ein Login-Link an und + * führt auf die Meldung, hat aber einen eigenen Zweck. Spätere + * Login-Links entwerten ihn deshalb nicht; ein neuer Bestätigungslink + * für dasselbe Bild entwertet nur den vorherigen für dieses Bild. + * + * @param array $payload Landeziel (`company_id`, `press_release_id`) + * @return array{magic_link: MagicLink, plain_token: string, expires_at: Carbon} + */ + public function createImageConfirmationLink( + User $user, + int $imageId, + array $payload, + ?string $requestedIp, + int $ttlMinutes, + ?ConversionSource $source = null, + ): array { + $plainToken = Str::random(64); + $expiresAt = now()->addMinutes($ttlMinutes); + + MagicLink::query() + ->where('user_id', $user->id) + ->where('purpose', MagicLink::PURPOSE_IMAGE_CONFIRMATION) + ->whereNull('consumed_at') + ->where('payload->image_confirmation', $imageId) + ->update([ + 'consumed_at' => now(), + 'ip_consumed' => $requestedIp, + ]); + + $magicLink = $user->magicLinks()->create([ + 'token_hash' => hash('sha256', $plainToken), + 'purpose' => MagicLink::PURPOSE_IMAGE_CONFIRMATION, + 'source' => $source?->value, + 'payload' => ['image_confirmation' => $imageId] + $payload, + 'expires_at' => $expiresAt, + 'ip_requested' => $requestedIp, + ]); + + return [ + 'magic_link' => $magicLink, + 'plain_token' => $plainToken, + 'expires_at' => $expiresAt, + ]; + } + /** * Meldeverfahren (Phase 3): Verifikations-Link an die hinterlegte * Pressekontaktadresse. Wer den Link öffnet, ist strukturell verifiziert — diff --git a/app/Services/Auth/SessionRevocation.php b/app/Services/Auth/SessionRevocation.php new file mode 100644 index 00000000..e32901a5 --- /dev/null +++ b/app/Services/Auth/SessionRevocation.php @@ -0,0 +1,75 @@ +cacheKey((int) $user->getKey()), + now()->getTimestampMs(), + now()->addMinutes(max(1, (int) config('session.lifetime', 120)) + 5), + ); + } + + /** + * Listener für `Login`: Zeitpunkt der Anmeldung in der Session. + * Die Methode heißt bewusst nicht `handle*` (Event-Discovery). + */ + public function stampLogin(Login $event): void + { + if (! app()->bound('request') || ! request()->hasSession()) { + return; + } + + request()->session()->put(self::SESSION_KEY, now()->getTimestampMs()); + } + + /** + * Session stammt aus der Zeit vor der letzten Sperre dieses Kontos. + * Ohne Anmeldezeitpunkt (Session von vor dem Deploy) gilt sie als alt. + */ + public function isRevoked(int $userId, Session $session): bool + { + $revokedAt = Cache::get($this->cacheKey($userId)); + + if ($revokedAt === null) { + return false; + } + + return (int) $session->get(self::SESSION_KEY, 0) < (int) $revokedAt; + } + + private function cacheKey(int $userId): string + { + return 'auth.sessions-revoked-at.'.$userId; + } +} diff --git a/app/Services/Auth/SocialAuthService.php b/app/Services/Auth/SocialAuthService.php index 0d5c6d1a..fec72863 100644 --- a/app/Services/Auth/SocialAuthService.php +++ b/app/Services/Auth/SocialAuthService.php @@ -3,6 +3,7 @@ namespace App\Services\Auth; use App\Models\User; +use Illuminate\Support\Str; /** * Auflösung eines Social-Logins (Google, ggf. weitere) auf einen lokalen User. @@ -16,6 +17,7 @@ class SocialAuthService public function __construct( private readonly UserRolePermissionSyncService $roleSync, private readonly CustomerAccountProvisioner $accountProvisioner, + private readonly SessionRevocation $sessions, ) {} /** @@ -44,11 +46,23 @@ class SocialAuthService // Noch nicht verifiziert (offener Selbst-Registrierer): der Provider // bestätigt die E-Mail → Onboarding abschließen (aktiv + customer). + // + // Vorab-Übernahme (Audit K-04): Wer die Adresse unverifiziert + // registriert hat, war nicht zwingend der Inhaber des Postfachs. Alles, + // was er dabei gesetzt hat, verfällt: Passwort, Remember-Cookie, + // API-Tokens und offene Sessions. Der Inhaber meldet sich per Google + // oder Magic Link an und setzt bei Bedarf selbst ein Passwort. if ($user->email_verified_at === null) { $user->forceFill([ 'email_verified_at' => now(), 'is_active' => true, - ])->save(); + 'password' => null, + ]); + $user->setRememberToken(Str::random(60)); + $user->save(); + + $user->tokens()->delete(); + $this->sessions->revokeAllFor($user); if ($user->roles()->doesntExist()) { $this->roleSync->assignRoleAndSyncPermissions($user, 'customer'); diff --git a/app/Services/Company/ApiNewsroomAssignment.php b/app/Services/Company/ApiNewsroomAssignment.php index adc4fd07..ef57f6e4 100644 --- a/app/Services/Company/ApiNewsroomAssignment.php +++ b/app/Services/Company/ApiNewsroomAssignment.php @@ -28,9 +28,21 @@ use App\Services\ActivityLog; * Unter einer Sammeladresse (Agentur) wird nur die Firma mit passendem Namen * wiederverwendet — ein neuer Kunde der Agentur bekommt wie bisher eine neue * Firma. Unter einer nie-veröffentlicht-Adresse wird nie zugeordnet. + * + * Berechtigung (Umsetzungsaudit 29.09.2026, R-01): Firmenname und + * Kontaktadresse stehen öffentlich im Newsroom. Zugeordnet wird deshalb nur, + * wenn der Token-Nutzer der Firma schon angehört (Owner oder Pivot) oder als + * Einlieferer freigeschaltet ist (`newsroom.api_assignment.allowed_user_ids`). + * Sonst entsteht wie vor 1a.4 eine neue Firma — ein fremdes Token wird nie + * Mitglied eines fremden Newsrooms. */ class ApiNewsroomAssignment { + /** + * Herkunft des Pivots (`company_user.granted_via`) einer API-Zuordnung. + */ + public const GRANTED_VIA = 'api'; + public function __construct( private readonly CompanyAddressClassifier $classifier, private readonly ActivityLog $activityLog, @@ -40,10 +52,11 @@ class ApiNewsroomAssignment * Bestehende Firma für diese Anfrage — oder null, dann legt der Aufrufer * wie bisher eine neue an. * - * Nebenwirkungen bei Treffer: Der API-Nutzer wird als Mitglied an die - * Firma gehängt (sonst dürfte er dort keine Meldung anlegen), und liegt - * die Firma auf dem anderen Portal, wird sie `both` — ihre Meldungen - * behalten ihr Portal, die neue bekommt das angefragte. + * Nebenwirkungen bei Treffer: Ein freigeschalteter API-Nutzer wird als + * Mitglied (`granted_via = api`) an die Firma gehängt (sonst dürfte er + * dort keine Meldung anlegen), und liegt die Firma auf dem anderen Portal, + * wird sie `both` — ihre Meldungen behalten ihr Portal, die neue bekommt + * das angefragte. Ohne Zugehörigkeit und ohne Freischaltung: null. */ public function assign(User $user, ?string $email, string $name, Portal $portal): ?Company { @@ -81,6 +94,13 @@ class ApiNewsroomAssignment $company = $matches->first(fn (Company $company): bool => $company->portal === $portal || $company->portal === Portal::Both) ?? $matches->first(); + // R-01: vor jeder Nebenwirkung (Pivot, Portal-Erweiterung) prüfen. + $belongs = $company->owner_user_id === $user->id || $company->users()->whereKey($user->id)->exists(); + + if (! $belongs && ! self::isAllowed((int) $user->getKey())) { + return null; + } + if ($portal !== Portal::Both && $company->portal !== Portal::Both && $company->portal !== $portal) { if ($company->slugTakenOnOtherPortals()) { return null; @@ -89,8 +109,11 @@ class ApiNewsroomAssignment $company->forceFill(['portal' => Portal::Both->value])->save(); } - if (! $company->users()->whereKey($user->id)->exists() && $company->owner_user_id !== $user->id) { - $company->users()->attach($user->id, ['role' => 'member']); + if (! $belongs) { + // Eigene Herkunft: der Login-Sync nimmt nur `auto`-Zuordnungen + // zurück, und die Bestandsprüfung (company-memberships:api-assignments) + // findet diese Pivots wieder. + $company->users()->attach($user->id, ['role' => 'member', 'granted_via' => self::GRANTED_VIA]); } $this->activityLog->record( @@ -104,4 +127,12 @@ class ApiNewsroomAssignment return $company; } + + /** + * Freigeschalteter Einlieferer (Allowlist in config/newsroom.php). + */ + public static function isAllowed(int $userId): bool + { + return in_array($userId, array_map('intval', (array) config('newsroom.api_assignment.allowed_user_ids', [])), true); + } } diff --git a/app/Services/Conversion/ConversionRecorder.php b/app/Services/Conversion/ConversionRecorder.php index b81ca76c..3d09e1b7 100644 --- a/app/Services/Conversion/ConversionRecorder.php +++ b/app/Services/Conversion/ConversionRecorder.php @@ -141,7 +141,7 @@ class ConversionRecorder try { $source = $user === null ? null : MagicLink::query() ->where('user_id', $user->getKey()) - ->where('purpose', 'login') + ->whereIn('purpose', MagicLink::LOGIN_PURPOSES) ->whereNotNull('consumed_at') ->latest('consumed_at') ->value('source'); diff --git a/bootstrap/app.php b/bootstrap/app.php index 2c486bb8..00ae979b 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -1,6 +1,7 @@ web(append: [RedirectPortalOnlyPagesFromHub::class]); + // Gesperrte Sessions (Audit K-04): nach einer Kontoübernahme per + // OAuth-Verifizierung fliegen ältere Sessions beim nächsten Request + // raus — nach StartSession, deshalb in der web-Gruppe. + $middleware->web(append: [EnsureSessionIsNotRevoked::class]); + // Wohin eingeloggte User von Gast-Routen (/login, /register) gelenkt // werden: rollen- und verifizierungsbewusst statt fix auf /dashboard, // sonst landet ein Customer dort im 403 und sitzt fest. diff --git a/config/newsroom.php b/config/newsroom.php index 7907ddb6..137fd2ed 100644 --- a/config/newsroom.php +++ b/config/newsroom.php @@ -31,11 +31,38 @@ return [ | claim_links_per_day: Anmeldelinks je Newsroom und Tag. Der Versand geht | an die hinterlegte Firmen-E-Mail, nicht an eine vom Besucher eingegebene | — er darf trotzdem kein Werkzeug werden, fremde Postfächer zu fluten. + | Gezählt werden nur versendete Links (R-09, 29.09.2026: 3 → 5, damit + | Dritte den Inhaber nicht mit wenigen Klicks für 24 h aussperren). | */ 'claim' => [ - 'links_per_day' => (int) env('NEWSROOM_CLAIM_LINKS_PER_DAY', 3), + 'links_per_day' => (int) env('NEWSROOM_CLAIM_LINKS_PER_DAY', 5), + ], + + /* + |-------------------------------------------------------------------------- + | API-Zuordnung zu bestehenden Newsrooms (Roadmap 1a.4, Audit R-01) + |-------------------------------------------------------------------------- + | + | Der Alt-Vertrag `POST /service/users/me/companies` hängt eine neue + | API-Firma an den bestehenden Newsroom mit derselben Firmen-E-Mail und + | demselben Namen. Firmenname und Kontaktadresse stehen öffentlich — die + | Zuordnung (samt Mitgliedschaft mit Schreibrecht auf alle Firmen-PMs) gibt + | es deshalb nur für freigeschaltete Einlieferer (heute connektar) oder + | wenn der Token-Nutzer der Firma ohnehin schon angehört. Alle anderen + | bekommen wie vor 1a.4 eine neue Firma. + | + | allowed_user_ids: kommagetrennte User-IDs (NEWSROOM_API_ASSIGNMENT_USER_IDS). + | Standard leer. + | + */ + + 'api_assignment' => [ + 'allowed_user_ids' => array_values(array_unique(array_filter( + array_map('intval', explode(',', (string) env('NEWSROOM_API_ASSIGNMENT_USER_IDS', ''))), + fn (int $id): bool => $id > 0, + ))), ], ]; diff --git a/database/migrations/2026_09_29_180200_add_image_confirmation_purpose_to_magic_links_table.php b/database/migrations/2026_09_29_180200_add_image_confirmation_purpose_to_magic_links_table.php new file mode 100644 index 00000000..1a00fbb3 --- /dev/null +++ b/database/migrations/2026_09_29_180200_add_image_confirmation_purpose_to_magic_links_table.php @@ -0,0 +1,46 @@ +enum('purpose', [ + 'login', + 'press_release_access', + 'invoice_access', + 'initial_setup', + 'press_release_report', + 'image_confirmation', + ])->default('login')->change(); + }); + } + + public function down(): void + { + // Links des entfallenden Zwecks zuerst räumen, sonst scheitert die + // Rückverengung des Enums an bestehenden Zeilen. + DB::table('magic_links')->where('purpose', 'image_confirmation')->delete(); + + Schema::table('magic_links', function (Blueprint $table) { + $table->enum('purpose', [ + 'login', + 'press_release_access', + 'invoice_access', + 'initial_setup', + 'press_release_report', + ])->default('login')->change(); + }); + } +}; diff --git a/resources/views/components/press-release-submit-modal.blade.php b/resources/views/components/press-release-submit-modal.blade.php index 6b0288ab..8c966dc7 100644 --- a/resources/views/components/press-release-submit-modal.blade.php +++ b/resources/views/components/press-release-submit-modal.blade.php @@ -6,6 +6,7 @@ 'quotaTotal' => null, 'quotaRemaining' => null, 'hasTitleImage' => true, + 'legalDocuments' => null, ]) {{-- @@ -35,8 +36,23 @@ $bookingRequired = ! (auth()->user()?->hasActiveBooking() ?? true); // Zustimmungsgate (Bildkennzeichnung Block G): offene Fassungen von AGB // und Richtlinien, die vor dem Einreichen aktiv angenommen werden müssen. - $legalGate = app(\App\Services\Legal\LegalAcceptanceGate::class); - $legalDocumentsToAccept = $legalGate->requiredFor(auth()->user())->map(fn ($document) => $legalGate->describe($document))->values(); + // Die Seiten reichen das je Request einmal ermittelte Ergebnis durch + // (InteractsWithAiDeclaration::legalDocumentsForSubmit(), OPT-7) — die + // Liste hätte sonst je Zeile eigene Abfragen. Ohne Übergabe: selbst + // ermitteln, bei fehlendem Pflichtdokument aber nicht beim Rendern + // scheitern (K-07); das Absenden prüft verbindlich. + if ($legalDocuments === null) { + $legalGate = app(\App\Services\Legal\LegalAcceptanceGate::class); + + try { + $legalDocuments = $legalGate->requiredFor(auth()->user())->map(fn ($document) => $legalGate->describe($document))->values(); + } catch (\RuntimeException $exception) { + report($exception); + $legalDocuments = collect(); + } + } + + $legalDocumentsToAccept = collect($legalDocuments); $needsLegalAcceptance = $legalDocumentsToAccept->isNotEmpty(); @endphp diff --git a/resources/views/livewire/auth/login.blade.php b/resources/views/livewire/auth/login.blade.php index ce3ff6c9..b2fecb5d 100644 --- a/resources/views/livewire/auth/login.blade.php +++ b/resources/views/livewire/auth/login.blade.php @@ -1,6 +1,7 @@ 'Willkommen zu // 2FA aktiv: nicht einloggen, sondern in Fortifys Challenge übergeben // (Session-Vertrag login.id/login.remember wie RedirectIfTwoFactorAuthenticatable). if ($user->hasEnabledTwoFactorAuthentication()) { - Session::put([ - 'login.id' => $user->getKey(), - 'login.remember' => $this->remember, - ]); + app(LoginCompletion::class)->handOffToTwoFactor(request(), $user, 'password', $this->remember); $this->redirect(route('two-factor.challenge')); @@ -78,10 +76,7 @@ new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Willkommen zu ]); } - $user->update([ - 'last_login_at' => now(), - 'last_login_ip' => request()->ip(), - ]); + app(LoginCompletion::class)->recordLogin($user, request()); Session::regenerate(); diff --git a/resources/views/livewire/customer/press-releases/create.blade.php b/resources/views/livewire/customer/press-releases/create.blade.php index 5a92bce9..a43fe2c1 100644 --- a/resources/views/livewire/customer/press-releases/create.blade.php +++ b/resources/views/livewire/customer/press-releases/create.blade.php @@ -1095,6 +1095,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex :confirm-label="__('Zur Prüfung freigeben')" :quota-total="$quotaTotal" :quota-remaining="$quotaRemaining" - :has-title-image="false" /> + :has-title-image="false" + :legal-documents="$this->legalDocumentsForSubmit()" /> @endif diff --git a/resources/views/livewire/customer/press-releases/edit.blade.php b/resources/views/livewire/customer/press-releases/edit.blade.php index 7f1d9185..86bb6121 100644 --- a/resources/views/livewire/customer/press-releases/edit.blade.php +++ b/resources/views/livewire/customer/press-releases/edit.blade.php @@ -1064,5 +1064,6 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl :confirm-label="$currentStatus === 'rejected' ? __('Speichern & erneut zur Prüfung freigeben') : __('Speichern & zur Prüfung freigeben')" :quota-total="$quotaTotal" :quota-remaining="$quotaRemaining" - :has-title-image="! $coverIsPlaceholder" /> + :has-title-image="! $coverIsPlaceholder" + :legal-documents="$this->legalDocumentsForSubmit()" /> diff --git a/resources/views/livewire/customer/press-releases/index.blade.php b/resources/views/livewire/customer/press-releases/index.blade.php index 7b485fdf..1f701e90 100644 --- a/resources/views/livewire/customer/press-releases/index.blade.php +++ b/resources/views/livewire/customer/press-releases/index.blade.php @@ -494,7 +494,8 @@ new #[Layout('components.layouts.app'), Title('Meine Pressemitteilungen')] class + :has-title-image="$pr->images_count > 0" + :legal-documents="$this->legalDocumentsForSubmit()" /> @endif @if ($boostEndsAt = $boostEndsById->get($pr->id)) diff --git a/routes/auth.php b/routes/auth.php index 1df55cc3..cefa671f 100644 --- a/routes/auth.php +++ b/routes/auth.php @@ -15,8 +15,11 @@ Route::group(['middleware' => config('fortify.middleware', ['web'])], function ( ->middleware(['guest:'.config('fortify.guard')]) ->name('login'); + // Ohne `guest` (Audit K-08): Ein eingeloggter Kunde, der seinen eigenen + // Link öffnet (z. B. Bildbestätigung), soll auf dem Ziel landen; fremde + // Links behandelt der Controller wie früher die guest-Middleware. Route::get('/magic-login/{token}', MagicLinkConsumeController::class) - ->middleware(['guest:'.config('fortify.guard'), 'throttle:10,1']) + ->middleware(['throttle:10,1']) ->name('magic-links.consume'); // Anmeldung per E-Mail-Link (Magic-Link): zusammengeführte Seite für diff --git a/tests/Feature/Api/ApiNewsroomAssignmentTest.php b/tests/Feature/Api/ApiNewsroomAssignmentTest.php index 108aec37..84a87c4e 100644 --- a/tests/Feature/Api/ApiNewsroomAssignmentTest.php +++ b/tests/Feature/Api/ApiNewsroomAssignmentTest.php @@ -8,6 +8,7 @@ use App\Models\Company; use App\Models\CompanyEmailGroup; use App\Models\PressRelease; use App\Models\User; +use App\Services\Auth\ContactAccessService; use Database\Seeders\RolesAndPermissionsSeeder; use Illuminate\Support\Facades\Queue; use Laravel\Sanctum\Sanctum; @@ -18,7 +19,7 @@ use Tests\TestCase; * Firmen-E-Mail (Roadmap 1a.4). connektar legt je Meldung eine Firma an — * ohne die Zuordnung entstünde je Meldung ein neuer Newsroom. */ -function assignmentApiUser(Portal $portal = Portal::Businessportal24): User +function assignmentApiUser(Portal $portal = Portal::Businessportal24, bool $allowlisted = true): User { test()->seed(RolesAndPermissionsSeeder::class); config()->set('billing.enforce_booking', false); @@ -27,6 +28,11 @@ function assignmentApiUser(Portal $portal = Portal::Businessportal24): User $user = User::factory()->create(['is_active' => true, 'portal' => $portal]); $user->forceFill(['registration_type' => 'apiuser'])->save(); + // Zuordnung zu bestehenden Firmen nur für freigeschaltete Einlieferer + // (Umsetzungsaudit 29.09.2026, R-01) — die Bestandstests laufen als + // freigeschalteter Einlieferer (connektar). + config()->set('newsroom.api_assignment.allowed_user_ids', $allowlisted ? [$user->id] : []); + Sanctum::actingAs($user, ['press-releases:read', 'press-releases:write', 'companies:read']); return $user; @@ -61,7 +67,7 @@ test('the same company under the same email is reused instead of created', funct expect($id)->toBe($existing->id) ->and(Company::withoutGlobalScopes()->where('email', 'like', '%muster.de')->count())->toBe(1) - ->and($existing->users()->whereKey($user->id)->wherePivot('role', 'member')->exists())->toBeTrue() + ->and($existing->users()->whereKey($user->id)->wherePivot('role', 'member')->wherePivot('granted_via', 'api')->exists())->toBeTrue() ->and(ActivityEvent::query()->where('subject_id', $existing->id)->where('description', 'like', 'API-Firma%')->exists())->toBeTrue(); }); @@ -162,3 +168,92 @@ test('without an email a company is created as before', function () { expect(postApiCompany(['email' => null]))->not->toBe($existing->id); }); + +/* + * R-01 (Umsetzungsaudit 29.09.2026): Firmenname und Kontaktadresse stehen + * öffentlich im Newsroom. Ohne Freischaltung darf ein fremdes Token darüber + * keine Mitgliedschaft (Schreibrecht auf alle Firmen-PMs) erlangen. + */ +test('a foreign token with matching email and name gets its own company and no membership', function () { + /** @var TestCase $this */ + $user = assignmentApiUser(Portal::Presseecho, allowlisted: false); + $existing = Company::factory()->create(['email' => 'info@muster.de', 'name' => 'Muster GmbH', 'portal' => 'businessportal24']); + + $id = postApiCompany(); + + expect($id)->not->toBe($existing->id) + ->and($existing->users()->whereKey($user->id)->exists())->toBeFalse() + ->and($existing->fresh()->portal)->toBe(Portal::Businessportal24) + ->and(Company::withoutGlobalScopes()->find($id)->owner_user_id)->toBe($user->id) + ->and(ActivityEvent::query()->where('subject_id', $existing->id)->where('description', 'like', 'API-Firma%')->exists())->toBeFalse(); +}); + +test('a user who already belongs to the company is reused without allowlist and keeps his role', function () { + /** @var TestCase $this */ + $user = assignmentApiUser(allowlisted: false); + $existing = Company::factory()->create(['email' => 'info@muster.de', 'name' => 'Muster GmbH', 'portal' => 'businessportal24']); + $existing->users()->attach($user->id, ['role' => 'responsible', 'granted_via' => 'manual']); + + expect(postApiCompany())->toBe($existing->id) + ->and($existing->users()->whereKey($user->id)->first()->pivot->role)->toBe('responsible') + ->and($existing->users()->whereKey($user->id)->first()->pivot->granted_via)->toBe('manual'); +}); + +test('the owner of the company is reused without allowlist and gets no extra pivot', function () { + /** @var TestCase $this */ + $user = assignmentApiUser(allowlisted: false); + $existing = Company::factory()->create(['email' => 'info@muster.de', 'name' => 'Muster GmbH', 'portal' => 'businessportal24', 'owner_user_id' => $user->id]); + + expect(postApiCompany())->toBe($existing->id) + ->and($existing->users()->whereKey($user->id)->exists())->toBeFalse(); +}); + +test('the login sync never revokes an api membership', function () { + /** @var TestCase $this */ + $user = assignmentApiUser(); + $existing = Company::factory()->create(['email' => 'info@muster.de', 'name' => 'Muster GmbH', 'portal' => 'businessportal24']); + postApiCompany(); + + app(ContactAccessService::class)->syncMembershipsForEmail($user->fresh()); + + expect($existing->users()->whereKey($user->id)->wherePivot('granted_via', 'api')->exists())->toBeTrue(); +}); + +test('the review command lists api assignments and removes foreign ones only with --remove', function () { + /** @var TestCase $this */ + $allowed = assignmentApiUser(); + $existing = Company::factory()->create(['email' => 'info@muster.de', 'name' => 'Muster GmbH', 'portal' => 'businessportal24']); + postApiCompany(); + + // Altfall vor dem Fix: ein fremdes Konto wurde per API-Zuordnung Mitglied. + $stranger = User::factory()->create(); + $existing->users()->attach($stranger->id, ['role' => 'member', 'granted_via' => 'manual']); + ActivityEvent::query()->create([ + 'type' => 'correction', 'occurred_at' => now(), 'user_id' => $stranger->id, 'actor_label' => 'api', + 'subject_type' => $existing->getMorphClass(), 'subject_id' => $existing->id, + 'description' => 'API-Firma „Muster GmbH“ dem bestehenden Newsroom #'.$existing->id.' „Muster GmbH“ zugeordnet (info@muster.de)', + 'meta' => ['api_newsroom_assignment' => true], + ]); + + // Mitglied schon vor der Zuordnung: bleibt unangetastet. + $veteran = User::factory()->create(); + $existing->users()->attach($veteran->id, ['role' => 'member', 'granted_via' => 'manual', 'created_at' => now()->subMonth(), 'updated_at' => now()->subMonth()]); + ActivityEvent::query()->create([ + 'type' => 'correction', 'occurred_at' => now(), 'user_id' => $veteran->id, 'actor_label' => 'api', + 'subject_type' => $existing->getMorphClass(), 'subject_id' => $existing->id, + 'description' => 'API-Firma „Muster GmbH“ dem bestehenden Newsroom #'.$existing->id.' „Muster GmbH“ zugeordnet (info@muster.de)', + 'meta' => ['api_newsroom_assignment' => true], + ]); + + $this->artisan('company-memberships:api-assignments') + ->expectsOutputToContain('2 über die API-Zuordnung entstandene Mitgliedschaft(en), davon 1 ohne Freischaltung') + ->assertSuccessful(); + + expect($existing->users()->whereKey($stranger->id)->exists())->toBeTrue(); + + $this->artisan('company-memberships:api-assignments', ['--remove' => true, '--force' => true])->assertSuccessful(); + + expect($existing->users()->whereKey($stranger->id)->exists())->toBeFalse() + ->and($existing->users()->whereKey($allowed->id)->exists())->toBeTrue() + ->and($existing->users()->whereKey($veteran->id)->exists())->toBeTrue(); +}); diff --git a/tests/Feature/Auth/GoogleLoginTest.php b/tests/Feature/Auth/GoogleLoginTest.php index a2a640b1..707feb1b 100644 --- a/tests/Feature/Auth/GoogleLoginTest.php +++ b/tests/Feature/Auth/GoogleLoginTest.php @@ -2,9 +2,12 @@ use App\Models\User; use Database\Seeders\RolesAndPermissionsSeeder; +use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Hash; use Laravel\Socialite\Contracts\Provider; use Laravel\Socialite\Facades\Socialite; use Laravel\Socialite\Two\User as SocialiteUser; +use Livewire\Volt\Volt; use PragmaRX\Google2FA\Google2FA; use Tests\TestCase; @@ -123,3 +126,55 @@ test('google login hands an account with confirmed two-factor to the challenge ( expect(session('login.id'))->toBe($user->id); expect($user->fresh()->last_login_at)->toBeNull(); }); + +/* + * K-04 (Umsetzungsaudit 29.09.2026): Vorab-Übernahme. Ein Angreifer + * registriert die Adresse der echten Pressestelle mit eigenem Passwort; die + * Pressestelle meldet sich später mit Google an. Danach darf weder das + * Passwort noch der Remember-Cookie noch eine offene Session des Angreifers + * das Konto erreichen. + */ +test('a pre-registered account is no longer reachable with the old password, token or session after google verifies it', function () { + /** @var TestCase $this */ + $pending = User::factory()->unverified()->create([ + 'email' => 'pressestelle@firma.test', + 'password' => Hash::make('attacker-secret'), + 'is_active' => false, + 'remember_token' => 'attacker-remember-token', + ]); + $pending->createToken('attacker', ['press-releases:read']); + + // Offene Session des Angreifers (vor der Google-Anmeldung entstanden). + $this->actingAs($pending); + $this->flushSession(); + + fakeGoogleUser('g-takeover-1', 'pressestelle@firma.test', 'Pressestelle'); + + Auth::logout(); + + $this->get(route('oauth.google.callback')) + ->assertRedirect(route('me.dashboard', absolute: false)); + + $pending->refresh(); + expect($pending->password)->toBeNull() + ->and($pending->remember_token)->not->toBe('attacker-remember-token') + ->and($pending->tokens()->count())->toBe(0); + + // Die neue Google-Session bleibt gültig. + $this->get(route('me.dashboard'))->assertOk(); + $this->assertAuthenticatedAs($pending); + + // Die Session des Angreifers (ohne Anmeldezeitpunkt nach der Übernahme) nicht. + Auth::logout(); + $this->flushSession(); + $this->actingAs($pending)->get(route('me.dashboard'))->assertRedirect(route('login')); + $this->assertGuest(); + + // Und das alte Passwort führt nicht mehr hinein. + Volt::test('auth.login') + ->set('email', 'pressestelle@firma.test') + ->set('password', 'attacker-secret') + ->call('login') + ->assertHasErrors(['email']); + $this->assertGuest(); +}); diff --git a/tests/Feature/Auth/LoginCompletionTest.php b/tests/Feature/Auth/LoginCompletionTest.php new file mode 100644 index 00000000..31b13aa1 --- /dev/null +++ b/tests/Feature/Auth/LoginCompletionTest.php @@ -0,0 +1,165 @@ +seed(RolesAndPermissionsSeeder::class); +}); + +/** + * @return array{user: User, secret: string|null, company: Company, pr: PressRelease} + */ +function loginCompletionCustomer(bool $twoFactor = false): array +{ + $user = User::factory()->create(['is_active' => true]); + $user->assignRole('customer'); + $secret = null; + + if ($twoFactor) { + $secret = app(Google2FA::class)->generateSecretKey(); + $user->forceFill(['two_factor_secret' => encrypt($secret), 'two_factor_confirmed_at' => now()])->save(); + } + + $company = Company::factory()->presseecho()->create(); + $user->companies()->attach($company->id, ['role' => 'owner']); + $pr = PressRelease::factory()->create([ + 'user_id' => $user->id, + 'company_id' => $company->id, + 'portal' => $company->portal->value, + 'status' => 'draft', + ]); + + return compact('user', 'secret', 'company', 'pr'); +} + +function loginCompletionLink(User $user, string $token, array $payload = []): MagicLink +{ + return MagicLink::query()->create([ + 'user_id' => $user->id, + 'token_hash' => hash('sha256', $token), + 'purpose' => 'login', + 'payload' => $payload ?: null, + 'expires_at' => now()->addMinutes(30), + ]); +} + +function loginCompletionMethod(User $user): ?string +{ + return ActivityEvent::query() + ->where('type', ActivityType::UserLoggedIn->value) + ->where('user_id', $user->id) + ->latest('id') + ->first()?->meta['method'] ?? null; +} + +test('the password login records the last login also after the two-factor challenge', function () { + /** @var TestCase $this */ + ['user' => $user, 'secret' => $secret] = loginCompletionCustomer(twoFactor: true); + + Volt::test('auth.login') + ->set('email', $user->email) + ->set('password', 'password') + ->call('login') + ->assertRedirect(route('two-factor.challenge')); + + $this->post('/two-factor-challenge', ['code' => app(Google2FA::class)->getCurrentOtp($secret)]) + ->assertRedirect(route('me.dashboard', absolute: false)); + + $this->assertAuthenticatedAs($user); + expect($user->fresh()->last_login_at)->not->toBeNull() + ->and(loginCompletionMethod($user))->toBe('password'); +}); + +test('a magic link keeps its landing target and its method through the two-factor challenge', function () { + /** @var TestCase $this */ + ['user' => $user, 'secret' => $secret, 'company' => $company, 'pr' => $pr] = loginCompletionCustomer(twoFactor: true); + loginCompletionLink($user, 'landing-2fa', ['company_id' => $company->id, 'press_release_id' => $pr->id]); + + $this->get(route('magic-links.consume', ['token' => 'landing-2fa'])) + ->assertRedirect(route('two-factor.challenge')); + + $this->post('/two-factor-challenge', ['code' => app(Google2FA::class)->getCurrentOtp($secret)]) + ->assertRedirect(route('me.press-releases.show', ['id' => $pr->id], absolute: false)); + + expect($user->fresh()->last_login_at)->not->toBeNull() + ->and(loginCompletionMethod($user))->toBe('magic-link'); +}); + +test('google no longer forces a remember cookie, also not through the two-factor challenge', function () { + /** @var TestCase $this */ + ['user' => $user, 'secret' => $secret] = loginCompletionCustomer(twoFactor: true); + + $provider = Mockery::mock(Provider::class); + $provider->shouldReceive('user')->andReturn((new SocialiteUser)->map(['id' => 'g-lc-1', 'email' => $user->email, 'name' => 'Kunde'])); + Socialite::shouldReceive('driver')->with('google')->andReturn($provider); + + $this->get(route('oauth.google.callback'))->assertRedirect(route('two-factor.challenge')); + expect(session('login.remember'))->toBeFalse(); + + $response = $this->post('/two-factor-challenge', ['code' => app(Google2FA::class)->getCurrentOtp($secret)]); + + $response->assertRedirect(route('me.dashboard', absolute: false)); + expect(collect($response->headers->getCookies())->contains(fn ($cookie) => str_starts_with($cookie->getName(), 'remember_web_')))->toBeFalse() + ->and(loginCompletionMethod($user))->toBe('google') + ->and($user->fresh()->last_login_at)->not->toBeNull(); +}); + +test('google without two-factor logs in without a remember cookie', function () { + /** @var TestCase $this */ + ['user' => $user] = loginCompletionCustomer(); + + $provider = Mockery::mock(Provider::class); + $provider->shouldReceive('user')->andReturn((new SocialiteUser)->map(['id' => 'g-lc-2', 'email' => $user->email, 'name' => 'Kunde'])); + Socialite::shouldReceive('driver')->with('google')->andReturn($provider); + + $response = $this->get(route('oauth.google.callback')); + + $response->assertRedirect(route('me.dashboard', absolute: false)); + $this->assertAuthenticatedAs($user); + expect(collect($response->headers->getCookies())->contains(fn ($cookie) => str_starts_with($cookie->getName(), 'remember_web_')))->toBeFalse(); +}); + +test('a logged-in customer opening his own magic link lands on its target', function () { + /** @var TestCase $this */ + ['user' => $user, 'company' => $company, 'pr' => $pr] = loginCompletionCustomer(); + $link = loginCompletionLink($user, 'own-link', ['company_id' => $company->id, 'press_release_id' => $pr->id]); + + $this->actingAs($user) + ->get(route('magic-links.consume', ['token' => 'own-link'])) + ->assertRedirect(route('me.press-releases.show', ['id' => $pr->id], absolute: false)); + + $this->assertAuthenticatedAs($user); + expect($link->fresh()->consumed_at)->not->toBeNull(); +}); + +test('a logged-in user opening a link of another account keeps the previous behaviour', function () { + /** @var TestCase $this */ + ['user' => $user] = loginCompletionCustomer(); + ['user' => $other] = loginCompletionCustomer(); + $link = loginCompletionLink($other, 'foreign-link'); + + $this->actingAs($user) + ->get(route('magic-links.consume', ['token' => 'foreign-link'])) + ->assertRedirect(route('me.dashboard')); + + $this->assertAuthenticatedAs($user); + expect($link->fresh()->consumed_at)->toBeNull(); +}); diff --git a/tests/Feature/Legal/LegalAcceptanceGateTest.php b/tests/Feature/Legal/LegalAcceptanceGateTest.php index 78c5c484..91a8f59f 100644 --- a/tests/Feature/Legal/LegalAcceptanceGateTest.php +++ b/tests/Feature/Legal/LegalAcceptanceGateTest.php @@ -10,8 +10,11 @@ use App\Models\User; use App\Services\Legal\LegalAcceptanceGate; use Database\Seeders\LegalDocumentSeeder; use Database\Seeders\RolesAndPermissionsSeeder; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Exceptions; use Illuminate\Support\Facades\Mail; use Laravel\Sanctum\Sanctum; +use Livewire\Features\SupportLockedProperties\CannotUpdateLockedPropertyException; use Livewire\Volt\Volt as LivewireVolt; use Tests\TestCase; @@ -179,3 +182,102 @@ test('the api answers 428 with the required documents until they are accepted', $this->getJson('/api/v1/legal/acceptances')->assertSuccessful()->assertJsonPath('gate_enabled', true)->assertJsonCount(2, 'accepted'); }); + +/* + * K-07 / B-13 (Umsetzungsaudit 29.09.2026): Die gezeigten Fassungen sind + * gesperrt, ein Versionswechsel zwischen Prüfung und Speichern wird nicht + * still übergangen, und der Rendering-Hook fragt je Request höchstens einmal + * (bei ausgeschaltetem Gate gar nicht) und wirft nie beim Rendern. + */ +test('the shown versions cannot be changed from the browser', function () { + /** @var TestCase $this */ + config()->set('legal.acceptance_gate', true); + ['owner' => $owner, 'pr' => $pr] = legalGateDraft(); + $this->actingAs($owner); + + expect(fn () => LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id]) + ->set('legalAcceptanceVersions', ['agb' => '9.9', 'richtlinien' => '9.9'])) + ->toThrow(CannotUpdateLockedPropertyException::class); +}); + +test('a version change between validation and saving is rejected instead of silently skipped', function () { + /** @var TestCase $this */ + config()->set('legal.acceptance_gate', true); + ['owner' => $owner, 'pr' => $pr] = legalGateDraft(); + $this->actingAs($owner); + + $current = app(LegalAcceptanceGate::class)->requiredFor($owner); + $newer = LegalDocument::factory()->agb()->make(['version' => '0.7']); + $submitCalls = null; + + // Anzeige und Validierung sehen 0.6, beim Speichern gilt schon 0.7. + $this->partialMock(LegalAcceptanceGate::class) + ->shouldReceive('requiredFor') + ->andReturnUsing(function () use (&$submitCalls, $current, $newer) { + if ($submitCalls === null) { + return $current; + } + + $submitCalls++; + + return $submitCalls === 1 ? $current : collect([$newer]); + }); + + $component = LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id]) + ->set('aiTextDeclaration', 'none') + ->set('aiDeclarationConfirmed', true) + ->set('legalAcceptanceConfirmed', true); + + $submitCalls = 0; + + $component->call('submitForReview') + ->assertHasErrors(['legalAcceptanceConfirmed']); + + expect(LegalDocumentAcceptance::query()->count())->toBe(0) + ->and($pr->fresh()->status)->toBe(PressReleaseStatus::Draft); +}); + +test('with the gate off rendering the submit pages asks nothing about legal documents', function () { + /** @var TestCase $this */ + ['owner' => $owner, 'pr' => $pr] = legalGateDraft(); + $this->actingAs($owner); + + DB::enableQueryLog(); + LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id])->assertOk(); + + expect(collect(DB::getQueryLog())->filter(fn (array $query): bool => str_contains($query['query'], 'legal_document_acceptances')))->toBeEmpty(); +}); + +test('with the gate on a render asks once per request, not once per modal', function () { + /** @var TestCase $this */ + config()->set('legal.acceptance_gate', true); + ['owner' => $owner, 'pr' => $pr] = legalGateDraft(); + $this->actingAs($owner); + + DB::enableQueryLog(); + LivewireVolt::test('customer.press-releases.index')->assertOk(); + + // Eine Abfrage je Dokumenttyp (AGB, Richtlinien) — nicht zusätzlich je Modal. + expect(collect(DB::getQueryLog())->filter(fn (array $query): bool => str_contains($query['query'], 'legal_document_acceptances')))->toHaveCount(2); +}); + +test('a missing required document fails the submit, not the rendering of the page', function () { + /** @var TestCase $this */ + config()->set('legal.acceptance_gate', true); + ['owner' => $owner, 'pr' => $pr] = legalGateDraft(); + $this->actingAs($owner); + + LegalDocument::query()->where('type', 'richtlinien')->delete(); + Exceptions::fake(); + + LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id]) + ->assertOk() + ->set('aiTextDeclaration', 'none') + ->set('aiDeclarationConfirmed', true) + ->set('legalAcceptanceConfirmed', true) + ->call('submitForReview') + ->assertHasErrors(['legalAcceptanceConfirmed']); + + expect($pr->fresh()->status)->toBe(PressReleaseStatus::Draft); + Exceptions::assertReported(RuntimeException::class); +});