rootPath); $stats = null; if ($setup->isInstalled() && $this->database->isConfigured()) { try { $pdo = $this->database->pdo(); $stats = [ 'tenants' => (int) $pdo->query('SELECT COUNT(*) FROM tenants')->fetchColumn(), 'members' => (int) $pdo->query('SELECT COUNT(*) FROM members')->fetchColumn(), 'events' => (int) $pdo->query('SELECT COUNT(*) FROM consumption_events')->fetchColumn(), ]; } catch (\Throwable) { $stats = null; } } $this->view->render('home/index', [ 'title' => 'Kaffeeliste für Teams, Vereine und Büroküchen', 'setupComplete' => $setup->isInstalled(), 'stats' => $stats, 'allowPublicRegistration' => $this->config['allow_public_registration'] ?? false, ]); } public function installForm(): void { $setup = new SetupService($this->rootPath); if ($setup->isInstalled()) { Response::redirect('/admin/login'); } $this->view->render('install/index', [ 'title' => 'Installation', ]); } public function installSubmit(): void { $this->assertCsrf(); $setup = new SetupService($this->rootPath); try { $setup->install($_POST); $this->session->flash('success', 'Die Installation wurde abgeschlossen. Sie können sich jetzt als Plattform-Administrator anmelden.'); Response::redirect('/admin/login'); } catch (\Throwable $throwable) { remember_old_input($_POST); $this->session->flash('error', $throwable->getMessage()); Response::redirect('/install'); } } public function registerForm(): void { if (!(new SetupService($this->rootPath))->isInstalled()) { Response::redirect('/install'); } if (!$this->allowsPublicRegistration()) { $this->session->flash('error', 'Die offene Registrierung ist derzeit deaktiviert. Bitte kontaktieren Sie den Betreiber.'); Response::redirect('/'); } $this->view->render('home/register', [ 'title' => 'Kaffeeliste registrieren', ]); } public function registerSubmit(): void { $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', 'Die Kaffeeliste wurde angelegt. Sie können sich 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'); } } public function adminLoginForm(): void { $this->view->render('admin/login', [ 'title' => 'Kaffeelisten verwalten', ]); } public function adminLoginSubmit(): void { $this->assertCsrf(); 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' => $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) { remember_old_input($_POST); $this->session->flash('error', $throwable->getMessage()); Response::redirect('/admin/login'); } } 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-Bestätigung stimmt nicht überein.'); } $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(); $auth = new Auth($pdo, $this->session); if (!$auth->checkPlatformAdmin()) { $this->session->flash('error', 'Bitte zuerst als Plattform-Admin anmelden.'); Response::redirect('/admin/login'); } $tenants = $pdo->query( 'SELECT t.*, COUNT(DISTINCT m.id) AS member_count, COUNT(DISTINCT p.id) AS product_count FROM tenants t LEFT JOIN members m ON m.tenant_id = t.id LEFT JOIN products p ON p.tenant_id = t.id GROUP BY t.id ORDER BY t.created_at DESC' )->fetchAll(); $this->view->render('admin/index', [ 'title' => 'Plattform-Dashboard', 'tenants' => $tenants, ]); } public function adminLogout(): void { $this->assertCsrf(); $pdo = $this->database->pdo(); $auth = new Auth($pdo, $this->session); $auth->logout(); $this->session->flash('success', 'Du wurdest aus dem Plattformbereich abgemeldet.'); Response::redirect('/admin/login'); } private function assertCsrf(): void { if (!request_origin_matches_host() || !$this->csrf->validate($_POST['_token'] ?? null)) { 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'; } }