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

@ -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.
*/

View file

@ -0,0 +1,79 @@
<?php
use Livewire\Attributes\Layout;
use Livewire\Volt\Component;
new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Zwei-Faktor-Bestätigung', 'eyebrow' => 'Sicherheitsprüfung', 'showFromBanner' => false])] class extends Component {
public function mount(): void
{
// Ohne laufende Challenge (login.id aus dem ersten Schritt) hat die Seite
// keinen Kontext zurück zum Login.
if (! session()->has('login.id')) {
$this->redirect(route('login'), navigate: false);
}
}
}; ?>
<div x-data="{ recovery: false }">
@if ($errors->any())
<div class="field-status mb-4" role="alert">
{{ $errors->first() }}
</div>
@endif
<p class="text-[13.5px] text-ink-2 leading-[1.65] !-mt-2 mb-6">
<span x-show="! recovery">
Geben Sie den 6-stelligen Code aus Ihrer Authenticator-App ein.
</span>
<span x-show="recovery" x-cloak>
Geben Sie einen Ihrer Wiederherstellungs-Codes ein.
</span>
</p>
{{-- Echter POST an Fortifys 2FA-Challenge-Controller (verifiziert gegen die
login.id aus der Session und loggt bei Erfolg ein). --}}
<form method="POST" action="/two-factor-challenge" class="space-y-[18px]">
@csrf
<div x-show="! recovery">
<label class="field-label" for="code">Authentifizierungs-Code</label>
<input
id="code"
name="code"
type="text"
inputmode="numeric"
autocomplete="one-time-code"
class="field-input"
placeholder="123456"
x-bind:disabled="recovery"
x-ref="code"
/>
</div>
<div x-show="recovery" x-cloak>
<label class="field-label" for="recovery_code">Wiederherstellungs-Code</label>
<input
id="recovery_code"
name="recovery_code"
type="text"
autocomplete="one-time-code"
class="field-input"
placeholder="xxxxxxxx-xxxxxxxx"
x-bind:disabled="! recovery"
/>
</div>
<button type="submit" class="auth-btn-primary !mt-[18px]">
Bestätigen
</button>
<button
type="button"
class="auth-btn-outline !mt-0"
x-on:click="recovery = ! recovery; $nextTick(() => (recovery ? document.getElementById('recovery_code') : $refs.code)?.focus())"
>
<span x-show="! recovery">Wiederherstellungs-Code verwenden</span>
<span x-show="recovery" x-cloak>Code aus App verwenden</span>
</button>
</form>
</div>