adasd
This commit is contained in:
@@ -12,6 +12,25 @@ return [
|
|||||||
'approver_usernames' => [],
|
'approver_usernames' => [],
|
||||||
'password_crypto_key' => getenv('REGISTRATION_PASSWORD_KEY') ?: '',
|
'password_crypto_key' => getenv('REGISTRATION_PASSWORD_KEY') ?: '',
|
||||||
'reset_link_ttl_hours' => 8,
|
'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' => [
|
'mail' => [
|
||||||
'enabled' => true,
|
'enabled' => true,
|
||||||
'from_email' => getenv('MAIL_FROM_EMAIL') ?: 'noreply@desktop.kusche.berlin',
|
'from_email' => getenv('MAIL_FROM_EMAIL') ?: 'noreply@desktop.kusche.berlin',
|
||||||
|
|||||||
@@ -144,6 +144,16 @@ body {
|
|||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.login-honeypot {
|
||||||
|
position: absolute;
|
||||||
|
left: -9999px;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.login-form-grid {
|
.login-form-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
@@ -175,6 +185,31 @@ body {
|
|||||||
outline: none;
|
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 {
|
.login-field textarea {
|
||||||
min-height: 120px;
|
min-height: 120px;
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
|
|||||||
18
public/assets/auth/password-visibility.js
Normal file
18
public/assets/auth/password-visibility.js
Normal file
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,25 +5,51 @@ declare(strict_types=1);
|
|||||||
require_once dirname(__DIR__, 3) . '/src/App/bootstrap.php';
|
require_once dirname(__DIR__, 3) . '/src/App/bootstrap.php';
|
||||||
|
|
||||||
use App\ConfigLoader;
|
use App\ConfigLoader;
|
||||||
|
use App\PublicFormProtection;
|
||||||
use App\RegistrationService;
|
use App\RegistrationService;
|
||||||
|
|
||||||
|
session_start();
|
||||||
|
|
||||||
$projectRoot = dirname(__DIR__, 3);
|
$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;
|
$message = null;
|
||||||
$error = null;
|
$error = null;
|
||||||
$username = '';
|
$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') {
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
$username = trim((string) ($_POST['username'] ?? ''));
|
$username = trim((string) ($_POST['username'] ?? ''));
|
||||||
|
$postedToken = (string) ($_POST['csrf_token'] ?? '');
|
||||||
|
|
||||||
|
if ($postedToken === '' || !hash_equals($csrfToken, $postedToken)) {
|
||||||
|
$error = 'Die Formularprüfung ist fehlgeschlagen. Bitte versuche es erneut.';
|
||||||
|
} else {
|
||||||
|
$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');
|
$origin = ((isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http') . '://' . ($_SERVER['HTTP_HOST'] ?? 'localhost');
|
||||||
$result = $registration->sendPasswordReset($username, $origin);
|
$result = $registration->sendPasswordReset($username, $origin);
|
||||||
|
|
||||||
if ($result['success'] ?? false) {
|
if ($result['success'] ?? false) {
|
||||||
$message = (string) ($result['message'] ?? '');
|
$message = (string) ($result['message'] ?? '');
|
||||||
|
$csrfToken = bin2hex(random_bytes(16));
|
||||||
|
$_SESSION['forgot_password_form_token'] = $csrfToken;
|
||||||
} else {
|
} else {
|
||||||
$error = (string) ($result['message'] ?? '');
|
$error = (string) ($result['message'] ?? '');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
?><!DOCTYPE html>
|
?><!DOCTYPE html>
|
||||||
<html lang="de">
|
<html lang="de">
|
||||||
<head>
|
<head>
|
||||||
@@ -38,7 +64,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
<div class="login-panel-top">
|
<div class="login-panel-top">
|
||||||
<p class="login-kicker">Kusche.Berlin</p>
|
<p class="login-kicker">Kusche.Berlin</p>
|
||||||
<h1>Passwort vergessen</h1>
|
<h1>Passwort vergessen</h1>
|
||||||
<p class="login-copy">Gib deinen Benutzernamen an. Wenn der LDAP-Benutzer eine E-Mail-Adresse hat, senden wir einen 8 Stunden gültigen Reset-Link.</p>
|
<p class="login-copy">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.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php if ($message !== null): ?>
|
<?php if ($message !== null): ?>
|
||||||
@@ -50,6 +76,14 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<form class="login-form" method="post" action="/auth/forgot-password/">
|
<form class="login-form" method="post" action="/auth/forgot-password/">
|
||||||
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrfToken, ENT_QUOTES) ?>">
|
||||||
|
<div class="login-honeypot" aria-hidden="true">
|
||||||
|
<label>
|
||||||
|
<span>Website</span>
|
||||||
|
<input type="text" name="<?= htmlspecialchars($honeypotField, ENT_QUOTES) ?>" value="" tabindex="-1" autocomplete="off">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<label class="login-field">
|
<label class="login-field">
|
||||||
<span>Benutzername</span>
|
<span>Benutzername</span>
|
||||||
<input type="text" name="username" value="<?= htmlspecialchars($username, ENT_QUOTES) ?>" required>
|
<input type="text" name="username" value="<?= htmlspecialchars($username, ENT_QUOTES) ?>" required>
|
||||||
|
|||||||
@@ -5,12 +5,15 @@ declare(strict_types=1);
|
|||||||
require_once dirname(__DIR__, 3) . '/src/App/bootstrap.php';
|
require_once dirname(__DIR__, 3) . '/src/App/bootstrap.php';
|
||||||
|
|
||||||
use App\ConfigLoader;
|
use App\ConfigLoader;
|
||||||
|
use App\PublicFormProtection;
|
||||||
use App\RegistrationService;
|
use App\RegistrationService;
|
||||||
|
|
||||||
session_start();
|
session_start();
|
||||||
|
|
||||||
$projectRoot = dirname(__DIR__, 3);
|
$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);
|
||||||
|
|
||||||
if (!$registration->isEnabled()) {
|
if (!$registration->isEnabled()) {
|
||||||
http_response_code(404);
|
http_response_code(404);
|
||||||
@@ -26,11 +29,13 @@ if ($csrfToken === '') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$errors = [];
|
$errors = [];
|
||||||
|
$honeypotField = $formProtection->honeypotField();
|
||||||
$form = [
|
$form = [
|
||||||
'username' => '',
|
'username' => '',
|
||||||
'given_name' => '',
|
'given_name' => '',
|
||||||
'family_name' => '',
|
'family_name' => '',
|
||||||
'email' => '',
|
'email' => '',
|
||||||
|
'note' => '',
|
||||||
'password' => '',
|
'password' => '',
|
||||||
'password_confirm' => '',
|
'password_confirm' => '',
|
||||||
];
|
];
|
||||||
@@ -41,6 +46,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
'given_name' => trim((string) ($_POST['given_name'] ?? '')),
|
'given_name' => trim((string) ($_POST['given_name'] ?? '')),
|
||||||
'family_name' => trim((string) ($_POST['family_name'] ?? '')),
|
'family_name' => trim((string) ($_POST['family_name'] ?? '')),
|
||||||
'email' => trim((string) ($_POST['email'] ?? '')),
|
'email' => trim((string) ($_POST['email'] ?? '')),
|
||||||
|
'note' => trim((string) ($_POST['note'] ?? '')),
|
||||||
'password' => (string) ($_POST['password'] ?? ''),
|
'password' => (string) ($_POST['password'] ?? ''),
|
||||||
'password_confirm' => (string) ($_POST['password_confirm'] ?? ''),
|
'password_confirm' => (string) ($_POST['password_confirm'] ?? ''),
|
||||||
];
|
];
|
||||||
@@ -49,6 +55,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
|
|
||||||
if ($postedToken === '' || !hash_equals($csrfToken, $postedToken)) {
|
if ($postedToken === '' || !hash_equals($csrfToken, $postedToken)) {
|
||||||
$errors[] = 'Die Formularprüfung ist fehlgeschlagen. Bitte versuche es erneut.';
|
$errors[] = 'Die Formularprüfung ist fehlgeschlagen. Bitte versuche es erneut.';
|
||||||
|
} else {
|
||||||
|
$protection = $formProtection->guard('registration', $_POST);
|
||||||
|
|
||||||
|
if (!($protection['success'] ?? false)) {
|
||||||
|
$errors = array_values(array_map('strval', (array) ($protection['errors'] ?? [])));
|
||||||
} else {
|
} else {
|
||||||
$result = $registration->submit($form);
|
$result = $registration->submit($form);
|
||||||
|
|
||||||
@@ -68,6 +79,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
?><!DOCTYPE html>
|
?><!DOCTYPE html>
|
||||||
<html lang="de">
|
<html lang="de">
|
||||||
<head>
|
<head>
|
||||||
@@ -82,7 +94,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
<div class="login-panel-top">
|
<div class="login-panel-top">
|
||||||
<p class="login-kicker">Kusche.Berlin</p>
|
<p class="login-kicker">Kusche.Berlin</p>
|
||||||
<h1>Registrierung anfragen</h1>
|
<h1>Registrierung anfragen</h1>
|
||||||
<p class="login-copy">Neue Konten werden nicht sofort freigeschaltet. Deine Anfrage wird geprüft und erst danach für LDAP, NAS und Desktop vorbereitet.</p>
|
<p class="login-copy">Erstelle deinen Zugang für den persönlichen Desktop-Bereich von Kusche.Berlin. Nach dem Absenden kannst du alle weiteren Schritte direkt von hier aus verfolgen.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php if ($errors !== []): ?>
|
<?php if ($errors !== []): ?>
|
||||||
@@ -98,6 +110,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
|
|
||||||
<form class="login-form" method="post" action="/auth/register/">
|
<form class="login-form" method="post" action="/auth/register/">
|
||||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrfToken, ENT_QUOTES) ?>">
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrfToken, ENT_QUOTES) ?>">
|
||||||
|
<div class="login-honeypot" aria-hidden="true">
|
||||||
|
<label>
|
||||||
|
<span>Website</span>
|
||||||
|
<input type="text" name="<?= htmlspecialchars($honeypotField, ENT_QUOTES) ?>" value="" tabindex="-1" autocomplete="off">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="login-form-grid">
|
<div class="login-form-grid">
|
||||||
<label class="login-field">
|
<label class="login-field">
|
||||||
@@ -124,15 +142,30 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
<div class="login-form-grid">
|
<div class="login-form-grid">
|
||||||
<label class="login-field">
|
<label class="login-field">
|
||||||
<span>Passwort</span>
|
<span>Passwort</span>
|
||||||
<input type="password" name="password" value="" required>
|
<div class="login-password-row">
|
||||||
|
<input type="password" name="password" value="" required data-password-field>
|
||||||
|
<button class="login-password-toggle" type="button" data-password-toggle aria-label="Passwort anzeigen">
|
||||||
|
<span aria-hidden="true">◉</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="login-field">
|
<label class="login-field">
|
||||||
<span>Passwort wiederholen</span>
|
<span>Passwort wiederholen</span>
|
||||||
<input type="password" name="password_confirm" value="" required>
|
<div class="login-password-row">
|
||||||
|
<input type="password" name="password_confirm" value="" required data-password-field>
|
||||||
|
<button class="login-password-toggle" type="button" data-password-toggle aria-label="Passwort anzeigen">
|
||||||
|
<span aria-hidden="true">◉</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<label class="login-field">
|
||||||
|
<span>Hinweise für Freischaltung</span>
|
||||||
|
<textarea name="note" rows="4" placeholder="Optional: Zusätzliche Hinweise zu dir oder deinem Zugangswunsch."><?= htmlspecialchars($form['note'], ENT_QUOTES) ?></textarea>
|
||||||
|
</label>
|
||||||
|
|
||||||
<div class="login-actions">
|
<div class="login-actions">
|
||||||
<button class="login-button login-button-primary" type="submit">Registrierung absenden</button>
|
<button class="login-button login-button-primary" type="submit">Registrierung absenden</button>
|
||||||
<a class="login-button login-button-secondary" href="/auth/keycloak">Zurück zum Login</a>
|
<a class="login-button login-button-secondary" href="/auth/keycloak">Zurück zum Login</a>
|
||||||
@@ -140,5 +173,6 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
<script src="/assets/auth/password-visibility.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
108
src/App/PublicFormProtection.php
Normal file
108
src/App/PublicFormProtection.php
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App;
|
||||||
|
|
||||||
|
final class PublicFormProtection
|
||||||
|
{
|
||||||
|
private RateLimiter $rateLimiter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $config
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
private readonly string $projectRoot,
|
||||||
|
private readonly array $config,
|
||||||
|
) {
|
||||||
|
$rateLimitPath = $this->resolveProjectPath((string) (($this->config['abuse_protection']['rate_limit_storage_path'] ?? 'data/public-form-rate-limit.json')));
|
||||||
|
$this->rateLimiter = new RateLimiter($rateLimitPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $input
|
||||||
|
* @return array{success: bool, errors: array<int, string>}
|
||||||
|
*/
|
||||||
|
public function guard(string $scope, array $input): array
|
||||||
|
{
|
||||||
|
$errors = [];
|
||||||
|
$honeypotField = (string) ($this->config['abuse_protection']['honeypot_field'] ?? 'company');
|
||||||
|
|
||||||
|
if (trim((string) ($input[$honeypotField] ?? '')) !== '') {
|
||||||
|
$errors[] = 'Die Anfrage konnte nicht verarbeitet werden.';
|
||||||
|
}
|
||||||
|
|
||||||
|
$rules = is_array($this->config['abuse_protection']['rules'] ?? null) ? $this->config['abuse_protection']['rules'] : [];
|
||||||
|
$scopeRule = is_array($rules[$scope] ?? null) ? $rules[$scope] : [];
|
||||||
|
$maxAttempts = (int) ($scopeRule['max_attempts'] ?? 5);
|
||||||
|
$windowSeconds = (int) ($scopeRule['window_seconds'] ?? 900);
|
||||||
|
|
||||||
|
$keyParts = [
|
||||||
|
$scope,
|
||||||
|
$this->clientIp(),
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($scope === 'password_reset') {
|
||||||
|
$keyParts[] = strtolower(trim((string) ($input['username'] ?? '')));
|
||||||
|
}
|
||||||
|
|
||||||
|
$attempt = $this->rateLimiter->attempt($scope, implode('|', $keyParts), $maxAttempts, $windowSeconds);
|
||||||
|
|
||||||
|
if (!($attempt['allowed'] ?? false)) {
|
||||||
|
$retryAfter = (int) ($attempt['retry_after'] ?? 0);
|
||||||
|
$minutes = max(1, (int) ceil($retryAfter / 60));
|
||||||
|
$errors[] = 'Zu viele Versuche. Bitte warte etwa ' . $minutes . ' Minute' . ($minutes === 1 ? '' : 'n') . ' und versuche es erneut.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => $errors === [],
|
||||||
|
'errors' => $errors,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function honeypotField(): string
|
||||||
|
{
|
||||||
|
return (string) ($this->config['abuse_protection']['honeypot_field'] ?? 'company');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function clientIp(): string
|
||||||
|
{
|
||||||
|
$headers = [
|
||||||
|
'HTTP_CF_CONNECTING_IP',
|
||||||
|
'HTTP_X_FORWARDED_FOR',
|
||||||
|
'REMOTE_ADDR',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($headers as $header) {
|
||||||
|
$raw = trim((string) ($_SERVER[$header] ?? ''));
|
||||||
|
|
||||||
|
if ($raw === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($header === 'HTTP_X_FORWARDED_FOR') {
|
||||||
|
$parts = array_map('trim', explode(',', $raw));
|
||||||
|
$raw = (string) ($parts[0] ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($raw !== '') {
|
||||||
|
return $raw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveProjectPath(string $path): string
|
||||||
|
{
|
||||||
|
if ($path === '') {
|
||||||
|
return $this->projectRoot . '/data/public-form-rate-limit.json';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_starts_with($path, '/')) {
|
||||||
|
return $path;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->projectRoot . '/' . ltrim($path, '/');
|
||||||
|
}
|
||||||
|
}
|
||||||
55
src/App/RateLimiter.php
Normal file
55
src/App/RateLimiter.php
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App;
|
||||||
|
|
||||||
|
final class RateLimiter
|
||||||
|
{
|
||||||
|
private JsonStore $store;
|
||||||
|
|
||||||
|
public function __construct(string $storagePath)
|
||||||
|
{
|
||||||
|
$this->store = new JsonStore($storagePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{allowed: bool, retry_after: int}
|
||||||
|
*/
|
||||||
|
public function attempt(string $scope, string $key, int $maxAttempts, int $windowSeconds): array
|
||||||
|
{
|
||||||
|
$maxAttempts = max(1, $maxAttempts);
|
||||||
|
$windowSeconds = max(60, $windowSeconds);
|
||||||
|
$now = time();
|
||||||
|
$bucketKey = $scope . ':' . hash('sha256', $key);
|
||||||
|
$payload = $this->store->read();
|
||||||
|
$bucket = $payload[$bucketKey] ?? [
|
||||||
|
'attempts' => [],
|
||||||
|
];
|
||||||
|
|
||||||
|
$attempts = array_values(array_filter(
|
||||||
|
is_array($bucket['attempts'] ?? null) ? $bucket['attempts'] : [],
|
||||||
|
static fn (mixed $timestamp): bool => is_int($timestamp) && $timestamp > ($now - $windowSeconds)
|
||||||
|
));
|
||||||
|
|
||||||
|
if (count($attempts) >= $maxAttempts) {
|
||||||
|
$retryAfter = max(1, $windowSeconds - ($now - min($attempts)));
|
||||||
|
$payload[$bucketKey] = ['attempts' => $attempts];
|
||||||
|
$this->store->write($payload);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'allowed' => false,
|
||||||
|
'retry_after' => $retryAfter,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$attempts[] = $now;
|
||||||
|
$payload[$bucketKey] = ['attempts' => $attempts];
|
||||||
|
$this->store->write($payload);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'allowed' => true,
|
||||||
|
'retry_after' => 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -92,21 +92,21 @@ final class RegistrationService
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!$this->secretBox->isConfigured()) {
|
if (!$this->secretBox->isConfigured()) {
|
||||||
$errors[] = 'Die Registrierungsverschlüsselung ist noch nicht konfiguriert.';
|
$errors[] = 'Die Registrierung ist im Moment nicht verfügbar.';
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($note !== '' && mb_strlen($note) > 2000) {
|
if ($note !== '' && mb_strlen($note) > 2000) {
|
||||||
$errors[] = 'Die Zusatzinfo ist zu lang.';
|
$errors[] = 'Die Hinweise für die Freischaltung sind zu lang.';
|
||||||
}
|
}
|
||||||
|
|
||||||
$conflict = $this->store->findConflict($username, $email);
|
$conflict = $this->store->findConflict($username, $email);
|
||||||
|
|
||||||
if ($conflict !== null) {
|
if ($conflict !== null) {
|
||||||
$errors[] = 'Für diesen Benutzernamen oder diese E-Mail existiert bereits eine offene oder freigegebene Registrierung.';
|
$errors[] = 'Für diesen Benutzernamen oder diese E-Mail-Adresse existiert bereits eine Registrierung.';
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->ldapProvisioner->usernameExists($username)) {
|
if ($this->ldapProvisioner->usernameExists($username)) {
|
||||||
$errors[] = 'Dieser Benutzername ist bereits im LDAP vergeben.';
|
$errors[] = 'Dieser Benutzername ist leider bereits vergeben.';
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($errors !== []) {
|
if ($errors !== []) {
|
||||||
@@ -170,7 +170,7 @@ final class RegistrationService
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'errors' => ['LDAP-Provisionierung bei Registrierung fehlgeschlagen: ' . $exception->getMessage()],
|
'errors' => ['Die Registrierung konnte im Moment nicht gespeichert werden. Bitte versuche es später erneut.'],
|
||||||
'request' => $this->store->create($request),
|
'request' => $this->store->create($request),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user