diff --git a/app/Http/Middleware/BlockAdminDuringImpersonation.php b/app/Http/Middleware/BlockAdminDuringImpersonation.php new file mode 100644 index 00000000..364c9191 --- /dev/null +++ b/app/Http/Middleware/BlockAdminDuringImpersonation.php @@ -0,0 +1,34 @@ +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); + } +} diff --git a/app/Http/Middleware/EnsureUserIsAdmin.php b/app/Http/Middleware/EnsureUserIsAdmin.php index 813e4ece..e55ede0c 100644 --- a/app/Http/Middleware/EnsureUserIsAdmin.php +++ b/app/Http/Middleware/EnsureUserIsAdmin.php @@ -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.'); } diff --git a/app/Http/Middleware/EnsureUserIsCustomer.php b/app/Http/Middleware/EnsureUserIsCustomer.php index 2b7e737c..cedb5d55 100644 --- a/app/Http/Middleware/EnsureUserIsCustomer.php +++ b/app/Http/Middleware/EnsureUserIsCustomer.php @@ -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.'); } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 34af02b6..00bca29b 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -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, ]); diff --git a/dev/optimize/entwicklungsplan.md b/dev/optimize/entwicklungsplan.md index 76872d1d..2d436d50 100644 --- a/dev/optimize/entwicklungsplan.md +++ b/dev/optimize/entwicklungsplan.md @@ -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. --- diff --git a/routes/admin.php b/routes/admin.php index e39397a3..3a06fee5 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -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'); diff --git a/tests/Feature/Auth/AccessGatesTest.php b/tests/Feature/Auth/AccessGatesTest.php new file mode 100644 index 00000000..6a26da0f --- /dev/null +++ b/tests/Feature/Auth/AccessGatesTest.php @@ -0,0 +1,42 @@ +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(); +});