Audit: dev/sicherheit/SICHERHEITSAUDIT-2026-10-07.md, Plan und Entscheidungen: dev/sicherheit/ENTWICKLUNGSPLAN-SICHERHEIT-2026-10-07.md. - S-01 Legacy-Rechnungen fremder Kunden nur mit billing:manage (auch Kundenroute). - S-02 Kontaktzuordnung auf der Firmenanzeige verlangt companies:write, $id gesperrt. - S-03 CompanyMembershipObserver: automatische Firmenrechte werden bei jeder Adress-/Zuordnungsänderung sofort neu bewertet; memberships:resync-auto. - S-04 Passwortwechsel beendet alle anderen Sessions (PasswordChanger), Reset alle Sessions und offene Login-Links; Fortify PUT /user/password abgeschaltet. API-Tokens bleiben bestehen. - S-05 Google: email_verified Pflicht, feste Bindung über die Provider-ID; Erstverknüpfung bestehender Konten außerhalb gmail/hd nur per Bestätigungslink in derselben Browser-Session. - S-06 Frischer Identitätsnachweis (Passwort, App-Code, E-Mail-Code oder Passkey) vor E-Mail-Wechsel, erster 2FA-Einrichtung und Token-Erzeugung; Hinweis-Mails an die (bisherige) Kontoadresse. - Entscheidung 5: Passkeys (Fortify, @laravel/passkeys) für alle; Super-Admins und Admins bestätigen die Session für den Admin-Bereich per Passkey oder App-Code (EnsureStrongAdminAuthentication, Notfallschalter AUTH_REQUIRE_STRONG_ADMIN); Reset des zweiten Faktors durch Super-Admins. Neue Migration: create_passkeys_table. Suite 2.403 grün (1 übersprungen), Larastan ohne Meldung. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
218 lines
9.2 KiB
PHP
218 lines
9.2 KiB
PHP
<?php
|
||
|
||
use App\Models\User;
|
||
use App\Services\Auth\LoginCompletion;
|
||
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
|
||
{
|
||
if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) {
|
||
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 = RateLimiter::availableIn($this->throttleKey());
|
||
|
||
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>
|