presseportale/app/Providers/AppServiceProvider.php
Kevin Adametz 5dfd622141 security: Andere Geräte abmelden, IP-weites Login-Limit, dauerhafte Sperrmarke, Passwortregeln, CSP im Melde-Modus
Restliche Phase 4 aus dem Sicherheitsaudit 07.10.2026:

- Sicherheitsseite: „Andere Geräte abmelden“ rotiert remember_token und
  beendet alle anderen Sessions, die aktuelle bleibt (nicht während einer
  Impersonation).
- LoginIpThrottle: 20 Fehlversuche je IP in 15 Minuten über alle Konten.
  Gezählt über das Failed-Event, damit Livewire-Login und Fortifys
  POST /login denselben Zähler teilen; Erfolg setzt ihn nicht zurück.
- Sperrmarke für Sessions in users.sessions_revoked_at_ms statt nur im
  Cache — übersteht cache:clear. Die alte Cache-Marke zählt übergangsweise
  mit und kann im nächsten Release entfallen.
- Neue und geänderte Passwörter: mindestens 12 Zeichen, in Produktion
  zusätzlich uncompromised() (HIBP, k-Anonymität). Bestehende bleiben gültig.
- Content-Security-Policy-Report-Only in Produktion; Meldungen über
  /csp-report nach storage/logs/csp-*.log, je Meldung höchstens einmal pro
  Stunde, ohne Query und tokenartige Pfadsegmente.

Migration: users.sessions_revoked_at_ms (users hat FULLTEXT, 25 MB, Sekunden).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 13:02:49 +00:00

