presseportale/app/Services/Auth/MagicLinkGenerator.php
Kevin Adametz 1ccb4a8479 auth/security: API-Firmenzuordnung nur für Mitglieder oder Allowlist, Google-Vorabübernahme geschlossen, gemeinsamer Login-Abschluss (Umsetzungsaudit 29.09.: R-01, K-04, K-07, K-08, B-10)
- R-01: ApiNewsroomAssignment hängt an eine bestehende Firma nur noch Owner,
  Mitglieder oder Nutzer aus NEWSROOM_API_ASSIGNMENT_USER_IDS; Pivot mit
  granted_via = api; Befehl company-memberships:api-assignments (Liste,
  --remove mit Vorschau)
- K-04: OAuth-Verifikation eines offenen Kontos verwirft Passwort,
  remember_token und Sanctum-Tokens; SessionRevocation + Middleware
  EnsureSessionIsNotRevoked widerruft alte Sessions (Redis-tauglich)
- K-08: LoginCompletion für Passwort, Magic Link und Google — last_login_at
  und Anmeldemethode auch nach 2FA, Landeziel über die Challenge, Google
  ohne erzwungenes remember, magic-login ohne guest für eigene Links
- B-10: eigener Magic-Link-Zweck image_confirmation (Migration)
- K-07: legalAcceptanceVersions #[Locked], Versionswechsel beim Absenden
  wird abgelehnt, Gate-Abfragen je Request zwischengespeichert

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 13:09:00 +00:00

172 lines
6.1 KiB
PHP

<?php
namespace App\Services\Auth;
use App\Enums\ConversionSource;
use App\Models\MagicLink;
use App\Models\PressRelease;
use App\Models\User;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
class MagicLinkGenerator
{
/**
* @param array<string, mixed> $payload z. B. `company_id`, auf dessen Firmenseite der Login landen soll (Roadmap 1b.2)
* @return array{magic_link: MagicLink, plain_token: string, expires_at: Carbon}
*/
public function createLoginLink(
User $user,
?string $requestedIp = null,
int $ttlMinutes = 15,
?ConversionSource $source = null,
array $payload = [],
): array {
$plainToken = Str::random(64);
$tokenHash = hash('sha256', $plainToken);
$expiresAt = now()->addMinutes($ttlMinutes);
MagicLink::query()
->where('user_id', $user->id)
->where('purpose', 'login')
->whereNull('consumed_at')
->update([
'consumed_at' => now(),
'ip_consumed' => $requestedIp,
]);
$magicLink = $user->magicLinks()->create([
'token_hash' => $tokenHash,
'purpose' => 'login',
// Herkunft gehört auf das Token, nicht in die Session: Die Strecke
// führt über zwei Domains und einen Mailclient, die Sitzung reißt
// dabei genau an der interessanten Stelle (Roadmap 0.4).
'source' => $source?->value,
'payload' => $payload ?: null,
'expires_at' => $expiresAt,
'ip_requested' => $requestedIp,
]);
return [
'magic_link' => $magicLink,
'plain_token' => $plainToken,
'expires_at' => $expiresAt,
];
}
/**
* Link „Bild bestätigen" für den Kontoinhaber (Decision-Update E10,
* Umsetzungsaudit 29.09.2026 B-10): meldet wie ein Login-Link an und
* führt auf die Meldung, hat aber einen eigenen Zweck. Spätere
* Login-Links entwerten ihn deshalb nicht; ein neuer Bestätigungslink
* für dasselbe Bild entwertet nur den vorherigen für dieses Bild.
*
* @param array<string, mixed> $payload Landeziel (`company_id`, `press_release_id`)
* @return array{magic_link: MagicLink, plain_token: string, expires_at: Carbon}
*/
public function createImageConfirmationLink(
User $user,
int $imageId,
array $payload,
?string $requestedIp,
int $ttlMinutes,
?ConversionSource $source = null,
): array {
$plainToken = Str::random(64);
$expiresAt = now()->addMinutes($ttlMinutes);
MagicLink::query()
->where('user_id', $user->id)
->where('purpose', MagicLink::PURPOSE_IMAGE_CONFIRMATION)
->whereNull('consumed_at')
->where('payload->image_confirmation', $imageId)
->update([
'consumed_at' => now(),
'ip_consumed' => $requestedIp,
]);
$magicLink = $user->magicLinks()->create([
'token_hash' => hash('sha256', $plainToken),
'purpose' => MagicLink::PURPOSE_IMAGE_CONFIRMATION,
'source' => $source?->value,
'payload' => ['image_confirmation' => $imageId] + $payload,
'expires_at' => $expiresAt,
'ip_requested' => $requestedIp,
]);
return [
'magic_link' => $magicLink,
'plain_token' => $plainToken,
'expires_at' => $expiresAt,
];
}
/**
* Meldeverfahren (Phase 3): Verifikations-Link an die hinterlegte
* Pressekontaktadresse. Wer den Link öffnet, ist strukturell verifiziert —
* kein Ermessen, kein Ausweis. Ältere unverbrauchte Report-Links derselben
* PM werden entwertet (nur der jüngste Link zählt).
*
* @return array{magic_link: MagicLink, plain_token: string, expires_at: Carbon}
*/
public function createReportLink(PressRelease $pressRelease, string $contactEmail, ?int $ttlHours = null): array
{
$plainToken = Str::random(64);
$tokenHash = hash('sha256', $plainToken);
$expiresAt = now()->addHours($ttlHours ?? (int) config('legal.report_link_ttl_hours'));
MagicLink::query()
->where('purpose', 'press_release_report')
->whereNull('consumed_at')
->where('payload->press_release_id', $pressRelease->id)
->update(['consumed_at' => now()]);
$magicLink = MagicLink::query()->create([
'user_id' => $pressRelease->user_id,
'token_hash' => $tokenHash,
'purpose' => 'press_release_report',
'payload' => [
'press_release_id' => $pressRelease->id,
'contact_email' => $contactEmail,
],
'expires_at' => $expiresAt,
]);
return [
'magic_link' => $magicLink,
'plain_token' => $plainToken,
'expires_at' => $expiresAt,
];
}
/**
* Public read-only share link for a press release. Token holder can view
* the press release in any state (draft/review/rejected/published) until
* the link expires. Default TTL: 7 days.
*
* @return array{magic_link: MagicLink, plain_token: string, url: string, expires_at: Carbon}
*/
public function createPressReleaseShareLink(PressRelease $pressRelease, ?User $issuer = null, int $ttlDays = 7): array
{
$plainToken = Str::random(64);
$tokenHash = hash('sha256', $plainToken);
$expiresAt = now()->addDays($ttlDays);
$magicLink = MagicLink::query()->create([
'user_id' => $issuer?->id ?? $pressRelease->user_id,
'token_hash' => $tokenHash,
'purpose' => 'press_release_access',
'payload' => [
'press_release_id' => $pressRelease->id,
],
'expires_at' => $expiresAt,
]);
return [
'magic_link' => $magicLink,
'plain_token' => $plainToken,
'url' => route('press-releases.preview', ['token' => $plainToken]),
'expires_at' => $expiresAt,
];
}
}