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:
parent
3dbc3fa673
commit
1ccb4a8479
29 changed files with 1287 additions and 73 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
52
app/Http/Middleware/EnsureSessionIsNotRevoked.php
Normal file
52
app/Http/Middleware/EnsureSessionIsNotRevoked.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue