From ae79d5bee439f3e7fe76adc61c9a6227207c9c96 Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Tue, 16 Jun 2026 10:19:32 +0000 Subject: [PATCH] =?UTF-8?q?Security:=20JSON-Login=20durchl=C3=A4uft=20die?= =?UTF-8?q?=20is=5Factive-/Verifizierungschecks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RoleAwareLoginResponse gab bei wantsJson() sofort 204 zurück – VOR den Sicherheitschecks. Ein XHR/JSON-Login eines verifiziert-inaktiven Accounts erhielt damit eine Session ohne Logout. Checks laufen jetzt zuerst: verifiziert-inaktiv → Logout + Session-Invalidate + 403 (JSON) bzw. Login mit Fehler (HTML); unverifiziert → 204 (JSON) bzw. Notice (HTML); danach der Erfolgsfall. Tests: JSON-Login eines inaktiven Accounts (403, guest), JSON-Login eines aktiven Users (204, authentifiziert). Co-Authored-By: Claude Fable 5 --- app/Http/Responses/RoleAwareLoginResponse.php | 44 ++++++++++++------- ...-Hinweise (Auth, Rollen, Verifizierung).md | 2 + tests/Feature/Auth/TwoFactorLoginTest.php | 22 ++++++++++ 3 files changed, 52 insertions(+), 16 deletions(-) diff --git a/app/Http/Responses/RoleAwareLoginResponse.php b/app/Http/Responses/RoleAwareLoginResponse.php index 966412e..28b3bd9 100644 --- a/app/Http/Responses/RoleAwareLoginResponse.php +++ b/app/Http/Responses/RoleAwareLoginResponse.php @@ -11,34 +11,46 @@ use Laravel\Fortify\Contracts\LoginResponse as LoginResponseContract; use Laravel\Fortify\Contracts\TwoFactorLoginResponse as TwoFactorLoginResponseContract; /** - * Einheitliche Antwort für den Fortify-POST-Login UND den Abschluss der - * 2FA-Challenge. Spiegelt dieselbe Policy wie der Volt-Login: - * - unverifiziert → Verifizierungs-Notice - * - verifiziert, aber inaktiv → Login blockiert (Logout + Fehler) + * Einheitliche Antwort für den Fortify-POST-Login (HTML und JSON/XHR) UND den + * Abschluss der 2FA-Challenge. Spiegelt dieselbe Policy wie der Volt-Login: + * - verifiziert, aber inaktiv → Session beenden, KEIN Login (auch für JSON) + * - unverifiziert → Verifizierungs-Notice (JSON: 204, verified-Middleware schützt) * - sonst rollengerechter, 403-sicherer Redirect (intended nur wenn erreichbar) + * + * Wichtig: Die Sicherheitsprüfungen laufen VOR dem JSON-Kurzschluss, damit ein + * XHR-Login keine Session für einen deaktivierten Account erhält. */ class RoleAwareLoginResponse implements LoginResponseContract, TwoFactorLoginResponseContract { public function toResponse($request): RedirectResponse|JsonResponse { - if ($request instanceof Request && $request->wantsJson()) { - return new JsonResponse('', 204); - } - $user = $request->user(); + $wantsJson = $request instanceof Request && $request->wantsJson(); - if ($user && ! $user->hasVerifiedEmail()) { - return redirect()->route('verification.notice'); - } - - if ($user && ! $user->is_active) { + // Sicherheits-Boundary zuerst: ein verifizierter, aber deaktivierter + // Account darf keine Session behalten – egal ob HTML oder JSON. + if ($user && $user->hasVerifiedEmail() && ! $user->is_active) { Auth::guard('web')->logout(); $request->session()->invalidate(); $request->session()->regenerateToken(); - return redirect()->route('login')->withErrors([ - 'email' => __('Ihr Konto ist nicht aktiv. Bitte wenden Sie sich an den Support.'), - ]); + $message = __('Ihr Konto ist nicht aktiv. Bitte wenden Sie sich an den Support.'); + + return $wantsJson + ? new JsonResponse(['message' => $message], 403) + : redirect()->route('login')->withErrors(['email' => $message]); + } + + // Unverifizierte Selbst-Registrierer sind authentifiziert, aber die + // verified-Middleware sperrt geschützte Routen. HTML → Notice, JSON → 204. + if ($user && ! $user->hasVerifiedEmail()) { + return $wantsJson + ? new JsonResponse('', 204) + : redirect()->route('verification.notice'); + } + + if ($wantsJson) { + return new JsonResponse('', 204); } $default = LoginRedirect::homeFor($user); diff --git a/docs/weiteres/Sicherheit & Deployment-Hinweise (Auth, Rollen, Verifizierung).md b/docs/weiteres/Sicherheit & Deployment-Hinweise (Auth, Rollen, Verifizierung).md index ad4e063..fca43f0 100644 --- a/docs/weiteres/Sicherheit & Deployment-Hinweise (Auth, Rollen, Verifizierung).md +++ b/docs/weiteres/Sicherheit & Deployment-Hinweise (Auth, Rollen, Verifizierung).md @@ -90,6 +90,8 @@ Aus einer gezielten Auth-Prüfung umgesetzt: **Hinweis:** `fortify.views => false` bleibt; die Challenge wird vom Volt-Frontend bereitgestellt, die Verifizierung übernimmt Fortify. +**Nachschärfung (Review 16.06., Teil 3):** Der `wantsJson()`-Kurzschluss in `RoleAwareLoginResponse` lief zuvor VOR den Sicherheitschecks – ein XHR/JSON-Login eines verifiziert-inaktiven Accounts erhielt so eine Session ohne Logout. Jetzt laufen die Prüfungen zuerst: verifiziert-inaktiv → `Auth::logout()` + Session-Invalidate + **403** (JSON) bzw. Login mit Fehler (HTML); unverifiziert → 204 (JSON) bzw. Notice (HTML). Erst danach der 204-/Redirect-Erfolgsfall. + --- ## 7. Deployment-Reihenfolge (Migrationen dieser Phase) diff --git a/tests/Feature/Auth/TwoFactorLoginTest.php b/tests/Feature/Auth/TwoFactorLoginTest.php index ee5732b..0f043e4 100644 --- a/tests/Feature/Auth/TwoFactorLoginTest.php +++ b/tests/Feature/Auth/TwoFactorLoginTest.php @@ -65,6 +65,28 @@ test('the fortify login post blocks inactive but verified users', function () { $this->assertGuest(); }); +test('a json login does not grant a session to an inactive verified account', function () { + /** @var TestCase $this */ + $user = User::factory()->create(['is_active' => false]); + + $this->postJson('/login', ['email' => $user->email, 'password' => 'password']) + ->assertStatus(403); + + $this->assertGuest(); +}); + +test('a json login for an active user succeeds with 204', function () { + /** @var TestCase $this */ + $this->seed(RolesAndPermissionsSeeder::class); + $customer = User::factory()->create(['is_active' => true]); + $customer->assignRole('customer'); + + $this->postJson('/login', ['email' => $customer->email, 'password' => 'password']) + ->assertNoContent(); + + $this->assertAuthenticatedAs($customer); +}); + test('the fortify login post keeps a customer out of the admin area on stale intended', function () { /** @var TestCase $this */ $this->seed(RolesAndPermissionsSeeder::class);