diff --git a/app/Http/Controllers/Auth/MagicLinkConsumeController.php b/app/Http/Controllers/Auth/MagicLinkConsumeController.php index 16325fb..fefd203 100644 --- a/app/Http/Controllers/Auth/MagicLinkConsumeController.php +++ b/app/Http/Controllers/Auth/MagicLinkConsumeController.php @@ -30,10 +30,20 @@ class MagicLinkConsumeController extends Controller return redirect()->route('login')->with('status', __('Your account is not active.')); } - $magicLink->update([ - 'consumed_at' => now(), - 'ip_consumed' => $request->ip(), - ]); + // Atomar beanspruchen: nur EIN paralleler Request darf den Link + // verbrauchen. Das konditionale UPDATE greift dank Zeilen-Atomarität + // genau einmal; verliert ein Race, ist consumed_at bereits gesetzt. + $claimed = MagicLink::query() + ->whereKey($magicLink->id) + ->whereNull('consumed_at') + ->update([ + 'consumed_at' => now(), + 'ip_consumed' => $request->ip(), + ]); + + if ($claimed === 0) { + return redirect()->route('login')->with('status', __('The magic login link has expired or was already used.')); + } $magicLink->user->update([ 'last_login_at' => now(), diff --git a/app/Services/Auth/ContactAccessService.php b/app/Services/Auth/ContactAccessService.php index b73b074..1892943 100644 --- a/app/Services/Auth/ContactAccessService.php +++ b/app/Services/Auth/ContactAccessService.php @@ -45,12 +45,16 @@ class ContactAccessService } $user = $this->resolveUser($email, $contacts); - $this->linkCompanies($user, $contacts); + // Deaktivierte Bestands-Accounts nicht verändern: eine unauthentifizierte + // Anfrage darf weder Firmenzuordnungen vorbereiten noch einen Link + // auslösen. Frisch lazy angelegte Accounts sind aktiv und laufen weiter. if (! $user->is_active) { return false; } + $this->linkCompanies($user, $contacts); + $generated = $this->magicLinks->createLoginLink($user, $ip); Mail::to($user->email)->send(new MagicLoginLink( diff --git a/docs/weiteres/Sicherheit & Deployment-Hinweise (Auth, Rollen, Verifizierung).md b/docs/weiteres/Sicherheit & Deployment-Hinweise (Auth, Rollen, Verifizierung).md index 81cab24..c65ce52 100644 --- a/docs/weiteres/Sicherheit & Deployment-Hinweise (Auth, Rollen, Verifizierung).md +++ b/docs/weiteres/Sicherheit & Deployment-Hinweise (Auth, Rollen, Verifizierung).md @@ -67,7 +67,21 @@ Ergänzend: Magic-Link-Consume nutzt einen rollensicheren Redirect **ohne** `int --- -## 6. Deployment-Reihenfolge (Migrationen dieser Phase) +## 6. Login-/Magic-Link-Härtung (Security-Review 16.06.) + +Aus einer gezielten Auth-Prüfung umgesetzt: + +- **Magic-Link-Versand rate-limited** (vorher ungedrosselt): pro E-Mail+IP (3/h) und zusätzlich pro IP (15/h) – verhindert Mail-Fluten aktiver Accounts und das laufende Entwerten alter Links. Der Pressekontakt-Zugang war bereits limitiert. +- **Inaktive (aber verifizierte) User werden beim Passwort-Login zentral blockiert** (`Auth::logout()` + Fehler), analog zum Magic-Link-Consume. Schließt nur-`auth`+`verified`-Routen ohne zusätzlichen `is_active`-Check ab. +- **Rollensicherer Login-Redirect**: eine gemerkte `intended`-Admin-URL (z. B. `/admin/users`) schickt einen Customer nicht mehr in den 403 – inaktzessible Admin-Pfade fallen auf das rollengerechte Ziel zurück. +- **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. + +--- + +## 7. Deployment-Reihenfolge (Migrationen dieser Phase) 1. `2026_06_15_101337_backfill_email_verified_at_for_existing_users` — verhindert Lockout. 2. `2026_06_16_080913_downgrade_legacy_editor_users_to_customer` — schließt die Admin-Überberechtigung. diff --git a/resources/views/livewire/auth/login.blade.php b/resources/views/livewire/auth/login.blade.php index 0a955d7..6e684e4 100644 --- a/resources/views/livewire/auth/login.blade.php +++ b/resources/views/livewire/auth/login.blade.php @@ -45,24 +45,35 @@ new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Willkommen zu } $authenticatedUser = Auth::user(); - if ($authenticatedUser) { - $authenticatedUser->update([ - 'last_login_at' => now(), - 'last_login_ip' => request()->ip(), - ]); - } - - RateLimiter::clear($this->throttleKey()); - Session::regenerate(); // 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()); + 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) { + Auth::logout(); + + throw ValidationException::withMessages([ + 'email' => __('Ihr Konto ist nicht aktiv. Bitte wenden Sie sich an den Support.'), + ]); + } + + $authenticatedUser?->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 @@ -74,7 +85,7 @@ new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Willkommen zu ? route('me.dashboard', absolute: false) : '/'); - $this->redirectIntended(default: $defaultRoute); + $this->redirect($this->safeRedirectTarget($authenticatedUser, $defaultRoute)); } public function sendMagicLink(): void @@ -84,6 +95,10 @@ new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Willkommen zu attributes: ['magicEmail' => __('E-Mail-Adresse')], ); + $this->ensureMagicLinkNotRateLimited(); + RateLimiter::hit($this->magicLinkThrottleKey(), 3600); + RateLimiter::hit($this->magicLinkIpThrottleKey(), 3600); + $user = User::query()->where('email', $this->magicEmail)->first(); if ($user && $user->is_active) { @@ -126,6 +141,65 @@ new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Willkommen zu ]); } + /** + * Magic-Link-Versand drosseln: pro E-Mail+IP (gegen Mail-Fluten eines + * Accounts und das laufende Entwerten alter Links) und zusätzlich pro IP + * (gegen das Durchprobieren vieler Accounts von einer Quelle). + */ + protected function ensureMagicLinkNotRateLimited(): void + { + $withinEmail = ! RateLimiter::tooManyAttempts($this->magicLinkThrottleKey(), 3); + $withinIp = ! RateLimiter::tooManyAttempts($this->magicLinkIpThrottleKey(), 15); + + if ($withinEmail && $withinIp) { + return; + } + + $seconds = max( + RateLimiter::availableIn($this->magicLinkThrottleKey()), + RateLimiter::availableIn($this->magicLinkIpThrottleKey()), + ); + + throw ValidationException::withMessages([ + 'magicEmail' => __('Zu viele Anfragen. Bitte versuchen Sie es in :minutes Minuten erneut.', [ + 'minutes' => max(1, ceil($seconds / 60)), + ]), + ]); + } + + /** + * Ü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. */ @@ -133,6 +207,16 @@ new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Willkommen zu { return Str::transliterate(Str::lower($this->email).'|'.request()->ip()); } + + protected function magicLinkThrottleKey(): string + { + return 'magic-link|'.Str::transliterate(Str::lower($this->magicEmail).'|'.request()->ip()); + } + + protected function magicLinkIpThrottleKey(): string + { + return 'magic-link-ip|'.request()->ip(); + } }; ?>