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

@ -2,17 +2,22 @@
namespace App\Http\Responses;
use App\Support\LoginRedirect;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Laravel\Fortify\Contracts\LoginResponse as LoginResponseContract;
use Laravel\Fortify\Contracts\TwoFactorLoginResponse as TwoFactorLoginResponseContract;
/**
* Leitet Panel-User nach erfolgreichem Login je nach Rolle:
* - Admin/Editor /dashboard (Admin-Bereich)
* - Customer /admin/me (Mein Bereich)
* 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)
* - sonst rollengerechter, 403-sicherer Redirect (intended nur wenn erreichbar)
*/
class RoleAwareLoginResponse implements LoginResponseContract
class RoleAwareLoginResponse implements LoginResponseContract, TwoFactorLoginResponseContract
{
public function toResponse($request): RedirectResponse|JsonResponse
{
@ -21,31 +26,24 @@ class RoleAwareLoginResponse implements LoginResponseContract
}
$user = $request->user();
$intended = redirect()->intended();
if ($user?->canAccessAdmin()) {
return $intended->setTargetUrl(
$this->resolveTarget($intended->getTargetUrl(), route('dashboard'))
);
if ($user && ! $user->hasVerifiedEmail()) {
return redirect()->route('verification.notice');
}
if ($user?->canAccessCustomer()) {
return $intended->setTargetUrl(
$this->resolveTarget($intended->getTargetUrl(), route('me.dashboard'))
);
if ($user && ! $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.'),
]);
}
return $intended->setTargetUrl(url('/'));
}
$default = LoginRedirect::homeFor($user);
$intended = $request->session()->pull('url.intended');
/**
* Übernimmt die Intended-URL nur, wenn sie nicht auf den Default-Home-Pfad zeigt.
*/
private function resolveTarget(string $intendedUrl, string $fallback): string
{
$homePath = (string) config('fortify.home', '/dashboard');
$intendedPath = parse_url($intendedUrl, PHP_URL_PATH) ?: '/';
return $intendedPath === $homePath || $intendedPath === '/' ? $fallback : $intendedUrl;
return redirect(LoginRedirect::safeTarget($user, $intended, $default));
}
}