Files
kaffeekasse-saas/app/saas-auth.php
T
clemensandClaude Opus 5 39f662d541 Kontofunktionen vor dem Go-Live und Landingpage-Kuerzung
Vier Luecken geschlossen, die im Alltag sofort aufgefallen waeren:

- Passwort aendern war im eingeloggten Zustand gar nicht moeglich; es gab
  nur den Reset per Mail-Link. saas_change_password() prueft das aktuelle
  Passwort, verlangt ein tatsaechlich anderes und erneuert danach die
  Session-ID. Formular in konto.php.
- mandant-auswahl.php war nur direkt nach dem Login erreichbar. Wer bei
  mehreren Mandanten Mitglied ist, musste sich zum Wechseln abmelden. Die
  Seite bedient jetzt beide Wege, die Mandantenpruefung bleibt unveraendert
  ueber saas_identity_for_user_tenant(). Menuepunkt ab zwei Mitgliedschaften.
- email_verified_at wurde nirgends geprueft, nur angezeigt - bei offener
  Selbstregistrierung konnte sich jemand mit fremder Adresse anmelden und
  alles nutzen. Erzwungen wird jetzt gezielt dort, wo eine Aktion nach
  aussen wirkt: Einladung, Info-Mail, Jahresabschluss. Der Login selbst
  bleibt bewusst frei, sonst waeren alle migrierten Bestandsnutzer mit
  NULL-Verifikation ausgesperrt. Zusaetzlich setzt der Passwort-Reset die
  Verifikation mit, weil der Mail-Link den Postfachzugriff nachweist -
  sonst blieben eingeladene Mitglieder dauerhaft unbestaetigt.
- Login landete auf konto.php statt auf dem Dashboard.

Landingpage: die gruene Vertrauenszeile auf "DSGVO-konform" gekuerzt, die
Eintraege zu Paragraf 19 UStG und "Bestehende Ablaeufe bleiben" entfernt.

Geprueft: neues scripts/check-konto-und-mandantenwechsel.php mit 18
Assertions gruen, Passwortformular zusaetzlich manuell inkl. CSRF (419).
Bestehende Suiten unveraendert gruen: HTTP-Smoke 34 Seiten, Rollenmatrix
55, Mandanten-Isolation 12, M3-Auth 9, M3-Settings 15.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 17:03:50 +02:00

1318 lines
43 KiB
PHP