309 lines
14 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Providers;
use App\Contracts\NewsletterSyncClient;
use App\Exceptions\MailDeliveryPausedException;
use App\Helpers\ThemeHelper;
use App\Http\Middleware\BlockAdminDuringImpersonation;
use App\Http\Middleware\EnsureStrongAdminAuthentication;
use App\Http\Middleware\EnsureUserIsAdmin;
use App\Http\Middleware\EnsureUserIsCustomer;
use App\Http\Middleware\LogSlowAdminRequests;
use App\Http\Middleware\SetEdition;
use App\Listeners\ActivateUserAfterVerification;
use App\Listeners\GateOutgoingMail;
use App\Listeners\RecordAuthActivity;
use App\Listeners\RecordMailActivity;
use App\Listeners\RecordSmtpFailure;
use App\Listeners\SyncCompanyMembershipsOnLogin;
use App\Models\AdminPreset;
use App\Models\Category;
use App\Models\CategoryTranslation;
use App\Models\Company;
use App\Models\Contact;
use App\Models\ContactMessage;
use App\Models\ImageRightsConfirmation;
use App\Models\LegalRequest;
use App\Models\NewsletterSubscription;
use App\Models\PressRelease;
use App\Models\TerminationRequest;
use App\Models\User;
use App\Models\WithdrawalRequest;
use App\Observers\ActivityLogObserver;
use App\Observers\AdminPerformanceCacheObserver;
use App\Observers\CompanyMembershipObserver;
use App\Observers\CompanyPublicCacheObserver;
use App\Observers\EmailDomainObserver;
use App\Observers\ImageRightsConfirmationObserver;
use App\Observers\PressReleaseSearchObserver;
use App\Services\Admin\AdminRequestPerformanceMetrics;
use App\Services\Auth\SessionRevocation;
use App\Services\Mail\DnsResolver;
use App\Services\Mail\SystemDnsResolver;
use App\Services\Newsletter\NullNewsletterSyncClient;
use App\Services\PressRelease\PressReleaseService;
use App\Support\OperationalAlert;
use App\Support\WebPageCache;
use Illuminate\Auth\Events\Failed;
use Illuminate\Auth\Events\Lockout;
use Illuminate\Auth\Events\Login;
use Illuminate\Auth\Events\Registered;
use Illuminate\Auth\Events\Verified;
use Illuminate\Auth\Listeners\SendEmailVerificationNotification;
use Illuminate\Auth\Notifications\ResetPassword;
use Illuminate\Auth\Notifications\VerifyEmail;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Events\QueryExecuted;
use Illuminate\Foundation\Console\AboutCommand;
use Illuminate\Mail\Events\MessageSending;
use Illuminate\Mail\Events\MessageSent;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Queue\Events\JobFailed;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\ServiceProvider;
use Illuminate\Validation\Rules\Password;
use Laravel\Cashier\Cashier;
use Laravel\Fortify\Events\TwoFactorAuthenticationFailed;
use Livewire\Livewire;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
$this->app->bind(NewsletterSyncClient::class, function (): NewsletterSyncClient {
$provider = (string) config('newsletter.sync.provider', 'placeholder');
// Es existiert bislang nur der No-Op-Client. Ein aktivierter, aber
// nicht implementierter Provider soll laut schlagen statt stumm
// ins Leere zu synchronisieren.
if (config('newsletter.sync.enabled') && $provider !== 'placeholder') {
throw new \RuntimeException(
"Newsletter-Sync-Provider [{$provider}] ist nicht implementiert – es existiert nur der Platzhalter-Client."
);
}
return new NullNewsletterSyncClient;
});
$this->app->bind(DnsResolver::class, SystemDnsResolver::class);
$this->app->singleton(PressReleaseService::class);
$this->app->singleton(AdminRequestPerformanceMetrics::class);
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
Model::preventLazyLoading(app()->isLocal());
// Ausgelieferte Version in `php artisan about` (Datei RELEASE bzw. APP_VERSION).
AboutCommand::add('Pressekonto', fn (): array => ['Version' => config('app.version') ?? '—']);
// Passwortregeln für neue und geänderte Passwörter (Sicherheitsaudit
// 07.10.2026, Phase 4). Bestehende kürzere Passwörter bleiben gültig.
// Der Leak-Abgleich fragt Have I Been Pwned per k-Anonymität ab (nur
// die ersten 5 Zeichen des SHA-1-Hashes verlassen den Server) und
// lässt das Passwort durch, wenn der Dienst nicht antwortet — daher
// nur in Produktion, Tests und lokale Entwicklung bleiben offline.
Password::defaults(function (): Password {
$rule = Password::min(12);
return $this->app->isProduction() ? $rule->uncompromised() : $rule;
});
// Die Generations-Nummer der Seiten-Caches wird prozessintern gemerkt.
// Ein Queue-Worker lebt über beliebig viele Jobs hinweg und würde sonst
// dauerhaft mit dem Stand seines ersten Zugriffs arbeiten — Keys einer
// längst verworfenen Generation lesen und löschen.
Queue::looping(static fn () => WebPageCache::resetMemo());
$scheme = request()->header('X-Forwarded-Proto')
?? request()->server('HTTP_X_FORWARDED_PROTO')
?? (request()->secure() ? 'https' : 'http');
if ($scheme === 'https') {
URL::forceScheme('https');
}
// Baseline für das Ausgabe-Präfix (de|en). Die SetEdition-Middleware
// überschreibt dies pro Request mit der tatsächlichen Ausgabe; der
// Default hier stellt sicher, dass route('…')-Aufrufe das {edition}-
// Segment auch außerhalb der HTTP-Middleware erhalten (Livewire-Tests,
// 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.
// Super-Admins dürfen alles: zentraler Kurzschluss vor allen Gates und
// Permission-Checks (analog zu den Policy-`before()`-Methoden). Damit
// greifen feingranulare `can('users:manage')`-Prüfungen auch für
// Super-Admins, die keine Spatie-Rolle mit der Permission tragen.
// Deaktivierung geht vor (Audit F09): ein gesperrter Super-Admin fällt
// auf die regulären Gates zurück, die `is_active` prüfen – sonst wäre
// die Sperre für bestehende Sessions wirkungslos.
// Ausnahme: die Kundenbestätigung eines Admin-Uploads ist an die
// Identität des Kontoinhabers gebunden (PressReleasePolicy, Audit N05).
Gate::before(fn (User $user, string $ability): ?bool => $ability !== 'confirmImageRights' && $user->is_super_admin && $user->is_active ? true : null);
Gate::define('access-admin', fn (User $user): bool => $user->canAccessAdmin());
Gate::define('access-customer', fn (User $user): bool => $user->canAccessCustomer());
// Betriebs-Alert bei fehlgeschlagenem Queue-Job (R5.3): die KI-Jobs
// laufen auf der `classification`-Queue — ein stiller Stau in
// `failed_jobs` würde jede Einreichung in der Prüfung hängen lassen.
Queue::failing(function (JobFailed $event): void {
// Bounce-Pfad 2: SMTP-Ablehnung im Worker → Sperre bzw. Circuit
// Breaker (Roadmap 1b.6). Läuft VOR dem Alert, damit der Breaker
// seinen eigenen, einmaligen Alert setzen kann.
app(RecordSmtpFailure::class)->record($event);
// Der Breaker hat den Versand angehalten: Das ist EIN Alert (beim
// Öffnen), nicht einer je liegengebliebener Mail.
if ($event->exception instanceof MailDeliveryPausedException) {
return;
}
OperationalAlert::send("Queue-Job fehlgeschlagen: {$event->job->resolveName()}", [
'connection' => $event->connectionName,
'queue' => $event->job->getQueue(),
'exception' => $event->exception->getMessage(),
]);
});
// Registrierung → Verifizierungsmail; bestätigter Klick → Aktivierung.
Event::listen(Registered::class, SendEmailVerificationNotification::class);
Event::listen(Verified::class, ActivateUserAfterVerification::class);
// Aktivitäts-Verlauf (Admin ▸ Reports ▸ Verlauf): Registrierung und
// Login; Formular-Eingänge laufen über den ActivityLogObserver unten.
Event::listen(Registered::class, [RecordAuthActivity::class, 'recordRegistered']);
Event::listen(Login::class, [RecordAuthActivity::class, 'recordLogin']);
Event::listen(Failed::class, [RecordAuthActivity::class, 'recordFailedLogin']);
Event::listen(Lockout::class, [RecordAuthActivity::class, 'recordLockout']);
Event::listen(TwoFactorAuthenticationFailed::class, [RecordAuthActivity::class, 'recordTwoFactorFailed']);
// Anmeldezeitpunkt in der Session — Grundlage für das Beenden fremder
// Sessions nach einer Kontoübernahme (Audit K-04, SessionRevocation).
Event::listen(Login::class, [SessionRevocation::class, 'stampLogin']);
// Das eine Gate vor jeder ausgehenden Mail: Suppression, Return-Path,
// Circuit Breaker (Roadmap 1b.6). Gibt false zurück → Mail geht nicht raus.
Event::listen(MessageSending::class, [GateOutgoingMail::class, 'gate']);
// Jede tatsächlich versendete E-Mail landet ebenfalls im Verlauf.
Event::listen(MessageSent::class, [RecordMailActivity::class, 'recordSent']);
// Framework-Auth-Mails (Passwort-Reset, E-Mail-Verifizierung) im
// pressekonto-CI rendern statt im Laravel-Standard-Markdown-Theme —
// gleiches Layout wie alle übrigen System-Mails (emails/layout).
ResetPassword::toMailUsing(function (User $user, string $token): MailMessage {
$resetUrl = url(route('password.reset', [
'token' => $token,
'email' => $user->getEmailForPasswordReset(),
], false));
return (new MailMessage)
->subject('Passwort zurücksetzen für Ihr pressekonto')
->view('emails.auth.reset-password', [
'user' => $user,
'resetUrl' => $resetUrl,
'expiresInMinutes' => (int) config('auth.passwords.'.config('auth.defaults.passwords').'.expire'),
]);
});
VerifyEmail::toMailUsing(function (User $user, string $verificationUrl): MailMessage {
return (new MailMessage)
->subject('E-Mail-Adresse bestätigen für Ihr pressekonto')
->view('emails.auth.verify-email', [
'user' => $user,
'verificationUrl' => $verificationUrl,
]);
});
// Bei jedem Login Firmen-/Kontakt-Zuordnungen (gleiche E-Mail) auffrischen.
Event::listen(Login::class, SyncCompanyMembershipsOnLogin::class);
// Stripe Tax berechnet die USt im Checkout automatisch nach den
// gleichen Regeln wie der VatResolver im MAN-Kreis (DE mit Steuer,
// EU nur mit USt-ID befreit, Drittland befreit). Aktiviert zugleich
// die USt-ID-Abfrage im Stripe Checkout.
Cashier::calculateTaxes();
// Formular-Eingänge in den Aktivitäts-Verlauf schreiben.
ContactMessage::observe(ActivityLogObserver::class);
LegalRequest::observe(ActivityLogObserver::class);
WithdrawalRequest::observe(ActivityLogObserver::class);
TerminationRequest::observe(ActivityLogObserver::class);
NewsletterSubscription::observe(ActivityLogObserver::class);
// Hält die Suchtabelle synchron — sie trägt die Volltext-Indizes,
// damit press_releases ohne teuren Rebuild erweitert werden kann.
PressRelease::observe(PressReleaseSearchObserver::class);
// Bestätigungsprotokoll je Bild bleibt insert-only (Bildkennzeichnung A.2).
ImageRightsConfirmation::observe(ImageRightsConfirmationObserver::class);
// Kontakterreichbarkeit: DNS-Befund der Mail-Domain nachziehen,
// sobald eine Adresse entsteht oder sich ändert.
Company::observe(EmailDomainObserver::class);
Contact::observe(EmailDomainObserver::class);
// Automatische Firmenrechte sofort neu bewerten, wenn sich ihre
// E-Mail-Grundlage ändert (Sicherheitsaudit 07.10.2026, S-03).
Company::observe(CompanyMembershipObserver::class);
Contact::observe(CompanyMembershipObserver::class);
User::observe(CompanyMembershipObserver::class);
// Öffentliche Seiten-Caches bei Löschung/Deaktivierung einer Firma
// verwerfen — auf jedem Löschweg, nicht nur im Admin-Formular (F13).
Company::observe(CompanyPublicCacheObserver::class);
AdminPreset::observe(AdminPerformanceCacheObserver::class);
Category::observe(AdminPerformanceCacheObserver::class);
CategoryTranslation::observe(AdminPerformanceCacheObserver::class);
Company::observe(AdminPerformanceCacheObserver::class);
Contact::observe(AdminPerformanceCacheObserver::class);
NewsletterSubscription::observe(AdminPerformanceCacheObserver::class);
PressRelease::observe(AdminPerformanceCacheObserver::class);
User::observe(AdminPerformanceCacheObserver::class);
Permission::observe(AdminPerformanceCacheObserver::class);
Role::observe(AdminPerformanceCacheObserver::class);
DB::listen(fn (QueryExecuted $query) => app(AdminRequestPerformanceMetrics::class)->record($query));
// Auch bei Livewire-Folge-Requests erneut prüfen — sonst gälten die
// Panel-Gates nur für den initialen Seitenaufruf (R6.2: Customer-Gate
// symmetrisch zum Admin-Gate persistieren).
Livewire::addPersistentMiddleware([
BlockAdminDuringImpersonation::class,
EnsureUserIsAdmin::class,
EnsureStrongAdminAuthentication::class,
EnsureUserIsCustomer::class,
LogSlowAdminRequests::class,
]);
}
/**
* Set the asset URL dynamically based on the current theme
*/
protected function setDynamicAssetUrl(): void
{
try {
$assetUrl = ThemeHelper::getAssetUrl();
config(['app.asset_url' => $assetUrl]);
} catch (\Exception $e) {
// Fallback to default if theme detection fails
config(['app.asset_url' => 'https://assets.pressekonto.test']);
}
}
}