Compare commits
73 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 218d706141 | |||
| e9cd572aa4 | |||
| b38c69bd36 | |||
| 39c5065807 | |||
| 67016ed549 | |||
| 209a79f173 | |||
| ee849271fc | |||
| 678070a0f9 | |||
| 2c590fa9df | |||
| ebd61c8fd9 | |||
| b65998cbed | |||
| bd0aaaf2e6 | |||
| 1efb30f9ce | |||
| c2f44f42c3 | |||
| 33fcde5558 | |||
| 2c332052ce | |||
| a157d7a2a1 | |||
| 623fa66771 | |||
| b4870f98bb | |||
| e8cf684f02 | |||
| f5e3f1d1fe | |||
| eb9e991654 | |||
| 0498c734fd | |||
| 4db979eb63 | |||
| 9961fbe8a1 | |||
| 6c6015f960 | |||
| 6647e420c8 | |||
| b3d2d1e5b4 | |||
| 2d7e32b13c | |||
| 4bf22a81d0 | |||
| 53da182d17 | |||
| 2d74fd28c9 | |||
| fce7baea2b | |||
| 2150d4b7ae | |||
| 8124d20946 | |||
| 853dfe2131 | |||
| 2172d91d19 | |||
| da0b5bffbb | |||
| a3663d5628 | |||
| da011ccdbf | |||
| 9692de581d | |||
| 62d00a6c9d | |||
| ddff7dc989 | |||
| ccef83b952 | |||
| 002dc08b5c | |||
| d1b9ffdcf1 | |||
| 29e68330ca | |||
| 53e3b270e0 | |||
| 919b753f54 | |||
| 421c0d92f9 | |||
| 8e3554bf91 | |||
| 35fd2f6e55 | |||
| b3bcc43c4c | |||
| 010258a2ab | |||
| 06d2889a4d | |||
| 61f449f483 | |||
| 75e206dc92 | |||
| b06f5a8659 | |||
| bbc2b6048f | |||
| 65086cafd2 | |||
| 077e522554 | |||
| 5dfd622141 | |||
| 9c4962fbe0 | |||
| 798ae75e64 | |||
| e77d9e7d09 | |||
| 3def447f51 | |||
| f693226d4a | |||
| af72810296 | |||
| 8045ef18cc | |||
| bae8347f6c | |||
| fd8dd74d5f | |||
| 32e1349517 | |||
| d083110b00 |
342 changed files with 20439 additions and 880 deletions
21
.ai/rules/general.md
Normal file
21
.ai/rules/general.md
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
---
|
||||||
|
paths:
|
||||||
|
- '**/*'
|
||||||
|
---
|
||||||
|
|
||||||
|
# General
|
||||||
|
|
||||||
|
## Befehle direkt im Dev-Container ausführen
|
||||||
|
Die Arbeitsumgebung /var/www/html liegt bereits im DEV-Container. PHP-/Artisan-, Composer- und Node-Befehle dort direkt ausführen (z. B. php artisan test --compact), ohne vendor/bin/sail davor. Eine Sail-Meldung über nicht laufendes Docker ist in diesem Container kein Nachweis, dass Docker auf dem Host gestoppt ist. Diese ausdrückliche Benutzeranweisung ersetzt die allgemeine Sail-Prefix-Vorgabe für diese Umgebung.
|
||||||
|
|
||||||
|
## Fremde oder frische Laravel-Projekte nie gegen die lokale Datenbank testen
|
||||||
|
Auf `global-mysql` liegen die Datenbanken mehrerer Projekte, und Container-Umgebungsvariablen haben in Laravel Vorrang vor `.env` und nicht erzwungenen `phpunit.xml`-Werten. Ein frisches Laravel-Projekt hat die SQLite-Zeilen in `phpunit.xml` nur auskommentiert — `RefreshDatabase` führt dann `migrate:fresh` auf der Datenbank aus, die die Umgebung vorgibt (07.10.2026: so wurde `pressekonto` vollständig geleert). Deshalb:
|
||||||
|
- Tests, `migrate`, `migrate:fresh`, `db:wipe` oder Seeder eines anderen bzw. frisch angelegten Laravel-Projekts in diesem Container nur mit `DB_CONNECTION=sqlite DB_DATABASE=:memory: DB_URL=` davor ausführen, z. B. `DB_CONNECTION=sqlite DB_DATABASE=:memory: DB_URL= php artisan test`.
|
||||||
|
- Vorher prüfen, dass `phpunit.xml` dort SQLite `:memory:` mit `force="true"` setzt (siehe `dev/git-deploy-monitoring/02-fehlerprotokoll-glitchtip.md`, „Testdatenbank absichern“).
|
||||||
|
- `php artisan migrate` in diesem Projekt erst nach `php artisan migrate:status`: Stehen unerwartet viele Migrationen aus, ist die Datenbank nicht die erwartete — anhalten und nachfragen.
|
||||||
|
|
||||||
|
## Keine personenbezogenen Daten oder Schlüssel ins Repository
|
||||||
|
Datenbank-Dumps, Produktions-Logs, Postman-Collections, Adresslisten, `.env`-Inhalte und API-Schlüssel nie committen, auch nicht unter `dev/` oder im Archiv. Am 12.05.2026 wurden zwei Produktiv-Dumps committet; Enttracken half nicht, erst die Historienbereinigung vom 09.10.2026 hat sie entfernt (alle Hashes neu, Force-Push, Server-Abgleich). Deshalb:
|
||||||
|
- Vor jedem `git add` prüfen, ob neue Dateien solche Daten enthalten; im Zweifel nachfragen. Solche Arbeitsdateien gehören ins Scratchpad und werden nach Gebrauch gelöscht.
|
||||||
|
- Ist es doch passiert: nicht nur löschen, sondern Betreiber informieren und nach `dev/betrieb/git-deploy.md`, Abschnitt G vorgehen (zuerst Schlüssel rotieren).
|
||||||
|
- Die Historie nie ohne ausdrückliche Freigabe umschreiben oder force-pushen.
|
||||||
7
.ai/rules/index.md
Normal file
7
.ai/rules/index.md
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
# Project Rules Index
|
||||||
|
|
||||||
|
Before planning or editing, find the row whose globs match the file's path and read that rule file.
|
||||||
|
|
||||||
|
| Applies to | Rule file |
|
||||||
|
| --- | --- |
|
||||||
|
| **/* | .ai/rules/general.md |
|
||||||
29
.env.example
29
.env.example
|
|
@ -278,6 +278,14 @@ UMAMI_ENABLED=false
|
||||||
# erst nach Ergänzung der Datenschutzerklärung einschalten.
|
# erst nach Ergänzung der Datenschutzerklärung einschalten.
|
||||||
UMAMI_PORTAL_ENABLED=false
|
UMAMI_PORTAL_ENABLED=false
|
||||||
UMAMI_IDENTIFY_USERS=false
|
UMAMI_IDENTIFY_USERS=false
|
||||||
|
# Nur lesender API-Zugang für Admin ▸ Reports ▸ Wachstum und `umami:report`
|
||||||
|
# (Umami-Benutzer mit der Rolle „View only“, Mitglied im Team mit den drei
|
||||||
|
# Websites). Ohne diese Werte zeigt die Seite die Umami-Kennzahlen als
|
||||||
|
# „nicht verbunden“.
|
||||||
|
UMAMI_API_URL=
|
||||||
|
UMAMI_API_USERNAME=
|
||||||
|
UMAMI_API_PASSWORD=
|
||||||
|
UMAMI_API_TEAM_ID=
|
||||||
|
|
||||||
# Haupt-E-Mail für alle rechtlichen Anliegen (Impressum, Datenschutz, AGB,
|
# Haupt-E-Mail für alle rechtlichen Anliegen (Impressum, Datenschutz, AGB,
|
||||||
# Meldeverfahren, Richtlinien) — auf allen drei Domains identisch.
|
# Meldeverfahren, Richtlinien) — auf allen drei Domains identisch.
|
||||||
|
|
@ -346,3 +354,24 @@ IMAGE_PRECHECK=false
|
||||||
IMAGE_PRECHECK_STAGE=1
|
IMAGE_PRECHECK_STAGE=1
|
||||||
# IMAGE_PRECHECK_MODEL=
|
# IMAGE_PRECHECK_MODEL=
|
||||||
# IMAGE_PRECHECK_DOWNGRADE=
|
# IMAGE_PRECHECK_DOWNGRADE=
|
||||||
|
|
||||||
|
# Proxy-Vertrauen: leer = keinem Proxy (Live, nginx direkt). Lokal ohne Angabe `*` (Traefik).
|
||||||
|
# Steht ein Proxy/CDN davor, dessen Adressbereiche kommagetrennt eintragen.
|
||||||
|
# TRUSTED_PROXIES=
|
||||||
|
# HSTS: ohne Angabe nur bei APP_ENV=production an, max-age 1 Jahr.
|
||||||
|
# SECURITY_HSTS=
|
||||||
|
# SECURITY_HSTS_MAX_AGE=31536000
|
||||||
|
# CSP im Melde-Modus: ohne Angabe nur bei APP_ENV=production an, Meldungen in storage/logs/csp-*.log.
|
||||||
|
# SECURITY_CSP_REPORT_ONLY=
|
||||||
|
|
||||||
|
# Spamschutz mit Cloudflare Turnstile (Kontakt-Widget, Registrierung). Ohne beide Keys aus.
|
||||||
|
# Lokal die Testkeys von Cloudflare verwenden (1x00000000000000000000AA / 1x0000000000000000000000000000000AA),
|
||||||
|
# die echten Keys sind an die Live-Hostnamen gebunden. Ist Cloudflare nicht erreichbar,
|
||||||
|
# wird durchgelassen und markiert; TURNSTILE_FAIL_OPEN=false lehnt stattdessen ab.
|
||||||
|
TURNSTILE_SITE_KEY=
|
||||||
|
TURNSTILE_SECRET_KEY=
|
||||||
|
# TURNSTILE_TIMEOUT=3
|
||||||
|
# TURNSTILE_FAIL_OPEN=true
|
||||||
|
# Unbestätigte Bot-Registrierungen nach 14 Tagen löschen (users:prune-bot-registrations).
|
||||||
|
# Erst einschalten, nachdem der Probelauf auf Live geprüft ist.
|
||||||
|
# USERS_PRUNE_BOT_REGISTRATIONS=false
|
||||||
|
|
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -83,3 +83,8 @@ config/files/
|
||||||
|
|
||||||
# Produktions-Logs zur Auswertung (können personenbezogene Daten enthalten)
|
# Produktions-Logs zur Auswertung (können personenbezogene Daten enthalten)
|
||||||
/dev/**/*.log
|
/dev/**/*.log
|
||||||
|
|
||||||
|
# Nachweise zum Anwaltspaket: Screenshots und Verträge nicht versionieren
|
||||||
|
# (Vertraulich, Kunden- und Organisationsnummern, Größe). Ablage lokal und im Paket.
|
||||||
|
/dev/rechtliches/nachweise/*.png
|
||||||
|
/dev/rechtliches/nachweise/*.pdf
|
||||||
|
|
|
||||||
12
CLAUDE.md
12
CLAUDE.md
|
|
@ -172,12 +172,24 @@ look at GlitchTip or at production errors, follow
|
||||||
workflow and the privacy rules for error reporting. Monitoring decisions:
|
workflow and the privacy rules for error reporting. Monitoring decisions:
|
||||||
`dev/betrieb/monitoring-plan.md`; deploys: `dev/betrieb/deploy-runbook.md`.
|
`dev/betrieb/monitoring-plan.md`; deploys: `dev/betrieb/deploy-runbook.md`.
|
||||||
|
|
||||||
|
### Webanalyse (Umami) and growth figures
|
||||||
|
|
||||||
|
Self-hosted Umami (`sys.adametz.media`, v3 API) is reachable read-only via
|
||||||
|
`UMAMI_API_*` in `.env` (view-only user, websites in the team „Presse
|
||||||
|
Reports“, IDs in `config/domains.php`; never print the credentials or the
|
||||||
|
token). Use `php artisan umami:report` for page views, referrers and
|
||||||
|
channels, and `php artisan growth:report` for the abort conditions of the
|
||||||
|
growth blocks (Roadmap 6.1, thresholds in `config/growth.php`) — the same
|
||||||
|
figures as Admin ▸ Reports ▸ Wachstum. The conversion funnel itself:
|
||||||
|
`php artisan conversion:funnel`.
|
||||||
|
|
||||||
### Testing Framework
|
### Testing Framework
|
||||||
|
|
||||||
- **Pest PHP** (v3.8+) - Modern testing framework built on PHPUnit
|
- **Pest PHP** (v3.8+) - Modern testing framework built on PHPUnit
|
||||||
- Configuration: `phpunit.xml` and `tests/Pest.php`
|
- Configuration: `phpunit.xml` and `tests/Pest.php`
|
||||||
- Test database: Uses `DB_DATABASE=testing` environment (configured in phpunit.xml)
|
- Test database: Uses `DB_DATABASE=testing` environment (configured in phpunit.xml)
|
||||||
- Test directories: `tests/Feature/` (authentication, dashboard, settings) and `tests/Unit/`
|
- Test directories: `tests/Feature/` (authentication, dashboard, settings) and `tests/Unit/`
|
||||||
|
- **Never run tests, migrations or seeders of another or freshly created Laravel project in this container against MySQL.** `global-mysql` holds several projects' databases and container env vars beat `.env`; a fresh project's `RefreshDatabase` wiped `pressekonto` on 07.10.2026. Prefix such runs with `DB_CONNECTION=sqlite DB_DATABASE=:memory: DB_URL=` and check `migrate:status` before `migrate` here (details: `.ai/rules/general.md`).
|
||||||
|
|
||||||
## Important Files & Configuration
|
## Important Files & Configuration
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -212,25 +212,12 @@ paths:
|
||||||
/newsletter/subscribe:
|
/newsletter/subscribe:
|
||||||
post:
|
post:
|
||||||
tags: [Newsletter]
|
tags: [Newsletter]
|
||||||
summary: Subscribe an email address
|
deprecated: true
|
||||||
description: Requires `newsletter:subscribe`.
|
summary: Discontinued — newsletter subscription via the API
|
||||||
requestBody:
|
description: Discontinued on 2026-10-09. Responds with 410 and creates no subscription.
|
||||||
required: true
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/SubscribeNewsletterRequest'
|
|
||||||
responses:
|
responses:
|
||||||
'201':
|
'410':
|
||||||
description: Subscription created.
|
description: Endpoint discontinued.
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/NewsletterSubscriptionResponse'
|
|
||||||
'403':
|
|
||||||
$ref: '#/components/responses/Forbidden'
|
|
||||||
'422':
|
|
||||||
$ref: '#/components/responses/ValidationError'
|
|
||||||
components:
|
components:
|
||||||
securitySchemes:
|
securitySchemes:
|
||||||
sanctumBearer:
|
sanctumBearer:
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
namespace App\Actions\Fortify;
|
namespace App\Actions\Fortify;
|
||||||
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use App\Services\Auth\PasswordChanger;
|
||||||
use Illuminate\Support\Facades\Validator;
|
use Illuminate\Support\Facades\Validator;
|
||||||
use Laravel\Fortify\Contracts\UpdatesUserPasswords;
|
use Laravel\Fortify\Contracts\UpdatesUserPasswords;
|
||||||
|
|
||||||
|
|
@ -12,7 +12,10 @@ class UpdateUserPassword implements UpdatesUserPasswords
|
||||||
use PasswordValidationRules;
|
use PasswordValidationRules;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate and update the user's password.
|
* Validate and update the user's password. Die Fortify-Route dazu ist
|
||||||
|
* abgeschaltet (Sicherheitsaudit 07.10.2026, Entscheidung 4); die Action
|
||||||
|
* nutzt trotzdem den gemeinsamen PasswordChanger, falls sie wieder
|
||||||
|
* aktiviert wird.
|
||||||
*
|
*
|
||||||
* @param array<string, string> $input
|
* @param array<string, string> $input
|
||||||
*/
|
*/
|
||||||
|
|
@ -25,8 +28,6 @@ class UpdateUserPassword implements UpdatesUserPasswords
|
||||||
'current_password.current_password' => __('The provided password does not match your current password.'),
|
'current_password.current_password' => __('The provided password does not match your current password.'),
|
||||||
])->validateWithBag('updatePassword');
|
])->validateWithBag('updatePassword');
|
||||||
|
|
||||||
$user->forceFill([
|
app(PasswordChanger::class)->change($user, $input['password'], request()->hasSession() ? request()->session() : null);
|
||||||
'password' => Hash::make($input['password']),
|
|
||||||
])->save();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
151
app/Console/Commands/AuditAdmins.php
Normal file
151
app/Console/Commands/AuditAdmins.php
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Berechtigungsnachweis BN-02 (Rechtspaket 10/2026): listet jedes Konto, das
|
||||||
|
* über den Kundenbereich hinaus Rechte hat — Super-Admin-Merker, eine Rolle
|
||||||
|
* außer `customer`/`api-only` oder direkt vergebene Permissions —, auch
|
||||||
|
* gesperrte und gelöschte. Ausgabe als Markdown für die Ablage unter
|
||||||
|
* `dev/rechtliches/nachweise/`; jährlich wiederholen.
|
||||||
|
*
|
||||||
|
* Sitzungen liegen auf Live in Redis und lassen sich nicht je Konto zählen;
|
||||||
|
* die Liste zeigt stattdessen den letzten Widerruf (`sessions_revoked_at_ms`).
|
||||||
|
*
|
||||||
|
* E-Mail-Adressen erscheinen standardmäßig nur mit Domain, damit die Datei
|
||||||
|
* ins Repository darf. Der Befehl ändert nichts.
|
||||||
|
*/
|
||||||
|
class AuditAdmins extends Command
|
||||||
|
{
|
||||||
|
/** @var list<string> */
|
||||||
|
private const CUSTOMER_ROLES = ['customer', 'api-only'];
|
||||||
|
|
||||||
|
protected $signature = 'audit:admins
|
||||||
|
{--with-emails : Vollständige E-Mail-Adressen statt nur der Domain ausgeben}';
|
||||||
|
|
||||||
|
protected $description = 'Listet alle Konten mit Admin-, Editor-, Super-Admin- oder Sonderrechten als Markdown (Nachweis BN-02).';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$users = $this->privilegedUsers();
|
||||||
|
$now = now();
|
||||||
|
|
||||||
|
$this->line(sprintf('# Berechtigungen, Stand %s', $now->format('d.m.Y H:i')));
|
||||||
|
$this->newLine();
|
||||||
|
$this->line($this->summary($users, $now));
|
||||||
|
$this->newLine();
|
||||||
|
$this->line('| ID | E-Mail | Rollen | Super-Admin | Aktiv | Gelöscht | Admin-Zugang | TOTP | Passkeys | Letzte Anmeldung | API-Tokens | Sitzungen zuletzt widerrufen |');
|
||||||
|
$this->line('|---|---|---|---|---|---|---|---|---|---|---|---|');
|
||||||
|
|
||||||
|
foreach ($users as $user) {
|
||||||
|
$this->line('| '.implode(' | ', $this->row($user)).' |');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($users->isEmpty()) {
|
||||||
|
$this->line('| – | – | – | – | – | – | – | – | – | – | – | – |');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->newLine();
|
||||||
|
$this->line('Admin-Zugang = aktiv und Super-Admin oder Rolle `admin`/`editor` (`User::canAccessAdmin()`). Sitzungen liegen in Redis und sind je Konto nicht zählbar; ein Widerruf beendet alle älteren Sitzungen.');
|
||||||
|
$this->newLine();
|
||||||
|
$this->line('**Zugänge außerhalb der Anwendung** (Ploi, Hetzner-Konsole, SSH): [eintragen]');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Collection<int, User>
|
||||||
|
*/
|
||||||
|
private function privilegedUsers(): Collection
|
||||||
|
{
|
||||||
|
return User::query()
|
||||||
|
->withTrashed()
|
||||||
|
->with(['roles:id,name', 'permissions:id,name'])
|
||||||
|
->withCount(['passkeys', 'tokens'])
|
||||||
|
->withMax('tokens', 'last_used_at')
|
||||||
|
->where(fn (Builder $query) => $query
|
||||||
|
->where('is_super_admin', true)
|
||||||
|
->orWhereHas('roles', fn (Builder $roles) => $roles->whereNotIn('name', self::CUSTOMER_ROLES))
|
||||||
|
->orWhereHas('permissions'))
|
||||||
|
->orderBy('id')
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<int, User> $users
|
||||||
|
*/
|
||||||
|
private function summary(Collection $users, Carbon $now): string
|
||||||
|
{
|
||||||
|
$withAccess = $users->filter(fn (User $user): bool => ! $user->trashed() && $user->canAccessAdmin());
|
||||||
|
$withSecondFactor = $withAccess->filter(fn (User $user): bool => $this->hasSecondFactor($user));
|
||||||
|
$editors = $withAccess->filter(fn (User $user): bool => $user->roles->contains('name', 'editor'));
|
||||||
|
$superAdmins = $withAccess->where('is_super_admin', true);
|
||||||
|
|
||||||
|
return sprintf(
|
||||||
|
'Stand %s: %d %s mit Zugang zum Admin-Bereich, davon %d mit zweitem Faktor (TOTP oder Passkey), %d Super-Admin, %d Editor-%s. Gelistet %s %d %s mit Rechten über den Kundenbereich hinaus, einschließlich gesperrter und gelöschter.',
|
||||||
|
$now->format('d.m.Y'),
|
||||||
|
$withAccess->count(),
|
||||||
|
$withAccess->count() === 1 ? 'Konto' : 'Konten',
|
||||||
|
$withSecondFactor->count(),
|
||||||
|
$superAdmins->count(),
|
||||||
|
$editors->count(),
|
||||||
|
$editors->count() === 1 ? 'Konto' : 'Konten',
|
||||||
|
$users->count() === 1 ? 'ist' : 'sind',
|
||||||
|
$users->count(),
|
||||||
|
$users->count() === 1 ? 'Konto' : 'Konten',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private function row(User $user): array
|
||||||
|
{
|
||||||
|
$roles = $user->roles->pluck('name')
|
||||||
|
->merge($user->permissions->pluck('name')->map(fn (string $name): string => 'direkt: '.$name))
|
||||||
|
->implode(', ');
|
||||||
|
$revokedAt = $user->sessions_revoked_at_ms
|
||||||
|
? Carbon::createFromTimestampMs($user->sessions_revoked_at_ms)->timezone(config('app.timezone'))->format('d.m.Y H:i')
|
||||||
|
: '–';
|
||||||
|
$tokenLastUsedAt = $user->getAttribute('tokens_max_last_used_at');
|
||||||
|
$tokenLastUsed = $tokenLastUsedAt
|
||||||
|
? ', zuletzt '.Carbon::parse($tokenLastUsedAt)->format('d.m.Y')
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return [
|
||||||
|
(string) $user->id,
|
||||||
|
$this->email($user),
|
||||||
|
$roles !== '' ? $roles : '–',
|
||||||
|
$user->is_super_admin ? 'ja' : 'nein',
|
||||||
|
$user->is_active ? 'ja' : 'gesperrt',
|
||||||
|
$user->trashed() ? $user->deleted_at->format('d.m.Y') : 'nein',
|
||||||
|
! $user->trashed() && $user->canAccessAdmin() ? 'ja' : 'nein',
|
||||||
|
$user->two_factor_confirmed_at ? 'ja' : 'nein',
|
||||||
|
(string) $user->passkeys_count,
|
||||||
|
$user->last_login_at?->format('d.m.Y H:i') ?? '–',
|
||||||
|
$user->tokens_count.$tokenLastUsed,
|
||||||
|
$revokedAt,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function hasSecondFactor(User $user): bool
|
||||||
|
{
|
||||||
|
return $user->two_factor_confirmed_at !== null || $user->passkeys_count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function email(User $user): string
|
||||||
|
{
|
||||||
|
if ($this->option('with-emails')) {
|
||||||
|
return (string) $user->email;
|
||||||
|
}
|
||||||
|
|
||||||
|
return '…@'.Str::after((string) $user->email, '@');
|
||||||
|
}
|
||||||
|
}
|
||||||
54
app/Console/Commands/BackfillChangelog.php
Normal file
54
app/Console/Commands/BackfillChangelog.php
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Enums\ChangelogCategory;
|
||||||
|
use App\Enums\ChangelogStatus;
|
||||||
|
use App\Models\ChangelogEntry;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nachtrag des Changelogs seit dem Relaunch (Decision-Update Changelog, O7)
|
||||||
|
* aus `database/seeders/data/changelog-backfill.php` — als Entwurf, idempotent.
|
||||||
|
*/
|
||||||
|
class BackfillChangelog extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'changelog:backfill {--dry-run : nur zählen, nichts speichern}';
|
||||||
|
|
||||||
|
protected $description = 'Changelog-Einträge seit dem Relaunch als Entwurf nachtragen.';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
/** @var list<array{date: string, tag: string|null, category: string, text: string}> $rows */
|
||||||
|
$rows = require database_path('seeders/data/changelog-backfill.php');
|
||||||
|
$created = 0;
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$key = 'backfill:'.sha1($row['date'].'|'.$row['category'].'|'.$row['text']);
|
||||||
|
|
||||||
|
if (ChangelogEntry::query()->where('source_key', $key)->exists()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$created++;
|
||||||
|
|
||||||
|
if ($this->option('dry-run')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
ChangelogEntry::query()->create([
|
||||||
|
'released_on' => $row['date'],
|
||||||
|
'release_tag' => $row['tag'],
|
||||||
|
'category' => ChangelogCategory::from($row['category']),
|
||||||
|
'text' => $row['text'],
|
||||||
|
'status' => ChangelogStatus::Draft,
|
||||||
|
'source_key' => $key,
|
||||||
|
'review_note' => 'Nachtrag aus Protokoll und Roadmap — Datum und Text prüfen.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info(sprintf('%s%d von %d Einträgen nachgetragen (Entwurf). Freigabe im Admin ▸ Changelog.', $this->option('dry-run') ? '[Dry-Run] ' : '', $created, count($rows)));
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
61
app/Console/Commands/CollectChangelog.php
Normal file
61
app/Console/Commands/CollectChangelog.php
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Services\Changelog\ChangelogCollector;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stufe 2 des Changelogs: Entwürfe aus den Commits eines Releases. Läuft in
|
||||||
|
* `dev/betrieb/deploy.sh` nach `up`; ein Fehler hier bricht keinen Deploy ab.
|
||||||
|
*/
|
||||||
|
class CollectChangelog extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'changelog:collect
|
||||||
|
{from : vorheriger Tag (ausschließlich), z. B. v1.11.0}
|
||||||
|
{to : neuer Tag, z. B. v1.12.0}
|
||||||
|
{--date= : Datum der Einträge (Standard: heute)}
|
||||||
|
{--dry-run : nur anzeigen, nichts speichern}';
|
||||||
|
|
||||||
|
protected $description = 'Changelog-Entwürfe aus den Commit-Zeilen Changelog:/Public: eines Releases anlegen.';
|
||||||
|
|
||||||
|
public function handle(ChangelogCollector $collector): int
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$result = $collector->collect(
|
||||||
|
(string) $this->argument('from'),
|
||||||
|
(string) $this->argument('to'),
|
||||||
|
$this->option('date') !== null ? Carbon::parse((string) $this->option('date')) : null,
|
||||||
|
(bool) $this->option('dry-run'),
|
||||||
|
);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$this->error($e->getMessage());
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info(sprintf(
|
||||||
|
'%s%d neue Entwürfe, %d schon vorhanden, %d Commits ohne Changelog-Zeile (intern).',
|
||||||
|
$this->option('dry-run') ? '[Dry-Run] ' : '',
|
||||||
|
$result['created'],
|
||||||
|
$result['existing'],
|
||||||
|
$result['internal'],
|
||||||
|
));
|
||||||
|
|
||||||
|
if ($result['entries'] !== []) {
|
||||||
|
$this->table(
|
||||||
|
['Rubrik', 'Text', 'Hinweis'],
|
||||||
|
array_map(static fn ($entry): array => [
|
||||||
|
$entry->category->label(),
|
||||||
|
$entry->text,
|
||||||
|
$entry->review_note ?? '',
|
||||||
|
], $result['entries']),
|
||||||
|
);
|
||||||
|
$this->line(' Freigabe im Admin ▸ Changelog.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
145
app/Console/Commands/PruneBotRegistrations.php
Normal file
145
app/Console/Commands/PruneBotRegistrations.php
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Spam\GibberishDetector;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Query\Builder as QueryBuilder;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Löscht Bot-Registrierungen (Spamschutz B4): 389 Konten in 30 Tagen bis
|
||||||
|
* 07.10.2026, jedes mit einer Bestätigungsmail an eine fremde Adresse.
|
||||||
|
*
|
||||||
|
* Gelöscht wird nur, was über das Registrierungsformular entstand und nie
|
||||||
|
* genutzt wurde — alle Bedingungen zugleich:
|
||||||
|
* - keine Legacy-ID (66.870 unbestätigte Altkonten bleiben unberührt),
|
||||||
|
* - E-Mail nie bestätigt, Konto inaktiv, nie angemeldet, kein Google-Login,
|
||||||
|
* - älter als die Frist (Standard 14 Tage),
|
||||||
|
* - keine Firma, kein Kontakt, keine Meldung, keine Zahlung, kein Token.
|
||||||
|
*/
|
||||||
|
class PruneBotRegistrations extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'users:prune-bot-registrations
|
||||||
|
{--days= : Frist in Tagen (Standard: config auth.prune_bot_registrations.after_days)}
|
||||||
|
{--dry-run : Nur auflisten, nichts löschen}
|
||||||
|
{--limit=2000 : Höchstzahl je Lauf}';
|
||||||
|
|
||||||
|
protected $description = 'Löscht unbestätigte, nie genutzte Konten aus dem Registrierungsformular (Bot-Registrierungen).';
|
||||||
|
|
||||||
|
/** Tabellen, deren Einträge ein Konto als genutzt ausweisen. */
|
||||||
|
private const USAGE_TABLES = [
|
||||||
|
'company_user',
|
||||||
|
'contact_user',
|
||||||
|
'press_releases',
|
||||||
|
'invoices',
|
||||||
|
'legacy_invoices',
|
||||||
|
'subscriptions',
|
||||||
|
'single_purchases',
|
||||||
|
'credit_transactions',
|
||||||
|
'credit_topups',
|
||||||
|
'booking_confirmations',
|
||||||
|
'boosts',
|
||||||
|
'billing_addresses',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$days = (int) ($this->option('days') ?? config('auth.prune_bot_registrations.after_days', 14));
|
||||||
|
|
||||||
|
if ($days < 7) {
|
||||||
|
$this->error('Die Frist muss mindestens 7 Tage betragen.');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$candidates = $this->candidates($days)->limit((int) $this->option('limit'))->get();
|
||||||
|
|
||||||
|
if ($candidates->isEmpty()) {
|
||||||
|
$this->info('Keine Bot-Registrierungen zu löschen.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->option('dry-run')) {
|
||||||
|
$this->table(
|
||||||
|
['ID', 'Angelegt', 'Name', 'E-Mail', 'Zufallsname'],
|
||||||
|
$candidates->map(fn (User $user): array => [
|
||||||
|
$user->id,
|
||||||
|
$user->created_at?->format('d.m.Y H:i'),
|
||||||
|
Str::limit((string) $user->name, 30),
|
||||||
|
$this->maskEmail((string) $user->email),
|
||||||
|
GibberishDetector::isRandomPersonName((string) $user->name) ? 'ja' : '',
|
||||||
|
])->all(),
|
||||||
|
);
|
||||||
|
$this->info(sprintf(
|
||||||
|
'Probelauf: %d Konten älter als %d Tage würden gelöscht, davon %d mit Zufallsnamen.',
|
||||||
|
$candidates->count(),
|
||||||
|
$days,
|
||||||
|
$candidates->filter(fn (User $user): bool => GibberishDetector::isRandomPersonName((string) $user->name))->count(),
|
||||||
|
));
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$deleted = 0;
|
||||||
|
$failed = 0;
|
||||||
|
|
||||||
|
foreach ($candidates as $user) {
|
||||||
|
try {
|
||||||
|
DB::transaction(fn () => $user->forceDelete());
|
||||||
|
$deleted++;
|
||||||
|
} catch (\Throwable $exception) {
|
||||||
|
$failed++;
|
||||||
|
Log::channel('spam')->warning('Bot-Registrierung konnte nicht gelöscht werden.', [
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'error' => $exception->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::channel('spam')->info('Bot-Registrierungen gelöscht.', ['deleted' => $deleted, 'failed' => $failed, 'days' => $days]);
|
||||||
|
$this->info("Bot-Registrierungen gelöscht: {$deleted}".($failed > 0 ? ", {$failed} fehlgeschlagen (siehe spam-Log)" : '').'.');
|
||||||
|
|
||||||
|
return $failed > 0 ? self::FAILURE : self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Builder<User>
|
||||||
|
*/
|
||||||
|
private function candidates(int $days): Builder
|
||||||
|
{
|
||||||
|
$query = User::query()
|
||||||
|
->whereNull('legacy_id')
|
||||||
|
->whereNull('email_verified_at')
|
||||||
|
->whereNull('last_login_at')
|
||||||
|
->whereNull('oauth_provider')
|
||||||
|
->where('is_active', false)
|
||||||
|
->where('is_super_admin', false)
|
||||||
|
->where('created_at', '<', now()->subDays($days))
|
||||||
|
->whereDoesntHave('roles')
|
||||||
|
->whereNotExists(fn (QueryBuilder $query) => $query
|
||||||
|
->from('personal_access_tokens')
|
||||||
|
->whereColumn('personal_access_tokens.tokenable_id', 'users.id')
|
||||||
|
->where('personal_access_tokens.tokenable_type', (new User)->getMorphClass()));
|
||||||
|
|
||||||
|
foreach (self::USAGE_TABLES as $table) {
|
||||||
|
$query->whereNotExists(fn (QueryBuilder $sub) => $sub
|
||||||
|
->from($table)
|
||||||
|
->whereColumn($table.'.user_id', 'users.id'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query->orderBy('id');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function maskEmail(string $email): string
|
||||||
|
{
|
||||||
|
[$local, $domain] = array_pad(explode('@', $email, 2), 2, '');
|
||||||
|
|
||||||
|
return Str::substr($local, 0, 2).'***@'.$domain;
|
||||||
|
}
|
||||||
|
}
|
||||||
60
app/Console/Commands/QuarantineContactSpam.php
Normal file
60
app/Console/Commands/QuarantineContactSpam.php
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\ContactMessage;
|
||||||
|
use App\Services\Spam\ContactSpamFilter;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Einmalig für den Altbestand (Spamschutz, Entscheidung 2): offene
|
||||||
|
* Kontakt-Anliegen von Gästen nach denselben Regeln wie beim Eingang in die
|
||||||
|
* Spam-Quarantäne legen. Was die Regeln nicht erkennen, markiert der Admin
|
||||||
|
* über die Sammelaktion der Kontakt-Queue.
|
||||||
|
*/
|
||||||
|
class QuarantineContactSpam extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'contact-messages:quarantine-spam {--dry-run : Nur auflisten, nichts ändern}';
|
||||||
|
|
||||||
|
protected $description = 'Legt offene Kontakt-Anliegen von Gästen, die der Spamfilter erkennt, in die Quarantäne.';
|
||||||
|
|
||||||
|
public function handle(ContactSpamFilter $filter): int
|
||||||
|
{
|
||||||
|
$hits = ContactMessage::query()
|
||||||
|
->open()
|
||||||
|
->whereNull('user_id')
|
||||||
|
->orderBy('id')
|
||||||
|
->get()
|
||||||
|
->map(fn (ContactMessage $message): array => [
|
||||||
|
'message' => $message,
|
||||||
|
// Ohne Zeitstempel: Der Altbestand hatte kein Formular-Feld dafür.
|
||||||
|
'reason' => $filter->reasonFor((string) $message->name, (string) $message->message, null),
|
||||||
|
])
|
||||||
|
->filter(fn (array $hit): bool => $hit['reason'] !== null);
|
||||||
|
|
||||||
|
$this->table(
|
||||||
|
['Vorgang', 'Eingang', 'Grund', 'Nachricht'],
|
||||||
|
$hits->map(fn (array $hit): array => [
|
||||||
|
$hit['message']->reference(),
|
||||||
|
$hit['message']->created_at?->format('d.m.Y'),
|
||||||
|
$hit['reason'],
|
||||||
|
Str::limit(Str::squish((string) $hit['message']->message), 60),
|
||||||
|
])->all(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($this->option('dry-run')) {
|
||||||
|
$this->info("Probelauf: {$hits->count()} Anliegen würden als Spam markiert.");
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($hits as $hit) {
|
||||||
|
$hit['message']->markAsSpam(null, $hit['reason']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("{$hits->count()} Anliegen als Spam markiert. Offen bleiben: ".ContactMessage::query()->open()->count().'.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
74
app/Console/Commands/ReportGrowth.php
Normal file
74
app/Console/Commands/ReportGrowth.php
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Services\Growth\GrowthReport;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stand der Abbruchbedingungen für Block 1 bis 3 (Roadmap 6.1) — dieselben
|
||||||
|
* Zahlen wie Admin ▸ Reports ▸ Wachstum.
|
||||||
|
*/
|
||||||
|
class ReportGrowth extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'growth:report
|
||||||
|
{--fresh : Umami-Werte neu abfragen statt aus dem Zwischenspeicher}
|
||||||
|
{--json : Ausgabe als JSON statt als Tabelle}';
|
||||||
|
|
||||||
|
protected $description = 'Abbruchbedingungen der Wachstumsblöcke: Ist, Schwelle, Status.';
|
||||||
|
|
||||||
|
public function handle(GrowthReport $report): int
|
||||||
|
{
|
||||||
|
$data = $report->build((bool) $this->option('fresh'));
|
||||||
|
|
||||||
|
if ($this->option('json')) {
|
||||||
|
$this->line((string) json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$period = $data['period'];
|
||||||
|
$this->info(sprintf(
|
||||||
|
'Wachstum — Zeitraum %s bis %s, Zwischenblick %s, %d %% der Zeit vergangen',
|
||||||
|
$period['start']->format('d.m.Y'),
|
||||||
|
$period['end']->format('d.m.Y'),
|
||||||
|
$period['review']->format('d.m.Y'),
|
||||||
|
(int) round($period['elapsed_share'] * 100),
|
||||||
|
));
|
||||||
|
|
||||||
|
foreach ($data['blocks'] as $block) {
|
||||||
|
$this->newLine();
|
||||||
|
$this->line("<comment>{$block['title']}</comment>");
|
||||||
|
|
||||||
|
$this->table(
|
||||||
|
['Bedingung', 'Ist', 'Soll heute', 'Schwelle', 'Status'],
|
||||||
|
array_map(static fn (array $criterion): array => [
|
||||||
|
$criterion['label'],
|
||||||
|
$criterion['actual'] ?? '–',
|
||||||
|
$criterion['expected'] ?? '–',
|
||||||
|
$criterion['threshold'],
|
||||||
|
GrowthReport::statusLabel($criterion['status']),
|
||||||
|
], $block['criteria']),
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach ($block['criteria'] as $criterion) {
|
||||||
|
if ($criterion['note'] !== null) {
|
||||||
|
$this->line(' '.$criterion['note']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->line(' '.$block['consequence']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($data['pages'] !== []) {
|
||||||
|
$this->newLine();
|
||||||
|
$this->line('<comment>Transparenzseiten, letzte 30 Tage (Umami)</comment>');
|
||||||
|
$this->table(
|
||||||
|
['Portal', 'Seite', 'Aufrufe', 'aus der Suche'],
|
||||||
|
array_map(static fn (array $row): array => [$row['brand'], $row['page'], $row['views'], $row['search_views']], $data['pages']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
103
app/Console/Commands/ReportUmami.php
Normal file
103
app/Console/Commands/ReportUmami.php
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Services\Analytics\UmamiClient;
|
||||||
|
use App\Services\Analytics\UmamiUnavailable;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nur lesende Abfrage der Umami-Webanalyse (Roadmap 6.16): Kennzahlen je
|
||||||
|
* Website, dazu eine Rangliste (Seiten, Herkunft, Kanäle …), optional auf
|
||||||
|
* Pfade gefiltert. Zugangsdaten aus `UMAMI_API_*`, werden nie ausgegeben.
|
||||||
|
*/
|
||||||
|
class ReportUmami extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'umami:report
|
||||||
|
{--from= : Startdatum (Standard: vor 30 Tagen)}
|
||||||
|
{--to= : Enddatum (Standard: jetzt)}
|
||||||
|
{--site=* : pressekonto, presseecho, businessportal24 (Standard: alle)}
|
||||||
|
{--path= : nur Pfade, die diesen Text enthalten, z. B. gute-pressemitteilung}
|
||||||
|
{--metric=channel : Rangliste nach path, referrer, channel, country, device, browser, event}
|
||||||
|
{--limit=10 : Zeilen der Rangliste}
|
||||||
|
{--json : Ausgabe als JSON statt als Tabelle}';
|
||||||
|
|
||||||
|
protected $description = 'Umami-Kennzahlen je Website (nur lesend).';
|
||||||
|
|
||||||
|
private const array METRICS = ['path', 'referrer', 'channel', 'country', 'device', 'browser', 'os', 'event', 'title', 'entry', 'exit'];
|
||||||
|
|
||||||
|
public function handle(UmamiClient $umami): int
|
||||||
|
{
|
||||||
|
if (! $umami->isConfigured()) {
|
||||||
|
$this->error('Umami-API nicht konfiguriert: UMAMI_API_URL, UMAMI_API_USERNAME und UMAMI_API_PASSWORD setzen.');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$metric = (string) $this->option('metric');
|
||||||
|
|
||||||
|
if (! in_array($metric, self::METRICS, true)) {
|
||||||
|
$this->error('Unbekannte Rangliste. Möglich: '.implode(', ', self::METRICS).'.');
|
||||||
|
|
||||||
|
return self::INVALID;
|
||||||
|
}
|
||||||
|
|
||||||
|
$to = $this->option('to') !== null ? Carbon::parse((string) $this->option('to'))->endOfDay() : Carbon::now();
|
||||||
|
$from = $this->option('from') !== null ? Carbon::parse((string) $this->option('from'))->startOfDay() : $to->copy()->subDays(30);
|
||||||
|
$sites = (array) $this->option('site') ?: UmamiClient::BRANDS;
|
||||||
|
$filters = filled($this->option('path')) ? ['path' => 'c.'.$this->option('path')] : [];
|
||||||
|
$limit = max(1, min(500, (int) $this->option('limit')));
|
||||||
|
|
||||||
|
$result = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
foreach ($sites as $site) {
|
||||||
|
$websiteId = $umami->websiteId((string) $site);
|
||||||
|
|
||||||
|
if ($websiteId === null) {
|
||||||
|
$this->warn("Keine Umami-Website für „{$site}“ in config/domains.php.");
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result[$site] = [
|
||||||
|
'stats' => $umami->stats($websiteId, $from, $to, $filters),
|
||||||
|
$metric => $umami->metrics($websiteId, $metric, $from, $to, $filters, $limit),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
} catch (UmamiUnavailable $e) {
|
||||||
|
$this->error($e->getMessage());
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->option('json')) {
|
||||||
|
$this->line((string) json_encode([
|
||||||
|
'from' => $from->toIso8601String(),
|
||||||
|
'to' => $to->toIso8601String(),
|
||||||
|
'filters' => $filters,
|
||||||
|
'sites' => $result,
|
||||||
|
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info(sprintf('Umami — %s bis %s%s', $from->format('d.m.Y'), $to->format('d.m.Y'), $filters === [] ? '' : ', Pfad enthält „'.$this->option('path').'“'));
|
||||||
|
|
||||||
|
foreach ($result as $site => $data) {
|
||||||
|
$this->newLine();
|
||||||
|
$this->line("<comment>{$site}</comment>");
|
||||||
|
$this->table(
|
||||||
|
['Aufrufe', 'Besucher', 'Besuche', 'Absprünge'],
|
||||||
|
[[$data['stats']['pageviews'], $data['stats']['visitors'], $data['stats']['visits'], $data['stats']['bounces']]],
|
||||||
|
);
|
||||||
|
$this->table(
|
||||||
|
[ucfirst($metric), 'Anzahl'],
|
||||||
|
array_map(static fn (array $row): array => [$row['x'], $row['y']], $data[$metric]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
107
app/Console/Commands/ResyncAutomaticMemberships.php
Normal file
107
app/Console/Commands/ResyncAutomaticMemberships.php
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Auth\ContactAccessService;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bewertet automatisch vergebene Firmenrechte (`granted_via` = auto bzw.
|
||||||
|
* auto-upgrade) aller betroffenen User neu (Sicherheitsaudit 07.10.2026,
|
||||||
|
* S-03). Der CompanyMembershipObserver erledigt das bei jeder Änderung über
|
||||||
|
* Eloquent; dieses Kommando bereinigt den Altbestand und repariert nach
|
||||||
|
* Massen-Updates per Query-Builder.
|
||||||
|
*
|
||||||
|
* Manuelle (`manual`) und API-Mitgliedschaften (`api`) bleiben unberührt.
|
||||||
|
*
|
||||||
|
* Sicherung (nach dem Abgleich vom 07.10.2026, v1.4.1): Der Lauf geschieht in
|
||||||
|
* einer Transaktion. `--dry-run` rollt immer zurück und zeigt nur die Zahlen.
|
||||||
|
* Würde mehr als MAX_REVOKE_SHARE der Rechte (mindestens MAX_REVOKE_ABSOLUTE)
|
||||||
|
* wegfallen, rollt das Kommando zurück, solange nicht `--force` gesetzt ist —
|
||||||
|
* ein solcher Einbruch deutet auf einen Fehler, nicht auf veraltete Rechte.
|
||||||
|
*/
|
||||||
|
class ResyncAutomaticMemberships extends Command
|
||||||
|
{
|
||||||
|
private const MAX_REVOKE_SHARE = 0.05;
|
||||||
|
|
||||||
|
private const MAX_REVOKE_ABSOLUTE = 50;
|
||||||
|
|
||||||
|
protected $signature = 'memberships:resync-auto
|
||||||
|
{--user= : Nur diesen User (ID) abgleichen}
|
||||||
|
{--dry-run : Nur zählen, nichts speichern}
|
||||||
|
{--force : Auch bei auffällig vielen Entzügen speichern}';
|
||||||
|
|
||||||
|
protected $description = 'Gleicht automatisch vergebene Firmenrechte mit den aktuellen Kontakt- und Firmenadressen ab.';
|
||||||
|
|
||||||
|
public function handle(ContactAccessService $contactAccess): int
|
||||||
|
{
|
||||||
|
$before = $this->countAutomaticGrants();
|
||||||
|
|
||||||
|
$userIds = DB::table('company_user')
|
||||||
|
->whereIn('granted_via', ['auto', 'auto-upgrade'])
|
||||||
|
->when($this->option('user'), fn ($query, $userId) => $query->where('user_id', (int) $userId))
|
||||||
|
->distinct()
|
||||||
|
->pluck('user_id');
|
||||||
|
|
||||||
|
if ($userIds->isEmpty()) {
|
||||||
|
$this->info('Keine automatisch vergebenen Firmenrechte gefunden — nichts zu tun.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::beginTransaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$bar = $this->output->createProgressBar($userIds->count());
|
||||||
|
|
||||||
|
foreach ($userIds->chunk(200) as $chunk) {
|
||||||
|
User::query()->whereIn('id', $chunk->all())->get()->each(function (User $user) use ($contactAccess, $bar): void {
|
||||||
|
$contactAccess->syncMembershipsForEmail($user);
|
||||||
|
$bar->advance();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$bar->finish();
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
$after = $this->countAutomaticGrants();
|
||||||
|
$revoked = max(0, $before - $after);
|
||||||
|
$summary = "Abgeglichen: {$userIds->count()} User. Automatische Rechte vorher {$before}, nachher {$after}.";
|
||||||
|
|
||||||
|
if ($this->option('dry-run')) {
|
||||||
|
DB::rollBack();
|
||||||
|
$this->info($summary.' Probelauf — nichts gespeichert.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$limit = max(self::MAX_REVOKE_ABSOLUTE, (int) ceil($before * self::MAX_REVOKE_SHARE));
|
||||||
|
|
||||||
|
if ($revoked > $limit && ! $this->option('force')) {
|
||||||
|
DB::rollBack();
|
||||||
|
$this->error($summary." Es würden {$revoked} Rechte wegfallen (Grenze {$limit}) — zurückgerollt. Ursache prüfen, nur bei Absicht mit --force wiederholen.");
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::commit();
|
||||||
|
$this->info($summary);
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
DB::rollBack();
|
||||||
|
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function countAutomaticGrants(): int
|
||||||
|
{
|
||||||
|
return DB::table('company_user')
|
||||||
|
->where('role', 'responsible')
|
||||||
|
->whereIn('granted_via', ['auto', 'auto-upgrade'])
|
||||||
|
->count();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -20,7 +20,7 @@ use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bereinigt die Altlast aus unbezahlten Legacy-Rechnungen (Einordnung
|
* Bereinigt die Altlast aus unbezahlten Legacy-Rechnungen (Einordnung
|
||||||
* 16.09.2026, dev/lagacy-rechnungen/entwicklungsplan-2026-09-16.md, Phase B).
|
* 16.09.2026, dev/archive/legacy-rechnungen/entwicklungsplan-2026-09-16.md, Phase B).
|
||||||
*
|
*
|
||||||
* `legacy:grandfather-subscriptions` hat Bestandsschutz-Posten auch aus
|
* `legacy:grandfather-subscriptions` hat Bestandsschutz-Posten auch aus
|
||||||
* Rechnungen abgeleitet, die nie bezahlt wurden. Dieser Befehl löst das
|
* Rechnungen abgeleitet, die nie bezahlt wurden. Dieser Befehl löst das
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,12 @@ enum ActivityType: string
|
||||||
|
|
||||||
case TwoFactorFailed = 'two_factor_failed';
|
case TwoFactorFailed = 'two_factor_failed';
|
||||||
|
|
||||||
|
/** Cloudflare Turnstile war nicht erreichbar — Formular durchgelassen, bitte nachprüfen (Spamschutz). */
|
||||||
|
case BotCheckUnavailable = 'bot_check_unavailable';
|
||||||
|
|
||||||
|
/** Admin hat eine gebuchte Platzierung vorzeitig beendet. */
|
||||||
|
case BoostEnded = 'boost_ended';
|
||||||
|
|
||||||
public function label(): string
|
public function label(): string
|
||||||
{
|
{
|
||||||
return match ($this) {
|
return match ($this) {
|
||||||
|
|
@ -73,6 +79,8 @@ enum ActivityType: string
|
||||||
self::LoginFailed => 'Anmeldung fehlgeschlagen',
|
self::LoginFailed => 'Anmeldung fehlgeschlagen',
|
||||||
self::LoginLockout => 'Anmeldesperre',
|
self::LoginLockout => 'Anmeldesperre',
|
||||||
self::TwoFactorFailed => '2FA fehlgeschlagen',
|
self::TwoFactorFailed => '2FA fehlgeschlagen',
|
||||||
|
self::BotCheckUnavailable => 'Ohne Bot-Prüfung',
|
||||||
|
self::BoostEnded => 'Boost beendet',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -102,6 +110,8 @@ enum ActivityType: string
|
||||||
self::ManualInvoice => 'amber',
|
self::ManualInvoice => 'amber',
|
||||||
self::LoginFailed, self::TwoFactorFailed => 'orange',
|
self::LoginFailed, self::TwoFactorFailed => 'orange',
|
||||||
self::LoginLockout => 'red',
|
self::LoginLockout => 'red',
|
||||||
|
self::BotCheckUnavailable => 'orange',
|
||||||
|
self::BoostEnded => 'amber',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
27
app/Enums/BotCheckStatus.php
Normal file
27
app/Enums/BotCheckStatus.php
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ergebnis der Bot-Prüfung (Cloudflare Turnstile) eines öffentlichen
|
||||||
|
* Formulars, siehe TurnstileVerifier.
|
||||||
|
*/
|
||||||
|
enum BotCheckStatus: string
|
||||||
|
{
|
||||||
|
/** Cloudflare hat den Menschen bestätigt. */
|
||||||
|
case Passed = 'passed';
|
||||||
|
|
||||||
|
/** Token fehlt, ist ungültig, abgelaufen oder gehört zu einem anderen Formular. */
|
||||||
|
case Failed = 'failed';
|
||||||
|
|
||||||
|
/** Cloudflare war nicht erreichbar — durchgelassen und zur Nachprüfung markiert. */
|
||||||
|
case Unavailable = 'unavailable';
|
||||||
|
|
||||||
|
/** Prüfung abgeschaltet (keine Keys, z. B. in Tests). */
|
||||||
|
case Skipped = 'skipped';
|
||||||
|
|
||||||
|
public function blocks(): bool
|
||||||
|
{
|
||||||
|
return $this === self::Failed;
|
||||||
|
}
|
||||||
|
}
|
||||||
48
app/Enums/ChangelogCategory.php
Normal file
48
app/Enums/ChangelogCategory.php
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rubriken des Changelogs (Decision-Update Changelog, E3). Sicherheit
|
||||||
|
* erscheint nur als Sammelzeile, nie mit Beschreibung (E5).
|
||||||
|
*/
|
||||||
|
enum ChangelogCategory: string
|
||||||
|
{
|
||||||
|
case New = 'neu';
|
||||||
|
case Improved = 'verbessert';
|
||||||
|
case Fixed = 'behoben';
|
||||||
|
case Security = 'sicherheit';
|
||||||
|
|
||||||
|
public const string SECURITY_LINE = 'Sicherheitsverbesserungen eingespielt.';
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::New => 'Neu',
|
||||||
|
self::Improved => 'Verbessert',
|
||||||
|
self::Fixed => 'Behoben',
|
||||||
|
self::Security => 'Sicherheit',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reihenfolge innerhalb eines Tages.
|
||||||
|
*/
|
||||||
|
public function order(): int
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::New => 1,
|
||||||
|
self::Improved => 2,
|
||||||
|
self::Fixed => 3,
|
||||||
|
self::Security => 4,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wert aus der Commit-Zeile `Changelog: …` — Groß-/Kleinschreibung egal.
|
||||||
|
*/
|
||||||
|
public static function fromTrailer(string $value): ?self
|
||||||
|
{
|
||||||
|
return self::tryFrom(mb_strtolower(trim($value)));
|
||||||
|
}
|
||||||
|
}
|
||||||
28
app/Enums/ChangelogStatus.php
Normal file
28
app/Enums/ChangelogStatus.php
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
enum ChangelogStatus: string
|
||||||
|
{
|
||||||
|
case Draft = 'draft';
|
||||||
|
case Published = 'published';
|
||||||
|
case Discarded = 'discarded';
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::Draft => 'Entwurf',
|
||||||
|
self::Published => 'Freigegeben',
|
||||||
|
self::Discarded => 'Verworfen',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function badgeColor(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::Draft => 'amber',
|
||||||
|
self::Published => 'green',
|
||||||
|
self::Discarded => 'zinc',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,9 @@ enum ContactMessageStatus: string
|
||||||
case Resolved = 'resolved';
|
case Resolved = 'resolved';
|
||||||
case Rejected = 'rejected';
|
case Rejected = 'rejected';
|
||||||
|
|
||||||
|
/** Quarantäne des Spamschutzes: keine Mail, nicht im Zähler, nach 30 Tagen gelöscht. */
|
||||||
|
case Spam = 'spam';
|
||||||
|
|
||||||
public function label(): string
|
public function label(): string
|
||||||
{
|
{
|
||||||
return match ($this) {
|
return match ($this) {
|
||||||
|
|
@ -16,6 +19,7 @@ enum ContactMessageStatus: string
|
||||||
self::InProgress => 'In Bearbeitung',
|
self::InProgress => 'In Bearbeitung',
|
||||||
self::Resolved => 'Erledigt',
|
self::Resolved => 'Erledigt',
|
||||||
self::Rejected => 'Abgelehnt',
|
self::Rejected => 'Abgelehnt',
|
||||||
|
self::Spam => 'Spam',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,11 +12,21 @@ namespace App\Enums;
|
||||||
* Die Wiedereinreichung nach Ablehnung gehört ausdrücklich NICHT hierher. Sie
|
* Die Wiedereinreichung nach Ablehnung gehört ausdrücklich NICHT hierher. Sie
|
||||||
* misst etwas anderes, nämlich ob die Infoseite (1d) wirkt, und wird gegen den
|
* misst etwas anderes, nämlich ob die Infoseite (1d) wirkt, und wird gegen den
|
||||||
* Basiswert aus 0.2 separat geführt (`press-releases:rejection-rate`).
|
* Basiswert aus 0.2 separat geführt (`press-releases:rejection-rate`).
|
||||||
|
*
|
||||||
|
* Stufen 2 und 3 seit 13.10.2026 (Roadmap 6.16): Vorher sprang die Leiter vom
|
||||||
|
* Klick auf IRGENDEINEN Mail-Link direkt zur Einlösung. Ob jemand die
|
||||||
|
* Verwaltungsseite überhaupt geöffnet und den Link angefordert hatte, war
|
||||||
|
* nicht zu sehen — und damit nicht, ob es am Hinweis, an der Seite oder an
|
||||||
|
* der Zustellung hakt.
|
||||||
*/
|
*/
|
||||||
enum ConversionEventType: string
|
enum ConversionEventType: string
|
||||||
{
|
{
|
||||||
case MailLinkClicked = 'mail_link_clicked';
|
case MailLinkClicked = 'mail_link_clicked';
|
||||||
|
|
||||||
|
case ManagePageOpened = 'manage_page_opened';
|
||||||
|
|
||||||
|
case AccessRequested = 'access_requested';
|
||||||
|
|
||||||
case MagicLinkConsumed = 'magic_link_consumed';
|
case MagicLinkConsumed = 'magic_link_consumed';
|
||||||
|
|
||||||
case NewsroomEdited = 'newsroom_edited';
|
case NewsroomEdited = 'newsroom_edited';
|
||||||
|
|
@ -32,10 +42,12 @@ enum ConversionEventType: string
|
||||||
{
|
{
|
||||||
return match ($this) {
|
return match ($this) {
|
||||||
self::MailLinkClicked => 1,
|
self::MailLinkClicked => 1,
|
||||||
self::MagicLinkConsumed => 2,
|
self::ManagePageOpened => 2,
|
||||||
self::NewsroomEdited => 3,
|
self::AccessRequested => 3,
|
||||||
self::AccountCreated => 4,
|
self::MagicLinkConsumed => 4,
|
||||||
self::FirstBooking => 5,
|
self::NewsroomEdited => 5,
|
||||||
|
self::AccountCreated => 6,
|
||||||
|
self::FirstBooking => 7,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -43,6 +55,8 @@ enum ConversionEventType: string
|
||||||
{
|
{
|
||||||
return match ($this) {
|
return match ($this) {
|
||||||
self::MailLinkClicked => 'Mail-Link geklickt',
|
self::MailLinkClicked => 'Mail-Link geklickt',
|
||||||
|
self::ManagePageOpened => 'Verwaltungsseite geöffnet',
|
||||||
|
self::AccessRequested => 'Anmeldelink angefordert',
|
||||||
self::MagicLinkConsumed => 'Magic Link eingelöst',
|
self::MagicLinkConsumed => 'Magic Link eingelöst',
|
||||||
self::NewsroomEdited => 'Newsroom bearbeitet',
|
self::NewsroomEdited => 'Newsroom bearbeitet',
|
||||||
self::AccountCreated => 'Konto angelegt',
|
self::AccountCreated => 'Konto angelegt',
|
||||||
|
|
@ -58,7 +72,9 @@ enum ConversionEventType: string
|
||||||
{
|
{
|
||||||
return match ($this) {
|
return match ($this) {
|
||||||
self::MailLinkClicked => 'Betreff oder Anlass trägt nicht',
|
self::MailLinkClicked => 'Betreff oder Anlass trägt nicht',
|
||||||
self::MagicLinkConsumed => 'Der Verwaltungszugang wird nicht verstanden',
|
self::ManagePageOpened => 'Der Hinweis auf die Verwaltung wird übersehen',
|
||||||
|
self::AccessRequested => 'Der Verwaltungszugang wird nicht verstanden',
|
||||||
|
self::MagicLinkConsumed => 'Der Link kommt nicht an oder wird nicht geöffnet',
|
||||||
self::NewsroomEdited => 'Kein Anreiz, das Profil zu pflegen',
|
self::NewsroomEdited => 'Kein Anreiz, das Profil zu pflegen',
|
||||||
self::AccountCreated => 'Der Nutzen des Kontos ist nicht erkennbar',
|
self::AccountCreated => 'Der Nutzen des Kontos ist nicht erkennbar',
|
||||||
self::FirstBooking => 'Preis oder Angebot passt nicht',
|
self::FirstBooking => 'Preis oder Angebot passt nicht',
|
||||||
|
|
|
||||||
51
app/Enums/SecurityChange.php
Normal file
51
app/Enums/SecurityChange.php
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sicherheitsrelevante Kontoänderung, über die der Kontoinhaber an seine
|
||||||
|
* bisherige Adresse informiert wird (Sicherheitsaudit 07.10.2026, S-06).
|
||||||
|
*/
|
||||||
|
enum SecurityChange: string
|
||||||
|
{
|
||||||
|
case PasswordChanged = 'password_changed';
|
||||||
|
|
||||||
|
case PasswordReset = 'password_reset';
|
||||||
|
|
||||||
|
case EmailChanged = 'email_changed';
|
||||||
|
|
||||||
|
case TwoFactorEnabled = 'two_factor_enabled';
|
||||||
|
|
||||||
|
case TwoFactorDisabled = 'two_factor_disabled';
|
||||||
|
|
||||||
|
case GoogleLinked = 'google_linked';
|
||||||
|
|
||||||
|
case PasskeyAdded = 'passkey_added';
|
||||||
|
|
||||||
|
case PasskeyRemoved = 'passkey_removed';
|
||||||
|
|
||||||
|
case SecondFactorsReset = 'second_factors_reset';
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::PasswordChanged => 'Ihr Passwort wurde geändert.',
|
||||||
|
self::PasswordReset => 'Ihr Passwort wurde über „Passwort vergessen“ neu gesetzt.',
|
||||||
|
self::EmailChanged => 'Die Login-Adresse Ihres Kontos wurde geändert.',
|
||||||
|
self::TwoFactorEnabled => 'Die Zwei-Faktor-Authentifizierung wurde aktiviert.',
|
||||||
|
self::TwoFactorDisabled => 'Die Zwei-Faktor-Authentifizierung wurde deaktiviert.',
|
||||||
|
self::GoogleLinked => 'Ein Google-Konto wurde mit Ihrem Konto verknüpft.',
|
||||||
|
self::PasskeyAdded => 'Ein neuer Passkey wurde zu Ihrem Konto hinzugefügt.',
|
||||||
|
self::PasskeyRemoved => 'Ein Passkey wurde aus Ihrem Konto entfernt.',
|
||||||
|
self::SecondFactorsReset => 'Ein Administrator hat Ihre Passkeys und Ihre Zwei-Faktor-Authentifizierung zurückgesetzt.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ob andere angemeldete Geräte dabei abgemeldet wurden.
|
||||||
|
*/
|
||||||
|
public function endsOtherSessions(): bool
|
||||||
|
{
|
||||||
|
return in_array($this, [self::PasswordChanged, self::PasswordReset], true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,78 +3,21 @@
|
||||||
namespace App\Http\Controllers\Api\V1;
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Requests\Api\V1\SubscribeNewsletterRequest;
|
|
||||||
use App\Models\NewsletterSubscription;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Newsletter-Anmeldung über die API — abgeschaltet am 09.10.2026
|
||||||
|
* (Roadmap 4.3). Der Endpunkt legte Abos ohne Bestätigungsweg an, obwohl die
|
||||||
|
* Spezifikation ein Double-Opt-in versprach, und wurde seit dem Go-Live nicht
|
||||||
|
* genutzt. Eine Anmeldung mit Double-Opt-in kommt mit dem Branchen-Digest
|
||||||
|
* (Roadmap 6.4); bis dahin antwortet die Route mit 410.
|
||||||
|
*/
|
||||||
class NewsletterSubscriptionController extends Controller
|
class NewsletterSubscriptionController extends Controller
|
||||||
{
|
{
|
||||||
public function store(SubscribeNewsletterRequest $request): JsonResponse
|
public function store(): JsonResponse
|
||||||
{
|
{
|
||||||
$validated = $request->validated();
|
|
||||||
|
|
||||||
$email = mb_strtolower($validated['email']);
|
|
||||||
|
|
||||||
$subscription = DB::transaction(function () use ($request, $validated, $email): NewsletterSubscription {
|
|
||||||
$subscription = NewsletterSubscription::withoutGlobalScopes()
|
|
||||||
->where('portal', $validated['portal'])
|
|
||||||
->where('email', $email)
|
|
||||||
->lockForUpdate()
|
|
||||||
->first();
|
|
||||||
|
|
||||||
// Nur ein fremder, tatsächlich gesetzter Eigentümer blockiert (409).
|
|
||||||
// Verwaiste Datensätze (user_id NULL nach Konto-Löschung/Legacy-Import)
|
|
||||||
// dürfen neu gebunden werden — sonst wäre die portal+email-Kombination
|
|
||||||
// dauerhaft nicht mehr abonnierbar.
|
|
||||||
if ($subscription !== null && $subscription->user_id !== null && $subscription->user_id !== $request->user()->id) {
|
|
||||||
abort(409, 'This email address is already subscribed.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($subscription === null) {
|
|
||||||
return NewsletterSubscription::withoutGlobalScopes()->create([
|
|
||||||
'portal' => $validated['portal'],
|
|
||||||
'email' => $email,
|
|
||||||
'user_id' => $request->user()->id,
|
|
||||||
'salutation_key' => $validated['salutation_key'] ?? null,
|
|
||||||
'first_name' => $validated['first_name'] ?? null,
|
|
||||||
'last_name' => $validated['last_name'] ?? null,
|
|
||||||
'ip_address' => $request->ip(),
|
|
||||||
'is_confirmed' => false,
|
|
||||||
'confirmation_token' => Str::random(32),
|
|
||||||
'subscribed_at' => now(),
|
|
||||||
'unsubscribed_at' => null,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$attributes = [
|
|
||||||
'user_id' => $request->user()->id,
|
|
||||||
'salutation_key' => $validated['salutation_key'] ?? null,
|
|
||||||
'first_name' => $validated['first_name'] ?? null,
|
|
||||||
'last_name' => $validated['last_name'] ?? null,
|
|
||||||
'ip_address' => $request->ip(),
|
|
||||||
'subscribed_at' => now(),
|
|
||||||
'unsubscribed_at' => null,
|
|
||||||
];
|
|
||||||
|
|
||||||
if (! $subscription->is_confirmed) {
|
|
||||||
$attributes['confirmation_token'] = Str::random(32);
|
|
||||||
}
|
|
||||||
|
|
||||||
$subscription->update($attributes);
|
|
||||||
|
|
||||||
return $subscription;
|
|
||||||
});
|
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'message' => 'Newsletter subscription created.',
|
'message' => 'Newsletter subscription via the API has been discontinued.',
|
||||||
'data' => [
|
], 410);
|
||||||
'id' => $subscription->id,
|
|
||||||
'portal' => $subscription->portal->value,
|
|
||||||
'email' => $subscription->email,
|
|
||||||
'is_confirmed' => $subscription->is_confirmed,
|
|
||||||
],
|
|
||||||
], 201);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,11 @@
|
||||||
|
|
||||||
namespace App\Http\Controllers\Auth;
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
|
use App\Enums\SecurityChange;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Auth\ContactAccessService;
|
use App\Services\Auth\ContactAccessService;
|
||||||
|
use App\Services\Auth\SecurityNotifier;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
|
@ -15,7 +17,7 @@ class ConfirmEmailChangeController extends Controller
|
||||||
* signierten Link aus der Mail an die NEUE Adresse geklickt hat.
|
* signierten Link aus der Mail an die NEUE Adresse geklickt hat.
|
||||||
* Bis dahin bleibt die alte Adresse aktiv (kein Aussperren bei Tippfehlern).
|
* Bis dahin bleibt die alte Adresse aktiv (kein Aussperren bei Tippfehlern).
|
||||||
*/
|
*/
|
||||||
public function __invoke(Request $request, User $user, string $hash, ContactAccessService $contactAccess): RedirectResponse
|
public function __invoke(Request $request, User $user, string $hash, ContactAccessService $contactAccess, SecurityNotifier $notifier): RedirectResponse
|
||||||
{
|
{
|
||||||
abort_unless($request->user()->is($user), 403);
|
abort_unless($request->user()->is($user), 403);
|
||||||
|
|
||||||
|
|
@ -34,6 +36,8 @@ class ConfirmEmailChangeController extends Controller
|
||||||
->with('security-error', __('Die neue E-Mail-Adresse wird inzwischen von einem anderen Konto verwendet. Die Änderung wurde verworfen.'));
|
->with('security-error', __('Die neue E-Mail-Adresse wird inzwischen von einem anderen Konto verwendet. Die Änderung wurde verworfen.'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$previousEmail = (string) $user->email;
|
||||||
|
|
||||||
$user->forceFill([
|
$user->forceFill([
|
||||||
'email' => $user->pending_email,
|
'email' => $user->pending_email,
|
||||||
'email_verified_at' => now(),
|
'email_verified_at' => now(),
|
||||||
|
|
@ -45,6 +49,11 @@ class ConfirmEmailChangeController extends Controller
|
||||||
// vergeben – nicht erst beim nächsten Login.
|
// vergeben – nicht erst beim nächsten Login.
|
||||||
$contactAccess->syncMembershipsForEmail($user);
|
$contactAccess->syncMembershipsForEmail($user);
|
||||||
|
|
||||||
|
// Die bisherige Adresse erfährt von der Änderung (Sicherheitsaudit
|
||||||
|
// 07.10.2026, S-06) — sonst bliebe eine Übernahme per fremder Session
|
||||||
|
// für den Inhaber unsichtbar.
|
||||||
|
$notifier->notify($user, SecurityChange::EmailChanged, recipient: $previousEmail, newEmail: (string) $user->email);
|
||||||
|
|
||||||
return redirect()->route('me.security')
|
return redirect()->route('me.security')
|
||||||
->with('security-status', __('Ihre E-Mail-Adresse wurde erfolgreich geändert.'));
|
->with('security-status', __('Ihre E-Mail-Adresse wurde erfolgreich geändert.'));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,17 +3,24 @@
|
||||||
namespace App\Http\Controllers\Auth;
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\User;
|
||||||
use App\Services\Auth\LoginCompletion;
|
use App\Services\Auth\LoginCompletion;
|
||||||
|
use App\Services\Auth\SocialAccountLinker;
|
||||||
use App\Services\Auth\SocialAuthService;
|
use App\Services\Auth\SocialAuthService;
|
||||||
|
use App\Services\Auth\SocialLoginResult;
|
||||||
use App\Support\LoginRedirect;
|
use App\Support\LoginRedirect;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Laravel\Socialite\AbstractUser;
|
||||||
use Laravel\Socialite\Facades\Socialite;
|
use Laravel\Socialite\Facades\Socialite;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Google-Login (WS-6). Erst-Login legt – de-dupliziert über die E-Mail – einen
|
* Google-Login (WS-6). Erst-Login legt – de-dupliziert über die E-Mail – einen
|
||||||
* aktiven, verifizierten customer-Account an; bestehende Accounts werden nur
|
* aktiven, verifizierten customer-Account an; bestehende Accounts werden
|
||||||
* verknüpft. Deaktivierte Accounts werden nicht reaktiviert.
|
* verknüpft, bei Adressen außerhalb von Googles Zuständigkeit erst nach
|
||||||
|
* Bestätigung per E-Mail (Sicherheitsaudit 07.10.2026, S-05). Deaktivierte
|
||||||
|
* Accounts werden nicht reaktiviert.
|
||||||
*/
|
*/
|
||||||
class GoogleController extends Controller
|
class GoogleController extends Controller
|
||||||
{
|
{
|
||||||
|
|
@ -22,29 +29,72 @@ class GoogleController extends Controller
|
||||||
return Socialite::driver('google')->redirect();
|
return Socialite::driver('google')->redirect();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function callback(Request $request, SocialAuthService $social, LoginCompletion $completion): RedirectResponse
|
public function callback(Request $request, SocialAuthService $social, SocialAccountLinker $linker, LoginCompletion $completion): RedirectResponse
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$googleUser = Socialite::driver('google')->user();
|
$googleUser = Socialite::driver('google')->user();
|
||||||
} catch (\Throwable) {
|
} catch (\Throwable) {
|
||||||
return redirect()->route('login')->withErrors([
|
return $this->failed(__('Die Anmeldung mit Google ist fehlgeschlagen. Bitte versuchen Sie es erneut.'));
|
||||||
'email' => __('Die Anmeldung mit Google ist fehlgeschlagen. Bitte versuchen Sie es erneut.'),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$user = $social->resolveUser(
|
$raw = $googleUser instanceof AbstractUser ? $googleUser->getRaw() : [];
|
||||||
|
$providerId = (string) $googleUser->getId();
|
||||||
|
|
||||||
|
$result = $social->resolve(
|
||||||
'google',
|
'google',
|
||||||
(string) $googleUser->getId(),
|
$providerId,
|
||||||
$googleUser->getEmail(),
|
$googleUser->getEmail(),
|
||||||
$googleUser->getName(),
|
$googleUser->getName(),
|
||||||
|
emailVerified: filter_var($raw['email_verified'] ?? false, FILTER_VALIDATE_BOOLEAN),
|
||||||
|
hostedDomain: isset($raw['hd']) ? (string) $raw['hd'] : null,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (! $user || ! $user->is_active) {
|
if ($result->outcome === SocialLoginResult::UNVERIFIED_EMAIL) {
|
||||||
return redirect()->route('login')->withErrors([
|
return $this->failed(__('Google hat keine bestätigte E-Mail-Adresse übermittelt. Bitte melden Sie sich per E-Mail-Link an.'));
|
||||||
'email' => __('Ihr Konto ist nicht aktiv. Bitte wenden Sie sich an den Support.'),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($result->outcome === SocialLoginResult::PROVIDER_MISMATCH) {
|
||||||
|
Log::warning('google-login.provider-mismatch', ['user_id' => $result->user?->getKey()]);
|
||||||
|
|
||||||
|
return $this->failed(__('Ihr Konto ist bereits mit einem anderen Google-Konto verknüpft. Bitte melden Sie sich per E-Mail-Link an.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = $result->user;
|
||||||
|
|
||||||
|
if (! $user || ! $user->is_active) {
|
||||||
|
return $this->failed(__('Ihr Konto ist nicht aktiv. Bitte wenden Sie sich an den Support.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($result->outcome === SocialLoginResult::CONFIRM_LINK) {
|
||||||
|
if (! $linker->requestConfirmation($user, 'google', $providerId)) {
|
||||||
|
return $this->failed(__('Zu viele Anfragen. Bitte warten Sie einige Minuten und versuchen Sie es erneut.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->route('login')->with('status', __('Für Ihr bestehendes Konto ist eine einmalige Bestätigung nötig: Wir haben Ihnen einen Link an Ihre E-Mail-Adresse geschickt. Bitte öffnen Sie ihn in diesem Browser.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->completeLogin($request, $user, $completion);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bestätigungslink aus der Mail (SocialAccountLinker): verknüpft und
|
||||||
|
* meldet an — nur in der Browser-Session, die den Google-Login begonnen hat.
|
||||||
|
*/
|
||||||
|
public function confirmLink(Request $request, User $user, SocialAccountLinker $linker, LoginCompletion $completion): RedirectResponse
|
||||||
|
{
|
||||||
|
if (! $linker->confirm($user, (string) $request->query('provider_id', ''))) {
|
||||||
|
return $this->failed(__('Der Bestätigungslink ist ungültig oder abgelaufen. Bitte öffnen Sie ihn im selben Browser, in dem Sie die Anmeldung mit Google gestartet haben, oder starten Sie die Anmeldung erneut.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user->is_active) {
|
||||||
|
return $this->failed(__('Ihr Konto ist nicht aktiv. Bitte wenden Sie sich an den Support.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->completeLogin($request, $user, $completion);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function completeLogin(Request $request, User $user, LoginCompletion $completion): RedirectResponse
|
||||||
|
{
|
||||||
// 2FA aktiv: wie Passwort- und Magic-Link-Login nicht direkt einloggen,
|
// 2FA aktiv: wie Passwort- und Magic-Link-Login nicht direkt einloggen,
|
||||||
// sondern in Fortifys Challenge übergeben (Audit F02). Google belegt
|
// sondern in Fortifys Challenge übergeben (Audit F02). Google belegt
|
||||||
// den Besitz des Postfachs, nicht den zweiten Faktor.
|
// den Besitz des Postfachs, nicht den zweiten Faktor.
|
||||||
|
|
@ -65,4 +115,9 @@ class GoogleController extends Controller
|
||||||
LoginRedirect::homeFor($user),
|
LoginRedirect::homeFor($user),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function failed(string $message): RedirectResponse
|
||||||
|
{
|
||||||
|
return redirect()->route('login')->withErrors(['email' => $message]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
|
|
||||||
namespace App\Http\Controllers\Auth;
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
use App\Enums\ConversionEventType;
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\MagicLink;
|
use App\Models\MagicLink;
|
||||||
use App\Models\PressRelease;
|
use App\Models\PressRelease;
|
||||||
|
|
@ -75,13 +74,10 @@ class MagicLinkConsumeController extends Controller
|
||||||
return redirect()->route('login')->with('status', __('The magic login link has expired or was already used.'));
|
return redirect()->route('login')->with('status', __('The magic login link has expired or was already used.'));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stufe 2 der Konversionsleiter. Die Herkunft kommt vom Token, nicht
|
// Stufe 4 der Konversionsleiter. Die Herkunft kommt vom Token, nicht
|
||||||
// aus der Session — die ist beim Weg über Mail und Domainwechsel
|
// aus der Session — die ist beim Weg über Mail und Domainwechsel
|
||||||
// längst gerissen (Roadmap 0.4).
|
// längst gerissen (Roadmap 0.4).
|
||||||
$this->recorder->record(
|
$this->recorder->recordMagicLinkConsumed($magicLink);
|
||||||
ConversionEventType::MagicLinkConsumed,
|
|
||||||
$this->recorder->sourceOf($magicLink),
|
|
||||||
);
|
|
||||||
|
|
||||||
$user = $magicLink->user;
|
$user = $magicLink->user;
|
||||||
|
|
||||||
|
|
@ -123,10 +119,7 @@ class MagicLinkConsumeController extends Controller
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if ($claimed > 0) {
|
if ($claimed > 0) {
|
||||||
$this->recorder->record(
|
$this->recorder->recordMagicLinkConsumed($magicLink);
|
||||||
ConversionEventType::MagicLinkConsumed,
|
|
||||||
$this->recorder->sourceOf($magicLink),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
65
app/Http/Controllers/CspReportController.php
Normal file
65
app/Http/Controllers/CspReportController.php
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Response;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nimmt CSP-Meldungen der Browser entgegen (ContentSecurityPolicy, Melde-
|
||||||
|
* Modus) und schreibt sie nach storage/logs/csp-*.log. Gleiche Meldungen
|
||||||
|
* landen höchstens einmal pro Stunde im Log. Adressen werden ohne Query und
|
||||||
|
* ohne tokenartige Pfadsegmente gespeichert — signierte Links und
|
||||||
|
* Anmeldelinks gehören nicht ins Log.
|
||||||
|
*/
|
||||||
|
class CspReportController extends Controller
|
||||||
|
{
|
||||||
|
private const MAX_BYTES = 16384;
|
||||||
|
|
||||||
|
public function __invoke(Request $request): Response
|
||||||
|
{
|
||||||
|
$body = $request->getContent();
|
||||||
|
|
||||||
|
if (! config('security.csp.report_only') || $body === '' || strlen($body) > self::MAX_BYTES) {
|
||||||
|
return response()->noContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
$report = json_decode($body, true)['csp-report'] ?? null;
|
||||||
|
|
||||||
|
if (! is_array($report)) {
|
||||||
|
return response()->noContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
$entry = [
|
||||||
|
'directive' => mb_substr((string) ($report['effective-directive'] ?? $report['violated-directive'] ?? ''), 0, 60),
|
||||||
|
'blocked' => $this->scrub($report['blocked-uri'] ?? ''),
|
||||||
|
'document' => $this->scrub($report['document-uri'] ?? ''),
|
||||||
|
'source' => $this->scrub($report['source-file'] ?? ''),
|
||||||
|
'line' => (int) ($report['line-number'] ?? 0),
|
||||||
|
'sample' => mb_substr((string) ($report['script-sample'] ?? ''), 0, 40),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (Cache::add('csp-report.'.sha1(json_encode($entry)), true, now()->addHour())) {
|
||||||
|
Log::channel('csp')->warning('CSP-Verstoß (nur gemeldet)', $entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->noContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function scrub(mixed $value): string
|
||||||
|
{
|
||||||
|
$value = mb_substr(is_string($value) ? $value : '', 0, 500);
|
||||||
|
$parts = parse_url($value);
|
||||||
|
|
||||||
|
// Schlüsselwörter wie „inline“, „eval“, „data“ unverändert.
|
||||||
|
if (! is_array($parts) || empty($parts['host'])) {
|
||||||
|
return mb_substr(strtok($value, '?#') ?: '', 0, 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = preg_replace('#/[A-Za-z0-9_-]{32,}(?=/|$)#', '/[Filtered]', $parts['path'] ?? '');
|
||||||
|
|
||||||
|
return mb_substr(($parts['scheme'] ?? 'https').'://'.$parts['host'].$path, 0, 200);
|
||||||
|
}
|
||||||
|
}
|
||||||
81
app/Http/Controllers/Web/ChangelogController.php
Normal file
81
app/Http/Controllers/Web/ChangelogController.php
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Web;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\ChangelogEntry;
|
||||||
|
use App\Services\Changelog\ChangelogTimeline;
|
||||||
|
use App\Support\DomainAssetContext;
|
||||||
|
use Illuminate\Contracts\View\View;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Response;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Öffentlicher Changelog unter pressekonto.com/changelog mit RSS-Feed
|
||||||
|
* (Decision-Update Changelog, E2). Er gehört zum Hub; auf den Portalen leitet
|
||||||
|
* die Adresse dorthin weiter.
|
||||||
|
*/
|
||||||
|
class ChangelogController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private readonly ChangelogTimeline $timeline) {}
|
||||||
|
|
||||||
|
public function index(Request $request): View|RedirectResponse
|
||||||
|
{
|
||||||
|
if (! $this->onHub($request)) {
|
||||||
|
return redirect($this->hubBase().'/changelog', 301);
|
||||||
|
}
|
||||||
|
|
||||||
|
DomainAssetContext::applyWebDomainConfig('pressekonto');
|
||||||
|
|
||||||
|
return view('web.changelog', [
|
||||||
|
'entries' => $this->timeline->entries(),
|
||||||
|
'feedUrl' => $this->hubBase().'/changelog/feed',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function feed(Request $request): Response|RedirectResponse
|
||||||
|
{
|
||||||
|
if (! $this->onHub($request)) {
|
||||||
|
return redirect($this->hubBase().'/changelog/feed', 301);
|
||||||
|
}
|
||||||
|
|
||||||
|
$base = $this->hubBase();
|
||||||
|
$entries = $this->timeline->entries(50);
|
||||||
|
|
||||||
|
$items = $entries->map(fn (ChangelogEntry $entry): array => [
|
||||||
|
'title' => $entry->category->label().': '.Str::limit($entry->publicText(), 90),
|
||||||
|
'link' => $base.'/changelog#eintrag-'.$entry->id,
|
||||||
|
'published_at' => $entry->released_on->copy()->setTime(12, 0)->toRfc2822String(),
|
||||||
|
'description' => $entry->publicText(),
|
||||||
|
'category' => $entry->category->label(),
|
||||||
|
'creator' => null,
|
||||||
|
])->all();
|
||||||
|
|
||||||
|
$xml = view('web.feed', [
|
||||||
|
'channel' => [
|
||||||
|
'title' => 'pressekonto – Changelog',
|
||||||
|
'link' => $base.'/changelog',
|
||||||
|
'self' => $base.'/changelog/feed',
|
||||||
|
'description' => 'Neuerungen auf pressekonto, presseecho und businessportal24.',
|
||||||
|
'language' => 'de',
|
||||||
|
'last_build' => ($entries->first()?->released_on?->copy()->setTime(12, 0) ?? Carbon::now())->toRfc2822String(),
|
||||||
|
],
|
||||||
|
'items' => $items,
|
||||||
|
])->render();
|
||||||
|
|
||||||
|
return response($xml, 200, ['Content-Type' => 'application/rss+xml; charset=UTF-8']);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function onHub(Request $request): bool
|
||||||
|
{
|
||||||
|
return str_contains($request->getHost(), 'pressekonto');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function hubBase(): string
|
||||||
|
{
|
||||||
|
return rtrim((string) config('domains.domain_portal_url'), '/');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,14 +2,21 @@
|
||||||
|
|
||||||
namespace App\Http\Controllers\Web;
|
namespace App\Http\Controllers\Web;
|
||||||
|
|
||||||
|
use App\Enums\ActivityType;
|
||||||
|
use App\Enums\BotCheckStatus;
|
||||||
|
use App\Enums\ContactMessageStatus;
|
||||||
use App\Enums\ContactMessageTopic;
|
use App\Enums\ContactMessageTopic;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Mail\ContactRequestSubmitted;
|
|
||||||
use App\Models\ContactMessage;
|
use App\Models\ContactMessage;
|
||||||
|
use App\Services\ActivityLog;
|
||||||
|
use App\Services\Spam\ContactSpamFilter;
|
||||||
|
use App\Services\Spam\SpamLog;
|
||||||
|
use App\Services\Spam\TurnstileVerifier;
|
||||||
|
use App\Services\Web\ContactMessageNotifier;
|
||||||
use App\Support\WebPortalContext;
|
use App\Support\WebPortalContext;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Mail;
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Support\Facades\RateLimiter;
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
@ -20,9 +27,21 @@ use Illuminate\Validation\ValidationException;
|
||||||
* (contact_messages) und versendet zusätzlich eine Mail an die Brand-
|
* (contact_messages) und versendet zusätzlich eine Mail an die Brand-
|
||||||
* Kontaktadresse (config domains.*.brand.contact_email, Fallback:
|
* Kontaktadresse (config domains.*.brand.contact_email, Fallback:
|
||||||
* Absenderadresse der Anwendung).
|
* Absenderadresse der Anwendung).
|
||||||
|
*
|
||||||
|
* Spamschutz (dev/sicherheit/ENTWICKLUNGSPLAN-SPAMSCHUTZ-2026-10-09.md):
|
||||||
|
* Cloudflare Turnstile für Gäste, danach Inhaltsfilter. Treffer des Filters
|
||||||
|
* landen in der Quarantäne (Status Spam, keine Mail); war Cloudflare nicht
|
||||||
|
* erreichbar, wird angenommen und im Verlauf markiert.
|
||||||
*/
|
*/
|
||||||
class ContactController extends Controller
|
class ContactController extends Controller
|
||||||
{
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly TurnstileVerifier $turnstile,
|
||||||
|
private readonly ContactSpamFilter $spamFilter,
|
||||||
|
private readonly ContactMessageNotifier $notifier,
|
||||||
|
private readonly ActivityLog $activityLog,
|
||||||
|
) {}
|
||||||
|
|
||||||
public function __invoke(Request $request): RedirectResponse
|
public function __invoke(Request $request): RedirectResponse
|
||||||
{
|
{
|
||||||
// Bot: ein ausgefülltes Honeypot-Feld → still und neutral abnicken,
|
// Bot: ein ausgefülltes Honeypot-Feld → still und neutral abnicken,
|
||||||
|
|
@ -53,27 +72,66 @@ class ContactController extends Controller
|
||||||
$this->ensureNotRateLimited($request);
|
$this->ensureNotRateLimited($request);
|
||||||
RateLimiter::hit($this->throttleKey($request), 3600);
|
RateLimiter::hit($this->throttleKey($request), 3600);
|
||||||
|
|
||||||
|
// Bestätigte Kunden sind keine Bots. Unbestätigte Konten (dort
|
||||||
|
// landen die Bot-Registrierungen) prüfen wir wie Gäste.
|
||||||
|
$trusted = $request->user()?->hasVerifiedEmail() === true;
|
||||||
|
|
||||||
|
$botCheck = $trusted ? null : $this->turnstile->verifyRequest($request, 'contact');
|
||||||
|
|
||||||
|
if ($botCheck?->blocks()) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'turnstile' => __('web.launcher.bot_check_failed'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$spamReason = $trusted ? null : $this->spamFilter->reasonFor(
|
||||||
|
$validated['name'],
|
||||||
|
$validated['message'],
|
||||||
|
$request->string(ContactSpamFilter::STARTED_FIELD)->toString(),
|
||||||
|
);
|
||||||
|
|
||||||
$topic = ContactMessageTopic::tryFrom($validated['topic'] ?? '') ?? ContactMessageTopic::Contact;
|
$topic = ContactMessageTopic::tryFrom($validated['topic'] ?? '') ?? ContactMessageTopic::Contact;
|
||||||
|
|
||||||
ContactMessage::create([
|
$message = ContactMessage::create([
|
||||||
'portal' => $this->portal($request),
|
'portal' => $this->portal($request),
|
||||||
'topic' => $topic,
|
'topic' => $topic,
|
||||||
|
'status' => $spamReason !== null ? ContactMessageStatus::Spam : ContactMessageStatus::Open,
|
||||||
'name' => $validated['name'],
|
'name' => $validated['name'],
|
||||||
'email' => $validated['email'],
|
'email' => $validated['email'],
|
||||||
'message' => $validated['message'],
|
'message' => $validated['message'],
|
||||||
'page_url' => $validated['page_url'] ?? null,
|
'page_url' => $validated['page_url'] ?? null,
|
||||||
|
'payload' => array_filter([
|
||||||
|
'spam_reason' => $spamReason,
|
||||||
|
'bot_check' => $botCheck?->status === BotCheckStatus::Skipped ? null : $botCheck?->status->value,
|
||||||
|
'bot_check_reason' => $botCheck?->needsReview() ? $botCheck->reason : null,
|
||||||
|
]) ?: null,
|
||||||
'requester_ip' => $request->ip(),
|
'requester_ip' => $request->ip(),
|
||||||
'user_id' => $request->user()?->id,
|
'user_id' => $request->user()?->id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
Mail::to($this->recipient())->queue((new ContactRequestSubmitted(
|
// Spam: gespeichert zur Nachprüfung, aber keine Mail. Der Absender
|
||||||
senderName: $validated['name'],
|
// bekommt dieselbe Bestätigung wie alle — der Bot lernt nichts.
|
||||||
senderEmail: $validated['email'],
|
if ($spamReason !== null) {
|
||||||
messageText: $validated['message'],
|
Log::channel('spam')->info('Kontaktanfrage in Quarantäne.', [
|
||||||
host: $request->getHost(),
|
'reason' => $spamReason,
|
||||||
topicLabel: $topic->label(),
|
'portal' => $message->portal,
|
||||||
pageUrl: $validated['page_url'] ?? null,
|
'ip_prefix' => SpamLog::ipPrefix($request->ip()),
|
||||||
))->afterCommit());
|
]);
|
||||||
|
|
||||||
|
return $this->done();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($botCheck?->needsReview()) {
|
||||||
|
$this->activityLog->record(
|
||||||
|
ActivityType::BotCheckUnavailable,
|
||||||
|
sprintf('Kontaktanfrage %s ohne Bot-Prüfung angenommen (%s) — bitte nachprüfen.', $message->reference(), $botCheck->reason),
|
||||||
|
subject: $message,
|
||||||
|
actorLabel: (string) $message->email,
|
||||||
|
meta: ['form' => 'contact', 'reason' => $botCheck->reason],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->notifier->notify($message, $request->getHost());
|
||||||
|
|
||||||
return $this->done();
|
return $this->done();
|
||||||
}
|
}
|
||||||
|
|
@ -96,19 +154,6 @@ class ContactController extends Controller
|
||||||
return WebPortalContext::portalFromHost()->value;
|
return WebPortalContext::portalFromHost()->value;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function recipient(): string
|
|
||||||
{
|
|
||||||
// Auch die Hub-Domain (pressekonto) nutzt das Formular — sie ist kein
|
|
||||||
// Spoke-Portal, daher vor dem WebPortalContext am Host unterscheiden.
|
|
||||||
if (str_contains(request()->getHost(), 'pressekonto')) {
|
|
||||||
$brand = config('domains.domains.pressekonto.brand', []);
|
|
||||||
} else {
|
|
||||||
$brand = WebPortalContext::fromRequest()->domainConfig['brand'] ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $brand['contact_email'] ?? config('mail.from.address');
|
|
||||||
}
|
|
||||||
|
|
||||||
private function ensureNotRateLimited(Request $request): void
|
private function ensureNotRateLimited(Request $request): void
|
||||||
{
|
{
|
||||||
if (! RateLimiter::tooManyAttempts($this->throttleKey($request), 5)) {
|
if (! RateLimiter::tooManyAttempts($this->throttleKey($request), 5)) {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
namespace App\Http\Controllers\Web;
|
namespace App\Http\Controllers\Web;
|
||||||
|
|
||||||
|
use App\Enums\ConversionEventType;
|
||||||
use App\Enums\ConversionSource;
|
use App\Enums\ConversionSource;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\Company;
|
use App\Models\Company;
|
||||||
|
|
@ -9,6 +10,7 @@ use App\Models\PressRelease;
|
||||||
use App\Scopes\PortalScope;
|
use App\Scopes\PortalScope;
|
||||||
use App\Services\Company\MergedNewsroomLookup;
|
use App\Services\Company\MergedNewsroomLookup;
|
||||||
use App\Services\Company\NewsroomClaimService;
|
use App\Services\Company\NewsroomClaimService;
|
||||||
|
use App\Services\Conversion\ConversionRecorder;
|
||||||
use App\Support\WebPortalContext;
|
use App\Support\WebPortalContext;
|
||||||
use Illuminate\Contracts\View\View;
|
use Illuminate\Contracts\View\View;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
|
@ -29,9 +31,10 @@ class NewsroomClaimController extends Controller
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly NewsroomClaimService $claims,
|
private readonly NewsroomClaimService $claims,
|
||||||
private readonly MergedNewsroomLookup $mergedNewsrooms,
|
private readonly MergedNewsroomLookup $mergedNewsrooms,
|
||||||
|
private readonly ConversionRecorder $recorder,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function show(string $edition, string $slug): View|Response|RedirectResponse
|
public function show(Request $request, string $edition, string $slug): View|Response|RedirectResponse
|
||||||
{
|
{
|
||||||
$context = WebPortalContext::fromRequest();
|
$context = WebPortalContext::fromRequest();
|
||||||
$company = $this->claims->findClaimable($context->portalValues, $slug);
|
$company = $this->claims->findClaimable($context->portalValues, $slug);
|
||||||
|
|
@ -40,6 +43,8 @@ class NewsroomClaimController extends Controller
|
||||||
return $this->resolveMissing($context, $edition, $slug);
|
return $this->resolveMissing($context, $edition, $slug);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->recorder->recordManagePageOpened($request, $company, $this->sourceOf($request), $this->landingPressRelease($request, $company));
|
||||||
|
|
||||||
return view('web.newsroom-claim', [
|
return view('web.newsroom-claim', [
|
||||||
'company' => $company,
|
'company' => $company,
|
||||||
'recipients' => $this->claims->maskedRecipients($company),
|
'recipients' => $this->claims->maskedRecipients($company),
|
||||||
|
|
@ -70,7 +75,8 @@ class NewsroomClaimController extends Controller
|
||||||
|
|
||||||
// Enumeration-sicher: dieselbe Meldung, ob ein Konto deaktiviert ist
|
// Enumeration-sicher: dieselbe Meldung, ob ein Konto deaktiviert ist
|
||||||
// oder nicht — die Adresse ist ohnehin nur maskiert sichtbar.
|
// oder nicht — die Adresse ist ohnehin nur maskiert sichtbar.
|
||||||
$sent = $this->claims->request($company, $request->ip(), $this->sourceOf($request), $this->landingPressReleaseId($request, $company));
|
$landing = $this->landingPressRelease($request, $company);
|
||||||
|
$sent = $this->claims->request($company, $request->ip(), $this->sourceOf($request), $landing?->getKey());
|
||||||
|
|
||||||
// Der Firmenzähler zählt nur tatsächlich versendete Links — sonst
|
// Der Firmenzähler zählt nur tatsächlich versendete Links — sonst
|
||||||
// sperrten Dritte den Inhaber mit Fehlversuchen 24 h aus
|
// sperrten Dritte den Inhaber mit Fehlversuchen 24 h aus
|
||||||
|
|
@ -78,6 +84,10 @@ class NewsroomClaimController extends Controller
|
||||||
// Drossel oben.
|
// Drossel oben.
|
||||||
if ($sent > 0) {
|
if ($sent > 0) {
|
||||||
RateLimiter::hit($this->companyKey($company->getKey()), 86400);
|
RateLimiter::hit($this->companyKey($company->getKey()), 86400);
|
||||||
|
|
||||||
|
// Stufe 3 der Konversionsleiter (Roadmap 6.16): nur, wenn
|
||||||
|
// wirklich ein Link verschickt wurde.
|
||||||
|
$this->recorder->record(ConversionEventType::AccessRequested, $this->sourceOf($request), $company, $landing);
|
||||||
}
|
}
|
||||||
|
|
||||||
return back()->with('newsroom-claim-status', __('static.newsroom_claim.flash_sent'));
|
return back()->with('newsroom-claim-status', __('static.newsroom_claim.flash_sent'));
|
||||||
|
|
@ -88,7 +98,7 @@ class NewsroomClaimController extends Controller
|
||||||
* Link), landet er nach dem Login auf genau dieser Meldung samt Aufrufen
|
* Link), landet er nach dem Login auf genau dieser Meldung samt Aufrufen
|
||||||
* (Roadmap 1c.2) — sofern sie wirklich zu dieser Firma gehört.
|
* (Roadmap 1c.2) — sofern sie wirklich zu dieser Firma gehört.
|
||||||
*/
|
*/
|
||||||
private function landingPressReleaseId(Request $request, Company $company): ?int
|
private function landingPressRelease(Request $request, Company $company): ?PressRelease
|
||||||
{
|
{
|
||||||
$id = (int) $request->input('pr', 0);
|
$id = (int) $request->input('pr', 0);
|
||||||
|
|
||||||
|
|
@ -96,7 +106,7 @@ class NewsroomClaimController extends Controller
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return PressRelease::query()->withoutGlobalScope(PortalScope::class)->whereKey($id)->where('company_id', $company->getKey())->exists() ? $id : null;
|
return PressRelease::query()->withoutGlobalScope(PortalScope::class)->whereKey($id)->where('company_id', $company->getKey())->first();
|
||||||
}
|
}
|
||||||
|
|
||||||
private function resolveMissing(WebPortalContext $context, string $edition, string $slug): Response|RedirectResponse
|
private function resolveMissing(WebPortalContext $context, string $edition, string $slug): Response|RedirectResponse
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,9 @@ class BlockUnusedFortifyRoutes
|
||||||
'two-factor.secret-key',
|
'two-factor.secret-key',
|
||||||
'two-factor.recovery-codes',
|
'two-factor.recovery-codes',
|
||||||
'two-factor.regenerate-recovery-codes',
|
'two-factor.regenerate-recovery-codes',
|
||||||
|
// Passkeys löscht die Sicherheitsseite mit Identitätsnachweis
|
||||||
|
// (Entscheidung 5, 07.10.2026).
|
||||||
|
'passkey.destroy',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function handle(Request $request, Closure $next): Response
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
|
|
||||||
38
app/Http/Middleware/EnsureApiAccessIsBooked.php
Normal file
38
app/Http/Middleware/EnsureApiAccessIsBooked.php
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Api\ApiAccessEligibilityService;
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API-Zugang nur mit laufender Buchung (08.10.2026): aktives Abo,
|
||||||
|
* aktive Zahlungsoption oder Bestandsschutz — dieselbe Regel wie beim
|
||||||
|
* Erzeugen eines Tokens (ApiAccessEligibilityService), jetzt bei jedem
|
||||||
|
* Aufruf. Endet die Buchung, sperren die Tokens; sie bleiben erhalten und
|
||||||
|
* funktionieren wieder, sobald erneut gebucht ist. Gilt für API v1 und die
|
||||||
|
* Legacy-Aliasse unter /service.
|
||||||
|
*/
|
||||||
|
class EnsureApiAccessIsBooked
|
||||||
|
{
|
||||||
|
public function __construct(private readonly ApiAccessEligibilityService $eligibility) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Closure(Request): (Response) $next
|
||||||
|
*/
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
if ($user instanceof User && ! $this->eligibility->hasApiAccess($user)) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'API access requires an active subscription or booking.',
|
||||||
|
], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
49
app/Http/Middleware/EnsurePasskeyManagementIsConfirmed.php
Normal file
49
app/Http/Middleware/EnsurePasskeyManagementIsConfirmed.php
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use App\Actions\Admin\UserImpersonation;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Auth\SecurityConfirmation;
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Passkey hinzufügen nur mit frischem Identitätsnachweis (Entscheidung 5,
|
||||||
|
* Sicherheitsaudit 07.10.2026, S-06): Ein Passkey ist dauerhafter Zugang —
|
||||||
|
* eine fremde Session allein darf keinen hinterlegen. Den Nachweis erbringt
|
||||||
|
* die Sicherheitsseite (ConfirmsIdentity), bevor sie die Registrierung im
|
||||||
|
* Browser startet. Während einer Impersonation nie.
|
||||||
|
*
|
||||||
|
* Hängt über `fortify.middleware` an allen Fortify-Routen und greift nur bei
|
||||||
|
* den Registrierungsrouten. Gelöscht wird über die Sicherheitsseite; die
|
||||||
|
* Fortify-Löschroute sperrt BlockUnusedFortifyRoutes.
|
||||||
|
*/
|
||||||
|
class EnsurePasskeyManagementIsConfirmed
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
public const GUARDED_ROUTES = [
|
||||||
|
'passkey.registration-options',
|
||||||
|
'passkey.store',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function __construct(private readonly SecurityConfirmation $confirmation) {}
|
||||||
|
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
if (! in_array($request->route()?->getName(), self::GUARDED_ROUTES, true)) {
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User || app(UserImpersonation::class)->isActive() || ! $this->confirmation->isFresh($user)) {
|
||||||
|
abort(403, __('Bitte bestätigen Sie zuerst Ihre Identität.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
namespace App\Http\Middleware;
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
use App\Services\Auth\SessionRevocation;
|
use App\Services\Auth\SessionRevocation;
|
||||||
use Closure;
|
use Closure;
|
||||||
use Illuminate\Auth\SessionGuard;
|
use Illuminate\Auth\SessionGuard;
|
||||||
|
|
@ -11,8 +12,8 @@ use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Meldet eine Session ab, die vor der letzten Sperre ihres Kontos entstanden
|
* Meldet eine Session ab, die vor der letzten Sperre ihres Kontos entstanden
|
||||||
* ist (SessionRevocation, Audit K-04). Kostet je angemeldetem Request einen
|
* ist (SessionRevocation, Audit K-04). Die Marke steht am geladenen User;
|
||||||
* Cache-Zugriff; Gäste lösen keinen aus.
|
* bis zum Release nach v1.6.0 kommt ein Cache-Zugriff dazu (Übergang).
|
||||||
*/
|
*/
|
||||||
class EnsureSessionIsNotRevoked
|
class EnsureSessionIsNotRevoked
|
||||||
{
|
{
|
||||||
|
|
@ -33,7 +34,7 @@ class EnsureSessionIsNotRevoked
|
||||||
|
|
||||||
$user = $guard->user();
|
$user = $guard->user();
|
||||||
|
|
||||||
if ($user === null || ! $this->revocation->isRevoked((int) $user->getAuthIdentifier(), $request->session())) {
|
if (! $user instanceof User || ! $this->revocation->isRevoked($user, $request->session())) {
|
||||||
return $next($request);
|
return $next($request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
38
app/Http/Middleware/EnsureStrongAdminAuthentication.php
Normal file
38
app/Http/Middleware/EnsureStrongAdminAuthentication.php
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Auth\StrongAuthentication;
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin-Bereich nur mit starker Anmeldung (Entscheidung 5, 07.10.2026):
|
||||||
|
* Super-Admins und Admins bestätigen ihre Session einmalig per Passkey oder
|
||||||
|
* Authenticator-Code. Seitenaufrufe führen zur Bestätigungsseite und danach
|
||||||
|
* zurück; Livewire- und JSON-Folgeanfragen werden abgewiesen. Editoren und
|
||||||
|
* Kunden sind nicht betroffen. Läuft auch als persistente Livewire-Middleware.
|
||||||
|
*/
|
||||||
|
class EnsureStrongAdminAuthentication
|
||||||
|
{
|
||||||
|
public function __construct(private readonly StrongAuthentication $strong) {}
|
||||||
|
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User || ! $this->strong->isRequiredFor($user) || $this->strong->isSatisfiedBy($user)) {
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $request->isMethod('GET') || $request->expectsJson()) {
|
||||||
|
abort(403, __('Bitte bestätigen Sie Ihre Anmeldung mit Passkey oder Authenticator-Code.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$request->session()->put(StrongAuthentication::INTENDED_KEY, $request->fullUrl());
|
||||||
|
|
||||||
|
return redirect()->route('strong-auth.confirm');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,7 +7,6 @@ use App\Enums\ConversionSource;
|
||||||
use App\Models\PressRelease;
|
use App\Models\PressRelease;
|
||||||
use App\Scopes\PortalScope;
|
use App\Scopes\PortalScope;
|
||||||
use App\Services\Conversion\ConversionRecorder;
|
use App\Services\Conversion\ConversionRecorder;
|
||||||
use App\Support\CrawlerIdentifier;
|
|
||||||
use Closure;
|
use Closure;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\RateLimiter;
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
|
|
@ -49,7 +48,7 @@ class RecordMailLinkClick
|
||||||
// Nur echte Aufrufe. Mailclients und Sicherheitsscanner holen Links
|
// Nur echte Aufrufe. Mailclients und Sicherheitsscanner holen Links
|
||||||
// vorab per HEAD oder mit eigener Kennung; das wäre kein Klick,
|
// vorab per HEAD oder mit eigener Kennung; das wäre kein Klick,
|
||||||
// sondern eine Zustellung (Umsetzungsaudit 29.09.2026, R-08).
|
// sondern eine Zustellung (Umsetzungsaudit 29.09.2026, R-08).
|
||||||
if (! $request->isMethod('GET') || $this->isScanner($request->userAgent())) {
|
if (! $request->isMethod('GET') || $this->recorder->isAutomatedVisit($request->userAgent())) {
|
||||||
return $next($request);
|
return $next($request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -81,23 +80,6 @@ class RecordMailLinkClick
|
||||||
return $next($request);
|
return $next($request);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function isScanner(?string $userAgent): bool
|
|
||||||
{
|
|
||||||
$userAgent = mb_strtolower(trim((string) $userAgent));
|
|
||||||
|
|
||||||
if ($userAgent === '' || CrawlerIdentifier::identify($userAgent) !== null) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ((array) config('conversion.mail_clicks.scanner_agents', []) as $pattern) {
|
|
||||||
if (str_contains($userAgent, mb_strtolower((string) $pattern))) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scope-frei aufgelöst: Der Klick landet je nach Link auf dem Hub oder auf
|
* Scope-frei aufgelöst: Der Klick landet je nach Link auf dem Hub oder auf
|
||||||
* einem der beiden Spokes, der Portal-Scope würde die Meldung dort
|
* einem der beiden Spokes, der Portal-Scope würde die Meldung dort
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
namespace App\Http\Middleware;
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use App\Support\ContentSecurityPolicy;
|
||||||
use Closure;
|
use Closure;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
@ -10,8 +11,8 @@ use Symfony\Component\HttpFoundation\Response;
|
||||||
* Applikationsseitige Security-Header (R6.2) — bewusst unabhängig vom
|
* Applikationsseitige Security-Header (R6.2) — bewusst unabhängig vom
|
||||||
* Reverse-Proxy gesetzt, damit der Schutz auch bei Proxy-Wechsel bleibt.
|
* Reverse-Proxy gesetzt, damit der Schutz auch bei Proxy-Wechsel bleibt.
|
||||||
* Bereits vorhandene Header (z. B. vom Proxy) werden nicht überschrieben.
|
* Bereits vorhandene Header (z. B. vom Proxy) werden nicht überschrieben.
|
||||||
* CSP fehlt absichtlich: Livewire/Alpine/Vite brauchen eine eigene
|
* HSTS nur auf HTTPS-Antworten und laut config/security.php.
|
||||||
* Inline-Script-Strategie — separat einführen, nicht nebenbei.
|
* CSP vorerst nur im Melde-Modus (ContentSecurityPolicy, config/security.php).
|
||||||
*/
|
*/
|
||||||
class SecurityHeaders
|
class SecurityHeaders
|
||||||
{
|
{
|
||||||
|
|
@ -30,6 +31,16 @@ class SecurityHeaders
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (config('security.csp.report_only')
|
||||||
|
&& str_contains((string) $response->headers->get('Content-Type'), 'text/html')
|
||||||
|
&& ! $response->headers->has('Content-Security-Policy-Report-Only')) {
|
||||||
|
$response->headers->set('Content-Security-Policy-Report-Only', ContentSecurityPolicy::header());
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->isSecure() && config('security.hsts.enabled') && ! $response->headers->has('Strict-Transport-Security')) {
|
||||||
|
$response->headers->set('Strict-Transport-Security', 'max-age='.(int) config('security.hsts.max_age'));
|
||||||
|
}
|
||||||
|
|
||||||
return $response;
|
return $response;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Requests\Api\V1;
|
|
||||||
|
|
||||||
use App\Enums\Portal;
|
|
||||||
use Illuminate\Contracts\Validation\ValidationRule;
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
|
||||||
use Illuminate\Validation\Rule;
|
|
||||||
|
|
||||||
class SubscribeNewsletterRequest extends FormRequest
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Determine if the user is authorized to make this request.
|
|
||||||
*/
|
|
||||||
public function authorize(): bool
|
|
||||||
{
|
|
||||||
return $this->user()?->tokenCan('newsletter:subscribe') === true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the validation rules that apply to the request.
|
|
||||||
*
|
|
||||||
* @return array<string, ValidationRule|array<mixed>|string>
|
|
||||||
*/
|
|
||||||
public function rules(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'portal' => ['required', Rule::in([
|
|
||||||
Portal::Presseecho->value,
|
|
||||||
Portal::Businessportal24->value,
|
|
||||||
])],
|
|
||||||
'email' => ['required', 'email', 'max:190'],
|
|
||||||
'salutation_key' => ['nullable', 'string', 'max:20'],
|
|
||||||
'first_name' => ['nullable', 'string', 'max:80'],
|
|
||||||
'last_name' => ['nullable', 'string', 'max:80'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
38
app/Http/Responses/PasskeyLoginResponse.php
Normal file
38
app/Http/Responses/PasskeyLoginResponse.php
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Responses;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Auth\LoginCompletion;
|
||||||
|
use App\Support\LoginRedirect;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Laravel\Passkeys\Contracts\PasskeyLoginResponse as PasskeyLoginResponseContract;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abschluss eines Passkey-Logins (Entscheidung 5, 07.10.2026): wie
|
||||||
|
* RoleAwareLoginResponse den Login protokollieren und nur auf erlaubte Ziele
|
||||||
|
* weiterleiten. Aktiv- und Verifizierungsstatus prüft bereits
|
||||||
|
* Passkeys::authorizeLoginUsing() vor der Anmeldung (FortifyServiceProvider).
|
||||||
|
* Der Browser-Client erwartet JSON mit `redirect`.
|
||||||
|
*/
|
||||||
|
class PasskeyLoginResponse implements PasskeyLoginResponseContract
|
||||||
|
{
|
||||||
|
public function toResponse($request): JsonResponse|RedirectResponse
|
||||||
|
{
|
||||||
|
/** @var User $user */
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
app(LoginCompletion::class)->recordLogin($user, $request);
|
||||||
|
|
||||||
|
$target = LoginRedirect::safeTarget(
|
||||||
|
$user,
|
||||||
|
$request->session()->pull('url.intended'),
|
||||||
|
LoginRedirect::homeFor($user),
|
||||||
|
);
|
||||||
|
|
||||||
|
return $request->wantsJson()
|
||||||
|
? new JsonResponse(['redirect' => $target])
|
||||||
|
: redirect($target);
|
||||||
|
}
|
||||||
|
}
|
||||||
25
app/Listeners/CountFailedLoginsPerIp.php
Normal file
25
app/Listeners/CountFailedLoginsPerIp.php
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Listeners;
|
||||||
|
|
||||||
|
use App\Services\Auth\LoginIpThrottle;
|
||||||
|
use Illuminate\Auth\Events\Failed;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zählt jede fehlgeschlagene Web-Anmeldung für die IP-weite Obergrenze
|
||||||
|
* (LoginIpThrottle) — gleich, ob über die Livewire-Anmeldung oder Fortifys
|
||||||
|
* `POST /login`.
|
||||||
|
*/
|
||||||
|
class CountFailedLoginsPerIp
|
||||||
|
{
|
||||||
|
public function __construct(private readonly LoginIpThrottle $throttle) {}
|
||||||
|
|
||||||
|
public function handle(Failed $event): void
|
||||||
|
{
|
||||||
|
if ($event->guard !== 'web' || ! app()->bound('request')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->throttle->recordFailure(request()->ip());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -248,7 +248,7 @@ class ProcessStripeWebhook
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stufe 5 der Konversionsleiter: die erste bezahlte Rechnung eines
|
// Stufe 7 der Konversionsleiter: die erste bezahlte Rechnung eines
|
||||||
// Kontos. Deckt Abos und alle über Rechnung abgewickelten Zahlungen ab;
|
// Kontos. Deckt Abos und alle über Rechnung abgewickelten Zahlungen ab;
|
||||||
// Einmalkäufe melden zusätzlich in fulfillSinglePurchase(). Die Sperre
|
// Einmalkäufe melden zusätzlich in fulfillSinglePurchase(). Die Sperre
|
||||||
// je Firma sorgt dafür, dass trotzdem nur die erste Buchung zählt.
|
// je Firma sorgt dafür, dass trotzdem nur die erste Buchung zählt.
|
||||||
|
|
@ -469,7 +469,7 @@ class ProcessStripeWebhook
|
||||||
if ($result['marked_paid']) {
|
if ($result['marked_paid']) {
|
||||||
Log::channel('billing')->info('Einmalkauf als bezahlt markiert.', ['single_purchase_id' => $purchase->id]);
|
Log::channel('billing')->info('Einmalkauf als bezahlt markiert.', ['single_purchase_id' => $purchase->id]);
|
||||||
|
|
||||||
// Stufe 5 der Konversionsleiter. recordFirstBooking() zählt je
|
// Stufe 7 der Konversionsleiter. recordFirstBooking() zählt je
|
||||||
// Firma nur den ersten Treffer, deshalb dürfen beide Zahlungspfade
|
// Firma nur den ersten Treffer, deshalb dürfen beide Zahlungspfade
|
||||||
// melden, ohne sich gegenseitig zu verfälschen.
|
// melden, ohne sich gegenseitig zu verfälschen.
|
||||||
$this->conversions->recordFirstBooking($purchase->user);
|
$this->conversions->recordFirstBooking($purchase->user);
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@ class RecordAuthActivity
|
||||||
meta: ['way' => 'form'],
|
meta: ['way' => 'form'],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Stufe 4 der Konversionsleiter. Ohne Firmenbezug — beim Anlegen des
|
// Stufe 6 der Konversionsleiter. Ohne Firmenbezug — beim Anlegen des
|
||||||
// Kontos steht noch keine fest; die Zuordnung entsteht erst, wenn ein
|
// Kontos steht noch keine fest; die Zuordnung entsteht erst, wenn ein
|
||||||
// Newsroom beansprucht wird.
|
// Newsroom beansprucht wird.
|
||||||
$this->recorder->record(ConversionEventType::AccountCreated, ConversionSource::Organic);
|
$this->recorder->record(ConversionEventType::AccountCreated, ConversionSource::Organic);
|
||||||
|
|
|
||||||
42
app/Listeners/RevokeAccessAfterPasswordReset.php
Normal file
42
app/Listeners/RevokeAccessAfterPasswordReset.php
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Listeners;
|
||||||
|
|
||||||
|
use App\Enums\SecurityChange;
|
||||||
|
use App\Models\MagicLink;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Auth\SecurityNotifier;
|
||||||
|
use App\Services\Auth\SessionRevocation;
|
||||||
|
use Illuminate\Auth\Events\PasswordReset;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Passwort-Reset als Kontowiederherstellung (Sicherheitsaudit 07.10.2026,
|
||||||
|
* S-04): Alle offenen Browser-Sessions enden, noch nicht eingelöste
|
||||||
|
* Anmeldelinks verfallen. Den Remember-Token rotiert die Reset-Komponente
|
||||||
|
* selbst. API-Tokens bleiben bestehen (Entscheidung 1) — der Hinweis an die
|
||||||
|
* Kontoadresse weist darauf hin.
|
||||||
|
*/
|
||||||
|
class RevokeAccessAfterPasswordReset
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly SessionRevocation $sessions,
|
||||||
|
private readonly SecurityNotifier $notifier,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function handle(PasswordReset $event): void
|
||||||
|
{
|
||||||
|
if (! $event->user instanceof User) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->sessions->revokeAllFor($event->user);
|
||||||
|
|
||||||
|
MagicLink::query()
|
||||||
|
->where('user_id', $event->user->getKey())
|
||||||
|
->whereIn('purpose', MagicLink::LOGIN_PURPOSES)
|
||||||
|
->whereNull('consumed_at')
|
||||||
|
->update(['consumed_at' => now()]);
|
||||||
|
|
||||||
|
$this->notifier->notify($event->user, SecurityChange::PasswordReset);
|
||||||
|
}
|
||||||
|
}
|
||||||
71
app/Listeners/TrackSecondFactorEvents.php
Normal file
71
app/Listeners/TrackSecondFactorEvents.php
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Listeners;
|
||||||
|
|
||||||
|
use App\Enums\SecurityChange;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Auth\SecurityConfirmation;
|
||||||
|
use App\Services\Auth\SecurityNotifier;
|
||||||
|
use App\Services\Auth\StrongAuthentication;
|
||||||
|
use Laravel\Fortify\Events\ValidTwoFactorAuthenticationCodeProvided;
|
||||||
|
use Laravel\Passkeys\Events\PasskeyDeleted;
|
||||||
|
use Laravel\Passkeys\Events\PasskeyRegistered;
|
||||||
|
use Laravel\Passkeys\Events\PasskeyVerified;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verbindet Passkeys und Authenticator-Codes mit den eigenen Schutzschichten
|
||||||
|
* (Entscheidung 5, 07.10.2026):
|
||||||
|
* - Ein geprüfter Passkey (Login oder Bestätigung) oder ein gültiger Code
|
||||||
|
* in der 2FA-Challenge macht die Session „stark“ (Admin-Bereich).
|
||||||
|
* - Ein geprüfter Passkey gilt zugleich als frischer Identitätsnachweis
|
||||||
|
* für Sicherheitsänderungen (S-06).
|
||||||
|
* - Hinzugefügte und entfernte Passkeys werden an die Kontoadresse gemeldet.
|
||||||
|
*/
|
||||||
|
class TrackSecondFactorEvents
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly StrongAuthentication $strong,
|
||||||
|
private readonly SecurityConfirmation $confirmation,
|
||||||
|
private readonly SecurityNotifier $notifier,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function handlePasskeyVerified(PasskeyVerified $event): void
|
||||||
|
{
|
||||||
|
if (! $event->user instanceof User) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->strong->mark($event->user, 'passkey');
|
||||||
|
$this->confirmation->markConfirmed($event->user);
|
||||||
|
|
||||||
|
// Login per Passkey: Anmeldeweg für das Aktivitätsprotokoll.
|
||||||
|
if (! auth()->guard('web')->check()) {
|
||||||
|
request()->attributes->set(RecordAuthActivity::AUTH_METHOD_ATTRIBUTE, 'passkey');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handleValidTwoFactorCode(ValidTwoFactorAuthenticationCodeProvided $event): void
|
||||||
|
{
|
||||||
|
$this->strong->mark($event->user, 'totp');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handlePasskeyRegistered(PasskeyRegistered $event): void
|
||||||
|
{
|
||||||
|
if (! $event->user instanceof User) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Die Registrierung verlangt Benutzerverifizierung am Gerät — die
|
||||||
|
// Session ist damit ebenso stark bestätigt wie durch einen Login.
|
||||||
|
$this->strong->mark($event->user, 'passkey');
|
||||||
|
|
||||||
|
$this->notifier->notify($event->user, SecurityChange::PasskeyAdded);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handlePasskeyDeleted(PasskeyDeleted $event): void
|
||||||
|
{
|
||||||
|
if ($event->user instanceof User) {
|
||||||
|
$this->notifier->notify($event->user, SecurityChange::PasskeyRemoved);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
142
app/Livewire/Concerns/ConfirmsIdentity.php
Normal file
142
app/Livewire/Concerns/ConfirmsIdentity.php
Normal file
|
|
@ -0,0 +1,142 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire\Concerns;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Auth\SecurityConfirmation;
|
||||||
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
use Laravel\Fortify\TwoFactorAuthenticationProvider;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erneuter Nachweis vor sicherheitskritischen Aktionen (Audit F03, E3;
|
||||||
|
* Sicherheitsaudit 07.10.2026, S-06). Je nach Kontoart:
|
||||||
|
* - 2FA aktiv → aktueller App-Code oder Wiederherstellungs-Code,
|
||||||
|
* - Passwort gesetzt → Passwort,
|
||||||
|
* - sonst → Einmal-Code an die aktuelle Kontoadresse.
|
||||||
|
*
|
||||||
|
* Nach erfolgreichem Nachweis gilt die Session eine Weile als bestätigt
|
||||||
|
* (SecurityConfirmation). Für das Abschalten der 2FA und neue
|
||||||
|
* Wiederherstellungs-Codes verlangt assertSecondFactor() dagegen immer einen
|
||||||
|
* aktuellen zweiten Faktor. Das Formular dazu: `partials.security-proof`.
|
||||||
|
*/
|
||||||
|
trait ConfirmsIdentity
|
||||||
|
{
|
||||||
|
public string $securityCode = '';
|
||||||
|
|
||||||
|
public string $securityPassword = '';
|
||||||
|
|
||||||
|
public string $securityEmailCode = '';
|
||||||
|
|
||||||
|
public bool $securityEmailCodeSent = false;
|
||||||
|
|
||||||
|
public function requestSecurityEmailCode(): void
|
||||||
|
{
|
||||||
|
/** @var User $user */
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! app(SecurityConfirmation::class)->sendEmailCode($user)) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'securityEmailCode' => __('Zu viele Codes angefordert. Bitte warten Sie einige Minuten.'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->securityEmailCodeSent = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Art des Nachweises für die Anzeige: fresh, two_factor, password oder email_code.
|
||||||
|
*/
|
||||||
|
public function securityProofKind(User $user): string
|
||||||
|
{
|
||||||
|
return match (true) {
|
||||||
|
app(SecurityConfirmation::class)->isFresh($user) => 'fresh',
|
||||||
|
$user->hasEnabledTwoFactorAuthentication() => 'two_factor',
|
||||||
|
(string) $user->password !== '' => 'password',
|
||||||
|
default => 'email_code',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function assertSecurityProof(User $user): void
|
||||||
|
{
|
||||||
|
$confirmation = app(SecurityConfirmation::class);
|
||||||
|
|
||||||
|
if ($confirmation->isFresh($user)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($user->hasEnabledTwoFactorAuthentication()) {
|
||||||
|
$this->assertSecondFactor($user);
|
||||||
|
} elseif ((string) $user->password !== '') {
|
||||||
|
try {
|
||||||
|
$this->validate(['securityPassword' => ['required', 'string', 'current_password']]);
|
||||||
|
} finally {
|
||||||
|
$this->reset('securityPassword');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$this->assertEmailCode($user);
|
||||||
|
}
|
||||||
|
|
||||||
|
$confirmation->markConfirmed($user);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft `securityCode` als TOTP (Fortify-Provider, replay-geschützt) oder
|
||||||
|
* als Wiederherstellungs-Code (wird dabei verbraucht). Fünf Fehlversuche
|
||||||
|
* pro Minute, damit eine Session die sechs Stellen nicht durchprobiert.
|
||||||
|
*/
|
||||||
|
protected function assertSecondFactor(User $user): void
|
||||||
|
{
|
||||||
|
$this->validate(['securityCode' => ['required', 'string']]);
|
||||||
|
|
||||||
|
$code = trim($this->securityCode);
|
||||||
|
$this->reset('securityCode');
|
||||||
|
|
||||||
|
$throttleKey = 'security-proof:'.$user->getKey();
|
||||||
|
|
||||||
|
if (RateLimiter::tooManyAttempts($throttleKey, 5)) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'securityCode' => __('Zu viele Fehlversuche. Bitte warten Sie eine Minute.'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$secret = decrypt($user->two_factor_secret);
|
||||||
|
|
||||||
|
if (app(TwoFactorAuthenticationProvider::class)->verify($secret, $code)) {
|
||||||
|
RateLimiter::clear($throttleKey);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array($code, $user->recoveryCodes() ?? [], true)) {
|
||||||
|
$user->replaceRecoveryCode($code);
|
||||||
|
RateLimiter::clear($throttleKey);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
RateLimiter::hit($throttleKey, 60);
|
||||||
|
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'securityCode' => __('Der Code ist ungültig. Geben Sie einen aktuellen Code aus Ihrer Authenticator-App oder einen Wiederherstellungs-Code ein.'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function assertEmailCode(User $user): void
|
||||||
|
{
|
||||||
|
$this->validate(['securityEmailCode' => ['required', 'string']], [
|
||||||
|
'securityEmailCode.required' => __('Bitte fordern Sie einen Bestätigungscode per E-Mail an und geben Sie ihn ein.'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$code = $this->securityEmailCode;
|
||||||
|
$this->reset('securityEmailCode');
|
||||||
|
|
||||||
|
if (! app(SecurityConfirmation::class)->verifyEmailCode($user, $code)) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'securityEmailCode' => __('Der Code ist ungültig oder abgelaufen. Bitte fordern Sie einen neuen Code an.'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->securityEmailCodeSent = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,7 @@ use App\Enums\EmailNotificationType;
|
||||||
use App\Mail\Concerns\Unsubscribable;
|
use App\Mail\Concerns\Unsubscribable;
|
||||||
use App\Models\PressRelease;
|
use App\Models\PressRelease;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Services\Auth\MailAccessLink;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Mail\Mailable;
|
use Illuminate\Mail\Mailable;
|
||||||
use Illuminate\Mail\Mailables\Content;
|
use Illuminate\Mail\Mailables\Content;
|
||||||
|
|
@ -35,6 +36,16 @@ class PressReleaseHeldForReview extends Mailable
|
||||||
|
|
||||||
public int $maxAttempts;
|
public int $maxAttempts;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Empfänger ohne Konto (API-Meldung, Roadmap 6.17): Adresse dieser Mail
|
||||||
|
* und Link, der einen Anmeldelink an sie schickt. Der Link zum Kundenbereich
|
||||||
|
* führte sonst auf eine Anmeldeseite für ein Konto, das der Empfänger
|
||||||
|
* nicht kennt.
|
||||||
|
*/
|
||||||
|
public ?string $accessEmail = null;
|
||||||
|
|
||||||
|
public ?string $accessUrl = null;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public readonly User $user,
|
public readonly User $user,
|
||||||
public readonly PressRelease $pressRelease,
|
public readonly PressRelease $pressRelease,
|
||||||
|
|
@ -55,6 +66,8 @@ class PressReleaseHeldForReview extends Mailable
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if ($recipientEmail !== null && mb_strtolower(trim($recipientEmail)) !== mb_strtolower((string) $user->email)) {
|
if ($recipientEmail !== null && mb_strtolower(trim($recipientEmail)) !== mb_strtolower((string) $user->email)) {
|
||||||
|
$this->accessEmail = trim($recipientEmail);
|
||||||
|
$this->accessUrl = app(MailAccessLink::class)->url($this->accessEmail, $pressRelease, ConversionSource::MailReview);
|
||||||
$this->configureAddressUnsubscribe($recipientEmail, EmailNotificationType::PressReleaseRejected);
|
$this->configureAddressUnsubscribe($recipientEmail, EmailNotificationType::PressReleaseRejected);
|
||||||
} else {
|
} else {
|
||||||
$this->configureUnsubscribe($user, EmailNotificationType::PressReleaseRejected);
|
$this->configureUnsubscribe($user, EmailNotificationType::PressReleaseRejected);
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ use App\Enums\EmailNotificationType;
|
||||||
use App\Mail\Concerns\Unsubscribable;
|
use App\Mail\Concerns\Unsubscribable;
|
||||||
use App\Models\PressRelease;
|
use App\Models\PressRelease;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Services\Auth\MailAccessLink;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Mail\Mailable;
|
use Illuminate\Mail\Mailable;
|
||||||
use Illuminate\Mail\Mailables\Content;
|
use Illuminate\Mail\Mailables\Content;
|
||||||
|
|
@ -39,6 +40,16 @@ class PressReleasePublished extends Mailable
|
||||||
*/
|
*/
|
||||||
public ?string $manageNewsroomUrl = null;
|
public ?string $manageNewsroomUrl = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Empfänger ohne Konto (API-Meldung an Pressekontakt oder Firmen-E-Mail,
|
||||||
|
* Roadmap 6.17): Adresse, an die diese Mail geht, und der Link, der mit
|
||||||
|
* einem Klick einen Anmeldelink an genau diese Adresse schickt. Null für
|
||||||
|
* Kunden, die selbst eingereicht haben — die kennen ihr Konto.
|
||||||
|
*/
|
||||||
|
public ?string $accessEmail = null;
|
||||||
|
|
||||||
|
public ?string $accessUrl = null;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public readonly User $user,
|
public readonly User $user,
|
||||||
public readonly PressRelease $pressRelease,
|
public readonly PressRelease $pressRelease,
|
||||||
|
|
@ -72,6 +83,8 @@ class PressReleasePublished extends Mailable
|
||||||
// Geht die Mail an einen Empfänger ohne Konto (API-Meldung), meldet
|
// Geht die Mail an einen Empfänger ohne Konto (API-Meldung), meldet
|
||||||
// der Abmeldelink diese Adresse ab — nicht das Konto des Einlieferers.
|
// der Abmeldelink diese Adresse ab — nicht das Konto des Einlieferers.
|
||||||
if ($recipientEmail !== null && mb_strtolower(trim($recipientEmail)) !== mb_strtolower((string) $user->email)) {
|
if ($recipientEmail !== null && mb_strtolower(trim($recipientEmail)) !== mb_strtolower((string) $user->email)) {
|
||||||
|
$this->accessEmail = trim($recipientEmail);
|
||||||
|
$this->accessUrl = app(MailAccessLink::class)->url($this->accessEmail, $pressRelease, ConversionSource::MailPublish);
|
||||||
$this->configureAddressUnsubscribe($recipientEmail, EmailNotificationType::PressReleasePublished);
|
$this->configureAddressUnsubscribe($recipientEmail, EmailNotificationType::PressReleasePublished);
|
||||||
} else {
|
} else {
|
||||||
$this->configureUnsubscribe($user, EmailNotificationType::PressReleasePublished);
|
$this->configureUnsubscribe($user, EmailNotificationType::PressReleasePublished);
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ use App\Enums\Portal;
|
||||||
use App\Mail\Concerns\Unsubscribable;
|
use App\Mail\Concerns\Unsubscribable;
|
||||||
use App\Models\PressRelease;
|
use App\Models\PressRelease;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Services\Auth\MailAccessLink;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Mail\Mailable;
|
use Illuminate\Mail\Mailable;
|
||||||
use Illuminate\Mail\Mailables\Content;
|
use Illuminate\Mail\Mailables\Content;
|
||||||
|
|
@ -29,6 +30,16 @@ class PressReleaseRejected extends Mailable
|
||||||
*/
|
*/
|
||||||
public string $recipientName;
|
public string $recipientName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Empfänger ohne Konto (API-Meldung, Roadmap 6.17/6.18): Adresse dieser
|
||||||
|
* Mail und Link, der einen Anmeldelink an sie schickt. Nach der Anmeldung
|
||||||
|
* kann der Pressekontakt die Meldung überarbeiten und kostenlos erneut
|
||||||
|
* einreichen (Entscheidung 14.10.2026).
|
||||||
|
*/
|
||||||
|
public ?string $accessEmail = null;
|
||||||
|
|
||||||
|
public ?string $accessUrl = null;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public readonly User $user,
|
public readonly User $user,
|
||||||
public readonly PressRelease $pressRelease,
|
public readonly PressRelease $pressRelease,
|
||||||
|
|
@ -53,6 +64,8 @@ class PressReleaseRejected extends Mailable
|
||||||
// Geht die Mail an einen Empfänger ohne Konto (API-Meldung), meldet
|
// Geht die Mail an einen Empfänger ohne Konto (API-Meldung), meldet
|
||||||
// der Abmeldelink diese Adresse ab — nicht das Konto des Einlieferers.
|
// der Abmeldelink diese Adresse ab — nicht das Konto des Einlieferers.
|
||||||
if ($recipientEmail !== null && mb_strtolower(trim($recipientEmail)) !== mb_strtolower((string) $user->email)) {
|
if ($recipientEmail !== null && mb_strtolower(trim($recipientEmail)) !== mb_strtolower((string) $user->email)) {
|
||||||
|
$this->accessEmail = trim($recipientEmail);
|
||||||
|
$this->accessUrl = app(MailAccessLink::class)->url($this->accessEmail, $pressRelease, ConversionSource::MailReject);
|
||||||
$this->configureAddressUnsubscribe($recipientEmail, EmailNotificationType::PressReleaseRejected);
|
$this->configureAddressUnsubscribe($recipientEmail, EmailNotificationType::PressReleaseRejected);
|
||||||
} else {
|
} else {
|
||||||
$this->configureUnsubscribe($user, EmailNotificationType::PressReleaseRejected);
|
$this->configureUnsubscribe($user, EmailNotificationType::PressReleaseRejected);
|
||||||
|
|
|
||||||
54
app/Mail/SecurityChangeNotice.php
Normal file
54
app/Mail/SecurityChangeNotice.php
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Mail;
|
||||||
|
|
||||||
|
use App\Enums\SecurityChange;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Mail\Mailable;
|
||||||
|
use Illuminate\Mail\Mailables\Content;
|
||||||
|
use Illuminate\Mail\Mailables\Envelope;
|
||||||
|
use Illuminate\Mail\Mailables\Headers;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hinweis an die (bisherige) Kontoadresse nach einer sicherheitsrelevanten
|
||||||
|
* Änderung (Sicherheitsaudit 07.10.2026, S-06). Enthält bewusst keinen Link
|
||||||
|
* mit Anmeldewirkung und keine Codes — nur Art und Zeitpunkt der Änderung.
|
||||||
|
*/
|
||||||
|
class SecurityChangeNotice extends Mailable implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public User $user,
|
||||||
|
public SecurityChange $change,
|
||||||
|
public string $occurredAt,
|
||||||
|
public ?string $newEmail = null,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function headers(): Headers
|
||||||
|
{
|
||||||
|
return new Headers(text: [(string) config('bounces.reference_header', 'X-Pk-Ref') => 'user:'.$this->user->id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function envelope(): Envelope
|
||||||
|
{
|
||||||
|
return new Envelope(
|
||||||
|
subject: 'Sicherheitshinweis zu Ihrem pressekonto'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function content(): Content
|
||||||
|
{
|
||||||
|
return new Content(
|
||||||
|
view: 'emails.auth.security-change-notice'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function attachments(): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
51
app/Mail/SecurityConfirmationCode.php
Normal file
51
app/Mail/SecurityConfirmationCode.php
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Mail;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Mail\Mailable;
|
||||||
|
use Illuminate\Mail\Mailables\Content;
|
||||||
|
use Illuminate\Mail\Mailables\Envelope;
|
||||||
|
use Illuminate\Mail\Mailables\Headers;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Einmal-Code für den Identitätsnachweis von Konten ohne Passwort und ohne
|
||||||
|
* 2FA (Sicherheitsaudit 07.10.2026, S-06).
|
||||||
|
*/
|
||||||
|
class SecurityConfirmationCode extends Mailable implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public User $user,
|
||||||
|
public string $code,
|
||||||
|
public string $expiresAt,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function headers(): Headers
|
||||||
|
{
|
||||||
|
return new Headers(text: [(string) config('bounces.reference_header', 'X-Pk-Ref') => 'user:'.$this->user->id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function envelope(): Envelope
|
||||||
|
{
|
||||||
|
return new Envelope(
|
||||||
|
subject: 'Ihr Bestätigungscode für pressekonto'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function content(): Content
|
||||||
|
{
|
||||||
|
return new Content(
|
||||||
|
view: 'emails.auth.security-confirmation-code'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function attachments(): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
51
app/Mail/SocialAccountLinkConfirmation.php
Normal file
51
app/Mail/SocialAccountLinkConfirmation.php
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Mail;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Mail\Mailable;
|
||||||
|
use Illuminate\Mail\Mailables\Content;
|
||||||
|
use Illuminate\Mail\Mailables\Envelope;
|
||||||
|
use Illuminate\Mail\Mailables\Headers;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bestätigungslink für die Erstverknüpfung eines Google-Kontos mit einem
|
||||||
|
* bestehenden Konto (Sicherheitsaudit 07.10.2026, S-05).
|
||||||
|
*/
|
||||||
|
class SocialAccountLinkConfirmation extends Mailable implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public User $user,
|
||||||
|
public string $confirmUrl,
|
||||||
|
public string $expiresAt,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function headers(): Headers
|
||||||
|
{
|
||||||
|
return new Headers(text: [(string) config('bounces.reference_header', 'X-Pk-Ref') => 'user:'.$this->user->id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function envelope(): Envelope
|
||||||
|
{
|
||||||
|
return new Envelope(
|
||||||
|
subject: 'Google-Anmeldung für Ihr pressekonto bestätigen'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function content(): Content
|
||||||
|
{
|
||||||
|
return new Content(
|
||||||
|
view: 'emails.auth.social-account-link-confirmation'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function attachments(): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,8 @@
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use App\Enums\ActivityType;
|
use App\Enums\ActivityType;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\MassPrunable;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||||
|
|
@ -13,6 +15,8 @@ use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||||
*/
|
*/
|
||||||
class ActivityEvent extends Model
|
class ActivityEvent extends Model
|
||||||
{
|
{
|
||||||
|
use MassPrunable;
|
||||||
|
|
||||||
public $timestamps = false;
|
public $timestamps = false;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
|
|
@ -50,4 +54,14 @@ class ActivityEvent extends Model
|
||||||
{
|
{
|
||||||
return $this->morphTo();
|
return $this->morphTo();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Einträge nach der Löschfrist (config monitoring.retention, 395 Tage).
|
||||||
|
*
|
||||||
|
* @return Builder<static>
|
||||||
|
*/
|
||||||
|
public function prunable(): Builder
|
||||||
|
{
|
||||||
|
return static::query()->where('occurred_at', '<', now()->subDays((int) config('monitoring.retention.activity_events_days')));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,16 @@
|
||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\MassPrunable;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Laravel\Sanctum\PersonalAccessToken;
|
use Laravel\Sanctum\PersonalAccessToken;
|
||||||
|
|
||||||
class ApiUsageLog extends Model
|
class ApiUsageLog extends Model
|
||||||
{
|
{
|
||||||
|
use MassPrunable;
|
||||||
|
|
||||||
public $timestamps = false;
|
public $timestamps = false;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
|
|
@ -45,4 +49,14 @@ class ApiUsageLog extends Model
|
||||||
{
|
{
|
||||||
return $this->belongsTo(PersonalAccessToken::class);
|
return $this->belongsTo(PersonalAccessToken::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zugriffe nach der Löschfrist (config monitoring.retention, 90 Tage).
|
||||||
|
*
|
||||||
|
* @return Builder<static>
|
||||||
|
*/
|
||||||
|
public function prunable(): Builder
|
||||||
|
{
|
||||||
|
return static::query()->where('requested_at', '<', now()->subDays((int) config('monitoring.retention.api_usage_logs_days')));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
71
app/Models/ChangelogEntry.php
Normal file
71
app/Models/ChangelogEntry.php
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Enums\ChangelogCategory;
|
||||||
|
use App\Enums\ChangelogStatus;
|
||||||
|
use Database\Factories\ChangelogEntryFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein Changelog-Eintrag (Decision-Update Changelog): Entwurf aus dem Deploy,
|
||||||
|
* öffentlich erst nach Freigabe.
|
||||||
|
*/
|
||||||
|
class ChangelogEntry extends Model
|
||||||
|
{
|
||||||
|
/** @use HasFactory<ChangelogEntryFactory> */
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'released_on',
|
||||||
|
'release_tag',
|
||||||
|
'category',
|
||||||
|
'text',
|
||||||
|
'status',
|
||||||
|
'source_key',
|
||||||
|
'commit_subject',
|
||||||
|
'review_note',
|
||||||
|
'article_worthy',
|
||||||
|
'published_at',
|
||||||
|
'reviewed_by_user_id',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'released_on' => 'date',
|
||||||
|
'category' => ChangelogCategory::class,
|
||||||
|
'status' => ChangelogStatus::class,
|
||||||
|
'article_worthy' => 'boolean',
|
||||||
|
'published_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return BelongsTo<User, $this>
|
||||||
|
*/
|
||||||
|
public function reviewer(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'reviewed_by_user_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Builder<ChangelogEntry> $query
|
||||||
|
*/
|
||||||
|
public function scopePublished(Builder $query): void
|
||||||
|
{
|
||||||
|
$query->where('status', ChangelogStatus::Published->value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Öffentlicher Text: Sicherheit immer nur als Sammelzeile (E5), egal
|
||||||
|
* was im Entwurf steht.
|
||||||
|
*/
|
||||||
|
public function publicText(): string
|
||||||
|
{
|
||||||
|
return $this->category === ChangelogCategory::Security ? ChangelogCategory::SECURITY_LINE : $this->text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -161,6 +161,54 @@ class Company extends Model
|
||||||
return $this->gone_at !== null;
|
return $this->gone_at !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Öffentliche Newsroom-Seiten dieser Firma, je Portal — nur dort, wo sie
|
||||||
|
* schon erscheint: Der Newsroom ist öffentlich ab der ersten sichtbaren
|
||||||
|
* Meldung in dieser Sprache (Roadmap 1b.1). Deutsch hat Vorrang, sonst
|
||||||
|
* Englisch. Für die Verknüpfung „Firma ↔ Newsroom“ im Kunden- und
|
||||||
|
* Adminbereich.
|
||||||
|
*
|
||||||
|
* @return list<array{portal: Portal, label: string, url: string}>
|
||||||
|
*/
|
||||||
|
public function publicNewsroomLinks(): array
|
||||||
|
{
|
||||||
|
if (! $this->slug || ! $this->is_active || $this->isGone()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$portals = $this->portal === Portal::Both ? [Portal::Presseecho, Portal::Businessportal24] : [$this->portal];
|
||||||
|
$links = [];
|
||||||
|
|
||||||
|
foreach ($portals as $portal) {
|
||||||
|
$languages = PressRelease::query()
|
||||||
|
->withoutGlobalScope(PortalScope::class)
|
||||||
|
->where('company_id', $this->getKey())
|
||||||
|
->whereIn('portal', [$portal->value, Portal::Both->value])
|
||||||
|
->visibleInArchive()
|
||||||
|
->whereIn('language', ['de', 'en'])
|
||||||
|
->distinct()
|
||||||
|
->pluck('language')
|
||||||
|
->all();
|
||||||
|
|
||||||
|
if ($languages === []) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$links[] = [
|
||||||
|
'portal' => $portal,
|
||||||
|
'label' => $portal->label(),
|
||||||
|
'url' => sprintf(
|
||||||
|
'%s/%s/newsroom/%s',
|
||||||
|
rtrim((string) ($portal === Portal::Presseecho ? config('domains.domain_presseecho_url') : config('domains.domain_businessportal_url')), '/'),
|
||||||
|
in_array('de', $languages, true) ? 'de' : 'en',
|
||||||
|
$this->slug,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $links;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Trägt eine fremde, lebende Firma auf einem ANDEREN Portal diesen Slug?
|
* Trägt eine fremde, lebende Firma auf einem ANDEREN Portal diesen Slug?
|
||||||
* Dann darf diese Firma nicht `both` werden: Die öffentliche Auflösung
|
* Dann darf diese Firma nicht `both` werden: Die öffentliche Auflösung
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ use App\Enums\ContactMessageTopic;
|
||||||
use Database\Factories\ContactMessageFactory;
|
use Database\Factories\ContactMessageFactory;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\MassPrunable;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
|
@ -21,6 +22,11 @@ class ContactMessage extends Model
|
||||||
/** @use HasFactory<ContactMessageFactory> */
|
/** @use HasFactory<ContactMessageFactory> */
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
|
use MassPrunable;
|
||||||
|
|
||||||
|
/** Tage, die Spam zur Nachprüfung in der Quarantäne bleibt. */
|
||||||
|
public const SPAM_RETENTION_DAYS = 30;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'portal',
|
'portal',
|
||||||
'topic',
|
'topic',
|
||||||
|
|
@ -47,6 +53,87 @@ class ContactMessage extends Model
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spam-Quarantäne nach Ablauf der Prüffrist löschen (model:prune).
|
||||||
|
*
|
||||||
|
* @return Builder<ContactMessage>
|
||||||
|
*/
|
||||||
|
public function prunable(): Builder
|
||||||
|
{
|
||||||
|
return self::query()
|
||||||
|
->where('status', ContactMessageStatus::Spam->value)
|
||||||
|
->where('created_at', '<', now()->subDays(self::SPAM_RETENTION_DAYS));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isSpam(): bool
|
||||||
|
{
|
||||||
|
return $this->status === ContactMessageStatus::Spam;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In die Spam-Quarantäne legen (Admin, Sammelaktion oder Altbestand).
|
||||||
|
* Ein vorhandener Filtergrund bleibt erhalten.
|
||||||
|
*/
|
||||||
|
public function markAsSpam(?User $by, string $reason = 'manual'): void
|
||||||
|
{
|
||||||
|
$this->update([
|
||||||
|
'status' => ContactMessageStatus::Spam->value,
|
||||||
|
'payload' => [...($this->payload ?? []), 'spam_reason' => $this->payload['spam_reason'] ?? $reason],
|
||||||
|
'resolved_by_user_id' => $by?->id,
|
||||||
|
'resolved_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fehlalarm: zurück in die offene Queue. Gibt zurück, ob die Team-Mail
|
||||||
|
* noch aussteht: Hat der Filter die Anfrage beim Eingang abgefangen, ging
|
||||||
|
* keine raus — beim ersten Zurückholen wird sie nachgeholt. Von Hand
|
||||||
|
* markierter Spam hatte seine Mail schon.
|
||||||
|
*/
|
||||||
|
public function releaseFromSpam(): bool
|
||||||
|
{
|
||||||
|
$payload = $this->payload ?? [];
|
||||||
|
$mailPending = isset($payload['spam_reason'])
|
||||||
|
&& $payload['spam_reason'] !== 'manual'
|
||||||
|
&& ! isset($payload['released_from_spam_at']);
|
||||||
|
|
||||||
|
$this->update([
|
||||||
|
'status' => ContactMessageStatus::Open->value,
|
||||||
|
'payload' => [...$payload, 'released_from_spam_at' => now()->toIso8601String()],
|
||||||
|
'resolved_by_user_id' => null,
|
||||||
|
'resolved_at' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $mailPending;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grund der Quarantäne für die Admin-Ansicht (ContactSpamFilter).
|
||||||
|
*/
|
||||||
|
public function spamReasonLabel(): ?string
|
||||||
|
{
|
||||||
|
$reason = $this->payload['spam_reason'] ?? null;
|
||||||
|
|
||||||
|
return match ($reason) {
|
||||||
|
null => null,
|
||||||
|
'link' => 'Link im Text',
|
||||||
|
'page_title' => 'Floskel mit Seitentitel',
|
||||||
|
'gibberish' => 'Zeichensalat',
|
||||||
|
'blacklist' => 'Begriff der Sperrliste',
|
||||||
|
'too_fast' => 'Zu schnell abgesendet',
|
||||||
|
'manual' => 'Von Hand markiert',
|
||||||
|
default => (string) $reason,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Angenommen, obwohl Cloudflare Turnstile nicht erreichbar war.
|
||||||
|
*/
|
||||||
|
public function skippedBotCheck(): bool
|
||||||
|
{
|
||||||
|
return ($this->payload['bot_check'] ?? null) === 'unavailable';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Öffentliche Vorgangsnummer — analog zu Legal-Requests ("M-{id}").
|
* Öffentliche Vorgangsnummer — analog zu Legal-Requests ("M-{id}").
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -19,14 +19,16 @@ use Illuminate\Notifications\Notifiable;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Laravel\Cashier\Billable;
|
use Laravel\Cashier\Billable;
|
||||||
|
use Laravel\Fortify\Contracts\PasskeyUser;
|
||||||
|
use Laravel\Fortify\PasskeyAuthenticatable;
|
||||||
use Laravel\Fortify\TwoFactorAuthenticatable;
|
use Laravel\Fortify\TwoFactorAuthenticatable;
|
||||||
use Laravel\Sanctum\HasApiTokens;
|
use Laravel\Sanctum\HasApiTokens;
|
||||||
use Spatie\Permission\Traits\HasRoles;
|
use Spatie\Permission\Traits\HasRoles;
|
||||||
|
|
||||||
class User extends Authenticatable implements MustVerifyEmail
|
class User extends Authenticatable implements MustVerifyEmail, PasskeyUser
|
||||||
{
|
{
|
||||||
/** @use HasFactory<UserFactory> */
|
/** @use HasFactory<UserFactory> */
|
||||||
use Billable, HasApiTokens, HasFactory, HasRoles, Notifiable, SoftDeletes, TwoFactorAuthenticatable;
|
use Billable, HasApiTokens, HasFactory, HasRoles, Notifiable, PasskeyAuthenticatable, SoftDeletes, TwoFactorAuthenticatable;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The attributes that are mass assignable.
|
* The attributes that are mass assignable.
|
||||||
|
|
@ -85,6 +87,7 @@ class User extends Authenticatable implements MustVerifyEmail
|
||||||
'deleted_at' => 'datetime',
|
'deleted_at' => 'datetime',
|
||||||
'password' => 'hashed',
|
'password' => 'hashed',
|
||||||
'press_release_quota_used_this_month' => 'integer',
|
'press_release_quota_used_this_month' => 'integer',
|
||||||
|
'sessions_revoked_at_ms' => 'integer',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
namespace App\Observers;
|
namespace App\Observers;
|
||||||
|
|
||||||
use App\Enums\ActivityType;
|
use App\Enums\ActivityType;
|
||||||
|
use App\Enums\ContactMessageStatus;
|
||||||
use App\Models\ContactMessage;
|
use App\Models\ContactMessage;
|
||||||
use App\Models\LegalRequest;
|
use App\Models\LegalRequest;
|
||||||
use App\Models\NewsletterSubscription;
|
use App\Models\NewsletterSubscription;
|
||||||
|
|
@ -23,6 +24,8 @@ class ActivityLogObserver
|
||||||
public function created(Model $model): void
|
public function created(Model $model): void
|
||||||
{
|
{
|
||||||
match (true) {
|
match (true) {
|
||||||
|
// Spam-Quarantäne gehört nicht in den Verlauf.
|
||||||
|
$model instanceof ContactMessage && $model->status === ContactMessageStatus::Spam => null,
|
||||||
$model instanceof ContactMessage => $this->activityLog->record(
|
$model instanceof ContactMessage => $this->activityLog->record(
|
||||||
ActivityType::ContactRequest,
|
ActivityType::ContactRequest,
|
||||||
sprintf('Kontaktanfrage „%s“ von %s (%s).', $model->topic->label(), $model->name, $model->email),
|
sprintf('Kontaktanfrage „%s“ von %s (%s).', $model->topic->label(), $model->name, $model->email),
|
||||||
|
|
|
||||||
105
app/Observers/CompanyMembershipObserver.php
Normal file
105
app/Observers/CompanyMembershipObserver.php
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Observers;
|
||||||
|
|
||||||
|
use App\Models\Company;
|
||||||
|
use App\Models\Contact;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Auth\ContactAccessService;
|
||||||
|
use App\Support\EmailQuery;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bewertet automatisch vergebene Firmenrechte neu, sobald sich ihre
|
||||||
|
* Grundlage ändert (Sicherheitsaudit 07.10.2026, S-03): Kontakt- oder
|
||||||
|
* Firmenadresse geändert, Kontakt in eine andere Firma verschoben, gelöscht
|
||||||
|
* oder wiederhergestellt, Login-Adresse eines Users geändert.
|
||||||
|
*
|
||||||
|
* Bisher geschah das nur beim Login — ein aktiver Nutzer behielt entzogene
|
||||||
|
* Rechte so lange, wie seine Session lief. Am Model statt in den
|
||||||
|
* Formularen: Kunden-Newsroom, Admin-CRM, API und Importer schreiben dieselben
|
||||||
|
* Felder. Läuft synchron in derselben Transaktion; ein Rollback nimmt die
|
||||||
|
* Rechteänderung mit zurück. Massen-Updates per Query-Builder lösen keine
|
||||||
|
* Events aus — dafür `php artisan memberships:resync-auto`.
|
||||||
|
*/
|
||||||
|
class CompanyMembershipObserver
|
||||||
|
{
|
||||||
|
public function __construct(private readonly ContactAccessService $contactAccess) {}
|
||||||
|
|
||||||
|
public function updated(Model $model): void
|
||||||
|
{
|
||||||
|
if ($model instanceof User) {
|
||||||
|
if ($model->wasChanged('email')) {
|
||||||
|
$this->contactAccess->syncMembershipsForEmail($model);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$relevant = $model instanceof Contact ? ['email', 'company_id'] : ['email'];
|
||||||
|
|
||||||
|
if (! $model->wasChanged($relevant)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->resync(
|
||||||
|
emails: [$model->getOriginal('email'), $model->getAttribute('email')],
|
||||||
|
companyIds: $this->companyIdsOf($model, withOriginal: true),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleted(Model $model): void
|
||||||
|
{
|
||||||
|
if ($model instanceof User) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->resync([$model->getAttribute('email')], $this->companyIdsOf($model));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function restored(Model $model): void
|
||||||
|
{
|
||||||
|
$this->deleted($model);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<int>
|
||||||
|
*/
|
||||||
|
private function companyIdsOf(Company|Contact|Model $model, bool $withOriginal = false): array
|
||||||
|
{
|
||||||
|
$ids = $model instanceof Contact
|
||||||
|
? [$model->getAttribute('company_id'), $withOriginal ? $model->getOriginal('company_id') : null]
|
||||||
|
: [$model->getKey()];
|
||||||
|
|
||||||
|
return array_values(array_unique(array_map('intval', array_filter($ids))));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alle User, deren automatische Rechte an den betroffenen Firmen hängen,
|
||||||
|
* sowie alle User mit einer der betroffenen Adressen.
|
||||||
|
*
|
||||||
|
* @param list<string|null> $emails
|
||||||
|
* @param list<int> $companyIds
|
||||||
|
*/
|
||||||
|
private function resync(array $emails, array $companyIds): void
|
||||||
|
{
|
||||||
|
$userIds = $companyIds === [] ? collect() : DB::table('company_user')
|
||||||
|
->whereIn('company_id', $companyIds)
|
||||||
|
->whereIn('granted_via', ['auto', 'auto-upgrade'])
|
||||||
|
->pluck('user_id');
|
||||||
|
|
||||||
|
/** @var Collection<int, User> $users */
|
||||||
|
$users = User::query()->whereIn('id', $userIds->all())->get();
|
||||||
|
|
||||||
|
foreach (array_unique(array_filter(array_map(
|
||||||
|
fn (?string $email): string => mb_strtolower(trim((string) $email)),
|
||||||
|
$emails,
|
||||||
|
))) as $email) {
|
||||||
|
$users = $users->merge(EmailQuery::whereEmail(User::query(), $email)->get());
|
||||||
|
}
|
||||||
|
|
||||||
|
$users->unique('id')->each(fn (User $user) => $this->contactAccess->syncMembershipsForEmail($user));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,9 +2,16 @@
|
||||||
|
|
||||||
namespace App\Policies;
|
namespace App\Policies;
|
||||||
|
|
||||||
|
use App\Authorization\Permission;
|
||||||
use App\Models\LegacyInvoice;
|
use App\Models\LegacyInvoice;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alt-Rechnungen aus dem Legacy-Import. Kunden sehen ihre eigenen Belege,
|
||||||
|
* fremde nur mit `billing:manage` — der Panel-Zugang (Editor) allein reicht
|
||||||
|
* nicht (Sicherheitsaudit 07.10.2026, S-01). Gilt für die Kunden- und die
|
||||||
|
* Admin-Downloadroute gleichermaßen.
|
||||||
|
*/
|
||||||
class LegacyInvoicePolicy
|
class LegacyInvoicePolicy
|
||||||
{
|
{
|
||||||
public function before(User $user): ?bool
|
public function before(User $user): ?bool
|
||||||
|
|
@ -19,7 +26,7 @@ class LegacyInvoicePolicy
|
||||||
|
|
||||||
public function view(User $user, LegacyInvoice $legacyInvoice): bool
|
public function view(User $user, LegacyInvoice $legacyInvoice): bool
|
||||||
{
|
{
|
||||||
if ($user->canAccessAdmin()) {
|
if ($user->can(Permission::BillingManage)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ use App\Contracts\NewsletterSyncClient;
|
||||||
use App\Exceptions\MailDeliveryPausedException;
|
use App\Exceptions\MailDeliveryPausedException;
|
||||||
use App\Helpers\ThemeHelper;
|
use App\Helpers\ThemeHelper;
|
||||||
use App\Http\Middleware\BlockAdminDuringImpersonation;
|
use App\Http\Middleware\BlockAdminDuringImpersonation;
|
||||||
|
use App\Http\Middleware\EnsureStrongAdminAuthentication;
|
||||||
use App\Http\Middleware\EnsureUserIsAdmin;
|
use App\Http\Middleware\EnsureUserIsAdmin;
|
||||||
use App\Http\Middleware\EnsureUserIsCustomer;
|
use App\Http\Middleware\EnsureUserIsCustomer;
|
||||||
use App\Http\Middleware\LogSlowAdminRequests;
|
use App\Http\Middleware\LogSlowAdminRequests;
|
||||||
|
|
@ -31,6 +32,7 @@ use App\Models\User;
|
||||||
use App\Models\WithdrawalRequest;
|
use App\Models\WithdrawalRequest;
|
||||||
use App\Observers\ActivityLogObserver;
|
use App\Observers\ActivityLogObserver;
|
||||||
use App\Observers\AdminPerformanceCacheObserver;
|
use App\Observers\AdminPerformanceCacheObserver;
|
||||||
|
use App\Observers\CompanyMembershipObserver;
|
||||||
use App\Observers\CompanyPublicCacheObserver;
|
use App\Observers\CompanyPublicCacheObserver;
|
||||||
use App\Observers\EmailDomainObserver;
|
use App\Observers\EmailDomainObserver;
|
||||||
use App\Observers\ImageRightsConfirmationObserver;
|
use App\Observers\ImageRightsConfirmationObserver;
|
||||||
|
|
@ -64,6 +66,7 @@ use Illuminate\Support\Facades\Gate;
|
||||||
use Illuminate\Support\Facades\Queue;
|
use Illuminate\Support\Facades\Queue;
|
||||||
use Illuminate\Support\Facades\URL;
|
use Illuminate\Support\Facades\URL;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
use Illuminate\Validation\Rules\Password;
|
||||||
use Laravel\Cashier\Cashier;
|
use Laravel\Cashier\Cashier;
|
||||||
use Laravel\Fortify\Events\TwoFactorAuthenticationFailed;
|
use Laravel\Fortify\Events\TwoFactorAuthenticationFailed;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
|
|
@ -106,6 +109,18 @@ class AppServiceProvider extends ServiceProvider
|
||||||
// Ausgelieferte Version in `php artisan about` (Datei RELEASE bzw. APP_VERSION).
|
// Ausgelieferte Version in `php artisan about` (Datei RELEASE bzw. APP_VERSION).
|
||||||
AboutCommand::add('Pressekonto', fn (): array => ['Version' => config('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.
|
// Die Generations-Nummer der Seiten-Caches wird prozessintern gemerkt.
|
||||||
// Ein Queue-Worker lebt über beliebig viele Jobs hinweg und würde sonst
|
// Ein Queue-Worker lebt über beliebig viele Jobs hinweg und würde sonst
|
||||||
// dauerhaft mit dem Stand seines ersten Zugriffs arbeiten — Keys einer
|
// dauerhaft mit dem Stand seines ersten Zugriffs arbeiten — Keys einer
|
||||||
|
|
@ -243,6 +258,12 @@ class AppServiceProvider extends ServiceProvider
|
||||||
Company::observe(EmailDomainObserver::class);
|
Company::observe(EmailDomainObserver::class);
|
||||||
Contact::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
|
// Öffentliche Seiten-Caches bei Löschung/Deaktivierung einer Firma
|
||||||
// verwerfen — auf jedem Löschweg, nicht nur im Admin-Formular (F13).
|
// verwerfen — auf jedem Löschweg, nicht nur im Admin-Formular (F13).
|
||||||
Company::observe(CompanyPublicCacheObserver::class);
|
Company::observe(CompanyPublicCacheObserver::class);
|
||||||
|
|
@ -266,6 +287,7 @@ class AppServiceProvider extends ServiceProvider
|
||||||
Livewire::addPersistentMiddleware([
|
Livewire::addPersistentMiddleware([
|
||||||
BlockAdminDuringImpersonation::class,
|
BlockAdminDuringImpersonation::class,
|
||||||
EnsureUserIsAdmin::class,
|
EnsureUserIsAdmin::class,
|
||||||
|
EnsureStrongAdminAuthentication::class,
|
||||||
EnsureUserIsCustomer::class,
|
EnsureUserIsCustomer::class,
|
||||||
LogSlowAdminRequests::class,
|
LogSlowAdminRequests::class,
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,10 @@ use App\Actions\Fortify\CreateNewUser;
|
||||||
use App\Actions\Fortify\ResetUserPassword;
|
use App\Actions\Fortify\ResetUserPassword;
|
||||||
use App\Actions\Fortify\UpdateUserPassword;
|
use App\Actions\Fortify\UpdateUserPassword;
|
||||||
use App\Http\Responses\FailedTwoFactorChallengeResponse;
|
use App\Http\Responses\FailedTwoFactorChallengeResponse;
|
||||||
|
use App\Http\Responses\PasskeyLoginResponse;
|
||||||
use App\Http\Responses\RoleAwareLoginResponse;
|
use App\Http\Responses\RoleAwareLoginResponse;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Auth\LoginIpThrottle;
|
||||||
use Illuminate\Cache\RateLimiting\Limit;
|
use Illuminate\Cache\RateLimiting\Limit;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\RateLimiter;
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
|
|
@ -17,6 +20,10 @@ use Laravel\Fortify\Contracts\FailedTwoFactorLoginResponse;
|
||||||
use Laravel\Fortify\Contracts\LoginResponse;
|
use Laravel\Fortify\Contracts\LoginResponse;
|
||||||
use Laravel\Fortify\Contracts\TwoFactorLoginResponse;
|
use Laravel\Fortify\Contracts\TwoFactorLoginResponse;
|
||||||
use Laravel\Fortify\Fortify;
|
use Laravel\Fortify\Fortify;
|
||||||
|
use Laravel\Passkeys\Contracts\PasskeyLoginResponse as PasskeyLoginResponseContract;
|
||||||
|
use Laravel\Passkeys\Contracts\PasskeyUser;
|
||||||
|
use Laravel\Passkeys\Passkey;
|
||||||
|
use Laravel\Passkeys\Passkeys;
|
||||||
|
|
||||||
class FortifyServiceProvider extends ServiceProvider
|
class FortifyServiceProvider extends ServiceProvider
|
||||||
{
|
{
|
||||||
|
|
@ -30,6 +37,7 @@ class FortifyServiceProvider extends ServiceProvider
|
||||||
// Fortifys Default leitet auf die nicht registrierte Route
|
// Fortifys Default leitet auf die nicht registrierte Route
|
||||||
// `two-factor.login` (views => false) – zurück zur Livewire-Challenge.
|
// `two-factor.login` (views => false) – zurück zur Livewire-Challenge.
|
||||||
$this->app->singleton(FailedTwoFactorLoginResponse::class, FailedTwoFactorChallengeResponse::class);
|
$this->app->singleton(FailedTwoFactorLoginResponse::class, FailedTwoFactorChallengeResponse::class);
|
||||||
|
$this->app->singleton(PasskeyLoginResponseContract::class, PasskeyLoginResponse::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -46,6 +54,20 @@ class FortifyServiceProvider extends ServiceProvider
|
||||||
// Keine View-Definitionen, die Auth-Seiten sind Livewire-Komponenten
|
// Keine View-Definitionen, die Auth-Seiten sind Livewire-Komponenten
|
||||||
|
|
||||||
RateLimiter::for('login', function (Request $request) {
|
RateLimiter::for('login', function (Request $request) {
|
||||||
|
// IP-weite Obergrenze über alle Konten (LoginIpThrottle) — der
|
||||||
|
// Zähler ist derselbe wie in der Livewire-Anmeldung.
|
||||||
|
$ipThrottle = app(LoginIpThrottle::class);
|
||||||
|
|
||||||
|
if ($ipThrottle->isBlocked($request->ip())) {
|
||||||
|
$seconds = $ipThrottle->availableIn($request->ip());
|
||||||
|
|
||||||
|
return $request->expectsJson()
|
||||||
|
? response()->json(['message' => __('auth.throttle', ['seconds' => $seconds, 'minutes' => ceil($seconds / 60)])], 429, ['Retry-After' => $seconds])
|
||||||
|
: back()->withInput($request->only(Fortify::username()))->withErrors([
|
||||||
|
Fortify::username() => __('auth.throttle', ['seconds' => $seconds, 'minutes' => ceil($seconds / 60)]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
$throttleKey = Str::transliterate(Str::lower($request->input(Fortify::username())).'|'.$request->ip());
|
$throttleKey = Str::transliterate(Str::lower($request->input(Fortify::username())).'|'.$request->ip());
|
||||||
|
|
||||||
return Limit::perMinute(5)->by($throttleKey);
|
return Limit::perMinute(5)->by($throttleKey);
|
||||||
|
|
@ -54,5 +76,15 @@ class FortifyServiceProvider extends ServiceProvider
|
||||||
RateLimiter::for('two-factor', function (Request $request) {
|
RateLimiter::for('two-factor', function (Request $request) {
|
||||||
return Limit::perMinute(5)->by($request->session()->get('login.id'));
|
return Limit::perMinute(5)->by($request->session()->get('login.id'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
RateLimiter::for('passkeys', function (Request $request) {
|
||||||
|
return Limit::perMinute(10)->by($request->user()?->getAuthIdentifier() ?? $request->ip());
|
||||||
|
});
|
||||||
|
|
||||||
|
// Passkey-Login (Entscheidung 5, 07.10.2026): nur aktive, verifizierte
|
||||||
|
// Konten — wie die übrigen Anmeldewege.
|
||||||
|
Passkeys::authorizeLoginUsing(function (Request $request, PasskeyUser $user, Passkey $passkey): bool {
|
||||||
|
return $user instanceof User && $user->is_active && $user->hasVerifiedEmail();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
class NoPromotionalText implements ValidationRule
|
class NoPromotionalText implements ValidationRule
|
||||||
{
|
{
|
||||||
/** Schema, www. — oder eine nackte Domain mit Pfad („spam.example/deal"). */
|
/** Schema, www. — oder eine nackte Domain mit Pfad („spam.example/deal"). */
|
||||||
private const LINK_PATTERN = '~(?:https?://|www\.|\b[a-z0-9-]+\.[a-z]{2,}/\S)~iu';
|
public const LINK_PATTERN = '~(?:https?://|www\.|\b[a-z0-9-]+\.[a-z]{2,}/\S)~iu';
|
||||||
|
|
||||||
private const EMAIL_PATTERN = '~[^\s@]+@[^\s@]+\.[a-z]{2,}~iu';
|
private const EMAIL_PATTERN = '~[^\s@]+@[^\s@]+\.[a-z]{2,}~iu';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@
|
||||||
namespace App\Services\Admin;
|
namespace App\Services\Admin;
|
||||||
|
|
||||||
use App\Authorization\Permission as PermissionCatalog;
|
use App\Authorization\Permission as PermissionCatalog;
|
||||||
|
use App\Enums\ActivityType;
|
||||||
|
use App\Models\ActivityEvent;
|
||||||
use App\Models\ArchiveEntry;
|
use App\Models\ArchiveEntry;
|
||||||
use App\Models\FailedLoginTally;
|
use App\Models\FailedLoginTally;
|
||||||
use App\Models\PressReleaseAttachment;
|
use App\Models\PressReleaseAttachment;
|
||||||
|
|
@ -11,6 +13,7 @@ use App\Services\Archive\ArchivePendingQueue;
|
||||||
use App\Services\Archive\EvidenceArchive;
|
use App\Services\Archive\EvidenceArchive;
|
||||||
use App\Services\Monitoring\HealthProbe;
|
use App\Services\Monitoring\HealthProbe;
|
||||||
use App\Services\PressRelease\Images\PressReleaseImageRemover;
|
use App\Services\PressRelease\Images\PressReleaseImageRemover;
|
||||||
|
use App\Services\Spam\TurnstileVerifier;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Schema;
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
@ -238,6 +241,45 @@ class AdminSystemHealth
|
||||||
: 'SESSION_SECURE_COOKIE ist aus'.($isLocal ? ' (lokal ok).' : ' — in https-Umgebungen aktivieren.'),
|
: 'SESSION_SECURE_COOKIE ist aus'.($isLocal ? ' (lokal ok).' : ' — in https-Umgebungen aktivieren.'),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Imagick trägt Herkunftsprüfung und KI-Vorab-Check der Bilder. Fehlt
|
||||||
|
// die Erweiterung, scheitern beide still (Bilder werden durchgelassen)
|
||||||
|
// — so geschehen auf Live bis 08.10.2026.
|
||||||
|
$precheckEnabled = (bool) config('images.precheck.enabled');
|
||||||
|
$checks[] = [
|
||||||
|
'key' => 'imagick',
|
||||||
|
'label' => 'PHP-Erweiterung Imagick',
|
||||||
|
'status' => $this->imagickAvailable() ? self::StatusOk : ($precheckEnabled ? self::StatusCritical : self::StatusWarn),
|
||||||
|
'detail' => $this->imagickAvailable()
|
||||||
|
? 'Imagick ist geladen (Herkunftsprüfung und KI-Vorab-Check der Bilder).'
|
||||||
|
: 'Imagick fehlt — Herkunftsprüfung'.($precheckEnabled ? ' und KI-Vorab-Check' : '').' der Bilder laufen ins Leere. Auf dem Server php8.4-imagick installieren und PHP-FPM neu laden.',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Spamschutz (Cloudflare Turnstile): Keys in Produktion gesetzt, und
|
||||||
|
// Formulare, die bei nicht erreichbarem Cloudflare durchgelassen und
|
||||||
|
// markiert wurden (Entscheidung 09.10.2026) — die gehören nachgeprüft.
|
||||||
|
$turnstileEnabled = TurnstileVerifier::enabled();
|
||||||
|
$checks[] = [
|
||||||
|
'key' => 'turnstile',
|
||||||
|
'label' => 'Spamschutz (Cloudflare Turnstile)',
|
||||||
|
'status' => ($turnstileEnabled || ! app()->isProduction()) ? self::StatusOk : self::StatusWarn,
|
||||||
|
'detail' => $turnstileEnabled
|
||||||
|
? 'Bot-Prüfung für Kontakt-Widget und Registrierung ist aktiv.'
|
||||||
|
: 'TURNSTILE_SITE_KEY/TURNSTILE_SECRET_KEY fehlen — Kontakt-Widget und Registrierung laufen ohne Bot-Prüfung.',
|
||||||
|
];
|
||||||
|
|
||||||
|
$unchecked = ActivityEvent::query()
|
||||||
|
->where('type', ActivityType::BotCheckUnavailable->value)
|
||||||
|
->where('occurred_at', '>=', now()->subDays(7))
|
||||||
|
->count();
|
||||||
|
$checks[] = [
|
||||||
|
'key' => 'bot_check_unavailable',
|
||||||
|
'label' => 'Formulare ohne Bot-Prüfung',
|
||||||
|
'status' => $unchecked > 0 ? self::StatusWarn : self::StatusOk,
|
||||||
|
'detail' => $unchecked > 0
|
||||||
|
? sprintf('%d Formular(e) in 7 Tagen angenommen, während Cloudflare nicht erreichbar war — im Verlauf (Typ „Ohne Bot-Prüfung“) nachprüfen.', $unchecked)
|
||||||
|
: 'In den letzten 7 Tagen wurde jedes Formular geprüft.',
|
||||||
|
];
|
||||||
|
|
||||||
// Herkunftsprüfung der Bild-Uploads (Bildkennzeichnung E.7): Fehler
|
// Herkunftsprüfung der Bild-Uploads (Bildkennzeichnung E.7): Fehler
|
||||||
// der letzten sieben Tage zählen; Zustand D im Formular blockiert
|
// der letzten sieben Tage zählen; Zustand D im Formular blockiert
|
||||||
// nichts, muss aber sichtbar bleiben.
|
// nichts, muss aber sichtbar bleiben.
|
||||||
|
|
@ -341,6 +383,11 @@ class AdminSystemHealth
|
||||||
return $checks;
|
return $checks;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function imagickAvailable(): bool
|
||||||
|
{
|
||||||
|
return class_exists(\Imagick::class);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $queue
|
* @param array<string, mixed> $queue
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
166
app/Services/Analytics/UmamiClient.php
Normal file
166
app/Services/Analytics/UmamiClient.php
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Analytics;
|
||||||
|
|
||||||
|
use Carbon\CarbonInterface;
|
||||||
|
use Illuminate\Http\Client\ConnectionException;
|
||||||
|
use Illuminate\Http\Client\Response;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nur lesender Client für die selbst gehostete Umami-Instanz (Roadmap 6.16).
|
||||||
|
*
|
||||||
|
* Anmeldung mit einem Umami-Benutzer der Rolle „View only“; das Token wird
|
||||||
|
* zwischengespeichert und bei einer 401 einmal erneuert. Die Websites hängen
|
||||||
|
* am Team, nicht am Benutzer — ihre IDs stehen je Marke in
|
||||||
|
* `config/domains.php`.
|
||||||
|
*
|
||||||
|
* Eigenheiten der Umami-v3-API: Seiten heißen `path` (nicht `url`), ein
|
||||||
|
* Filter `path=c.<Teil>` sucht nach „enthält“, Zeiten in Millisekunden.
|
||||||
|
*/
|
||||||
|
class UmamiClient
|
||||||
|
{
|
||||||
|
private const string TOKEN_CACHE_KEY = 'umami.api-token';
|
||||||
|
|
||||||
|
/** Marken mit eigener Umami-Website. */
|
||||||
|
public const array BRANDS = ['pressekonto', 'presseecho', 'businessportal24'];
|
||||||
|
|
||||||
|
public function isConfigured(): bool
|
||||||
|
{
|
||||||
|
return filled(config('services.umami.api_url'))
|
||||||
|
&& filled(config('services.umami.api_username'))
|
||||||
|
&& filled(config('services.umami.api_password'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function websiteId(string $brand): ?string
|
||||||
|
{
|
||||||
|
$id = config('domains.domains.'.$brand.'.brand.umami_website_id');
|
||||||
|
|
||||||
|
return is_string($id) && $id !== '' ? $id : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kennzahlen eines Zeitraums, optional gefiltert (z. B. `['path' => 'c.preise']`).
|
||||||
|
*
|
||||||
|
* @param array<string, string> $filters
|
||||||
|
* @return array{pageviews: int, visitors: int, visits: int, bounces: int, totaltime: int}
|
||||||
|
*/
|
||||||
|
public function stats(string $websiteId, CarbonInterface $from, CarbonInterface $to, array $filters = []): array
|
||||||
|
{
|
||||||
|
$data = $this->get('/api/websites/'.$websiteId.'/stats', $this->range($from, $to) + $filters);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'pageviews' => (int) ($data['pageviews'] ?? 0),
|
||||||
|
'visitors' => (int) ($data['visitors'] ?? 0),
|
||||||
|
'visits' => (int) ($data['visits'] ?? 0),
|
||||||
|
'bounces' => (int) ($data['bounces'] ?? 0),
|
||||||
|
'totaltime' => (int) ($data['totaltime'] ?? 0),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rangliste einer Dimension: `path`, `referrer`, `channel`, `country`,
|
||||||
|
* `browser`, `os`, `device`, `event` …
|
||||||
|
*
|
||||||
|
* @param array<string, string> $filters
|
||||||
|
* @return list<array{x: string, y: int}>
|
||||||
|
*/
|
||||||
|
public function metrics(string $websiteId, string $type, CarbonInterface $from, CarbonInterface $to, array $filters = [], int $limit = 10): array
|
||||||
|
{
|
||||||
|
$data = $this->get('/api/websites/'.$websiteId.'/metrics', $this->range($from, $to) + $filters + [
|
||||||
|
'type' => $type,
|
||||||
|
'limit' => $limit,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return array_values(array_map(
|
||||||
|
static fn (array $row): array => ['x' => (string) ($row['x'] ?? ''), 'y' => (int) ($row['y'] ?? 0)],
|
||||||
|
array_filter($data, 'is_array'),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, scalar> $query
|
||||||
|
* @return array<mixed>
|
||||||
|
*/
|
||||||
|
private function get(string $path, array $query): array
|
||||||
|
{
|
||||||
|
$response = $this->send($path, $query, $this->token());
|
||||||
|
|
||||||
|
if ($response->status() === 401) {
|
||||||
|
Cache::forget(self::TOKEN_CACHE_KEY);
|
||||||
|
$response = $this->send($path, $query, $this->token());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $response->successful()) {
|
||||||
|
throw new UmamiUnavailable(sprintf('Umami antwortet mit %d auf %s.', $response->status(), $path));
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $response->json();
|
||||||
|
|
||||||
|
return is_array($data) ? $data : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, scalar> $query
|
||||||
|
*/
|
||||||
|
private function send(string $path, array $query, string $token): Response
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return Http::baseUrl($this->baseUrl())
|
||||||
|
->withToken($token)
|
||||||
|
->acceptJson()
|
||||||
|
->connectTimeout(5)
|
||||||
|
->timeout((int) config('services.umami.api_timeout_seconds', 15))
|
||||||
|
->get($path, $query);
|
||||||
|
} catch (ConnectionException $e) {
|
||||||
|
throw new UmamiUnavailable('Umami nicht erreichbar.', previous: $e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function token(): string
|
||||||
|
{
|
||||||
|
if (! $this->isConfigured()) {
|
||||||
|
throw new UmamiUnavailable('Umami-API nicht konfiguriert (UMAMI_API_URL, UMAMI_API_USERNAME, UMAMI_API_PASSWORD).');
|
||||||
|
}
|
||||||
|
|
||||||
|
return Cache::remember(self::TOKEN_CACHE_KEY, now()->addHours(12), function (): string {
|
||||||
|
try {
|
||||||
|
$response = Http::baseUrl($this->baseUrl())
|
||||||
|
->acceptJson()
|
||||||
|
->connectTimeout(5)
|
||||||
|
->timeout((int) config('services.umami.api_timeout_seconds', 15))
|
||||||
|
->post('/api/auth/login', [
|
||||||
|
'username' => (string) config('services.umami.api_username'),
|
||||||
|
'password' => (string) config('services.umami.api_password'),
|
||||||
|
]);
|
||||||
|
} catch (ConnectionException $e) {
|
||||||
|
throw new UmamiUnavailable('Umami nicht erreichbar.', previous: $e);
|
||||||
|
}
|
||||||
|
|
||||||
|
$token = $response->json('token');
|
||||||
|
|
||||||
|
if (! $response->successful() || ! is_string($token) || $token === '') {
|
||||||
|
throw new UmamiUnavailable(sprintf('Umami-Anmeldung abgelehnt (%d).', $response->status()));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $token;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private function baseUrl(): string
|
||||||
|
{
|
||||||
|
return rtrim((string) config('services.umami.api_url'), '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{startAt: int, endAt: int}
|
||||||
|
*/
|
||||||
|
private function range(CarbonInterface $from, CarbonInterface $to): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'startAt' => $from->getTimestampMs(),
|
||||||
|
'endAt' => $to->getTimestampMs(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
11
app/Services/Analytics/UmamiUnavailable.php
Normal file
11
app/Services/Analytics/UmamiUnavailable.php
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Analytics;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Umami war nicht erreichbar oder hat die Anfrage abgelehnt. Auswertungen
|
||||||
|
* fangen das ab und zeigen „nicht verbunden“ statt abzubrechen.
|
||||||
|
*/
|
||||||
|
class UmamiUnavailable extends RuntimeException {}
|
||||||
|
|
@ -5,14 +5,24 @@ namespace App\Services\Api;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Token-Gate des hybriden Rechnungsmodells: freigeschaltet ist, wer ein
|
* API-Gate des hybriden Rechnungsmodells: freigeschaltet ist, wer ein
|
||||||
* aktives Stripe-Abo (Cashier, STR-Kreis), eine laufende Zahlungsoption
|
* aktives Stripe-Abo (Cashier, STR-Kreis), eine laufende Zahlungsoption
|
||||||
* (MAN-Kreis/Bestandsschutz) oder eine bezahlte letzte Legacy-Rechnung hat —
|
* (MAN-Kreis/Bestandsschutz) oder eine bezahlte letzte Legacy-Rechnung hat —
|
||||||
* spiegelbildlich zu User::hasActiveBooking().
|
* spiegelbildlich zu User::hasActiveBooking(). Geprüft beim Erzeugen eines
|
||||||
|
* Tokens und bei jedem API-Aufruf.
|
||||||
*/
|
*/
|
||||||
class ApiAccessEligibilityService
|
class ApiAccessEligibilityService
|
||||||
{
|
{
|
||||||
public function canCreateToken(User $user): bool
|
public function canCreateToken(User $user): bool
|
||||||
|
{
|
||||||
|
return $this->hasApiAccess($user);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gilt beim Erzeugen eines Tokens und bei jedem API-Aufruf
|
||||||
|
* (EnsureApiAccessIsBooked).
|
||||||
|
*/
|
||||||
|
public function hasApiAccess(User $user): bool
|
||||||
{
|
{
|
||||||
return $this->denialReason($user) === null;
|
return $this->denialReason($user) === null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -119,13 +119,14 @@ class ContactAccessService
|
||||||
* (`granted_via = manual`) bleiben, ebenso Mitgliedschaften aus der
|
* (`granted_via = manual`) bleiben, ebenso Mitgliedschaften aus der
|
||||||
* API-Zuordnung (`granted_via = api`, Audit R-01) — ihre Grundlage ist
|
* API-Zuordnung (`granted_via = api`, Audit R-01) — ihre Grundlage ist
|
||||||
* die Freischaltung des Einlieferers, nicht die E-Mail.
|
* die Freischaltung des Einlieferers, nicht die E-Mail.
|
||||||
|
*
|
||||||
|
* Neben Login und E-Mail-Bestätigung ruft auch der
|
||||||
|
* CompanyMembershipObserver diese Methode auf, sobald sich Kontakt- oder
|
||||||
|
* Firmenadressen bzw. Kontaktzuordnungen ändern — der Entzug greift damit
|
||||||
|
* in der laufenden Session (Sicherheitsaudit 07.10.2026, S-03).
|
||||||
*/
|
*/
|
||||||
public function syncMembershipsForEmail(User $user): void
|
public function syncMembershipsForEmail(User $user): void
|
||||||
{
|
{
|
||||||
if (! $user->is_active || ! $user->hasVerifiedEmail()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$email = mb_strtolower(trim((string) $user->email));
|
$email = mb_strtolower(trim((string) $user->email));
|
||||||
|
|
||||||
if ($email === '') {
|
if ($email === '') {
|
||||||
|
|
@ -136,9 +137,15 @@ class ContactAccessService
|
||||||
$companies = $this->companiesFor($email);
|
$companies = $this->companiesFor($email);
|
||||||
$companyIds = $this->companyIdsFrom($contacts, $companies);
|
$companyIds = $this->companyIdsFrom($contacts, $companies);
|
||||||
|
|
||||||
|
// Entzogen wird nur, was keine E-Mail-Grundlage mehr hat — unabhängig
|
||||||
|
// vom Kontostatus (S-03). Bestandskonten aus dem Altsystem haben kein
|
||||||
|
// `email_verified_at`; ihre noch begründeten Rechte bleiben. Eine
|
||||||
|
// erste Fassung entzog Unverifizierten pauschal alles und nahm beim
|
||||||
|
// Abgleich am 07.10.2026 so 19.598 begründete Rechte weg (v1.4.1).
|
||||||
$this->revokeStaleAutomaticGrants($user, $companyIds);
|
$this->revokeStaleAutomaticGrants($user, $companyIds);
|
||||||
|
|
||||||
if ($companyIds->isEmpty()) {
|
// Neue Rechte nur für aktive Konten mit nachgewiesener Adresse (F01).
|
||||||
|
if (! $user->is_active || ! $user->hasVerifiedEmail() || $companyIds->isEmpty()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
43
app/Services/Auth/LoginIpThrottle.php
Normal file
43
app/Services/Auth/LoginIpThrottle.php
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Auth;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obergrenze für fehlgeschlagene Anmeldungen je IP-Adresse über alle Konten
|
||||||
|
* hinweg (Sicherheitsaudit 07.10.2026, Phase 4). Das Limit je Adresse und IP
|
||||||
|
* (5 pro Minute) bremst kein Durchprobieren vieler fremder Adressen von einer
|
||||||
|
* Quelle — davon kamen laut Monitoring rund 250 Versuche am Tag. Gezählt
|
||||||
|
* wird über das `Failed`-Event, damit Livewire-Anmeldung und Fortifys
|
||||||
|
* `POST /login` denselben Zähler teilen. Erfolgreiche Anmeldungen setzen ihn
|
||||||
|
* bewusst nicht zurück — sonst reichte ein eigenes Konto zum Entsperren.
|
||||||
|
* Wirkt erst, seit gefälschte X-Forwarded-For-Header ignoriert werden
|
||||||
|
* (config/trustedproxy.php).
|
||||||
|
*/
|
||||||
|
class LoginIpThrottle
|
||||||
|
{
|
||||||
|
public const MAX_FAILURES = 20;
|
||||||
|
|
||||||
|
public const DECAY_SECONDS = 900;
|
||||||
|
|
||||||
|
public function recordFailure(?string $ip): void
|
||||||
|
{
|
||||||
|
RateLimiter::hit($this->key($ip), self::DECAY_SECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isBlocked(?string $ip): bool
|
||||||
|
{
|
||||||
|
return RateLimiter::tooManyAttempts($this->key($ip), self::MAX_FAILURES);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function availableIn(?string $ip): int
|
||||||
|
{
|
||||||
|
return RateLimiter::availableIn($this->key($ip));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function key(?string $ip): string
|
||||||
|
{
|
||||||
|
return 'login-failures-ip|'.($ip ?? 'unknown');
|
||||||
|
}
|
||||||
|
}
|
||||||
63
app/Services/Auth/MailAccessLink.php
Normal file
63
app/Services/Auth/MailAccessLink.php
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Auth;
|
||||||
|
|
||||||
|
use App\Enums\ConversionSource;
|
||||||
|
use App\Models\PressRelease;
|
||||||
|
use Illuminate\Contracts\Encryption\DecryptException;
|
||||||
|
use Illuminate\Support\Facades\Crypt;
|
||||||
|
use Illuminate\Support\Facades\URL;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* „Anmeldelink an diese Adresse“ aus einer Kundenmail (Roadmap 6.17).
|
||||||
|
*
|
||||||
|
* Veröffentlichungs-, Ablehnungs- und Prüfmails zu API-Meldungen gehen an den
|
||||||
|
* Pressekontakt oder die Firmen-E-Mail — Empfänger, die aus ihrer Sicht kein
|
||||||
|
* Konto haben. Der Link führt auf eine Seite, die mit einem Klick einen
|
||||||
|
* Anmeldelink an genau die Adresse schickt, an die die Mail ging, und nach
|
||||||
|
* der Anmeldung auf der Meldung landet.
|
||||||
|
*
|
||||||
|
* Der Link meldet niemanden an: Er löst nur einen Anmeldelink an eine
|
||||||
|
* Adresse aus, die ihn ohnehin bekommen könnte (`/anmeldelink`). Signiert und
|
||||||
|
* befristet, die Adresse verschlüsselt — sie steht nicht lesbar in der URL,
|
||||||
|
* in Logs oder in der Webanalyse. `src` und `pr` stehen zusätzlich im Klartext
|
||||||
|
* für die Klickmessung (RecordMailLinkClick).
|
||||||
|
*/
|
||||||
|
class MailAccessLink
|
||||||
|
{
|
||||||
|
public const int VALID_DAYS = 30;
|
||||||
|
|
||||||
|
public function url(string $email, PressRelease $pressRelease, ConversionSource $source): string
|
||||||
|
{
|
||||||
|
return URL::temporarySignedRoute('mail-access', now()->addDays(self::VALID_DAYS), [
|
||||||
|
't' => Crypt::encryptString((string) json_encode([
|
||||||
|
'e' => mb_strtolower(trim($email)),
|
||||||
|
'p' => $pressRelease->getKey(),
|
||||||
|
])),
|
||||||
|
'src' => $source->value,
|
||||||
|
'pr' => $pressRelease->getKey(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{email: string, press_release_id: int}|null
|
||||||
|
*/
|
||||||
|
public function decode(?string $token): ?array
|
||||||
|
{
|
||||||
|
if (blank($token)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$data = json_decode(Crypt::decryptString((string) $token), true);
|
||||||
|
} catch (DecryptException) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_array($data) || ! is_string($data['e'] ?? null) || ! filter_var($data['e'], FILTER_VALIDATE_EMAIL) || (int) ($data['p'] ?? 0) <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['email' => $data['e'], 'press_release_id' => (int) $data['p']];
|
||||||
|
}
|
||||||
|
}
|
||||||
41
app/Services/Auth/PasswordChanger.php
Normal file
41
app/Services/Auth/PasswordChanger.php
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Auth;
|
||||||
|
|
||||||
|
use App\Enums\SecurityChange;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Contracts\Session\Session;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Einziger Weg für den Passwortwechsel eines angemeldeten Kontos
|
||||||
|
* (Sicherheitsaudit 07.10.2026, S-04). Neben dem neuen Hash wird der
|
||||||
|
* Remember-Token rotiert und jede andere Browser-Session beendet — die
|
||||||
|
* aktuelle bleibt angemeldet. API-Tokens bleiben bewusst bestehen
|
||||||
|
* (Entscheidung 1: Integrationen sollen nicht unangekündigt brechen); der
|
||||||
|
* Hinweis an die Kontoadresse nennt sie.
|
||||||
|
*/
|
||||||
|
class PasswordChanger
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly SessionRevocation $sessions,
|
||||||
|
private readonly SecurityNotifier $notifier,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function change(User $user, string $plainPassword, ?Session $currentSession = null): void
|
||||||
|
{
|
||||||
|
$user->forceFill([
|
||||||
|
'password' => Hash::make($plainPassword),
|
||||||
|
'remember_token' => Str::random(60),
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
if ($currentSession !== null) {
|
||||||
|
$this->sessions->revokeOthersFor($user, $currentSession);
|
||||||
|
} else {
|
||||||
|
$this->sessions->revokeAllFor($user);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->notifier->notify($user, SecurityChange::PasswordChanged);
|
||||||
|
}
|
||||||
|
}
|
||||||
121
app/Services/Auth/SecurityConfirmation.php
Normal file
121
app/Services/Auth/SecurityConfirmation.php
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Auth;
|
||||||
|
|
||||||
|
use App\Mail\SecurityConfirmationCode;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
|
use Illuminate\Support\Facades\Session;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Frischer Identitätsnachweis vor dauerhaften Sicherheitsänderungen
|
||||||
|
* (Sicherheitsaudit 07.10.2026, S-06): Login-Adresse ändern, 2FA erstmals
|
||||||
|
* einrichten, API-Token erzeugen. Eine bestehende Session allein reicht dafür
|
||||||
|
* nicht.
|
||||||
|
*
|
||||||
|
* Konten ohne Passwort und ohne 2FA (Magic-Link-/Google-Konten) bestätigen
|
||||||
|
* per Einmal-Code an die aktuelle Kontoadresse — wer nur die Session, nicht
|
||||||
|
* aber das Postfach hat, kommt damit nicht weiter. Nach erfolgreichem Nachweis
|
||||||
|
* gilt die Session für FRESH_FOR_SECONDS als bestätigt.
|
||||||
|
*/
|
||||||
|
class SecurityConfirmation
|
||||||
|
{
|
||||||
|
public const SESSION_KEY = 'auth.security_confirmed';
|
||||||
|
|
||||||
|
public const FRESH_FOR_SECONDS = 900;
|
||||||
|
|
||||||
|
public const CODE_VALID_MINUTES = 10;
|
||||||
|
|
||||||
|
public const MAX_CODE_ATTEMPTS = 5;
|
||||||
|
|
||||||
|
public function isFresh(User $user): bool
|
||||||
|
{
|
||||||
|
$confirmed = Session::get(self::SESSION_KEY);
|
||||||
|
|
||||||
|
return is_array($confirmed)
|
||||||
|
&& (int) ($confirmed['user_id'] ?? 0) === (int) $user->getKey()
|
||||||
|
&& (int) ($confirmed['at'] ?? 0) >= now()->getTimestamp() - self::FRESH_FOR_SECONDS;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markConfirmed(User $user): void
|
||||||
|
{
|
||||||
|
Session::put(self::SESSION_KEY, [
|
||||||
|
'user_id' => $user->getKey(),
|
||||||
|
'at' => now()->getTimestamp(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schickt einen 6-stelligen Code an die aktuelle Kontoadresse. Liefert
|
||||||
|
* false, wenn zu viele Codes angefordert wurden (3 je 10 Minuten).
|
||||||
|
*/
|
||||||
|
public function sendEmailCode(User $user): bool
|
||||||
|
{
|
||||||
|
$throttleKey = 'security-email-code-send:'.$user->getKey();
|
||||||
|
|
||||||
|
if (RateLimiter::tooManyAttempts($throttleKey, 3)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
RateLimiter::hit($throttleKey, 600);
|
||||||
|
|
||||||
|
$code = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);
|
||||||
|
$expiresAt = now()->addMinutes(self::CODE_VALID_MINUTES);
|
||||||
|
|
||||||
|
Cache::put($this->codeKey($user), [
|
||||||
|
'hash' => $this->hash($code),
|
||||||
|
'attempts' => 0,
|
||||||
|
'expires_at' => $expiresAt->getTimestamp(),
|
||||||
|
], $expiresAt);
|
||||||
|
|
||||||
|
Mail::to($user->email)->queue((new SecurityConfirmationCode(
|
||||||
|
user: $user,
|
||||||
|
code: $code,
|
||||||
|
expiresAt: $expiresAt->format('H:i'),
|
||||||
|
))->afterCommit());
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft den Code; er ist nur einmal gültig und nach
|
||||||
|
* MAX_CODE_ATTEMPTS Fehlversuchen verbraucht.
|
||||||
|
*/
|
||||||
|
public function verifyEmailCode(User $user, string $code): bool
|
||||||
|
{
|
||||||
|
$key = $this->codeKey($user);
|
||||||
|
$entry = Cache::get($key);
|
||||||
|
|
||||||
|
if (! is_array($entry) || (int) ($entry['expires_at'] ?? 0) < now()->getTimestamp()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hash_equals((string) $entry['hash'], $this->hash(trim($code)))) {
|
||||||
|
Cache::forget($key);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$entry['attempts'] = (int) $entry['attempts'] + 1;
|
||||||
|
|
||||||
|
if ($entry['attempts'] >= self::MAX_CODE_ATTEMPTS) {
|
||||||
|
Cache::forget($key);
|
||||||
|
} else {
|
||||||
|
Cache::put($key, $entry, now()->setTimestamp((int) $entry['expires_at']));
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function codeKey(User $user): string
|
||||||
|
{
|
||||||
|
return 'security-email-code:'.$user->getKey();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function hash(string $code): string
|
||||||
|
{
|
||||||
|
return hash_hmac('sha256', $code, (string) config('app.key'));
|
||||||
|
}
|
||||||
|
}
|
||||||
36
app/Services/Auth/SecurityNotifier.php
Normal file
36
app/Services/Auth/SecurityNotifier.php
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Auth;
|
||||||
|
|
||||||
|
use App\Enums\SecurityChange;
|
||||||
|
use App\Mail\SecurityChangeNotice;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Informiert den Kontoinhaber über sicherheitsrelevante Änderungen
|
||||||
|
* (Sicherheitsaudit 07.10.2026, S-06). Wer eine fremde Session nutzt, soll
|
||||||
|
* nicht unbemerkt Passwort, Login-Adresse oder zweiten Faktor ändern können.
|
||||||
|
*/
|
||||||
|
class SecurityNotifier
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param string|null $recipient Abweichender Empfänger, z. B. die
|
||||||
|
* bisherige Adresse nach einem E-Mail-Wechsel.
|
||||||
|
*/
|
||||||
|
public function notify(User $user, SecurityChange $change, ?string $recipient = null, ?string $newEmail = null): void
|
||||||
|
{
|
||||||
|
$recipient = trim((string) ($recipient ?? $user->email));
|
||||||
|
|
||||||
|
if ($recipient === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Mail::to($recipient)->queue((new SecurityChangeNotice(
|
||||||
|
user: $user,
|
||||||
|
change: $change,
|
||||||
|
occurredAt: now()->format('d.m.Y H:i'),
|
||||||
|
newEmail: $newEmail,
|
||||||
|
))->afterCommit());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,7 +5,7 @@ namespace App\Services\Auth;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Auth\Events\Login;
|
use Illuminate\Auth\Events\Login;
|
||||||
use Illuminate\Contracts\Session\Session;
|
use Illuminate\Contracts\Session\Session;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Beendet alle offenen Browser-Sessions eines Kontos (Umsetzungsaudit
|
* Beendet alle offenen Browser-Sessions eines Kontos (Umsetzungsaudit
|
||||||
|
|
@ -16,10 +16,12 @@ use Illuminate\Support\Facades\Cache;
|
||||||
* ein Zeitstempel je Konto: Jede Anmeldung schreibt ihren Zeitpunkt in die
|
* ein Zeitstempel je Konto: Jede Anmeldung schreibt ihren Zeitpunkt in die
|
||||||
* Session (`Login`-Event), `revokeAllFor()` merkt sich „ungültig vor jetzt",
|
* Session (`Login`-Event), `revokeAllFor()` merkt sich „ungültig vor jetzt",
|
||||||
* und die Middleware EnsureSessionIsNotRevoked meldet jede ältere Session
|
* und die Middleware EnsureSessionIsNotRevoked meldet jede ältere Session
|
||||||
* beim nächsten Request ab. Die Marke lebt so lange wie eine Session
|
* beim nächsten Request ab. Remember-Cookies deckt das Rotieren von
|
||||||
* (`session.lifetime`) — danach ist jede ältere Session ohnehin abgelaufen.
|
* `remember_token` ab, API-Tokens löscht der Aufrufer.
|
||||||
* Remember-Cookies deckt das Rotieren von `remember_token` ab, API-Tokens
|
*
|
||||||
* löscht der Aufrufer.
|
* Die Marke steht seit v1.6.0 in `users.sessions_revoked_at_ms` und übersteht
|
||||||
|
* so ein Leeren des Caches (Sicherheitsaudit 07.10.2026, Phase 4). Die
|
||||||
|
* Middleware liest sie am ohnehin geladenen User — kein zusätzlicher Zugriff.
|
||||||
*/
|
*/
|
||||||
class SessionRevocation
|
class SessionRevocation
|
||||||
{
|
{
|
||||||
|
|
@ -31,13 +33,41 @@ class SessionRevocation
|
||||||
/**
|
/**
|
||||||
* Alle bis jetzt entstandenen Sessions des Kontos werden ungültig.
|
* Alle bis jetzt entstandenen Sessions des Kontos werden ungültig.
|
||||||
*/
|
*/
|
||||||
public function revokeAllFor(User $user): void
|
public function revokeAllFor(User $user): int
|
||||||
{
|
{
|
||||||
Cache::put(
|
$revokedAt = now()->getTimestampMs();
|
||||||
$this->cacheKey((int) $user->getKey()),
|
|
||||||
now()->getTimestampMs(),
|
// Ohne Model-Events und ohne updated_at: Die Marke ist keine
|
||||||
now()->addMinutes(max(1, (int) config('session.lifetime', 120)) + 5),
|
// inhaltliche Änderung am Konto und soll keine Observer auslösen.
|
||||||
);
|
$user->newQuery()->whereKey($user->getKey())->update(['sessions_revoked_at_ms' => $revokedAt]);
|
||||||
|
$user->forceFill(['sessions_revoked_at_ms' => $revokedAt])->syncOriginalAttribute('sessions_revoked_at_ms');
|
||||||
|
|
||||||
|
return $revokedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alle anderen Sessions des Kontos werden ungültig, die übergebene
|
||||||
|
* (aktuelle) bleibt angemeldet: Sie erhält einen Anmeldezeitpunkt nach
|
||||||
|
* der Sperre und eine neue Session-ID (Sicherheitsaudit 07.10.2026, S-04).
|
||||||
|
*/
|
||||||
|
public function revokeOthersFor(User $user, Session $current): void
|
||||||
|
{
|
||||||
|
$revokedAt = $this->revokeAllFor($user);
|
||||||
|
|
||||||
|
$current->put(self::SESSION_KEY, $revokedAt + 1);
|
||||||
|
$current->migrate(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* „Andere Geräte abmelden“ auf der Sicherheitsseite: Remember-Cookies
|
||||||
|
* anderer Geräte werden ungültig (neuer `remember_token`), alle anderen
|
||||||
|
* Sessions enden beim nächsten Request, die aktuelle bleibt angemeldet.
|
||||||
|
*/
|
||||||
|
public function logoutOtherDevices(User $user, Session $current): void
|
||||||
|
{
|
||||||
|
$user->forceFill(['remember_token' => Str::random(60)])->save();
|
||||||
|
|
||||||
|
$this->revokeOthersFor($user, $current);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -57,19 +87,14 @@ class SessionRevocation
|
||||||
* Session stammt aus der Zeit vor der letzten Sperre dieses Kontos.
|
* Session stammt aus der Zeit vor der letzten Sperre dieses Kontos.
|
||||||
* Ohne Anmeldezeitpunkt (Session von vor dem Deploy) gilt sie als alt.
|
* Ohne Anmeldezeitpunkt (Session von vor dem Deploy) gilt sie als alt.
|
||||||
*/
|
*/
|
||||||
public function isRevoked(int $userId, Session $session): bool
|
public function isRevoked(User $user, Session $session): bool
|
||||||
{
|
{
|
||||||
$revokedAt = Cache::get($this->cacheKey($userId));
|
$revokedAt = (int) $user->sessions_revoked_at_ms;
|
||||||
|
|
||||||
if ($revokedAt === null) {
|
if ($revokedAt === 0) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (int) $session->get(self::SESSION_KEY, 0) < (int) $revokedAt;
|
return (int) $session->get(self::SESSION_KEY, 0) < $revokedAt;
|
||||||
}
|
|
||||||
|
|
||||||
private function cacheKey(int $userId): string
|
|
||||||
{
|
|
||||||
return 'auth.sessions-revoked-at.'.$userId;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
90
app/Services/Auth/SocialAccountLinker.php
Normal file
90
app/Services/Auth/SocialAccountLinker.php
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Auth;
|
||||||
|
|
||||||
|
use App\Mail\SocialAccountLinkConfirmation;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
|
use Illuminate\Support\Facades\Session;
|
||||||
|
use Illuminate\Support\Facades\URL;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bestätigte Erstverknüpfung eines Social-Logins mit einem bestehenden Konto
|
||||||
|
* (Sicherheitsaudit 07.10.2026, S-05, Entscheidung 3).
|
||||||
|
*
|
||||||
|
* Die Provider-Identität wird zunächst nur in der Session vorgemerkt, die
|
||||||
|
* den Login gestartet hat; an die Kontoadresse geht ein signierter Link.
|
||||||
|
* Verknüpft wird erst, wenn dieser Link in DERSELBEN Browser-Session geöffnet
|
||||||
|
* wird. So belegt der Klick den Postfachbesitz, und ein Postfachinhaber, der
|
||||||
|
* eine fremd ausgelöste Mail anklickt, verknüpft nichts.
|
||||||
|
*/
|
||||||
|
class SocialAccountLinker
|
||||||
|
{
|
||||||
|
public const SESSION_KEY = 'oauth.pending_link';
|
||||||
|
|
||||||
|
public const EXPIRES_IN_MINUTES = 30;
|
||||||
|
|
||||||
|
public function __construct(private readonly SocialAuthService $social) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merkt die Verknüpfung vor und verschickt den Bestätigungslink.
|
||||||
|
* Liefert false, wenn für das Konto zu viele Anfragen kamen.
|
||||||
|
*/
|
||||||
|
public function requestConfirmation(User $user, string $provider, string $providerId): bool
|
||||||
|
{
|
||||||
|
$throttleKey = 'social-link-mail:'.$user->getKey();
|
||||||
|
|
||||||
|
if (RateLimiter::tooManyAttempts($throttleKey, 3)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
RateLimiter::hit($throttleKey, 600);
|
||||||
|
|
||||||
|
$expiresAt = now()->addMinutes(self::EXPIRES_IN_MINUTES);
|
||||||
|
|
||||||
|
Session::put(self::SESSION_KEY, [
|
||||||
|
'user_id' => $user->getKey(),
|
||||||
|
'provider' => $provider,
|
||||||
|
'provider_id' => $providerId,
|
||||||
|
'expires_at' => $expiresAt->getTimestamp(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$confirmUrl = URL::temporarySignedRoute('oauth.google.confirm-link', $expiresAt, [
|
||||||
|
'user' => $user->getKey(),
|
||||||
|
'provider_id' => $providerId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Mail::to($user->email)->queue((new SocialAccountLinkConfirmation(
|
||||||
|
user: $user,
|
||||||
|
confirmUrl: $confirmUrl,
|
||||||
|
expiresAt: $expiresAt->format('H:i'),
|
||||||
|
))->afterCommit());
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Speichert die vorgemerkte Verknüpfung, wenn sie zu dieser Session,
|
||||||
|
* diesem Konto und dieser Provider-Identität gehört.
|
||||||
|
*/
|
||||||
|
public function confirm(User $user, string $providerId): bool
|
||||||
|
{
|
||||||
|
$pending = Session::pull(self::SESSION_KEY);
|
||||||
|
|
||||||
|
if (! is_array($pending)
|
||||||
|
|| (int) ($pending['user_id'] ?? 0) !== (int) $user->getKey()
|
||||||
|
|| ! hash_equals((string) ($pending['provider_id'] ?? ''), $providerId)
|
||||||
|
|| (int) ($pending['expires_at'] ?? 0) < now()->getTimestamp()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filled($user->oauth_provider_id)) {
|
||||||
|
return hash_equals((string) $user->oauth_provider_id, $providerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->social->link($user, (string) $pending['provider'], $providerId);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,47 +2,79 @@
|
||||||
|
|
||||||
namespace App\Services\Auth;
|
namespace App\Services\Auth;
|
||||||
|
|
||||||
|
use App\Enums\SecurityChange;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Auflösung eines Social-Logins (Google, ggf. weitere) auf einen lokalen User.
|
* Auflösung eines Social-Logins (Google, ggf. weitere) auf einen lokalen User.
|
||||||
*
|
*
|
||||||
* Identität ist die E-Mail (De-Dup darüber, keine Dubletten). Der Provider
|
* Reihenfolge (Sicherheitsaudit 07.10.2026, S-05):
|
||||||
* bestätigt die E-Mail, daher gilt die Verifizierung über den Kanal als erfüllt
|
* 1. Ohne vom Provider bestätigte E-Mail (`email_verified`) kein Login.
|
||||||
* (Entscheidung 15.06.) – kein zusätzlicher E-Mail-Verifizierungsschritt.
|
* 2. Eine bereits verknüpfte Provider-Identität (`oauth_provider_id`) ist
|
||||||
|
* die stabile Bindung und gewinnt vor der E-Mail.
|
||||||
|
* 3. Sonst De-Dup über die E-Mail. Ein Konto, das schon mit einer ANDEREN
|
||||||
|
* Identität desselben Providers verknüpft ist, wird nicht still
|
||||||
|
* umgehängt.
|
||||||
|
* 4. Die Erstverknüpfung eines bestehenden, verifizierten Kontos erfolgt
|
||||||
|
* direkt nur, wenn Google für das Postfach zuständig ist (gmail.com bzw.
|
||||||
|
* Workspace-Domain `hd`). Bei sonstigen Adressen belegt Google den
|
||||||
|
* aktuellen Postfachbesitz nicht — dann erst nach Bestätigungslink an die
|
||||||
|
* Adresse (SocialAccountLinker).
|
||||||
|
*
|
||||||
|
* Neue Konten und noch unverifizierte Selbst-Registrierer werden wie bisher
|
||||||
|
* direkt angelegt bzw. übernommen (Entscheidung 15.06., Audit K-04).
|
||||||
*/
|
*/
|
||||||
class SocialAuthService
|
class SocialAuthService
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* Domains, deren Postfächer Google selbst betreibt.
|
||||||
|
*/
|
||||||
|
private const GOOGLE_MAIL_DOMAINS = ['gmail.com', 'googlemail.com'];
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly UserRolePermissionSyncService $roleSync,
|
private readonly UserRolePermissionSyncService $roleSync,
|
||||||
private readonly CustomerAccountProvisioner $accountProvisioner,
|
private readonly CustomerAccountProvisioner $accountProvisioner,
|
||||||
private readonly SessionRevocation $sessions,
|
private readonly SessionRevocation $sessions,
|
||||||
|
private readonly SecurityNotifier $notifier,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Liefert den (ggf. neu angelegten) User zur Social-Identität. Ein
|
* Ein deaktivierter Bestands-Account wird NICHT reaktiviert – der Aufrufer
|
||||||
* deaktivierter Bestands-Account wird NICHT reaktiviert – der Aufrufer prüft
|
* prüft danach is_active und blockiert.
|
||||||
* danach is_active und blockiert.
|
|
||||||
*/
|
*/
|
||||||
public function resolveUser(string $provider, string $providerId, ?string $email, ?string $name): ?User
|
public function resolve(
|
||||||
{
|
string $provider,
|
||||||
|
string $providerId,
|
||||||
|
?string $email,
|
||||||
|
?string $name,
|
||||||
|
bool $emailVerified,
|
||||||
|
?string $hostedDomain = null,
|
||||||
|
): SocialLoginResult {
|
||||||
$email = $email ? mb_strtolower(trim($email)) : null;
|
$email = $email ? mb_strtolower(trim($email)) : null;
|
||||||
|
|
||||||
if ($email === null || $email === '') {
|
if ($email === null || $email === '' || ! $emailVerified || $providerId === '') {
|
||||||
return null;
|
return SocialLoginResult::unverifiedEmail();
|
||||||
|
}
|
||||||
|
|
||||||
|
$linked = User::query()
|
||||||
|
->where('oauth_provider', $provider)
|
||||||
|
->where('oauth_provider_id', $providerId)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($linked) {
|
||||||
|
return SocialLoginResult::login($linked);
|
||||||
}
|
}
|
||||||
|
|
||||||
$user = User::findByEmail($email);
|
$user = User::findByEmail($email);
|
||||||
|
|
||||||
if (! $user) {
|
if (! $user) {
|
||||||
return $this->createUser($provider, $providerId, $email, $name);
|
return SocialLoginResult::login($this->createUser($provider, $providerId, $email, $name));
|
||||||
}
|
}
|
||||||
|
|
||||||
$user->forceFill([
|
if (filled($user->oauth_provider_id)) {
|
||||||
'oauth_provider' => $provider,
|
return SocialLoginResult::providerMismatch($user);
|
||||||
'oauth_provider_id' => $providerId,
|
}
|
||||||
]);
|
|
||||||
|
|
||||||
// Noch nicht verifiziert (offener Selbst-Registrierer): der Provider
|
// Noch nicht verifiziert (offener Selbst-Registrierer): der Provider
|
||||||
// bestätigt die E-Mail → Onboarding abschließen (aktiv + customer).
|
// bestätigt die E-Mail → Onboarding abschließen (aktiv + customer).
|
||||||
|
|
@ -54,6 +86,8 @@ class SocialAuthService
|
||||||
// oder Magic Link an und setzt bei Bedarf selbst ein Passwort.
|
// oder Magic Link an und setzt bei Bedarf selbst ein Passwort.
|
||||||
if ($user->email_verified_at === null) {
|
if ($user->email_verified_at === null) {
|
||||||
$user->forceFill([
|
$user->forceFill([
|
||||||
|
'oauth_provider' => $provider,
|
||||||
|
'oauth_provider_id' => $providerId,
|
||||||
'email_verified_at' => now(),
|
'email_verified_at' => now(),
|
||||||
'is_active' => true,
|
'is_active' => true,
|
||||||
'password' => null,
|
'password' => null,
|
||||||
|
|
@ -68,14 +102,47 @@ class SocialAuthService
|
||||||
$this->roleSync->assignRoleAndSyncPermissions($user, 'customer');
|
$this->roleSync->assignRoleAndSyncPermissions($user, 'customer');
|
||||||
}
|
}
|
||||||
|
|
||||||
return $user;
|
return SocialLoginResult::login($user);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->providerOwnsMailbox($email, $hostedDomain)) {
|
||||||
|
return SocialLoginResult::confirmLink($user);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bereits verifiziert: nur Provider verknüpfen. is_active bleibt
|
// Bereits verifiziert: nur Provider verknüpfen. is_active bleibt
|
||||||
// unangetastet (deaktivierte Accounts werden nicht reaktiviert).
|
// unangetastet (deaktivierte Accounts werden nicht reaktiviert).
|
||||||
$user->save();
|
$this->link($user, $provider, $providerId);
|
||||||
|
|
||||||
return $user;
|
return SocialLoginResult::login($user);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verknüpft die Provider-Identität mit einem bestehenden Konto und
|
||||||
|
* informiert den Kontoinhaber darüber.
|
||||||
|
*/
|
||||||
|
public function link(User $user, string $provider, string $providerId): void
|
||||||
|
{
|
||||||
|
$user->forceFill([
|
||||||
|
'oauth_provider' => $provider,
|
||||||
|
'oauth_provider_id' => $providerId,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
$this->notifier->notify($user, SecurityChange::GoogleLinked);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Google ist für das Postfach zuständig: eigene Mail-Domain oder eine
|
||||||
|
* Workspace-Domain (`hd`), zu der die Adresse gehört.
|
||||||
|
*/
|
||||||
|
private function providerOwnsMailbox(string $email, ?string $hostedDomain): bool
|
||||||
|
{
|
||||||
|
$domain = Str::afterLast($email, '@');
|
||||||
|
|
||||||
|
if (in_array($domain, self::GOOGLE_MAIL_DOMAINS, true)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return filled($hostedDomain) && strcasecmp(trim($hostedDomain), $domain) === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function createUser(string $provider, string $providerId, string $email, ?string $name): User
|
private function createUser(string $provider, string $providerId, string $email, ?string $name): User
|
||||||
|
|
|
||||||
47
app/Services/Auth/SocialLoginResult.php
Normal file
47
app/Services/Auth/SocialLoginResult.php
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Auth;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ergebnis der Auflösung eines Social-Logins (SocialAuthService::resolve()).
|
||||||
|
*/
|
||||||
|
final readonly class SocialLoginResult
|
||||||
|
{
|
||||||
|
public const LOGIN = 'login';
|
||||||
|
|
||||||
|
/** Bestehendes Konto, Verknüpfung erst nach Bestätigung per E-Mail (S-05). */
|
||||||
|
public const CONFIRM_LINK = 'confirm_link';
|
||||||
|
|
||||||
|
/** Keine bzw. eine vom Provider nicht bestätigte E-Mail-Adresse. */
|
||||||
|
public const UNVERIFIED_EMAIL = 'unverified_email';
|
||||||
|
|
||||||
|
/** Konto ist bereits mit einer anderen Identität desselben Providers verknüpft. */
|
||||||
|
public const PROVIDER_MISMATCH = 'provider_mismatch';
|
||||||
|
|
||||||
|
private function __construct(
|
||||||
|
public string $outcome,
|
||||||
|
public ?User $user = null,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public static function login(User $user): self
|
||||||
|
{
|
||||||
|
return new self(self::LOGIN, $user);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function confirmLink(User $user): self
|
||||||
|
{
|
||||||
|
return new self(self::CONFIRM_LINK, $user);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function unverifiedEmail(): self
|
||||||
|
{
|
||||||
|
return new self(self::UNVERIFIED_EMAIL);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function providerMismatch(User $user): self
|
||||||
|
{
|
||||||
|
return new self(self::PROVIDER_MISMATCH, $user);
|
||||||
|
}
|
||||||
|
}
|
||||||
56
app/Services/Auth/StrongAuthentication.php
Normal file
56
app/Services/Auth/StrongAuthentication.php
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Auth;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Facades\Session;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starke Anmeldung für den Admin-Bereich (Entscheidung 5, 07.10.2026).
|
||||||
|
*
|
||||||
|
* Super-Admins und Admins brauchen eine Session, die mit einem zweiten Faktor
|
||||||
|
* bestätigt wurde: Passkey (Login oder Bestätigung, Benutzerverifizierung per
|
||||||
|
* Biometrie/PIN ist Pflicht) oder Authenticator-Code (2FA-Challenge bzw.
|
||||||
|
* Bestätigung). Magic Link, Google und Passwort allein genügen nicht — sonst
|
||||||
|
* ließe sich die Pflicht über den bequemsten Anmeldeweg umgehen. Die Marke
|
||||||
|
* gilt für die Lebensdauer der Session.
|
||||||
|
*/
|
||||||
|
class StrongAuthentication
|
||||||
|
{
|
||||||
|
public const SESSION_KEY = 'auth.strong';
|
||||||
|
|
||||||
|
public const INTENDED_KEY = 'auth.strong_intended';
|
||||||
|
|
||||||
|
public function isRequiredFor(User $user): bool
|
||||||
|
{
|
||||||
|
if (! config('auth.require_strong_admin_authentication', true)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $user->is_super_admin || $user->hasRole('admin');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isSatisfiedBy(User $user): bool
|
||||||
|
{
|
||||||
|
$strong = Session::get(self::SESSION_KEY);
|
||||||
|
|
||||||
|
return is_array($strong) && (int) ($strong['user_id'] ?? 0) === (int) $user->getKey();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $method passkey oder totp
|
||||||
|
*/
|
||||||
|
public function mark(User $user, string $method): void
|
||||||
|
{
|
||||||
|
Session::put(self::SESSION_KEY, [
|
||||||
|
'user_id' => $user->getKey(),
|
||||||
|
'method' => $method,
|
||||||
|
'at' => now()->getTimestamp(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasPasskeys(User $user): bool
|
||||||
|
{
|
||||||
|
return $user->passkeys()->exists();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -19,7 +19,7 @@ use Illuminate\Support\Facades\Mail;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bearbeitungsweg einer MAN-Rechnung nach der Ausstellung (Legacy-
|
* Bearbeitungsweg einer MAN-Rechnung nach der Ausstellung (Legacy-
|
||||||
* Überweisungskunden, dev/lagacy-rechnungen/entwicklungsplan-2026-09-16.md):
|
* Überweisungskunden, dev/archive/legacy-rechnungen/entwicklungsplan-2026-09-16.md):
|
||||||
*
|
*
|
||||||
* senden → (erinnern) → als bezahlt markieren
|
* senden → (erinnern) → als bezahlt markieren
|
||||||
* → Bestandsschutz beenden (Rechnung storniert, Bündel gekündigt)
|
* → Bestandsschutz beenden (Rechnung storniert, Bündel gekündigt)
|
||||||
|
|
|
||||||
209
app/Services/Changelog/ChangelogCollector.php
Normal file
209
app/Services/Changelog/ChangelogCollector.php
Normal file
|
|
@ -0,0 +1,209 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Changelog;
|
||||||
|
|
||||||
|
use App\Enums\ChangelogCategory;
|
||||||
|
use App\Enums\ChangelogStatus;
|
||||||
|
use App\Models\ChangelogEntry;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Facades\Process;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stufe 2 des Changelogs (Decision-Update Changelog): Beim Deploy werden die
|
||||||
|
* Commits zwischen dem vorigen und dem neuen Tag gelesen und aus den Zeilen
|
||||||
|
* `Changelog:` / `Public:` Entwürfe angelegt. Veröffentlicht wird nichts —
|
||||||
|
* das macht der Betreiber im Admin (Stufe 3).
|
||||||
|
*
|
||||||
|
* Die Zeilen werden aus der ganzen Commit-Nachricht gelesen, nicht über
|
||||||
|
* `git interpret-trailers`: Git zählt nur den LETZTEN Absatz als Trailer, und
|
||||||
|
* dort steht meist `Co-Authored-By`.
|
||||||
|
*
|
||||||
|
* Regeln aus dem Decision-Update:
|
||||||
|
* - Ohne `Changelog:` bleibt ein Commit intern.
|
||||||
|
* - Ohne `Public:` wird ein Vorschlag aus dem Titel gemacht und markiert.
|
||||||
|
* - Sicherheit ergibt je Release genau eine Sammelzeile (E5).
|
||||||
|
* - Klingt der Titel nach Sicherheit, die Rubrik aber nicht, wird der
|
||||||
|
* Eintrag auf Sicherheit gesetzt und markiert — zurückstufen nur bewusst.
|
||||||
|
*/
|
||||||
|
class ChangelogCollector
|
||||||
|
{
|
||||||
|
/** Begriffe im Commit-Titel, die auf eine Sicherheitskorrektur deuten. */
|
||||||
|
private const array SECURITY_TERMS = [
|
||||||
|
'auth', 'session', 'token', 'csrf', 'xss', 'injection', 'permission', 'escape', 'sanitize', 'rate limit', 'rate-limit',
|
||||||
|
'sicherheit', 'security',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string|null $repositoryPath Git-Repository (Standard: die Anwendung selbst)
|
||||||
|
*/
|
||||||
|
public function __construct(private readonly ?string $repositoryPath = null) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{created: int, existing: int, internal: int, entries: list<ChangelogEntry>}
|
||||||
|
*/
|
||||||
|
public function collect(string $from, string $to, ?Carbon $releasedOn = null, bool $dryRun = false): array
|
||||||
|
{
|
||||||
|
$releasedOn ??= Carbon::today();
|
||||||
|
$created = 0;
|
||||||
|
$existing = 0;
|
||||||
|
$internal = 0;
|
||||||
|
$entries = [];
|
||||||
|
$hasSecurity = false;
|
||||||
|
|
||||||
|
foreach ($this->commits($from, $to) as $commit) {
|
||||||
|
$trailers = self::parse($commit['message']);
|
||||||
|
|
||||||
|
if ($trailers === null) {
|
||||||
|
$internal++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
[$category, $note] = $this->categoryFor($trailers['category'], $commit['subject']);
|
||||||
|
|
||||||
|
if ($category === ChangelogCategory::Security) {
|
||||||
|
$hasSecurity = true;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$text = $trailers['public'];
|
||||||
|
|
||||||
|
if ($text === null) {
|
||||||
|
$text = self::suggestionFrom($commit['subject']);
|
||||||
|
$note ??= 'Kein Public-Satz im Commit — Vorschlag aus dem Commit-Titel.';
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $this->store($commit['hash'], $releasedOn, $to, $category, $text, $commit['subject'], $note, $dryRun);
|
||||||
|
$result['created'] ? $created++ : $existing++;
|
||||||
|
$entries[] = $result['entry'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($hasSecurity) {
|
||||||
|
$result = $this->store('security:'.$to, $releasedOn, $to, ChangelogCategory::Security, ChangelogCategory::SECURITY_LINE, null, null, $dryRun);
|
||||||
|
$result['created'] ? $created++ : $existing++;
|
||||||
|
$entries[] = $result['entry'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['created' => $created, 'existing' => $existing, 'internal' => $internal, 'entries' => $entries];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zeilen `Changelog:` und `Public:` aus einer Commit-Nachricht. Null, wenn
|
||||||
|
* es keine gültige `Changelog:`-Zeile gibt.
|
||||||
|
*
|
||||||
|
* @return array{category: ChangelogCategory, public: string|null}|null
|
||||||
|
*/
|
||||||
|
public static function parse(string $message): ?array
|
||||||
|
{
|
||||||
|
if (! preg_match('/^Changelog:[ \t]*(.+)$/mi', $message, $categoryMatch)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$category = ChangelogCategory::fromTrailer($categoryMatch[1]);
|
||||||
|
|
||||||
|
if ($category === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$public = null;
|
||||||
|
|
||||||
|
// Public darf umbrechen: bis zur nächsten Leerzeile oder Trailer-Zeile.
|
||||||
|
if (preg_match('/^Public:[ \t]*(.+(?:\n(?![ \t]*\n)(?![A-Za-z-]+:[ \t]).+)*)/mi', $message, $publicMatch)) {
|
||||||
|
$public = trim((string) preg_replace('/\s+/', ' ', $publicMatch[1]));
|
||||||
|
$public = $public === '' ? null : $public;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['category' => $category, 'public' => $public];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vorschlag aus dem Commit-Titel: ohne Bereichspräfix (`web: …`), erster
|
||||||
|
* Buchstabe groß, mit Punkt.
|
||||||
|
*/
|
||||||
|
public static function suggestionFrom(string $subject): string
|
||||||
|
{
|
||||||
|
$text = trim((string) preg_replace('/^[a-z][\w-]*(\([^)]*\))?!?:\s*/i', '', $subject));
|
||||||
|
|
||||||
|
return rtrim(mb_strtoupper(mb_substr($text, 0, 1)).mb_substr($text, 1), '.').'.';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{0: ChangelogCategory, 1: string|null}
|
||||||
|
*/
|
||||||
|
private function categoryFor(ChangelogCategory $category, string $subject): array
|
||||||
|
{
|
||||||
|
if ($category === ChangelogCategory::Security) {
|
||||||
|
return [$category, null];
|
||||||
|
}
|
||||||
|
|
||||||
|
$lower = mb_strtolower($subject);
|
||||||
|
|
||||||
|
foreach (self::SECURITY_TERMS as $term) {
|
||||||
|
if (preg_match('/\b'.preg_quote($term, '/').'\b/u', $lower)) {
|
||||||
|
return [ChangelogCategory::Security, sprintf('Automatisch auf Sicherheit gesetzt („%s“ im Titel, Rubrik war %s) — zurückstufen nur bewusst.', $term, $category->label())];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$category, null];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{created: bool, entry: ChangelogEntry}
|
||||||
|
*/
|
||||||
|
private function store(string $key, Carbon $releasedOn, string $tag, ChangelogCategory $category, string $text, ?string $subject, ?string $note, bool $dryRun): array
|
||||||
|
{
|
||||||
|
$existing = ChangelogEntry::query()->where('source_key', $key)->first();
|
||||||
|
|
||||||
|
if ($existing !== null) {
|
||||||
|
return ['created' => false, 'entry' => $existing];
|
||||||
|
}
|
||||||
|
|
||||||
|
$entry = new ChangelogEntry([
|
||||||
|
'released_on' => $releasedOn->toDateString(),
|
||||||
|
'release_tag' => mb_substr($tag, 0, 20),
|
||||||
|
'category' => $category,
|
||||||
|
'text' => mb_substr($text, 0, 500),
|
||||||
|
'status' => ChangelogStatus::Draft,
|
||||||
|
'source_key' => $key,
|
||||||
|
'commit_subject' => $subject === null ? null : mb_substr($subject, 0, 255),
|
||||||
|
'review_note' => $note === null ? null : mb_substr($note, 0, 255),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (! $dryRun) {
|
||||||
|
$entry->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['created' => true, 'entry' => $entry];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Commits von `$from` (ausschließlich) bis `$to`, älteste zuerst, ohne
|
||||||
|
* Merges.
|
||||||
|
*
|
||||||
|
* @return list<array{hash: string, subject: string, message: string}>
|
||||||
|
*/
|
||||||
|
private function commits(string $from, string $to): array
|
||||||
|
{
|
||||||
|
$result = Process::path($this->repositoryPath ?? base_path())->run([
|
||||||
|
'git', 'log', '--no-merges', '--reverse', '--format=%H%x1f%s%x1f%B%x1e', $from.'..'.$to,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (! $result->successful()) {
|
||||||
|
throw new RuntimeException(sprintf('git log %s..%s fehlgeschlagen: %s', $from, $to, trim($result->errorOutput())));
|
||||||
|
}
|
||||||
|
|
||||||
|
$commits = [];
|
||||||
|
|
||||||
|
foreach (explode("\x1e", $result->output()) as $record) {
|
||||||
|
$parts = explode("\x1f", trim($record), 3);
|
||||||
|
|
||||||
|
if (count($parts) === 3 && $parts[0] !== '') {
|
||||||
|
$commits[] = ['hash' => $parts[0], 'subject' => $parts[1], 'message' => $parts[2]];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $commits;
|
||||||
|
}
|
||||||
|
}
|
||||||
69
app/Services/Changelog/ChangelogTimeline.php
Normal file
69
app/Services/Changelog/ChangelogTimeline.php
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Changelog;
|
||||||
|
|
||||||
|
use App\Enums\ChangelogCategory;
|
||||||
|
use App\Models\ChangelogEntry;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Öffentliche Sicht auf den Changelog: nur freigegebene Einträge, jüngste
|
||||||
|
* zuerst, je Tag in der Reihenfolge der Rubriken. Sicherheit erscheint je Tag
|
||||||
|
* höchstens einmal als Sammelzeile (E5) — auch wenn mehrere Releases an einem
|
||||||
|
* Tag eine mitbringen.
|
||||||
|
*/
|
||||||
|
class ChangelogTimeline
|
||||||
|
{
|
||||||
|
public const string RECENT_CACHE_KEY = 'changelog.has-recent';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Collection<int, ChangelogEntry>
|
||||||
|
*/
|
||||||
|
public function entries(int $limit = 200): Collection
|
||||||
|
{
|
||||||
|
$entries = ChangelogEntry::query()
|
||||||
|
->published()
|
||||||
|
->orderByDesc('released_on')
|
||||||
|
->orderBy('id')
|
||||||
|
->limit($limit)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$seenSecurityDays = [];
|
||||||
|
|
||||||
|
return $entries
|
||||||
|
->filter(function (ChangelogEntry $entry) use (&$seenSecurityDays): bool {
|
||||||
|
if ($entry->category !== ChangelogCategory::Security) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$day = $entry->released_on->toDateString();
|
||||||
|
|
||||||
|
if (isset($seenSecurityDays[$day])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$seenSecurityDays[$day] = true;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
->sortBy([
|
||||||
|
fn (ChangelogEntry $a, ChangelogEntry $b): int => $b->released_on <=> $a->released_on,
|
||||||
|
fn (ChangelogEntry $a, ChangelogEntry $b): int => $a->category->order() <=> $b->category->order(),
|
||||||
|
fn (ChangelogEntry $a, ChangelogEntry $b): int => $a->id <=> $b->id,
|
||||||
|
])
|
||||||
|
->values();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt es einen freigegebenen Eintrag der letzten Tage? Für den
|
||||||
|
* „Neu“-Hinweis im Kundenbereich (E11).
|
||||||
|
*/
|
||||||
|
public function hasRecent(int $days = 14): bool
|
||||||
|
{
|
||||||
|
return ChangelogEntry::query()
|
||||||
|
->published()
|
||||||
|
->where('released_on', '>=', Carbon::today()->subDays($days)->toDateString())
|
||||||
|
->exists();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,9 @@ use App\Models\MagicLink;
|
||||||
use App\Models\PressRelease;
|
use App\Models\PressRelease;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Scopes\PortalScope;
|
use App\Scopes\PortalScope;
|
||||||
|
use App\Support\CrawlerIdentifier;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Schreibt die Ereignisse der Konversionsleiter (Roadmap 0.5).
|
* Schreibt die Ereignisse der Konversionsleiter (Roadmap 0.5).
|
||||||
|
|
@ -73,6 +76,83 @@ class ConversionRecorder
|
||||||
$this->record($type, $source, $company, $pressRelease);
|
$this->record($type, $source, $company, $pressRelease);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stufe 2: die Seite „Diesen Newsroom verwalten“ wurde geöffnet.
|
||||||
|
*
|
||||||
|
* Aus einer Mail (`src`) oder über den Hinweis auf der Newsroom- bzw.
|
||||||
|
* Meldungsseite. Wie beim Mail-Klick zählen nur echte Aufrufe, und
|
||||||
|
* dieselbe Adresse zählt je Firma höchstens einmal im Zeitfenster.
|
||||||
|
*/
|
||||||
|
public function recordManagePageOpened(Request $request, Company $company, ConversionSource $source, ?PressRelease $pressRelease = null): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
if (! $request->isMethod('GET') || $this->isAutomatedVisit($request->userAgent())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$key = 'manage-page:'.hash('sha256', (string) $request->ip()).':'.$company->getKey();
|
||||||
|
$decay = max(1, (int) config('conversion.mail_clicks.throttle_minutes', 30)) * 60;
|
||||||
|
|
||||||
|
if (! RateLimiter::attempt($key, 1, static fn (): bool => true, $decay)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
report($e);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->record(ConversionEventType::ManagePageOpened, $source, $company, $pressRelease);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stufe 4: Magic Link eingelöst — mit der Firma und Meldung, die das
|
||||||
|
* Token trägt („Diesen Newsroom verwalten“, Veröffentlichungsmail).
|
||||||
|
* Ohne diesen Bezug landete jede Einlösung in „ohne Firma“, und die
|
||||||
|
* Stufe zeigte bis 13.10.2026 immer null Firmen (Roadmap 6.16).
|
||||||
|
*/
|
||||||
|
public function recordMagicLinkConsumed(MagicLink $magicLink): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$companyId = (int) ($magicLink->payload['company_id'] ?? 0);
|
||||||
|
$pressReleaseId = (int) ($magicLink->payload['press_release_id'] ?? 0);
|
||||||
|
|
||||||
|
$company = $companyId > 0
|
||||||
|
? Company::query()->withoutGlobalScope(PortalScope::class)->find($companyId)
|
||||||
|
: null;
|
||||||
|
$pressRelease = $pressReleaseId > 0
|
||||||
|
? PressRelease::query()->withoutGlobalScope(PortalScope::class)->find($pressReleaseId)
|
||||||
|
: null;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
report($e);
|
||||||
|
$company = null;
|
||||||
|
$pressRelease = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->record(ConversionEventType::MagicLinkConsumed, $this->sourceOf($magicLink), $company, $pressRelease);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mailserver-Scanner, Vorschau-Dienste und Crawler — kein Mensch, also
|
||||||
|
* kein Schritt in der Leiter (Umsetzungsaudit 29.09.2026, R-08).
|
||||||
|
*/
|
||||||
|
public function isAutomatedVisit(?string $userAgent): bool
|
||||||
|
{
|
||||||
|
$userAgent = mb_strtolower(trim((string) $userAgent));
|
||||||
|
|
||||||
|
if ($userAgent === '' || CrawlerIdentifier::identify($userAgent) !== null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ((array) config('conversion.mail_clicks.scanner_agents', []) as $pattern) {
|
||||||
|
if (str_contains($userAgent, mb_strtolower((string) $pattern))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Herkunft eines Magic-Link-Tokens.
|
* Herkunft eines Magic-Link-Tokens.
|
||||||
*
|
*
|
||||||
|
|
@ -102,13 +182,17 @@ class ConversionRecorder
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stufe 5 der Konversionsleiter.
|
* Stufe 7 der Konversionsleiter.
|
||||||
*
|
*
|
||||||
* Der Trichter läuft über die FIRMA, gezahlt wird aber vom Konto. Die
|
* Der Trichter läuft über die FIRMA, gezahlt wird aber vom Konto. Die
|
||||||
* Brücke ist die Firma des Kontos — bevorzugt die eigene, sonst die erste
|
* Brücke ist die Firma des Kontos — bevorzugt die eigene, sonst die erste
|
||||||
* zugeordnete. Ohne Firma bleibt der Bezug leer; das Ereignis ist dann
|
* zugeordnete. Ohne Firma bleibt der Bezug leer; das Ereignis ist dann
|
||||||
* für die Trichter-Auswertung wertlos, aber immer noch eine ehrliche Zeile
|
* für die Trichter-Auswertung wertlos, aber immer noch eine ehrliche Zeile
|
||||||
* im Log.
|
* im Log.
|
||||||
|
*
|
||||||
|
* Die Herkunft ist wie bei Stufe 5 die des letzten eingelösten Magic
|
||||||
|
* Links; bis 13.10.2026 stand hier immer `organic`, auch nach einem
|
||||||
|
* Claim aus der Veröffentlichungsmail (Roadmap 6.16).
|
||||||
*/
|
*/
|
||||||
public function recordFirstBooking(?User $user): void
|
public function recordFirstBooking(?User $user): void
|
||||||
{
|
{
|
||||||
|
|
@ -125,11 +209,11 @@ class ConversionRecorder
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->recordOnce(ConversionEventType::FirstBooking, ConversionSource::Organic, $company);
|
$this->recordOnce(ConversionEventType::FirstBooking, $this->lastMagicLinkSource($user), $company);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stufe 3 der Konversionsleiter: Newsroom gepflegt (Roadmap 1b.3).
|
* Stufe 5 der Konversionsleiter: Newsroom gepflegt (Roadmap 1b.3).
|
||||||
*
|
*
|
||||||
* Einmal je Firma. Die Herkunft ist die des letzten eingelösten Magic
|
* Einmal je Firma. Die Herkunft ist die des letzten eingelösten Magic
|
||||||
* Links dieses Kontos — wer über „Diesen Newsroom verwalten" aus der Mail
|
* Links dieses Kontos — wer über „Diesen Newsroom verwalten" aus der Mail
|
||||||
|
|
@ -138,8 +222,17 @@ class ConversionRecorder
|
||||||
*/
|
*/
|
||||||
public function recordNewsroomEdited(Company $company, ?User $user): void
|
public function recordNewsroomEdited(Company $company, ?User $user): void
|
||||||
{
|
{
|
||||||
|
$this->recordOnce(ConversionEventType::NewsroomEdited, $this->lastMagicLinkSource($user), $company);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function lastMagicLinkSource(?User $user): ConversionSource
|
||||||
|
{
|
||||||
|
if ($user === null) {
|
||||||
|
return ConversionSource::Organic;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$source = $user === null ? null : MagicLink::query()
|
$source = MagicLink::query()
|
||||||
->where('user_id', $user->getKey())
|
->where('user_id', $user->getKey())
|
||||||
->whereIn('purpose', MagicLink::LOGIN_PURPOSES)
|
->whereIn('purpose', MagicLink::LOGIN_PURPOSES)
|
||||||
->whereNotNull('consumed_at')
|
->whereNotNull('consumed_at')
|
||||||
|
|
@ -150,7 +243,7 @@ class ConversionRecorder
|
||||||
$source = null;
|
$source = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->recordOnce(ConversionEventType::NewsroomEdited, ConversionSource::parse($source), $company);
|
return ConversionSource::parse($source);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function alreadyRecorded(ConversionEventType $type, Company $company): bool
|
private function alreadyRecorded(ConversionEventType $type, Company $company): bool
|
||||||
|
|
|
||||||
271
app/Services/Growth/GrowthReport.php
Normal file
271
app/Services/Growth/GrowthReport.php
Normal file
|
|
@ -0,0 +1,271 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Growth;
|
||||||
|
|
||||||
|
use App\Enums\ConversionEventType;
|
||||||
|
use App\Enums\ConversionSource;
|
||||||
|
use App\Models\ConversionEvent;
|
||||||
|
use App\Services\Analytics\UmamiClient;
|
||||||
|
use App\Services\Analytics\UmamiUnavailable;
|
||||||
|
use App\Services\PressRelease\FeedFetchReport;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stand der Abbruchbedingungen für Block 1 bis 3 (Roadmap 6.1, 6.16).
|
||||||
|
*
|
||||||
|
* Je Bedingung: Ist-Wert, Schwelle, Status. Für Bedingungen, die über den
|
||||||
|
* Zeitraum hinweg zusammenkommen (Block 1), zusätzlich „auf Kurs“: Liegt der
|
||||||
|
* Ist-Wert mindestens beim zeitanteiligen Soll? Block 2 und 3 sind
|
||||||
|
* Momentaufnahmen der letzten 30 Tage und haben kein Soll unterwegs.
|
||||||
|
*
|
||||||
|
* Quellen: eigene Ereignisdaten (Trichter, Feed-Abrufe) und Umami. Fällt
|
||||||
|
* Umami aus, bleibt der Rest stehen; die Umami-Werte zeigen dann den Grund.
|
||||||
|
*/
|
||||||
|
class GrowthReport
|
||||||
|
{
|
||||||
|
/** Herkünfte, die als „über Mail oder Claim“ zählen. */
|
||||||
|
private const array CLAIM_SOURCES = [
|
||||||
|
ConversionSource::MailPublish,
|
||||||
|
ConversionSource::MailReject,
|
||||||
|
ConversionSource::MailReview,
|
||||||
|
ConversionSource::ProfileClaim,
|
||||||
|
];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly UmamiClient $umami,
|
||||||
|
private readonly FeedFetchReport $feeds,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* period: array{start: Carbon, review: Carbon, end: Carbon, elapsed_share: float},
|
||||||
|
* blocks: list<array{key: string, title: string, consequence: string, criteria: list<array<string, mixed>>}>,
|
||||||
|
* umami: array{configured: bool, error: string|null},
|
||||||
|
* pages: list<array{brand: string, page: string, views: int, search_views: int}>,
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function build(bool $fresh = false, ?Carbon $now = null): array
|
||||||
|
{
|
||||||
|
$now ??= Carbon::now();
|
||||||
|
$start = Carbon::parse((string) config('growth.period.start'))->startOfDay();
|
||||||
|
$review = Carbon::parse((string) config('growth.period.review'))->startOfDay();
|
||||||
|
$end = Carbon::parse((string) config('growth.period.end'))->endOfDay();
|
||||||
|
$elapsedShare = $now->lessThanOrEqualTo($start) ? 0.0 : min(1.0, $start->diffInSeconds($now) / max(1, $start->diffInSeconds($end)));
|
||||||
|
$ended = $now->greaterThan($end);
|
||||||
|
|
||||||
|
$umami = $this->searchViews($fresh, $now);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'period' => ['start' => $start, 'review' => $review, 'end' => $end, 'elapsed_share' => $elapsedShare],
|
||||||
|
'blocks' => [
|
||||||
|
[
|
||||||
|
'key' => 'block1',
|
||||||
|
'title' => 'Block 1 — Konversion aus dem Bestand',
|
||||||
|
'consequence' => 'Wenn verfehlt: Block 1 einfrieren, Gewicht auf Block 3.',
|
||||||
|
'criteria' => [
|
||||||
|
$this->criterion('claims', 'Firmen mit eingelöstem Verwaltungslink', $this->claimedCompanies($start), (int) config('growth.block1.claims_min'), $elapsedShare, $ended, cumulative: true,
|
||||||
|
note: $this->claimsWithoutCompanyNote($start)),
|
||||||
|
$this->criterion('bookings', 'Erstbuchungen über Mail oder Claim', $this->companiesWith(ConversionEventType::FirstBooking, $start), (int) config('growth.block1.bookings_min'), $elapsedShare, $ended, cumulative: true),
|
||||||
|
$this->criterion('reject_clicks', 'Klicks aus der Ablehnungsmail', $this->rejectMailClicks($start), (int) config('growth.block1.reject_clicks_min'), $elapsedShare, $ended, cumulative: true),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => 'block2',
|
||||||
|
'title' => 'Block 2 — Organische Sichtbarkeit',
|
||||||
|
'consequence' => 'Wenn verfehlt: Marketing-Teil nicht weiter ausbauen, nur 2.1 bis 2.3 behalten.',
|
||||||
|
'criteria' => [
|
||||||
|
$this->criterion('search_views', 'Aufrufe der Transparenzseiten aus der Suche (30 Tage)', $umami['total'], (int) config('growth.block2.search_views_min'), $elapsedShare, $ended, cumulative: false,
|
||||||
|
note: $umami['error'] ?? 'Gewertet wird der Monat '.Carbon::parse((string) config('growth.block2.evaluation_month').'-01')->locale('de')->isoFormat('MMMM YYYY').'.'),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => 'block3',
|
||||||
|
'title' => 'Block 3 — Reichweite',
|
||||||
|
'consequence' => 'Wenn verfehlt: RSS ist kein Verkaufsargument; der Digest muss sich eigenständig begründen.',
|
||||||
|
'criteria' => [
|
||||||
|
$this->criterion('feed_subscribers', 'Gemeldete Feed-Abonnenten (30 Tage)', $this->feedSubscribers(), (int) config('growth.block3.feed_subscribers_min'), $elapsedShare, $ended, cumulative: false,
|
||||||
|
note: 'Nur Reader-Dienste, die Abonnenten im User-Agent melden (Feedly, Inoreader …).'),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'umami' => ['configured' => $this->umami->isConfigured(), 'error' => $umami['error']],
|
||||||
|
'pages' => $umami['pages'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function statusLabel(string $status): string
|
||||||
|
{
|
||||||
|
return match ($status) {
|
||||||
|
'reached' => 'erreicht',
|
||||||
|
'on_track' => 'auf Kurs',
|
||||||
|
'behind' => 'hinter Plan',
|
||||||
|
'open' => 'noch nicht erreicht',
|
||||||
|
'missed' => 'verfehlt',
|
||||||
|
default => 'keine Daten',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flux-Badge-Farbe je Status.
|
||||||
|
*/
|
||||||
|
public static function statusColor(string $status): string
|
||||||
|
{
|
||||||
|
return match ($status) {
|
||||||
|
'reached', 'on_track' => 'green',
|
||||||
|
'behind', 'open' => 'amber',
|
||||||
|
'missed' => 'red',
|
||||||
|
default => 'zinc',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function criterion(string $key, string $label, ?int $actual, int $threshold, float $elapsedShare, bool $ended, bool $cumulative, ?string $note = null): array
|
||||||
|
{
|
||||||
|
$expected = $cumulative ? (int) ceil($threshold * $elapsedShare) : null;
|
||||||
|
|
||||||
|
$status = match (true) {
|
||||||
|
$actual === null => 'unknown',
|
||||||
|
$actual >= $threshold => 'reached',
|
||||||
|
$ended => 'missed',
|
||||||
|
$cumulative && $actual >= $expected => 'on_track',
|
||||||
|
$cumulative => 'behind',
|
||||||
|
default => 'open',
|
||||||
|
};
|
||||||
|
|
||||||
|
return [
|
||||||
|
'key' => $key,
|
||||||
|
'label' => $label,
|
||||||
|
'actual' => $actual,
|
||||||
|
'threshold' => $threshold,
|
||||||
|
'expected' => $expected,
|
||||||
|
'status' => $status,
|
||||||
|
'note' => $note,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function claimedCompanies(Carbon $start): int
|
||||||
|
{
|
||||||
|
return $this->companiesWith(ConversionEventType::MagicLinkConsumed, $start);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function companiesWith(ConversionEventType $type, Carbon $start): int
|
||||||
|
{
|
||||||
|
return ConversionEvent::query()
|
||||||
|
->where('type', $type->value)
|
||||||
|
->whereIn('source', array_map(static fn (ConversionSource $source): string => $source->value, self::CLAIM_SOURCES))
|
||||||
|
->whereNotNull('company_id')
|
||||||
|
->where('created_at', '>=', $start)
|
||||||
|
->distinct()
|
||||||
|
->count('company_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Einlösungen vor v1.10 tragen keine Firma (Roadmap 6.16) und fehlen
|
||||||
|
* deshalb in der Zählung — der Hinweis macht die Lücke sichtbar.
|
||||||
|
*/
|
||||||
|
private function claimsWithoutCompanyNote(Carbon $start): ?string
|
||||||
|
{
|
||||||
|
$count = ConversionEvent::query()
|
||||||
|
->where('type', ConversionEventType::MagicLinkConsumed->value)
|
||||||
|
->whereIn('source', array_map(static fn (ConversionSource $source): string => $source->value, self::CLAIM_SOURCES))
|
||||||
|
->whereNull('company_id')
|
||||||
|
->where('created_at', '>=', $start)
|
||||||
|
->count();
|
||||||
|
|
||||||
|
return $count > 0
|
||||||
|
? sprintf('Dazu %d %s ohne Firmenbezug (vor der Korrektur am 13.10.2026 erfasst).', $count, $count === 1 ? 'Einlösung' : 'Einlösungen')
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function rejectMailClicks(Carbon $start): int
|
||||||
|
{
|
||||||
|
return ConversionEvent::query()
|
||||||
|
->where('type', ConversionEventType::MailLinkClicked->value)
|
||||||
|
->where('source', ConversionSource::MailReject->value)
|
||||||
|
->where('created_at', '>=', $start)
|
||||||
|
->count();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function feedSubscribers(): int
|
||||||
|
{
|
||||||
|
return (int) collect($this->feeds->byReader(30))->sum(static fn (array $reader): int => (int) $reader['subscribers']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aufrufe der Transparenzseiten der letzten 30 Tage, gesamt und aus der
|
||||||
|
* Suche, je Marke und Seite. Deutsch und Englisch zusammen
|
||||||
|
* (Filter „enthält“ auf den Slug).
|
||||||
|
*
|
||||||
|
* @return array{total: int|null, error: string|null, pages: list<array{brand: string, page: string, views: int, search_views: int}>}
|
||||||
|
*/
|
||||||
|
private function searchViews(bool $fresh, Carbon $now): array
|
||||||
|
{
|
||||||
|
if (! $this->umami->isConfigured()) {
|
||||||
|
return ['total' => null, 'error' => 'Umami-API nicht verbunden (UMAMI_API_* in der .env).', 'pages' => []];
|
||||||
|
}
|
||||||
|
|
||||||
|
$key = 'growth.block2.'.$now->toDateString();
|
||||||
|
|
||||||
|
if ($fresh) {
|
||||||
|
Cache::forget($key);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
/** @var list<array{brand: string, page: string, views: int, search_views: int}> $pages */
|
||||||
|
$pages = Cache::remember($key, now()->addMinutes((int) config('growth.cache_minutes', 360)), function () use ($now): array {
|
||||||
|
$from = $now->copy()->subDays(30);
|
||||||
|
$engines = (array) config('growth.block2.search_referrers', []);
|
||||||
|
$rows = [];
|
||||||
|
|
||||||
|
foreach ((array) config('growth.block2.brands', []) as $brand) {
|
||||||
|
$websiteId = $this->umami->websiteId((string) $brand);
|
||||||
|
|
||||||
|
if ($websiteId === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ((array) config('growth.block2.pages', []) as $page) {
|
||||||
|
$filter = ['path' => 'c.'.$page];
|
||||||
|
$views = $this->umami->stats($websiteId, $from, $now, $filter)['pageviews'];
|
||||||
|
$search = 0;
|
||||||
|
|
||||||
|
if ($views > 0) {
|
||||||
|
foreach ($this->umami->metrics($websiteId, 'referrer', $from, $now, $filter, 100) as $referrer) {
|
||||||
|
if (self::isSearchReferrer($referrer['x'], $engines)) {
|
||||||
|
$search += $referrer['y'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows[] = ['brand' => (string) $brand, 'page' => (string) $page, 'views' => $views, 'search_views' => $search];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $rows;
|
||||||
|
});
|
||||||
|
} catch (UmamiUnavailable $e) {
|
||||||
|
return ['total' => null, 'error' => $e->getMessage(), 'pages' => []];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['total' => (int) array_sum(array_column($pages, 'search_views')), 'error' => null, 'pages' => $pages];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, mixed> $engines
|
||||||
|
*/
|
||||||
|
private static function isSearchReferrer(string $referrer, array $engines): bool
|
||||||
|
{
|
||||||
|
$referrer = mb_strtolower($referrer);
|
||||||
|
|
||||||
|
foreach ($engines as $engine) {
|
||||||
|
if ($engine !== '' && str_contains($referrer, mb_strtolower((string) $engine))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
namespace App\Services\PressRelease;
|
namespace App\Services\PressRelease;
|
||||||
|
|
||||||
|
use App\Enums\ActivityType;
|
||||||
use App\Enums\BoostPlacement;
|
use App\Enums\BoostPlacement;
|
||||||
use App\Enums\Portal;
|
use App\Enums\Portal;
|
||||||
use App\Enums\PressReleaseClassification;
|
use App\Enums\PressReleaseClassification;
|
||||||
|
|
@ -11,6 +12,7 @@ use App\Models\Boost;
|
||||||
use App\Models\PressRelease;
|
use App\Models\PressRelease;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Scopes\PortalScope;
|
use App\Scopes\PortalScope;
|
||||||
|
use App\Services\ActivityLog;
|
||||||
use App\Services\Billing\CreditPricingService;
|
use App\Services\Billing\CreditPricingService;
|
||||||
use App\Services\Billing\CreditWalletService;
|
use App\Services\Billing\CreditWalletService;
|
||||||
use App\Support\WebPageCache;
|
use App\Support\WebPageCache;
|
||||||
|
|
@ -35,6 +37,7 @@ class BoostService
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly CreditPricingService $pricing,
|
private readonly CreditPricingService $pricing,
|
||||||
private readonly CreditWalletService $wallet,
|
private readonly CreditWalletService $wallet,
|
||||||
|
private readonly ActivityLog $activityLog,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -180,6 +183,52 @@ class BoostService
|
||||||
return $boost;
|
return $boost;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Beendet alle laufenden und bereits angeschlossenen Boosts einer PM
|
||||||
|
* sofort (Admin ▸ Pressemitteilung), etwa nach einer Test-Buchung oder
|
||||||
|
* wenn die Platzierung nicht mehr vertretbar ist. Die Zeilen bleiben als
|
||||||
|
* Buchungsnachweis erhalten; Credits werden nicht zurückgebucht —
|
||||||
|
* Erstattungen laufen getrennt.
|
||||||
|
*
|
||||||
|
* @return int Anzahl der beendeten Boosts
|
||||||
|
*/
|
||||||
|
public function endActive(PressRelease $pressRelease, User $endedBy, string $reason): int
|
||||||
|
{
|
||||||
|
$now = now();
|
||||||
|
$boosts = $pressRelease->boosts()->active()->get();
|
||||||
|
|
||||||
|
if ($boosts->isEmpty()) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($boosts as $boost) {
|
||||||
|
$boost->update([
|
||||||
|
'starts_at' => $boost->starts_at->isFuture() ? $now : $boost->starts_at,
|
||||||
|
'ends_at' => $now,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$boosts->pluck('placement')->unique()->each(
|
||||||
|
fn (BoostPlacement $placement) => $this->forgetPlacementCaches($pressRelease, $placement),
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->activityLog->record(
|
||||||
|
ActivityType::BoostEnded,
|
||||||
|
sprintf(
|
||||||
|
'%s hat %d gebuchte Platzierung(en) von PM #%d vorzeitig beendet. Grund: %s',
|
||||||
|
$endedBy->name,
|
||||||
|
$boosts->count(),
|
||||||
|
$pressRelease->id,
|
||||||
|
$reason,
|
||||||
|
),
|
||||||
|
user: $endedBy,
|
||||||
|
subject: $pressRelease,
|
||||||
|
meta: ['boost_ids' => $boosts->modelKeys(), 'ended_by' => $endedBy->id, 'reason' => $reason],
|
||||||
|
);
|
||||||
|
|
||||||
|
return $boosts->count();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Serialisiert Käufe, die dasselbe sichtbare Slot-Inventar verändern.
|
* Serialisiert Käufe, die dasselbe sichtbare Slot-Inventar verändern.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -1107,14 +1107,18 @@ class PressReleaseService
|
||||||
// API-Einlieferer (z. B. connektar) nicht zuspammen (Entscheidung
|
// API-Einlieferer (z. B. connektar) nicht zuspammen (Entscheidung
|
||||||
// 10.07.2026): Statt-Mail an den Pressekontakt der PM, sonst an die
|
// 10.07.2026): Statt-Mail an den Pressekontakt der PM, sonst an die
|
||||||
// Firmen-E-Mail; nur ohne beides an den Einlieferer selbst. Marker
|
// Firmen-E-Mail; nur ohne beides an den Einlieferer selbst. Marker
|
||||||
// ist die Quelle des letzten Review-Uebergangs im Status-Log — der
|
// ist ein Review-Uebergang aus der API im Status-Log — der Kontotyp
|
||||||
// Kontotyp ist nicht verlaesslich (connektar ist z. B. 'agency').
|
// ist nicht verlaesslich (connektar ist z. B. 'agency'). Bewusst
|
||||||
|
// irgendeiner, nicht der letzte: Reicht der Pressekontakt eine
|
||||||
|
// abgelehnte API-Meldung im Kundenbereich neu ein (Roadmap 6.18),
|
||||||
|
// bleibt es eine API-Meldung, und das Ergebnis geht an ihn statt an
|
||||||
|
// das Konto des Einlieferers.
|
||||||
$recipient = $user->email;
|
$recipient = $user->email;
|
||||||
$recipientName = $user->name;
|
$recipientName = $user->name;
|
||||||
$isApiSubmitter = $pressRelease->statusLogs()
|
$isApiSubmitter = $pressRelease->statusLogs()
|
||||||
->where('to_status', PressReleaseStatus::Review->value)
|
->where('to_status', PressReleaseStatus::Review->value)
|
||||||
->orderByDesc('id')
|
->where('source', 'api')
|
||||||
->value('source') === 'api';
|
->exists();
|
||||||
|
|
||||||
if ($isApiSubmitter) {
|
if ($isApiSubmitter) {
|
||||||
// Scope-frei auflösen: der Mail-Versand läuft auch aus Jobs/
|
// Scope-frei auflösen: der Mail-Versand läuft auch aus Jobs/
|
||||||
|
|
|
||||||
27
app/Services/Spam/BotCheck.php
Normal file
27
app/Services/Spam/BotCheck.php
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Spam;
|
||||||
|
|
||||||
|
use App\Enums\BotCheckStatus;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ergebnis einer Turnstile-Prüfung samt technischem Grund (für Log und
|
||||||
|
* Markierung, nie für den Nutzer).
|
||||||
|
*/
|
||||||
|
final readonly class BotCheck
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public BotCheckStatus $status,
|
||||||
|
public string $reason = '',
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function blocks(): bool
|
||||||
|
{
|
||||||
|
return $this->status->blocks();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function needsReview(): bool
|
||||||
|
{
|
||||||
|
return $this->status === BotCheckStatus::Unavailable;
|
||||||
|
}
|
||||||
|
}
|
||||||
136
app/Services/Spam/ContactSpamFilter.php
Normal file
136
app/Services/Spam/ContactSpamFilter.php
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Spam;
|
||||||
|
|
||||||
|
use App\Rules\NoPromotionalText;
|
||||||
|
use App\Services\PressRelease\BlacklistService;
|
||||||
|
use Illuminate\Contracts\Encryption\DecryptException;
|
||||||
|
use Illuminate\Support\Facades\Crypt;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inhaltsfilter des Kontaktformulars für nicht angemeldete Absender
|
||||||
|
* (Spamschutz B2). Gemessen an 141 Anfragen aus 30 Tagen bis 07.10.2026:
|
||||||
|
* 56 mit Link, 43 mit Floskel plus Seitentitel, dazu Zeichensalat.
|
||||||
|
*
|
||||||
|
* Ein Treffer blockiert nicht, sondern legt die Anfrage in die Quarantäne
|
||||||
|
* (ContactMessageStatus::Spam) — Fehlalarme holt der Admin zurück.
|
||||||
|
*/
|
||||||
|
class ContactSpamFilter
|
||||||
|
{
|
||||||
|
/** Feld mit dem verschlüsselten Zeitpunkt, zu dem das Formular ausgeliefert wurde. */
|
||||||
|
public const STARTED_FIELD = 'form_started';
|
||||||
|
|
||||||
|
/** Schneller füllt kein Mensch Name, E-Mail und Nachricht aus. */
|
||||||
|
private const MIN_SECONDS = 3;
|
||||||
|
|
||||||
|
/** HTML-Links und BBCode, die das Link-Muster nicht abdeckt. */
|
||||||
|
private const MARKUP_PATTERN = '~<a\s|\[url[\]=]~iu';
|
||||||
|
|
||||||
|
public function __construct(private readonly BlacklistService $blacklist) {}
|
||||||
|
|
||||||
|
public static function startedToken(): string
|
||||||
|
{
|
||||||
|
return Crypt::encryptString((string) now()->getTimestamp());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grund (technischer Schlüssel) oder null, wenn die Anfrage sauber ist.
|
||||||
|
*/
|
||||||
|
public function reasonFor(string $name, string $message, ?string $startedToken): ?string
|
||||||
|
{
|
||||||
|
if ($this->containsForeignLink($message)) {
|
||||||
|
return 'link';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->quotesPageTitle($message)) {
|
||||||
|
return 'page_title';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (GibberishDetector::isRandomToken($name) || GibberishDetector::isRandomToken($message)) {
|
||||||
|
return 'gibberish';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->blacklist->find($name.' '.$message) !== null) {
|
||||||
|
return 'blacklist';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->submittedTooFast($startedToken)) {
|
||||||
|
return 'too_fast';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gäste dürfen keine Links schicken (Entscheidung 5) — außer die Nachricht
|
||||||
|
* verweist auf eine unserer Seiten: Korrekturwünsche nennen die eigene
|
||||||
|
* Pressemitteilung und oft daneben die Firmen-Website (KT-32, KT-161).
|
||||||
|
*/
|
||||||
|
private function containsForeignLink(string $message): bool
|
||||||
|
{
|
||||||
|
if (preg_match(self::MARKUP_PATTERN, $message) === 1) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match(NoPromotionalText::LINK_PATTERN, $message) !== 1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ownHosts = collect((array) config('domains.domains', []))
|
||||||
|
->pluck('domain_name')
|
||||||
|
->filter()
|
||||||
|
->map(fn (string $host): string => preg_quote(Str::after($host, 'www.'), '~'))
|
||||||
|
->unique()
|
||||||
|
->implode('|');
|
||||||
|
|
||||||
|
if ($ownHosts === '') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mit Schema („https://presseecho.de“) oder mit Pfad („presseecho.de/de/…“).
|
||||||
|
$ownLink = '~https?://(?:www\.)?(?:'.$ownHosts.')\b|\b(?:www\.)?(?:'.$ownHosts.')/~i';
|
||||||
|
|
||||||
|
return preg_match($ownLink, $message) !== 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formular-Bots hängen den Seitentitel an eine Floskel: „Ich wünsche weitere
|
||||||
|
* Informationen … — aktuelle pressemitteilungen aus der deutschen wirtschaft.“
|
||||||
|
*/
|
||||||
|
private function quotesPageTitle(string $message): bool
|
||||||
|
{
|
||||||
|
$haystack = Str::lower($message);
|
||||||
|
|
||||||
|
foreach ((array) config('domains.domains', []) as $domain) {
|
||||||
|
foreach (preg_split('/\s[–-]\s/u', (string) ($domain['brand']['meta_title'] ?? '')) ?: [] as $segment) {
|
||||||
|
$segment = Str::lower(trim($segment));
|
||||||
|
|
||||||
|
if (mb_strlen($segment) >= 20 && str_contains($haystack, $segment)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fehlt der Zeitstempel oder ist er manipuliert, entscheidet Turnstile —
|
||||||
|
* dieser Filter greift nur bei nachweislich zu schnellem Absenden.
|
||||||
|
*/
|
||||||
|
private function submittedTooFast(?string $startedToken): bool
|
||||||
|
{
|
||||||
|
if (blank($startedToken)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$startedAt = (int) Crypt::decryptString($startedToken);
|
||||||
|
} catch (DecryptException) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return now()->getTimestamp() - $startedAt < self::MIN_SECONDS;
|
||||||
|
}
|
||||||
|
}
|
||||||
44
app/Services/Spam/GibberishDetector.php
Normal file
44
app/Services/Spam/GibberishDetector.php
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Spam;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erkennt Zufallszeichenketten, wie sie Bots in Namensfelder schreiben
|
||||||
|
* („ruLrlotwaCoVStmZetSxD“, „NAEWTRER2720160NEYRTHYT“).
|
||||||
|
*
|
||||||
|
* Beide Regeln sind am Bestand vom 07.10.2026 gemessen: keine einzige
|
||||||
|
* Person unter rund 67.000 Konten und 0 Kontakte getroffen.
|
||||||
|
*/
|
||||||
|
final class GibberishDetector
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Personenname (Registrierung): ein einziges Wort aus mindestens 12
|
||||||
|
* Buchstaben mit mindestens drei Großbuchstaben nach dem ersten Zeichen.
|
||||||
|
* Trifft im Bestand 511 Bot-Konten und keinen echten Namen. Firmennamen
|
||||||
|
* wie „StammZellenPowerTeam“ fielen darunter — darum nur für Personen.
|
||||||
|
*/
|
||||||
|
public static function isRandomPersonName(string $name): bool
|
||||||
|
{
|
||||||
|
$name = trim($name);
|
||||||
|
|
||||||
|
return preg_match('/^[A-Za-z]{12,}$/', $name) === 1
|
||||||
|
&& preg_match_all('/[A-Z]/', substr($name, 1)) >= 3
|
||||||
|
&& preg_match_all('/[a-z]/', $name) >= 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Freies Namensfeld, das auch Firmen tragen kann (Kontaktformular):
|
||||||
|
* mindestens vier Wechsel von klein auf groß („…aCoVStmZet…“), oder ein
|
||||||
|
* Block Großbuchstaben + Ziffern + Großbuchstaben.
|
||||||
|
*/
|
||||||
|
public static function isRandomToken(string $value): bool
|
||||||
|
{
|
||||||
|
$value = trim($value);
|
||||||
|
|
||||||
|
if (preg_match('/^[A-Za-z]{12,}$/', $value) === 1 && preg_match_all('/[a-z][A-Z]/', $value) >= 4) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return preg_match('/[A-Z]{5,}\d{4,}[A-Z]{5,}/', $value) === 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
29
app/Services/Spam/SpamLog.php
Normal file
29
app/Services/Spam/SpamLog.php
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Spam;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hilfen für den Log-Kanal `spam`: nur Netz-Präfix statt voller IP, nie
|
||||||
|
* Inhalte — der Kanal zählt abgewiesene Anfragen, er archiviert sie nicht.
|
||||||
|
*/
|
||||||
|
final class SpamLog
|
||||||
|
{
|
||||||
|
public static function ipPrefix(?string $ip): ?string
|
||||||
|
{
|
||||||
|
if ($ip === null || $ip === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
|
||||||
|
return implode('.', array_slice(explode('.', $ip), 0, 3)).'.x';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
|
||||||
|
$expanded = explode(':', (string) inet_ntop((string) inet_pton($ip)));
|
||||||
|
|
||||||
|
return implode(':', array_slice($expanded, 0, 3)).'::/48';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
151
app/Services/Spam/TurnstileVerifier.php
Normal file
151
app/Services/Spam/TurnstileVerifier.php
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Spam;
|
||||||
|
|
||||||
|
use App\Enums\BotCheckStatus;
|
||||||
|
use Illuminate\Http\Client\ConnectionException;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serverseitige Prüfung des Cloudflare-Turnstile-Tokens
|
||||||
|
* (dev/sicherheit/ENTWICKLUNGSPLAN-SPAMSCHUTZ-2026-10-09.md, B1).
|
||||||
|
*
|
||||||
|
* Entscheidung 09.10.2026: Ist Cloudflare nicht erreichbar, wird nicht
|
||||||
|
* blockiert, sondern durchgelassen und markiert (BotCheckStatus::Unavailable).
|
||||||
|
* Das gilt für drei Fälle:
|
||||||
|
* - Siteverify antwortet nicht (Timeout, Netzfehler, 5xx, internal-error),
|
||||||
|
* - unsere Keys werden abgelehnt (Konfigurationsfehler, nicht Schuld des Nutzers),
|
||||||
|
* - das Widget konnte im Browser nicht laden (Feld `turnstile_unavailable`).
|
||||||
|
* Der letzte Fall ist vom Absender behauptet — darum bleibt er sichtbar
|
||||||
|
* markiert und zählt in der Betriebsüberwachung. Wird er missbraucht,
|
||||||
|
* schaltet TURNSTILE_FAIL_OPEN=false auf Ablehnen um.
|
||||||
|
*
|
||||||
|
* Die Hostname-Bindung prüft Cloudflare selbst (Widget-Einstellungen);
|
||||||
|
* hier zählt zusätzlich die Aktion, damit ein Token nur für das Formular
|
||||||
|
* gilt, für das es gelöst wurde.
|
||||||
|
*/
|
||||||
|
class TurnstileVerifier
|
||||||
|
{
|
||||||
|
public const ENDPOINT = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
|
||||||
|
|
||||||
|
/** Name des Token-Felds, das das Widget im Formular anlegt. */
|
||||||
|
public const TOKEN_FIELD = 'cf-turnstile-response';
|
||||||
|
|
||||||
|
/** Vom Widget gesetzt, wenn das Skript im Browser nicht lud. */
|
||||||
|
public const UNAVAILABLE_FIELD = 'turnstile_unavailable';
|
||||||
|
|
||||||
|
private const MAX_TOKEN_LENGTH = 2048;
|
||||||
|
|
||||||
|
public static function enabled(): bool
|
||||||
|
{
|
||||||
|
return filled(config('services.turnstile.site_key')) && filled(config('services.turnstile.secret_key'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft ein klassisches Formular (Token und Ausfall-Flag im Request).
|
||||||
|
*/
|
||||||
|
public function verifyRequest(Request $request, string $action): BotCheck
|
||||||
|
{
|
||||||
|
return $this->verify(
|
||||||
|
$request->string(self::TOKEN_FIELD)->toString(),
|
||||||
|
$request->boolean(self::UNAVAILABLE_FIELD),
|
||||||
|
$action,
|
||||||
|
$request->ip(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function verify(?string $token, bool $clientUnavailable, string $action, ?string $ip): BotCheck
|
||||||
|
{
|
||||||
|
if (! self::enabled()) {
|
||||||
|
return new BotCheck(BotCheckStatus::Skipped);
|
||||||
|
}
|
||||||
|
|
||||||
|
$token = trim((string) $token);
|
||||||
|
|
||||||
|
if ($token === '') {
|
||||||
|
return $clientUnavailable
|
||||||
|
? $this->unavailable('widget_not_loaded', $action, $ip)
|
||||||
|
: $this->failed('missing_token', $action, $ip);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strlen($token) > self::MAX_TOKEN_LENGTH) {
|
||||||
|
return $this->failed('token_too_long', $action, $ip);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = Http::asForm()
|
||||||
|
->timeout((int) config('services.turnstile.timeout', 3))
|
||||||
|
->post(self::ENDPOINT, array_filter([
|
||||||
|
'secret' => config('services.turnstile.secret_key'),
|
||||||
|
'response' => $token,
|
||||||
|
'remoteip' => $ip,
|
||||||
|
]));
|
||||||
|
} catch (ConnectionException) {
|
||||||
|
return $this->unavailable('siteverify_unreachable', $action, $ip);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $response->successful()) {
|
||||||
|
return $this->unavailable('siteverify_http_'.$response->status(), $action, $ip);
|
||||||
|
}
|
||||||
|
|
||||||
|
$errors = (array) $response->json('error-codes', []);
|
||||||
|
|
||||||
|
if ($response->json('success') !== true) {
|
||||||
|
if (in_array('internal-error', $errors, true)) {
|
||||||
|
return $this->unavailable('siteverify_internal_error', $action, $ip);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_intersect(['missing-input-secret', 'invalid-input-secret'], $errors) !== []) {
|
||||||
|
// Unser Fehler, nicht der des Absenders — laut melden, nicht blockieren.
|
||||||
|
Log::error('Turnstile lehnt den Secret-Key ab — TURNSTILE_SECRET_KEY prüfen.', ['errors' => $errors]);
|
||||||
|
|
||||||
|
return $this->unavailable('invalid_secret', $action, $ip);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->failed(implode(',', $errors) ?: 'rejected', $action, $ip);
|
||||||
|
}
|
||||||
|
|
||||||
|
$returnedAction = (string) $response->json('action', '');
|
||||||
|
|
||||||
|
// Die Cloudflare-Testkeys (1x…/2x…/3x…) liefern keine echte Aktion.
|
||||||
|
if (! $this->usesTestSecret() && $returnedAction !== $action) {
|
||||||
|
return $this->failed('action_mismatch', $action, $ip);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new BotCheck(BotCheckStatus::Passed);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function unavailable(string $reason, string $action, ?string $ip): BotCheck
|
||||||
|
{
|
||||||
|
$this->log('Bot-Prüfung nicht verfügbar — durchgelassen und markiert.', $reason, $action, $ip);
|
||||||
|
|
||||||
|
if (! config('services.turnstile.fail_open', true)) {
|
||||||
|
return new BotCheck(BotCheckStatus::Failed, $reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new BotCheck(BotCheckStatus::Unavailable, $reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function failed(string $reason, string $action, ?string $ip): BotCheck
|
||||||
|
{
|
||||||
|
$this->log('Bot-Prüfung nicht bestanden.', $reason, $action, $ip);
|
||||||
|
|
||||||
|
return new BotCheck(BotCheckStatus::Failed, $reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function log(string $message, string $reason, string $action, ?string $ip): void
|
||||||
|
{
|
||||||
|
Log::channel('spam')->info($message, [
|
||||||
|
'form' => $action,
|
||||||
|
'reason' => $reason,
|
||||||
|
'ip_prefix' => SpamLog::ipPrefix($ip),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function usesTestSecret(): bool
|
||||||
|
{
|
||||||
|
return (bool) preg_match('/^[123]x0{20,}/', (string) config('services.turnstile.secret_key'));
|
||||||
|
}
|
||||||
|
}
|
||||||
40
app/Services/Web/ContactMessageNotifier.php
Normal file
40
app/Services/Web/ContactMessageNotifier.php
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Web;
|
||||||
|
|
||||||
|
use App\Mail\ContactRequestSubmitted;
|
||||||
|
use App\Models\ContactMessage;
|
||||||
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Benachrichtigt die Brand-Kontaktadresse über ein Kontakt-Anliegen
|
||||||
|
* (config domains.domains.{portal}.brand.contact_email, Fallback:
|
||||||
|
* Absenderadresse der Anwendung). Läuft beim Eingang und beim Zurückholen
|
||||||
|
* eines Fehlalarms aus der Spam-Quarantäne.
|
||||||
|
*/
|
||||||
|
class ContactMessageNotifier
|
||||||
|
{
|
||||||
|
public function notify(ContactMessage $message, ?string $host = null): void
|
||||||
|
{
|
||||||
|
Mail::to($this->recipient($message))->queue((new ContactRequestSubmitted(
|
||||||
|
senderName: (string) $message->name,
|
||||||
|
senderEmail: (string) $message->email,
|
||||||
|
messageText: (string) $message->message,
|
||||||
|
host: $host ?? $this->hostOf($message),
|
||||||
|
topicLabel: $message->topic->label(),
|
||||||
|
pageUrl: $message->page_url,
|
||||||
|
))->afterCommit());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function recipient(ContactMessage $message): string
|
||||||
|
{
|
||||||
|
return config('domains.domains.'.$message->portal.'.brand.contact_email')
|
||||||
|
?? config('mail.from.address');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function hostOf(ContactMessage $message): string
|
||||||
|
{
|
||||||
|
return parse_url((string) $message->page_url, PHP_URL_HOST)
|
||||||
|
?: (string) config('domains.domains.'.$message->portal.'.domain_name', $message->portal);
|
||||||
|
}
|
||||||
|
}
|
||||||
75
app/Support/ContentSecurityPolicy.php
Normal file
75
app/Support/ContentSecurityPolicy.php
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Content Security Policy, vorerst nur im Melde-Modus
|
||||||
|
* (`Content-Security-Policy-Report-Only`, Sicherheitsaudit 07.10.2026,
|
||||||
|
* Phase 4). Browser blockieren nichts, sondern melden Verstöße an
|
||||||
|
* `/csp-report` (storage/logs/csp-*.log). Erst wenn die Meldungen zeigen,
|
||||||
|
* dass die Liste vollständig ist, wird sie scharf geschaltet.
|
||||||
|
*
|
||||||
|
* `'unsafe-inline'` und `'unsafe-eval'` sind für Livewire/Alpine und die
|
||||||
|
* Inline-Skripte der Layouts vorerst nötig; die Policy begrenzt daher vor
|
||||||
|
* allem, von wo Skripte geladen und wohin Daten geschickt werden dürfen,
|
||||||
|
* sowie Einbettung, Formularziele und `<base>`.
|
||||||
|
*/
|
||||||
|
class ContentSecurityPolicy
|
||||||
|
{
|
||||||
|
public const REPORT_PATH = '/csp-report';
|
||||||
|
|
||||||
|
public static function header(): string
|
||||||
|
{
|
||||||
|
$own = array_merge(["'self'"], self::ownOrigins());
|
||||||
|
$umami = config('services.umami.enabled') ? self::originOf((string) config('services.umami.script_url')) : null;
|
||||||
|
$errors = self::originOf((string) config('monitoring.browser.dsn'));
|
||||||
|
// Cloudflare Turnstile (Spamschutz): Skript und Challenge-iframe.
|
||||||
|
$turnstile = 'https://challenges.cloudflare.com';
|
||||||
|
|
||||||
|
$directives = [
|
||||||
|
'default-src' => ["'self'"],
|
||||||
|
'script-src' => [...$own, "'unsafe-inline'", "'unsafe-eval'", $umami, $turnstile],
|
||||||
|
'style-src' => [...$own, "'unsafe-inline'"],
|
||||||
|
'img-src' => [...$own, 'data:', 'blob:', 'https:'],
|
||||||
|
'font-src' => [...$own, 'data:'],
|
||||||
|
'connect-src' => [...$own, $umami, $errors],
|
||||||
|
'media-src' => [...$own],
|
||||||
|
'object-src' => ["'none'"],
|
||||||
|
'frame-src' => ["'self'", $turnstile],
|
||||||
|
'frame-ancestors' => ["'self'"],
|
||||||
|
'base-uri' => ["'self'"],
|
||||||
|
'form-action' => [...$own, 'https://accounts.google.com', 'https://checkout.stripe.com', 'https://billing.stripe.com'],
|
||||||
|
'report-uri' => [self::REPORT_PATH],
|
||||||
|
];
|
||||||
|
|
||||||
|
return collect($directives)
|
||||||
|
->map(fn (array $sources, string $directive): string => trim($directive.' '.implode(' ', array_unique(array_filter($sources)))))
|
||||||
|
->implode('; ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die drei Domains der Anwendung — Formulare und Assets können
|
||||||
|
* domainübergreifend auf die anderen zeigen.
|
||||||
|
*
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private static function ownOrigins(): array
|
||||||
|
{
|
||||||
|
return collect([
|
||||||
|
config('domains.domain_portal'),
|
||||||
|
config('domains.domain_presseecho'),
|
||||||
|
config('domains.domain_businessportal'),
|
||||||
|
])->filter()->map(fn (string $host): string => 'https://'.$host)->values()->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function originOf(string $url): ?string
|
||||||
|
{
|
||||||
|
$parts = parse_url($url);
|
||||||
|
|
||||||
|
if (! is_array($parts) || empty($parts['host'])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ($parts['scheme'] ?? 'https').'://'.$parts['host'].(isset($parts['port']) ? ':'.$parts['port'] : '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ namespace App\Support\Monitoring;
|
||||||
|
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
use Sentry\Breadcrumb;
|
||||||
use Sentry\Event;
|
use Sentry\Event;
|
||||||
use Sentry\EventHint;
|
use Sentry\EventHint;
|
||||||
use Sentry\UserDataBag;
|
use Sentry\UserDataBag;
|
||||||
|
|
@ -41,6 +42,8 @@ class ErrorReportScrubber
|
||||||
$event->setUser($userId !== null ? UserDataBag::createFromUserIdentifier($userId) : null);
|
$event->setUser($userId !== null ? UserDataBag::createFromUserIdentifier($userId) : null);
|
||||||
$event->setRequest(self::scrubRequest($event->getRequest()));
|
$event->setRequest(self::scrubRequest($event->getRequest()));
|
||||||
|
|
||||||
|
$event->setBreadcrumb(array_map(self::scrubBreadcrumb(...), $event->getBreadcrumbs()));
|
||||||
|
|
||||||
foreach ($event->getExceptions() as $exception) {
|
foreach ($event->getExceptions() as $exception) {
|
||||||
$exception->setValue(self::scrubText($exception->getValue()));
|
$exception->setValue(self::scrubText($exception->getValue()));
|
||||||
}
|
}
|
||||||
|
|
@ -58,6 +61,20 @@ class ErrorReportScrubber
|
||||||
return $event;
|
return $event;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function scrubBreadcrumb(Breadcrumb $breadcrumb): Breadcrumb
|
||||||
|
{
|
||||||
|
$message = $breadcrumb->getMessage();
|
||||||
|
|
||||||
|
return new Breadcrumb(
|
||||||
|
$breadcrumb->getLevel(),
|
||||||
|
$breadcrumb->getType(),
|
||||||
|
$breadcrumb->getCategory(),
|
||||||
|
$message !== null ? self::scrubText($message) : null,
|
||||||
|
[],
|
||||||
|
$breadcrumb->getTimestamp(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $request
|
* @param array<string, mixed> $request
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Controllers\CspReportController;
|
||||||
use App\Http\Controllers\HealthController;
|
use App\Http\Controllers\HealthController;
|
||||||
use App\Http\Middleware\BasicAuthMiddleware;
|
use App\Http\Middleware\BasicAuthMiddleware;
|
||||||
use App\Http\Middleware\EnsureSessionIsNotRevoked;
|
use App\Http\Middleware\EnsureSessionIsNotRevoked;
|
||||||
|
|
@ -12,6 +13,7 @@ use App\Http\Middleware\ReportErrorSignals;
|
||||||
use App\Http\Middleware\SecurityHeaders;
|
use App\Http\Middleware\SecurityHeaders;
|
||||||
use App\Http\Middleware\SetCurrentPortal;
|
use App\Http\Middleware\SetCurrentPortal;
|
||||||
use App\Http\Middleware\SetEdition;
|
use App\Http\Middleware\SetEdition;
|
||||||
|
use App\Support\ContentSecurityPolicy;
|
||||||
use Illuminate\Foundation\Application;
|
use Illuminate\Foundation\Application;
|
||||||
use Illuminate\Foundation\Configuration\Exceptions;
|
use Illuminate\Foundation\Configuration\Exceptions;
|
||||||
use Illuminate\Foundation\Configuration\Middleware;
|
use Illuminate\Foundation\Configuration\Middleware;
|
||||||
|
|
@ -19,8 +21,11 @@ use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
use Laravel\Sanctum\Http\Middleware\CheckAbilities;
|
use Laravel\Sanctum\Http\Middleware\CheckAbilities;
|
||||||
use Laravel\Sanctum\Http\Middleware\CheckForAnyAbility;
|
use Laravel\Sanctum\Http\Middleware\CheckForAnyAbility;
|
||||||
|
use Livewire\Features\SupportFileUploads\MissingFileUploadsTraitException;
|
||||||
|
use Livewire\Features\SupportLockedProperties\CannotUpdateLockedPropertyException;
|
||||||
use Sentry\Laravel\Integration;
|
use Sentry\Laravel\Integration;
|
||||||
use Symfony\Component\Console\Exception\CommandNotFoundException;
|
use Symfony\Component\Console\Exception\CommandNotFoundException;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||||
|
|
||||||
return Application::configure(basePath: dirname(__DIR__))
|
return Application::configure(basePath: dirname(__DIR__))
|
||||||
->withRouting(
|
->withRouting(
|
||||||
|
|
@ -38,6 +43,12 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||||
Route::get('/health', HealthController::class)
|
Route::get('/health', HealthController::class)
|
||||||
->middleware('throttle:30,1')
|
->middleware('throttle:30,1')
|
||||||
->name('health.deep');
|
->name('health.deep');
|
||||||
|
|
||||||
|
// CSP-Meldungen der Browser (Melde-Modus, Sicherheitsaudit
|
||||||
|
// 07.10.2026) — ohne web-Gruppe: keine Session, kein CSRF.
|
||||||
|
Route::post(ContentSecurityPolicy::REPORT_PATH, CspReportController::class)
|
||||||
|
->middleware('throttle:60,1')
|
||||||
|
->name('csp.report');
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
->withMiddleware(function (Middleware $middleware) {
|
->withMiddleware(function (Middleware $middleware) {
|
||||||
|
|
@ -118,16 +129,18 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||||
// ob erzwungen wird, entscheidet die Middleware zur Laufzeit anhand
|
// ob erzwungen wird, entscheidet die Middleware zur Laufzeit anhand
|
||||||
// von config('auth.basic.*') (greift auch unter `config:cache`).
|
// von config('auth.basic.*') (greift auch unter `config:cache`).
|
||||||
$middleware->append(BasicAuthMiddleware::class);
|
$middleware->append(BasicAuthMiddleware::class);
|
||||||
// Trust all proxies (for Traefik/Docker setup)
|
// Welchen Proxies vertraut wird, steht in config/trustedproxy.php:
|
||||||
|
// lokal hinter Traefik allen, auf Live (nginx direkt, kein Proxy)
|
||||||
|
// keinem — sonst könnte jeder Client per X-Forwarded-For seine IP
|
||||||
|
// fälschen und damit IP-Limits und Protokolle umgehen.
|
||||||
$middleware->trustProxies(
|
$middleware->trustProxies(
|
||||||
at: '*',
|
|
||||||
headers: Request::HEADER_X_FORWARDED_FOR |
|
headers: Request::HEADER_X_FORWARDED_FOR |
|
||||||
Request::HEADER_X_FORWARDED_HOST |
|
Request::HEADER_X_FORWARDED_HOST |
|
||||||
Request::HEADER_X_FORWARDED_PORT |
|
Request::HEADER_X_FORWARDED_PORT |
|
||||||
Request::HEADER_X_FORWARDED_PROTO
|
Request::HEADER_X_FORWARDED_PROTO
|
||||||
);
|
);
|
||||||
|
|
||||||
// Host-Whitelist: Da allen Proxies inkl. X-Forwarded-Host vertraut wird,
|
// Host-Whitelist: Wo Proxies inkl. X-Forwarded-Host vertraut wird,
|
||||||
// muss die App fremde Host-Header verwerfen — sonst kann ein Client per
|
// muss die App fremde Host-Header verwerfen — sonst kann ein Client per
|
||||||
// X-Forwarded-Host die URL-Generierung vergiften (Passwort-Reset-/Verify-
|
// X-Forwarded-Host die URL-Generierung vergiften (Passwort-Reset-/Verify-
|
||||||
// Mails, signierte Links, Cache-Poisoning). Nur die konfigurierten
|
// Mails, signierte Links, Cache-Poisoning). Nur die konfigurierten
|
||||||
|
|
@ -167,4 +180,18 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||||
// Vertippte artisan-Befehle sind kein Anwendungsfehler (GlitchTip
|
// Vertippte artisan-Befehle sind kein Anwendungsfehler (GlitchTip
|
||||||
// server-pressekonto, Issue 9: „config:cachels“ auf Live).
|
// server-pressekonto, Issue 9: „config:cachels“ auf Live).
|
||||||
$exceptions->dontReport(CommandNotFoundException::class);
|
$exceptions->dontReport(CommandNotFoundException::class);
|
||||||
|
|
||||||
|
// Manipulierte Livewire-Anfragen (Scanner, die gesperrte Properties
|
||||||
|
// setzen oder Uploads an Komponenten ohne Upload schicken) als 400
|
||||||
|
// beantworten statt als 500 bzw. 419 — Livewire weist sie korrekt ab,
|
||||||
|
// sie sind kein Anwendungsfehler und kein abgelaufenes Formular
|
||||||
|
// (GlitchTip server-pressekonto, Issues 38 und 39, 08.10.2026).
|
||||||
|
$exceptions->map(
|
||||||
|
CannotUpdateLockedPropertyException::class,
|
||||||
|
fn (CannotUpdateLockedPropertyException $e): BadRequestHttpException => new BadRequestHttpException('Ungültige Livewire-Anfrage.', $e),
|
||||||
|
);
|
||||||
|
$exceptions->map(
|
||||||
|
MissingFileUploadsTraitException::class,
|
||||||
|
fn (MissingFileUploadsTraitException $e): BadRequestHttpException => new BadRequestHttpException('Ungültige Livewire-Anfrage.', $e),
|
||||||
|
);
|
||||||
})->create();
|
})->create();
|
||||||
|
|
|
||||||
|
|
@ -114,6 +114,21 @@ return [
|
||||||
|
|
||||||
'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),
|
'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Starke Anmeldung für Administratoren
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Super-Admins und Admins brauchen für den Admin-Bereich eine Session, die
|
||||||
|
| mit Passkey oder Authenticator-Code bestätigt wurde (Entscheidung 5,
|
||||||
|
| 07.10.2026, EnsureStrongAdminAuthentication). Abschalten nur als
|
||||||
|
| Notfallschalter, z. B. wenn WebAuthn auf der Domain ausfällt. In der
|
||||||
|
| Testsuite aus; die Tests der Funktion schalten sie gezielt ein.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'require_strong_admin_authentication' => env('AUTH_REQUIRE_STRONG_ADMIN', true),
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
| HTTP Basic Auth Gate (Staging-Schutz)
|
| HTTP Basic Auth Gate (Staging-Schutz)
|
||||||
|
|
@ -132,4 +147,21 @@ return [
|
||||||
'password' => env('BASIC_AUTH_PASSWORD'),
|
'password' => env('BASIC_AUTH_PASSWORD'),
|
||||||
],
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Bot-Registrierungen aufräumen (Spamschutz B4)
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| `users:prune-bot-registrations` löscht über das Formular angelegte
|
||||||
|
| Konten, die nach `after_days` weder bestätigt noch genutzt wurden.
|
||||||
|
| Der tägliche Lauf ist aus, bis der Probelauf (--dry-run) auf Live
|
||||||
|
| geprüft ist; Altkonten aus dem Legacy-Import sind nie betroffen.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'prune_bot_registrations' => [
|
||||||
|
'enabled' => (bool) env('USERS_PRUNE_BOT_REGISTRATIONS', false),
|
||||||
|
'after_days' => 14,
|
||||||
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,7 @@ return [
|
||||||
'footer_legal' => '© :year pressekonto · Alle Rechte vorbehalten',
|
'footer_legal' => '© :year pressekonto · Alle Rechte vorbehalten',
|
||||||
'about_label' => 'Über pressekonto',
|
'about_label' => 'Über pressekonto',
|
||||||
'meta_title' => 'pressekonto – Publisher-Hub für presseecho und businessportal24',
|
'meta_title' => 'pressekonto – Publisher-Hub für presseecho und businessportal24',
|
||||||
'meta_description' => 'Ein Konto, zwei Reichweiten: Pressemitteilungen qualitätsgeprüft auf presseecho und businessportal24 gleichzeitig veröffentlichen.',
|
'meta_description' => 'Ein Konto, zwei Reichweiten: Pressemitteilungen nach automatisierter Prüfung auf presseecho und businessportal24 gleichzeitig veröffentlichen.',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|
@ -85,11 +85,11 @@ return [
|
||||||
'report_email' => env('REPORT_EMAIL_PRESSEECHO'),
|
'report_email' => env('REPORT_EMAIL_PRESSEECHO'),
|
||||||
'umami_website_id' => '3ea8a7b0-105d-4519-b200-138cadc0fbdc',
|
'umami_website_id' => '3ea8a7b0-105d-4519-b200-138cadc0fbdc',
|
||||||
'tagline_short' => 'Pressemitteilungen · DACH',
|
'tagline_short' => 'Pressemitteilungen · DACH',
|
||||||
'tagline_long' => 'Fachmeldungen aus Wirtschaft und Industrie. Qualitätsgeprüft. Strukturiert distribuiert.',
|
'tagline_long' => 'Fachmeldungen aus Wirtschaft und Industrie. Automatisiert geprüft. Strukturiert distribuiert.',
|
||||||
'footer_legal' => '© :year presseecho · Alle Rechte vorbehalten',
|
'footer_legal' => '© :year presseecho · Alle Rechte vorbehalten',
|
||||||
'about_label' => 'Über presseecho',
|
'about_label' => 'Über presseecho',
|
||||||
'meta_title' => 'Aktuelle Pressemitteilungen aus Wirtschaft & Industrie – presseecho',
|
'meta_title' => 'Aktuelle Pressemitteilungen aus Wirtschaft & Industrie – presseecho',
|
||||||
'meta_description' => 'Qualitätsgeprüfte Pressemitteilungen aus Wirtschaft und Industrie im DACH-Raum.',
|
'meta_description' => 'Automatisiert geprüfte Pressemitteilungen aus Wirtschaft und Industrie im DACH-Raum.',
|
||||||
'newsletter_topics' => [
|
'newsletter_topics' => [
|
||||||
['name' => 'Tageszusammenfassung', 'meta' => 'Mo–Fr · 07:00 Uhr', 'active' => true],
|
['name' => 'Tageszusammenfassung', 'meta' => 'Mo–Fr · 07:00 Uhr', 'active' => true],
|
||||||
['name' => 'Wochenrückblick', 'meta' => 'Sonntags · 18:00 Uhr', 'active' => false],
|
['name' => 'Wochenrückblick', 'meta' => 'Sonntags · 18:00 Uhr', 'active' => false],
|
||||||
|
|
@ -119,11 +119,11 @@ return [
|
||||||
'report_email' => env('REPORT_EMAIL_BUSINESSPORTAL'),
|
'report_email' => env('REPORT_EMAIL_BUSINESSPORTAL'),
|
||||||
'umami_website_id' => 'f03a5fc3-ac44-488f-9118-84004861099f',
|
'umami_website_id' => 'f03a5fc3-ac44-488f-9118-84004861099f',
|
||||||
'tagline_short' => 'Pressemitteilungen · DACH',
|
'tagline_short' => 'Pressemitteilungen · DACH',
|
||||||
'tagline_long' => 'Veröffentlichungs-Portal für qualitätsgeprüfte Pressemitteilungen aus Deutschland, Österreich und der Schweiz.',
|
'tagline_long' => 'Veröffentlichungs-Portal für automatisiert geprüfte Pressemitteilungen aus Deutschland, Österreich und der Schweiz.',
|
||||||
'footer_legal' => '© :year businessportal24 · Alle Rechte vorbehalten',
|
'footer_legal' => '© :year businessportal24 · Alle Rechte vorbehalten',
|
||||||
'about_label' => 'Über businessportal24',
|
'about_label' => 'Über businessportal24',
|
||||||
'meta_title' => 'Aktuelle Pressemitteilungen aus der deutschen Wirtschaft – businessportal24',
|
'meta_title' => 'Aktuelle Pressemitteilungen aus der deutschen Wirtschaft – businessportal24',
|
||||||
'meta_description' => 'Pressemitteilungen aus Deutschland, Österreich und der Schweiz. Qualitätsgeprüft. Strukturiert distribuiert.',
|
'meta_description' => 'Pressemitteilungen aus Deutschland, Österreich und der Schweiz. Automatisiert geprüft. Strukturiert distribuiert.',
|
||||||
'newsletter_topics' => [
|
'newsletter_topics' => [
|
||||||
['name' => 'Tageszusammenfassung', 'meta' => 'Mo–Fr · 07:00 Uhr', 'active' => true],
|
['name' => 'Tageszusammenfassung', 'meta' => 'Mo–Fr · 07:00 Uhr', 'active' => true],
|
||||||
['name' => 'Wochenrückblick', 'meta' => 'Sonntags · 18:00 Uhr', 'active' => false],
|
['name' => 'Wochenrückblick', 'meta' => 'Sonntags · 18:00 Uhr', 'active' => false],
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Http\Middleware\BlockUnusedFortifyRoutes;
|
use App\Http\Middleware\BlockUnusedFortifyRoutes;
|
||||||
|
use App\Http\Middleware\EnsurePasskeyManagementIsConfirmed;
|
||||||
use Laravel\Fortify\Features;
|
use Laravel\Fortify\Features;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|
@ -106,7 +107,7 @@ return [
|
||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'middleware' => ['web', BlockUnusedFortifyRoutes::class],
|
'middleware' => ['web', BlockUnusedFortifyRoutes::class, EnsurePasskeyManagementIsConfirmed::class],
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
|
|
@ -122,6 +123,7 @@ return [
|
||||||
'limiters' => [
|
'limiters' => [
|
||||||
'login' => 'login',
|
'login' => 'login',
|
||||||
'two-factor' => 'two-factor',
|
'two-factor' => 'two-factor',
|
||||||
|
'passkeys' => 'passkeys',
|
||||||
],
|
],
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|
@ -155,12 +157,39 @@ return [
|
||||||
// updateProfileInformation() bewusst AUS (Audit F01): der Endpunkt
|
// updateProfileInformation() bewusst AUS (Audit F01): der Endpunkt
|
||||||
// tauschte die E-Mail sofort und ohne Verifizierung – Firmenrechte
|
// tauschte die E-Mail sofort und ohne Verifizierung – Firmenrechte
|
||||||
// hängen an der Adresse. Die Kunden-UI nutzt den pending_email-Prozess.
|
// hängen an der Adresse. Die Kunden-UI nutzt den pending_email-Prozess.
|
||||||
Features::updatePasswords(),
|
// Features::updatePasswords(), // Abgeschaltet (Sicherheitsaudit 07.10.2026): Passwortwechsel nur über Livewire + PasswordChanger
|
||||||
Features::twoFactorAuthentication([
|
Features::twoFactorAuthentication([
|
||||||
'confirm' => true,
|
'confirm' => true,
|
||||||
'confirmPassword' => true,
|
'confirmPassword' => true,
|
||||||
// 'window' => 0,
|
// 'window' => 0,
|
||||||
]),
|
]),
|
||||||
|
// Passkeys (Entscheidung 5, 07.10.2026). Den frischen Nachweis vor dem
|
||||||
|
// Hinzufügen verlangt EnsurePasskeyManagementIsConfirmed statt
|
||||||
|
// `password.confirm` — Letzteres setzt ein Passwort voraus, das
|
||||||
|
// Magic-Link- und Google-Konten nicht haben.
|
||||||
|
Features::passkeys([
|
||||||
|
'confirmPassword' => false,
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Passkeys
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Login und Sicherheitsseite laufen ausschließlich auf der Portal-Domain
|
||||||
|
| (routes/domains.php) — daran sind die Passkeys gebunden. Bewusst nicht
|
||||||
|
| APP_URL: die zeigt je nach Umgebung auf ein Web-Portal.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'passkeys' => [
|
||||||
|
'relying_party_id' => env('PASSKEYS_RP_ID', env('APP_PORTAL_NAME', 'pressekonto.test')),
|
||||||
|
'allowed_origins' => [env('APP_PORTAL_URL', 'https://pressekonto.test')],
|
||||||
|
// Eigener Wert empfohlen: ein APP_KEY-Wechsel würde sonst die Kennungen
|
||||||
|
// ändern, unter denen die Geräte die Passkeys gespeichert haben.
|
||||||
|
'user_handle_secret' => env('PASSKEYS_USER_HANDLE_SECRET', env('APP_KEY')),
|
||||||
|
'timeout' => 60000,
|
||||||
],
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|
|
||||||
59
config/growth.php
Normal file
59
config/growth.php
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Abbruchbedingungen der Wachstumsblöcke (Roadmap 6.1, 13.10.2026)
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Grundlage für Admin ▸ Reports ▸ Wachstum und `growth:report`. Ein
|
||||||
|
| gemeinsamer Zeitraum für alle Blöcke, ein Zwischenblick, danach Audit im
|
||||||
|
| Quartal. Die Schwellen sind Entscheidungen des Betreibers — geändert
|
||||||
|
| werden sie hier und in der Roadmap, nicht nur an einer Stelle.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'period' => [
|
||||||
|
'start' => '2026-09-17',
|
||||||
|
'review' => '2026-12-17',
|
||||||
|
'end' => '2027-03-17',
|
||||||
|
],
|
||||||
|
|
||||||
|
// Block 1 — Konversion aus dem Bestand. Gezählt werden eindeutige Firmen.
|
||||||
|
'block1' => [
|
||||||
|
'claims_min' => 30,
|
||||||
|
'bookings_min' => 3,
|
||||||
|
'reject_clicks_min' => 10,
|
||||||
|
],
|
||||||
|
|
||||||
|
// Block 2 — Organische Sichtbarkeit: Aufrufe der Transparenzseiten (2.4)
|
||||||
|
// aus der Suche, gemessen in Umami über die letzten 30 Tage. Gewertet
|
||||||
|
// wird der Februar 2027.
|
||||||
|
'block2' => [
|
||||||
|
'search_views_min' => 100,
|
||||||
|
'evaluation_month' => '2027-02',
|
||||||
|
'pages' => [
|
||||||
|
'was-kostet-eine-pressemitteilung',
|
||||||
|
'pressemitteilung-veroeffentlichen-ohne-abo',
|
||||||
|
'pressemitteilung-loeschen-oder-aendern-lassen',
|
||||||
|
'pressemitteilung-kostenlos-veroeffentlichen',
|
||||||
|
],
|
||||||
|
'brands' => ['presseecho', 'businessportal24'],
|
||||||
|
// Verweisende Domains, die als Suche zählen (enthält).
|
||||||
|
'search_referrers' => [
|
||||||
|
'google.', 'googlequicksearchbox', 'bing.', 'duckduckgo.', 'ecosia.', 'yahoo.',
|
||||||
|
'search.brave.', 'qwant.', 'startpage.', 'yandex.', 'web.de', 'gmx.', 't-online.',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
// Block 3 — Reichweite: Abonnenten, die Reader-Dienste im User-Agent
|
||||||
|
// melden (Feedly, Inoreader …), über alle Feeds, letzte 30 Tage.
|
||||||
|
'block3' => [
|
||||||
|
'feed_subscribers_min' => 25,
|
||||||
|
],
|
||||||
|
|
||||||
|
// Umami-Abfragen werden so lange zwischengespeichert.
|
||||||
|
'cache_minutes' => (int) env('GROWTH_REPORT_CACHE_MINUTES', 360),
|
||||||
|
|
||||||
|
];
|
||||||
|
|
@ -104,6 +104,8 @@ return [
|
||||||
| monitoring Betriebs-Alerts (OperationalAlert): fehlgeschlagene
|
| monitoring Betriebs-Alerts (OperationalAlert): fehlgeschlagene
|
||||||
| Scheduler-Tasks/Queue-Jobs, Mail-Breaker, Bounces
|
| Scheduler-Tasks/Queue-Jobs, Mail-Breaker, Bounces
|
||||||
| market Kursdaten-Abruf (market:refresh)
|
| market Kursdaten-Abruf (market:refresh)
|
||||||
|
| csp CSP-Meldungen der Browser (Melde-Modus)
|
||||||
|
| spam Spamschutz: abgewiesene und ungeprüfte Formulare
|
||||||
| admin_slow langsame Admin-Requests (eigene Schwellen/Env-Werte)
|
| admin_slow langsame Admin-Requests (eigene Schwellen/Env-Werte)
|
||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
|
|
@ -116,6 +118,13 @@ return [
|
||||||
|
|
||||||
'market' => $topicChannel('market'),
|
'market' => $topicChannel('market'),
|
||||||
|
|
||||||
|
// CSP-Meldungen der Browser (Melde-Modus, CspReportController).
|
||||||
|
'csp' => $topicChannel('csp'),
|
||||||
|
|
||||||
|
// Spamschutz: abgewiesene und ungeprüfte Formulare, nur Grund und
|
||||||
|
// Netz-Präfix (TurnstileVerifier, ContactController).
|
||||||
|
'spam' => $topicChannel('spam'),
|
||||||
|
|
||||||
'admin_slow' => [
|
'admin_slow' => [
|
||||||
'driver' => 'daily',
|
'driver' => 'daily',
|
||||||
'path' => storage_path('logs/admin-slow.log'),
|
'path' => storage_path('logs/admin-slow.log'),
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue