diff --git a/app/Listeners/SyncCompanyMembershipsOnLogin.php b/app/Listeners/SyncCompanyMembershipsOnLogin.php new file mode 100644 index 0000000..e122375 --- /dev/null +++ b/app/Listeners/SyncCompanyMembershipsOnLogin.php @@ -0,0 +1,30 @@ +user instanceof User) { + return; + } + + $this->contactAccess->syncMembershipsForEmail($event->user); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 4511528..809f2dc 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -7,6 +7,7 @@ use App\Helpers\ThemeHelper; use App\Http\Middleware\EnsureUserIsAdmin; use App\Http\Middleware\LogSlowAdminRequests; use App\Listeners\ActivateUserAfterVerification; +use App\Listeners\SyncCompanyMembershipsOnLogin; use App\Models\AdminPreset; use App\Models\Category; use App\Models\CategoryTranslation; @@ -19,6 +20,7 @@ use App\Observers\AdminPerformanceCacheObserver; use App\Services\Admin\AdminRequestPerformanceMetrics; use App\Services\Newsletter\NullNewsletterSyncClient; use App\Services\PressRelease\PressReleaseService; +use Illuminate\Auth\Events\Login; use Illuminate\Auth\Events\Registered; use Illuminate\Auth\Events\Verified; use Illuminate\Auth\Listeners\SendEmailVerificationNotification; @@ -61,6 +63,9 @@ class AppServiceProvider extends ServiceProvider Event::listen(Registered::class, SendEmailVerificationNotification::class); Event::listen(Verified::class, ActivateUserAfterVerification::class); + // Bei jedem Login Firmen-/Kontakt-Zuordnungen (gleiche E-Mail) auffrischen. + Event::listen(Login::class, SyncCompanyMembershipsOnLogin::class); + // Stripe Tax berechnet die USt im Checkout automatisch nach den // gleichen Regeln wie der VatResolver im MAN-Kreis (DE mit Steuer, // EU nur mit USt-ID befreit, Drittland befreit). Aktiviert zugleich diff --git a/app/Services/Auth/ContactAccessService.php b/app/Services/Auth/ContactAccessService.php index 1892943..a18911d 100644 --- a/app/Services/Auth/ContactAccessService.php +++ b/app/Services/Auth/ContactAccessService.php @@ -4,20 +4,24 @@ namespace App\Services\Auth; use App\Enums\RegistrationType; use App\Mail\MagicLoginLink; +use App\Models\Company; use App\Models\Contact; use App\Models\User; +use App\Scopes\PortalScope; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Mail; /** - * Magic-Link-Zugang für Pressekontakte (WS-2). + * Magic-Link-Zugang für Firmen & Pressekontakte (WS-2). * - * Hat eine im System hinterlegte Kontakt-E-Mail Zugang angefragt, wird – lazy – - * ein Account angelegt (oder ein bestehender per E-Mail wiederverwendet), dem - * Kontakt-Firmen als Mitglied zugeordnet und ein regulärer Login-Magic-Link - * verschickt. Über den Firmen-Scope (PressReleasePolicy) verwaltet der Kontakt - * danach die PMs seiner Firma(en). Eintrittsweg in dieselbe Verwaltung wie der - * reguläre Login – kein paralleles System. + * Überall, wo eine E-Mail hinterlegt ist – an einer Firma (`companies.email`) + * oder an einem Pressekontakt (`contacts.email`) –, kann sich diese E-Mail per + * Magic-Link anmelden. Es entsteht (lazy) GENAU EIN Account je E-Mail, dem ALLE + * Firmen und Pressekontakte zugeordnet werden, die diese E-Mail tragen – über + * alle Portale hinweg. Über den Firmen-Scope (PressReleasePolicy) verwaltet der + * Account danach die Pressemitteilungen (inkl. Terminierung/Kalender) und + * Kontakte seiner Firmen. Eintrittsweg in dieselbe Verwaltung wie der reguläre + * Login – kein paralleles System. */ class ContactAccessService { @@ -27,24 +31,56 @@ class ContactAccessService ) {} /** - * Verarbeitet eine Zugangsanfrage. Enumeration-sicher: Der Aufrufer zeigt - * unabhängig vom Ergebnis dieselbe neutrale Meldung; gibt zurück, ob ein - * Link verschickt wurde (nur für interne Tests/Logs). + * Einheitlicher „Anmeldung per E-Mail-Link"-Eintritt für die zusammengeführte + * Seite: deckt bestehende (aktive) Konten, hinterlegte Firmen-E-Mails UND + * Pressekontakte ab. Enumeration-sicher – der Aufrufer zeigt unabhängig vom + * Ergebnis dieselbe neutrale Meldung. + * + * - Bestehendes aktives Konto → Login-Magic-Link (auch ohne Hinterlegung). + * - Hinterlegte Firma/Kontakt(e) ohne Konto → lazy Account + Scope + Link. + * - Beides zugleich → Konto wiederverwenden, Scope ergänzen, Link. + * - Mehrfach hinterlegt → ein Konto, ALLE Firmen/Kontakte zugeordnet. + * - Deaktiviertes Bestandskonto / unbekannte E-Mail → keine Aktion. + */ + public function requestMagicAccess(string $email, ?string $ip = null): bool + { + $email = mb_strtolower(trim($email)); + $contacts = $this->contactsFor($email); + $companies = $this->companiesFor($email); + $existing = User::query()->whereRaw('LOWER(email) = ?', [$email])->first(); + + if (! $existing && $contacts->isEmpty() && $companies->isEmpty()) { + return false; + } + + $user = $existing ?? $this->createAccount($email, $contacts, $companies); + + // Deaktivierte Bestands-Accounts nicht verändern und nicht einloggen. + if (! $user->is_active) { + return false; + } + + $this->linkMemberships($user, $this->companyIdsFrom($contacts, $companies), $contacts); + $this->sendLoginLink($user, $ip); + + return true; + } + + /** + * Verarbeitet eine reine Pressekontakt-Zugangsanfrage (nur hinterlegte + * Kontakte). Enumeration-sicher; gibt zurück, ob ein Link verschickt wurde + * (Tests/Logs). Bleibt für gezielte Aufrufe erhalten. */ public function requestAccess(string $email, ?string $ip = null): bool { $email = mb_strtolower(trim($email)); - - $contacts = Contact::query() - ->whereRaw('LOWER(email) = ?', [$email]) - ->whereNotNull('company_id') - ->get(); + $contacts = $this->contactsFor($email); if ($contacts->isEmpty()) { return false; } - $user = $this->resolveUser($email, $contacts); + $user = $this->resolveUser($email, $contacts, collect()); // Deaktivierte Bestands-Accounts nicht verändern: eine unauthentifizierte // Anfrage darf weder Firmenzuordnungen vorbereiten noch einen Link @@ -53,37 +89,111 @@ class ContactAccessService return false; } - $this->linkCompanies($user, $contacts); - - $generated = $this->magicLinks->createLoginLink($user, $ip); - - Mail::to($user->email)->send(new MagicLoginLink( - user: $user, - loginUrl: route('magic-links.consume', ['token' => $generated['plain_token']]), - expiresAt: $generated['expires_at']->format('d.m.Y H:i'), - )); + $this->linkMemberships($user, $this->companyIdsFrom($contacts, collect()), $contacts); + $this->sendLoginLink($user, $ip); return true; } /** - * Bestehenden Account per E-Mail wiederverwenden (keine Dubletten) oder lazy - * einen neuen anlegen. Verifizierung gilt über den Magic-Link-Kanal als - * erfüllt (Entscheidung 15.06.) – daher direkt aktiv + customer. - * - * @param Collection $contacts + * Synchronisiert die Firmen-/Kontakt-Zuordnungen eines bereits + * angemeldeten Users anhand seiner E-Mail. Wird bei JEDEM Login aufgerufen + * (Magic-Link, Passwort, Google) – so greifen auch nachträglich (z. B. per + * API) hinzugekommene Firmen/Kontakte mit derselben E-Mail. Rein additiv, + * legt kein Konto an und verschickt keine Mail. */ - private function resolveUser(string $email, Collection $contacts): User + public function syncMembershipsForEmail(User $user): void { - $existing = User::query()->whereRaw('LOWER(email) = ?', [$email])->first(); + $email = mb_strtolower(trim((string) $user->email)); - if ($existing) { - return $existing; + if ($email === '') { + return; } + $contacts = $this->contactsFor($email); + $companies = $this->companiesFor($email); + + if ($contacts->isEmpty() && $companies->isEmpty()) { + return; + } + + $this->linkMemberships($user, $this->companyIdsFrom($contacts, $companies), $contacts); + } + + /** + * Hinterlegte Pressekontakte (mit Firma) zu einer E-Mail – case-insensitive, + * portalübergreifend (kein PortalScope). + * + * @return Collection + */ + private function contactsFor(string $email): Collection + { + return Contact::withoutGlobalScope(PortalScope::class) + ->whereRaw('LOWER(email) = ?', [$email]) + ->whereNotNull('company_id') + ->get(); + } + + /** + * Firmen, die diese E-Mail selbst hinterlegt haben – case-insensitive, + * portalübergreifend (kein PortalScope). + * + * @return Collection + */ + private function companiesFor(string $email): Collection + { + return Company::withoutGlobalScope(PortalScope::class) + ->whereRaw('LOWER(email) = ?', [$email]) + ->get(); + } + + /** + * Alle zuzuordnenden Firmen-IDs: direkt an Firmen hinterlegt ODER über + * Pressekontakte verknüpft. + * + * @param Collection $contacts + * @param Collection $companies + * @return Collection + */ + private function companyIdsFrom(Collection $contacts, Collection $companies): Collection + { + return $companies->pluck('id') + ->merge($contacts->pluck('company_id')) + ->filter() + ->unique() + ->values(); + } + + /** + * Bestehenden Account per E-Mail wiederverwenden (keine Dubletten) oder lazy + * einen neuen anlegen. + * + * @param Collection $contacts + * @param Collection $companies + */ + private function resolveUser(string $email, Collection $contacts, Collection $companies): User + { + return User::query()->whereRaw('LOWER(email) = ?', [$email])->first() + ?? $this->createAccount($email, $contacts, $companies); + } + + /** + * Legt für eine hinterlegte Firma/Kontakt lazy einen Account an. Verifizierung + * gilt über den Magic-Link-Kanal als erfüllt (Entscheidung 15.06.) – daher + * direkt aktiv + customer. Name: Kontaktname, sonst Firmenname, sonst E-Mail. + * + * @param Collection $contacts + * @param Collection $companies + */ + private function createAccount(string $email, Collection $contacts, Collection $companies): User + { $contact = $contacts->first(); $name = trim(($contact->first_name ?? '').' '.($contact->last_name ?? '')); + if ($name === '') { + $name = (string) ($companies->first()->name ?? ''); + } + $user = User::create([ 'name' => $name !== '' ? $name : $email, 'email' => $email, @@ -98,24 +208,53 @@ class ContactAccessService } /** - * Ordnet den User den Firmen seiner Kontakte als Mitglied zu (Firmen-Scope) - * und verknüpft ihn mit den Kontakt-Datensätzen. Owner bleiben Owner. + * Versendet einen regulären Login-Magic-Link an den Account. + */ + private function sendLoginLink(User $user, ?string $ip): void + { + $generated = $this->magicLinks->createLoginLink($user, $ip); + + Mail::to($user->email)->send(new MagicLoginLink( + user: $user, + loginUrl: route('magic-links.consume', ['token' => $generated['plain_token']]), + expiresAt: $generated['expires_at']->format('d.m.Y H:i'), + )); + } + + /** + * Ordnet den User allen übergebenen Firmen als „Verantwortlicher" + * (`responsible`) zu und verknüpft ihn mit den Kontakt-Datensätzen. Wer seine + * E-Mail an einer Firma/einem Pressekontakt hinterlegt hat, verwaltet die + * gesamte Firma (Stammdaten, Kontakte, Pressemitteilungen) – `responsible` + * gewährt den dafür nötigen Schreibzugriff (siehe CompanyPolicy::update()). * + * Owner (`owner_user_id`) bleiben unangetastet; ein bestehender reiner + * Lese-Pivot (`member`) wird auf `responsible` hochgestuft, `owner`/ + * `responsible` bleiben erhalten. + * + * @param Collection $companyIds * @param Collection $contacts */ - private function linkCompanies(User $user, Collection $contacts): void + private function linkMemberships(User $user, Collection $companyIds, Collection $contacts): void { - $companyIds = $contacts->pluck('company_id')->filter()->unique(); - foreach ($companyIds as $companyId) { - $alreadyLinked = $user->ownedCompanies()->whereKey($companyId)->exists() - || $user->companies()->whereKey($companyId)->exists(); + // Owner via owner_user_id → bereits voller Zugriff. + if ($user->ownedCompanies()->withoutGlobalScope(PortalScope::class)->whereKey($companyId)->exists()) { + continue; + } - if (! $alreadyLinked) { - $user->companies()->attach($companyId, ['role' => 'member']); + $linked = $user->companies()->withoutGlobalScope(PortalScope::class)->whereKey($companyId)->first(); + + if ($linked === null) { + $user->companies()->attach($companyId, ['role' => 'responsible']); + } elseif ($linked->pivot->role === 'member') { + // Nur den Read-only-Default hochstufen; owner/responsible bleiben. + $user->companies()->updateExistingPivot($companyId, ['role' => 'responsible']); } } - $user->contacts()->syncWithoutDetaching($contacts->pluck('id')->all()); + if ($contacts->isNotEmpty()) { + $user->contacts()->syncWithoutDetaching($contacts->pluck('id')->all()); + } } } diff --git a/docs/weiteres/Sicherheit & Deployment-Hinweise (Auth, Rollen, Verifizierung).md b/docs/weiteres/Sicherheit & Deployment-Hinweise (Auth, Rollen, Verifizierung).md index 1be7def..0013f55 100644 --- a/docs/weiteres/Sicherheit & Deployment-Hinweise (Auth, Rollen, Verifizierung).md +++ b/docs/weiteres/Sicherheit & Deployment-Hinweise (Auth, Rollen, Verifizierung).md @@ -48,14 +48,26 @@ PM-Zugriff war hart an `user_id` (Autor) gebunden. Jetzt **additiv**: Zugriff = --- -## 4. Magic-Link-Zugang für Pressekontakte (WS-2) +## 4. Anmeldung per E-Mail-Link – zusammengeführt (WS-2) -Öffentliches Formular `/pressekontakt-zugang` (`auth.contact-access`): -- **Enumeration-sicher** (neutrale Antwort unabhängig vom Treffer), **Honeypot** + **Rate-Limit**. -- Eine hinterlegte Kontakt-E-Mail → **lazy** angelegter, per E-Mail **de-duplizierter** `customer`-Account (aktiv, verifiziert), der den Firmen seiner Kontakte als **Mitglied** zugeordnet wird. +Eine einzige öffentliche Seite `/anmeldelink` (`auth.magic-link`) deckt **beide** Magic-Link-Fälle ab; das frühere Login-Modal und die separate Pressekontakt-Seite sind aufgelöst (war nicht selbsterklärend, zwei fast identische Wege). Die alte URL `/pressekontakt-zugang` leitet per Redirect auf `/anmeldelink`. + +- **Enumeration-sicher** (neutrale Antwort unabhängig vom Treffer), **Honeypot** + **Rate-Limit** (pro E-Mail+IP 3/h, pro IP 15/h). +- Einheitlicher Service-Eintritt `ContactAccessService::requestMagicAccess()`. Berücksichtigt **jede** Stelle, an der eine E-Mail hinterlegt ist – an einer **Firma** (`companies.email`) **und** an einem **Pressekontakt** (`contacts.email`), **portalübergreifend** (Abfragen ohne `PortalScope`): + - **Bestehendes aktives Konto** → Login-Magic-Link (auch ohne Hinterlegung – ersetzt das alte Login-Modal). + - **Hinterlegte Firma/Kontakt(e) ohne Konto** → **lazy** angelegter, per E-Mail **de-duplizierter** `customer`-Account (aktiv, verifiziert). + - **Mehrfach hinterlegt** → **genau ein** Konto je E-Mail, dem **alle** zugehörigen Firmen und Pressekontakte zugeordnet werden – auch über Portalgrenzen hinweg. + - **Pivot-Rolle `responsible`** („Verantwortlich"): Wer seine E-Mail an einer Firma/einem Pressekontakt hinterlegt hat, erhält **Schreibzugriff** auf die gesamte Firma (Stammdaten, Pressekontakte, Pressemitteilungen inkl. Terminierung/„Kalender") – siehe `CompanyPolicy::update()`. Owner (`owner_user_id`) bleiben unangetastet; ein bestehender reiner Lese-Pivot (`member`) wird beim nächsten Login/Magic-Access auf `responsible` hochgestuft. Greift über den Login-Re-Sync auf **allen** Anmeldewegen. + - **Beides zugleich** → Konto wiederverwenden, Scope ergänzen, Link. + - **Deaktiviertes Bestandskonto / unbekannte E-Mail** → keine Aktion, gleiche neutrale Meldung. - Versand über den **bestehenden Login-Magic-Link** (`MagicLinkGenerator` + `MagicLinkConsumeController`) – keine Schema-Änderung. -**Design-Nuance (bewusst):** Der Account wird beim **Absenden der Anfrage** angelegt (nicht erst beim Klick), um eine fragile `magic_links`-Schema-Änderung (ENUM/FK über SQLite-Tests + MySQL-Prod) zu vermeiden. Er ist „lazy" (nur für real existierende Kontakt-Mails) und ohne den per Mail versendeten Token wertlos. +**Design-Nuance (bewusst):** Der Pressekontakt-Account wird beim **Absenden der Anfrage** angelegt (nicht erst beim Klick), um eine fragile `magic_links`-Schema-Änderung (ENUM/FK über SQLite-Tests + MySQL-Prod) zu vermeiden. Er ist „lazy" (nur für real existierende Kontakt-Mails) und ohne den per Mail versendeten Token wertlos. Die ältere Methode `requestAccess()` (nur Pressekontakte) bleibt für gezielte Aufrufe/Tests erhalten. + +**Re-Sync bei jedem Login:** `App\Listeners\SyncCompanyMembershipsOnLogin` hört auf das `Login`-Event und ruft `ContactAccessService::syncMembershipsForEmail()` – greift damit bei **allen** Anmeldewegen (Magic-Link, Passwort, Google). Nachträglich (z. B. per API) hinzugekommene Firmen/Kontakte mit derselben E-Mail werden so beim nächsten Login automatisch zugeordnet, unabhängig vom Login-Weg. Rein additiv (kein Konto, keine Mail), bestehende Mitgliedschaften/Owner bleiben unangetastet. + +- **Passwort-Login für Magic-Link-Accounts:** Lazy angelegte Accounts haben `password = null` und können sich (korrekt) nicht per Passwort anmelden, bis sie über **„Passwort vergessen"** eins setzen. Danach ist auch der reguläre Passwort-Login möglich. +- **Performance-Hinweis:** Der Re-Sync fragt `companies`/`contacts` per `LOWER(email)` ab (case-insensitive, SQLite-Tests + MySQL-Prod). Bei sehr großen Tabellen pro Login besser über einen lowercased/funktionalen Index auf `email` absichern (Follow-up, noch nicht angelegt). --- diff --git a/resources/views/components/layouts/auth/pressekonto.blade.php b/resources/views/components/layouts/auth/pressekonto.blade.php index ed1481c..66c4f43 100644 --- a/resources/views/components/layouts/auth/pressekonto.blade.php +++ b/resources/views/components/layouts/auth/pressekonto.blade.php @@ -38,6 +38,12 @@ + {{-- Der Auth-Bereich ist bewusst immer im Hellmodus; Dark/Light gibt es nur + im eingeloggten Portal. Eine aus dem Portal übernommene .dark-Klasse + (flux_appearance-Cookie / DOM-Übernahme) wird hier sofort entfernt, + bevor die Styles greifen – sonst erscheint der Login dunkel. --}} + + {{ $pageTitle }} diff --git a/resources/views/livewire/auth/contact-access.blade.php b/resources/views/livewire/auth/contact-access.blade.php deleted file mode 100644 index 9187df0..0000000 --- a/resources/views/livewire/auth/contact-access.blade.php +++ /dev/null @@ -1,105 +0,0 @@ - '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()); - } -}; ?> - -
- @if (session('status')) -
- {{ session('status') }} -
- @endif - -

