presseportale/resources/views/livewire/auth/login.blade.php
Kevin Adametz 5dfd622141 security: Andere Geräte abmelden, IP-weites Login-Limit, dauerhafte Sperrmarke, Passwortregeln, CSP im Melde-Modus
Restliche Phase 4 aus dem Sicherheitsaudit 07.10.2026:

- Sicherheitsseite: „Andere Geräte abmelden“ rotiert remember_token und
  beendet alle anderen Sessions, die aktuelle bleibt (nicht während einer
  Impersonation).
- LoginIpThrottle: 20 Fehlversuche je IP in 15 Minuten über alle Konten.
  Gezählt über das Failed-Event, damit Livewire-Login und Fortifys
  POST /login denselben Zähler teilen; Erfolg setzt ihn nicht zurück.
- Sperrmarke für Sessions in users.sessions_revoked_at_ms statt nur im
  Cache — übersteht cache:clear. Die alte Cache-Marke zählt übergangsweise
  mit und kann im nächsten Release entfallen.
- Neue und geänderte Passwörter: mindestens 12 Zeichen, in Produktion
  zusätzlich uncompromised() (HIBP, k-Anonymität). Bestehende bleiben gültig.
- Content-Security-Policy-Report-Only in Produktion; Meldungen über
  /csp-report nach storage/logs/csp-*.log, je Meldung höchstens einmal pro
  Stunde, ohne Query und tokenartige Pfadsegmente.

Migration: users.sessions_revoked_at_ms (users hat FULLTEXT, 25 MB, Sekunden).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 13:02:49 +00:00

