presseportale/app/Http/Controllers/Auth/MagicLinkConsumeController.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
7.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\Http\Controllers\Auth;
use App\Enums\ConversionEventType;
use App\Http\Controllers\Controller;
use App\Models\MagicLink;
use App\Models\PressRelease;
use App\Models\User;
use App\Scopes\PortalScope;
use App\Services\Auth\LoginCompletion;
use App\Services\Conversion\ConversionRecorder;
use App\Services\Customer\CustomerCompanyContext;
use App\Support\LoginRedirect;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class MagicLinkConsumeController extends Controller
{
public function __construct(
private readonly ConversionRecorder $recorder,
private readonly CustomerCompanyContext $companies,
private readonly LoginCompletion $completion,
) {}
public function __invoke(Request $request, string $token): RedirectResponse
{
$magicLink = MagicLink::query()
->with('user')
->where('token_hash', hash('sha256', $token))
// Login- und Bildbestätigungslinks melden gleich an (B-10).
->whereIn('purpose', MagicLink::LOGIN_PURPOSES)
->first();
// Bereits angemeldet (Audit K-08): Die Route liegt nicht mehr hinter
// `guest`. Öffnet ein eingeloggter Kunde seinen EIGENEN Link (etwa die
// Bildbestätigung), landet er auf dem Ziel statt auf seinem Dashboard.
// Jeder andere Link verhält sich wie früher unter `guest`: zurück
// nach Hause, der Link bleibt unberührt.
$current = Auth::guard('web')->user();
if ($current instanceof User) {
if (! $magicLink || $magicLink->user_id !== $current->getKey()) {
return redirect(! $current->hasVerifiedEmail() ? route('verification.notice') : LoginRedirect::homeFor($current));
}
return $this->openOwnLinkWhileLoggedIn($request, $magicLink, $current);
}
if (! $magicLink || ! $magicLink->user) {
return redirect()->route('login')->with('status', __('The magic login link is invalid.'));
}
if ($magicLink->consumed_at !== null || $magicLink->expires_at->isPast()) {
return redirect()->route('login')->with('status', __('The magic login link has expired or was already used.'));
}
if (! $magicLink->user->is_active) {
return redirect()->route('login')->with('status', __('Your account is not active.'));
}
// Atomar beanspruchen: nur EIN paralleler Request darf den Link
// verbrauchen. Das konditionale UPDATE greift dank Zeilen-Atomarität
// genau einmal; verliert ein Race, ist consumed_at bereits gesetzt.
$claimed = MagicLink::query()
->whereKey($magicLink->id)
->whereNull('consumed_at')
->update([
'consumed_at' => now(),
'ip_consumed' => $request->ip(),
]);
if ($claimed === 0) {
return redirect()->route('login')->with('status', __('The magic login link has expired or was already used.'));
}
// Stufe 2 der Konversionsleiter. Die Herkunft kommt vom Token, nicht
// aus der Session — die ist beim Weg über Mail und Domainwechsel
// längst gerissen (Roadmap 0.4).
$this->recorder->record(
ConversionEventType::MagicLinkConsumed,
$this->recorder->sourceOf($magicLink),
);
$user = $magicLink->user;
// 2FA aktiv: nicht direkt einloggen, sondern – wie der Passwort-Login –
// in Fortifys Challenge übergeben. Der Magic-Link (Besitz des Postfachs)
// ersetzt nur das Passwort, nicht den zweiten Faktor. Der Link ist bereits
// atomar verbraucht; bricht der Nutzer die Challenge ab, fordert er einen
// neuen an.
//
// Das Landeziel geht als `url.intended` mit durch die Challenge
// (Audit K-08); RoleAwareLoginResponse übernimmt es nur, wenn der
// Nutzer es erreichen darf.
if ($user->hasEnabledTwoFactorAuthentication()) {
$this->completion->handOffToTwoFactor($request, $user, 'magic-link', intended: $this->landingFor($user, $magicLink));
return redirect()->route('two-factor.challenge');
}
$this->completion->login($request, $user, 'magic-link');
return redirect($this->landingFor($user, $magicLink));
}
/**
* Eigener Link bei bestehender Anmeldung: kein erneuter Login. Ein noch
* gültiger Link wird wie sonst verbraucht (Einmal-Nutzung), ein schon
* verbrauchter oder abgelaufener führt trotzdem zum Ziel — der Nutzer
* ist ja angemeldet.
*/
private function openOwnLinkWhileLoggedIn(Request $request, MagicLink $magicLink, User $user): RedirectResponse
{
if ($magicLink->consumed_at === null && ! $magicLink->expires_at->isPast()) {
$claimed = MagicLink::query()
->whereKey($magicLink->id)
->whereNull('consumed_at')
->update([
'consumed_at' => now(),
'ip_consumed' => $request->ip(),
]);
if ($claimed > 0) {
$this->recorder->record(
ConversionEventType::MagicLinkConsumed,
$this->recorder->sourceOf($magicLink),
);
}
}
return redirect($this->landingFor($user, $magicLink));
}
/**
* Rollensicheres Landeziel ohne intended(): eine als Gast besuchte
* Admin-URL (z. B. /dashboard) darf einen Customer nach dem
* Magic-Link-Login nicht in den 403-Admin-Bereich schicken.
*/
private function landingFor(User $user, MagicLink $magicLink): string
{
$home = $user->canAccessAdmin()
? route('dashboard', absolute: false)
: ($user->canAccessCustomer()
? route('me.dashboard', absolute: false)
: '/');
// „Diesen Newsroom verwalten" (Roadmap 1b.2): direkt auf die Firma,
// um die es ging — sofern der Account sie wirklich verwalten darf.
$landingCompanyId = (int) ($magicLink->payload['company_id'] ?? 0);
if ($landingCompanyId > 0 && $user->canAccessCustomer() && ! $user->canAccessAdmin()
&& $this->companies->findFor($user, $landingCompanyId) !== null) {
$home = route('me.press-kits.show', ['id' => $landingCompanyId], absolute: false);
// Aus der Veröffentlichungsmail (Roadmap 1c.2): direkt auf die
// Meldung mit ihren Aufrufen, wenn der Account sie sehen darf.
$landingPressReleaseId = (int) ($magicLink->payload['press_release_id'] ?? 0);
if ($landingPressReleaseId > 0) {
$pressRelease = PressRelease::query()->withoutGlobalScope(PortalScope::class)->whereKey($landingPressReleaseId)->where('company_id', $landingCompanyId)->first();
if ($pressRelease !== null && $user->can('view', $pressRelease)) {
$home = route('me.press-releases.show', ['id' => $landingPressReleaseId], absolute: false);
}
}
}
return $home;
}
}