Phase1 Bearbeitung

This commit is contained in:
2026-06-15 18:36:57 +02:00
parent e4fa714419
commit 06645f1e9c
29 changed files with 3367 additions and 300 deletions
+270 -1
View File
@@ -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';
}
}
+533 -96
View File
@@ -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);
}
}
}
+40 -1
View File
@@ -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,
]);
}
}
+408
View File
@@ -0,0 +1,408 @@
<?php
namespace App\Services;
use PDO;
use RuntimeException;
final class ExportService
{
private const CSV_DELIMITER = ';';
private const FLUSH_EVERY_ROWS = 250;
public function __construct(private readonly PDO $pdo)
{
}
public function streamMemberBalancesCsv(int $tenantId, ?string $filename = null): void
{
$this->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();
}
}
+270
View File
@@ -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();
}
}
+177
View File
@@ -0,0 +1,177 @@
<?php
namespace App\Services;
use RuntimeException;
final class MailerService
{
private readonly string $rootPath;
public function __construct(private readonly array $config = [])
{
$this->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));
}
}
+445
View File
@@ -0,0 +1,445 @@
<?php
namespace App\Services;
use DateTimeImmutable;
use DateTimeZone;
use PDO;
use RuntimeException;
final class PasswordResetService
{
private readonly DateTimeZone $utc;
public function __construct(
private readonly PDO $pdo,
private readonly MailerService $mailer,
private readonly array $config = []
) {
$this->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();
}
}
+272
View File
@@ -0,0 +1,272 @@
<?php
namespace App\Services;
use DateTimeImmutable;
use DateTimeZone;
use PDO;
use RuntimeException;
final class RateLimiterService
{
private readonly DateTimeZone $utc;
public function __construct(
private readonly PDO $pdo,
private readonly array $config = []
) {
$this->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();
}
}
+17 -1
View File
@@ -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'
+97
View File
@@ -0,0 +1,97 @@
<?php
namespace App\Services;
final class SecurityHeadersService
{
public function buildHeaders(?bool $isHttps = null): array
{
$isHttps = $isHttps ?? $this->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';
}
}
+62 -2
View File
@@ -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;
}
}