Audit vom 11.09. gegen HEAD geprüft, Entwicklungsplan dazu unter dev/2026-09-11-audit/ENTWICKLUNGSPLAN.md. Dieser Commit ist Deploy A: die sechs kleinen Punkte aus Block 1 ohne Schemaänderung. F01: Der Fortify-Endpunkt PUT /user/profile-information tauschte die E-Mail sofort und setzte die Verifizierung zurück; der Login-Sync vergab danach Firmenrechte allein nach E-Mail-Treffer. Feature abgeschaltet (die Kunden-UI hat den pending_email-Prozess), und der Sync verlangt jetzt ein verifiziertes, aktives Konto – für alle Aufrufer. F02: Google-Login übergibt Konten mit bestätigtem TOTP in die Challenge, wie Passwort- und Magic-Link-Login. F03: showRecoveryCodes ist gesperrt; ein Client-Update konnte die Recovery-Codes entschlüsseln lassen. F08: Nutzer-Detailroute unter can:users:manage (persistente Livewire- Middleware), Gutschriften autorisieren zusätzlich in der Action. F09: Gate::before greift nur für aktive Super-Admins; deaktivierte fallen auf die Gates mit is_active-Prüfung zurück. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
260 lines
12 KiB
PHP
260 lines
12 KiB
PHP
<?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\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\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\EmailDomainObserver;
|
||
use App\Observers\PressReleaseSearchObserver;
|
||
use App\Services\Admin\AdminRequestPerformanceMetrics;
|
||
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\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\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 Laravel\Cashier\Cashier;
|
||
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());
|
||
|
||
// 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.
|
||
Gate::before(fn (User $user, string $ability): ?bool => ($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']);
|
||
|
||
// 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);
|
||
|
||
// Kontakterreichbarkeit: DNS-Befund der Mail-Domain nachziehen,
|
||
// sobald eine Adresse entsteht oder sich ändert.
|
||
Company::observe(EmailDomainObserver::class);
|
||
Contact::observe(EmailDomainObserver::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,
|
||
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']);
|
||
}
|
||
}
|
||
}
|