- 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>
172 lines
7.1 KiB
PHP
172 lines
7.1 KiB
PHP
<?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;
|
||
}
|
||
}
|