Optimierung 3.6: Zugriffs-Gates und getrennte Impersonation-Sperre
- Gate::define('access-admin'|'access-customer') als Single Source of Truth im
AppServiceProvider (auch via @can/Policies nutzbar). EnsureUserIsAdmin und
EnsureUserIsCustomer pruefen jetzt ueber diese Gates.
- Impersonation-Redirect aus EnsureUserIsAdmin in die eigene Middleware
BlockAdminDuringImpersonation ausgelagert (Sitzungs-Bedingung statt
Rollenfrage); laeuft vor der Zugriffspruefung und ist zusaetzlich als
persistente Livewire-Middleware registriert.
- Routen-Alias 'can:' bewusst NICHT verwendet: die Admin-Middleware ist
persistente Livewire-Middleware und braucht eine konkrete Klasse; der
parametrisierte Alias kann dort nicht stehen. EnsureApiUserIsActive bleibt
eigenstaendig (is_active-Pruefung mit getesteter JSON-Antwort, kein Gate).
- Tests: Auth/AccessGatesTest; UserImpersonationTest bleibt gruen.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b255d90721
commit
31e01e46b6
7 changed files with 104 additions and 16 deletions
34
app/Http/Middleware/BlockAdminDuringImpersonation.php
Normal file
34
app/Http/Middleware/BlockAdminDuringImpersonation.php
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Actions\Admin\UserImpersonation;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Sperrt den Admin-Bereich, solange eine Benutzer-Impersonation läuft. GET/HEAD
|
||||
* werden auf das Kunden-Dashboard umgeleitet (der Admin agiert in der
|
||||
* Kunden-Sicht), schreibende Zugriffe werden hart abgewiesen.
|
||||
*
|
||||
* Bewusst von der reinen Zugriffsprüfung ({@see EnsureUserIsAdmin}) getrennt:
|
||||
* Impersonation ist eine Sitzungs-Bedingung, keine Rollenfrage.
|
||||
*/
|
||||
class BlockAdminDuringImpersonation
|
||||
{
|
||||
public function __construct(private readonly UserImpersonation $impersonation) {}
|
||||
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if ($this->impersonation->isActive()) {
|
||||
if ($request->isMethod('GET') || $request->isMethod('HEAD')) {
|
||||
return redirect()->route('me.dashboard');
|
||||
}
|
||||
|
||||
abort(403, 'Während der Benutzer-Impersonation ist der Admin-Bereich gesperrt.');
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
|
|
@ -2,26 +2,22 @@
|
|||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Actions\Admin\UserImpersonation;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Reine Zugriffsprüfung auf den Admin-Bereich über das `access-admin`-Gate. Die
|
||||
* Impersonation-Sperre liegt in {@see BlockAdminDuringImpersonation} und läuft
|
||||
* davor. Bleibt eine konkrete Klasse (statt `can:access-admin`), weil sie als
|
||||
* persistente Livewire-Middleware registriert ist (AppServiceProvider).
|
||||
*/
|
||||
class EnsureUserIsAdmin
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
if (app(UserImpersonation::class)->isActive()) {
|
||||
if ($request->isMethod('GET') || $request->isMethod('HEAD')) {
|
||||
return redirect()->route('me.dashboard');
|
||||
}
|
||||
|
||||
abort(403, 'Während der Benutzer-Impersonation ist der Admin-Bereich gesperrt.');
|
||||
}
|
||||
|
||||
if (! $user?->canAccessAdmin()) {
|
||||
if (Gate::denies('access-admin')) {
|
||||
abort(403, 'Kein Zugriff auf den Admin-Bereich.');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,13 +4,18 @@ namespace App\Http\Middleware;
|
|||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Zugriffsprüfung auf das Kundenportal („Mein Bereich") über das
|
||||
* `access-customer`-Gate (Rollen admin/editor/customer, aktiv).
|
||||
*/
|
||||
class EnsureUserIsCustomer
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (! $request->user()?->canAccessCustomer()) {
|
||||
if (Gate::denies('access-customer')) {
|
||||
abort(403, 'Kein Zugriff auf das Kundenportal.');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ namespace App\Providers;
|
|||
|
||||
use App\Contracts\NewsletterSyncClient;
|
||||
use App\Helpers\ThemeHelper;
|
||||
use App\Http\Middleware\BlockAdminDuringImpersonation;
|
||||
use App\Http\Middleware\EnsureUserIsAdmin;
|
||||
use App\Http\Middleware\LogSlowAdminRequests;
|
||||
use App\Http\Middleware\SetEdition;
|
||||
|
|
@ -28,6 +29,7 @@ use Illuminate\Auth\Listeners\SendEmailVerificationNotification;
|
|||
use Illuminate\Database\Events\QueryExecuted;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Laravel\Cashier\Cashier;
|
||||
|
|
@ -67,6 +69,11 @@ class AppServiceProvider extends ServiceProvider
|
|||
// Konsole, Mails).
|
||||
URL::defaults(['edition' => SetEdition::DEFAULT_EDITION]);
|
||||
|
||||
// Zugriffs-Gates als Single Source of Truth (auch in Blade via @can und in
|
||||
// Policies nutzbar). Dieselbe Definition, die die Panel-Middleware prüfen.
|
||||
Gate::define('access-admin', fn (User $user): bool => $user->canAccessAdmin());
|
||||
Gate::define('access-customer', fn (User $user): bool => $user->canAccessCustomer());
|
||||
|
||||
// Registrierung → Verifizierungsmail; bestätigter Klick → Aktivierung.
|
||||
Event::listen(Registered::class, SendEmailVerificationNotification::class);
|
||||
Event::listen(Verified::class, ActivateUserAfterVerification::class);
|
||||
|
|
@ -94,6 +101,7 @@ class AppServiceProvider extends ServiceProvider
|
|||
DB::listen(fn (QueryExecuted $query) => app(AdminRequestPerformanceMetrics::class)->record($query));
|
||||
|
||||
Livewire::addPersistentMiddleware([
|
||||
BlockAdminDuringImpersonation::class,
|
||||
EnsureUserIsAdmin::class,
|
||||
LogSlowAdminRequests::class,
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -62,7 +62,9 @@ Keine Fachlogik-Änderung. Reduziert erreichbare Demo-Komponenten und Altlasten.
|
|||
- [ ] **3.3 `DomainContext`-Service** — Host→Config-Auflösung 4× (`ThemeServiceProvider::boot`, `ThemeMiddleware`, `SetDomainUrl`, `SetCurrentPortal`). **Vorher prüfen, ob `SetDomainUrl` toter Code ist** (scheint nicht in `bootstrap/app.php` registriert). `CurrentPortalContext` mutable static → request-scoped Binding.
|
||||
- [x] **3.4 Performance** — `LogApiUsage` ist jetzt terminable (INSERT nach gesendeter Antwort, kein Queue-Worker nötig; Startzeit über Request-Attribut). Offene Single-Purchase über `User::openSinglePurchaseCount()` per-Request memoisiert (vorher 3× Count + 1× exists pro Status-Widget-Render → jetzt 1× Count), genutzt von Quota-Total/-Remaining, `hasActiveBooking` und `CustomerStatusSummary`. `CustomerCompanyContext::selectedCompany()` nutzt eine einzige `first()`-Query statt exists()+first() (Verwerfen bei Nichterreichbarkeit bleibt). `LegacySubscriptionService::cancelBundleFor` ist ein einziges Bulk-Update (UserPaymentOption ohne Observer → sicher).
|
||||
- [x] **3.5 Kleinere Zentralisierungen** — `VatResolver::normalizeCountryCode()`/`isEuCountry()` als SSOT, genutzt von `resolve()`, `VatIdValidationService` und `StripeCheckoutService` (3×). `User::findByEmail()` bündelt die `LOWER(email)`-Lookups (ContactAccessService 2×, SocialAuthService 1×); bleibt bewusst case-insensitiv, weil E-Mails nicht normalisiert gespeichert werden — der indexfreundliche Direktvergleich braucht erst eine Schreib-Normalisierung (offen). Währung über `config('billing.default_currency')` ('EUR'; für Stripe-Calls `strtolower(...)`), ersetzt 8 verstreute `'EUR'`/`'eur'`-Literale und vereinheitlicht nebenbei die uneinheitliche `SinglePurchase.currency`. Neuer `CustomerAccountProvisioner::provisionVerifiedCustomer()` ist die gemeinsame Provisioning-Methode für ContactAccessService und SocialAuthService (verifiziert + aktiv + customer-Rolle). Tests: `UserFindByEmailTest`, erweiterte `VatResolverTest`.
|
||||
- [ ] **3.6 Auth-Gates** — `EnsureUserIsAdmin/Customer`, `EnsureApiUserIsActive` von Hand-Rolling → `Gate::define('access-admin'|'access-customer')` + `can:`-Middleware. Impersonation-Redirect aus dem Admin-Gate in eigene Middleware.
|
||||
- [x] **3.6 Auth-Gates** — `Gate::define('access-admin'|'access-customer')` als SSOT (AppServiceProvider), nutzbar via `@can`/Policies. `EnsureUserIsAdmin`/`EnsureUserIsCustomer` prüfen jetzt über diese Gates (`Gate::denies(...)`). Der Impersonation-Redirect ist in die eigene Middleware `BlockAdminDuringImpersonation` ausgelagert (Sitzungs-Bedingung statt Rollenfrage), sie läuft vor der Zugriffsprüfung und ist zusätzlich als persistente Livewire-Middleware registriert. Tests: `Auth/AccessGatesTest`, bestehende `UserImpersonationTest` (Redirect bleibt grün).
|
||||
- **Bewusst NICHT auf `can:`-Routen-Alias umgestellt:** `EnsureUserIsAdmin` ist persistente Livewire-Middleware (`Livewire::addPersistentMiddleware`), die konkrete Klassen erwartet — der parametrisierte `can:access-admin`-Alias kann dort nicht stehen. Die Gates sind die SSOT, die Middleware der konkrete Träger.
|
||||
- **`EnsureApiUserIsActive` bleibt eigenständig:** Es ist eine `is_active`-Prüfung (keine Rollenfrage) und liefert eine spezifische JSON-Antwort (`'API access is disabled for inactive users.'`, im API-Contract getestet) statt einer generischen 403 — ein `can:`-Gate würde Vertrag und Meldung ändern.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
use App\Http\Controllers\Admin\DashboardController;
|
||||
use App\Http\Controllers\Admin\LeaveImpersonationController;
|
||||
use App\Http\Controllers\LegacyInvoicePdfController;
|
||||
use App\Http\Middleware\BlockAdminDuringImpersonation;
|
||||
use App\Http\Middleware\EnsureUserIsAdmin;
|
||||
use App\Http\Middleware\LogSlowAdminRequests;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
|
@ -13,7 +14,7 @@ use Livewire\Volt\Volt;
|
|||
// Eingeloggte Admins werden über die Auth-Pipeline direkt nach /dashboard
|
||||
// geleitet (siehe FORTIFY_HOME / LoginResponseContract).
|
||||
Route::get('dashboard', DashboardController::class)
|
||||
->middleware(['auth', 'verified', EnsureUserIsAdmin::class, LogSlowAdminRequests::class])
|
||||
->middleware(['auth', 'verified', BlockAdminDuringImpersonation::class, EnsureUserIsAdmin::class, LogSlowAdminRequests::class])
|
||||
->name('dashboard');
|
||||
|
||||
// ========================================
|
||||
|
|
@ -33,7 +34,7 @@ Route::post('admin/impersonate/leave', LeaveImpersonationController::class)
|
|||
// ========================================
|
||||
// Admin-Bereich (nur Rollen admin/editor)
|
||||
// ========================================
|
||||
Route::middleware(['auth', 'verified', EnsureUserIsAdmin::class, LogSlowAdminRequests::class])->group(function () {
|
||||
Route::middleware(['auth', 'verified', BlockAdminDuringImpersonation::class, EnsureUserIsAdmin::class, LogSlowAdminRequests::class])->group(function () {
|
||||
|
||||
// Content Management
|
||||
Volt::route('admin/press-releases', 'admin.press-releases.index')->name('admin.press-releases.index');
|
||||
|
|
|
|||
42
tests/Feature/Auth/AccessGatesTest.php
Normal file
42
tests/Feature/Auth/AccessGatesTest.php
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Tests\TestCase;
|
||||
|
||||
beforeEach(function (): void {
|
||||
/** @var TestCase $this */
|
||||
$this->seed(RolesAndPermissionsSeeder::class);
|
||||
});
|
||||
|
||||
test('access-admin gate mirrors canAccessAdmin', function () {
|
||||
$admin = User::factory()->create(['is_active' => true]);
|
||||
$admin->assignRole('admin');
|
||||
|
||||
$customer = User::factory()->create(['is_active' => true]);
|
||||
$customer->assignRole('customer');
|
||||
|
||||
expect(Gate::forUser($admin)->allows('access-admin'))->toBeTrue()
|
||||
->and(Gate::forUser($customer)->allows('access-admin'))->toBeFalse();
|
||||
});
|
||||
|
||||
test('access-customer gate mirrors canAccessCustomer', function () {
|
||||
$customer = User::factory()->create(['is_active' => true]);
|
||||
$customer->assignRole('customer');
|
||||
|
||||
$apiOnly = User::factory()->create(['is_active' => true]);
|
||||
$apiOnly->assignRole('api-only');
|
||||
|
||||
expect(Gate::forUser($customer)->allows('access-customer'))->toBeTrue()
|
||||
->and(Gate::forUser($apiOnly)->allows('access-customer'))->toBeFalse();
|
||||
});
|
||||
|
||||
test('a non-admin user is forbidden from the admin dashboard', function () {
|
||||
$customer = User::factory()->create(['is_active' => true]);
|
||||
$customer->assignRole('customer');
|
||||
|
||||
$this->actingAs($customer)
|
||||
->get(route('dashboard'))
|
||||
->assertForbidden();
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue