- 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>
364 lines
14 KiB
PHP
364 lines
14 KiB
PHP
<?php
|
||
|
||
use App\Mail\MagicLoginLink;
|
||
use App\Models\User;
|
||
use App\Services\Auth\MagicLinkGenerator;
|
||
use Illuminate\Auth\Events\Lockout;
|
||
use Illuminate\Support\Facades\Auth;
|
||
use Illuminate\Support\Facades\Mail;
|
||
use Illuminate\Support\Facades\RateLimiter;
|
||
use Illuminate\Support\Facades\Route;
|
||
use Illuminate\Support\Facades\Session;
|
||
use Illuminate\Support\Str;
|
||
use Illuminate\Validation\ValidationException;
|
||
use Livewire\Attributes\Layout;
|
||
use Livewire\Attributes\Validate;
|
||
use Livewire\Volt\Component;
|
||
|
||
new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Willkommen zurück', 'eyebrow' => 'Anmeldung im Publisher-Hub', 'topRightLabel' => 'Noch kein Konto?', 'topRightLinkText' => 'Konto erstellen', 'topRightLinkHref' => '/register'])] class extends Component {
|
||
#[Validate('required|string|email')]
|
||
public string $email = '';
|
||
|
||
#[Validate('required|string')]
|
||
public string $password = '';
|
||
|
||
public bool $remember = false;
|
||
|
||
// Eigene Eingabe für das Magic-Link-Modal, getrennt vom Login-Formular.
|
||
public string $magicEmail = '';
|
||
|
||
/**
|
||
* Handle an incoming authentication request.
|
||
*/
|
||
public function login(): void
|
||
{
|
||
$this->validate();
|
||
|
||
$this->ensureIsNotRateLimited();
|
||
|
||
if (! Auth::attempt(['email' => $this->email, 'password' => $this->password], $this->remember)) {
|
||
RateLimiter::hit($this->throttleKey());
|
||
|
||
throw ValidationException::withMessages([
|
||
'email' => __('auth.failed'),
|
||
]);
|
||
}
|
||
|
||
$authenticatedUser = Auth::user();
|
||
|
||
// 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
|
||
// (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));
|
||
}
|
||
|
||
public function sendMagicLink(): void
|
||
{
|
||
$this->validate(
|
||
['magicEmail' => 'required|string|email'],
|
||
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) {
|
||
$generated = app(MagicLinkGenerator::class)->createLoginLink($user, request()->ip());
|
||
$loginUrl = route('magic-links.consume', ['token' => $generated['plain_token']]);
|
||
|
||
Mail::to($user->email)->send(
|
||
new MagicLoginLink(
|
||
user: $user,
|
||
loginUrl: $loginUrl,
|
||
expiresAt: $generated['expires_at']->format('d.m.Y H:i')
|
||
)
|
||
);
|
||
}
|
||
|
||
$this->reset('magicEmail');
|
||
$this->dispatch('magic-link-sent');
|
||
|
||
session()->flash('status', __('If an active account exists for this email, we sent a magic login link.'));
|
||
}
|
||
|
||
/**
|
||
* Ensure the authentication request is not rate limited.
|
||
*/
|
||
protected function ensureIsNotRateLimited(): void
|
||
{
|
||
if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) {
|
||
return;
|
||
}
|
||
|
||
event(new Lockout(request()));
|
||
|
||
$seconds = RateLimiter::availableIn($this->throttleKey());
|
||
|
||
throw ValidationException::withMessages([
|
||
'email' => __('auth.throttle', [
|
||
'seconds' => $seconds,
|
||
'minutes' => ceil($seconds / 60),
|
||
]),
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* 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.
|
||
*/
|
||
protected function throttleKey(): string
|
||
{
|
||
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">
|
||
@if (session('status'))
|
||
<div class="field-status mb-4" role="status">
|
||
{{ session('status') }}
|
||
</div>
|
||
@endif
|
||
|
||
<form wire:submit="login" class="space-y-[18px]" x-data="{ showPassword: false }" novalidate>
|
||
|
||
<div>
|
||
<label class="field-label" for="auth-email">E-Mail-Adresse</label>
|
||
<input
|
||
id="auth-email"
|
||
type="email"
|
||
wire:model="email"
|
||
autocomplete="username"
|
||
required
|
||
autofocus
|
||
class="field-input"
|
||
placeholder="redaktion@ihr-unternehmen.de"
|
||
@error('email') aria-invalid="true" @enderror
|
||
/>
|
||
@error('email')
|
||
<p class="field-error">{{ $message }}</p>
|
||
@enderror
|
||
</div>
|
||
|
||
<div>
|
||
<div class="flex items-baseline justify-between mb-1.5">
|
||
<label class="field-label !mb-0" for="auth-password">Passwort</label>
|
||
@if (\Illuminate\Support\Facades\Route::has('password.request'))
|
||
<a href="{{ route('password.request') }}" class="link-hub text-[12px]" wire:navigate>
|
||
Passwort vergessen?
|
||
</a>
|
||
@endif
|
||
</div>
|
||
<div class="field-pw-wrap">
|
||
<input
|
||
id="auth-password"
|
||
wire:model="password"
|
||
:type="showPassword ? 'text' : 'password'"
|
||
autocomplete="current-password"
|
||
required
|
||
class="field-input pr-[72px]"
|
||
placeholder="••••••••••"
|
||
@error('password') aria-invalid="true" @enderror
|
||
/>
|
||
<button
|
||
type="button"
|
||
class="field-affix"
|
||
@click="showPassword = !showPassword"
|
||
x-text="showPassword ? 'Verbergen' : 'Anzeigen'"
|
||
>Anzeigen</button>
|
||
</div>
|
||
@error('password')
|
||
<p class="field-error">{{ $message }}</p>
|
||
@enderror
|
||
</div>
|
||
|
||
<label class="flex items-center gap-2.5 text-[12.5px] text-ink-2 cursor-pointer select-none">
|
||
<input type="checkbox" wire:model="remember" class="auth-check" />
|
||
Angemeldet bleiben
|
||
</label>
|
||
|
||
<button type="submit" class="auth-btn-primary !mt-[22px]" wire:loading.attr="disabled" wire:target="login">
|
||
<span wire:loading.remove wire:target="login">Anmelden</span>
|
||
<span wire:loading wire:target="login">Anmelden …</span>
|
||
</button>
|
||
|
||
<div class="flex items-center gap-3 !mt-[22px] !mb-[14px]">
|
||
<span class="flex-1 h-px bg-bg-rule"></span>
|
||
<span class="text-[11px] font-semibold tracking-[0.18em] uppercase text-ink-3">oder</span>
|
||
<span class="flex-1 h-px bg-bg-rule"></span>
|
||
</div>
|
||
|
||
<button
|
||
type="button"
|
||
@click="magicModal = true; $nextTick(() => $refs.magicEmail?.focus())"
|
||
class="auth-btn-outline !mt-0"
|
||
>
|
||
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||
<rect x="2" y="3" width="12" height="10" stroke="currentColor" stroke-width="1.4" />
|
||
<path d="M2.5 4l5.5 5 5.5-5" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round" />
|
||
</svg>
|
||
<span>Magic-Link senden</span>
|
||
</button>
|
||
|
||
<a href="{{ route('contact-access.request') }}" class="block text-center text-[12px] text-ink-3 hover:text-hub transition-colors !mt-4" wire:navigate>
|
||
Als Pressekontakt hinterlegt? Zugang anfordern →
|
||
</a>
|
||
</form>
|
||
|
||
{{-- Magic-Link-Modal: eigene E-Mail-Eingabe, unabhängig vom Login-Formular --}}
|
||
<div
|
||
x-show="magicModal"
|
||
x-cloak
|
||
style="display: none"
|
||
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||
x-on:keydown.escape.window="magicModal = false"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
>
|
||
<div class="absolute inset-0 bg-black/50" @click="magicModal = false"></div>
|
||
|
||
<div
|
||
class="relative w-full max-w-sm rounded-lg border border-bg-rule bg-bg-card p-6 shadow-xl"
|
||
x-transition
|
||
>
|
||
<h3 class="text-[15px] font-semibold text-ink mb-1.5">Magic-Link anfordern</h3>
|
||
<p class="text-[12.5px] text-ink-2 leading-[1.55] mb-4">
|
||
Geben Sie Ihre E-Mail-Adresse ein. Wir senden Ihnen einen Anmeldelink,
|
||
mit dem Sie sich ohne Passwort anmelden können.
|
||
</p>
|
||
|
||
<form wire:submit="sendMagicLink">
|
||
<label class="field-label" for="magic-email">E-Mail-Adresse</label>
|
||
<input
|
||
id="magic-email"
|
||
x-ref="magicEmail"
|
||
type="email"
|
||
wire:model="magicEmail"
|
||
autocomplete="email"
|
||
class="field-input"
|
||
placeholder="redaktion@ihr-unternehmen.de"
|
||
@error('magicEmail') aria-invalid="true" @enderror
|
||
/>
|
||
@error('magicEmail')
|
||
<p class="field-error">{{ $message }}</p>
|
||
@enderror
|
||
|
||
<div class="flex gap-2.5 !mt-4">
|
||
<button type="button" class="auth-btn-outline !mt-0 flex-1" @click="magicModal = false">
|
||
Abbrechen
|
||
</button>
|
||
<button type="submit" class="auth-btn-primary !mt-0 flex-1" wire:loading.attr="disabled" wire:target="sendMagicLink">
|
||
<span wire:loading.remove wire:target="sendMagicLink">Link senden</span>
|
||
<span wire:loading wire:target="sendMagicLink">Senden …</span>
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|