- 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. -

- -
-
- - - @error('email') -

{{ $message }}

- @enderror -
- - {{-- Honeypot: für Menschen unsichtbar --}} - - - -
-
diff --git a/resources/views/livewire/auth/login.blade.php b/resources/views/livewire/auth/login.blade.php index bed67f7..22347b5 100644 --- a/resources/views/livewire/auth/login.blade.php +++ b/resources/views/livewire/auth/login.blade.php @@ -1,15 +1,11 @@ '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(); - } }; ?> -
+
@if (session('status'))
{{ session('status') }} @@ -274,17 +199,13 @@ new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Willkommen zu
- + Anmeldung per E-Mail-Link + Mit Google anmelden - - Als Pressekontakt hinterlegt? Zugang anfordern → - - - {{-- Magic-Link-Modal: eigene E-Mail-Eingabe, unabhängig vom Login-Formular --}} -
diff --git a/resources/views/livewire/auth/magic-link.blade.php b/resources/views/livewire/auth/magic-link.blade.php new file mode 100644 index 0000000..616f3df --- /dev/null +++ b/resources/views/livewire/auth/magic-link.blade.php @@ -0,0 +1,136 @@ + '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(); + } +}; ?> + +
+ @if (session('status')) +
+ {{ session('status') }} +
+ @endif + +

