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));
}
}

View file

@ -14,6 +14,7 @@ use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Str;
use Laravel\Fortify\Actions\RedirectIfTwoFactorAuthenticatable;
use Laravel\Fortify\Contracts\LoginResponse;
use Laravel\Fortify\Contracts\TwoFactorLoginResponse;
use Laravel\Fortify\Fortify;
use Livewire\Volt\Volt;
@ -25,6 +26,7 @@ class FortifyServiceProvider extends ServiceProvider
public function register(): void
{
$this->app->singleton(LoginResponse::class, RoleAwareLoginResponse::class);
$this->app->singleton(TwoFactorLoginResponse::class, RoleAwareLoginResponse::class);
}
/**

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');
}
}