Files
kaffeekasse-saas/app/saas-auth.php
T
clemens 19ff11c21a M3 Mandant-Einstellungen und Rollencheck ergänzen
- zentrale Owner/Admin-Rollenpruefung in SaaS-Auth ergaenzen
- Mandant-Einstellungen fuer Preise, PayPal und Basisdaten bauen
- Settings-Flow-Test, Navigation und M3-Dokumentation aktualisieren
2026-07-12 22:32:51 +02:00

551 lines
17 KiB
PHP

<?php
declare(strict_types=1);
require_once __DIR__ . '/bootstrap.php';
require_once __DIR__ . '/database.php';
function saas_email_norm(string $email): string
{
return strtolower(trim($email));
}
function saas_slugify(string $value): string
{
$slug = strtolower(trim($value));
$slug = preg_replace('/[^a-z0-9]+/', '-', $slug) ?? '';
$slug = trim($slug, '-');
return $slug;
}
function saas_html(mixed $value): string
{
return htmlspecialchars((string)$value, ENT_QUOTES, 'UTF-8');
}
function saas_parse_money_cents(mixed $value): ?int
{
$normalized = trim((string)$value);
$normalized = str_replace(["\xc2\xa0", ' '], '', $normalized);
if ($normalized === '') {
return null;
}
if (str_contains($normalized, ',') && str_contains($normalized, '.')) {
$normalized = str_replace('.', '', $normalized);
}
$normalized = str_replace(',', '.', $normalized);
if (!preg_match('/^-?[0-9]+(?:\.[0-9]{1,2})?$/', $normalized)) {
return null;
}
$negative = str_starts_with($normalized, '-');
if ($negative) {
$normalized = substr($normalized, 1);
}
[$euros, $cents] = array_pad(explode('.', $normalized, 2), 2, '0');
$result = ((int)$euros * 100) + (int)str_pad(substr($cents, 0, 2), 2, '0');
return $negative ? -$result : $result;
}
function saas_format_money_cents(mixed $cents): string
{
return number_format(((int)$cents) / 100, 2, ',', '');
}
function saas_current_user(?PDO $pdo = null): ?array
{
static $cached = false;
static $user = null;
app_start_session();
if ($cached) {
return $user;
}
$cached = true;
$userId = isset($_SESSION['saas_user_id']) ? (int)$_SESSION['saas_user_id'] : 0;
$tenantId = isset($_SESSION['saas_tenant_id']) ? (int)$_SESSION['saas_tenant_id'] : 0;
if ($userId <= 0 || $tenantId <= 0) {
return null;
}
try {
$pdo = $pdo ?? app_db_pdo();
$stmt = $pdo->prepare(
'SELECT
u.id AS user_id,
u.email,
u.email_norm,
u.display_name,
u.status AS user_status,
t.id AS tenant_id,
t.slug AS tenant_slug,
t.name AS tenant_name,
t.status AS tenant_status,
tm.role,
tm.status AS membership_status
FROM users u
JOIN tenant_memberships tm ON tm.user_id = u.id
JOIN tenants t ON t.id = tm.tenant_id
WHERE u.id = ?
AND t.id = ?
AND u.status = ?
AND tm.status = ?
AND t.status = ?
LIMIT 1'
);
$stmt->execute([$userId, $tenantId, 'active', 'active', 'active']);
$row = $stmt->fetch();
} catch (Throwable $e) {
return null;
}
if ($row === false) {
unset($_SESSION['saas_user_id'], $_SESSION['saas_tenant_id'], $_SESSION['saas_role']);
return null;
}
$user = $row;
return $user;
}
function saas_session_login(array $identity): void
{
app_start_session();
session_regenerate_id(true);
$_SESSION['saas_user_id'] = (int)$identity['user_id'];
$_SESSION['saas_tenant_id'] = (int)$identity['tenant_id'];
$_SESSION['saas_role'] = (string)$identity['role'];
}
function saas_logout(): void
{
app_start_session();
unset($_SESSION['saas_user_id'], $_SESSION['saas_tenant_id'], $_SESSION['saas_role']);
}
function saas_require_login(): array
{
$user = saas_current_user();
if ($user !== null) {
return $user;
}
header('Location: login.php');
exit;
}
function saas_user_has_role(array|string $roles, ?array $user = null): bool
{
$user = $user ?? saas_current_user();
if ($user === null) {
return false;
}
$allowed = is_array($roles) ? $roles : [$roles];
return in_array((string)($user['role'] ?? ''), $allowed, true);
}
function saas_require_role(array|string $roles): array
{
$user = saas_require_login();
if (saas_user_has_role($roles, $user)) {
return $user;
}
http_response_code(403);
die('Keine Berechtigung.');
}
function saas_can_manage_tenant_settings(?array $user = null): bool
{
return saas_user_has_role(['owner', 'admin'], $user);
}
function saas_fetch_tenant_settings(PDO $pdo, int $tenantId): ?array
{
$stmt = $pdo->prepare(
'SELECT
t.id AS tenant_id,
t.slug,
t.name,
t.status,
t.timezone,
t.locale,
t.currency_code,
ts.mark_price_cents,
ts.self_entry_enabled,
ts.paypal_enabled,
ts.paypal_url_template,
ts.sheet_window_days,
ts.negative_warning_cents
FROM tenants t
LEFT JOIN tenant_settings ts ON ts.tenant_id = t.id
WHERE t.id = ?
LIMIT 1'
);
$stmt->execute([$tenantId]);
$settings = $stmt->fetch();
if ($settings === false) {
return null;
}
if ($settings['mark_price_cents'] === null) {
$pdo->prepare('INSERT INTO tenant_settings (tenant_id) VALUES (?)')->execute([$tenantId]);
return saas_fetch_tenant_settings($pdo, $tenantId);
}
return [
'tenant_id' => (int)$settings['tenant_id'],
'slug' => (string)$settings['slug'],
'name' => (string)$settings['name'],
'status' => (string)$settings['status'],
'timezone' => (string)$settings['timezone'],
'locale' => (string)$settings['locale'],
'currency_code' => (string)$settings['currency_code'],
'mark_price_cents' => (int)$settings['mark_price_cents'],
'self_entry_enabled' => (int)$settings['self_entry_enabled'],
'paypal_enabled' => (int)$settings['paypal_enabled'],
'paypal_url_template' => (string)$settings['paypal_url_template'],
'sheet_window_days' => (int)$settings['sheet_window_days'],
'negative_warning_cents' => (int)$settings['negative_warning_cents'],
];
}
function saas_update_tenant_settings(PDO $pdo, int $tenantId, array $input): array
{
$tenantName = trim((string)($input['tenant_name'] ?? ''));
$timezone = trim((string)($input['timezone'] ?? 'Europe/Berlin'));
$locale = trim((string)($input['locale'] ?? 'de-DE'));
$currencyCode = strtoupper(trim((string)($input['currency_code'] ?? 'EUR')));
$markPriceCents = saas_parse_money_cents($input['mark_price'] ?? '');
$sheetWindowDays = filter_var($input['sheet_window_days'] ?? null, FILTER_VALIDATE_INT);
$negativeWarningCents = saas_parse_money_cents($input['negative_warning'] ?? '');
$paypalUrlTemplate = trim((string)($input['paypal_url_template'] ?? ''));
$errors = [];
if (strlen($tenantName) < 3 || strlen($tenantName) > 255) {
$errors[] = 'Der Kundenname muss zwischen 3 und 255 Zeichen lang sein.';
}
if (!in_array($timezone, DateTimeZone::listIdentifiers(), true)) {
$errors[] = 'Die Zeitzone ist ungueltig.';
}
if (!preg_match('/^[a-z]{2}-[A-Z]{2}$/', $locale)) {
$errors[] = 'Die Locale muss dem Muster de-DE entsprechen.';
}
if (!preg_match('/^[A-Z]{3}$/', $currencyCode)) {
$errors[] = 'Der Waehrungscode muss aus drei Grossbuchstaben bestehen.';
}
if ($markPriceCents === null || $markPriceCents < 1 || $markPriceCents > 10000) {
$errors[] = 'Der Preis pro Strich muss zwischen 0,01 und 100,00 liegen.';
}
if ($sheetWindowDays === false || $sheetWindowDays < 1 || $sheetWindowDays > 365) {
$errors[] = 'Das Listenfenster muss zwischen 1 und 365 Tagen liegen.';
}
if ($negativeWarningCents === null || $negativeWarningCents < 0 || $negativeWarningCents > 100000) {
$errors[] = 'Die Schulden-Warnschwelle muss zwischen 0,00 und 1000,00 liegen.';
}
if (strlen($paypalUrlTemplate) > 1000) {
$errors[] = 'Der PayPal-Link ist zu lang.';
}
if ($errors !== []) {
return ['ok' => false, 'errors' => $errors];
}
$selfEntryEnabled = !empty($input['self_entry_enabled']) ? 1 : 0;
$paypalEnabled = !empty($input['paypal_enabled']) ? 1 : 0;
try {
$pdo->beginTransaction();
$stmt = $pdo->prepare(
'UPDATE tenants
SET name = ?, timezone = ?, locale = ?, currency_code = ?
WHERE id = ?'
);
$stmt->execute([$tenantName, $timezone, $locale, $currencyCode, $tenantId]);
$stmt = $pdo->prepare(
'INSERT INTO tenant_settings
(tenant_id, mark_price_cents, self_entry_enabled, paypal_enabled,
paypal_url_template, sheet_window_days, negative_warning_cents)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
mark_price_cents = VALUES(mark_price_cents),
self_entry_enabled = VALUES(self_entry_enabled),
paypal_enabled = VALUES(paypal_enabled),
paypal_url_template = VALUES(paypal_url_template),
sheet_window_days = VALUES(sheet_window_days),
negative_warning_cents = VALUES(negative_warning_cents)'
);
$stmt->execute([
$tenantId,
$markPriceCents,
$selfEntryEnabled,
$paypalEnabled,
$paypalUrlTemplate,
(int)$sheetWindowDays,
-abs($negativeWarningCents),
]);
$pdo->commit();
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
return [
'ok' => false,
'errors' => ['Die Einstellungen konnten nicht gespeichert werden.'],
];
}
return [
'ok' => true,
'errors' => [],
'settings' => saas_fetch_tenant_settings($pdo, $tenantId),
];
}
function saas_authenticate(PDO $pdo, string $email, string $password, string $tenantSlug = ''): array
{
$emailNorm = saas_email_norm($email);
if (!filter_var($emailNorm, FILTER_VALIDATE_EMAIL) || $password === '') {
return [
'ok' => false,
'errors' => ['E-Mail oder Passwort ist ungueltig.'],
];
}
$stmt = $pdo->prepare(
'SELECT id, email, email_norm, display_name, password_hash, status
FROM users
WHERE email_norm = ?
LIMIT 1'
);
$stmt->execute([$emailNorm]);
$user = $stmt->fetch();
if ($user === false
|| (string)$user['status'] !== 'active'
|| empty($user['password_hash'])
|| !password_verify($password, (string)$user['password_hash'])
) {
return [
'ok' => false,
'errors' => ['E-Mail oder Passwort ist ungueltig.'],
];
}
$params = [(int)$user['id'], 'active', 'active'];
$tenantFilter = '';
if (trim($tenantSlug) !== '') {
$tenantFilter = ' AND t.slug = ?';
$params[] = saas_slugify($tenantSlug);
}
$stmt = $pdo->prepare(
'SELECT
t.id AS tenant_id,
t.slug AS tenant_slug,
t.name AS tenant_name,
tm.role
FROM tenant_memberships tm
JOIN tenants t ON t.id = tm.tenant_id
WHERE tm.user_id = ?
AND tm.status = ?
AND t.status = ?' . $tenantFilter . '
ORDER BY
CASE tm.role
WHEN \'owner\' THEN 1
WHEN \'admin\' THEN 2
ELSE 3
END,
t.name
LIMIT 1'
);
$stmt->execute($params);
$membership = $stmt->fetch();
if ($membership === false) {
return [
'ok' => false,
'errors' => ['Fuer dieses Konto ist kein aktiver Mandant verfuegbar.'],
];
}
$pdo->prepare('UPDATE users SET last_login_at = NOW() WHERE id = ?')->execute([(int)$user['id']]);
return [
'ok' => true,
'identity' => [
'user_id' => (int)$user['id'],
'email' => (string)$user['email'],
'email_norm' => (string)$user['email_norm'],
'display_name' => (string)$user['display_name'],
'tenant_id' => (int)$membership['tenant_id'],
'tenant_slug' => (string)$membership['tenant_slug'],
'tenant_name' => (string)$membership['tenant_name'],
'role' => (string)$membership['role'],
],
'errors' => [],
];
}
function saas_register_tenant_owner(PDO $pdo, array $input): array
{
$tenantName = trim((string)($input['tenant_name'] ?? ''));
$tenantSlug = saas_slugify((string)($input['tenant_slug'] ?? ''));
if ($tenantSlug === '') {
$tenantSlug = saas_slugify($tenantName);
}
$displayName = trim((string)($input['display_name'] ?? ''));
$email = trim((string)($input['email'] ?? ''));
$emailNorm = saas_email_norm($email);
$password = (string)($input['password'] ?? '');
$passwordConfirm = (string)($input['password_confirm'] ?? '');
$errors = [];
if (strlen($tenantName) < 3 || strlen($tenantName) > 255) {
$errors[] = 'Der Kundenname muss zwischen 3 und 255 Zeichen lang sein.';
}
if (!preg_match('/^[a-z0-9][a-z0-9-]{1,98}[a-z0-9]$/', $tenantSlug)) {
$errors[] = 'Das Kundenkuerzel darf nur Kleinbuchstaben, Zahlen und Bindestriche enthalten.';
}
if (strlen($displayName) < 2 || strlen($displayName) > 255) {
$errors[] = 'Der Name muss zwischen 2 und 255 Zeichen lang sein.';
}
if (!filter_var($emailNorm, FILTER_VALIDATE_EMAIL)) {
$errors[] = 'Bitte eine gueltige E-Mail-Adresse eingeben.';
}
if (strlen($password) < 8) {
$errors[] = 'Das Passwort muss mindestens 8 Zeichen lang sein.';
}
if ($password !== $passwordConfirm) {
$errors[] = 'Die Passwort-Wiederholung stimmt nicht.';
}
if ($errors !== []) {
return ['ok' => false, 'errors' => $errors];
}
$stmt = $pdo->prepare('SELECT id, password_hash, status FROM users WHERE email_norm = ? LIMIT 1');
$stmt->execute([$emailNorm]);
$existingUser = $stmt->fetch();
if ($existingUser !== false && (string)$existingUser['status'] !== 'active') {
return [
'ok' => false,
'errors' => ['Dieses Benutzerkonto ist nicht aktiv.'],
];
}
if ($existingUser !== false
&& !empty($existingUser['password_hash'])
&& !password_verify($password, (string)$existingUser['password_hash'])
) {
return [
'ok' => false,
'errors' => ['Diese E-Mail ist bereits vergeben. Bitte mit dem bestehenden Passwort anmelden.'],
];
}
$stmt = $pdo->prepare('SELECT COUNT(*) FROM tenants WHERE slug = ?');
$stmt->execute([$tenantSlug]);
if ((int)$stmt->fetchColumn() > 0) {
return [
'ok' => false,
'errors' => ['Dieses Kundenkuerzel ist bereits vergeben.'],
];
}
$passwordHash = password_hash($password, PASSWORD_DEFAULT);
try {
$pdo->beginTransaction();
$stmt = $pdo->prepare(
'INSERT INTO tenants (slug, name, status, timezone, locale, currency_code)
VALUES (?, ?, ?, ?, ?, ?)'
);
$stmt->execute([$tenantSlug, $tenantName, 'active', 'Europe/Berlin', 'de-DE', 'EUR']);
$tenantId = (int)$pdo->lastInsertId();
$stmt = $pdo->prepare(
'INSERT INTO tenant_settings
(tenant_id, mark_price_cents, self_entry_enabled, paypal_enabled, paypal_url_template)
VALUES (?, ?, ?, ?, ?)'
);
$stmt->execute([$tenantId, 20, 1, 0, '']);
if ($existingUser === false) {
$stmt = $pdo->prepare(
'INSERT INTO users (email, email_norm, display_name, password_hash, status)
VALUES (?, ?, ?, ?, ?)'
);
$stmt->execute([$email, $emailNorm, $displayName, $passwordHash, 'active']);
$userId = (int)$pdo->lastInsertId();
} else {
$userId = (int)$existingUser['id'];
$stmt = $pdo->prepare(
'UPDATE users
SET email = ?, display_name = ?, password_hash = ?, status = ?
WHERE id = ?'
);
$stmt->execute([$email, $displayName, $passwordHash, 'active', $userId]);
}
$stmt = $pdo->prepare(
'INSERT INTO tenant_memberships (tenant_id, user_id, role, status, joined_at)
VALUES (?, ?, ?, ?, NOW())'
);
$stmt->execute([$tenantId, $userId, 'owner', 'active']);
$stmt = $pdo->prepare(
'INSERT INTO participants
(tenant_id, user_id, display_name, email, email_norm, active)
VALUES (?, ?, ?, ?, ?, ?)'
);
$stmt->execute([$tenantId, $userId, $displayName, $email, $emailNorm, 1]);
$pdo->commit();
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
return [
'ok' => false,
'errors' => ['Die Registrierung konnte nicht gespeichert werden.'],
];
}
return [
'ok' => true,
'identity' => [
'user_id' => $userId,
'email' => $email,
'email_norm' => $emailNorm,
'display_name' => $displayName,
'tenant_id' => $tenantId,
'tenant_slug' => $tenantSlug,
'tenant_name' => $tenantName,
'role' => 'owner',
],
'errors' => [],
];
}