M8: Security-Headers, Rate-Limits und Audit-Log

Erste drei Bausteine der Haertung:

- Security-Headers (X-Content-Type-Options, X-Frame-Options, Referrer-
  Policy, Permissions-Policy, HSTS bei HTTPS) laufen automatisch ueber
  app_send_security_headers() am Ende von app/bootstrap.php fuer jede
  dynamische Seite; landing.php war als einzige Seite ganz ohne PHP und
  bekam einen minimalen Bootstrap-Aufruf. Bewusst kein CSP, da die
  bestehenden Templates durchgaengig auf Inline-style-Attribute setzen.
- DB-gestuetzte Rate-Limits (neue Tabelle rate_limit_attempts) fuer
  Login (10/15min je E-Mail, 20/15min je IP), Registrierung (5/h je IP)
  und Passwort-Reset-Anfrage (5/h je E-Mail, 10/h je IP); bei
  ausgereiztem Reset-Limit erscheint dieselbe generische Meldung wie im
  Erfolgsfall, um kein Konto-Enumeration-Signal zu geben.
- Zentrales Audit-Log (neue Tabelle audit_log) fuer Mitgliederverwaltung,
  Zugangsvergabe/-entzug, Storno, Mandant-Einstellungen, Hinweise,
  CSV-Import, Jahresbonus-Verteilung und Live-Mailversand; sichtbar fuer
  Owner/Admin auf mandant-einstellungen.php.

