presseportale/app/Http/Controllers/Auth/MagicLinkConsumeController.php
Kevin Adametz 9143cc4a54 newsroom: Verwaltungszugang „Diesen Newsroom verwalten" (Roadmap 1b.2)
Neue Seite /newsroom/{slug}/verwalten auf den Web-Portalen: schickt den
Anmeldelink an die hinterlegte Firmen-E-Mail (sonst Pressekontakte), zeigt
sie nur maskiert, und funktioniert auch für Firmen ohne veröffentlichte
Meldung. Konto, Zuordnung und Login laufen durch ContactAccessService —
neu dort: Herkunft (profile_claim auf dem Token) und Ziel-Firma, auf deren
Kundenseite der Login landet. 410-entfernte bleiben Tombstone, aufgegangene
Slugs führen zum Überlebenden (MergedNewsroomLookup, geteilt mit der
Newsroom-Seite). 3 Links je Newsroom und Tag, 15 je IP und Stunde.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 10:42:20 +00:00

113 lines
4.3 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\Listeners\RecordAuthActivity;
use App\Models\MagicLink;
use App\Services\Conversion\ConversionRecorder;
use App\Services\Customer\CustomerCompanyContext;
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,
) {}
public function __invoke(Request $request, string $token): RedirectResponse
{
$magicLink = MagicLink::query()
->with('user')
->where('token_hash', hash('sha256', $token))
->where('purpose', 'login')
->first();
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.
if ($user->hasEnabledTwoFactorAuthentication()) {
$request->session()->put([
'login.id' => $user->getKey(),
'login.remember' => false,
]);
$request->session()->regenerate();
return redirect()->route('two-factor.challenge');
}
$user->update([
'last_login_at' => now(),
'last_login_ip' => $request->ip(),
]);
$request->attributes->set(RecordAuthActivity::AUTH_METHOD_ATTRIBUTE, 'magic-link');
Auth::guard('web')->login($user);
$request->session()->regenerate();
// Rollensicherer Redirect 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.
$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);
}
return redirect($home);
}
}