diff --git a/.env.example b/.env.example index 00e2d7f..1f46c90 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,8 @@ APP_DEBUG=0 APP_URL=http://localhost:8080 APP_TIMEZONE=Europe/Berlin APP_KEY= +ALLOW_PUBLIC_REGISTRATION=0 +RFID_INGEST_ENABLED=0 DB_HOST=127.0.0.1 DB_PORT=3306 @@ -12,4 +14,21 @@ DB_USER=root DB_PASS= MAIL_FROM=noreply@example.com +MAIL_REPLY_TO= +MAIL_SUBJECT_PREFIX="[Kaffeekasse] " +MAIL_LOG_FALLBACK=1 +MAIL_LOG_FILE= + +PASSWORD_RESET_URL=http://localhost:8080/reset-password?token={{token}} +PASSWORD_RESET_TTL_MINUTES=60 +PASSWORD_RESET_MAX_ACTIVE_TOKENS=3 +PASSWORD_RESET_SUBJECT="Passwort zuruecksetzen" + +RATE_LIMIT_LOGIN_MAX_ATTEMPTS=5 +RATE_LIMIT_LOGIN_WINDOW_SECONDS=900 +RATE_LIMIT_LOGIN_BLOCK_SECONDS=900 +RATE_LIMIT_REGISTRATION_MAX_ATTEMPTS=3 +RATE_LIMIT_REGISTRATION_WINDOW_SECONDS=3600 +RATE_LIMIT_REGISTRATION_BLOCK_SECONDS=3600 + RFID_SHARED_SECRET=change-me diff --git a/app/Controllers/PlatformController.php b/app/Controllers/PlatformController.php index 34ec734..d4549e0 100644 --- a/app/Controllers/PlatformController.php +++ b/app/Controllers/PlatformController.php @@ -7,8 +7,12 @@ use App\Core\Csrf; use App\Core\Database; use App\Core\Response; use App\Core\Session; +use App\Core\TenantContext; use App\Core\View; use App\Services\AuditService; +use App\Services\MailerService; +use App\Services\PasswordResetService; +use App\Services\RateLimiterService; use App\Services\SetupService; use App\Services\TenantRegistrationService; use RuntimeException; @@ -47,6 +51,7 @@ final class PlatformController 'title' => 'Kaffeekasse fuer Teams, Vereine und Coworking', 'setupComplete' => $setup->isInstalled(), 'stats' => $stats, + 'allowPublicRegistration' => $this->config['allow_public_registration'] ?? false, ]); } @@ -86,6 +91,11 @@ final class PlatformController Response::redirect('/install'); } + if (!$this->allowsPublicRegistration()) { + $this->session->flash('error', 'Die offene Registrierung ist derzeit deaktiviert. Fuer einen Pilotzugang bitte direkt den Betreiber kontaktieren.'); + Response::redirect('/'); + } + $this->view->render('home/register', [ 'title' => 'Mandant registrieren', ]); @@ -96,13 +106,31 @@ final class PlatformController $this->assertCsrf(); try { + if (!$this->allowsPublicRegistration()) { + throw new RuntimeException('Die offene Registrierung ist derzeit deaktiviert.'); + } + $pdo = $this->database->pdo(); + $rateLimiter = $this->rateLimiter($pdo); + $email = mb_strtolower(trim((string) ($_POST['owner_email'] ?? ''))); + $scopes = $this->buildScopes('registration', $email); + + $this->ensureAllowed($rateLimiter, 'registration', $scopes); + $audit = new AuditService($pdo); $service = new TenantRegistrationService($pdo, $audit); $slug = $service->register($_POST); + $this->clearScopes($rateLimiter, 'registration', $scopes); $this->session->flash('success', 'Dein Mandant wurde angelegt. Du kannst dich jetzt anmelden.'); Response::redirect('/t/' . rawurlencode($slug) . '/login'); } catch (\Throwable $throwable) { + if ($this->database->isConfigured()) { + try { + $this->registerFailure('registration', mb_strtolower(trim((string) ($_POST['owner_email'] ?? '')))); + } catch (\Throwable) { + } + } + remember_old_input($_POST); $this->session->flash('error', $throwable->getMessage()); Response::redirect('/register'); @@ -122,17 +150,25 @@ final class PlatformController try { $pdo = $this->database->pdo(); + $email = mb_strtolower(trim((string) ($_POST['email'] ?? ''))); + $rateLimiter = $this->rateLimiter($pdo); + $scopes = $this->buildScopes('login', 'platform|' . $email); + + $this->ensureAllowed($rateLimiter, 'login', $scopes); + $auth = new Auth($pdo, $this->session); $audit = new AuditService($pdo); $success = $auth->attemptPlatformLogin((string) ($_POST['email'] ?? ''), (string) ($_POST['password'] ?? '')); $audit->log(null, null, 'platform.login', 'user', null, $success ? 'success' : 'failure', [ - 'email' => mb_strtolower(trim((string) ($_POST['email'] ?? ''))), + 'email' => $email, ]); if (!$success) { + $this->hitScopes($rateLimiter, 'login', $scopes); throw new RuntimeException('Die Zugangsdaten konnten nicht verifiziert werden.'); } + $this->clearScopes($rateLimiter, 'login', $scopes); $this->session->flash('success', 'Willkommen im Plattformbereich.'); Response::redirect('/admin'); } catch (\Throwable $throwable) { @@ -142,6 +178,108 @@ final class PlatformController } } + public function passwordResetRequestForm(): void + { + $scope = $this->normalizePasswordResetScope((string) ($_GET['scope'] ?? 'platform')); + $tenantSlug = trim((string) ($_GET['tenant'] ?? '')); + $tenant = $this->passwordResetTenant($scope, $tenantSlug); + + $this->view->render('auth/reset-request', [ + 'title' => 'Passwort vergessen', + 'scope' => $scope, + 'tenantSlug' => $tenantSlug, + 'tenant' => $tenant, + ]); + } + + public function passwordResetRequestSubmit(): void + { + $this->assertCsrf(); + + try { + $pdo = $this->database->pdo(); + $scope = $this->normalizePasswordResetScope((string) ($_POST['scope'] ?? 'platform')); + $tenantSlug = trim((string) ($_POST['tenant_slug'] ?? '')); + $tenant = $this->passwordResetTenant($scope, $tenantSlug); + $service = new PasswordResetService( + $pdo, + new MailerService($this->config['mail'] ?? []), + $this->config['password_reset'] ?? [] + ); + $service->requestReset((string) ($_POST['email'] ?? ''), $this->passwordResetContext($scope, $tenant, $tenantSlug)); + $this->session->flash('success', 'Falls ein Konto existiert, wurde ein Link zum Zuruecksetzen versendet.'); + Response::redirect($this->passwordResetRequestUrl($scope, $tenant, $tenantSlug)); + } catch (\Throwable $throwable) { + remember_old_input($_POST); + $this->session->flash('error', $throwable->getMessage()); + $scope = $this->normalizePasswordResetScope((string) ($_POST['scope'] ?? 'platform')); + $tenantSlug = trim((string) ($_POST['tenant_slug'] ?? '')); + Response::redirect($this->passwordResetRequestUrl($scope, null, $tenantSlug)); + } + } + + public function passwordResetConfirmForm(): void + { + $scope = $this->normalizePasswordResetScope((string) ($_GET['scope'] ?? 'platform')); + $tenantSlug = trim((string) ($_GET['tenant'] ?? '')); + $tenant = $this->passwordResetTenant($scope, $tenantSlug); + $token = (string) ($_GET['token'] ?? ''); + $resetToken = null; + + if ($this->database->isConfigured() && trim($token) !== '') { + try { + $resetToken = $this->passwordResetService($this->database->pdo())->findValidToken($token); + } catch (\Throwable) { + $resetToken = null; + } + } + + $this->view->render('auth/reset-confirm', [ + 'title' => 'Neues Passwort setzen', + 'scope' => $scope, + 'tenantSlug' => $tenantSlug, + 'tenant' => $tenant, + 'token' => $token, + 'resetToken' => $resetToken, + ]); + } + + public function passwordResetConfirmSubmit(): void + { + $this->assertCsrf(); + + try { + $scope = $this->normalizePasswordResetScope((string) ($_POST['scope'] ?? 'platform')); + $tenantSlug = trim((string) ($_POST['tenant_slug'] ?? '')); + $tenant = $this->passwordResetTenant($scope, $tenantSlug); + $password = (string) ($_POST['password'] ?? ''); + $passwordConfirm = (string) ($_POST['password_confirm'] ?? ''); + + if ($password !== $passwordConfirm) { + throw new RuntimeException('Die Passwort-Bestaetigung stimmt nicht ueberein.'); + } + + $pdo = $this->database->pdo(); + $service = $this->passwordResetService($pdo); + $userId = $service->resetPassword( + (string) ($_POST['token'] ?? ''), + $password + ); + + (new AuditService($pdo))->log(null, $userId, 'password.reset', 'user', $userId, 'success'); + + $this->session->flash('success', 'Das Passwort wurde aktualisiert. Du kannst dich jetzt anmelden.'); + Response::redirect($this->passwordResetLoginUrl($scope, $tenant, $tenantSlug)); + } catch (\Throwable $throwable) { + remember_old_input($_POST); + $this->session->flash('error', $throwable->getMessage()); + $token = trim((string) ($_POST['token'] ?? '')); + $scope = $this->normalizePasswordResetScope((string) ($_POST['scope'] ?? 'platform')); + $tenantSlug = trim((string) ($_POST['tenant_slug'] ?? '')); + Response::redirect($this->passwordResetConfirmUrl($token, $scope, null, $tenantSlug)); + } + } + public function adminDashboard(): void { $pdo = $this->database->pdo(); @@ -186,4 +324,135 @@ final class PlatformController throw new RuntimeException('Die Anfrage konnte nicht bestaetigt werden. Bitte erneut versuchen.'); } } + + private function allowsPublicRegistration(): bool + { + return (bool) ($this->config['allow_public_registration'] ?? false); + } + + private function rateLimiter(\PDO $pdo): RateLimiterService + { + return new RateLimiterService($pdo, $this->config['rate_limits'] ?? []); + } + + private function passwordResetService(\PDO $pdo): PasswordResetService + { + return new PasswordResetService( + $pdo, + new MailerService($this->config['mail'] ?? []), + $this->config['password_reset'] ?? [] + ); + } + + private function buildScopes(string $action, string $identifier = ''): array + { + $ip = trim((string) ($_SERVER['REMOTE_ADDR'] ?? 'unknown')); + $scopes = [$action . '|ip|' . $ip]; + + if (trim($identifier) !== '') { + $scopes[] = $action . '|subject|' . mb_strtolower(trim($identifier)); + } + + return $scopes; + } + + private function ensureAllowed(RateLimiterService $rateLimiter, string $action, array $scopes): void + { + foreach ($scopes as $scope) { + $state = $rateLimiter->inspect($action, $scope); + + if (!$state['allowed']) { + throw new RuntimeException( + 'Zu viele Versuche. Bitte in etwa ' . (int) $state['retry_after_seconds'] . ' Sekunden erneut versuchen.' + ); + } + } + } + + private function hitScopes(RateLimiterService $rateLimiter, string $action, array $scopes): void + { + foreach ($scopes as $scope) { + $rateLimiter->hit($action, $scope); + } + } + + private function clearScopes(RateLimiterService $rateLimiter, string $action, array $scopes): void + { + foreach ($scopes as $scope) { + $rateLimiter->clear($action, $scope); + } + } + + private function registerFailure(string $action, string $identifier = ''): void + { + $pdo = $this->database->pdo(); + $rateLimiter = $this->rateLimiter($pdo); + + foreach ($this->buildScopes($action, $identifier) as $scope) { + $rateLimiter->hit($action, $scope); + } + } + + private function normalizePasswordResetScope(string $scope): string + { + return trim($scope) === 'tenant' ? 'tenant' : 'platform'; + } + + private function passwordResetTenant(string $scope, string $tenantSlug): ?array + { + $tenantSlug = trim($tenantSlug); + + if ($scope !== 'tenant' || $tenantSlug === '' || !$this->database->isConfigured()) { + return null; + } + + $tenant = (new TenantContext($this->database->pdo()))->bySlug($tenantSlug); + + return is_array($tenant) ? $tenant : null; + } + + private function passwordResetContext(string $scope, ?array $tenant, string $tenantSlug): array + { + $context = ['scope' => $scope]; + $resolvedSlug = $tenant['slug'] ?? trim($tenantSlug); + + if ($scope === 'tenant' && is_string($resolvedSlug) && $resolvedSlug !== '') { + $context['tenant'] = $resolvedSlug; + } + + return $context; + } + + private function passwordResetRequestUrl(string $scope, ?array $tenant, string $tenantSlug): string + { + $query = http_build_query($this->passwordResetContext($scope, $tenant, $tenantSlug), '', '&', PHP_QUERY_RFC3986); + + return '/password/forgot' . ($query !== '' ? '?' . $query : ''); + } + + private function passwordResetConfirmUrl(string $token, string $scope, ?array $tenant, string $tenantSlug): string + { + $query = $this->passwordResetContext($scope, $tenant, $tenantSlug); + + if (trim($token) !== '') { + $query['token'] = $token; + } + + $encoded = http_build_query($query, '', '&', PHP_QUERY_RFC3986); + + return '/reset-password' . ($encoded !== '' ? '?' . $encoded : ''); + } + + private function passwordResetLoginUrl(string $scope, ?array $tenant, string $tenantSlug): string + { + if ($scope === 'tenant') { + $resolvedSlug = $tenant['slug'] ?? trim($tenantSlug); + + if (is_string($resolvedSlug) && $resolvedSlug !== '') { + return tenant_url($resolvedSlug, 'login'); + } + } + + return '/admin/login'; + } } diff --git a/app/Controllers/TenantController.php b/app/Controllers/TenantController.php index c9dfd14..c7d78b0 100644 --- a/app/Controllers/TenantController.php +++ b/app/Controllers/TenantController.php @@ -10,7 +10,9 @@ use App\Core\Session; use App\Core\TenantContext; use App\Core\View; use App\Services\AuditService; +use App\Services\ExportService; use App\Services\LedgerService; +use App\Services\RateLimiterService; use App\Services\RfidService; use RuntimeException; @@ -20,7 +22,8 @@ final class TenantController private readonly Database $database, private readonly View $view, private readonly Session $session, - private readonly Csrf $csrf + private readonly Csrf $csrf, + private readonly array $config = [] ) { } @@ -37,24 +40,43 @@ final class TenantController public function loginSubmit(string $tenantSlug): void { $this->assertCsrf(); - $tenant = $this->tenant($tenantSlug); - $pdo = $this->database->pdo(); - $auth = new Auth($pdo, $this->session); - $audit = new AuditService($pdo); - $success = $auth->attemptTenantLogin($tenantSlug, (string) ($_POST['email'] ?? ''), (string) ($_POST['password'] ?? '')); - $audit->log((int) $tenant['id'], null, 'tenant.login', 'user', null, $success ? 'success' : 'failure', [ - 'email' => mb_strtolower(trim((string) ($_POST['email'] ?? ''))), - ]); + try { + $tenant = $this->tenant($tenantSlug); + $pdo = $this->database->pdo(); + $auth = new Auth($pdo, $this->session); + $audit = new AuditService($pdo); + $email = mb_strtolower(trim((string) ($_POST['email'] ?? ''))); + $rateLimiter = $this->rateLimiter($pdo); + $scopes = $this->buildRateLimitScopes($tenantSlug, $email); - if (!$success) { + $this->ensureRateLimitAllowed($rateLimiter, $scopes); + + $success = $auth->attemptTenantLogin($tenantSlug, (string) ($_POST['email'] ?? ''), (string) ($_POST['password'] ?? '')); + $audit->log((int) $tenant['id'], null, 'tenant.login', 'user', null, $success ? 'success' : 'failure', [ + 'email' => $email, + ]); + + if (!$success) { + $this->hitRateLimitScopes($rateLimiter, $scopes); + throw new RuntimeException('Die Zugangsdaten konnten nicht verifiziert werden.'); + } + + $authState = $auth->user() ?? []; + + if (!$this->isTenantManager($authState) && !$this->selfServiceAllowed((int) $tenant['id'])) { + $auth->logout(); + throw new RuntimeException('Die digitale Selbstbuchung ist fuer diesen Mandanten derzeit deaktiviert.'); + } + + $this->clearRateLimitScopes($rateLimiter, $scopes); + $this->session->flash('success', 'Willkommen zur digitalen Kaffeeliste.'); + Response::redirect(tenant_url($tenantSlug)); + } catch (\Throwable $throwable) { remember_old_input($_POST); - $this->session->flash('error', 'Die Zugangsdaten konnten nicht verifiziert werden.'); + $this->session->flash('error', $throwable->getMessage()); Response::redirect(tenant_url($tenantSlug, 'login')); } - - $this->session->flash('success', 'Willkommen zur digitalen Kaffeeliste.'); - Response::redirect(tenant_url($tenantSlug)); } public function logout(string $tenantSlug): void @@ -71,54 +93,144 @@ final class TenantController { [$tenant, $authState] = $this->tenantAccess($tenantSlug); $pdo = $this->database->pdo(); + $isManager = $this->isTenantManager($authState); + $currentMember = $this->memberRecordForAuthState((int) $tenant['id'], $authState); - $summaryStatement = $pdo->prepare( - 'SELECT - COUNT(DISTINCT members.id) AS member_count, - COUNT(DISTINCT products.id) AS product_count, - COUNT(DISTINCT paper_sheets.id) AS paper_sheet_count, - COALESCE(SUM(CASE WHEN ledger_lines.account_code = "member_balance" THEN ledger_lines.amount_cents ELSE 0 END), 0) AS open_balance_cents - FROM tenants - LEFT JOIN members ON members.tenant_id = tenants.id AND members.status = "active" - LEFT JOIN products ON products.tenant_id = tenants.id AND products.is_active = 1 - LEFT JOIN paper_sheets ON paper_sheets.tenant_id = tenants.id - LEFT JOIN ledger_lines ON ledger_lines.tenant_id = tenants.id - WHERE tenants.id = :tenant_id - GROUP BY tenants.id' - ); - $summaryStatement->execute(['tenant_id' => $tenant['id']]); - $summary = $summaryStatement->fetch() ?: [ - 'member_count' => 0, - 'product_count' => 0, - 'paper_sheet_count' => 0, - 'open_balance_cents' => 0, - ]; + if ($isManager) { + $summaryStatement = $pdo->prepare( + 'SELECT + COUNT(DISTINCT members.id) AS member_count, + COUNT(DISTINCT products.id) AS product_count, + COUNT(DISTINCT paper_sheets.id) AS paper_sheet_count, + COALESCE(SUM(CASE WHEN ledger_lines.account_code = "member_balance" THEN ledger_lines.amount_cents ELSE 0 END), 0) AS open_balance_cents + FROM tenants + LEFT JOIN members ON members.tenant_id = tenants.id AND members.status = "active" + LEFT JOIN products ON products.tenant_id = tenants.id AND products.is_active = 1 + LEFT JOIN paper_sheets ON paper_sheets.tenant_id = tenants.id + LEFT JOIN ledger_lines ON ledger_lines.tenant_id = tenants.id + WHERE tenants.id = :tenant_id + GROUP BY tenants.id' + ); + $summaryStatement->execute(['tenant_id' => $tenant['id']]); + $summary = $summaryStatement->fetch() ?: [ + 'member_count' => 0, + 'product_count' => 0, + 'paper_sheet_count' => 0, + 'open_balance_cents' => 0, + ]; - $eventsStatement = $pdo->prepare( - 'SELECT c.*, m.display_name AS member_name, p.name AS product_name, s.name AS source_name - FROM consumption_events c - INNER JOIN members m ON m.id = c.member_id - INNER JOIN products p ON p.id = c.product_id - INNER JOIN capture_sources s ON s.id = c.source_id - WHERE c.tenant_id = :tenant_id - ORDER BY c.recorded_at DESC - LIMIT 8' - ); - $eventsStatement->execute(['tenant_id' => $tenant['id']]); + $eventsStatement = $pdo->prepare( + 'SELECT c.*, + m.display_name AS member_name, + p.name AS product_name, + s.name AS source_name, + CASE WHEN reversal.id IS NULL THEN 0 ELSE 1 END AS is_reversed + FROM consumption_events c + INNER JOIN members m ON m.id = c.member_id + INNER JOIN products p ON p.id = c.product_id + INNER JOIN capture_sources s ON s.id = c.source_id + LEFT JOIN ledger_entries le + ON le.tenant_id = c.tenant_id + AND le.reference_type = "consumption_event" + AND le.reference_id = c.id + AND le.type = "consumption" + LEFT JOIN ledger_entries reversal + ON reversal.tenant_id = le.tenant_id + AND reversal.reversal_of_entry_id = le.id + WHERE c.tenant_id = :tenant_id + ORDER BY c.recorded_at DESC + LIMIT 8' + ); + $eventsStatement->execute(['tenant_id' => $tenant['id']]); - $balancesStatement = $pdo->prepare( - 'SELECT m.id, m.display_name, COALESCE(SUM(ll.amount_cents), 0) AS balance_cents - FROM members m - LEFT JOIN ledger_lines ll - ON ll.member_id = m.id - AND ll.tenant_id = m.tenant_id - AND ll.account_code = "member_balance" - WHERE m.tenant_id = :tenant_id - GROUP BY m.id - ORDER BY balance_cents DESC, m.display_name ASC - LIMIT 8' - ); - $balancesStatement->execute(['tenant_id' => $tenant['id']]); + $balancesStatement = $pdo->prepare( + 'SELECT m.id, m.display_name, COALESCE(SUM(ll.amount_cents), 0) AS balance_cents + FROM members m + LEFT JOIN ledger_lines ll + ON ll.member_id = m.id + AND ll.tenant_id = m.tenant_id + AND ll.account_code = "member_balance" + WHERE m.tenant_id = :tenant_id + GROUP BY m.id + ORDER BY balance_cents DESC, m.display_name ASC + LIMIT 8' + ); + $balancesStatement->execute(['tenant_id' => $tenant['id']]); + } else { + if ($currentMember === null) { + throw new RuntimeException('Fuer diesen Benutzer ist kein aktives Mitglied im Mandanten hinterlegt.'); + } + + $summaryStatement = $pdo->prepare( + 'SELECT + 1 AS member_count, + (SELECT COUNT(*) FROM products WHERE tenant_id = :tenant_id_products AND is_active = 1) AS product_count, + 0 AS paper_sheet_count, + COALESCE(SUM(CASE WHEN ll.account_code = "member_balance" THEN ll.amount_cents ELSE 0 END), 0) AS open_balance_cents + FROM members m + LEFT JOIN ledger_lines ll + ON ll.member_id = m.id + AND ll.tenant_id = m.tenant_id + WHERE m.tenant_id = :tenant_id + AND m.id = :member_id + GROUP BY m.id' + ); + $summaryStatement->execute([ + 'tenant_id_products' => $tenant['id'], + 'tenant_id' => $tenant['id'], + 'member_id' => $currentMember['id'], + ]); + $summary = $summaryStatement->fetch() ?: [ + 'member_count' => 1, + 'product_count' => 0, + 'paper_sheet_count' => 0, + 'open_balance_cents' => 0, + ]; + + $eventsStatement = $pdo->prepare( + 'SELECT c.*, + m.display_name AS member_name, + p.name AS product_name, + s.name AS source_name, + CASE WHEN reversal.id IS NULL THEN 0 ELSE 1 END AS is_reversed + FROM consumption_events c + INNER JOIN members m ON m.id = c.member_id + INNER JOIN products p ON p.id = c.product_id + INNER JOIN capture_sources s ON s.id = c.source_id + LEFT JOIN ledger_entries le + ON le.tenant_id = c.tenant_id + AND le.reference_type = "consumption_event" + AND le.reference_id = c.id + AND le.type = "consumption" + LEFT JOIN ledger_entries reversal + ON reversal.tenant_id = le.tenant_id + AND reversal.reversal_of_entry_id = le.id + WHERE c.tenant_id = :tenant_id + AND c.member_id = :member_id + ORDER BY c.recorded_at DESC + LIMIT 8' + ); + $eventsStatement->execute([ + 'tenant_id' => $tenant['id'], + 'member_id' => $currentMember['id'], + ]); + + $balancesStatement = $pdo->prepare( + 'SELECT m.id, m.display_name, COALESCE(SUM(ll.amount_cents), 0) AS balance_cents + FROM members m + LEFT JOIN ledger_lines ll + ON ll.member_id = m.id + AND ll.tenant_id = m.tenant_id + AND ll.account_code = "member_balance" + WHERE m.tenant_id = :tenant_id + AND m.id = :member_id + GROUP BY m.id' + ); + $balancesStatement->execute([ + 'tenant_id' => $tenant['id'], + 'member_id' => $currentMember['id'], + ]); + } $this->view->render('tenant/dashboard', [ 'title' => 'Dashboard', @@ -127,6 +239,8 @@ final class TenantController 'summary' => $summary, 'latestEvents' => $eventsStatement->fetchAll(), 'memberBalances' => $balancesStatement->fetchAll(), + 'isManager' => $isManager, + 'currentMember' => $currentMember, ]); } @@ -148,6 +262,10 @@ final class TenantController throw new RuntimeException('Bitte einen Anzeigenamen angeben.'); } + if ($accessPin !== '' && mb_strlen($accessPin) < 4) { + throw new RuntimeException('Die PIN muss mindestens 4 Zeichen haben.'); + } + $userId = null; if ($email !== '') { @@ -159,6 +277,13 @@ final class TenantController throw new RuntimeException('Fuer Login-faehige Mitglieder ist ein Passwort mit mindestens 12 Zeichen noetig.'); } + $existingUserStatement = $pdo->prepare('SELECT id FROM users WHERE email = :email LIMIT 1'); + $existingUserStatement->execute(['email' => mb_strtolower($email)]); + + if ($existingUserStatement->fetchColumn()) { + throw new RuntimeException('Die E-Mail-Adresse ist bereits im System vorhanden.'); + } + $userInsert = $pdo->prepare( 'INSERT INTO users (full_name, email, password_hash, platform_role) VALUES (:full_name, :email, :password_hash, "user")' @@ -189,7 +314,7 @@ final class TenantController 'user_id' => $userId, 'display_name' => $displayName, 'email' => $email !== '' ? mb_strtolower($email) : null, - 'access_pin' => $accessPin !== '' ? $accessPin : null, + 'access_pin' => $accessPin !== '' ? secure_password_hash($accessPin) : null, ]); (new AuditService($pdo))->log((int) $tenant['id'], (int) $authState['user_id'], 'member.created', 'member', (int) $pdo->lastInsertId(), 'success'); @@ -291,23 +416,44 @@ final class TenantController { [$tenant, $authState] = $this->tenantAccess($tenantSlug); $pdo = $this->database->pdo(); + $isManager = $this->isTenantManager($authState); + $currentMember = $this->memberRecordForAuthState((int) $tenant['id'], $authState); if ($_SERVER['REQUEST_METHOD'] === 'POST') { $this->assertCsrf(); try { $service = new LedgerService($pdo, new AuditService($pdo)); - $service->createConsumption( - (int) $tenant['id'], - (int) ($_POST['member_id'] ?? 0), - (int) ($_POST['product_id'] ?? 0), - (string) ($_POST['source_code'] ?? 'digital_self'), - normalize_datetime_input((string) ($_POST['effective_at'] ?? '')), - (int) $authState['user_id'], - (float) ($_POST['quantity'] ?? 1), - trim((string) ($_POST['notes'] ?? '')) - ); - $this->session->flash('success', 'Die Buchung wurde gespeichert.'); + $action = (string) ($_POST['action'] ?? 'create_booking'); + + if ($action === 'reverse_booking') { + if (!$isManager) { + throw new RuntimeException('Nur Admins duerfen Buchungen stornieren.'); + } + + $service->reverseLedgerEntry( + (int) $tenant['id'], + (int) ($_POST['ledger_entry_id'] ?? 0), + (int) $authState['user_id'], + trim((string) ($_POST['reason'] ?? '')) ?: null + ); + $this->session->flash('success', 'Die Buchung wurde storniert.'); + } else { + $memberId = $this->selectedMemberIdForBooking((int) $tenant['id'], $authState, (int) ($_POST['member_id'] ?? 0), $currentMember); + $sourceCode = $isManager ? (string) ($_POST['source_code'] ?? 'digital_self') : 'digital_self'; + + $service->createConsumption( + (int) $tenant['id'], + $memberId, + (int) ($_POST['product_id'] ?? 0), + $sourceCode, + normalize_datetime_input((string) ($_POST['effective_at'] ?? '')), + (int) $authState['user_id'], + (float) ($_POST['quantity'] ?? 1), + trim((string) ($_POST['notes'] ?? '')) + ); + $this->session->flash('success', 'Die Buchung wurde gespeichert.'); + } } catch (\Throwable $throwable) { remember_old_input($_POST); $this->session->flash('error', $throwable->getMessage()); @@ -316,20 +462,46 @@ final class TenantController Response::redirect(tenant_url($tenantSlug, 'bookings')); } - $members = $this->membersForTenant((int) $tenant['id']); + $members = $isManager + ? $this->membersForTenant((int) $tenant['id']) + : ($currentMember !== null ? [['id' => $currentMember['id'], 'display_name' => $currentMember['display_name']]] : []); $products = $this->productsForTenant((int) $tenant['id']); - $bookingsStatement = $pdo->prepare( - 'SELECT c.*, m.display_name AS member_name, p.name AS product_name, s.name AS source_name - FROM consumption_events c - INNER JOIN members m ON m.id = c.member_id - INNER JOIN products p ON p.id = c.product_id - INNER JOIN capture_sources s ON s.id = c.source_id - WHERE c.tenant_id = :tenant_id - ORDER BY c.recorded_at DESC - LIMIT 30' - ); - $bookingsStatement->execute(['tenant_id' => $tenant['id']]); + $sql = 'SELECT c.*, + m.display_name AS member_name, + p.name AS product_name, + s.name AS source_name, + le.id AS ledger_entry_id, + CASE WHEN reversal.id IS NULL THEN 0 ELSE 1 END AS is_reversed + FROM consumption_events c + INNER JOIN members m ON m.id = c.member_id + INNER JOIN products p ON p.id = c.product_id + INNER JOIN capture_sources s ON s.id = c.source_id + LEFT JOIN ledger_entries le + ON le.tenant_id = c.tenant_id + AND le.reference_type = "consumption_event" + AND le.reference_id = c.id + AND le.type = "consumption" + LEFT JOIN ledger_entries reversal + ON reversal.tenant_id = le.tenant_id + AND reversal.reversal_of_entry_id = le.id + WHERE c.tenant_id = :tenant_id'; + + $params = ['tenant_id' => $tenant['id']]; + + if (!$isManager) { + if ($currentMember === null) { + throw new RuntimeException('Fuer diesen Benutzer ist kein aktives Mitglied im Mandanten hinterlegt.'); + } + + $sql .= ' AND c.member_id = :member_id'; + $params['member_id'] = $currentMember['id']; + } + + $sql .= ' ORDER BY c.recorded_at DESC LIMIT 30'; + + $bookingsStatement = $pdo->prepare($sql); + $bookingsStatement->execute($params); $this->view->render('tenant/bookings', [ 'title' => 'Digitale Buchungen', @@ -338,9 +510,80 @@ final class TenantController 'members' => $members, 'products' => $products, 'bookings' => $bookingsStatement->fetchAll(), + 'isManager' => $isManager, + 'currentMember' => $currentMember, ]); } + public function exportBalances(string $tenantSlug): void + { + [$tenant, $authState] = $this->tenantAccess($tenantSlug, ['owner', 'admin']); + + (new AuditService($this->database->pdo()))->log( + (int) $tenant['id'], + (int) $authState['user_id'], + 'export.member_balances', + 'tenant', + (int) $tenant['id'], + 'success' + ); + + (new ExportService($this->database->pdo()))->streamMemberBalancesCsv( + (int) $tenant['id'], + $tenant['slug'] . '-salden-' . gmdate('Ymd-His') . '.csv' + ); + } + + public function exportBookings(string $tenantSlug): void + { + [$tenant, $authState] = $this->tenantAccess($tenantSlug); + $isManager = $this->isTenantManager($authState); + $currentMember = $this->memberRecordForAuthState((int) $tenant['id'], $authState); + $memberId = null; + + if ($isManager) { + $requestedMemberId = (int) ($_GET['member_id'] ?? 0); + + if ($requestedMemberId > 0) { + $memberId = $this->assertMemberIdBelongsToTenant((int) $tenant['id'], $requestedMemberId); + } + } else { + if ($currentMember === null) { + throw new RuntimeException('Fuer diesen Benutzer ist kein aktives Mitglied im Mandanten hinterlegt.'); + } + + $memberId = (int) $currentMember['id']; + } + + (new AuditService($this->database->pdo()))->log( + (int) $tenant['id'], + (int) $authState['user_id'], + 'export.bookings', + 'tenant', + (int) $tenant['id'], + 'success', + [ + 'member_id' => $memberId, + 'from' => trim((string) ($_GET['from'] ?? '')) ?: null, + 'until' => trim((string) ($_GET['until'] ?? '')) ?: null, + ] + ); + + $filename = $tenant['slug'] . '-buchungen-' . gmdate('Ymd-His') . '.csv'; + + if ($memberId !== null) { + $filename = $tenant['slug'] . '-mitglied-' . $memberId . '-buchungen-' . gmdate('Ymd-His') . '.csv'; + } + + (new ExportService($this->database->pdo()))->streamBookingsCsv( + (int) $tenant['id'], + $memberId, + trim((string) ($_GET['from'] ?? '')) ?: null, + trim((string) ($_GET['until'] ?? '')) ?: null, + $filename + ); + } + public function payments(string $tenantSlug): void { [$tenant, $authState] = $this->tenantAccess($tenantSlug, ['owner', 'admin']); @@ -351,15 +594,27 @@ final class TenantController try { $service = new LedgerService($pdo, new AuditService($pdo)); - $service->createPayment( - (int) $tenant['id'], - (int) ($_POST['member_id'] ?? 0), - (int) round(((float) ($_POST['amount_eur'] ?? 0)) * 100), - normalize_datetime_input((string) ($_POST['occurred_at'] ?? '')), - (int) $authState['user_id'], - trim((string) ($_POST['notes'] ?? '')) - ); - $this->session->flash('success', 'Die Einzahlung wurde gebucht.'); + $action = (string) ($_POST['action'] ?? 'create_payment'); + + if ($action === 'reverse_payment') { + $service->reverseLedgerEntry( + (int) $tenant['id'], + (int) ($_POST['ledger_entry_id'] ?? 0), + (int) $authState['user_id'], + trim((string) ($_POST['reason'] ?? '')) ?: null + ); + $this->session->flash('success', 'Die Einzahlung wurde storniert.'); + } else { + $service->createPayment( + (int) $tenant['id'], + (int) ($_POST['member_id'] ?? 0), + (int) round(((float) ($_POST['amount_eur'] ?? 0)) * 100), + normalize_datetime_input((string) ($_POST['occurred_at'] ?? '')), + (int) $authState['user_id'], + trim((string) ($_POST['notes'] ?? '')) + ); + $this->session->flash('success', 'Die Einzahlung wurde gebucht.'); + } } catch (\Throwable $throwable) { remember_old_input($_POST); $this->session->flash('error', $throwable->getMessage()); @@ -372,10 +627,14 @@ final class TenantController $paymentsStatement = $pdo->prepare( 'SELECT le.*, m.display_name AS member_name, - ABS(COALESCE(SUM(CASE WHEN ll.account_code = "member_balance" THEN ll.amount_cents ELSE 0 END), 0)) AS amount_cents + ABS(COALESCE(SUM(CASE WHEN ll.account_code = "member_balance" THEN ll.amount_cents ELSE 0 END), 0)) AS amount_cents, + CASE WHEN reversal.id IS NULL THEN 0 ELSE 1 END AS is_reversed FROM ledger_entries le LEFT JOIN members m ON m.id = le.member_id LEFT JOIN ledger_lines ll ON ll.ledger_entry_id = le.id + LEFT JOIN ledger_entries reversal + ON reversal.tenant_id = le.tenant_id + AND reversal.reversal_of_entry_id = le.id WHERE le.tenant_id = :tenant_id AND le.type = "payment" GROUP BY le.id @@ -390,6 +649,7 @@ final class TenantController 'authState' => $authState, 'members' => $members, 'payments' => $paymentsStatement->fetchAll(), + 'isManager' => true, ]); } @@ -397,7 +657,6 @@ final class TenantController { [$tenant, $authState] = $this->tenantAccess($tenantSlug, ['owner', 'admin']); $pdo = $this->database->pdo(); - $service = new LedgerService($pdo, new AuditService($pdo)); if ($_SERVER['REQUEST_METHOD'] === 'POST') { @@ -432,6 +691,14 @@ final class TenantController } elseif ($action === 'post_sheet') { $service->postPaperSheet((int) $tenant['id'], (int) ($_POST['paper_sheet_id'] ?? 0), (int) $authState['user_id']); $this->session->flash('success', 'Die Papierliste wurde ins Ledger uebernommen.'); + } elseif ($action === 'reverse_sheet') { + $service->reversePaperSheet( + (int) $tenant['id'], + (int) ($_POST['paper_sheet_id'] ?? 0), + (int) $authState['user_id'], + trim((string) ($_POST['reason'] ?? '')) ?: null + ); + $this->session->flash('success', 'Die verbuchte Papierliste wurde storniert.'); } } catch (\Throwable $throwable) { remember_old_input($_POST); @@ -444,9 +711,21 @@ final class TenantController $members = $this->membersForTenant((int) $tenant['id']); $products = $this->productsForTenant((int) $tenant['id']); $sheetsStatement = $pdo->prepare( - 'SELECT ps.*, COUNT(psl.id) AS line_count + 'SELECT ps.*, + COUNT(psl.id) AS line_count, + COUNT(DISTINCT le.id) AS posted_entry_count, + COUNT(DISTINCT reversal.id) AS reversed_entry_count FROM paper_sheets ps LEFT JOIN paper_sheet_lines psl ON psl.paper_sheet_id = ps.id + LEFT JOIN consumption_events ce ON ce.paper_sheet_line_id = psl.id + LEFT JOIN ledger_entries le + ON le.reference_type = "consumption_event" + AND le.reference_id = ce.id + AND le.tenant_id = ps.tenant_id + AND le.type = "consumption" + LEFT JOIN ledger_entries reversal + ON reversal.reversal_of_entry_id = le.id + AND reversal.tenant_id = le.tenant_id WHERE ps.tenant_id = :tenant_id GROUP BY ps.id ORDER BY ps.created_at DESC' @@ -472,6 +751,7 @@ final class TenantController 'products' => $products, 'sheets' => $sheets, 'sheetLines' => $linesStatement->fetchAll(), + 'isManager' => true, ]); } @@ -542,11 +822,18 @@ final class TenantController 'devices' => $devices->fetchAll(), 'tags' => $tags->fetchAll(), 'events' => $events->fetchAll(), + 'isManager' => true, + 'rfidIngestEnabled' => (bool) ($this->config['rfid_ingest_enabled'] ?? false), ]); } public function rfidIngest(): void { + if (!($this->config['rfid_ingest_enabled'] ?? false)) { + Response::notFound(); + return; + } + $payload = json_decode(file_get_contents('php://input') ?: '[]', true); if (!is_array($payload)) { @@ -596,7 +883,20 @@ final class TenantController Response::redirect(tenant_url($tenantSlug)); } - return [$tenant, $auth->user()]; + $authState = $auth->user(); + + if (!is_array($authState)) { + $this->session->flash('error', 'Bitte zuerst anmelden.'); + Response::redirect(tenant_url($tenantSlug, 'login')); + } + + if (!$this->isTenantManager($authState) && !$this->selfServiceAllowed((int) $tenant['id'])) { + $auth->logout(); + $this->session->flash('error', 'Die digitale Selbstbuchung ist fuer diesen Mandanten derzeit deaktiviert.'); + Response::redirect(tenant_url($tenantSlug, 'login')); + } + + return [$tenant, $authState]; } private function membersForTenant(int $tenantId): array @@ -627,10 +927,147 @@ final class TenantController return $statement->fetchAll(); } + private function memberRecordForAuthState(int $tenantId, array $authState): ?array + { + $memberId = $authState['member_id'] ?? null; + + if (!is_int($memberId)) { + return null; + } + + $statement = $this->database->pdo()->prepare( + 'SELECT id, display_name, email + FROM members + WHERE tenant_id = :tenant_id + AND id = :id + AND status = "active" + LIMIT 1' + ); + $statement->execute([ + 'tenant_id' => $tenantId, + 'id' => $memberId, + ]); + $member = $statement->fetch(); + + return $member ?: null; + } + + private function isTenantManager(array $authState): bool + { + $role = $authState['tenant_role'] ?? null; + + return is_string($role) && in_array($role, ['owner', 'admin'], true); + } + + private function selectedMemberIdForBooking( + int $tenantId, + array $authState, + int $postedMemberId, + ?array $currentMember + ): int { + if ($this->isTenantManager($authState)) { + if ($postedMemberId <= 0) { + throw new RuntimeException('Bitte ein Mitglied fuer die Buchung auswaehlen.'); + } + + return $this->assertMemberIdBelongsToTenant($tenantId, $postedMemberId); + } + + if ($currentMember === null) { + throw new RuntimeException('Fuer dieses Konto ist kein buchungsfaehiges Mitglied hinterlegt.'); + } + + if ($postedMemberId > 0 && $postedMemberId !== (int) $currentMember['id']) { + throw new RuntimeException('Du darfst nur fuer dein eigenes Mitglied buchen.'); + } + + return (int) $currentMember['id']; + } + + private function assertMemberIdBelongsToTenant(int $tenantId, int $memberId): int + { + $statement = $this->database->pdo()->prepare( + 'SELECT id + FROM members + WHERE tenant_id = :tenant_id + AND id = :id + AND status = "active" + LIMIT 1' + ); + $statement->execute([ + 'tenant_id' => $tenantId, + 'id' => $memberId, + ]); + + if (!$statement->fetchColumn()) { + throw new RuntimeException('Das angegebene Mitglied gehoert nicht zu diesem Mandanten.'); + } + + return $memberId; + } + private function assertCsrf(): void { if (!request_origin_matches_host() || !$this->csrf->validate($_POST['_token'] ?? null)) { throw new RuntimeException('Die Anfrage konnte nicht bestaetigt werden.'); } } + + private function selfServiceAllowed(int $tenantId): bool + { + $statement = $this->database->pdo()->prepare( + 'SELECT allow_self_service + FROM tenant_settings + WHERE tenant_id = :tenant_id + LIMIT 1' + ); + $statement->execute(['tenant_id' => $tenantId]); + $value = $statement->fetchColumn(); + + return $value === false ? true : (int) $value === 1; + } + + private function rateLimiter(\PDO $pdo): RateLimiterService + { + return new RateLimiterService($pdo, $this->config['rate_limits'] ?? []); + } + + private function buildRateLimitScopes(string $tenantSlug, string $email): array + { + $ip = trim((string) ($_SERVER['REMOTE_ADDR'] ?? 'unknown')); + $scopes = ['tenant-login|ip|' . $tenantSlug . '|' . $ip]; + + if (trim($email) !== '') { + $scopes[] = 'tenant-login|subject|' . $tenantSlug . '|' . $email; + } + + return $scopes; + } + + private function ensureRateLimitAllowed(RateLimiterService $rateLimiter, array $scopes): void + { + foreach ($scopes as $scope) { + $state = $rateLimiter->inspect('login', $scope); + + if (!$state['allowed']) { + throw new RuntimeException( + 'Zu viele Login-Versuche. Bitte in etwa ' . (int) $state['retry_after_seconds'] . ' Sekunden erneut versuchen.' + ); + } + } + } + + private function hitRateLimitScopes(RateLimiterService $rateLimiter, array $scopes): void + { + foreach ($scopes as $scope) { + $rateLimiter->hit('login', $scope); + } + } + + private function clearRateLimitScopes(RateLimiterService $rateLimiter, array $scopes): void + { + foreach ($scopes as $scope) { + $rateLimiter->clear('login', $scope); + } + } } diff --git a/app/Core/Auth.php b/app/Core/Auth.php index d1442cf..bd11f89 100644 --- a/app/Core/Auth.php +++ b/app/Core/Auth.php @@ -28,6 +28,8 @@ final class Auth return false; } + $this->rehashPasswordIfNeeded((int) $user['id'], (string) $user['password_hash'], $password); + $this->session->regenerate(); $this->session->put('auth', [ 'user_id' => (int) $user['id'], @@ -44,10 +46,15 @@ final class Auth u.password_hash, m.tenant_id, m.role, - t.slug AS tenant_slug + t.slug AS tenant_slug, + member.id AS member_id FROM users u INNER JOIN tenant_memberships m ON m.user_id = u.id AND m.status = "active" INNER JOIN tenants t ON t.id = m.tenant_id AND t.slug = :slug AND t.status = "active" + LEFT JOIN members member + ON member.user_id = u.id + AND member.tenant_id = m.tenant_id + AND member.status = "active" WHERE u.email = :email LIMIT 1' ); @@ -61,12 +68,15 @@ final class Auth return false; } + $this->rehashPasswordIfNeeded((int) $membership['id'], (string) $membership['password_hash'], $password); + $this->session->regenerate(); $this->session->put('auth', [ 'user_id' => (int) $membership['id'], 'tenant_id' => (int) $membership['tenant_id'], 'tenant_slug' => $membership['tenant_slug'], 'tenant_role' => $membership['role'], + 'member_id' => isset($membership['member_id']) ? (int) $membership['member_id'] : null, 'platform_role' => 'user', ]); @@ -101,8 +111,37 @@ final class Auth return is_string($role) && in_array($role, $allowedRoles, true); } + public function tenantMemberId(): ?int + { + $memberId = $this->user()['member_id'] ?? null; + + return is_int($memberId) ? $memberId : null; + } + + public function canManageTenant(): bool + { + $role = $this->user()['tenant_role'] ?? null; + + return is_string($role) && in_array($role, ['owner', 'admin'], true); + } + public function logout(): void { $this->session->invalidate(); } + + private function rehashPasswordIfNeeded(int $userId, string $currentHash, string $password): void + { + if (!password_needs_rehash($currentHash, defined('PASSWORD_ARGON2ID') ? PASSWORD_ARGON2ID : PASSWORD_BCRYPT)) { + return; + } + + $statement = $this->pdo->prepare( + 'UPDATE users SET password_hash = :password_hash WHERE id = :id' + ); + $statement->execute([ + 'password_hash' => secure_password_hash($password), + 'id' => $userId, + ]); + } } diff --git a/app/Services/ExportService.php b/app/Services/ExportService.php new file mode 100644 index 0000000..9fef981 --- /dev/null +++ b/app/Services/ExportService.php @@ -0,0 +1,408 @@ +assertTenantId($tenantId); + + $statement = $this->pdo->prepare( + 'SELECT + m.id AS member_id, + m.display_name, + m.email, + m.status, + m.created_at, + COALESCE(balance_summary.balance_cents, 0) AS balance_cents, + balance_summary.last_occurred_at + FROM members m + LEFT JOIN ( + SELECT + ll.member_id, + SUM(ll.amount_cents) AS balance_cents, + MAX(le.occurred_at) AS last_occurred_at + FROM ledger_lines ll + INNER JOIN ledger_entries le + ON le.id = ll.ledger_entry_id + AND le.tenant_id = ll.tenant_id + WHERE ll.tenant_id = :balance_tenant_id + AND ll.account_code = "member_balance" + AND ll.member_id IS NOT NULL + GROUP BY ll.member_id + ) balance_summary + ON balance_summary.member_id = m.id + WHERE m.tenant_id = :tenant_id + ORDER BY m.display_name ASC, m.id ASC' + ); + $statement->execute([ + 'balance_tenant_id' => $tenantId, + 'tenant_id' => $tenantId, + ]); + + $handle = $this->openCsvOutput($filename ?? $this->defaultFilename('member-balances', $tenantId)); + + try { + $this->writeCsvRow($handle, [ + 'mitglied_id', + 'anzeigename', + 'email', + 'status', + 'saldo_cents', + 'saldo_eur', + 'letzte_buchung_am', + 'angelegt_am', + ]); + + $rowCount = 0; + + while (($row = $statement->fetch(PDO::FETCH_ASSOC)) !== false) { + $balanceCents = (int) $row['balance_cents']; + + $this->writeCsvRow($handle, [ + (int) $row['member_id'], + (string) $row['display_name'], + $row['email'], + (string) $row['status'], + $balanceCents, + $this->formatMoneyFromCents($balanceCents), + $row['last_occurred_at'], + (string) $row['created_at'], + ]); + + $rowCount++; + $this->flushIfNeeded($rowCount); + } + } finally { + fclose($handle); + } + } + + public function streamBookingsCsv( + int $tenantId, + ?int $memberId = null, + ?string $from = null, + ?string $until = null, + ?string $filename = null + ): void { + $this->assertTenantId($tenantId); + + if ($memberId !== null && $memberId <= 0) { + throw new RuntimeException('Die Mitglieds-ID muss groesser als 0 sein.'); + } + + $from = $this->normalizeDateTimeFilter($from, false); + $until = $this->normalizeDateTimeFilter($until, true); + + $sql = 'SELECT + le.id AS booking_id, + le.type, + le.reference_type, + le.reference_id, + le.description, + le.occurred_at, + le.created_at AS entry_created_at, + le.member_id, + m.display_name AS member_name, + s.code AS source_code, + s.name AS source_name, + COALESCE(line_totals.member_balance_cents, 0) AS member_balance_cents, + COALESCE(line_totals.cashbox_cents, 0) AS cashbox_cents, + COALESCE(line_totals.coffee_revenue_cents, 0) AS coffee_revenue_cents, + c.id AS consumption_event_id, + c.quantity, + c.unit_price_cents, + c.total_cents, + c.effective_at, + c.recorded_at, + c.source_reference, + c.notes, + c.paper_sheet_line_id, + p.id AS product_id, + p.name AS product_name + FROM ledger_entries le + LEFT JOIN members m + ON m.id = le.member_id + AND m.tenant_id = le.tenant_id + LEFT JOIN capture_sources s + ON s.id = le.source_id + AND s.tenant_id = le.tenant_id + LEFT JOIN ( + SELECT + ledger_entry_id, + SUM(CASE WHEN account_code = "member_balance" THEN amount_cents ELSE 0 END) AS member_balance_cents, + SUM(CASE WHEN account_code = "cashbox" THEN amount_cents ELSE 0 END) AS cashbox_cents, + SUM(CASE WHEN account_code = "coffee_revenue" THEN amount_cents ELSE 0 END) AS coffee_revenue_cents + FROM ledger_lines + WHERE tenant_id = :line_tenant_id + GROUP BY ledger_entry_id + ) line_totals + ON line_totals.ledger_entry_id = le.id + LEFT JOIN consumption_events c + ON c.id = le.reference_id + AND c.tenant_id = le.tenant_id + AND le.reference_type = "consumption_event" + LEFT JOIN products p + ON p.id = c.product_id + AND p.tenant_id = c.tenant_id + WHERE le.tenant_id = :tenant_id'; + + $params = [ + 'line_tenant_id' => $tenantId, + 'tenant_id' => $tenantId, + ]; + + if ($memberId !== null) { + $sql .= ' AND le.member_id = :member_id'; + $params['member_id'] = $memberId; + } + + if ($from !== null) { + $sql .= ' AND le.occurred_at >= :from_occurred_at'; + $params['from_occurred_at'] = $from; + } + + if ($until !== null) { + $sql .= ' AND le.occurred_at <= :until_occurred_at'; + $params['until_occurred_at'] = $until; + } + + $sql .= ' ORDER BY le.occurred_at DESC, le.id DESC'; + + $statement = $this->pdo->prepare($sql); + $statement->execute($params); + + $handle = $this->openCsvOutput($filename ?? $this->defaultFilename('bookings', $tenantId)); + + try { + $this->writeCsvRow($handle, [ + 'buchung_id', + 'typ', + 'buchungsdatum', + 'mitglied_id', + 'mitglied_name', + 'beschreibung', + 'quellcode', + 'quelle', + 'referenz_typ', + 'referenz_id', + 'saldo_delta_cents', + 'saldo_delta_eur', + 'kasse_delta_cents', + 'kasse_delta_eur', + 'umsatz_delta_cents', + 'umsatz_delta_eur', + 'produkt_id', + 'produkt_name', + 'menge', + 'einzelpreis_cents', + 'einzelpreis_eur', + 'gesamt_cents', + 'gesamt_eur', + 'notizen', + 'wirksam_am', + 'erfasst_am', + 'quellreferenz', + 'papierlisten_zeile_id', + 'angelegt_am', + ]); + + $rowCount = 0; + + while (($row = $statement->fetch(PDO::FETCH_ASSOC)) !== false) { + $memberBalanceCents = (int) $row['member_balance_cents']; + $cashboxCents = (int) $row['cashbox_cents']; + $coffeeRevenueCents = (int) $row['coffee_revenue_cents']; + $unitPriceCents = $row['unit_price_cents'] !== null ? (int) $row['unit_price_cents'] : null; + $totalCents = $row['total_cents'] !== null ? (int) $row['total_cents'] : null; + $recordedAt = $row['recorded_at'] ?? $row['entry_created_at']; + + $this->writeCsvRow($handle, [ + (int) $row['booking_id'], + (string) $row['type'], + (string) $row['occurred_at'], + $row['member_id'] !== null ? (int) $row['member_id'] : '', + $row['member_name'], + (string) $row['description'], + $row['source_code'], + $row['source_name'], + $row['reference_type'], + $row['reference_id'], + $memberBalanceCents, + $this->formatMoneyFromCents($memberBalanceCents), + $cashboxCents, + $this->formatMoneyFromCents($cashboxCents), + $coffeeRevenueCents, + $this->formatMoneyFromCents($coffeeRevenueCents), + $row['product_id'] !== null ? (int) $row['product_id'] : '', + $row['product_name'], + $this->formatQuantity($row['quantity']), + $unitPriceCents ?? '', + $unitPriceCents !== null ? $this->formatMoneyFromCents($unitPriceCents) : '', + $totalCents ?? '', + $totalCents !== null ? $this->formatMoneyFromCents($totalCents) : '', + $row['notes'], + $row['effective_at'] ?? $row['occurred_at'], + $recordedAt, + $row['source_reference'], + $row['paper_sheet_line_id'], + (string) $row['entry_created_at'], + ]); + + $rowCount++; + $this->flushIfNeeded($rowCount); + } + } finally { + fclose($handle); + } + } + + private function assertTenantId(int $tenantId): void + { + if ($tenantId <= 0) { + throw new RuntimeException('Die Tenant-ID muss groesser als 0 sein.'); + } + } + + private function defaultFilename(string $prefix, int $tenantId): string + { + return sprintf('%s-tenant-%d-%s.csv', $prefix, $tenantId, gmdate('Ymd-His')); + } + + private function openCsvOutput(string $filename) + { + if (headers_sent($file, $line)) { + throw new RuntimeException(sprintf('CSV-Header koennen nicht mehr gesendet werden (%s:%d).', $file, $line)); + } + + $safeFilename = $this->sanitizeFilename($filename); + + header('Content-Type: text/csv; charset=UTF-8'); + header('Content-Disposition: attachment; filename="' . $safeFilename . '"; filename*=UTF-8\'\'' . rawurlencode($safeFilename)); + header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0'); + header('Pragma: no-cache'); + header('Expires: 0'); + header('X-Accel-Buffering: no'); + header('X-Content-Type-Options: nosniff'); + + $handle = fopen('php://output', 'wb'); + + if ($handle === false) { + throw new RuntimeException('php://output konnte nicht geoeffnet werden.'); + } + + fwrite($handle, "\xEF\xBB\xBF"); + + return $handle; + } + + private function sanitizeFilename(string $filename): string + { + $sanitized = preg_replace('/[^A-Za-z0-9._-]+/', '-', trim($filename)) ?? ''; + $sanitized = trim($sanitized, '.-'); + + if ($sanitized === '') { + $sanitized = 'export'; + } + + if (!str_ends_with(strtolower($sanitized), '.csv')) { + $sanitized .= '.csv'; + } + + return $sanitized; + } + + private function writeCsvRow($handle, array $row): void + { + $values = array_map(function (mixed $value): string { + return $this->sanitizeCsvValue($value); + }, $row); + + if (fputcsv($handle, $values, self::CSV_DELIMITER) === false) { + throw new RuntimeException('Die CSV-Zeile konnte nicht geschrieben werden.'); + } + } + + private function sanitizeCsvValue(mixed $value): string + { + if ($value === null) { + return ''; + } + + if (is_bool($value)) { + return $value ? '1' : '0'; + } + + $string = str_replace(["\r\n", "\r"], "\n", (string) $value); + + // Prevent spreadsheet formula execution for user-controlled text cells. + if ( + $string !== '' + && in_array($string[0], ['=', '+', '-', '@', "\t"], true) + && !is_numeric($string) + ) { + return "'" . $string; + } + + return $string; + } + + private function formatMoneyFromCents(int $amountCents): string + { + return number_format($amountCents / 100, 2, ',', '.'); + } + + private function formatQuantity(mixed $quantity): string + { + if ($quantity === null || $quantity === '') { + return ''; + } + + return rtrim(rtrim(number_format((float) $quantity, 2, '.', ''), '0'), '.'); + } + + private function normalizeDateTimeFilter(?string $value, bool $endOfDay): ?string + { + $value = trim((string) $value); + + if ($value === '') { + return null; + } + + $value = str_replace('T', ' ', $value); + + if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) === 1) { + return $value . ($endOfDay ? ' 23:59:59' : ' 00:00:00'); + } + + if (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/', $value) === 1) { + return $value . ':00'; + } + + return $value; + } + + private function flushIfNeeded(int $rowCount): void + { + if ($rowCount % self::FLUSH_EVERY_ROWS !== 0) { + return; + } + + if (ob_get_level() > 0) { + ob_flush(); + } + + flush(); + } +} diff --git a/app/Services/LedgerService.php b/app/Services/LedgerService.php index 6c0bbb3..7ec84a6 100644 --- a/app/Services/LedgerService.php +++ b/app/Services/LedgerService.php @@ -29,6 +29,9 @@ final class LedgerService throw new RuntimeException('Die Menge muss groesser als 0 sein.'); } + $this->assertTenantMembership($tenantId, $recordedByUserId); + $this->assertTenantMember($tenantId, $memberId); + $this->assertTenantProduct($tenantId, $productId); $source = $this->findSource($tenantId, $sourceCode); $priceCents = $this->resolvePriceCents($tenantId, $productId, $effectiveAt); $totalCents = (int) round($priceCents * $quantity); @@ -179,6 +182,8 @@ final class LedgerService throw new RuntimeException('Der Einzahlungsbetrag muss groesser als 0 sein.'); } + $this->assertTenantMembership($tenantId, $recordedByUserId); + $this->assertTenantMember($tenantId, $memberId); $source = $this->findSource($tenantId, 'admin_backoffice'); $entryInsert = $this->pdo->prepare( @@ -270,6 +275,12 @@ final class LedgerService ?string $notes, int $createdByUserId ): int { + $this->assertTenantMembership($tenantId, $createdByUserId); + + if (trim($title) === '') { + throw new RuntimeException('Bitte einen Titel fuer die Papierliste angeben.'); + } + $statement = $this->pdo->prepare( 'INSERT INTO paper_sheets ( tenant_id, @@ -320,6 +331,10 @@ final class LedgerService throw new RuntimeException('Die Menge muss groesser als 0 sein.'); } + $this->assertTenantMembership($tenantId, $actorUserId); + $this->assertTenantMember($tenantId, $memberId); + $this->assertTenantProduct($tenantId, $productId); + $sheet = $this->pdo->prepare( 'SELECT id, status FROM paper_sheets WHERE id = :id AND tenant_id = :tenant_id LIMIT 1' ); @@ -375,6 +390,8 @@ final class LedgerService public function postPaperSheet(int $tenantId, int $sheetId, int $actorUserId): void { + $this->assertTenantMembership($tenantId, $actorUserId); + $sheetStatement = $this->pdo->prepare( 'SELECT id, status FROM paper_sheets WHERE id = :id AND tenant_id = :tenant_id LIMIT 1' ); @@ -432,6 +449,170 @@ final class LedgerService $this->audit->log($tenantId, $actorUserId, 'paper_sheet.posted', 'paper_sheet', $sheetId, 'success'); } + public function reverseLedgerEntry(int $tenantId, int $ledgerEntryId, int $actorUserId, ?string $reason = null): int + { + $this->assertTenantMembership($tenantId, $actorUserId); + + $entry = $this->ledgerEntry($tenantId, $ledgerEntryId); + + if ($this->hasReversal($tenantId, $ledgerEntryId)) { + throw new RuntimeException('Dieser Vorgang wurde bereits storniert.'); + } + + $linesStatement = $this->pdo->prepare( + 'SELECT member_id, account_code, amount_cents + FROM ledger_lines + WHERE tenant_id = :tenant_id AND ledger_entry_id = :ledger_entry_id + ORDER BY id ASC' + ); + $linesStatement->execute([ + 'tenant_id' => $tenantId, + 'ledger_entry_id' => $ledgerEntryId, + ]); + $lines = $linesStatement->fetchAll(); + + if ($lines === []) { + throw new RuntimeException('Es wurden keine Ledger-Zeilen fuer den Vorgang gefunden.'); + } + + $description = 'Storno: ' . $entry['description']; + + if ($reason !== null && trim($reason) !== '') { + $description .= ' (' . trim($reason) . ')'; + } + + $this->pdo->beginTransaction(); + + try { + $entryInsert = $this->pdo->prepare( + 'INSERT INTO ledger_entries ( + tenant_id, + member_id, + source_id, + type, + reference_type, + reference_id, + description, + occurred_at, + created_by_user_id, + reversal_of_entry_id + ) VALUES ( + :tenant_id, + :member_id, + :source_id, + "correction", + "ledger_reversal", + :reference_id, + :description, + :occurred_at, + :created_by_user_id, + :reversal_of_entry_id + )' + ); + $entryInsert->execute([ + 'tenant_id' => $tenantId, + 'member_id' => $entry['member_id'], + 'source_id' => $entry['source_id'], + 'reference_id' => $ledgerEntryId, + 'description' => $description, + 'occurred_at' => gmdate('Y-m-d H:i:s'), + 'created_by_user_id' => $actorUserId, + 'reversal_of_entry_id' => $ledgerEntryId, + ]); + $reversalId = (int) $this->pdo->lastInsertId(); + + $lineInsert = $this->pdo->prepare( + 'INSERT INTO ledger_lines ( + ledger_entry_id, + tenant_id, + member_id, + account_code, + amount_cents + ) VALUES ( + :ledger_entry_id, + :tenant_id, + :member_id, + :account_code, + :amount_cents + )' + ); + + foreach ($lines as $line) { + $lineInsert->execute([ + 'ledger_entry_id' => $reversalId, + 'tenant_id' => $tenantId, + 'member_id' => $line['member_id'] !== null ? (int) $line['member_id'] : null, + 'account_code' => $line['account_code'], + 'amount_cents' => 0 - (int) $line['amount_cents'], + ]); + } + + $this->pdo->commit(); + } catch (\Throwable $throwable) { + $this->pdo->rollBack(); + throw $throwable; + } + + $this->audit->log($tenantId, $actorUserId, 'ledger_entry.reversed', 'ledger_entry', $reversalId, 'success', [ + 'reversal_of_entry_id' => $ledgerEntryId, + ]); + + return $reversalId; + } + + public function reversePaperSheet(int $tenantId, int $sheetId, int $actorUserId, ?string $reason = null): int + { + $this->assertTenantMembership($tenantId, $actorUserId); + + $statement = $this->pdo->prepare( + 'SELECT DISTINCT le.id + FROM ledger_entries le + INNER JOIN consumption_events ce + ON ce.id = le.reference_id + AND le.reference_type = "consumption_event" + INNER JOIN paper_sheet_lines psl + ON psl.id = ce.paper_sheet_line_id + WHERE le.tenant_id = :tenant_id + AND psl.paper_sheet_id = :paper_sheet_id + ORDER BY le.id ASC' + ); + $statement->execute([ + 'tenant_id' => $tenantId, + 'paper_sheet_id' => $sheetId, + ]); + $entryIds = $statement->fetchAll(PDO::FETCH_COLUMN); + + if ($entryIds === []) { + throw new RuntimeException('Zu dieser Papierliste wurden keine verbuchten Eintraege gefunden.'); + } + + $reversed = 0; + + foreach ($entryIds as $entryId) { + if ($this->hasReversal($tenantId, (int) $entryId)) { + continue; + } + + $this->reverseLedgerEntry( + $tenantId, + (int) $entryId, + $actorUserId, + $reason !== null && trim($reason) !== '' ? $reason : 'Papierliste #' . $sheetId + ); + $reversed++; + } + + if ($reversed === 0) { + throw new RuntimeException('Diese Papierliste wurde bereits vollstaendig storniert.'); + } + + $this->audit->log($tenantId, $actorUserId, 'paper_sheet.reversed', 'paper_sheet', $sheetId, 'success', [ + 'reversed_entries' => $reversed, + ]); + + return $reversed; + } + private function resolvePriceCents(int $tenantId, int $productId, string $effectiveAt): int { $statement = $this->pdo->prepare( @@ -475,4 +656,93 @@ final class LedgerService return $source; } + + private function assertTenantMember(int $tenantId, int $memberId): void + { + $statement = $this->pdo->prepare( + 'SELECT id FROM members WHERE id = :id AND tenant_id = :tenant_id AND status = "active" LIMIT 1' + ); + $statement->execute([ + 'id' => $memberId, + 'tenant_id' => $tenantId, + ]); + + if (!$statement->fetchColumn()) { + throw new RuntimeException('Das gewaehlte Mitglied gehoert nicht zu diesem Mandanten.'); + } + } + + private function assertTenantProduct(int $tenantId, int $productId): void + { + $statement = $this->pdo->prepare( + 'SELECT id FROM products WHERE id = :id AND tenant_id = :tenant_id AND is_active = 1 LIMIT 1' + ); + $statement->execute([ + 'id' => $productId, + 'tenant_id' => $tenantId, + ]); + + if (!$statement->fetchColumn()) { + throw new RuntimeException('Das gewaehlte Produkt gehoert nicht zu diesem Mandanten.'); + } + } + + private function assertTenantMembership(int $tenantId, int $userId): void + { + $statement = $this->pdo->prepare( + 'SELECT id + FROM tenant_memberships + WHERE tenant_id = :tenant_id + AND user_id = :user_id + AND status = "active" + LIMIT 1' + ); + $statement->execute([ + 'tenant_id' => $tenantId, + 'user_id' => $userId, + ]); + + if (!$statement->fetchColumn()) { + throw new RuntimeException('Der Benutzer ist fuer diesen Mandanten nicht aktiv.'); + } + } + + private function ledgerEntry(int $tenantId, int $ledgerEntryId): array + { + $statement = $this->pdo->prepare( + 'SELECT * + FROM ledger_entries + WHERE id = :id + AND tenant_id = :tenant_id + LIMIT 1' + ); + $statement->execute([ + 'id' => $ledgerEntryId, + 'tenant_id' => $tenantId, + ]); + $entry = $statement->fetch(); + + if (!$entry) { + throw new RuntimeException('Der angeforderte Vorgang wurde nicht gefunden.'); + } + + return $entry; + } + + private function hasReversal(int $tenantId, int $ledgerEntryId): bool + { + $statement = $this->pdo->prepare( + 'SELECT id + FROM ledger_entries + WHERE tenant_id = :tenant_id + AND reversal_of_entry_id = :reversal_of_entry_id + LIMIT 1' + ); + $statement->execute([ + 'tenant_id' => $tenantId, + 'reversal_of_entry_id' => $ledgerEntryId, + ]); + + return (bool) $statement->fetchColumn(); + } } diff --git a/app/Services/MailerService.php b/app/Services/MailerService.php new file mode 100644 index 0000000..3eea101 --- /dev/null +++ b/app/Services/MailerService.php @@ -0,0 +1,177 @@ +rootPath = dirname(__DIR__, 2); + } + + public function send(string $to, string $subject, string $textBody, array $headers = []): bool + { + $to = trim($to); + $subject = trim($subject); + $textBody = trim($textBody); + + if ($to === '' || !filter_var($to, FILTER_VALIDATE_EMAIL)) { + throw new RuntimeException('Die Zieladresse fuer den Mailversand ist ungueltig.'); + } + + if ($subject === '' || $textBody === '') { + throw new RuntimeException('Betreff und Inhalt fuer den Mailversand sind erforderlich.'); + } + + $subjectLine = $this->encodeSubject($this->sanitizeHeaderValue($this->subjectPrefix() . $subject)); + $headerLines = $this->buildHeaders($headers); + $body = $this->normalizeBody($textBody); + + $sent = false; + + if (function_exists('mail')) { + $sent = @mail($to, $subjectLine, $body, implode("\r\n", $headerLines)); + } + + if ($sent) { + return true; + } + + if (!$this->usesLogFallback()) { + throw new RuntimeException('Die E-Mail konnte nicht versendet werden.'); + } + + $this->writeFallbackLog([ + 'to' => $to, + 'subject' => $this->subjectPrefix() . $subject, + 'headers' => $headerLines, + 'body' => $textBody, + ]); + + return false; + } + + private function buildHeaders(array $headers): array + { + $headerLines = [ + 'MIME-Version: 1.0', + 'Content-Type: text/plain; charset=UTF-8', + 'Content-Transfer-Encoding: 8bit', + 'X-Mailer: Kaffeeliste-Neustart', + ]; + + $from = trim((string) ($this->config['from'] ?? '')); + + if ($from !== '') { + $headerLines[] = 'From: ' . $this->sanitizeHeaderValue($from); + } + + $replyTo = trim((string) ($this->config['reply_to'] ?? '')); + + if ($replyTo !== '') { + $headerLines[] = 'Reply-To: ' . $this->sanitizeHeaderValue($replyTo); + } + + foreach ($headers as $name => $value) { + if (is_int($name)) { + $line = trim((string) $value); + + if ($line !== '' && str_contains($line, ':')) { + [$rawName, $rawValue] = array_pad(explode(':', $line, 2), 2, ''); + $this->appendHeader($headerLines, $rawName, $rawValue); + } + + continue; + } + + $this->appendHeader($headerLines, (string) $name, (string) $value); + } + + return $headerLines; + } + + private function appendHeader(array &$headerLines, string $name, string $value): void + { + $name = trim($name); + + if ($name === '' || preg_match('/[^A-Za-z0-9-]/', $name)) { + return; + } + + $headerLines[] = $name . ': ' . $this->sanitizeHeaderValue($value); + } + + private function writeFallbackLog(array $payload): void + { + $path = $this->resolveLogPath(); + $directory = dirname($path); + + if (!is_dir($directory) && !mkdir($directory, 0775, true) && !is_dir($directory)) { + throw new RuntimeException('Das Mail-Log-Verzeichnis konnte nicht erstellt werden.'); + } + + $encodedPayload = json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + + if (!is_string($encodedPayload)) { + $encodedPayload = '{"error":"mail payload could not be encoded"}'; + } + + $entry = '[' . gmdate(DATE_ATOM) . "] mail_fallback\n" . $encodedPayload . "\n\n"; + + if (file_put_contents($path, $entry, FILE_APPEND | LOCK_EX) === false) { + throw new RuntimeException('Das Mail-Fallback-Log konnte nicht geschrieben werden.'); + } + } + + private function resolveLogPath(): string + { + $configuredPath = trim((string) ($this->config['log_file'] ?? '')); + + if ($configuredPath === '') { + return $this->rootPath . '/storage/logs/mail.log'; + } + + if (str_starts_with($configuredPath, '/')) { + return $configuredPath; + } + + return $this->rootPath . '/' . ltrim($configuredPath, '/'); + } + + private function usesLogFallback(): bool + { + return (bool) ($this->config['log_fallback'] ?? true); + } + + private function subjectPrefix(): string + { + return (string) ($this->config['subject_prefix'] ?? ''); + } + + private function sanitizeHeaderValue(string $value): string + { + return trim(str_replace(["\r", "\n"], ' ', $value)); + } + + private function encodeSubject(string $subject): string + { + if ($subject === '') { + return $subject; + } + + if (function_exists('mb_encode_mimeheader')) { + return mb_encode_mimeheader($subject, 'UTF-8', 'B', "\r\n"); + } + + return $subject; + } + + private function normalizeBody(string $textBody): string + { + return str_replace("\n", "\r\n", str_replace(["\r\n", "\r"], "\n", $textBody)); + } +} diff --git a/app/Services/PasswordResetService.php b/app/Services/PasswordResetService.php new file mode 100644 index 0000000..55aa88d --- /dev/null +++ b/app/Services/PasswordResetService.php @@ -0,0 +1,445 @@ +utc = new DateTimeZone('UTC'); + } + + public function requestReset(string $email, array $context = []): void + { + $email = $this->normalizeEmail($email); + + if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) { + return; + } + + $user = $this->findUserByEmail($email); + + if (!$user) { + return; + } + + $token = $this->createTokenForUser((int) $user['id'], $context); + $this->sendResetMail($token); + } + + public function createTokenForUser(int $userId, array $context = []): array + { + if ($userId <= 0) { + throw new RuntimeException('Der Benutzer fuer den Passwort-Reset ist ungueltig.'); + } + + $user = $this->findUserById($userId); + + if (!$user) { + throw new RuntimeException('Der Benutzer fuer den Passwort-Reset wurde nicht gefunden.'); + } + + $nowTs = time(); + $now = $this->formatTimestamp($nowTs); + $expiresAt = $this->formatTimestamp($nowTs + ($this->ttlMinutes() * 60)); + $token = bin2hex(random_bytes(32)); + $tokenHash = hash('sha256', $token); + $ip = trim((string) ($_SERVER['REMOTE_ADDR'] ?? '')); + $userAgent = trim((string) ($_SERVER['HTTP_USER_AGENT'] ?? '')); + + $this->pdo->beginTransaction(); + + try { + $cleanupStatement = $this->pdo->prepare( + 'DELETE FROM password_reset_tokens + WHERE user_id = :user_id + AND (consumed_at IS NOT NULL OR expires_at < :now)' + ); + $cleanupStatement->execute([ + 'user_id' => $userId, + 'now' => $now, + ]); + + $insertStatement = $this->pdo->prepare( + 'INSERT INTO password_reset_tokens ( + user_id, + email, + token_hash, + requested_ip_hash, + requested_user_agent_hash, + expires_at, + consumed_at, + created_at + ) VALUES ( + :user_id, + :email, + :token_hash, + :requested_ip_hash, + :requested_user_agent_hash, + :expires_at, + NULL, + :created_at + )' + ); + $insertStatement->execute([ + 'user_id' => $userId, + 'email' => $user['email'], + 'token_hash' => $tokenHash, + 'requested_ip_hash' => $ip !== '' ? hash('sha256', $ip) : null, + 'requested_user_agent_hash' => $userAgent !== '' ? hash('sha256', $userAgent) : null, + 'expires_at' => $expiresAt, + 'created_at' => $now, + ]); + + $trimStatement = $this->pdo->prepare( + 'SELECT id + FROM password_reset_tokens + WHERE user_id = :user_id + AND consumed_at IS NULL + AND expires_at >= :now + ORDER BY created_at DESC, id DESC' + ); + $trimStatement->execute([ + 'user_id' => $userId, + 'now' => $now, + ]); + + $tokenIds = array_map( + static fn (array $row): int => (int) $row['id'], + $trimStatement->fetchAll() + ); + + $idsToDelete = array_slice($tokenIds, $this->maxActiveTokens()); + + if ($idsToDelete !== []) { + $deleteStatement = $this->pdo->prepare( + 'DELETE FROM password_reset_tokens + WHERE user_id = :user_id + AND id = :id' + ); + + foreach ($idsToDelete as $tokenId) { + $deleteStatement->execute([ + 'user_id' => $userId, + 'id' => $tokenId, + ]); + } + } + + $this->pdo->commit(); + } catch (\Throwable $throwable) { + if ($this->pdo->inTransaction()) { + $this->pdo->rollBack(); + } + + throw $throwable; + } + + return [ + 'user_id' => $userId, + 'email' => $user['email'], + 'full_name' => $user['full_name'], + 'token' => $token, + 'expires_at' => $expiresAt, + 'reset_url' => $this->buildResetUrl($token, $context), + ]; + } + + public function findValidToken(string $token): ?array + { + $token = trim($token); + + if ($token === '') { + return null; + } + + $statement = $this->pdo->prepare( + 'SELECT prt.id, + prt.user_id, + prt.email, + prt.expires_at, + prt.consumed_at, + prt.created_at, + u.full_name + FROM password_reset_tokens prt + INNER JOIN users u ON u.id = prt.user_id + WHERE prt.token_hash = :token_hash + LIMIT 1' + ); + $statement->execute([ + 'token_hash' => hash('sha256', $token), + ]); + $resetToken = $statement->fetch(); + + if (!$resetToken) { + return null; + } + + $expiresAtTs = $this->timestampFromDatabase($resetToken['expires_at']); + + if ( + $resetToken['consumed_at'] !== null + || $expiresAtTs === null + || $expiresAtTs < time() + ) { + return null; + } + + return $resetToken; + } + + public function resetPassword(string $token, string $newPassword): int + { + $token = trim($token); + $newPassword = (string) $newPassword; + + if ($token === '') { + throw new RuntimeException('Der Reset-Link ist ungueltig oder abgelaufen.'); + } + + if (mb_strlen($newPassword) < 12) { + throw new RuntimeException('Das neue Passwort muss mindestens 12 Zeichen lang sein.'); + } + + $tokenHash = hash('sha256', $token); + $nowTs = time(); + $now = $this->formatTimestamp($nowTs); + + $this->pdo->beginTransaction(); + + try { + $statement = $this->pdo->prepare( + 'SELECT id, user_id, expires_at, consumed_at + FROM password_reset_tokens + WHERE token_hash = :token_hash + LIMIT 1 + FOR UPDATE' + ); + $statement->execute(['token_hash' => $tokenHash]); + $resetToken = $statement->fetch(); + + if (!$resetToken) { + throw new RuntimeException('Der Reset-Link ist ungueltig oder abgelaufen.'); + } + + $expiresAtTs = $this->timestampFromDatabase($resetToken['expires_at']); + + if ( + $resetToken['consumed_at'] !== null + || $expiresAtTs === null + || $expiresAtTs < $nowTs + ) { + throw new RuntimeException('Der Reset-Link ist ungueltig oder abgelaufen.'); + } + + $updateUserStatement = $this->pdo->prepare( + 'UPDATE users + SET password_hash = :password_hash + WHERE id = :user_id' + ); + $updateUserStatement->execute([ + 'password_hash' => secure_password_hash($newPassword), + 'user_id' => $resetToken['user_id'], + ]); + + $consumeTokenStatement = $this->pdo->prepare( + 'UPDATE password_reset_tokens + SET consumed_at = :consumed_at + WHERE id = :id' + ); + $consumeTokenStatement->execute([ + 'consumed_at' => $now, + 'id' => $resetToken['id'], + ]); + + $revokeOtherTokensStatement = $this->pdo->prepare( + 'DELETE FROM password_reset_tokens + WHERE user_id = :user_id + AND id <> :id' + ); + $revokeOtherTokensStatement->execute([ + 'user_id' => $resetToken['user_id'], + 'id' => $resetToken['id'], + ]); + + $this->pdo->commit(); + } catch (\Throwable $throwable) { + if ($this->pdo->inTransaction()) { + $this->pdo->rollBack(); + } + + throw $throwable; + } + + return (int) $resetToken['user_id']; + } + + public function revokeTokensForUser(int $userId): int + { + if ($userId <= 0) { + return 0; + } + + $statement = $this->pdo->prepare( + 'DELETE FROM password_reset_tokens + WHERE user_id = :user_id' + ); + $statement->execute(['user_id' => $userId]); + + return $statement->rowCount(); + } + + public function purgeExpiredTokens(): int + { + $statement = $this->pdo->prepare( + 'DELETE FROM password_reset_tokens + WHERE consumed_at IS NOT NULL + OR expires_at < :now' + ); + $statement->execute(['now' => $this->formatTimestamp(time())]); + + return $statement->rowCount(); + } + + private function sendResetMail(array $token): void + { + $name = trim((string) ($token['full_name'] ?? '')); + $greeting = $name !== '' ? 'Hallo ' . $name . ',' : 'Hallo,'; + $body = implode("\n\n", [ + $greeting, + 'fuer dein Konto wurde ein Passwort-Reset angefragt.', + 'Bitte nutze diesen Link, um ein neues Passwort zu setzen:', + (string) $token['reset_url'], + 'Der Link ist gueltig bis ' . (string) $token['expires_at'] . ' UTC.', + 'Falls du den Reset nicht angefragt hast, kannst du diese Nachricht ignorieren.', + ]); + + $this->mailer->send( + (string) $token['email'], + $this->mailSubject(), + $body + ); + } + + private function findUserByEmail(string $email): ?array + { + $statement = $this->pdo->prepare( + 'SELECT id, full_name, email + FROM users + WHERE email = :email + LIMIT 1' + ); + $statement->execute(['email' => $email]); + $user = $statement->fetch(); + + return $user ?: null; + } + + private function findUserById(int $userId): ?array + { + $statement = $this->pdo->prepare( + 'SELECT id, full_name, email + FROM users + WHERE id = :id + LIMIT 1' + ); + $statement->execute(['id' => $userId]); + $user = $statement->fetch(); + + return $user ?: null; + } + + private function normalizeEmail(string $email): string + { + return mb_strtolower(trim($email)); + } + + private function buildResetUrl(string $token, array $context = []): string + { + $template = trim((string) ($this->config['reset_url'] ?? '')); + + if ($template === '') { + $template = 'http://localhost:8080/reset-password?token={{token}}'; + } + + $query = []; + + foreach ($context as $key => $value) { + if (!is_string($key) || trim($key) === '' || !is_scalar($value)) { + continue; + } + + $normalizedValue = trim((string) $value); + + if ($normalizedValue === '') { + continue; + } + + $query[$key] = $normalizedValue; + } + + if (str_contains($template, '{{token}}')) { + $url = str_replace('{{token}}', rawurlencode($token), $template); + + if ($query === []) { + return $url; + } + + $separator = str_contains($url, '?') ? '&' : '?'; + + return $url . $separator . http_build_query($query, '', '&', PHP_QUERY_RFC3986); + } + + $query['token'] = $token; + $separator = str_contains($template, '?') ? '&' : '?'; + + return $template . $separator . http_build_query($query, '', '&', PHP_QUERY_RFC3986); + } + + private function mailSubject(): string + { + $subject = trim((string) ($this->config['subject'] ?? '')); + + return $subject !== '' ? $subject : 'Passwort zuruecksetzen'; + } + + private function ttlMinutes(): int + { + return max(5, (int) ($this->config['ttl_minutes'] ?? 60)); + } + + private function maxActiveTokens(): int + { + return max(1, (int) ($this->config['max_active_tokens'] ?? 3)); + } + + private function formatTimestamp(int $timestamp): string + { + return gmdate('Y-m-d H:i:s', $timestamp); + } + + private function timestampFromDatabase(?string $value): ?int + { + if ($value === null || trim($value) === '') { + return null; + } + + $date = DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $value, $this->utc); + + if (!$date instanceof DateTimeImmutable) { + return null; + } + + return $date->getTimestamp(); + } +} diff --git a/app/Services/RateLimiterService.php b/app/Services/RateLimiterService.php new file mode 100644 index 0000000..d495e3a --- /dev/null +++ b/app/Services/RateLimiterService.php @@ -0,0 +1,272 @@ +utc = new DateTimeZone('UTC'); + } + + public function inspect(string $action, string $scope): array + { + $policy = $this->resolvePolicy($action); + $statement = $this->pdo->prepare( + 'SELECT attempts, window_start, last_attempt_at, blocked_until + FROM rate_limits + WHERE action_key = :action_key + AND bucket_key = :bucket_key + LIMIT 1' + ); + $statement->execute([ + 'action_key' => $policy['action'], + 'bucket_key' => $this->bucketKey($scope), + ]); + + return $this->buildState($policy, $statement->fetch() ?: null, time()); + } + + public function hit(string $action, string $scope): array + { + $policy = $this->resolvePolicy($action); + $bucketKey = $this->bucketKey($scope); + $nowTs = time(); + $now = $this->formatTimestamp($nowTs); + + $this->pdo->beginTransaction(); + + try { + $bootstrapStatement = $this->pdo->prepare( + 'INSERT INTO rate_limits ( + action_key, + bucket_key, + attempts, + window_start, + last_attempt_at, + blocked_until, + created_at + ) VALUES ( + :action_key, + :bucket_key, + 0, + :window_start, + :last_attempt_at, + NULL, + :created_at + ) + ON DUPLICATE KEY UPDATE + action_key = action_key' + ); + $bootstrapStatement->execute([ + 'action_key' => $policy['action'], + 'bucket_key' => $bucketKey, + 'window_start' => $now, + 'last_attempt_at' => $now, + 'created_at' => $now, + ]); + + $selectStatement = $this->pdo->prepare( + 'SELECT attempts, window_start, last_attempt_at, blocked_until + FROM rate_limits + WHERE action_key = :action_key + AND bucket_key = :bucket_key + LIMIT 1 + FOR UPDATE' + ); + $selectStatement->execute([ + 'action_key' => $policy['action'], + 'bucket_key' => $bucketKey, + ]); + $row = $selectStatement->fetch(); + + if (!$row) { + throw new RuntimeException('Der Rate-Limit-Zustand konnte nicht geladen werden.'); + } + + $windowStartTs = $this->timestampFromDatabase($row['window_start']); + $blockedUntilTs = $this->timestampFromDatabase($row['blocked_until']); + $isBlocked = $blockedUntilTs !== null && $blockedUntilTs > $nowTs; + $resetWindow = $windowStartTs === null + || ($windowStartTs + $policy['window_seconds']) <= $nowTs + || ($blockedUntilTs !== null && $blockedUntilTs <= $nowTs); + + if ($isBlocked) { + $updatedRow = $row; + } else { + $attempts = $resetWindow ? 1 : ((int) $row['attempts'] + 1); + $windowStart = $resetWindow ? $now : (string) $row['window_start']; + $blockedUntil = $attempts >= $policy['max_attempts'] + ? $this->formatTimestamp($nowTs + $policy['block_seconds']) + : null; + + $updateStatement = $this->pdo->prepare( + 'UPDATE rate_limits + SET attempts = :attempts, + window_start = :window_start, + last_attempt_at = :last_attempt_at, + blocked_until = :blocked_until + WHERE action_key = :action_key + AND bucket_key = :bucket_key' + ); + $updateStatement->execute([ + 'attempts' => $attempts, + 'window_start' => $windowStart, + 'last_attempt_at' => $now, + 'blocked_until' => $blockedUntil, + 'action_key' => $policy['action'], + 'bucket_key' => $bucketKey, + ]); + + $updatedRow = [ + 'attempts' => $attempts, + 'window_start' => $windowStart, + 'last_attempt_at' => $now, + 'blocked_until' => $blockedUntil, + ]; + } + + $this->pdo->commit(); + } catch (\Throwable $throwable) { + if ($this->pdo->inTransaction()) { + $this->pdo->rollBack(); + } + + throw $throwable; + } + + return $this->buildState($policy, $updatedRow, $nowTs); + } + + public function clear(string $action, string $scope): void + { + $policy = $this->resolvePolicy($action); + $statement = $this->pdo->prepare( + 'DELETE FROM rate_limits + WHERE action_key = :action_key + AND bucket_key = :bucket_key' + ); + $statement->execute([ + 'action_key' => $policy['action'], + 'bucket_key' => $this->bucketKey($scope), + ]); + } + + public function tooManyAttempts(string $action, string $scope): bool + { + return !$this->inspect($action, $scope)['allowed']; + } + + public function prune(int $staleAfterSeconds = 604800): int + { + $cutoff = $this->formatTimestamp(time() - max(3600, $staleAfterSeconds)); + $statement = $this->pdo->prepare( + 'DELETE FROM rate_limits + WHERE (blocked_until IS NULL AND last_attempt_at < :cutoff) + OR (blocked_until IS NOT NULL AND blocked_until < :cutoff)' + ); + $statement->execute(['cutoff' => $cutoff]); + + return $statement->rowCount(); + } + + private function buildState(array $policy, ?array $row, int $nowTs): array + { + $attempts = 0; + $blockedUntil = null; + $retryAfter = 0; + $allowed = true; + $resetAtTs = $nowTs + $policy['window_seconds']; + + if ($row) { + $windowStartTs = $this->timestampFromDatabase($row['window_start']); + $blockedUntilTs = $this->timestampFromDatabase($row['blocked_until']); + + if ($blockedUntilTs !== null && $blockedUntilTs > $nowTs) { + $attempts = (int) $row['attempts']; + $blockedUntil = (string) $row['blocked_until']; + $retryAfter = max(1, $blockedUntilTs - $nowTs); + $allowed = false; + $resetAtTs = $blockedUntilTs; + } elseif ($windowStartTs !== null && ($windowStartTs + $policy['window_seconds']) > $nowTs) { + $attempts = (int) $row['attempts']; + $resetAtTs = $windowStartTs + $policy['window_seconds']; + } + } + + return [ + 'action' => $policy['action'], + 'allowed' => $allowed, + 'attempts' => $attempts, + 'max_attempts' => $policy['max_attempts'], + 'remaining_attempts' => max(0, $policy['max_attempts'] - $attempts), + 'window_seconds' => $policy['window_seconds'], + 'block_seconds' => $policy['block_seconds'], + 'retry_after_seconds' => $retryAfter, + 'reset_at' => $this->formatTimestamp($resetAtTs), + 'blocked_until' => $blockedUntil, + ]; + } + + private function resolvePolicy(string $action): array + { + $normalizedAction = mb_strtolower(trim($action)); + + if ($normalizedAction === '') { + throw new RuntimeException('Die Rate-Limit-Aktion ist erforderlich.'); + } + + $policy = $this->config[$normalizedAction] ?? null; + + if (!is_array($policy)) { + throw new RuntimeException(sprintf('Fuer die Aktion "%s" ist kein Rate-Limit konfiguriert.', $normalizedAction)); + } + + return [ + 'action' => $normalizedAction, + 'max_attempts' => max(1, (int) ($policy['max_attempts'] ?? 5)), + 'window_seconds' => max(60, (int) ($policy['window_seconds'] ?? 900)), + 'block_seconds' => max(60, (int) ($policy['block_seconds'] ?? 900)), + ]; + } + + private function bucketKey(string $scope): string + { + $scope = trim($scope); + + if ($scope === '') { + throw new RuntimeException('Der Rate-Limit-Scope ist erforderlich.'); + } + + return hash('sha256', $scope); + } + + private function formatTimestamp(int $timestamp): string + { + return gmdate('Y-m-d H:i:s', $timestamp); + } + + private function timestampFromDatabase(?string $value): ?int + { + if ($value === null || trim($value) === '') { + return null; + } + + $date = DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $value, $this->utc); + + if (!$date instanceof DateTimeImmutable) { + return null; + } + + return $date->getTimestamp(); + } +} diff --git a/app/Services/RfidService.php b/app/Services/RfidService.php index bc8d9a3..bb835cd 100644 --- a/app/Services/RfidService.php +++ b/app/Services/RfidService.php @@ -22,7 +22,7 @@ final class RfidService $token = bin2hex(random_bytes(20)); $statement = $this->pdo->prepare( 'INSERT INTO rfid_devices (tenant_id, name, location_label, device_token, status) - VALUES (:tenant_id, :name, :location_label, :device_token, "planned")' + VALUES (:tenant_id, :name, :location_label, :device_token, "active")' ); $statement->execute([ 'tenant_id' => $tenantId, @@ -43,6 +43,18 @@ final class RfidService throw new RuntimeException('Die Karten-UID darf nicht leer sein.'); } + $memberStatement = $this->pdo->prepare( + 'SELECT id FROM members WHERE tenant_id = :tenant_id AND id = :id AND status = "active" LIMIT 1' + ); + $memberStatement->execute([ + 'tenant_id' => $tenantId, + 'id' => $memberId, + ]); + + if (!$memberStatement->fetchColumn()) { + throw new RuntimeException('Das ausgewaehlte Mitglied ist fuer RFID nicht verfuegbar.'); + } + $statement = $this->pdo->prepare( 'INSERT INTO rfid_tags (tenant_id, member_id, uid_hash, label, status) VALUES (:tenant_id, :member_id, :uid_hash, :label, "active")' @@ -80,6 +92,10 @@ final class RfidService throw new RuntimeException('Das RFID-Geraet ist unbekannt.'); } + if (($device['status'] ?? '') !== 'active') { + throw new RuntimeException('Das RFID-Geraet ist nicht aktiv.'); + } + $status = 'received'; $tagStatement = $this->pdo->prepare( 'SELECT id FROM rfid_tags WHERE tenant_id = :tenant_id AND uid_hash = :uid_hash LIMIT 1' diff --git a/app/Services/SecurityHeadersService.php b/app/Services/SecurityHeadersService.php new file mode 100644 index 0000000..079c95b --- /dev/null +++ b/app/Services/SecurityHeadersService.php @@ -0,0 +1,97 @@ +isHttpsRequest(); + + $headers = [ + 'Content-Security-Policy' => $this->buildContentSecurityPolicy($isHttps), + 'Referrer-Policy' => 'strict-origin-when-cross-origin', + 'Permissions-Policy' => 'accelerometer=(), autoplay=(), camera=(), display-capture=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()', + 'Cross-Origin-Opener-Policy' => 'same-origin', + 'Cross-Origin-Resource-Policy' => 'same-origin', + 'Origin-Agent-Cluster' => '?1', + 'X-Content-Type-Options' => 'nosniff', + 'X-Frame-Options' => 'SAMEORIGIN', + 'X-Permitted-Cross-Domain-Policies' => 'none', + ]; + + if ($isHttps) { + $headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains'; + } + + return $headers; + } + + public function send(array $overrides = [], ?bool $isHttps = null): void + { + if (headers_sent()) { + return; + } + + $headers = $this->buildHeaders($isHttps); + + foreach ($overrides as $name => $value) { + if (!is_string($name) || trim($name) === '') { + continue; + } + + if ($value === null) { + unset($headers[$name]); + continue; + } + + $headers[$name] = (string) $value; + } + + header_remove('X-Powered-By'); + + foreach ($headers as $name => $value) { + header($name . ': ' . $value, true); + } + } + + private function buildContentSecurityPolicy(bool $isHttps): string + { + $directives = [ + "default-src 'self'", + "base-uri 'self'", + "form-action 'self'", + "frame-ancestors 'self'", + "object-src 'none'", + "connect-src 'self'", + "font-src 'self' data:", + "img-src 'self' data:", + "manifest-src 'self'", + "script-src 'self'", + "style-src 'self'", + ]; + + if ($isHttps) { + $directives[] = 'upgrade-insecure-requests'; + } + + return implode('; ', $directives); + } + + private function isHttpsRequest(): bool + { + $https = $_SERVER['HTTPS'] ?? null; + + if (is_string($https) && $https !== '' && strtolower($https) !== 'off') { + return true; + } + + $forwardedProto = $_SERVER['HTTP_X_FORWARDED_PROTO'] ?? ''; + + if (is_string($forwardedProto) && strtolower($forwardedProto) === 'https') { + return true; + } + + return (string) ($_SERVER['SERVER_PORT'] ?? '') === '443'; + } +} diff --git a/app/Services/SetupService.php b/app/Services/SetupService.php index 68b6551..331ac2f 100644 --- a/app/Services/SetupService.php +++ b/app/Services/SetupService.php @@ -43,6 +43,8 @@ final class SetupService throw new RuntimeException('Das Admin-Passwort muss mindestens 12 Zeichen lang sein.'); } + $this->resolveMailFrom($input, $this->normalizeAppUrl((string) $input['app_url'])); + $config = [ 'host' => trim((string) $input['db_host']), 'port' => (int) $input['db_port'], @@ -106,6 +108,8 @@ final class SetupService private function buildEnvFile(array $input): string { + $appUrl = $this->normalizeAppUrl((string) $input['app_url']); + $mailFrom = $this->resolveMailFrom($input, $appUrl); $appKey = bin2hex(random_bytes(32)); $rfidSecret = bin2hex(random_bytes(24)); @@ -113,9 +117,11 @@ final class SetupService 'APP_NAME="Kaffeekasse SaaS"', 'APP_ENV=production', 'APP_DEBUG=0', - 'APP_URL=' . trim((string) $input['app_url']), + 'APP_URL=' . $appUrl, 'APP_TIMEZONE=Europe/Berlin', 'APP_KEY=' . $appKey, + 'ALLOW_PUBLIC_REGISTRATION=1', + 'RFID_INGEST_ENABLED=0', '', 'DB_HOST=' . trim((string) $input['db_host']), 'DB_PORT=' . (int) $input['db_port'], @@ -123,10 +129,64 @@ final class SetupService 'DB_USER=' . trim((string) $input['db_user']), 'DB_PASS=' . (string) ($input['db_pass'] ?? ''), '', - 'MAIL_FROM=' . trim((string) ($input['mail_from'] ?? 'noreply@example.com')), + 'MAIL_FROM=' . $mailFrom, + 'MAIL_REPLY_TO=' . $mailFrom, + 'MAIL_SUBJECT_PREFIX="[Kaffeekasse] "', + 'MAIL_LOG_FALLBACK=1', + 'MAIL_LOG_FILE=storage/logs/mail.log', + '', + 'PASSWORD_RESET_URL=' . $appUrl . '/reset-password?token={{token}}', + 'PASSWORD_RESET_TTL_MINUTES=60', + 'PASSWORD_RESET_MAX_ACTIVE_TOKENS=3', + 'PASSWORD_RESET_SUBJECT="Passwort zuruecksetzen"', + '', + 'RATE_LIMIT_LOGIN_MAX_ATTEMPTS=5', + 'RATE_LIMIT_LOGIN_WINDOW_SECONDS=900', + 'RATE_LIMIT_LOGIN_BLOCK_SECONDS=900', + 'RATE_LIMIT_REGISTRATION_MAX_ATTEMPTS=3', + 'RATE_LIMIT_REGISTRATION_WINDOW_SECONDS=3600', + 'RATE_LIMIT_REGISTRATION_BLOCK_SECONDS=3600', + '', 'RFID_SHARED_SECRET=' . $rfidSecret, ]; return implode(PHP_EOL, $lines) . PHP_EOL; } + + private function normalizeAppUrl(string $appUrl): string + { + return rtrim(trim($appUrl), '/'); + } + + private function resolveMailFrom(array $input, string $appUrl): string + { + $mailFrom = mb_strtolower(trim((string) ($input['mail_from'] ?? ''))); + + if ($mailFrom === '' || $mailFrom === 'noreply@example.com') { + return $this->defaultMailFrom($appUrl); + } + + if (!filter_var($mailFrom, FILTER_VALIDATE_EMAIL)) { + throw new RuntimeException('Die Mail-Absenderadresse ist ungueltig.'); + } + + return $mailFrom; + } + + private function defaultMailFrom(string $appUrl): string + { + $host = parse_url($appUrl, PHP_URL_HOST); + + if (!is_string($host)) { + return 'noreply@example.com'; + } + + $host = preg_replace('/^www\./i', '', mb_strtolower(trim($host))) ?? ''; + + if ($host === '' || !str_contains($host, '.')) { + return 'noreply@example.com'; + } + + return 'noreply@' . $host; + } } diff --git a/bin/cron.php b/bin/cron.php index 8c38b4d..1512b29 100644 --- a/bin/cron.php +++ b/bin/cron.php @@ -2,6 +2,10 @@ declare(strict_types=1); +use App\Services\MailerService; +use App\Services\PasswordResetService; +use App\Services\RateLimiterService; + $rootPath = dirname(__DIR__); $lockFile = $rootPath . '/storage/cron.lock'; @@ -20,6 +24,17 @@ try { } $pdo = $app['database']->pdo(); + $passwordResetService = new PasswordResetService( + $pdo, + new MailerService($app['config']['mail'] ?? []), + $app['config']['password_reset'] ?? [] + ); + $rateLimiterService = new RateLimiterService( + $pdo, + $app['config']['rate_limits'] ?? [] + ); + $purgedPasswordResetTokens = $passwordResetService->purgeExpiredTokens(); + $prunedRateLimits = $rateLimiterService->prune(); $pendingRfid = (int) $pdo->query( 'SELECT COUNT(*) FROM rfid_events WHERE status = "received"' @@ -27,9 +42,14 @@ try { $summary = [ 'timestamp' => gmdate(DATE_ATOM), + 'cleanup' => [ + 'password_reset_tokens_removed' => $purgedPasswordResetTokens, + 'rate_limit_entries_removed' => $prunedRateLimits, + ], 'pending_rfid_events' => $pendingRfid, 'notes' => [ - 'In diesem MVP dient Cron als technischer Haken fuer spaetere Reminder, Exporte und RFID-Regeln.', + 'Cron bereinigt abgelaufene Passwort-Reset-Tokens und veraltete Rate-Limit-Eintraege.', + 'In diesem MVP dient Cron ausserdem als technischer Haken fuer spaetere Reminder, Exporte und RFID-Regeln.', ], ]; diff --git a/config/app.php b/config/app.php index ecb8d32..ba71e26 100644 --- a/config/app.php +++ b/config/app.php @@ -7,6 +7,8 @@ return [ 'url' => rtrim($_ENV['APP_URL'] ?? 'http://localhost:8080', '/'), 'timezone' => $_ENV['APP_TIMEZONE'] ?? 'Europe/Berlin', 'app_key' => $_ENV['APP_KEY'] ?? '', + 'allow_public_registration' => (($_ENV['ALLOW_PUBLIC_REGISTRATION'] ?? '0') === '1'), + 'rfid_ingest_enabled' => (($_ENV['RFID_INGEST_ENABLED'] ?? '0') === '1'), 'db' => [ 'host' => $_ENV['DB_HOST'] ?? '127.0.0.1', 'port' => (int) ($_ENV['DB_PORT'] ?? 3306), @@ -15,5 +17,31 @@ return [ 'pass' => $_ENV['DB_PASS'] ?? '', 'charset' => 'utf8mb4', ], + 'mail' => [ + 'from' => trim($_ENV['MAIL_FROM'] ?? 'noreply@example.com'), + 'reply_to' => trim($_ENV['MAIL_REPLY_TO'] ?? ''), + 'subject_prefix' => $_ENV['MAIL_SUBJECT_PREFIX'] ?? '[Kaffeekasse] ', + 'log_fallback' => (($_ENV['MAIL_LOG_FALLBACK'] ?? '1') === '1'), + 'log_file' => trim($_ENV['MAIL_LOG_FILE'] ?? ''), + ], + 'password_reset' => [ + 'reset_url' => $_ENV['PASSWORD_RESET_URL'] + ?? rtrim($_ENV['APP_URL'] ?? 'http://localhost:8080', '/') . '/reset-password?token={{token}}', + 'ttl_minutes' => max(5, (int) ($_ENV['PASSWORD_RESET_TTL_MINUTES'] ?? 60)), + 'max_active_tokens' => max(1, (int) ($_ENV['PASSWORD_RESET_MAX_ACTIVE_TOKENS'] ?? 3)), + 'subject' => $_ENV['PASSWORD_RESET_SUBJECT'] ?? 'Passwort zuruecksetzen', + ], + 'rate_limits' => [ + 'login' => [ + 'max_attempts' => max(1, (int) ($_ENV['RATE_LIMIT_LOGIN_MAX_ATTEMPTS'] ?? 5)), + 'window_seconds' => max(60, (int) ($_ENV['RATE_LIMIT_LOGIN_WINDOW_SECONDS'] ?? 900)), + 'block_seconds' => max(60, (int) ($_ENV['RATE_LIMIT_LOGIN_BLOCK_SECONDS'] ?? 900)), + ], + 'registration' => [ + 'max_attempts' => max(1, (int) ($_ENV['RATE_LIMIT_REGISTRATION_MAX_ATTEMPTS'] ?? 3)), + 'window_seconds' => max(60, (int) ($_ENV['RATE_LIMIT_REGISTRATION_WINDOW_SECONDS'] ?? 3600)), + 'block_seconds' => max(60, (int) ($_ENV['RATE_LIMIT_REGISTRATION_BLOCK_SECONDS'] ?? 3600)), + ], + ], 'rfid_shared_secret' => $_ENV['RFID_SHARED_SECRET'] ?? '', ]; diff --git a/database/schema.sql b/database/schema.sql index 6923ffc..d9e0515 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -7,6 +7,36 @@ CREATE TABLE IF NOT EXISTS users ( created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +CREATE TABLE IF NOT EXISTS password_reset_tokens ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT UNSIGNED NOT NULL, + email VARCHAR(190) NOT NULL, + token_hash CHAR(64) NOT NULL UNIQUE, + requested_ip_hash CHAR(64) NULL, + requested_user_agent_hash CHAR(64) NULL, + expires_at DATETIME NOT NULL, + consumed_at DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_password_reset_user_created (user_id, created_at), + INDEX idx_password_reset_expires (expires_at), + CONSTRAINT fk_password_reset_tokens_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS rate_limits ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + action_key VARCHAR(80) NOT NULL, + bucket_key CHAR(64) NOT NULL, + attempts INT UNSIGNED NOT NULL DEFAULT 0, + window_start DATETIME NOT NULL, + last_attempt_at DATETIME NOT NULL, + blocked_until DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uniq_rate_limit_bucket (action_key, bucket_key), + INDEX idx_rate_limit_blocked_until (blocked_until), + INDEX idx_rate_limit_last_attempt (last_attempt_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + CREATE TABLE IF NOT EXISTS tenants ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, name VARCHAR(190) NOT NULL, @@ -57,7 +87,7 @@ CREATE TABLE IF NOT EXISTS members ( user_id BIGINT UNSIGNED NULL, display_name VARCHAR(160) NOT NULL, email VARCHAR(190) NULL, - access_pin VARCHAR(20) NULL, + access_pin VARCHAR(255) NULL, status ENUM('active', 'archived') NOT NULL DEFAULT 'active', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uniq_member_email (tenant_id, email), diff --git a/public/index.php b/public/index.php index 0aee741..9a98888 100644 --- a/public/index.php +++ b/public/index.php @@ -5,9 +5,12 @@ declare(strict_types=1); use App\Controllers\PlatformController; use App\Controllers\TenantController; use App\Core\Response; +use App\Services\SecurityHeadersService; $app = require dirname(__DIR__) . '/app/bootstrap.php'; +(new SecurityHeadersService())->send(); + $platform = new PlatformController( $app['rootPath'], $app['config'], @@ -21,7 +24,8 @@ $tenantController = new TenantController( $app['database'], $app['view'], $app['session'], - $app['csrf'] + $app['csrf'], + $app['config'] ); $path = current_path(); @@ -48,6 +52,16 @@ try { return; } + if ($path === '/password/forgot') { + $method === 'POST' ? $platform->passwordResetRequestSubmit() : $platform->passwordResetRequestForm(); + return; + } + + if ($path === '/reset-password') { + $method === 'POST' ? $platform->passwordResetConfirmSubmit() : $platform->passwordResetConfirmForm(); + return; + } + if ($path === '/admin' && $method === 'GET') { $platform->adminDashboard(); return; @@ -59,6 +73,11 @@ try { } if ($path === '/api/rfid/intake' && $method === 'POST') { + if (!($app['config']['rfid_ingest_enabled'] ?? false)) { + Response::notFound(); + return; + } + $tenantController->rfidIngest(); return; } @@ -97,6 +116,16 @@ try { return; } + if ($subPath === 'exports/balances.csv' && $method === 'GET') { + $tenantController->exportBalances($tenantSlug); + return; + } + + if ($subPath === 'exports/bookings.csv' && $method === 'GET') { + $tenantController->exportBookings($tenantSlug); + return; + } + if ($subPath === 'payments') { $tenantController->payments($tenantSlug); return; diff --git a/resources/views/admin/login.php b/resources/views/admin/login.php index c4311c8..1e17f7f 100644 --- a/resources/views/admin/login.php +++ b/resources/views/admin/login.php @@ -1,22 +1,39 @@ - +
- Plattform + Plattformzugang

Admin-Login

-

Fuer Betrieb, Mandantenuebersicht, Support und spaetere Vermarktungssteuerung.

+

Fuer Pilotbetreuung, Mandantenuebersicht und Betriebsaufgaben. Mitglieder nutzen dafuer nicht diese Seite, sondern den jeweiligen Standort-Login.

-
- - - - -
+
+
+ + + + +

Passwort vergessen? Reset anstossen

+
+ + +
diff --git a/resources/views/auth/reset-confirm.php b/resources/views/auth/reset-confirm.php new file mode 100644 index 0000000..20a2b09 --- /dev/null +++ b/resources/views/auth/reset-confirm.php @@ -0,0 +1,119 @@ + + +
+ Reset bestaetigen +

Neues Passwort setzen

+

+ + So bleibt der Login-Reset von PIN, RFID und sonstigen Standortdaten getrennt. +

+
+ +
+ +
+ + + + + + + + + + +

+ + + Gueltig bis UTC. + +

+ + + + +

Nach erfolgreicher Bestaetigung soll dieser Flow wieder in den passenden Login zurueckfuehren.

+

Zurueck zum Login

+
+ +
+

Reset-Link neu anfordern

+

+ +

+

Neuen Reset-Link anfordern

+

Zurueck zum Login

+
+ + + +
+ + diff --git a/resources/views/auth/reset-request.php b/resources/views/auth/reset-request.php new file mode 100644 index 0000000..4109504 --- /dev/null +++ b/resources/views/auth/reset-request.php @@ -0,0 +1,92 @@ + + +
+ Passwort vergessen +

