presseportale/app/Http/Controllers/Auth/MagicLinkConsumeController.php
Kevin Adametz e8cf684f02 conversion: Trichter misst Verwaltungsseite, Anforderung und Firma bei der Einlösung
Roadmap 6.16. Bisher stand jede Einlösung ohne Firma im Trichter (Stufe
"Magic Link eingelöst" immer 0 Firmen), zwischen Mail-Klick und Einlösung
fehlten "Verwaltungsseite geöffnet" und "Anmeldelink angefordert", und
die Erstbuchung stand immer auf organic. Die Leiter hat jetzt sieben Stufen.

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

165 lines
6.8 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\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 4 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->recordMagicLinkConsumed($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->recordMagicLinkConsumed($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;
}
}