presseportale/app/Services/Auth/ContactAccessService.php
Kevin Adametz d98d297524 Security-Härtung Login & Magic-Link (Review 16.06.)
- 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>
2026-06-16 09:33:44 +00:00

121 lines
4.1 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);
// 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());
}
}