+ 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 + Firma oder als + Pressekontakt hinterlegt ist – + Sie verwalten damit die Pressemitteilungen aller zugeordneten Firmen. +

+ +
+
+ + + @error('email') +

{{ $message }}

+ @enderror +
+ + {{-- Honeypot: für Menschen unsichtbar --}} + + + + + + Zurück zur Anmeldung + +
+
diff --git a/routes/auth.php b/routes/auth.php index 127a382..dea4689 100644 --- a/routes/auth.php +++ b/routes/auth.php @@ -18,10 +18,14 @@ Route::group(['middleware' => config('fortify.middleware', ['web'])], function ( ->middleware(['guest:'.config('fortify.guard')]) ->name('magic-links.consume'); - // Magic-Link-Zugang für Pressekontakte ohne Account (WS-2) - Volt::route('/pressekontakt-zugang', 'auth.contact-access') + // Anmeldung per E-Mail-Link (Magic-Link): zusammengeführte Seite für + // Bestandskonten UND hinterlegte Pressekontakte ohne Account (WS-2). + Volt::route('/anmeldelink', 'auth.magic-link') ->middleware(['guest:'.config('fortify.guard')]) - ->name('contact-access.request'); + ->name('magic-link.request'); + + // Alte Pressekontakt-URL weiterleiten (Bestandslinks/Lesezeichen). + Route::redirect('/pressekontakt-zugang', '/anmeldelink'); // Google-Login (WS-6) Route::get('/auth/google/redirect', [GoogleController::class, 'redirect']) diff --git a/tests/Feature/Auth/AuthenticationTest.php b/tests/Feature/Auth/AuthenticationTest.php index 320ed91..5e46111 100644 --- a/tests/Feature/Auth/AuthenticationTest.php +++ b/tests/Feature/Auth/AuthenticationTest.php @@ -13,6 +13,19 @@ test('login screen can be rendered', function () { $response->assertStatus(200); }); +test('the auth area forces light mode and never inherits the portal dark class', function () { + /** @var TestCase $this */ + $portalUrl = rtrim((string) config('domains.domain_portal_url', 'http://pressekonto.test'), '/'); + + // Auch mit gesetztem Dark-Cookie aus dem Portal bleibt der Login hell: + // das -Tag trägt keine .dark-Klasse und das Strip-Skript ist präsent. + $response = $this->withCookie('flux_appearance', 'dark')->get($portalUrl.'/login'); + + $response->assertStatus(200); + $response->assertSee("classList.remove('dark')", false); + expect($response->getContent())->not->toContain(' $company->portal->value, ]); - Volt::test('auth.contact-access') + Volt::test('auth.magic-link') ->set('email', 'form@example.test') - ->call('requestAccess') + ->call('requestLink') ->assertHasNoErrors() ->assertSet('email', ''); @@ -95,6 +95,160 @@ test('the contact access form responds neutrally and triggers the service', func expect(User::query()->where('email', 'form@example.test')->exists())->toBeTrue(); }); +test('the magic link form sends a link to an existing active user without a contact', function () { + /** @var TestCase $this */ + Mail::fake(); + + $user = User::factory()->create(['email' => 'plain@example.test', 'is_active' => true]); + $user->assignRole('customer'); + + Volt::test('auth.magic-link') + ->set('email', 'plain@example.test') + ->call('requestLink') + ->assertHasNoErrors() + ->assertSet('email', ''); + + Mail::assertSent(MagicLoginLink::class, fn (MagicLoginLink $mail) => $mail->user->is($user)); +}); + +test('requestMagicAccess does nothing for an unknown email', function () { + /** @var TestCase $this */ + Mail::fake(); + + $sent = app(ContactAccessService::class)->requestMagicAccess('nobody@example.test'); + + expect($sent)->toBeFalse(); + expect(User::query()->where('email', 'nobody@example.test')->exists())->toBeFalse(); + Mail::assertNothingSent(); +}); + +test('requestMagicAccess does not send to an inactive existing user', function () { + /** @var TestCase $this */ + Mail::fake(); + + User::factory()->create(['email' => 'off@example.test', 'is_active' => false]); + + $sent = app(ContactAccessService::class)->requestMagicAccess('off@example.test'); + + expect($sent)->toBeFalse(); + Mail::assertNothingSent(); +}); + +test('a later-added company with the same email is linked on the next password login', function () { + /** @var TestCase $this */ + $user = User::factory()->create(['email' => 'sync@example.test', 'is_active' => true]); + $user->assignRole('customer'); + + // Firma kommt NACH dem Account dazu (z. B. per API) und trägt dieselbe E-Mail. + $company = Company::factory()->presseecho()->create(['email' => 'sync@example.test']); + + expect($user->accessibleCompanyIds())->not->toContain($company->id); + + Volt::test('auth.login') + ->set('email', 'sync@example.test') + ->set('password', 'password') + ->call('login') + ->assertHasNoErrors(); + + // Der Login-Hook (SyncCompanyMembershipsOnLogin) hat die Firma verknüpft. + expect($user->fresh()->accessibleCompanyIds())->toContain($company->id); +}); + +test('a magic-link account can manage (edit) the company it was linked to', function () { + /** @var TestCase $this */ + Mail::fake(); + + $company = Company::factory()->presseecho()->create(['email' => 'verantwortlich@example.test']); + + app(ContactAccessService::class)->requestMagicAccess('verantwortlich@example.test'); + + $user = User::query()->where('email', 'verantwortlich@example.test')->firstOrFail(); + + // Schreibzugriff (CompanyPolicy::update) – nicht nur Lesezugriff. + expect($user->can('update', $company))->toBeTrue(); + expect($user->can('view', $company))->toBeTrue(); +}); + +test('an existing read-only member is upgraded to responsible on magic access', function () { + /** @var TestCase $this */ + Mail::fake(); + + $company = Company::factory()->presseecho()->create(['email' => 'upgrade@example.test']); + + $user = User::factory()->create(['email' => 'upgrade@example.test', 'is_active' => true]); + $user->assignRole('customer'); + // Vorher nur Lesezugriff. + $user->companies()->attach($company->id, ['role' => 'member']); + + expect($user->can('update', $company))->toBeFalse(); + + app(ContactAccessService::class)->requestMagicAccess('upgrade@example.test'); + + expect($user->fresh()->can('update', $company))->toBeTrue(); + // Genau ein Pivot-Eintrag, jetzt 'responsible'. + expect($user->companies()->withoutGlobalScopes()->whereKey($company->id)->count())->toBe(1); +}); + +test('a company email lazily creates an account scoped to that company', function () { + /** @var TestCase $this */ + Mail::fake(); + + $company = Company::factory()->presseecho()->create([ + 'email' => 'firma@example.test', + 'name' => 'Firma AG', + ]); + + // Gross-/Kleinschreibung egal. + $sent = app(ContactAccessService::class)->requestMagicAccess('Firma@Example.test'); + + expect($sent)->toBeTrue(); + + $user = User::query()->where('email', 'firma@example.test')->firstOrFail(); + + expect($user->is_active)->toBeTrue(); + expect($user->hasVerifiedEmail())->toBeTrue(); + expect($user->hasRole('customer'))->toBeTrue(); + // Kein Kontaktname hinterlegt → Firmenname als Account-Name. + expect($user->name)->toBe('Firma AG'); + expect($user->accessibleCompanyIds())->toContain($company->id); + + Mail::assertSent(MagicLoginLink::class, fn (MagicLoginLink $mail) => $mail->user->is($user)); +}); + +test('one account is created for an email stored across multiple companies and contacts', function () { + /** @var TestCase $this */ + Mail::fake(); + + // Dieselbe E-Mail an zwei Firmen (verschiedene Portale) direkt hinterlegt … + $companyA = Company::factory()->presseecho()->create(['email' => 'multi@example.test']); + $companyB = Company::factory()->businessportal24()->create(['email' => 'multi@example.test']); + + // … und zusätzlich als Pressekontakt einer dritten Firma. + $companyC = Company::factory()->presseecho()->create(['email' => 'andere@example.test']); + Contact::factory()->for($companyC)->create([ + 'email' => 'multi@example.test', + 'portal' => $companyC->portal->value, + ]); + + $sent = app(ContactAccessService::class)->requestMagicAccess('multi@example.test'); + + expect($sent)->toBeTrue(); + + // Genau EIN Account für die E-Mail. + expect(User::query()->where('email', 'multi@example.test')->count())->toBe(1); + + $user = User::query()->where('email', 'multi@example.test')->firstOrFail(); + $ids = $user->accessibleCompanyIds(); + + // ALLE drei Firmen zugeordnet (zwei per Firmen-E-Mail, eine per Kontakt), + // portalübergreifend. + expect($ids)->toContain($companyA->id); + expect($ids)->toContain($companyB->id); + expect($ids)->toContain($companyC->id); + + Mail::assertSent(MagicLoginLink::class, 1); +}); + test('requesting access does not mutate an existing inactive account', function () { /** @var TestCase $this */ Mail::fake(); @@ -126,10 +280,10 @@ test('the honeypot blocks bots without creating an account', function () { 'portal' => $company->portal->value, ]); - Volt::test('auth.contact-access') + Volt::test('auth.magic-link') ->set('email', 'bot-target@example.test') ->set('website', 'http://spam.example') - ->call('requestAccess') + ->call('requestLink') ->assertHasNoErrors(); Mail::assertNothingSent(); diff --git a/tests/Feature/Auth/MagicLinkLoginTest.php b/tests/Feature/Auth/MagicLinkLoginTest.php index 8daaf68..f2c7410 100644 --- a/tests/Feature/Auth/MagicLinkLoginTest.php +++ b/tests/Feature/Auth/MagicLinkLoginTest.php @@ -8,13 +8,13 @@ use Illuminate\Support\Facades\Mail; use Livewire\Volt\Volt as LivewireVolt; use Tests\TestCase; -test('user can request a magic login link from login page', function () { +test('user can request a magic login link from the magic-link page', function () { Mail::fake(); $user = User::factory()->create(['is_active' => true]); - LivewireVolt::test('auth.login') - ->set('magicEmail', $user->email) - ->call('sendMagicLink') + LivewireVolt::test('auth.magic-link') + ->set('email', $user->email) + ->call('requestLink') ->assertHasNoErrors(); Mail::assertSent(MagicLoginLink::class, function (MagicLoginLink $mail) use ($user) { @@ -27,25 +27,24 @@ test('user can request a magic login link from login page', function () { expect($magicLink->token_hash)->toHaveLength(64); }); -test('the magic link modal validates its own email field and closes on success', function () { +test('the magic link form validates its email field and clears on success', function () { Mail::fake(); $user = User::factory()->create(['is_active' => true]); // Leere Eingabe → Validierungsfehler, keine Mail. - LivewireVolt::test('auth.login') - ->set('magicEmail', '') - ->call('sendMagicLink') - ->assertHasErrors(['magicEmail']); + LivewireVolt::test('auth.magic-link') + ->set('email', '') + ->call('requestLink') + ->assertHasErrors(['email']); Mail::assertNothingSent(); - // Gültige Eingabe → Feld wird geleert, Schließen-Event wird ausgelöst. - LivewireVolt::test('auth.login') - ->set('magicEmail', $user->email) - ->call('sendMagicLink') + // Gültige Eingabe → Feld wird geleert. + LivewireVolt::test('auth.magic-link') + ->set('email', $user->email) + ->call('requestLink') ->assertHasNoErrors() - ->assertSet('magicEmail', '') - ->assertDispatched('magic-link-sent'); + ->assertSet('email', ''); }); test('magic link requests are rate limited per email', function () { @@ -54,16 +53,16 @@ test('magic link requests are rate limited per email', function () { $user = User::factory()->create(['is_active' => true]); foreach (range(1, 3) as $ignored) { - LivewireVolt::test('auth.login') - ->set('magicEmail', $user->email) - ->call('sendMagicLink') + LivewireVolt::test('auth.magic-link') + ->set('email', $user->email) + ->call('requestLink') ->assertHasNoErrors(); } - LivewireVolt::test('auth.login') - ->set('magicEmail', $user->email) - ->call('sendMagicLink') - ->assertHasErrors(['magicEmail']); + LivewireVolt::test('auth.magic-link') + ->set('email', $user->email) + ->call('requestLink') + ->assertHasErrors(['email']); Mail::assertSent(MagicLoginLink::class, 3); }); @@ -75,9 +74,9 @@ test('admin can login with a valid magic link and lands on admin dashboard', fun $user = User::factory()->create(['is_active' => true]); $user->assignRole('admin'); - LivewireVolt::test('auth.login') - ->set('magicEmail', $user->email) - ->call('sendMagicLink'); + LivewireVolt::test('auth.magic-link') + ->set('email', $user->email) + ->call('requestLink'); $sentMail = null; @@ -107,9 +106,9 @@ test('customer is redirected to me dashboard after magic link login', function ( $customer = User::factory()->create(['is_active' => true]); $customer->assignRole('customer'); - LivewireVolt::test('auth.login') - ->set('magicEmail', $customer->email) - ->call('sendMagicLink'); + LivewireVolt::test('auth.magic-link') + ->set('email', $customer->email) + ->call('requestLink'); $sentMail = null; Mail::assertSent(MagicLoginLink::class, function (MagicLoginLink $mail) use (&$sentMail) {