459 lines
16 KiB
PHP
459 lines
16 KiB
PHP
<?php
|
|
|
|
namespace App\Controllers;
|
|
|
|
use App\Core\Auth;
|
|
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;
|
|
|
|
final class PlatformController
|
|
{
|
|
public function __construct(
|
|
private readonly string $rootPath,
|
|
private readonly array $config,
|
|
private readonly Database $database,
|
|
private readonly View $view,
|
|
private readonly Session $session,
|
|
private readonly Csrf $csrf
|
|
) {
|
|
}
|
|
|
|
public function home(): void
|
|
{
|
|
$setup = new SetupService($this->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' => 'Kaffeekasse fuer Teams, Vereine und Coworking',
|
|
'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. Jetzt kannst du dich als Plattform-Admin 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. Fuer einen Pilotzugang bitte direkt den Betreiber kontaktieren.');
|
|
Response::redirect('/');
|
|
}
|
|
|
|
$this->view->render('home/register', [
|
|
'title' => 'Mandant 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', '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');
|
|
}
|
|
}
|
|
|
|
public function adminLoginForm(): void
|
|
{
|
|
$this->view->render('admin/login', [
|
|
'title' => 'Plattform-Login',
|
|
]);
|
|
}
|
|
|
|
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-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();
|
|
$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';
|
|
}
|
|
}
|