<?php
declare(strict_types=1);
require_once __DIR__ . '/bootstrap.php';
require_once __DIR__ . '/database.php';
require_once __DIR__ . '/faq.php';
require_once __DIR__ . '/billing.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_should_show_auth_links(): bool
{
return app_is_dev() || app_env('APP_SHOW_AUTH_LINKS', '0') === '1';
}
function saas_token_hash(string $token): string
{
return hash('sha256', $token);
}
function saas_normalize_host(?string $host): string
{
$host = strtolower(trim((string)$host));
if ($host === '') {
return '';
}
if (str_contains($host, '://')) {
$parsedHost = parse_url($host, PHP_URL_HOST);
$host = is_string($parsedHost) ? $parsedHost : $host;
}
$host = preg_replace('/:\d+$/', '', $host) ?? $host;
return trim($host, " \t\n\r\0\x0B.");
}
function saas_primary_app_host(): string
{
return saas_normalize_host(app_env('APP_PRIMARY_HOST', ''));
}
function saas_is_primary_app_host(?string $host = null): bool
{
$primaryHost = saas_primary_app_host();
if ($primaryHost === '') {
return false;
}
$host = $host ?? ($_SERVER['HTTP_HOST'] ?? '');
return saas_normalize_host($host) === $primaryHost;
}
function saas_resolve_tenant_domain(PDO $pdo, ?string $host = null): ?array
{
$host = saas_normalize_host($host ?? ($_SERVER['HTTP_HOST'] ?? ''));
if ($host === '' || saas_is_primary_app_host($host)) {
return null;
}
$stmt = $pdo->prepare(
'SELECT
td.id AS tenant_domain_id,
td.domain,
td.domain_norm,
td.domain_type,
t.id AS tenant_id,
t.slug AS tenant_slug,
t.name AS tenant_name,
t.status AS tenant_status
FROM tenant_domains td
JOIN tenants t ON t.id = td.tenant_id
WHERE td.domain_norm = ?
AND td.status = ?
AND t.status = ?
LIMIT 1'
);
$stmt->execute([$host, 'active', 'active']);
$row = $stmt->fetch();
return $row !== false ? $row : null;
}
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.email_verified_at,
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'];
saas_clear_pending_tenant_selection();
}
function saas_logout(): void
{
app_start_session();
unset(
$_SESSION['saas_user_id'],
$_SESSION['saas_tenant_id'],
$_SESSION['saas_role'],
$_SESSION['saas_pending_user_id'],
$_SESSION['saas_pending_started_at']
);
}
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,
ts.pdf_show_empty_rows,
ts.pdf_split_mode,
ts.pdf_row_height_px,
ts.pdf_watermark_text,
ts.pdf_footer_text,
ts.payment_reminder_enabled,
ts.payment_reminder_interval_days,
ts.pdf_watermark_logo,
ts.cash_enabled,
ts.cash_contact,
ts.bank_transfer_enabled,
ts.bank_account_holder,
ts.bank_iban
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'],
'pdf_show_empty_rows' => (int)$settings['pdf_show_empty_rows'],
'pdf_split_mode' => (string)$settings['pdf_split_mode'],
'pdf_row_height_px' => (int)$settings['pdf_row_height_px'],
'pdf_watermark_text' => (string)$settings['pdf_watermark_text'],
'pdf_footer_text' => (string)$settings['pdf_footer_text'],
'payment_reminder_enabled' => (int)$settings['payment_reminder_enabled'],
'payment_reminder_interval_days' => (int)$settings['payment_reminder_interval_days'],
'pdf_watermark_logo' => (string)$settings['pdf_watermark_logo'],
'cash_enabled' => (int)$settings['cash_enabled'],
'cash_contact' => (string)$settings['cash_contact'],
'bank_transfer_enabled' => (int)$settings['bank_transfer_enabled'],
'bank_account_holder' => (string)$settings['bank_account_holder'],
'bank_iban' => (string)$settings['bank_iban'],
];
}
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'] ?? ''));
$cashContact = trim((string)($input['cash_contact'] ?? ''));
$bankAccountHolder = trim((string)($input['bank_account_holder'] ?? ''));
$bankIban = strtoupper(preg_replace('/\s+/', '', (string)($input['bank_iban'] ?? '')));
$pdfSplitMode = trim((string)($input['pdf_split_mode'] ?? 'drinking_behavior'));
$pdfRowHeightPx = filter_var($input['pdf_row_height_px'] ?? null, FILTER_VALIDATE_INT);
$pdfWatermarkText = trim((string)($input['pdf_watermark_text'] ?? ''));
$pdfFooterText = trim((string)($input['pdf_footer_text'] ?? ''));
$paymentReminderIntervalDays = filter_var($input['payment_reminder_interval_days'] ?? null, FILTER_VALIDATE_INT);
$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 ungültig.';
}
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 Währungscode muss aus drei Großbuchstaben 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 (!in_array($pdfSplitMode, ['drinking_behavior', 'alphabetical'], true)) {
$errors[] = 'Die Sortierung für den Ausdruck ist ungültig.';
}
if (strlen($pdfWatermarkText) > 500) {
$errors[] = 'Der Wasserzeichen-Text im Ausdruck ist zu lang (max. 500 Zeichen).';
}
if (strlen($pdfFooterText) > 500) {
$errors[] = 'Der Fußzeilen-Hinweis im Ausdruck ist zu lang (max. 500 Zeichen).';
}
if ($pdfRowHeightPx === false || $pdfRowHeightPx < 10 || $pdfRowHeightPx > 60) {
$errors[] = 'Die Zeilenhöhe im Ausdruck muss zwischen 10 und 60 Pixel liegen.';
}
if ($paymentReminderIntervalDays === false || $paymentReminderIntervalDays < 1 || $paymentReminderIntervalDays > 90) {
$errors[] = 'Das Erinnerungs-Intervall muss zwischen 1 und 90 Tagen liegen.';
}
if (strlen($cashContact) > 255) {
$errors[] = 'Der Bar-Ansprechpartner ist zu lang (max. 255 Zeichen).';
}
if (strlen($bankAccountHolder) > 255) {
$errors[] = 'Der Kontoinhaber ist zu lang (max. 255 Zeichen).';
}
if ($bankIban !== '' && !preg_match('/^[A-Z]{2}[0-9]{2}[A-Z0-9]{10,30}$/', $bankIban)) {
$errors[] = 'Die IBAN hat kein gültiges Format.';
}
if ($errors !== []) {
return ['ok' => false, 'errors' => $errors];
}
$selfEntryEnabled = !empty($input['self_entry_enabled']) ? 1 : 0;
$paypalEnabled = !empty($input['paypal_enabled']) ? 1 : 0;
$cashEnabled = !empty($input['cash_enabled']) ? 1 : 0;
$bankTransferEnabled = !empty($input['bank_transfer_enabled']) ? 1 : 0;
$pdfShowEmptyRows = !empty($input['pdf_show_empty_rows']) ? 1 : 0;
$paymentReminderEnabled = !empty($input['payment_reminder_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,
pdf_show_empty_rows, pdf_split_mode, pdf_row_height_px,
pdf_watermark_text, pdf_footer_text,
payment_reminder_enabled, payment_reminder_interval_days,
cash_enabled, cash_contact, bank_transfer_enabled,
bank_account_holder, bank_iban)
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),
pdf_show_empty_rows = VALUES(pdf_show_empty_rows),
pdf_split_mode = VALUES(pdf_split_mode),
pdf_row_height_px = VALUES(pdf_row_height_px),
pdf_watermark_text = VALUES(pdf_watermark_text),
pdf_footer_text = VALUES(pdf_footer_text),
payment_reminder_enabled = VALUES(payment_reminder_enabled),
payment_reminder_interval_days = VALUES(payment_reminder_interval_days),
cash_enabled = VALUES(cash_enabled),
cash_contact = VALUES(cash_contact),
bank_transfer_enabled = VALUES(bank_transfer_enabled),
bank_account_holder = VALUES(bank_account_holder),
bank_iban = VALUES(bank_iban)'
);
$stmt->execute([
$tenantId,
$markPriceCents,
$selfEntryEnabled,
$paypalEnabled,
$paypalUrlTemplate,
(int)$sheetWindowDays,
-abs($negativeWarningCents),
$pdfShowEmptyRows,
$pdfSplitMode,
(int)$pdfRowHeightPx,
$pdfWatermarkText,
$pdfFooterText,
$paymentReminderEnabled,
(int)$paymentReminderIntervalDays,
$cashEnabled,
$cashContact,
$bankTransferEnabled,
$bankAccountHolder,
$bankIban,
]);
$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_find_auth_identity(PDO $pdo, string $email, string $tenantSlug = ''): ?array
{
$emailNorm = saas_email_norm($email);
if (!filter_var($emailNorm, FILTER_VALIDATE_EMAIL)) {
return null;
}
$params = [$emailNorm, 'active', 'active', 'active'];
$tenantFilter = '';
if (trim($tenantSlug) !== '') {
$tenantFilter = ' AND t.slug = ?';
$params[] = saas_slugify($tenantSlug);
}
$stmt = $pdo->prepare(
'SELECT
u.id AS user_id,
u.email,
u.email_norm,
u.display_name,
u.status AS user_status,
u.email_verified_at,
t.id AS tenant_id,
t.slug AS tenant_slug,
t.name AS tenant_name,
tm.role
FROM users u
JOIN tenant_memberships tm ON tm.user_id = u.id
JOIN tenants t ON t.id = tm.tenant_id
WHERE u.email_norm = ?
AND u.status = ?
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);
$identity = $stmt->fetch();
return $identity !== false ? $identity : null;
}
function saas_list_user_memberships(PDO $pdo, int $userId): array
{
$stmt = $pdo->prepare(
'SELECT
u.id AS user_id,
u.email,
u.email_norm,
u.display_name,
u.status AS user_status,
u.email_verified_at,
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 u.status = ?
AND tm.status = ?
AND t.status = ?
ORDER BY
CASE tm.role
WHEN \'owner\' THEN 1
WHEN \'admin\' THEN 2
ELSE 3
END,
t.name'
);
$stmt->execute([$userId, 'active', 'active', 'active']);
return $stmt->fetchAll();
}
function saas_identity_for_user_tenant(PDO $pdo, int $userId, int $tenantId): ?array
{
$stmt = $pdo->prepare(
'SELECT
u.id AS user_id,
u.email,
u.email_norm,
u.display_name,
u.status AS user_status,
u.email_verified_at,
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']);
$identity = $stmt->fetch();
return $identity !== false ? $identity : null;
}
function saas_start_pending_tenant_selection(int $userId): void
{
app_start_session();
session_regenerate_id(true);
$_SESSION['saas_pending_user_id'] = $userId;
$_SESSION['saas_pending_started_at'] = time();
unset($_SESSION['saas_user_id'], $_SESSION['saas_tenant_id'], $_SESSION['saas_role']);
}
function saas_pending_tenant_user_id(): ?int
{
app_start_session();
$userId = isset($_SESSION['saas_pending_user_id']) ? (int)$_SESSION['saas_pending_user_id'] : 0;
$startedAt = isset($_SESSION['saas_pending_started_at']) ? (int)$_SESSION['saas_pending_started_at'] : 0;
if ($userId <= 0 || $startedAt <= 0 || $startedAt < time() - 900) {
saas_clear_pending_tenant_selection();
return null;
}
return $userId;
}
function saas_clear_pending_tenant_selection(): void
{
if (PHP_SAPI !== 'cli') {
app_start_session();
}
unset($_SESSION['saas_pending_user_id'], $_SESSION['saas_pending_started_at']);
}
function saas_create_auth_token(PDO $pdo, int $userId, string $tokenType, ?int $tenantId, int $ttlMinutes): array
{
$token = bin2hex(random_bytes(32));
$pdo->prepare(
'UPDATE user_auth_tokens
SET consumed_at = NOW()
WHERE user_id = ?
AND token_type = ?
AND consumed_at IS NULL'
)->execute([$userId, $tokenType]);
$stmt = $pdo->prepare(
'INSERT INTO user_auth_tokens (user_id, tenant_id, token_type, token_hash, expires_at)
VALUES (?, ?, ?, ?, DATE_ADD(NOW(), INTERVAL ? MINUTE))'
);
$stmt->execute([$userId, $tenantId, $tokenType, saas_token_hash($token), $ttlMinutes]);
return [
'token' => $token,
'expires_at' => date('Y-m-d H:i:s', time() + ($ttlMinutes * 60)),
];
}
function saas_fetch_valid_auth_token(PDO $pdo, string $token, string $tokenType): ?array
{
if (!preg_match('/^[a-f0-9]{64}$/', $token)) {
return null;
}
$stmt = $pdo->prepare(
'SELECT
at.id,
at.user_id,
at.tenant_id,
at.token_type,
at.expires_at,
u.email,
u.email_norm,
u.display_name,
u.status AS user_status,
u.email_verified_at
FROM user_auth_tokens at
JOIN users u ON u.id = at.user_id
WHERE at.token_hash = ?
AND at.token_type = ?
AND at.consumed_at IS NULL
AND at.expires_at >= NOW()
AND u.status = ?
LIMIT 1'
);
$stmt->execute([saas_token_hash($token), $tokenType, 'active']);
$row = $stmt->fetch();
return $row !== false ? $row : null;
}
function saas_request_password_reset(PDO $pdo, string $email, string $tenantSlug = ''): array
{
$emailNorm = saas_email_norm($email);
if (!filter_var($emailNorm, FILTER_VALIDATE_EMAIL)) {
return [
'ok' => false,
'errors' => ['Bitte eine gültige E-Mail-Adresse eingeben.'],
];
}
$identity = saas_find_auth_identity($pdo, $emailNorm, $tenantSlug);
if ($identity === null) {
return [
'ok' => true,
'message' => 'Wenn ein passendes Konto existiert, wurde ein Link vorbereitet.',
'token' => null,
'errors' => [],
];
}
$token = saas_create_auth_token(
$pdo,
(int)$identity['user_id'],
'password_reset',
(int)$identity['tenant_id'],
60
);
return [
'ok' => true,
'message' => 'Wenn ein passendes Konto existiert, wurde ein Link vorbereitet.',
'token' => $token['token'],
'expires_at' => $token['expires_at'],
'errors' => [],
];
}
function saas_reset_password_with_token(PDO $pdo, string $token, string $password, string $passwordConfirm): array
{
$errors = [];
if (strlen($password) < 8) {
$errors[] = 'Das Passwort muss mindestens 8 Zeichen lang sein.';
}
if ($password !== $passwordConfirm) {
$errors[] = 'Die Passwort-Wiederholung stimmt nicht.';
}
$tokenRow = saas_fetch_valid_auth_token($pdo, $token, 'password_reset');
if ($tokenRow === null) {
$errors[] = 'Der Link ist ungültig oder abgelaufen.';
}
if ($errors !== []) {
return ['ok' => false, 'errors' => $errors];
}
try {
$pdo->beginTransaction();
// Wer den per Mail zugestellten Link oeffnen konnte, hat den Zugriff
// auf das Postfach nachgewiesen - fachlich dasselbe wie eine
// Verifikation. Das betrifft vor allem eingeladene Mitglieder: die
// Zugangsvergabe laeuft ueber genau diesen Reset-Link, ohne dass je
// eine separate Verifikationsmail verschickt wird.
$pdo->prepare(
'UPDATE users
SET password_hash = ?,
email_verified_at = COALESCE(email_verified_at, NOW())
WHERE id = ?'
)->execute([password_hash($password, PASSWORD_DEFAULT), (int)$tokenRow['user_id']]);
$pdo->prepare('UPDATE user_auth_tokens SET consumed_at = NOW() WHERE id = ?')
->execute([(int)$tokenRow['id']]);
$pdo->commit();
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
return [
'ok' => false,
'errors' => ['Das Passwort konnte nicht gespeichert werden.'],
];
}
return ['ok' => true, 'errors' => []];
}
/**
* Passwortwechsel fuer den bereits angemeldeten Nutzer. Bewusst getrennt vom
* Token-Reset: hier weist das aktuelle Passwort die Identitaet nach, nicht
* ein Mail-Link. Setzt anders als der Reset keine E-Mail-Verifikation, weil
* dabei nichts ueber das Postfach nachgewiesen wird.
*/
function saas_change_password(
PDO $pdo,
int $userId,
string $currentPassword,
string $password,
string $passwordConfirm
): array {
$errors = [];
if (strlen($password) < 8) {
$errors[] = 'Das neue Passwort muss mindestens 8 Zeichen lang sein.';
}
if ($password !== $passwordConfirm) {
$errors[] = 'Die Passwort-Wiederholung stimmt nicht.';
}
if ($currentPassword !== '' && $password === $currentPassword) {
$errors[] = 'Das neue Passwort muss sich vom bisherigen unterscheiden.';
}
if ($errors !== []) {
return ['ok' => false, 'errors' => $errors];
}
$stmt = $pdo->prepare('SELECT password_hash, status FROM users WHERE id = ? LIMIT 1');
$stmt->execute([$userId]);
$user = $stmt->fetch();
if ($user === false || (string)$user['status'] !== 'active') {
return ['ok' => false, 'errors' => ['Dieses Benutzerkonto ist nicht aktiv.']];
}
$hash = (string)($user['password_hash'] ?? '');
if ($hash === '' || !password_verify($currentPassword, $hash)) {
return ['ok' => false, 'errors' => ['Das aktuelle Passwort ist nicht korrekt.']];
}
try {
$pdo->prepare('UPDATE users SET password_hash = ? WHERE id = ?')
->execute([password_hash($password, PASSWORD_DEFAULT), $userId]);
} catch (Throwable $e) {
return ['ok' => false, 'errors' => ['Das Passwort konnte nicht gespeichert werden.']];
}
// Die eigene Session bleibt bestehen, bekommt aber eine neue ID, damit ein
// eventuell mitgelesener Session-Bezeichner nach dem Wechsel wertlos ist.
if (PHP_SAPI !== 'cli' && session_status() === PHP_SESSION_ACTIVE) {
session_regenerate_id(true);
}
return ['ok' => true, 'errors' => []];
}
/**
* Hat der Nutzer seine E-Mail-Adresse nachgewiesen? Wird fuer Aktionen
* geprueft, die Mails an Dritte ausloesen oder fremde Konten anlegen -
* siehe saas_require_verified_email().
*/
function saas_email_verified(?array $user = null): bool
{
$user = $user ?? saas_current_user();
return $user !== null && ($user['email_verified_at'] ?? null) !== null;
}
/**
* Sperrt Aktionen, die aus dem Konto heraus nach aussen wirken (Mitglieder
* einladen, Massenmail, Jahresabschluss-Mails), solange die eigene Adresse
* nicht bestaetigt ist. Bewusst nicht auf den gesamten Login angewendet: die
* eigene Kaffeeliste darf man auch unbestaetigt fuehren, nur nicht im Namen
* einer moeglicherweise fremden Adresse Mails an Dritte ausloesen.
*/
function saas_require_verified_email(?array $user = null): array
{
$user = $user ?? saas_require_login();
if (saas_email_verified($user)) {
return $user;
}
http_response_code(403);
die(
'Für diese Aktion muss die eigene E-Mail-Adresse bestätigt sein. '
. 'Den Bestätigungslink kannst du unter "Kundenkonto" erneut anfordern.'
);
}
function saas_request_email_verification(PDO $pdo, int $userId, ?int $tenantId = null): array
{
$stmt = $pdo->prepare(
'SELECT id, email_verified_at, status
FROM users
WHERE id = ?
LIMIT 1'
);
$stmt->execute([$userId]);
$user = $stmt->fetch();
if ($user === false || (string)$user['status'] !== 'active') {
return [
'ok' => false,
'errors' => ['Dieses Benutzerkonto ist nicht aktiv.'],
];
}
if ($user['email_verified_at'] !== null) {
return [
'ok' => true,
'already_verified' => true,
'token' => null,
'errors' => [],
];
}
$token = saas_create_auth_token($pdo, $userId, 'email_verification', $tenantId, 1440);
return [
'ok' => true,
'already_verified' => false,
'token' => $token['token'],
'expires_at' => $token['expires_at'],
'errors' => [],
];
}
function saas_verify_email_token(PDO $pdo, string $token): array
{
$tokenRow = saas_fetch_valid_auth_token($pdo, $token, 'email_verification');
if ($tokenRow === null) {
return [
'ok' => false,
'errors' => ['Der Link ist ungültig oder abgelaufen.'],
];
}
try {
$pdo->beginTransaction();
$pdo->prepare('UPDATE users SET email_verified_at = COALESCE(email_verified_at, NOW()) WHERE id = ?')
->execute([(int)$tokenRow['user_id']]);
$pdo->prepare('UPDATE user_auth_tokens SET consumed_at = NOW() WHERE id = ?')
->execute([(int)$tokenRow['id']]);
$pdo->commit();
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
return [
'ok' => false,
'errors' => ['Die E-Mail-Adresse konnte nicht bestätigt werden.'],
];
}
return [
'ok' => true,
'email' => (string)$tokenRow['email'],
'errors' => [],
];
}
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 ungültig.'],
];
}
$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 ungültig.'],
];
}
$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
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'
);
$stmt->execute($params);
$tenantIds = array_map('intval', $stmt->fetchAll(PDO::FETCH_COLUMN));
if ($tenantIds === []) {
return [
'ok' => false,
'errors' => ['Für dieses Konto ist kein aktiver Mandant verfügbar.'],
];
}
if (count($tenantIds) > 1 && trim($tenantSlug) === '') {
return [
'ok' => true,
'needs_tenant_selection' => true,
'user_id' => (int)$user['id'],
'memberships' => saas_list_user_memberships($pdo, (int)$user['id']),
'errors' => [],
];
}
$identity = saas_identity_for_user_tenant($pdo, (int)$user['id'], $tenantIds[0]);
if ($identity === null) {
return [
'ok' => false,
'errors' => ['Für dieses Konto ist kein aktiver Mandant verfügbar.'],
];
}
$pdo->prepare('UPDATE users SET last_login_at = NOW() WHERE id = ?')->execute([(int)$user['id']]);
return [
'ok' => true,
'identity' => $identity,
'errors' => [],
];
}
/**
* Roles an admin can hand out through the member management UI. "owner" is
* deliberately excluded here; it is only ever set during registration.
*
* @return list<string>
*/
function saas_grantable_roles(): array
{
return ['member', 'treasurer', 'admin'];
}
/**
* Grants (or updates) tenant access for an existing participant: creates the
* login account if needed, links it to the participant, and (re)activates
* the tenant membership with the given role. The participant's name/email
* stay the notification identity regardless of login state.
*
* @return array{ok: bool, errors: list<string>, token?: string, user_email?: string}
*/
function saas_grant_participant_access(PDO $pdo, int $tenantId, int $participantId, string $role): array
{
if (!in_array($role, saas_grantable_roles(), true)) {
return ['ok' => false, 'errors' => ['Ungültige Rolle.']];
}
$stmt = $pdo->prepare(
'SELECT id, display_name, email, email_norm
FROM participants
WHERE id = ? AND tenant_id = ?'
);
$stmt->execute([$participantId, $tenantId]);
$participant = $stmt->fetch();
if ($participant === false) {
return ['ok' => false, 'errors' => ['Teilnehmer wurde nicht gefunden.']];
}
$emailNorm = (string)($participant['email_norm'] ?? '');
if ($emailNorm === '' || !filter_var($participant['email'], FILTER_VALIDATE_EMAIL)) {
return ['ok' => false, 'errors' => ['Für den Zugang wird eine gültige E-Mail-Adresse benötigt.']];
}
try {
$pdo->beginTransaction();
$stmt = $pdo->prepare('SELECT id, status FROM users WHERE email_norm = ? LIMIT 1');
$stmt->execute([$emailNorm]);
$user = $stmt->fetch();
if ($user === false) {
$stmt = $pdo->prepare(
'INSERT INTO users (email, email_norm, display_name, status) VALUES (?, ?, ?, ?)'
);
$stmt->execute([$participant['email'], $emailNorm, $participant['display_name'], 'active']);
$userId = (int)$pdo->lastInsertId();
} elseif ((string)$user['status'] !== 'active') {
$pdo->rollBack();
return ['ok' => false, 'errors' => ['Dieses Benutzerkonto ist nicht aktiv.']];
} else {
$userId = (int)$user['id'];
}
$stmt = $pdo->prepare(
'INSERT INTO tenant_memberships (tenant_id, user_id, role, status, invited_at, joined_at)
VALUES (?, ?, ?, ?, NOW(), NOW())
ON DUPLICATE KEY UPDATE
role = VALUES(role),
status = VALUES(status),
invited_at = COALESCE(tenant_memberships.invited_at, VALUES(invited_at)),
joined_at = COALESCE(tenant_memberships.joined_at, VALUES(joined_at))'
);
$stmt->execute([$tenantId, $userId, $role, 'active']);
$pdo->prepare('UPDATE participants SET user_id = ? WHERE id = ? AND tenant_id = ?')
->execute([$userId, $participantId, $tenantId]);
$pdo->commit();
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
return ['ok' => false, 'errors' => ['Der Zugang konnte nicht angelegt werden.']];
}
$token = saas_create_auth_token($pdo, $userId, 'password_reset', $tenantId, 1440);
return [
'ok' => true,
'errors' => [],
'token' => $token['token'],
'user_email' => (string)$participant['email'],
];
}
/**
* Revokes tenant access without touching the participant's name/email or
* deleting the membership row, so the grant can be reinstated later and the
* history stays auditable. Owners cannot be revoked here.
*
* @return array{ok: bool, errors: list<string>}
*/
function saas_revoke_participant_access(PDO $pdo, int $tenantId, int $participantId): array
{
$stmt = $pdo->prepare('SELECT user_id FROM participants WHERE id = ? AND tenant_id = ?');
$stmt->execute([$participantId, $tenantId]);
$participant = $stmt->fetch();
if ($participant === false || $participant['user_id'] === null) {
return ['ok' => true, 'errors' => []];
}
$userId = (int)$participant['user_id'];
$stmt = $pdo->prepare('SELECT role FROM tenant_memberships WHERE tenant_id = ? AND user_id = ?');
$stmt->execute([$tenantId, $userId]);
$membership = $stmt->fetch();
if ($membership !== false && (string)$membership['role'] === 'owner') {
return ['ok' => false, 'errors' => ['Dem Inhaber kann der Zugang nicht entzogen werden.']];
}
$pdo->prepare("UPDATE tenant_memberships SET status = 'revoked' WHERE tenant_id = ? AND user_id = ?")
->execute([$tenantId, $userId]);
return ['ok' => true, '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 Kundenkürzel 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 gültige 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 Kundenkürzel 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, '']);
faq_seed_default_entries($pdo, $tenantId);
billing_fetch_or_init($pdo, $tenantId);
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]);
$verificationToken = saas_create_auth_token($pdo, $userId, 'email_verification', $tenantId, 1440);
$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',
],
'email_verification_token' => $verificationToken['token'],
'errors' => [],
];
}