Reset-Anfrage vorbereiten

+

+ + Im Pilotbetrieb kann die Bestaetigung noch manuell begleitet werden. +

+
+ +
+
+ + + + + + + + + + + + + +

Nach dem Versand soll der Link wieder auf diese Strecke zurueckfuehren und danach zum passenden Login.

+

Zurueck zum Login

+
+ + +
+ + diff --git a/resources/views/home/index.php b/resources/views/home/index.php index 615f75e..47a5f8c 100644 --- a/resources/views/home/index.php +++ b/resources/views/home/index.php @@ -2,35 +2,35 @@
- Von der Papierliste zur produktiven SaaS -

Kaffeeliste, digitale Strichliste und RFID-Roadmap in einem System.

-

Die Anwendung verbindet klassische Kaffeekasse, digitale Selbstbuchung und spaetere Geraeteintegration. Mandanten registrieren sich selbst, verwalten Mitglieder, Produkte, Einzahlungen und Papierlisten in einem PHP/MySQL-Stack, der auf Netcup-Webspace laeuft.

+ Pilotbetrieb fuer Teams und Vereine +

Die digitale Kaffeeliste ist bereit fuer echte Pilotstandorte, nicht fuer leere Demo-Versprechen.

+

Aktuell wird der Ablauf mit ausgewaehlten Teams verfeinert: Pilotmandanten koennen sich registrieren, Mitglieder fuer die Selbstbuchung anlegen und Backoffice-Prozesse fuer Papierlisten, Einzahlungen und spaetere RFID-Anbindung realistisch testen.