Security-Härtung Login & Magic-Link (Review 16.06.)

- Magic-Link-Versand im Login rate-limited (E-Mail+IP 3/h und IP-only 15/h);
  verhindert Mail-Fluten und das Entwerten aktiver Links.
- Inaktive (aber verifizierte) User werden beim Passwort-Login zentral
  blockiert (Auth::logout + Fehler) – sichert nur-auth/verified-Routen ab.
- Rollensicherer Login-Redirect: gemerkte intended-Admin-URLs schicken einen
  Customer nicht mehr in den 403, sondern auf das rollengerechte Ziel.
- ContactAccess prüft is_active vor jeder Mutation: deaktivierte Bestands-
  Accounts werden durch eine Anfrage weder verändert noch angemailt.
- Magic-Link-Verbrauch atomar (UPDATE … whereNull(consumed_at)) – Single-Use
  auch bei parallelen Requests.
- Sicherheits-Doku um diese Härtungen + Captcha-Empfehlung ergänzt.

Tests: Rate-Limit, intended-Admin-URL für Customer, inaktiver Login,
ContactAccess ohne Mutation inaktiver Accounts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-06-16 09:33:44 +00:00
parent 84f7eb3aab
commit d98d297524
7 changed files with 201 additions and 16 deletions

View file

@ -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();
}
}; ?>
<div x-data="{ magicModal: false }" x-on:magic-link-sent.window="magicModal = false">