diff --git a/config/registration.php b/config/registration.php index 08a9f06b..82087799 100644 --- a/config/registration.php +++ b/config/registration.php @@ -12,6 +12,25 @@ return [ 'approver_usernames' => [], 'password_crypto_key' => getenv('REGISTRATION_PASSWORD_KEY') ?: '', 'reset_link_ttl_hours' => 8, + 'abuse_protection' => [ + 'honeypot_field' => 'website', + 'rate_limit_storage_path' => 'data/public-form-rate-limit.json', + 'rules' => [ + 'registration' => [ + 'max_attempts' => 5, + 'window_seconds' => 900, + ], + 'password_reset' => [ + 'max_attempts' => 5, + 'window_seconds' => 900, + ], + ], + 'turnstile' => [ + 'enabled' => filter_var(getenv('TURNSTILE_ENABLED') ?: 'false', FILTER_VALIDATE_BOOL), + 'site_key' => getenv('TURNSTILE_SITE_KEY') ?: '', + 'secret_key' => getenv('TURNSTILE_SECRET_KEY') ?: '', + ], + ], 'mail' => [ 'enabled' => true, 'from_email' => getenv('MAIL_FROM_EMAIL') ?: 'noreply@desktop.kusche.berlin', diff --git a/public/assets/auth/login.css b/public/assets/auth/login.css index 35111692..2a05c763 100644 --- a/public/assets/auth/login.css +++ b/public/assets/auth/login.css @@ -144,6 +144,16 @@ body { gap: 16px; } +.login-honeypot { + position: absolute; + left: -9999px; + width: 1px; + height: 1px; + overflow: hidden; + opacity: 0; + pointer-events: none; +} + .login-form-grid { display: grid; gap: 14px; @@ -175,6 +185,31 @@ body { outline: none; } +.login-password-row { + position: relative; +} + +.login-password-row input { + padding-right: 56px; +} + +.login-password-toggle { + position: absolute; + top: 50%; + right: 10px; + transform: translateY(-50%); + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border: 0; + border-radius: 999px; + background: transparent; + color: rgba(15, 23, 42, 0.56); + cursor: pointer; +} + .login-field textarea { min-height: 120px; resize: vertical; diff --git a/public/assets/auth/password-visibility.js b/public/assets/auth/password-visibility.js new file mode 100644 index 00000000..5ea1d1c4 --- /dev/null +++ b/public/assets/auth/password-visibility.js @@ -0,0 +1,18 @@ +document.addEventListener('DOMContentLoaded', () => { + const rows = document.querySelectorAll('.login-password-row'); + + rows.forEach((row) => { + const input = row.querySelector('[data-password-field]'); + const toggle = row.querySelector('[data-password-toggle]'); + + if (!(input instanceof HTMLInputElement) || !(toggle instanceof HTMLButtonElement)) { + return; + } + + toggle.addEventListener('click', () => { + const isHidden = input.type === 'password'; + input.type = isHidden ? 'text' : 'password'; + toggle.setAttribute('aria-label', isHidden ? 'Passwort verbergen' : 'Passwort anzeigen'); + }); + }); +}); diff --git a/public/auth/forgot-password/index.php b/public/auth/forgot-password/index.php index dc633f8a..207e39d1 100644 --- a/public/auth/forgot-password/index.php +++ b/public/auth/forgot-password/index.php @@ -5,23 +5,49 @@ declare(strict_types=1); require_once dirname(__DIR__, 3) . '/src/App/bootstrap.php'; use App\ConfigLoader; +use App\PublicFormProtection; use App\RegistrationService; +session_start(); + $projectRoot = dirname(__DIR__, 3); -$registration = new RegistrationService($projectRoot, ConfigLoader::load($projectRoot, 'registration')); +$registrationConfig = ConfigLoader::load($projectRoot, 'registration'); +$registration = new RegistrationService($projectRoot, $registrationConfig); +$formProtection = new PublicFormProtection($projectRoot, $registrationConfig); $message = null; $error = null; $username = ''; +$csrfToken = (string) ($_SESSION['forgot_password_form_token'] ?? ''); +$honeypotField = $formProtection->honeypotField(); + +if ($csrfToken === '') { + $csrfToken = bin2hex(random_bytes(16)); + $_SESSION['forgot_password_form_token'] = $csrfToken; +} if ($_SERVER['REQUEST_METHOD'] === 'POST') { $username = trim((string) ($_POST['username'] ?? '')); - $origin = ((isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http') . '://' . ($_SERVER['HTTP_HOST'] ?? 'localhost'); - $result = $registration->sendPasswordReset($username, $origin); + $postedToken = (string) ($_POST['csrf_token'] ?? ''); - if ($result['success'] ?? false) { - $message = (string) ($result['message'] ?? ''); + if ($postedToken === '' || !hash_equals($csrfToken, $postedToken)) { + $error = 'Die Formularprüfung ist fehlgeschlagen. Bitte versuche es erneut.'; } else { - $error = (string) ($result['message'] ?? ''); + $protection = $formProtection->guard('password_reset', $_POST); + + if (!($protection['success'] ?? false)) { + $error = implode(' ', array_map('strval', (array) ($protection['errors'] ?? []))); + } else { + $origin = ((isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http') . '://' . ($_SERVER['HTTP_HOST'] ?? 'localhost'); + $result = $registration->sendPasswordReset($username, $origin); + + if ($result['success'] ?? false) { + $message = (string) ($result['message'] ?? ''); + $csrfToken = bin2hex(random_bytes(16)); + $_SESSION['forgot_password_form_token'] = $csrfToken; + } else { + $error = (string) ($result['message'] ?? ''); + } + } } } ?> @@ -38,7 +64,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
Kusche.Berlin
Gib deinen Benutzernamen an. Wenn der LDAP-Benutzer eine E-Mail-Adresse hat, senden wir einen 8 Stunden gültigen Reset-Link.
+Gib deinen Benutzernamen an. Wenn für das Konto eine E-Mail-Adresse hinterlegt ist, senden wir einen 8 Stunden gültigen Link zum Zurücksetzen.