Security: 2FA-Bypass beheben & Login-Pfade konsolidieren

Befund (Review 16.06.): Der Volt-Login machte direkt Auth::attempt() und umging
Fortifys 2FA-Pipeline (2FA-Bypass); zusätzlich existierte der Fortify-POST /login
parallel mit schwächeren Post-Login-Regeln.

Fix (Volt-nativ):
- Volt-Login prüft Credentials ohne sofortiges Login; bei aktivem 2FA wird der
  Session-Vertrag login.id/login.remember gesetzt und auf eine neue Volt-
  2FA-Challenge-Seite (/two-factor-challenge) geleitet, die an Fortifys
  bestehenden Controller postet (TOTP + Recovery-Code).
- Gemeinsame Post-Login-Logik in App\Support\LoginRedirect (rollengerechtes
  Home + 403-sicherer intended-Redirect), genutzt von Volt-Login UND Response.
- RoleAwareLoginResponse implementiert jetzt LoginResponse UND
  TwoFactorLoginResponse und erzwingt einheitlich: unverifiziert → Notice,
  verifiziert-inaktiv → Logout+Fehler, sonst 403-sicherer Redirect. Damit ist
  auch der direkte Fortify-POST-Pfad gehärtet.

Tests: 2FA-Übergabe, Challenge-Guard, voller TOTP-Flow, Fortify-POST blockt
inaktive User und hält Customer aus dem Admin-Bereich.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-06-16 10:00:15 +00:00
parent d98d297524
commit f4ca452c6b
8 changed files with 295 additions and 81 deletions

View file

@ -0,0 +1,59 @@
<?php
namespace App\Support;
use App\Models\User;
/**
* Gemeinsame Post-Login-Redirect-Logik für beide Login-Pfade (Volt-Login und
* Fortify-LoginResponse), damit Rollen- und 403-Schutz nicht auseinanderlaufen.
*/
class LoginRedirect
{
/**
* Rollengerechtes Home: Admin/Editor Admin-Dashboard, Customer Mein
* Bereich, sonst Startseite.
*/
public static function homeFor(User $user): string
{
if ($user->canAccessAdmin()) {
return route('dashboard', absolute: false);
}
if ($user->canAccessCustomer()) {
return route('me.dashboard', absolute: false);
}
return '/';
}
/**
* Übernimmt die intended-URL nur, wenn der User sie erreichen darf sonst
* das rollengerechte Home. Verhindert die 403-Sackgasse (z. B. ein Customer
* mit intended=/admin/users).
*/
public static function safeTarget(User $user, ?string $intended, string $default): string
{
$intended = $intended ?: $default;
$path = parse_url($intended, PHP_URL_PATH) ?: '/';
if (! $user->canAccessAdmin() && self::isAdminOnlyPath($path)) {
return $default;
}
return $intended;
}
/**
* Reine Admin-Pfade: alles unter /admin außer dem Kundenbereich /admin/me
* sowie das Admin-Dashboard /dashboard.
*/
public static function isAdminOnlyPath(string $path): bool
{
if ($path === '/dashboard' || str_starts_with($path, '/dashboard/')) {
return true;
}
return str_starts_with($path, '/admin') && ! str_starts_with($path, '/admin/me');
}
}