- Magic-Link-Versand im Login rate-limited (E-Mail+IP 3/h und IP-only 15/h); verhindert Mail-Fluten und das Entwerten aktiver Links. - Inaktive (aber verifizierte) User werden beim Passwort-Login zentral blockiert (Auth::logout + Fehler) – sichert nur-auth/verified-Routen ab. - Rollensicherer Login-Redirect: gemerkte intended-Admin-URLs schicken einen Customer nicht mehr in den 403, sondern auf das rollengerechte Ziel. - ContactAccess prüft is_active vor jeder Mutation: deaktivierte Bestands- Accounts werden durch eine Anfrage weder verändert noch angemailt. - Magic-Link-Verbrauch atomar (UPDATE … whereNull(consumed_at)) – Single-Use auch bei parallelen Requests. - Sicherheits-Doku um diese Härtungen + Captcha-Empfehlung ergänzt. Tests: Rate-Limit, intended-Admin-URL für Customer, inaktiver Login, ContactAccess ohne Mutation inaktiver Accounts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
121 lines
4.1 KiB
PHP
121 lines
4.1 KiB
PHP
<?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);
|
||
|
||
// 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->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'),
|
||
));
|
||
|
||
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());
|
||
}
|
||
}
|