WS-2: Magic-Link für Firmen & Pressekontakte vereinheitlicht + Schreibzugriff
Magic-Link und Pressekontakt-Zugang zu einer Seite (/anmeldelink) zusammengeführt; altes Login-Modal entfernt, /pressekontakt-zugang leitet weiter. - ContactAccessService deckt jetzt Firmen-E-Mail UND Pressekontakt-E-Mail ab, portalübergreifend (ohne PortalScope). Eine E-Mail mehrfach hinterlegt → genau ein Account, dem alle Firmen + Kontakte zugeordnet werden. - Zugeordnete Firmen erhalten Pivot-Rolle 'responsible' (Schreibzugriff auf Stammdaten, Kontakte, Pressemitteilungen) statt nur 'member'; bestehende Lese-Pivots werden hochgestuft, Owner bleiben unangetastet. - Neuer Login-Listener (SyncCompanyMembershipsOnLogin) frischt die Zuordnungen bei JEDEM Login (Magic-Link, Passwort, Google) auf – auch nachträglich (API) hinzugekommene Firmen/Kontakte mit gleicher E-Mail greifen. - Auth-Bereich erzwingt Hellmodus: aus dem Portal übernommene .dark-Klasse wird am <html> entfernt (Login war im Dark Mode hängengeblieben). - Tests: Firmen-E-Mail-Login, Multi-Firmen-Aggregation, Schreibzugriff/Upgrade, Per-Login-Re-Sync, Auth-Hellmodus. Sicherheits-Doku aktualisiert. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
265d1fde53
commit
fec2148ebb
12 changed files with 587 additions and 327 deletions
|
|
@ -1,105 +0,0 @@
|
|||
<?php
|
||||
|
||||
use App\Services\Auth\ContactAccessService;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
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' => 'Pressemitteilung verwalten', 'eyebrow' => 'Zugang für Pressekontakte', 'topRightLabel' => 'Konto vorhanden?', 'topRightLinkText' => 'Anmelden', 'topRightLinkHref' => '/login'])] class extends Component {
|
||||
#[Validate('required|string|email')]
|
||||
public string $email = '';
|
||||
|
||||
// Honeypot gegen einfache Bots – muss leer bleiben.
|
||||
public string $website = '';
|
||||
|
||||
public function requestAccess(ContactAccessService $contactAccess): void
|
||||
{
|
||||
$this->validate();
|
||||
|
||||
if ($this->website !== '') {
|
||||
// Bot: identische neutrale Antwort, keine Aktion.
|
||||
$this->sent();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->ensureIsNotRateLimited();
|
||||
RateLimiter::hit($this->throttleKey(), 600);
|
||||
|
||||
$contactAccess->requestAccess($this->email, request()->ip());
|
||||
|
||||
$this->sent();
|
||||
}
|
||||
|
||||
private function sent(): void
|
||||
{
|
||||
$this->reset('email');
|
||||
session()->flash('status', __('Falls für diese E-Mail-Adresse ein Pressekontakt hinterlegt ist, haben wir Ihnen einen Zugangslink geschickt.'));
|
||||
}
|
||||
|
||||
protected function ensureIsNotRateLimited(): void
|
||||
{
|
||||
if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$seconds = RateLimiter::availableIn($this->throttleKey());
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'email' => __('Zu viele Anfragen. Bitte versuchen Sie es in :minutes Minuten erneut.', ['minutes' => ceil($seconds / 60)]),
|
||||
]);
|
||||
}
|
||||
|
||||
protected function throttleKey(): string
|
||||
{
|
||||
return 'contact-access|'.Str::transliterate(Str::lower($this->email).'|'.request()->ip());
|
||||
}
|
||||
}; ?>
|
||||
|
||||
<div>
|
||||
@if (session('status'))
|
||||
<div class="field-status mb-4" role="status">
|
||||
{{ session('status') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<p class="text-[13.5px] text-ink-2 leading-[1.65] !-mt-2 mb-6">
|
||||
Sind Sie als Pressekontakt einer Firma hinterlegt? Geben Sie Ihre
|
||||
E-Mail-Adresse ein – wir senden Ihnen einen Link, mit dem Sie die
|
||||
Pressemitteilungen Ihrer Firma verwalten können.
|
||||
</p>
|
||||
|
||||
<form wire:submit="requestAccess" class="space-y-[18px]" novalidate>
|
||||
<div>
|
||||
<label class="field-label" for="contact-email">E-Mail-Adresse</label>
|
||||
<input
|
||||
id="contact-email"
|
||||
type="email"
|
||||
wire:model="email"
|
||||
required
|
||||
autofocus
|
||||
autocomplete="email"
|
||||
class="field-input"
|
||||
placeholder="kontakt@ihre-firma.de"
|
||||
@error('email') aria-invalid="true" @enderror
|
||||
/>
|
||||
@error('email')
|
||||
<p class="field-error">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
{{-- Honeypot: für Menschen unsichtbar --}}
|
||||
<div class="hidden" aria-hidden="true">
|
||||
<label for="contact-website">Website</label>
|
||||
<input id="contact-website" type="text" wire:model="website" tabindex="-1" autocomplete="off" />
|
||||
</div>
|
||||
|
||||
<button type="submit" class="auth-btn-primary !mt-[18px]" wire:loading.attr="disabled" wire:target="requestAccess">
|
||||
<span wire:loading.remove wire:target="requestAccess">Zugangslink anfordern</span>
|
||||
<span wire:loading wire:target="requestAccess">Wird gesendet …</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
|
@ -1,15 +1,11 @@
|
|||
<?php
|
||||
|
||||
use App\Mail\MagicLoginLink;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\MagicLinkGenerator;
|
||||
use App\Support\LoginRedirect;
|
||||
use Illuminate\Auth\Events\Lockout;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
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;
|
||||
|
|
@ -26,9 +22,6 @@ new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Willkommen zu
|
|||
|
||||
public bool $remember = false;
|
||||
|
||||
// Eigene Eingabe für das Magic-Link-Modal, getrennt vom Login-Formular.
|
||||
public string $magicEmail = '';
|
||||
|
||||
/**
|
||||
* Handle an incoming authentication request.
|
||||
*/
|
||||
|
|
@ -101,38 +94,6 @@ new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Willkommen zu
|
|||
));
|
||||
}
|
||||
|
||||
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.
|
||||
*/
|
||||
|
|
@ -154,32 +115,6 @@ 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)),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the authentication rate limiting throttle key.
|
||||
*/
|
||||
|
|
@ -187,19 +122,9 @@ 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">
|
||||
<div>
|
||||
@if (session('status'))
|
||||
<div class="field-status mb-4" role="status">
|
||||
{{ session('status') }}
|
||||
|
|
@ -274,17 +199,13 @@ new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Willkommen zu
|
|||
<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"
|
||||
>
|
||||
<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>Magic-Link senden</span>
|
||||
</button>
|
||||
<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">
|
||||
|
|
@ -296,59 +217,5 @@ new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Willkommen zu
|
|||
<span>Mit Google anmelden</span>
|
||||
</a>
|
||||
|
||||
<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>
|
||||
|
|
|
|||
136
resources/views/livewire/auth/magic-link.blade.php
Normal file
136
resources/views/livewire/auth/magic-link.blade.php
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
<?php
|
||||
|
||||
use App\Services\Auth\ContactAccessService;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
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' => 'Anmeldung per E-Mail-Link', 'eyebrow' => 'Ohne Passwort anmelden', 'topRightLabel' => 'Lieber mit Passwort?', 'topRightLinkText' => 'Anmelden', 'topRightLinkHref' => '/login'])] class extends Component {
|
||||
#[Validate('required|string|email')]
|
||||
public string $email = '';
|
||||
|
||||
// Honeypot gegen einfache Bots – muss leer bleiben.
|
||||
public string $website = '';
|
||||
|
||||
/**
|
||||
* Versendet einen Anmeldelink. Deckt Bestandskonten UND hinterlegte
|
||||
* Pressekontakte (lazy Account) über denselben Service-Eintritt ab.
|
||||
* Enumeration-sicher: identische neutrale Antwort in jedem Fall.
|
||||
*/
|
||||
public function requestLink(ContactAccessService $contactAccess): void
|
||||
{
|
||||
$this->validate();
|
||||
|
||||
if ($this->website !== '') {
|
||||
// Bot: identische neutrale Antwort, keine Aktion.
|
||||
$this->sent();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->ensureIsNotRateLimited();
|
||||
RateLimiter::hit($this->throttleKey(), 3600);
|
||||
RateLimiter::hit($this->ipThrottleKey(), 3600);
|
||||
|
||||
$contactAccess->requestMagicAccess($this->email, request()->ip());
|
||||
|
||||
$this->sent();
|
||||
}
|
||||
|
||||
private function sent(): void
|
||||
{
|
||||
$this->reset('email');
|
||||
session()->flash('status', __('Falls für diese E-Mail-Adresse ein aktives Konto oder ein hinterlegter Pressekontakt existiert, haben wir Ihnen einen Anmeldelink geschickt.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 ensureIsNotRateLimited(): void
|
||||
{
|
||||
$withinEmail = ! RateLimiter::tooManyAttempts($this->throttleKey(), 3);
|
||||
$withinIp = ! RateLimiter::tooManyAttempts($this->ipThrottleKey(), 15);
|
||||
|
||||
if ($withinEmail && $withinIp) {
|
||||
return;
|
||||
}
|
||||
|
||||
$seconds = max(
|
||||
RateLimiter::availableIn($this->throttleKey()),
|
||||
RateLimiter::availableIn($this->ipThrottleKey()),
|
||||
);
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'email' => __('Zu viele Anfragen. Bitte versuchen Sie es in :minutes Minuten erneut.', [
|
||||
'minutes' => max(1, ceil($seconds / 60)),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
protected function throttleKey(): string
|
||||
{
|
||||
return 'magic-link|'.Str::transliterate(Str::lower($this->email).'|'.request()->ip());
|
||||
}
|
||||
|
||||
protected function ipThrottleKey(): string
|
||||
{
|
||||
return 'magic-link-ip|'.request()->ip();
|
||||
}
|
||||
}; ?>
|
||||
|
||||
<div>
|
||||
@if (session('status'))
|
||||
<div class="field-status mb-4" role="status">
|
||||
{{ session('status') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<p class="text-[13.5px] text-ink-2 leading-[1.65] !-mt-2 mb-6">
|
||||
Geben Sie Ihre E-Mail-Adresse ein – wir senden Ihnen einen Anmeldelink,
|
||||
mit dem Sie sich ohne Passwort anmelden können. Das funktioniert für
|
||||
bestehende Konten ebenso wie für jede E-Mail, die bei einer
|
||||
<strong class="text-ink-2 font-semibold">Firma</strong> oder als
|
||||
<strong class="text-ink-2 font-semibold">Pressekontakt</strong> hinterlegt ist –
|
||||
Sie verwalten damit die Pressemitteilungen aller zugeordneten Firmen.
|
||||
</p>
|
||||
|
||||
<form wire:submit="requestLink" class="space-y-[18px]" novalidate>
|
||||
<div>
|
||||
<label class="field-label" for="magic-email">E-Mail-Adresse</label>
|
||||
<input
|
||||
id="magic-email"
|
||||
type="email"
|
||||
wire:model="email"
|
||||
required
|
||||
autofocus
|
||||
autocomplete="email"
|
||||
class="field-input"
|
||||
placeholder="redaktion@ihr-unternehmen.de"
|
||||
@error('email') aria-invalid="true" @enderror
|
||||
/>
|
||||
@error('email')
|
||||
<p class="field-error">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
{{-- Honeypot: für Menschen unsichtbar --}}
|
||||
<div class="hidden" aria-hidden="true">
|
||||
<label for="magic-website">Website</label>
|
||||
<input id="magic-website" type="text" wire:model="website" tabindex="-1" autocomplete="off" />
|
||||
</div>
|
||||
|
||||
<button type="submit" class="auth-btn-primary !mt-[18px]" wire:loading.attr="disabled" wire:target="requestLink">
|
||||
<span wire:loading.remove wire:target="requestLink">Anmeldelink senden</span>
|
||||
<span wire:loading wire:target="requestLink">Wird gesendet …</span>
|
||||
</button>
|
||||
|
||||
<a href="{{ route('login') }}" class="auth-btn-outline !mt-3" wire:navigate>
|
||||
Zurück zur Anmeldung
|
||||
</a>
|
||||
</form>
|
||||
</div>
|
||||
Loading…
Add table
Add a link
Reference in a new issue