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:
parent
d98d297524
commit
f4ca452c6b
8 changed files with 295 additions and 81 deletions
|
|
@ -3,8 +3,10 @@
|
|||
use App\Mail\MagicLoginLink;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\MagicLinkGenerator;
|
||||
use App\Support\LoginRedirect;
|
||||
use Illuminate\Auth\Events\Lockout;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
|
@ -36,7 +38,12 @@ new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Willkommen zu
|
|||
|
||||
$this->ensureIsNotRateLimited();
|
||||
|
||||
if (! Auth::attempt(['email' => $this->email, 'password' => $this->password], $this->remember)) {
|
||||
// Zugangsdaten prüfen, OHNE schon einzuloggen – sonst würde die
|
||||
// Fortify-2FA-Pipeline umgangen (2FA-Bypass). Legacy-User ohne Passwort
|
||||
// (password = null) scheitern hier korrekt und nutzen Magic-Link/Reset.
|
||||
$user = User::query()->where('email', $this->email)->first();
|
||||
|
||||
if (! $user || ! $user->password || ! Hash::check($this->password, $user->password)) {
|
||||
RateLimiter::hit($this->throttleKey());
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
|
|
@ -44,21 +51,33 @@ new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Willkommen zu
|
|||
]);
|
||||
}
|
||||
|
||||
$authenticatedUser = Auth::user();
|
||||
RateLimiter::clear($this->throttleKey());
|
||||
|
||||
// Unverifizierte Selbst-Registrierer (Konto angelegt, Mail noch nicht
|
||||
// bestätigt) gehören auf die Notice-Seite, nicht in die Panel-Logik.
|
||||
if ($authenticatedUser && ! $authenticatedUser->hasVerifiedEmail()) {
|
||||
RateLimiter::clear($this->throttleKey());
|
||||
// 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,
|
||||
]);
|
||||
|
||||
$this->redirect(route('two-factor.challenge'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Auth::login($user, $this->remember);
|
||||
|
||||
// Unverifizierte Selbst-Registrierer → Notice-Seite.
|
||||
if (! $user->hasVerifiedEmail()) {
|
||||
Session::regenerate();
|
||||
$this->redirect(route('verification.notice', absolute: false));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Verifiziert, aber deaktiviert: Login zentral blockieren (analog zum
|
||||
// Magic-Link-Consume), damit auch nur-auth/verified-Routen sicher sind.
|
||||
if ($authenticatedUser && ! $authenticatedUser->is_active) {
|
||||
// Verifiziert, aber deaktiviert: Login zentral blockieren.
|
||||
if (! $user->is_active) {
|
||||
Auth::logout();
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
|
|
@ -66,26 +85,20 @@ new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Willkommen zu
|
|||
]);
|
||||
}
|
||||
|
||||
$authenticatedUser?->update([
|
||||
$user->update([
|
||||
'last_login_at' => now(),
|
||||
'last_login_ip' => request()->ip(),
|
||||
]);
|
||||
|
||||
RateLimiter::clear($this->throttleKey());
|
||||
Session::regenerate();
|
||||
|
||||
// Rollen-basierter Default-Redirect:
|
||||
// Admin/Editor → /dashboard, Customer → /admin/me.
|
||||
// Ohne navigate:true, weil das Portal ein anderes Vite-Bundle nutzt
|
||||
// (build/portal mit FluxUI) als das Hub-Auth-Layout (build/web).
|
||||
// SPA-Navigation kann den Bundle-Wechsel nicht handhaben.
|
||||
$defaultRoute = $authenticatedUser?->canAccessAdmin()
|
||||
? route('dashboard', absolute: false)
|
||||
: ($authenticatedUser?->canAccessCustomer()
|
||||
? route('me.dashboard', absolute: false)
|
||||
: '/');
|
||||
|
||||
$this->redirect($this->safeRedirectTarget($authenticatedUser, $defaultRoute));
|
||||
// Rollengerechter, 403-sicherer Redirect. Ohne navigate:true, weil das
|
||||
// Portal ein anderes Vite-Bundle nutzt als das Hub-Auth-Layout.
|
||||
$this->redirect(LoginRedirect::safeTarget(
|
||||
$user,
|
||||
Session::pull('url.intended'),
|
||||
LoginRedirect::homeFor($user),
|
||||
));
|
||||
}
|
||||
|
||||
public function sendMagicLink(): void
|
||||
|
|
@ -167,39 +180,6 @@ new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Willkommen zu
|
|||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Übernimmt die intended-URL nur, wenn der User sie auch erreichen darf –
|
||||
* sonst Default. Verhindert, dass ein Customer mit intended=/admin/users
|
||||
* nach dem Login in der 403-Sackgasse landet.
|
||||
*/
|
||||
protected function safeRedirectTarget(?User $user, string $default): string
|
||||
{
|
||||
// Wie Laravels intended(): die gemerkte URL aus der Session ziehen und
|
||||
// entfernen. (In Livewire ist redirect() der Livewire-Redirector ohne
|
||||
// getTargetUrl(), daher direkt über die Session.)
|
||||
$intended = (string) session()->pull('url.intended', $default);
|
||||
$path = parse_url($intended, PHP_URL_PATH) ?: '/';
|
||||
|
||||
if ($user && ! $user->canAccessAdmin() && $this->isAdminOnlyPath($path)) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
return $intended;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reine Admin-Pfade: alles unter /admin außer dem Kundenbereich /admin/me
|
||||
* sowie das Admin-Dashboard /dashboard.
|
||||
*/
|
||||
protected 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');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the authentication rate limiting throttle key.
|
||||
*/
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue