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; } }