diff --git a/app/audit.php b/app/audit.php new file mode 100644 index 0000000..ea98aa6 --- /dev/null +++ b/app/audit.php @@ -0,0 +1,53 @@ +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 + */ +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(); +} diff --git a/app/bootstrap.php b/app/bootstrap.php index 1de3a6b..2f710e5 100644 --- a/app/bootstrap.php +++ b/app/bootstrap.php @@ -27,6 +27,29 @@ function app_is_https(): bool || (($_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 { if (PHP_SAPI === 'cli' || session_status() === PHP_SESSION_ACTIVE) { @@ -102,3 +125,5 @@ function app_require_csrf(): void die('CSRF validation failed.'); } } + +app_send_security_headers(); diff --git a/app/rate-limit.php b/app/rate-limit.php new file mode 100644 index 0000000..a6a0410 --- /dev/null +++ b/app/rate-limit.php @@ -0,0 +1,34 @@ +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; +} diff --git a/csvupload.php b/csvupload.php index 5e2df43..674e195 100644 --- a/csvupload.php +++ b/csvupload.php @@ -3,6 +3,7 @@ include "functions.php"; require_once __DIR__ . "/app/ledger.php"; require_once __DIR__ . "/app/imports.php"; +require_once __DIR__ . "/app/audit.php"; app_require_csrf(); include "header.php"; include "headerline.php"; @@ -180,6 +181,7 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") { $batchId = (int)($_POST['batch_id'] ?? 0); try { $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.'; } catch (Throwable $e) { $fehler = $e->getMessage(); diff --git a/database/migrations/0010_saas_rate_limits.sql b/database/migrations/0010_saas_rate_limits.sql new file mode 100644 index 0000000..e1a6859 --- /dev/null +++ b/database/migrations/0010_saas_rate_limits.sql @@ -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; diff --git a/database/migrations/0011_saas_audit_log.sql b/database/migrations/0011_saas_audit_log.sql new file mode 100644 index 0000000..ee87303 --- /dev/null +++ b/database/migrations/0011_saas_audit_log.sql @@ -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; diff --git a/docs/m8-haertung.md b/docs/m8-haertung.md new file mode 100644 index 0000000..b26c836 --- /dev/null +++ b/docs/m8-haertung.md @@ -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. diff --git a/docs/saas-umstrukturierungsplan.md b/docs/saas-umstrukturierungsplan.md index e06ed78..617ec8d 100644 --- a/docs/saas-umstrukturierungsplan.md +++ b/docs/saas-umstrukturierungsplan.md @@ -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 | | 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 | -| 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 | ### M0: Planungs- und Sicherheitsbaseline @@ -643,16 +643,22 @@ Schritte: - Backups und Restore-Prozess definieren. - 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. - 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. - Rollenmatrix testen. Ergebnis: - SaaS ist betrieblich und datenschutzseitig belastbarer. +- Stand: gestartet. Dokumentation: `docs/m8-haertung.md`. Abhängigkeiten: diff --git a/hinweise.php b/hinweise.php index 37578ce..324513a 100644 --- a/hinweise.php +++ b/hinweise.php @@ -3,6 +3,7 @@ include "functions.php"; require_once __DIR__ . "/app/ledger.php"; require_once __DIR__ . "/app/notices.php"; +require_once __DIR__ . "/app/audit.php"; app_require_csrf(); include "header.php"; include "headerline.php"; @@ -45,10 +46,12 @@ if($hasAccess){ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $aktion = $_POST['aktion'] ?? 'speichern'; + $actorUserId = $saasUser !== null ? (int)$saasUser['user_id'] : null; + if ($aktion === 'loeschen') { $id = (int)($_POST['id'] ?? 0); - if ($id > 0) { - notices_soft_delete($pdo, $tenantId, $id); + if ($id > 0 && notices_soft_delete($pdo, $tenantId, $id)) { + app_audit_log($pdo, $tenantId, $actorUserId, 'notice.deleted', 'notice', $id); } } else { $nachricht = $_POST['nachricht'] ?? ''; @@ -57,8 +60,8 @@ if($hasAccess){ if ($dt) { $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, $createdByUserId); + notices_create($pdo, $tenantId, (string)$nachricht, $gueltig_bis_sql, $actorUserId); + app_audit_log($pdo, $tenantId, $actorUserId, 'notice.created', 'notice', null, ['valid_until' => $gueltig_bis_sql]); } } } diff --git a/jahresauswertung.php b/jahresauswertung.php index 185f118..9ce1d0d 100644 --- a/jahresauswertung.php +++ b/jahresauswertung.php @@ -3,6 +3,7 @@ include "functions.php"; require_once __DIR__ . "/app/ledger.php"; require_once __DIR__ . "/app/saas-mail.php"; +require_once __DIR__ . "/app/audit.php"; app_require_csrf(); include "header.php"; include "headerline.php"; @@ -130,6 +131,7 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") { if (!$dryRun) { $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) { if ($pdo->inTransaction()) { diff --git a/landing.php b/landing.php index c7eabc9..989fa37 100644 --- a/landing.php +++ b/landing.php @@ -1,3 +1,4 @@ + diff --git a/letzteneintraege.php b/letzteneintraege.php index fc48ca6..129d9dd 100644 --- a/letzteneintraege.php +++ b/letzteneintraege.php @@ -2,6 +2,7 @@ include "functions.php"; require_once __DIR__ . "/app/ledger.php"; +require_once __DIR__ . "/app/audit.php"; app_require_csrf(); include "header.php"; include "headerline.php"; @@ -66,6 +67,7 @@ if ($_SERVER["REQUEST_METHOD"] == "POST" && isset($_POST["aktion"]) && $_POST["a $ergebnis = loescheEinzahlung($einzahlungID, $pdo, $tenantId); if ($ergebnis === true) { + app_audit_log($pdo, $tenantId, $saasUser['user_id'] ?? null, 'payment.voided', 'kl_Einzahlungen', $einzahlungID); echo "Einzahlung erfolgreich gelöscht."; } else { echo "Fehler: $ergebnis"; @@ -157,6 +159,7 @@ if ($_SERVER["REQUEST_METHOD"] == "POST" && isset($_POST["aktion"]) && $_POST["a $ergebnis = loescheStrichEintrag($strichID, $pdo, $tenantId); if ($ergebnis === true) { + app_audit_log($pdo, $tenantId, $saasUser['user_id'] ?? null, 'consumption.voided', 'kl_Kaffeeverbrauch', $strichID); echo "Strich-Eintrag erfolgreich gelöscht."; } else { echo "Fehler: $ergebnis"; diff --git a/login.php b/login.php index 33c1f60..f6d87b3 100644 --- a/login.php +++ b/login.php @@ -1,6 +1,7 @@ diff --git a/mailversenden.php b/mailversenden.php index aa1c5b0..9228f1c 100644 --- a/mailversenden.php +++ b/mailversenden.php @@ -3,6 +3,7 @@ include "functions.php"; require_once __DIR__ . "/app/ledger.php"; require_once __DIR__ . "/app/saas-mail.php"; +require_once __DIR__ . "/app/audit.php"; app_require_csrf(); include "header.php"; include "headerline.php"; @@ -85,6 +86,10 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") { $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); diff --git a/mandant-einstellungen.php b/mandant-einstellungen.php index 724d60c..66cf671 100644 --- a/mandant-einstellungen.php +++ b/mandant-einstellungen.php @@ -1,6 +1,7 @@ Zurück + +

Protokoll

+

Die letzten Admin-Aktionen für diesen Mandanten.

+ + + + + + + + + + + + + +
DatumWerAktionBetrifft
Noch keine protokollierten Aktionen.
diff --git a/mitarbeiterverwalten.php b/mitarbeiterverwalten.php index 0476d1c..e7d0ecd 100644 --- a/mitarbeiterverwalten.php +++ b/mitarbeiterverwalten.php @@ -4,6 +4,7 @@ include "functions.php"; require_once __DIR__ . "/app/ledger.php"; require_once __DIR__ . "/app/saas-mail.php"; +require_once __DIR__ . "/app/audit.php"; app_require_csrf(); include "header.php"; include "headerline.php"; @@ -43,6 +44,7 @@ if($hasAccess){ $fehler = null; $einladungslink = null; $bearbeitenId = null; + $actorUserId = $saasUser['user_id'] ?? null; if ($_SERVER["REQUEST_METHOD"] == "POST") { $aktion = $_POST["aktion"] ?? ''; @@ -59,7 +61,8 @@ if($hasAccess){ $fehler = 'Bitte einen Namen und eine gültige E-Mail-Adresse angeben.'; } else { 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.'; } catch (Throwable $e) { $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)) { $fehler = 'Bitte einen Namen und eine gültige E-Mail-Adresse angeben.'; } 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.'; } else { $fehler = 'Das Mitglied konnte nicht gespeichert werden.'; @@ -82,6 +86,7 @@ if($hasAccess){ } elseif ($aktion === 'aktivieren' || $aktion === 'deaktivieren') { $participantId = (int)$_POST["mitgliedID"]; 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.'; } else { $fehler = 'Der Status konnte nicht geändert werden.'; @@ -99,6 +104,7 @@ if($hasAccess){ $rolle, $ergebnis['token'] ); + app_audit_log($pdo, $tenantId, $actorUserId, 'participant.access_granted', 'participant', $participantId, ['role' => $rolle]); $meldung = 'Zugang wurde gewährt, Einladung wurde verschickt.'; if (saas_should_show_auth_links()) { $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); if ($ergebnis['ok']) { + app_audit_log($pdo, $tenantId, $actorUserId, 'participant.access_revoked', 'participant', $participantId); $meldung = 'Zugang wurde entzogen.'; } else { $fehler = implode(' ', $ergebnis['errors']); diff --git a/passwort-vergessen.php b/passwort-vergessen.php index bb814fd..de6d66a 100644 --- a/passwort-vergessen.php +++ b/passwort-vergessen.php @@ -2,6 +2,7 @@ require_once __DIR__ . '/functions.php'; require_once __DIR__ . '/app/saas-mail.php'; +require_once __DIR__ . '/app/rate-limit.php'; $pdo = app_db_pdo(); $email = trim((string)($_POST['email'] ?? '')); @@ -12,18 +13,28 @@ $resetLink = null; if ($_SERVER['REQUEST_METHOD'] === 'POST') { app_require_csrf(); - $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']); - } + $emailBucketOk = app_rate_limit_check($pdo, 'pwreset_email:' . saas_email_norm($email), 5, 3600); + $ipBucketOk = app_rate_limit_check($pdo, 'pwreset_ip:' . app_client_ip(), 10, 3600); + + if (!$emailBucketOk || !$ipBucketOk) { + // Gleiche generische Meldung wie im Erfolgsfall, damit ein + // ausgereiztes Limit nicht verrät, ob das Konto existiert. + $message = 'Wenn ein passendes Konto existiert, wurde ein Link vorbereitet.'; } 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']; + } } } diff --git a/register.php b/register.php index 2b953e9..b561f21 100644 --- a/register.php +++ b/register.php @@ -2,6 +2,7 @@ require_once __DIR__ . '/functions.php'; require_once __DIR__ . '/app/saas-mail.php'; +require_once __DIR__ . '/app/rate-limit.php'; $pdo = app_db_pdo(); $errors = []; @@ -15,28 +16,32 @@ $values = [ if ($_SERVER['REQUEST_METHOD'] === 'POST') { app_require_csrf(); - $result = saas_register_tenant_owner($pdo, [ - 'tenant_name' => $values['tenant_name'], - 'tenant_slug' => $values['tenant_slug'], - 'display_name' => $values['display_name'], - 'email' => $values['email'], - 'password' => (string)($_POST['password'] ?? ''), - 'password_confirm' => (string)($_POST['password_confirm'] ?? ''), - ]); + if (!app_rate_limit_check($pdo, 'register_ip:' . app_client_ip(), 5, 3600)) { + $errors = ['Zu viele Registrierungsversuche. Bitte versuche es später erneut.']; + } else { + $result = saas_register_tenant_owner($pdo, [ + 'tenant_name' => $values['tenant_name'], + 'tenant_slug' => $values['tenant_slug'], + 'display_name' => $values['display_name'], + 'email' => $values['email'], + 'password' => (string)($_POST['password'] ?? ''), + 'password_confirm' => (string)($_POST['password_confirm'] ?? ''), + ]); - if ($result['ok']) { - if (!empty($result['email_verification_token'])) { - saas_send_email_verification_mail($values['email'], (string)$result['email_verification_token']); + if ($result['ok']) { + if (!empty($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'])) { - $_SESSION['saas_dev_email_verification_token'] = (string)$result['email_verification_token']; - } - header('Location: konto.php?registered=1'); - exit; + + $errors = $result['errors']; } - - $errors = $result['errors']; } ?>