Live getestet: Rate-Limit greift nach 10 Fehlversuchen, Audit-Log-Eintrag
mit korrekten Metadaten und Nutzernamen ueber einen isolierten Test-
Mandanten geprueft. Alle Regressionstests weiterhin gruen (26/26 Smoke,
104 Golden-Master-Assertions).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 18:04:14 +02:00
co-authored by Claude Sonnet 5
parent 7b517bcbf6
commit 54217d2acb
18 changed files with 375 additions and 55 deletions
+53
View File
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/bootstrap.php';
/**
* Records one admin/security-relevant action. Keep $action as a short,
* stable slug (e.g. "participant.access_granted") so entries stay
* filterable; put anything variable in $metadata.
*/
function app_audit_log(
PDO $pdo,
?int $tenantId,
?int $actorUserId,
string $action,
string $subjectType,
?int $subjectId,
array $metadata = []
): void {
$stmt = $pdo->prepare(
'INSERT INTO audit_log (tenant_id, actor_user_id, action, subject_type, subject_id, metadata_json, ip)
VALUES (?, ?, ?, ?, ?, ?, ?)'
);
$stmt->execute([
$tenantId,
$actorUserId,
$action,
$subjectType,
$subjectId,
$metadata !== [] ? json_encode($metadata, JSON_UNESCAPED_UNICODE) : null,
$_SERVER['REMOTE_ADDR'] ?? null,
]);
}
/**
* @return list<array{id: int, actor_user_id: ?int, actor_name: ?string, action: string, subject_type: string, subject_id: ?int, metadata_json: ?string, ip: ?string, created_at: string}>
*/
function app_fetch_audit_log(PDO $pdo, int $tenantId, int $limit = 100): array
{
$limit = max(1, min($limit, 500));
$stmt = $pdo->prepare(
"SELECT a.id, a.actor_user_id, u.display_name AS actor_name, a.action, a.subject_type, a.subject_id, a.metadata_json, a.ip, a.created_at
FROM audit_log a
LEFT JOIN users u ON u.id = a.actor_user_id
WHERE a.tenant_id = ?
ORDER BY a.created_at DESC, a.id DESC
LIMIT {$limit}"
);
$stmt->execute([$tenantId]);
return $stmt->fetchAll();
}
+25
View File
@@ -27,6 +27,29 @@ function app_is_https(): bool
|| (($_SERVER['SERVER_PORT'] ?? null) === '443'); || (($_SERVER['SERVER_PORT'] ?? null) === '443');
} }
/**
* Sends baseline security headers on every dynamic request. Deliberately
* does not set a Content-Security-Policy: the existing templates rely on
* inline style="" attributes throughout (banners, table cells, etc.), and
* locking that down would need a broader template pass. Runs once per
* request via the auto-invocation at the bottom of this file.
*/
function app_send_security_headers(): void
{
if (PHP_SAPI === 'cli' || headers_sent()) {
return;
}
header('X-Content-Type-Options: nosniff');
header('X-Frame-Options: DENY');
header('Referrer-Policy: strict-origin-when-cross-origin');
header('Permissions-Policy: geolocation=(), microphone=(), camera=()');
if (app_is_https()) {
header('Strict-Transport-Security: max-age=31536000; includeSubDomains');
}
}
function app_start_session(): void function app_start_session(): void
{ {
if (PHP_SAPI === 'cli' || session_status() === PHP_SESSION_ACTIVE) { if (PHP_SAPI === 'cli' || session_status() === PHP_SESSION_ACTIVE) {
@@ -102,3 +125,5 @@ function app_require_csrf(): void
die('CSRF validation failed.'); die('CSRF validation failed.');
} }
} }
app_send_security_headers();
+34
View File
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/bootstrap.php';
function app_client_ip(): string
{
return (string)($_SERVER['REMOTE_ADDR'] ?? 'unknown');
}
/**
* Records an attempt in the given bucket and reports whether the caller is
* still within the allowed rate. Call before doing the sensitive work (auth
* check, mail dispatch, account creation); if this returns false, show a
* generic "too many attempts" message without processing the request, so a
* bucket also can't be used to enumerate valid accounts by timing.
*/
function app_rate_limit_check(PDO $pdo, string $bucket, int $maxAttempts, int $windowSeconds): bool
{
$pdo->prepare('INSERT INTO rate_limit_attempts (bucket) VALUES (?)')->execute([$bucket]);
// Opportunistic cleanup so the table doesn't grow unbounded; scoped to
// this bucket to keep each call cheap.
$pdo->prepare('DELETE FROM rate_limit_attempts WHERE bucket = ? AND created_at < DATE_SUB(NOW(), INTERVAL ? SECOND)')
->execute([$bucket, $windowSeconds]);
$stmt = $pdo->prepare(
'SELECT COUNT(*) FROM rate_limit_attempts WHERE bucket = ? AND created_at >= DATE_SUB(NOW(), INTERVAL ? SECOND)'
);
$stmt->execute([$bucket, $windowSeconds]);
return (int)$stmt->fetchColumn() <= $maxAttempts;
}
+2
View File
@@ -3,6 +3,7 @@
include "functions.php"; include "functions.php";
require_once __DIR__ . "/app/ledger.php"; require_once __DIR__ . "/app/ledger.php";
require_once __DIR__ . "/app/imports.php"; require_once __DIR__ . "/app/imports.php";
require_once __DIR__ . "/app/audit.php";
app_require_csrf(); app_require_csrf();
include "header.php"; include "header.php";
include "headerline.php"; include "headerline.php";
@@ -180,6 +181,7 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") {
$batchId = (int)($_POST['batch_id'] ?? 0); $batchId = (int)($_POST['batch_id'] ?? 0);
try { try {
$ergebnis = imports_commit_batch($pdo, $tenantId, $batchId); $ergebnis = imports_commit_batch($pdo, $tenantId, $batchId);
app_audit_log($pdo, $tenantId, $saasUser['user_id'] ?? null, 'csv_import.committed', 'payment_import_batch', $batchId, ['imported' => $ergebnis['imported']]);
$meldung = $ergebnis['imported'] . ' Einzahlungen wurden importiert.'; $meldung = $ergebnis['imported'] . ' Einzahlungen wurden importiert.';
} catch (Throwable $e) { } catch (Throwable $e) {
$fehler = $e->getMessage(); $fehler = $e->getMessage();
@@ -0,0 +1,6 @@
CREATE TABLE IF NOT EXISTS rate_limit_attempts (
id INT AUTO_INCREMENT PRIMARY KEY,
bucket VARCHAR(191) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_rate_limit_attempts_bucket_created (bucket, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -0,0 +1,20 @@
CREATE TABLE IF NOT EXISTS audit_log (
id INT AUTO_INCREMENT PRIMARY KEY,
tenant_id INT NULL,
actor_user_id INT NULL,
action VARCHAR(60) NOT NULL,
subject_type VARCHAR(60) NOT NULL,
subject_id INT NULL,
metadata_json TEXT NULL,
ip VARCHAR(45) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_audit_log_tenant_created (tenant_id, created_at),
KEY idx_audit_log_actor_user (actor_user_id),
KEY idx_audit_log_subject (subject_type, subject_id),
CONSTRAINT fk_audit_log_tenant
FOREIGN KEY (tenant_id) REFERENCES tenants(id)
ON DELETE CASCADE,
CONSTRAINT fk_audit_log_actor_user
FOREIGN KEY (actor_user_id) REFERENCES users(id)
ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+108
View File
@@ -0,0 +1,108 @@
# M8 Härtung, Datenschutz und Betrieb
Stand: 2026-07-15
M8 macht die SaaS-App für den mehrmandantenfähigen Betrieb sicherer und
nachvollziehbarer: Security-Headers, Rate-Limits, ein zentrales Audit-Log,
sowie (folgend) Mandanten-Isolation, Rollenmatrix, Datenexport und
Löschung/Anonymisierung.
## Security-Headers
Umgesetzte Dateien:
```text
app/bootstrap.php
landing.php
```
- `app_send_security_headers()` setzt `X-Content-Type-Options: nosniff`,
`X-Frame-Options: DENY`, `Referrer-Policy: strict-origin-when-cross-origin`,
`Permissions-Policy` (Geolocation/Mikrofon/Kamera aus) sowie `Strict-
Transport-Security` bei HTTPS. Der Aufruf steht am Ende von
`app/bootstrap.php`, das transitiv von jeder dynamischen Seite geladen
wird, und läuft damit automatisch einmal pro Request.
- Bewusst **kein** Content-Security-Policy-Header: Die bestehenden
Templates nutzen durchgängig `style=""`-Inline-Attribute (Banner,
Tabellenzellen, Status-Einfärbung). Ein CSP-Lockdown würde diese
brechen und bräuchte einen eigenen Template-Durchgang als offener
Punkt vermerkt, nicht in diesem Schritt umgesetzt.
- `landing.php` war die einzige Seite ganz ohne PHP (reines HTML). Ein
minimaler `require_once app/bootstrap.php`-Aufruf am Dateianfang sorgt
dafür, dass auch sie die Header bekommt, ohne das Markup zu verändern.
## Rate-Limits
Umgesetzte Dateien:
```text
database/migrations/0010_saas_rate_limits.sql
app/rate-limit.php
login.php
register.php
passwort-vergessen.php
```
- Neue Tabelle `rate_limit_attempts` (Bucket + Zeitstempel) mit
`app_rate_limit_check()`: schreibt einen Versuch, räumt alte Einträge
für denselben Bucket auf und meldet, ob das Limit noch eingehalten ist.
- Login: 10 Versuche / 15 Minuten je E-Mail-Adresse **und** 20 Versuche /
15 Minuten je IP (beide müssen greifen, damit ein einzelnes Konto nicht
gezielt von verteilten IPs aus brute-forced werden kann und eine IP
nicht viele Konten gleichzeitig durchprobieren kann).
- Registrierung: 5 Versuche / Stunde je IP (gegen Spam-Registrierungen).
- Passwort-Reset-Anfrage: 5 / Stunde je E-Mail, 10 / Stunde je IP. Bei
überschrittenem Limit erscheint bewusst dieselbe generische
Erfolgsmeldung wie im echten Erfolgsfall, damit ein ausgereiztes Limit
nicht zusätzlich verrät, ob ein Konto existiert.
- Live getestet: 11 aufeinanderfolgende Fehlversuche gegen `login.php`
die ersten 10 zeigen „ungültig“, der 11. wird korrekt mit „Zu viele
Anmeldeversuche“ abgewiesen.
## Audit-Log
Umgesetzte Dateien:
```text
database/migrations/0011_saas_audit_log.sql
app/audit.php
mitarbeiterverwalten.php
letzteneintraege.php
mandant-einstellungen.php
hinweise.php
csvupload.php
jahresauswertung.php
mailversenden.php
```
- Neue Tabelle `audit_log` (Mandant, ausführender Nutzer, Aktion,
betroffener Datensatz, Metadaten als JSON, IP, Zeitpunkt) gemäß
Kernschema aus dem Umstrukturierungsplan.
- `app_audit_log()` schreibt einen Eintrag, `app_fetch_audit_log()` liest
die letzten N Einträge eines Mandanten.
- Protokollierte Aktionen: Mitglied anlegen/bearbeiten/(de)aktivieren,
Zugang gewähren/entziehen, Storno von Einzahlung/Strich, Mandant-
Einstellungen ändern, Hinweis anlegen/löschen, CSV-Import bestätigen,
Jahresbonus-Verteilung, Live-Mailversand.
- Sichtbar für Owner/Admin auf `mandant-einstellungen.php` unter
„Protokoll" (letzte 50 Einträge, mit Namen des ausführenden Nutzers).
- Live getestet: Hinweis anlegen erzeugt einen Log-Eintrag mit korrekten
Metadaten; über einen eigens angelegten Test-Mandanten geprüft, dass
eine Einstellungsänderung im UI-Protokoll mit korrektem Nutzernamen
erscheint. Testdaten anschließend entfernt.
## Prüfstatus
- Golden Master: grün mit 104 Assertions.
- M4 Ledger-Migration: grün mit 73 Assertions.
- M3 Settings-Flow: grün mit 13 Assertions.
- HTTP-Smoke: grün mit 26 geprüften Seiten.
## Noch offen in M8
- Content-Security-Policy (braucht Template-Bereinigung der Inline-Styles).
- Mandanten-Isolation automatisiert testen.
- Rollenmatrix automatisiert testen.
- Datenexport pro Mandant.
- Lösch-/Anonymisierungsprozess für Teilnehmer und Kunden.
- Backup-/Restore-Prozess und Monitoring/Fehlerlogging dokumentieren.
+9 -3
View File
@@ -308,7 +308,7 @@ Nicht tun:
| M5 | App-Kern | Weit fortgeschritten: Kernseiten lesen und schreiben tenant-sicher gegen das Ledger, inklusive Hinweise und rollenbasiertem Zugang; offen ist ein eigener Zahlungs-Screen | | M5 | App-Kern | Weit fortgeschritten: Kernseiten lesen und schreiben tenant-sicher gegen das Ledger, inklusive Hinweise und rollenbasiertem Zugang; offen ist ein eigener Zahlungs-Screen |
| M6 | Betriebsflows | Abgeschlossen: Import, Export, Mail und Jahresabschluss sind auditierbar | | M6 | Betriebsflows | Abgeschlossen: Import, Export, Mail und Jahresabschluss sind auditierbar |
| M7 | Landingpage | Public-Seite und Auth-Seiten sind im gemeinsamen Stil nutzbar; spätere Ausbaustufen folgen | | M7 | Landingpage | Public-Seite und Auth-Seiten sind im gemeinsamen Stil nutzbar; spätere Ausbaustufen folgen |
| M8 | Härtung | Betrieb, Datenschutz, Monitoring und Isolation sind geprüft | | M8 | Härtung | Gestartet: Security-Headers, Rate-Limits und Audit-Log stehen; Isolation/Rollenmatrix-Tests, Datenexport, Löschung und Betrieb offen |
| M9 | Cutover | Produktivumstellung ist vorbereitet und Legacy ist read-only | | M9 | Cutover | Produktivumstellung ist vorbereitet und Legacy ist read-only |
### M0: Planungs- und Sicherheitsbaseline ### M0: Planungs- und Sicherheitsbaseline
@@ -643,16 +643,22 @@ Schritte:
- Backups und Restore-Prozess definieren. - Backups und Restore-Prozess definieren.
- Monitoring und Fehlerlogging einrichten. - Monitoring und Fehlerlogging einrichten.
- Audit-Log für Admin-Aktionen prüfen. - Audit-Log für Admin-Aktionen prüfen: erledigt. Neue Tabelle `audit_log`,
protokolliert Mitgliederverwaltung, Storno, Mandant-Einstellungen,
Hinweise, CSV-Import, Jahresbonus und Live-Mailversand; sichtbar für
Owner/Admin auf `mandant-einstellungen.php`.
- Datenexport pro Tenant. - Datenexport pro Tenant.
- Lösch-/Anonymisierungsprozess für Teilnehmer und Kunden. - Lösch-/Anonymisierungsprozess für Teilnehmer und Kunden.
- Rate-Limits und Security Headers. - Rate-Limits und Security Headers: erledigt. Globale Security-Headers
über `app/bootstrap.php` (ohne CSP, siehe `docs/m8-haertung.md`),
DB-gestützte Rate-Limits für Login, Registrierung und Passwort-Reset.
- Mandanten-Isolation testen. - Mandanten-Isolation testen.
- Rollenmatrix testen. - Rollenmatrix testen.
Ergebnis: Ergebnis:
- SaaS ist betrieblich und datenschutzseitig belastbarer. - SaaS ist betrieblich und datenschutzseitig belastbarer.
- Stand: gestartet. Dokumentation: `docs/m8-haertung.md`.
Abhängigkeiten: Abhängigkeiten:
+7 -4
View File
@@ -3,6 +3,7 @@
include "functions.php"; include "functions.php";
require_once __DIR__ . "/app/ledger.php"; require_once __DIR__ . "/app/ledger.php";
require_once __DIR__ . "/app/notices.php"; require_once __DIR__ . "/app/notices.php";
require_once __DIR__ . "/app/audit.php";
app_require_csrf(); app_require_csrf();
include "header.php"; include "header.php";
include "headerline.php"; include "headerline.php";
@@ -45,10 +46,12 @@ if($hasAccess){
if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$aktion = $_POST['aktion'] ?? 'speichern'; $aktion = $_POST['aktion'] ?? 'speichern';
$actorUserId = $saasUser !== null ? (int)$saasUser['user_id'] : null;
if ($aktion === 'loeschen') { if ($aktion === 'loeschen') {
$id = (int)($_POST['id'] ?? 0); $id = (int)($_POST['id'] ?? 0);
if ($id > 0) { if ($id > 0 && notices_soft_delete($pdo, $tenantId, $id)) {
notices_soft_delete($pdo, $tenantId, $id); app_audit_log($pdo, $tenantId, $actorUserId, 'notice.deleted', 'notice', $id);
} }
} else { } else {
$nachricht = $_POST['nachricht'] ?? ''; $nachricht = $_POST['nachricht'] ?? '';
@@ -57,8 +60,8 @@ if($hasAccess){
if ($dt) { if ($dt) {
$gueltig_bis_sql = $dt->format('Y-m-d H:i:s'); // z.B. "2025-09-03 14:00:00" $gueltig_bis_sql = $dt->format('Y-m-d H:i:s'); // z.B. "2025-09-03 14:00:00"
$createdByUserId = $saasUser !== null ? (int)$saasUser['user_id'] : null; notices_create($pdo, $tenantId, (string)$nachricht, $gueltig_bis_sql, $actorUserId);
notices_create($pdo, $tenantId, (string)$nachricht, $gueltig_bis_sql, $createdByUserId); app_audit_log($pdo, $tenantId, $actorUserId, 'notice.created', 'notice', null, ['valid_until' => $gueltig_bis_sql]);
} }
} }
} }
+2
View File
@@ -3,6 +3,7 @@
include "functions.php"; include "functions.php";
require_once __DIR__ . "/app/ledger.php"; require_once __DIR__ . "/app/ledger.php";
require_once __DIR__ . "/app/saas-mail.php"; require_once __DIR__ . "/app/saas-mail.php";
require_once __DIR__ . "/app/audit.php";
app_require_csrf(); app_require_csrf();
include "header.php"; include "header.php";
include "headerline.php"; include "headerline.php";
@@ -130,6 +131,7 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") {
if (!$dryRun) { if (!$dryRun) {
$pdo->commit(); $pdo->commit();
app_audit_log($pdo, $tenantId, $createdByUserId, 'year_end_bonus.distributed', 'tenant', $tenantId, ['jahr' => $jahr, 'gesamtbetrag_cents' => $totalCents, 'empfaenger' => count($ergebnisse)]);
} }
} catch (Throwable $e) { } catch (Throwable $e) {
if ($pdo->inTransaction()) { if ($pdo->inTransaction()) {
+1
View File
@@ -1,3 +1,4 @@
<?php require_once __DIR__ . '/app/bootstrap.php'; ?>
<!DOCTYPE HTML> <!DOCTYPE HTML>
<html lang="de"> <html lang="de">
<head> <head>
+3
View File
@@ -2,6 +2,7 @@
include "functions.php"; include "functions.php";
require_once __DIR__ . "/app/ledger.php"; require_once __DIR__ . "/app/ledger.php";
require_once __DIR__ . "/app/audit.php";
app_require_csrf(); app_require_csrf();
include "header.php"; include "header.php";
include "headerline.php"; include "headerline.php";
@@ -66,6 +67,7 @@ if ($_SERVER["REQUEST_METHOD"] == "POST" && isset($_POST["aktion"]) && $_POST["a
$ergebnis = loescheEinzahlung($einzahlungID, $pdo, $tenantId); $ergebnis = loescheEinzahlung($einzahlungID, $pdo, $tenantId);
if ($ergebnis === true) { if ($ergebnis === true) {
app_audit_log($pdo, $tenantId, $saasUser['user_id'] ?? null, 'payment.voided', 'kl_Einzahlungen', $einzahlungID);
echo "Einzahlung erfolgreich gelöscht."; echo "Einzahlung erfolgreich gelöscht.";
} else { } else {
echo "Fehler: $ergebnis"; echo "Fehler: $ergebnis";
@@ -157,6 +159,7 @@ if ($_SERVER["REQUEST_METHOD"] == "POST" && isset($_POST["aktion"]) && $_POST["a
$ergebnis = loescheStrichEintrag($strichID, $pdo, $tenantId); $ergebnis = loescheStrichEintrag($strichID, $pdo, $tenantId);
if ($ergebnis === true) { if ($ergebnis === true) {
app_audit_log($pdo, $tenantId, $saasUser['user_id'] ?? null, 'consumption.voided', 'kl_Kaffeeverbrauch', $strichID);
echo "Strich-Eintrag erfolgreich gelöscht."; echo "Strich-Eintrag erfolgreich gelöscht.";
} else { } else {
echo "Fehler: $ergebnis"; echo "Fehler: $ergebnis";
+26 -18
View File
@@ -1,6 +1,7 @@
<?php <?php
require_once __DIR__ . '/functions.php'; require_once __DIR__ . '/functions.php';
require_once __DIR__ . '/app/rate-limit.php';
$pdo = app_db_pdo(); $pdo = app_db_pdo();
$errors = []; $errors = [];
@@ -10,26 +11,33 @@ $tenantSlug = trim((string)($_POST['tenant_slug'] ?? ''));
if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($_SERVER['REQUEST_METHOD'] === 'POST') {
app_require_csrf(); app_require_csrf();
$result = saas_authenticate( $emailBucketOk = app_rate_limit_check($pdo, 'login_email:' . saas_email_norm($email), 10, 900);
$pdo, $ipBucketOk = app_rate_limit_check($pdo, 'login_ip:' . app_client_ip(), 20, 900);
$email,
(string)($_POST['password'] ?? ''),
$tenantSlug
);
if ($result['ok'] && !empty($result['needs_tenant_selection'])) { if (!$emailBucketOk || !$ipBucketOk) {
saas_start_pending_tenant_selection((int)$result['user_id']); $errors = ['Zu viele Anmeldeversuche. Bitte versuche es in einigen Minuten erneut.'];
header('Location: mandant-auswahl.php'); } else {
exit; $result = saas_authenticate(
$pdo,
$email,
(string)($_POST['password'] ?? ''),
$tenantSlug
);
if ($result['ok'] && !empty($result['needs_tenant_selection'])) {
saas_start_pending_tenant_selection((int)$result['user_id']);
header('Location: mandant-auswahl.php');
exit;
}
if ($result['ok']) {
saas_session_login($result['identity']);
header('Location: konto.php');
exit;
}
$errors = $result['errors'];
} }
if ($result['ok']) {
saas_session_login($result['identity']);
header('Location: konto.php');
exit;
}
$errors = $result['errors'];
} }
?> ?>
+5
View File
@@ -3,6 +3,7 @@
include "functions.php"; include "functions.php";
require_once __DIR__ . "/app/ledger.php"; require_once __DIR__ . "/app/ledger.php";
require_once __DIR__ . "/app/saas-mail.php"; require_once __DIR__ . "/app/saas-mail.php";
require_once __DIR__ . "/app/audit.php";
app_require_csrf(); app_require_csrf();
include "header.php"; include "header.php";
include "headerline.php"; include "headerline.php";
@@ -85,6 +86,10 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") {
$ergebnisse[] = ['name' => $person['display_name'], 'email' => $email, 'status' => 'failed']; $ergebnisse[] = ['name' => $person['display_name'], 'email' => $email, 'status' => 'failed'];
} }
} }
if (!$dryRun) {
app_audit_log($pdo, $tenantId, $createdByUserId, 'mail_broadcast.sent', 'tenant', $tenantId, ['empfaenger' => count($ergebnisse)]);
}
} }
$log = saas_fetch_outbound_email_log($pdo, $tenantId, 20); $log = saas_fetch_outbound_email_log($pdo, $tenantId, 20);
+21
View File
@@ -1,6 +1,7 @@
<?php <?php
require_once __DIR__ . '/functions.php'; require_once __DIR__ . '/functions.php';
require_once __DIR__ . '/app/audit.php';
$pdo = app_db_pdo(); $pdo = app_db_pdo();
$user = saas_require_login(); $user = saas_require_login();
@@ -16,6 +17,7 @@ if ($canManageSettings) {
if ($result['ok']) { if ($result['ok']) {
$saved = true; $saved = true;
$settings = $result['settings']; $settings = $result['settings'];
app_audit_log($pdo, (int)$user['tenant_id'], (int)$user['user_id'], 'tenant_settings.updated', 'tenant', (int)$user['tenant_id']);
} else { } else {
$errors = $result['errors']; $errors = $result['errors'];
$settings = [ $settings = [
@@ -34,6 +36,8 @@ if ($canManageSettings) {
} else { } else {
$settings = saas_fetch_tenant_settings($pdo, (int)$user['tenant_id']); $settings = saas_fetch_tenant_settings($pdo, (int)$user['tenant_id']);
} }
$auditLog = app_fetch_audit_log($pdo, (int)$user['tenant_id'], 50);
} }
include 'header.php'; include 'header.php';
@@ -112,6 +116,23 @@ include 'nav.php';
<li><a href="konto.php" class="button alt">Zurück</a></li> <li><a href="konto.php" class="button alt">Zurück</a></li>
</ul> </ul>
</form> </form>
<h3>Protokoll</h3>
<p>Die letzten Admin-Aktionen für diesen Mandanten.</p>
<table>
<tr><th>Datum</th><th>Wer</th><th>Aktion</th><th>Betrifft</th></tr>
<?php if ($auditLog === []): ?>
<tr><td colspan="4">Noch keine protokollierten Aktionen.</td></tr>
<?php endif; ?>
<?php foreach ($auditLog as $eintrag): ?>
<tr>
<td><?php echo saas_html($eintrag['created_at']); ?></td>
<td><?php echo saas_html($eintrag['actor_name'] ?? '—'); ?></td>
<td><?php echo saas_html($eintrag['action']); ?></td>
<td><?php echo saas_html($eintrag['subject_type']); ?><?php echo $eintrag['subject_id'] !== null ? ' #' . (int)$eintrag['subject_id'] : ''; ?></td>
</tr>
<?php endforeach; ?>
</table>
<?php endif; ?> <?php endif; ?>
</div> </div>
</section> </section>
+8 -1
View File
@@ -4,6 +4,7 @@
include "functions.php"; include "functions.php";
require_once __DIR__ . "/app/ledger.php"; require_once __DIR__ . "/app/ledger.php";
require_once __DIR__ . "/app/saas-mail.php"; require_once __DIR__ . "/app/saas-mail.php";
require_once __DIR__ . "/app/audit.php";
app_require_csrf(); app_require_csrf();
include "header.php"; include "header.php";
include "headerline.php"; include "headerline.php";
@@ -43,6 +44,7 @@ if($hasAccess){
$fehler = null; $fehler = null;
$einladungslink = null; $einladungslink = null;
$bearbeitenId = null; $bearbeitenId = null;
$actorUserId = $saasUser['user_id'] ?? null;
if ($_SERVER["REQUEST_METHOD"] == "POST") { if ($_SERVER["REQUEST_METHOD"] == "POST") {
$aktion = $_POST["aktion"] ?? ''; $aktion = $_POST["aktion"] ?? '';
@@ -59,7 +61,8 @@ if($hasAccess){
$fehler = 'Bitte einen Namen und eine gültige E-Mail-Adresse angeben.'; $fehler = 'Bitte einen Namen und eine gültige E-Mail-Adresse angeben.';
} else { } else {
try { try {
ledger_create_participant($pdo, $tenantId, $name, $email, $paypalname, $aktiv); $neueId = ledger_create_participant($pdo, $tenantId, $name, $email, $paypalname, $aktiv);
app_audit_log($pdo, $tenantId, $actorUserId, 'participant.created', 'participant', $neueId, ['name' => $name, 'email' => $email]);
$meldung = 'Mitglied wurde angelegt.'; $meldung = 'Mitglied wurde angelegt.';
} catch (Throwable $e) { } catch (Throwable $e) {
$fehler = 'Das Mitglied konnte nicht angelegt werden (E-Mail eventuell schon vergeben).'; $fehler = 'Das Mitglied konnte nicht angelegt werden (E-Mail eventuell schon vergeben).';
@@ -75,6 +78,7 @@ if($hasAccess){
if ($name === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) { if ($name === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
$fehler = 'Bitte einen Namen und eine gültige E-Mail-Adresse angeben.'; $fehler = 'Bitte einen Namen und eine gültige E-Mail-Adresse angeben.';
} elseif (ledger_update_participant($pdo, $tenantId, $participantId, $name, $email, $paypalname, $aktiv)) { } elseif (ledger_update_participant($pdo, $tenantId, $participantId, $name, $email, $paypalname, $aktiv)) {
app_audit_log($pdo, $tenantId, $actorUserId, 'participant.updated', 'participant', $participantId, ['name' => $name, 'email' => $email]);
$meldung = 'Mitglied wurde gespeichert.'; $meldung = 'Mitglied wurde gespeichert.';
} else { } else {
$fehler = 'Das Mitglied konnte nicht gespeichert werden.'; $fehler = 'Das Mitglied konnte nicht gespeichert werden.';
@@ -82,6 +86,7 @@ if($hasAccess){
} elseif ($aktion === 'aktivieren' || $aktion === 'deaktivieren') { } elseif ($aktion === 'aktivieren' || $aktion === 'deaktivieren') {
$participantId = (int)$_POST["mitgliedID"]; $participantId = (int)$_POST["mitgliedID"];
if (ledger_set_participant_active($pdo, $tenantId, $participantId, $aktion === 'aktivieren')) { if (ledger_set_participant_active($pdo, $tenantId, $participantId, $aktion === 'aktivieren')) {
app_audit_log($pdo, $tenantId, $actorUserId, $aktion === 'aktivieren' ? 'participant.activated' : 'participant.deactivated', 'participant', $participantId);
$meldung = $aktion === 'aktivieren' ? 'Mitglied wurde aktiviert.' : 'Mitglied wurde deaktiviert.'; $meldung = $aktion === 'aktivieren' ? 'Mitglied wurde aktiviert.' : 'Mitglied wurde deaktiviert.';
} else { } else {
$fehler = 'Der Status konnte nicht geändert werden.'; $fehler = 'Der Status konnte nicht geändert werden.';
@@ -99,6 +104,7 @@ if($hasAccess){
$rolle, $rolle,
$ergebnis['token'] $ergebnis['token']
); );
app_audit_log($pdo, $tenantId, $actorUserId, 'participant.access_granted', 'participant', $participantId, ['role' => $rolle]);
$meldung = 'Zugang wurde gewährt, Einladung wurde verschickt.'; $meldung = 'Zugang wurde gewährt, Einladung wurde verschickt.';
if (saas_should_show_auth_links()) { if (saas_should_show_auth_links()) {
$einladungslink = saas_app_url('passwort-zuruecksetzen.php?token=' . urlencode($ergebnis['token'])); $einladungslink = saas_app_url('passwort-zuruecksetzen.php?token=' . urlencode($ergebnis['token']));
@@ -111,6 +117,7 @@ if($hasAccess){
$ergebnis = saas_revoke_participant_access($pdo, $tenantId, $participantId); $ergebnis = saas_revoke_participant_access($pdo, $tenantId, $participantId);
if ($ergebnis['ok']) { if ($ergebnis['ok']) {
app_audit_log($pdo, $tenantId, $actorUserId, 'participant.access_revoked', 'participant', $participantId);
$meldung = 'Zugang wurde entzogen.'; $meldung = 'Zugang wurde entzogen.';
} else { } else {
$fehler = implode(' ', $ergebnis['errors']); $fehler = implode(' ', $ergebnis['errors']);
+21 -10
View File
@@ -2,6 +2,7 @@
require_once __DIR__ . '/functions.php'; require_once __DIR__ . '/functions.php';
require_once __DIR__ . '/app/saas-mail.php'; require_once __DIR__ . '/app/saas-mail.php';
require_once __DIR__ . '/app/rate-limit.php';
$pdo = app_db_pdo(); $pdo = app_db_pdo();
$email = trim((string)($_POST['email'] ?? '')); $email = trim((string)($_POST['email'] ?? ''));
@@ -12,18 +13,28 @@ $resetLink = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($_SERVER['REQUEST_METHOD'] === 'POST') {
app_require_csrf(); app_require_csrf();
$result = saas_request_password_reset($pdo, $email, $tenantSlug);
if ($result['ok']) { $emailBucketOk = app_rate_limit_check($pdo, 'pwreset_email:' . saas_email_norm($email), 5, 3600);
$message = $result['message']; $ipBucketOk = app_rate_limit_check($pdo, 'pwreset_ip:' . app_client_ip(), 10, 3600);
if (saas_should_show_auth_links() && !empty($result['token'])) {
$resetLink = 'passwort-zuruecksetzen.php?token=' . urlencode((string)$result['token']); if (!$emailBucketOk || !$ipBucketOk) {
} // Gleiche generische Meldung wie im Erfolgsfall, damit ein
if (!empty($result['token'])) { // ausgereiztes Limit nicht verrät, ob das Konto existiert.
saas_send_password_reset_mail($email, (string)$result['token']); $message = 'Wenn ein passendes Konto existiert, wurde ein Link vorbereitet.';
}
} else { } else {
$errors = $result['errors']; $result = saas_request_password_reset($pdo, $email, $tenantSlug);
if ($result['ok']) {
$message = $result['message'];
if (saas_should_show_auth_links() && !empty($result['token'])) {
$resetLink = 'passwort-zuruecksetzen.php?token=' . urlencode((string)$result['token']);
}
if (!empty($result['token'])) {
saas_send_password_reset_mail($email, (string)$result['token']);
}
} else {
$errors = $result['errors'];
}
} }
} }
+24 -19
View File
@@ -2,6 +2,7 @@
require_once __DIR__ . '/functions.php'; require_once __DIR__ . '/functions.php';
require_once __DIR__ . '/app/saas-mail.php'; require_once __DIR__ . '/app/saas-mail.php';
require_once __DIR__ . '/app/rate-limit.php';
$pdo = app_db_pdo(); $pdo = app_db_pdo();
$errors = []; $errors = [];
@@ -15,28 +16,32 @@ $values = [
if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($_SERVER['REQUEST_METHOD'] === 'POST') {
app_require_csrf(); app_require_csrf();
$result = saas_register_tenant_owner($pdo, [ if (!app_rate_limit_check($pdo, 'register_ip:' . app_client_ip(), 5, 3600)) {
'tenant_name' => $values['tenant_name'], $errors = ['Zu viele Registrierungsversuche. Bitte versuche es später erneut.'];
'tenant_slug' => $values['tenant_slug'], } else {
'display_name' => $values['display_name'], $result = saas_register_tenant_owner($pdo, [
'email' => $values['email'], 'tenant_name' => $values['tenant_name'],
'password' => (string)($_POST['password'] ?? ''), 'tenant_slug' => $values['tenant_slug'],
'password_confirm' => (string)($_POST['password_confirm'] ?? ''), 'display_name' => $values['display_name'],
]); 'email' => $values['email'],
'password' => (string)($_POST['password'] ?? ''),
'password_confirm' => (string)($_POST['password_confirm'] ?? ''),
]);
if ($result['ok']) { if ($result['ok']) {
if (!empty($result['email_verification_token'])) { if (!empty($result['email_verification_token'])) {
saas_send_email_verification_mail($values['email'], (string)$result['email_verification_token']); saas_send_email_verification_mail($values['email'], (string)$result['email_verification_token']);
}
saas_session_login($result['identity']);
if (saas_should_show_auth_links() && !empty($result['email_verification_token'])) {
$_SESSION['saas_dev_email_verification_token'] = (string)$result['email_verification_token'];
}
header('Location: konto.php?registered=1');
exit;
} }
saas_session_login($result['identity']);
if (saas_should_show_auth_links() && !empty($result['email_verification_token'])) { $errors = $result['errors'];
$_SESSION['saas_dev_email_verification_token'] = (string)$result['email_verification_token'];
}
header('Location: konto.php?registered=1');
exit;
} }
$errors = $result['errors'];
} }
?> ?>