diff --git a/app/Http/Responses/RoleAwareLoginResponse.php b/app/Http/Responses/RoleAwareLoginResponse.php index eba4104..966412e 100644 --- a/app/Http/Responses/RoleAwareLoginResponse.php +++ b/app/Http/Responses/RoleAwareLoginResponse.php @@ -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)); } } diff --git a/app/Providers/FortifyServiceProvider.php b/app/Providers/FortifyServiceProvider.php index 9ff4559..aee537d 100644 --- a/app/Providers/FortifyServiceProvider.php +++ b/app/Providers/FortifyServiceProvider.php @@ -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); } /** diff --git a/app/Support/LoginRedirect.php b/app/Support/LoginRedirect.php new file mode 100644 index 0000000..b8b050c --- /dev/null +++ b/app/Support/LoginRedirect.php @@ -0,0 +1,59 @@ +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'); + } +} diff --git a/docs/weiteres/Sicherheit & Deployment-Hinweise (Auth, Rollen, Verifizierung).md b/docs/weiteres/Sicherheit & Deployment-Hinweise (Auth, Rollen, Verifizierung).md index c65ce52..ad4e063 100644 --- a/docs/weiteres/Sicherheit & Deployment-Hinweise (Auth, Rollen, Verifizierung).md +++ b/docs/weiteres/Sicherheit & Deployment-Hinweise (Auth, Rollen, Verifizierung).md @@ -77,7 +77,18 @@ Aus einer gezielten Auth-Prüfung umgesetzt: - **ContactAccess mutiert keine deaktivierten Bestands-Accounts** mehr: der `is_active`-Check läuft vor jeder Firmenzuordnung/jedem Linkversand. - **Magic-Link-Verbrauch ist atomar** (konditionales `UPDATE … whereNull(consumed_at)`): Single-Use auch bei parallelen Requests garantiert. -**Offen (Empfehlung):** Statt nur Honeypot + Rate-Limit beim Pressekontakt-Zugang ein echtes Captcha (Cloudflare Turnstile / hCaptcha) – benötigt Provider-Entscheidung, Keys und ein Paket. Bewusst noch nicht umgesetzt. +**Captcha (dokumentiert, vorerst NICHT eingebaut):** Beim Pressekontakt-Zugang greifen aktuell Honeypot + Rate-Limit. Ein echtes Captcha (Cloudflare Turnstile / hCaptcha) ist sinnvoll, wird aber erst eingebaut, falls im Livebetrieb zu viel Missbrauch auftritt (Entscheidung 16.06.) – braucht Provider-Entscheidung, Keys und ein Paket. + +## 6b. 2FA-Bypass behoben & Login-Pfade konsolidiert (Review 16.06., Teil 2) + +**Befund:** Der sichtbare Volt-Login machte direkt `Auth::attempt()` und umging damit Fortifys 2FA-Pipeline (`RedirectIfTwoFactorAuthenticatable`) – für Accounts mit aktivem 2FA faktisch ein **2FA-Bypass**. Zusätzlich existierte der Fortify-POST `/login` parallel mit schwächeren Post-Login-Regeln (kein `is_active`-Block, `resolveTarget` neutralisierte nur `/` und `/dashboard`). + +**Fix (Volt-nativ):** +- Der Volt-Login prüft die Zugangsdaten jetzt OHNE sofortiges Einloggen; bei aktivem 2FA (`hasEnabledTwoFactorAuthentication()`) wird der Session-Vertrag `login.id`/`login.remember` gesetzt und auf eine neue **Volt-2FA-Challenge-Seite** (`/two-factor-challenge`) geleitet. Diese postet an Fortifys bestehenden `two-factor-challenge`-Controller (TOTP + Recovery-Code). +- Gemeinsame Post-Login-Logik in `App\Support\LoginRedirect` (rollengerechtes Home + 403-sicherer intended-Redirect), genutzt von Volt-Login UND der Fortify-Response. +- `RoleAwareLoginResponse` implementiert jetzt **beide** Contracts (`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. + +**Hinweis:** `fortify.views => false` bleibt; die Challenge wird vom Volt-Frontend bereitgestellt, die Verifizierung übernimmt Fortify. --- diff --git a/resources/views/livewire/auth/login.blade.php b/resources/views/livewire/auth/login.blade.php index 6e684e4..9e8ec53 100644 --- a/resources/views/livewire/auth/login.blade.php +++ b/resources/views/livewire/auth/login.blade.php @@ -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. */ diff --git a/resources/views/livewire/auth/two-factor-challenge.blade.php b/resources/views/livewire/auth/two-factor-challenge.blade.php new file mode 100644 index 0000000..eb7cc19 --- /dev/null +++ b/resources/views/livewire/auth/two-factor-challenge.blade.php @@ -0,0 +1,79 @@ + '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); + } + } +}; ?> + +
+ + Geben Sie den 6-stelligen Code aus Ihrer Authenticator-App ein. + + + Geben Sie einen Ihrer Wiederherstellungs-Codes ein. + +
+ + {{-- Echter POST an Fortifys 2FA-Challenge-Controller (verifiziert gegen die + login.id aus der Session und loggt bei Erfolg ein). --}} + +