presseportale/app/Services/Auth/ContactAccessService.php
Kevin Adametz 980763c362 WS-2: Firmen-Scope für PMs & Magic-Link-Zugang für Pressekontakte
Firmen-Scope (Fundament):
- PM-Zugriff war hart an user_id (Autor) gebunden. Jetzt additiv: Autor ODER
  Mitglied der zugeordneten Firma (Owner via owner_user_id oder company_user-
  Pivot). Geändert in PressReleasePolicy (canManage) sowie den Queries der
  Listen-, Show- und Edit-Komponenten. Helfer User::accessibleCompanyIds()/
  canAccessCompany(). Solo-Owner unverändert; Firmenmitglieder sehen/bearbeiten
  alle PMs ihrer Firma.

Magic-Link-Zugang für Pressekontakte (ContactAccessService):
- Öffentliches, enumeration-sicheres Formular (/pressekontakt-zugang) mit
  Honeypot + Rate-Limit. Eine hinterlegte Kontakt-E-Mail führt zu einem lazy
  angelegten, de-duplizierten customer-Account (aktiv, verifiziert über den
  Magic-Link-Kanal), der den Firmen seiner Kontakte als Mitglied zugeordnet
  wird. Versand über den bestehenden Login-Magic-Link (Generator + Consume
  wiederverwendet) – keine Schema-Änderung, kein paralleles System.
- Dezenter Einstiegslink von der Login-Seite (PM-Frontend-Wiring später).

Tests: PressReleaseCompanyScopeTest (3), ContactAccessTest (6, inkl. De-Dup,
Enumeration-Sicherheit, Honeypot).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-16 08:33:12 +00:00

117 lines
3.8 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
namespace App\Services\Auth;
use App\Enums\RegistrationType;
use App\Mail\MagicLoginLink;
use App\Models\Contact;
use App\Models\User;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Mail;
/**
* Magic-Link-Zugang für 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.
*/
class ContactAccessService
{
public function __construct(
private readonly MagicLinkGenerator $magicLinks,
private readonly UserRolePermissionSyncService $roleSync,
) {}
/**
* 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).
*/
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();
if ($contacts->isEmpty()) {
return false;
}
$user = $this->resolveUser($email, $contacts);
$this->linkCompanies($user, $contacts);
if (! $user->is_active) {
return false;
}
$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'),
));
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<int, Contact> $contacts
*/
private function resolveUser(string $email, Collection $contacts): User
{
$existing = User::query()->whereRaw('LOWER(email) = ?', [$email])->first();
if ($existing) {
return $existing;
}
$contact = $contacts->first();
$name = trim(($contact->first_name ?? '').' '.($contact->last_name ?? ''));
$user = User::create([
'name' => $name !== '' ? $name : $email,
'email' => $email,
'registration_type' => RegistrationType::Company->value,
'is_active' => true,
]);
$user->forceFill(['email_verified_at' => now()])->save();
$this->roleSync->assignRoleAndSyncPermissions($user, 'customer');
return $user;
}
/**
* Ordnet den User den Firmen seiner Kontakte als Mitglied zu (Firmen-Scope)
* und verknüpft ihn mit den Kontakt-Datensätzen. Owner bleiben Owner.
*
* @param Collection<int, Contact> $contacts
*/
private function linkCompanies(User $user, 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();
if (! $alreadyLinked) {
$user->companies()->attach($companyId, ['role' => 'member']);
}
}
$user->contacts()->syncWithoutDetaching($contacts->pluck('id')->all());
}
}