226 lines
9.6 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
use App\Models\User;
use App\Services\Auth\LoginCompletion;
use App\Services\Auth\LoginIpThrottle;
use App\Support\LoginRedirect;
use Illuminate\Auth\Events\Failed;
use Illuminate\Auth\Events\Lockout;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Session;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Validate;
use Livewire\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;
/**
* Handle an incoming authentication request.
*/
public function login(): void
{
$this->validate();
$this->ensureIsNotRateLimited();
// 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());
// Standard-Event wie bei Auth::attempt() — ohne Passwort; der
// Aktivitäts-Verlauf leitet den Grund am User ab (Monitoring 1.7).
event(new Failed('web', $user, ['email' => $this->email]));
throw ValidationException::withMessages([
'email' => __('auth.failed'),
]);
}
RateLimiter::clear($this->throttleKey());
// 2FA aktiv: nicht einloggen, sondern in Fortifys Challenge übergeben
// (Session-Vertrag login.id/login.remember wie RedirectIfTwoFactorAuthenticatable).
if ($user->hasEnabledTwoFactorAuthentication()) {
app(LoginCompletion::class)->handOffToTwoFactor(request(), $user, 'password', $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.
if (! $user->is_active) {
Auth::logout();
throw ValidationException::withMessages([
'email' => __('Ihr Konto ist nicht aktiv. Bitte wenden Sie sich an den Support.'),
]);
}
app(LoginCompletion::class)->recordLogin($user, request());
Session::regenerate();
// 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),
));
}
/**
* Ensure the authentication request is not rate limited.
*/
protected function ensureIsNotRateLimited(): void
{
// Zwei Grenzen: je Adresse und IP sowie IP-weit über alle Konten
// (LoginIpThrottle, Fehlversuche zählt CountFailedLoginsPerIp).
$ipThrottle = app(LoginIpThrottle::class);
$accountLimited = RateLimiter::tooManyAttempts($this->throttleKey(), 5);
if (! $accountLimited && ! $ipThrottle->isBlocked(request()->ip())) {
return;
}
// Die Adresse steckt im Livewire-Payload, nicht im Request-Input —
// für den Aktivitäts-Verlauf mitgeben.
event(new Lockout(request()->duplicate(request: ['email' => $this->email])));
$seconds = $accountLimited
? RateLimiter::availableIn($this->throttleKey())
: $ipThrottle->availableIn(request()->ip());
throw ValidationException::withMessages([
'email' => __('auth.throttle', [
'seconds' => $seconds,
'minutes' => ceil($seconds / 60),
]),
]);
}
/**
* Get the authentication rate limiting throttle key.
*/
protected function throttleKey(): string
{
return Str::transliterate(Str::lower($this->email).'|'.request()->ip());
}
}; ?>
<div>
<x-session-status class="mb-4" />
<form wire:submit="login" class="space-y-[18px]" 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>
<x-password-input name="password" :label="__('Passwort')" autocomplete="current-password">
<x-slot:labelAction>
@if (\Illuminate\Support\Facades\Route::has('password.request'))
<a href="{{ route('password.request') }}" class="link-hub text-[12px]" wire:navigate>
{{ __('Passwort vergessen?') }}
</a>
@endif
</x-slot:labelAction>
</x-password-input>
<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>
{{-- Passkey-Login (Entscheidung 5, 07.10.2026): Fingerabdruck, Gesicht
oder Geräte-PIN statt Passwort und App-Code. Nur sichtbar, wenn der
Browser WebAuthn kann. --}}
<div x-data="{ busy: false, error: null, supported: typeof window.PublicKeyCredential !== 'undefined' }" x-show="supported" class="!mt-0 mb-3">
<button type="button" class="auth-btn-outline !mt-0" x-bind:disabled="busy"
x-on:click="busy = true; error = null;
const outcome = await window.PkPasskeys.login($wire.remember);
if (outcome.ok) { window.location.href = outcome.result?.redirect ?? '/'; return; }
error = outcome.error; busy = false;">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<circle cx="6" cy="5" r="3" stroke="currentColor" stroke-width="1.4" />
<path d="M1.5 14c0-2.5 2-4.5 4.5-4.5 1 0 1.9.3 2.6.8" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" />
<circle cx="12" cy="10" r="2" stroke="currentColor" stroke-width="1.4" />
<path d="M12 12v3M12 14h1.5" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" />
</svg>
<span x-show="! busy">{{ __('Mit Passkey anmelden') }}</span>
<span x-show="busy">{{ __('Passkey wird geprüft …') }}</span>
</button>
<p class="field-error" x-show="error" x-text="error"></p>
</div>
<a href="{{ route('magic-link.request') }}" class="auth-btn-outline !mt-0" wire:navigate>
<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>{{ __('Anmeldung per E-Mail-Link') }}</span>
</a>
<a href="{{ route('oauth.google.redirect') }}" class="auth-btn-outline !mt-3">
<svg width="15" height="15" viewBox="0 0 18 18" aria-hidden="true">
<path fill="#4285F4" d="M17.64 9.2c0-.64-.06-1.25-.16-1.84H9v3.48h4.84a4.14 4.14 0 0 1-1.8 2.72v2.26h2.92c1.71-1.57 2.68-3.89 2.68-6.62z"/>
<path fill="#34A853" d="M9 18c2.43 0 4.47-.8 5.96-2.18l-2.92-2.26c-.81.54-1.84.86-3.04.86-2.34 0-4.32-1.58-5.03-3.7H.96v2.33A9 9 0 0 0 9 18z"/>
<path fill="#FBBC05" d="M3.97 10.72A5.4 5.4 0 0 1 3.68 9c0-.6.1-1.18.29-1.72V4.95H.96A9 9 0 0 0 0 9c0 1.45.35 2.82.96 4.05l3.01-2.33z"/>
<path fill="#EA4335" d="M9 3.58c1.32 0 2.5.45 3.44 1.35l2.58-2.59C13.46.89 11.43 0 9 0A9 9 0 0 0 .96 4.95l3.01 2.33C4.68 5.16 6.66 3.58 9 3.58z"/>
</svg>
<span>{{ __('Mit Google anmelden') }}</span>
</a>
</form>
</div>