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>
This commit is contained in:
Kevin Adametz 2026-10-02 13:09:00 +00:00
parent 3dbc3fa673
commit 1ccb4a8479
29 changed files with 1287 additions and 73 deletions

View file

@ -3,12 +3,11 @@
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Listeners\RecordAuthActivity;
use App\Services\Auth\LoginCompletion;
use App\Services\Auth\SocialAuthService;
use App\Support\LoginRedirect;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Laravel\Socialite\Facades\Socialite;
/**
@ -23,7 +22,7 @@ class GoogleController extends Controller
return Socialite::driver('google')->redirect();
}
public function callback(Request $request, SocialAuthService $social): RedirectResponse
public function callback(Request $request, SocialAuthService $social, LoginCompletion $completion): RedirectResponse
{
try {
$googleUser = Socialite::driver('google')->user();
@ -49,25 +48,16 @@ class GoogleController extends Controller
// 2FA aktiv: wie Passwort- und Magic-Link-Login nicht direkt einloggen,
// sondern in Fortifys Challenge übergeben (Audit F02). Google belegt
// den Besitz des Postfachs, nicht den zweiten Faktor.
//
// Kein erzwungenes „Angemeldet bleiben" mehr (Audit K-08): wie beim
// Magic Link nur die Sitzung, siehe LoginCompletion.
if ($user->hasEnabledTwoFactorAuthentication()) {
$request->session()->put([
'login.id' => $user->getKey(),
'login.remember' => true,
]);
$request->session()->regenerate();
$completion->handOffToTwoFactor($request, $user, 'google');
return redirect()->route('two-factor.challenge');
}
$request->attributes->set(RecordAuthActivity::AUTH_METHOD_ATTRIBUTE, 'google');
Auth::login($user, remember: true);
$request->session()->regenerate();
$user->forceFill([
'last_login_at' => now(),
'last_login_ip' => $request->ip(),
])->save();
$completion->login($request, $user, 'google');
return redirect(LoginRedirect::safeTarget(
$user,

View file

@ -4,12 +4,14 @@ namespace App\Http\Controllers\Auth;
use App\Enums\ConversionEventType;
use App\Http\Controllers\Controller;
use App\Listeners\RecordAuthActivity;
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;
@ -19,6 +21,7 @@ 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
@ -26,9 +29,25 @@ class MagicLinkConsumeController extends Controller
$magicLink = MagicLink::query()
->with('user')
->where('token_hash', hash('sha256', $token))
->where('purpose', 'login')
// 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.'));
}
@ -71,30 +90,56 @@ class MagicLinkConsumeController extends Controller
// 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()) {
$request->session()->put([
'login.id' => $user->getKey(),
'login.remember' => false,
]);
$request->session()->regenerate();
$this->completion->handOffToTwoFactor($request, $user, 'magic-link', intended: $this->landingFor($user, $magicLink));
return redirect()->route('two-factor.challenge');
}
$user->update([
'last_login_at' => now(),
'last_login_ip' => $request->ip(),
]);
$this->completion->login($request, $user, 'magic-link');
$request->attributes->set(RecordAuthActivity::AUTH_METHOD_ATTRIBUTE, 'magic-link');
Auth::guard('web')->login($user);
$request->session()->regenerate();
return redirect($this->landingFor($user, $magicLink));
}
// 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.
/**
* 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()
@ -122,6 +167,6 @@ class MagicLinkConsumeController extends Controller
}
}
return redirect($home);
return $home;
}
}

View file

@ -0,0 +1,52 @@
<?php
namespace App\Http\Middleware;
use App\Services\Auth\SessionRevocation;
use Closure;
use Illuminate\Auth\SessionGuard;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response;
/**
* Meldet eine Session ab, die vor der letzten Sperre ihres Kontos entstanden
* ist (SessionRevocation, Audit K-04). Kostet je angemeldetem Request einen
* Cache-Zugriff; Gäste lösen keinen aus.
*/
class EnsureSessionIsNotRevoked
{
public function __construct(private readonly SessionRevocation $revocation) {}
public function handle(Request $request, Closure $next): Response
{
if (! $request->hasSession()) {
return $next($request);
}
/** @var SessionGuard $guard */
$guard = Auth::guard('web');
if (! $guard->hasUser() && ! $request->session()->has($guard->getName())) {
return $next($request);
}
$user = $guard->user();
if ($user === null || ! $this->revocation->isRevoked((int) $user->getAuthIdentifier(), $request->session())) {
return $next($request);
}
// Ohne logout(): das würde remember_token erneut rotieren und damit
// auch die gerade gültige Anmeldung des Kontoinhabers treffen.
$guard->logoutCurrentDevice();
$request->session()->invalidate();
$request->session()->regenerateToken();
if ($request->expectsJson()) {
abort(401);
}
return redirect()->guest(route('login'));
}
}

View file

@ -2,6 +2,7 @@
namespace App\Http\Responses;
use App\Services\Auth\LoginCompletion;
use App\Support\LoginRedirect;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
@ -49,6 +50,12 @@ class RoleAwareLoginResponse implements LoginResponseContract, TwoFactorLoginRes
: redirect()->route('verification.notice');
}
// Login-Abschluss auch nach der 2FA-Challenge (Audit K-08) — die
// direkten Wege setzen ihn in LoginCompletion::login().
if ($user) {
app(LoginCompletion::class)->recordLogin($user, $request);
}
if ($wantsJson) {
return new JsonResponse('', 204);
}