Auf Live meldete memberships:resync-auto nach v1.4.0 „vorher 19607, nachher 9": syncMembershipsForEmail() entzog Konten ohne email_verified_at alle automatischen Rechte, und 19.597 davon gehören Bestandskonten aus dem Altsystem. Jetzt wird nur entzogen, was keine E-Mail-Grundlage mehr hat — unabhängig vom Kontostatus (S-03 bleibt wirksam); neue Rechte weiterhin nur für aktive, verifizierte Konten (F01). memberships:resync-auto läuft in einer Transaktion, kennt --dry-run und rollt zurück, wenn mehr als 5 % (mindestens 50) der Rechte wegfallen würden (--force zum Erzwingen). Probelauf gegen die Live-Kopie: 19.607 → 19.316. Wiederherstellung: storage/app/private/restore-company-user-auto-2026-10-07.sql (nicht versioniert, INSERT IGNORE, idempotent). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
314 lines
13 KiB
PHP
314 lines
13 KiB
PHP
<?php
|
||
|
||
namespace App\Services\Auth;
|
||
|
||
use App\Enums\ConversionSource;
|
||
use App\Mail\MagicLoginLink;
|
||
use App\Models\Company;
|
||
use App\Models\Contact;
|
||
use App\Models\User;
|
||
use App\Scopes\PortalScope;
|
||
use App\Support\EmailQuery;
|
||
use Illuminate\Support\Collection;
|
||
use Illuminate\Support\Facades\DB;
|
||
use Illuminate\Support\Facades\Mail;
|
||
|
||
/**
|
||
* Magic-Link-Zugang für Firmen & Pressekontakte (WS-2).
|
||
*
|
||
* Ü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
|
||
{
|
||
public function __construct(
|
||
private readonly MagicLinkGenerator $magicLinks,
|
||
private readonly CustomerAccountProvisioner $accountProvisioner,
|
||
) {}
|
||
|
||
/**
|
||
* 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, ?ConversionSource $source = null, ?int $landingCompanyId = null, ?int $landingPressReleaseId = null): bool
|
||
{
|
||
$email = mb_strtolower(trim($email));
|
||
$contacts = $this->contactsFor($email);
|
||
$companies = $this->companiesFor($email);
|
||
$existing = User::findByEmail($email);
|
||
|
||
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, $source, $landingCompanyId, $landingPressReleaseId);
|
||
|
||
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 = $this->contactsFor($email);
|
||
|
||
if ($contacts->isEmpty()) {
|
||
return false;
|
||
}
|
||
|
||
$user = $this->resolveUser($email, $contacts, collect());
|
||
|
||
// Deaktivierte Bestands-Accounts nicht verändern: eine unauthentifizierte
|
||
// Anfrage darf weder Firmenzuordnungen vorbereiten noch einen Link
|
||
// auslösen. Frisch lazy angelegte Accounts sind aktiv und laufen weiter.
|
||
if (! $user->is_active) {
|
||
return false;
|
||
}
|
||
|
||
$this->linkMemberships($user, $this->companyIdsFrom($contacts, collect()), $contacts);
|
||
$this->sendLoginLink($user, $ip);
|
||
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* 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.
|
||
*
|
||
* Grundlage ist ausschließlich eine NACHGEWIESENE Adresse (Audit F01): ein
|
||
* Konto, das seine E-Mail gerade auf eine fremde Firmen-Adresse gewechselt
|
||
* hat, ist unverifiziert und erhält nichts – der Login-Event feuert auch
|
||
* vor der Verifizierungs-Weiche. Deaktivierte Konten ebenfalls nicht.
|
||
*
|
||
* Gegenrichtung (Audit F10/U01, E7): automatisch vergebene Zuordnungen,
|
||
* deren E-Mail-Grundlage weggefallen ist (Adresse an Firma oder Kontakt
|
||
* getauscht, Kontakt gelöscht, eigene Adresse geändert), werden entfernt –
|
||
* `member` wäre keine Leserolle, sondern die Teamrolle mit Schreibrecht
|
||
* auf alle Firmen-PMs. Eigene PMs bleiben über die Autorenschaft. Eine
|
||
* automatisch HOCHGESTUFTE manuelle Mitgliedschaft (`auto-upgrade`) fällt
|
||
* auf `member` zurück. Manuell oder aus dem Altbestand vergebene Rechte
|
||
* (`granted_via = manual`) bleiben, ebenso Mitgliedschaften aus der
|
||
* API-Zuordnung (`granted_via = api`, Audit R-01) — ihre Grundlage ist
|
||
* die Freischaltung des Einlieferers, nicht die E-Mail.
|
||
*
|
||
* Neben Login und E-Mail-Bestätigung ruft auch der
|
||
* CompanyMembershipObserver diese Methode auf, sobald sich Kontakt- oder
|
||
* Firmenadressen bzw. Kontaktzuordnungen ändern — der Entzug greift damit
|
||
* in der laufenden Session (Sicherheitsaudit 07.10.2026, S-03).
|
||
*/
|
||
public function syncMembershipsForEmail(User $user): void
|
||
{
|
||
$email = mb_strtolower(trim((string) $user->email));
|
||
|
||
if ($email === '') {
|
||
return;
|
||
}
|
||
|
||
$contacts = $this->contactsFor($email);
|
||
$companies = $this->companiesFor($email);
|
||
$companyIds = $this->companyIdsFrom($contacts, $companies);
|
||
|
||
// Entzogen wird nur, was keine E-Mail-Grundlage mehr hat — unabhängig
|
||
// vom Kontostatus (S-03). Bestandskonten aus dem Altsystem haben kein
|
||
// `email_verified_at`; ihre noch begründeten Rechte bleiben. Eine
|
||
// erste Fassung entzog Unverifizierten pauschal alles und nahm beim
|
||
// Abgleich am 07.10.2026 so 19.598 begründete Rechte weg (v1.4.1).
|
||
$this->revokeStaleAutomaticGrants($user, $companyIds);
|
||
|
||
// Neue Rechte nur für aktive Konten mit nachgewiesener Adresse (F01).
|
||
if (! $user->is_active || ! $user->hasVerifiedEmail() || $companyIds->isEmpty()) {
|
||
return;
|
||
}
|
||
|
||
$this->linkMemberships($user, $companyIds, $contacts);
|
||
}
|
||
|
||
/**
|
||
* Entfernt automatisch vergebene `responsible`-Pivots ohne aktuellen
|
||
* E-Mail-Treffer (E7). Eine hochgestufte manuelle Mitgliedschaft geht auf
|
||
* ihren vorherigen Stand `member` zurück und gilt wieder als manuell.
|
||
*
|
||
* @param Collection<int, int> $currentCompanyIds
|
||
*/
|
||
private function revokeStaleAutomaticGrants(User $user, Collection $currentCompanyIds): void
|
||
{
|
||
$stale = DB::table('company_user')
|
||
->where('user_id', $user->id)
|
||
->where('role', 'responsible')
|
||
->whereNotIn('company_id', $currentCompanyIds->all());
|
||
|
||
(clone $stale)->where('granted_via', 'auto')->delete();
|
||
|
||
(clone $stale)->where('granted_via', 'auto-upgrade')
|
||
->update(['role' => 'member', 'granted_via' => 'manual', 'updated_at' => now()]);
|
||
}
|
||
|
||
/**
|
||
* Hinterlegte Pressekontakte (mit Firma) zu einer E-Mail – case-insensitive
|
||
* über den Index (EmailQuery), portalübergreifend (kein PortalScope).
|
||
*
|
||
* @return Collection<int, Contact>
|
||
*/
|
||
private function contactsFor(string $email): Collection
|
||
{
|
||
return EmailQuery::whereEmail(Contact::withoutGlobalScope(PortalScope::class), $email)
|
||
->whereNotNull('company_id')
|
||
->get();
|
||
}
|
||
|
||
/**
|
||
* Firmen, die diese E-Mail selbst hinterlegt haben – case-insensitive,
|
||
* portalübergreifend (kein PortalScope).
|
||
*
|
||
* @return Collection<int, Company>
|
||
*/
|
||
private function companiesFor(string $email): Collection
|
||
{
|
||
return EmailQuery::whereEmail(Company::withoutGlobalScope(PortalScope::class), $email)
|
||
->get();
|
||
}
|
||
|
||
/**
|
||
* Alle zuzuordnenden Firmen-IDs: direkt an Firmen hinterlegt ODER über
|
||
* Pressekontakte verknüpft.
|
||
*
|
||
* @param Collection<int, Contact> $contacts
|
||
* @param Collection<int, Company> $companies
|
||
* @return Collection<int, int>
|
||
*/
|
||
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<int, Contact> $contacts
|
||
* @param Collection<int, Company> $companies
|
||
*/
|
||
private function resolveUser(string $email, Collection $contacts, Collection $companies): User
|
||
{
|
||
return User::findByEmail($email)
|
||
?? $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<int, Contact> $contacts
|
||
* @param Collection<int, Company> $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 ?? '');
|
||
}
|
||
|
||
return $this->accountProvisioner->provisionVerifiedCustomer($email, $name);
|
||
}
|
||
|
||
/**
|
||
* Versendet einen regulären Login-Magic-Link an den Account.
|
||
*/
|
||
private function sendLoginLink(User $user, ?string $ip, ?ConversionSource $source = null, ?int $landingCompanyId = null, ?int $landingPressReleaseId = null): void
|
||
{
|
||
// Landung nach dem Login (Roadmap 1b.2/1c.2): die Firma, um die es
|
||
// ging — und, wenn der Weg über eine Veröffentlichungsmail kam, die
|
||
// konkrete Meldung mit ihren Aufrufen.
|
||
$generated = $this->magicLinks->createLoginLink(
|
||
$user,
|
||
$ip,
|
||
source: $source,
|
||
payload: array_filter([
|
||
'company_id' => $landingCompanyId,
|
||
'press_release_id' => $landingPressReleaseId,
|
||
]),
|
||
);
|
||
|
||
Mail::to($user->email)->queue((new MagicLoginLink(
|
||
user: $user,
|
||
loginUrl: route('magic-links.consume', ['token' => $generated['plain_token']]),
|
||
expiresAt: $generated['expires_at']->format('d.m.Y H:i'),
|
||
))->afterCommit());
|
||
}
|
||
|
||
/**
|
||
* 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<int, int> $companyIds
|
||
* @param Collection<int, Contact> $contacts
|
||
*/
|
||
private function linkMemberships(User $user, Collection $companyIds, Collection $contacts): void
|
||
{
|
||
foreach ($companyIds as $companyId) {
|
||
// Owner via owner_user_id → bereits voller Zugriff.
|
||
if ($user->ownedCompanies()->withoutGlobalScope(PortalScope::class)->whereKey($companyId)->exists()) {
|
||
continue;
|
||
}
|
||
|
||
$linked = $user->companies()->withoutGlobalScope(PortalScope::class)->whereKey($companyId)->first();
|
||
|
||
if ($linked === null) {
|
||
$user->companies()->attach($companyId, ['role' => 'responsible', 'granted_via' => 'auto']);
|
||
} elseif ($linked->pivot->role === 'member') {
|
||
// Nur den Team-Default hochstufen; owner/responsible bleiben.
|
||
// `auto-upgrade` merkt sich, dass darunter eine manuelle
|
||
// Mitgliedschaft liegt – die Rücknahme führt dorthin zurück
|
||
// statt die Zeile zu löschen.
|
||
$user->companies()->updateExistingPivot($companyId, ['role' => 'responsible', 'granted_via' => 'auto-upgrade']);
|
||
}
|
||
}
|
||
|
||
if ($contacts->isNotEmpty()) {
|
||
$user->contacts()->syncWithoutDetaching($contacts->pluck('id')->all());
|
||
}
|
||
}
|
||
}
|