security/api/betrieb: Fortify-2FA-Nebenwege gesperrt, Policy nur für aktive Super-Admins, Nachprüfungen aus dem Umsetzungsaudit (Block 7, U08–U10, U13, 7.1, 7.2, 7.5)
- Fortifys sieben 2FA-Verwaltungsrouten antworten mit 404 (Middleware über fortify.middleware); die Sicherheitsseite verlangt weiter den zweiten Faktor (U09) - PressReleasePolicy/CompanyPolicy::before() bejahen nur aktive Super-Admins (7.2) - API-Update prüft bei Portalwechsel die bestehende Kategorie gegen das neue Portal (U10) - Aggregation: Zeitbudget 480 s unter dem Lock von 600 s, Marke nur unter Zeilensperre vom erwarteten Stand aus, sonst Fehler-Exit (U13) - Newsletter-Bereinigungsmigration behält je Paar die sent-Zeile (U08) - E-Mail-Wechsel stößt den Rechte-Sync sofort an; company-memberships:audit --classify zeigt vorher die Kandidaten und begrenzt mit --before (7.1) - Detailseite wählt bei Slug-Kollision wie der Melde-Resolver (7.5) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
c46dfd3b83
commit
05cf53e17d
18 changed files with 447 additions and 26 deletions
|
|
@ -8,8 +8,10 @@ use App\Models\PressRelease;
|
|||
use App\Models\PressReleaseEvent;
|
||||
use App\Scopes\PortalScope;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Schreibt die gezählten Aufrufe aus dem Ereignis-Log in den Gesamtzähler
|
||||
|
|
@ -32,7 +34,9 @@ use Illuminate\Support\Facades\DB;
|
|||
*/
|
||||
class AggregatePressReleaseViews extends Command
|
||||
{
|
||||
protected $signature = 'press-releases:aggregate-views {--chunk=5000 : Ereignisse je Durchgang}';
|
||||
protected $signature = 'press-releases:aggregate-views
|
||||
{--chunk=5000 : Ereignisse je Durchgang}
|
||||
{--budget=480 : Zeitbudget in Sekunden; der Rest wartet auf den nächsten Lauf}';
|
||||
|
||||
protected $description = 'Überträgt gezählte Aufrufe aus dem Ereignis-Log in press_releases.hits.';
|
||||
|
||||
|
|
@ -48,9 +52,18 @@ class AggregatePressReleaseViews extends Command
|
|||
*/
|
||||
public const LockKey = 'press-releases:aggregate-views';
|
||||
|
||||
/**
|
||||
* Der Lock verfällt nach dieser Zeit. Das Zeitbudget (--budget) muss
|
||||
* darunter liegen, sonst kann ein zweiter Lauf den Lock übernehmen,
|
||||
* während der erste noch verbucht (Audit U13). Als letzte Schranke wird
|
||||
* die Marke nur unter Zeilensperre und nur vom erwarteten Stand aus
|
||||
* fortgeschrieben – ein fremder Stand bricht den Durchgang ab.
|
||||
*/
|
||||
public const LockSeconds = 600;
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$lock = Cache::lock(self::LockKey, 600);
|
||||
$lock = Cache::lock(self::LockKey, self::LockSeconds);
|
||||
|
||||
if (! $lock->get()) {
|
||||
$this->warn('Ein anderer Aggregationslauf ist noch aktiv – dieser Aufruf tut nichts.');
|
||||
|
|
@ -68,6 +81,8 @@ class AggregatePressReleaseViews extends Command
|
|||
private function aggregate(): int
|
||||
{
|
||||
$chunk = max(100, (int) $this->option('chunk'));
|
||||
$budget = min(self::LockSeconds - 60, max(0, (int) $this->option('budget')));
|
||||
$startedAt = microtime(true);
|
||||
$watermark = AppSetting::getValue(self::WatermarkKey);
|
||||
|
||||
// Fehlende Marke heißt: noch nie initialisiert. Sie jetzt als 0 zu
|
||||
|
|
@ -91,6 +106,12 @@ class AggregatePressReleaseViews extends Command
|
|||
$totalReleases = 0;
|
||||
|
||||
while (true) {
|
||||
if (microtime(true) - $startedAt > $budget) {
|
||||
$this->warn(sprintf('Zeitbudget von %d s erreicht – der Rest folgt im nächsten Lauf.', $budget));
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
// Obergrenze des Durchgangs zuerst festlegen, damit gleichzeitig
|
||||
// eintreffende Aufrufe die Summe nicht gegen die Marke verschieben.
|
||||
// Bewusst über die IDs statt über max(): eine Aggregatfunktion
|
||||
|
|
@ -121,19 +142,14 @@ class AggregatePressReleaseViews extends Command
|
|||
// Zähler schon erhöht, die Marke steht aber noch auf dem alten
|
||||
// Stand — der nächste Lauf verbuchte genau diese Aufrufe ein
|
||||
// zweites Mal.
|
||||
DB::transaction(function () use ($counts, $upperBound, &$totalEvents, &$totalReleases): void {
|
||||
foreach ($counts as $pressReleaseId => $views) {
|
||||
PressRelease::query()
|
||||
->withoutGlobalScope(PortalScope::class)
|
||||
->whereKey((int) $pressReleaseId)
|
||||
->increment('hits', (int) $views);
|
||||
try {
|
||||
$this->bookChunk($counts, $watermark, $upperBound, $totalEvents, $totalReleases);
|
||||
} catch (RuntimeException $exception) {
|
||||
report($exception);
|
||||
$this->error($exception->getMessage());
|
||||
|
||||
$totalReleases++;
|
||||
$totalEvents += (int) $views;
|
||||
}
|
||||
|
||||
AppSetting::setValue(self::WatermarkKey, $upperBound);
|
||||
});
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$watermark = $upperBound;
|
||||
}
|
||||
|
|
@ -142,4 +158,40 @@ class AggregatePressReleaseViews extends Command
|
|||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zähler und Marke in EINER Transaktion; siehe Kommentar im Aufrufer.
|
||||
*
|
||||
* @param Collection<int|string, int|string> $counts
|
||||
*/
|
||||
private function bookChunk(Collection $counts, int $watermark, int $upperBound, int &$totalEvents, int &$totalReleases): void
|
||||
{
|
||||
DB::transaction(function () use ($counts, $watermark, $upperBound, &$totalEvents, &$totalReleases): void {
|
||||
// Marke unter Zeilensperre gegen den erwarteten Stand prüfen:
|
||||
// hat ein anderer Lauf sie inzwischen verschoben (Lock
|
||||
// verfallen), würde dieser Durchgang dieselben Ereignisse ein
|
||||
// zweites Mal verbuchen (Audit U13).
|
||||
$current = AppSetting::query()->whereKey(self::WatermarkKey)->lockForUpdate()->first();
|
||||
|
||||
if ($current === null || (int) $current->value !== $watermark) {
|
||||
throw new RuntimeException(sprintf(
|
||||
'Wasserstandsmarke steht auf %s statt %d – ein anderer Lauf hat übernommen, Durchgang verworfen.',
|
||||
$current === null ? 'null' : (string) $current->value,
|
||||
$watermark,
|
||||
));
|
||||
}
|
||||
|
||||
foreach ($counts as $pressReleaseId => $views) {
|
||||
PressRelease::query()
|
||||
->withoutGlobalScope(PortalScope::class)
|
||||
->whereKey((int) $pressReleaseId)
|
||||
->increment('hits', (int) $views);
|
||||
|
||||
$totalReleases++;
|
||||
$totalEvents += (int) $views;
|
||||
}
|
||||
|
||||
AppSetting::setValue(self::WatermarkKey, $upperBound);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,12 +20,19 @@ use Illuminate\Support\Facades\Storage;
|
|||
* der Login-Sync sie zurücknehmen kann, sobald die Grundlage wegfällt. Nicht
|
||||
* erklärbare Zeilen bleiben `manual` und damit geschützt – die Entscheidung
|
||||
* je Zeile trifft ein Mensch anhand der Liste.
|
||||
*
|
||||
* Die Klassifizierung ist eine Heuristik (Audit 7.1): eine bewusst manuell
|
||||
* vergebene Verantwortlichkeit mit zufällig passender Adresse wird ebenfalls
|
||||
* als auto markiert. Deshalb zeigt --classify vorher die betroffenen Zeilen,
|
||||
* und --before begrenzt den Lauf auf Zuordnungen aus der Zeit vor dem Deploy
|
||||
* von `granted_via` – alles Jüngere trägt seine Herkunft bereits.
|
||||
*/
|
||||
class AuditCompanyMemberships extends Command
|
||||
{
|
||||
protected $signature = 'company-memberships:audit
|
||||
{--classify : Erklärbare responsible-Zuordnungen als automatisch (auto) markieren}
|
||||
{--since=2026-07-20 : Nur Zuordnungen ab diesem Datum in der Tabelle zeigen (Go-Live); die Datei enthält alle}';
|
||||
{--since=2026-07-20 : Nur Zuordnungen ab diesem Datum in der Tabelle zeigen (Go-Live); die Datei enthält alle}
|
||||
{--before= : --classify nur für Zuordnungen, die vor diesem Zeitpunkt entstanden sind (Deploy von granted_via); danach ist die Herkunft bereits gespeichert}';
|
||||
|
||||
protected $description = 'Listet responsible-Firmenzuordnungen ohne aktuelle E-Mail-Grundlage und markiert erklärbare als automatisch vergeben.';
|
||||
|
||||
|
|
@ -73,13 +80,37 @@ class AuditCompanyMemberships extends Command
|
|||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$before = (string) $this->option('before');
|
||||
$candidates = $this->responsibleRows()
|
||||
->filter(fn (object $row): bool => isset($explained[$row->company_id.':'.$row->user_id]))
|
||||
->where('granted_via', '!=', 'auto')
|
||||
->filter(fn (object $row): bool => $before === '' || (string) $row->created_at < $before)
|
||||
->values();
|
||||
|
||||
$this->line(sprintf(
|
||||
'Umzustufen auf auto: %d Zuordnungen%s (Stichprobe, max. 20):',
|
||||
$candidates->count(),
|
||||
$before !== '' ? sprintf(' vor %s', $before) : '',
|
||||
));
|
||||
$this->table(
|
||||
['User', 'E-Mail', 'Firma', 'Firmenname', 'Herkunft', 'Angelegt'],
|
||||
$candidates->take(20)->map(fn (object $row): array => [
|
||||
$row->user_id,
|
||||
$row->email,
|
||||
$row->company_id,
|
||||
mb_strimwidth((string) $row->company_name, 0, 40, '…'),
|
||||
$row->granted_via,
|
||||
substr((string) $row->created_at, 0, 16),
|
||||
])->all(),
|
||||
);
|
||||
|
||||
$marked = 0;
|
||||
|
||||
foreach (collect($explained)->chunk(500) as $chunk) {
|
||||
foreach ($chunk as $pair) {
|
||||
foreach ($candidates->chunk(500) as $chunk) {
|
||||
foreach ($chunk as $row) {
|
||||
$marked += DB::table('company_user')
|
||||
->where('company_id', $pair['company_id'])
|
||||
->where('user_id', $pair['user_id'])
|
||||
->where('company_id', $row->company_id)
|
||||
->where('user_id', $row->user_id)
|
||||
->where('role', 'responsible')
|
||||
->where('granted_via', '!=', 'auto')
|
||||
->update(['granted_via' => 'auto']);
|
||||
|
|
|
|||
|
|
@ -135,8 +135,13 @@ class PressReleaseController extends Controller
|
|||
?? $pressRelease->portal
|
||||
?? $company->portal)->value;
|
||||
|
||||
// Der resultierende Zustand muss passen (Audit U10): eine neue
|
||||
// Kategorie gegen das Ziel-Portal, bei reinem Portalwechsel die
|
||||
// bestehende Kategorie gegen das neue Portal.
|
||||
if (isset($validated['category_id'])) {
|
||||
$this->assertCategoryFitsPortal((int) $validated['category_id'], $portal, $request);
|
||||
} elseif ($portal !== $pressRelease->portal->value) {
|
||||
$this->assertCategoryFitsPortal((int) $pressRelease->category_id, $portal, $request);
|
||||
}
|
||||
|
||||
$pressRelease->fill([
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ namespace App\Http\Controllers\Auth;
|
|||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\ContactAccessService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
|
|
@ -14,7 +15,7 @@ class ConfirmEmailChangeController extends Controller
|
|||
* signierten Link aus der Mail an die NEUE Adresse geklickt hat.
|
||||
* Bis dahin bleibt die alte Adresse aktiv (kein Aussperren bei Tippfehlern).
|
||||
*/
|
||||
public function __invoke(Request $request, User $user, string $hash): RedirectResponse
|
||||
public function __invoke(Request $request, User $user, string $hash, ContactAccessService $contactAccess): RedirectResponse
|
||||
{
|
||||
abort_unless($request->user()->is($user), 403);
|
||||
|
||||
|
|
@ -39,6 +40,11 @@ class ConfirmEmailChangeController extends Controller
|
|||
'pending_email' => null,
|
||||
])->save();
|
||||
|
||||
// Die Firmenrechte hängen an der Adresse (Audit 7.1): automatische
|
||||
// Rechte der alten Adresse sofort zurücknehmen, passende der neuen
|
||||
// vergeben – nicht erst beim nächsten Login.
|
||||
$contactAccess->syncMembershipsForEmail($user);
|
||||
|
||||
return redirect()->route('me.security')
|
||||
->with('security-status', __('Ihre E-Mail-Adresse wurde erfolgreich geändert.'));
|
||||
}
|
||||
|
|
|
|||
46
app/Http/Middleware/BlockUnusedFortifyRoutes.php
Normal file
46
app/Http/Middleware/BlockUnusedFortifyRoutes.php
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Sperrt die Fortify-Routen zur 2FA-Verwaltung, die das Projekt nicht nutzt
|
||||
* (Audit U09, Entscheidung E3/E8).
|
||||
*
|
||||
* Die Sicherheitsseite (customer/security) ruft die Fortify-Actions direkt
|
||||
* auf und verlangt vor Deaktivierung und Recovery-Codes einen TOTP- oder
|
||||
* Recovery-Code. Fortify registriert daneben eigene Routen für dieselben
|
||||
* Aktionen, die nur `password.confirm` verlangen – eine bis zu drei Stunden
|
||||
* alte Passwortbestätigung hätte den zweiten Faktor ersetzt. Fortify kennt
|
||||
* kein Abschalten einzelner Routen (nur `ignoreRoutes()` für alle), deshalb
|
||||
* hängt diese Middleware über `fortify.middleware` an allen Fortify-Routen
|
||||
* und beantwortet die ungenutzten mit 404. Challenge und Passwortwechsel
|
||||
* bleiben erreichbar.
|
||||
*/
|
||||
class BlockUnusedFortifyRoutes
|
||||
{
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
public const BLOCKED_ROUTES = [
|
||||
'two-factor.enable',
|
||||
'two-factor.confirm',
|
||||
'two-factor.disable',
|
||||
'two-factor.qr-code',
|
||||
'two-factor.secret-key',
|
||||
'two-factor.recovery-codes',
|
||||
'two-factor.regenerate-recovery-codes',
|
||||
];
|
||||
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (in_array($request->route()?->getName(), self::BLOCKED_ROUTES, true)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
|
|
@ -8,9 +8,13 @@ use App\Scopes\PortalScope;
|
|||
|
||||
class CompanyPolicy
|
||||
{
|
||||
/**
|
||||
* Kurzschluss nur für AKTIVE Super-Admins (Audit 7.2): ein deaktiviertes
|
||||
* Konto fällt auf die regulären Prüfungen zurück, die es ablehnen.
|
||||
*/
|
||||
public function before(User $user): ?bool
|
||||
{
|
||||
return $user->is_super_admin ? true : null;
|
||||
return ($user->is_super_admin && $user->is_active) ? true : null;
|
||||
}
|
||||
|
||||
public function viewAny(User $user): bool
|
||||
|
|
|
|||
|
|
@ -8,9 +8,13 @@ use App\Models\User;
|
|||
|
||||
class PressReleasePolicy
|
||||
{
|
||||
/**
|
||||
* Kurzschluss nur für AKTIVE Super-Admins (Audit 7.2): ein deaktiviertes
|
||||
* Konto fällt auf die regulären Prüfungen zurück, die es ablehnen.
|
||||
*/
|
||||
public function before(User $user): ?bool
|
||||
{
|
||||
return $user->is_super_admin ? true : null;
|
||||
return ($user->is_super_admin && $user->is_active) ? true : null;
|
||||
}
|
||||
|
||||
public function viewAny(User $user): bool
|
||||
|
|
|
|||
|
|
@ -43,6 +43,10 @@ class ReleaseDetailPageData
|
|||
'images' => fn ($query) => $query->orderByDesc('is_preview')->orderBy('sort_order'),
|
||||
'contacts',
|
||||
])
|
||||
// Doppelveröffentlichung mit gleichem Slug: dieselbe Wahl wie
|
||||
// ReportedUrlResolver – die Erstquelle gewinnt (Audit 7.5).
|
||||
->orderByRaw('canonical_press_release_id is null desc')
|
||||
->orderBy('id')
|
||||
->first();
|
||||
|
||||
if (! $release) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue