Files
kaffeekasse-saas/app/platform-admin.php
T
clemensandClaude Opus 5 a2dcc4df65 Zwei-Faktor-Anmeldung per TOTP
app/totp.php implementiert RFC 6238 selbst statt per Bibliothek: der
Algorithmus ist ein HMAC plus eine Truncation, und ein zweiter Faktor ist
die letzte Stelle fuer ungepruefte Abhaengigkeiten. Der QR-Code entsteht
aus dem ohnehin vorhandenen TCPDF, damit kein externer Dienst das
Geheimnis sieht.

Beim Login wird die Anmeldung bei aktivem zweitem Faktor nicht
abgeschlossen; der Zwischenzustand gewaehrt keinerlei Zugriff und ist
byte-identisch zu einem unangemeldeten Aufruf. users.totp_last_step
verhindert die Wiederverwendung eines abgefangenen Codes innerhalb seines
Gueltigkeitsfensters. Abschalten verlangt Passwort und Code.

APP_REQUIRE_2FA_FOR_ADMINS macht den Faktor fuer Platform-Admins
verbindlich, per Weiterleitung auf die Einrichtung statt als harte Sperre
- sonst koennte der Schalter den einzigen Admin aussperren. Standard aus.

check-konto-und-mandantenwechsel erwartete beim Login noch das entfernte
Kundenkuerzel-Feld und damit einen direkten Sprung aufs Dashboard; der
Check bildet jetzt den tatsaechlichen Weg ueber die Mandantenauswahl ab.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 23:51:45 +02:00

142 lines
4.7 KiB
PHP

<?php
declare(strict_types=1);
require_once __DIR__ . '/bootstrap.php';
require_once __DIR__ . '/saas-auth.php';
require_once __DIR__ . '/audit.php';
require_once __DIR__ . '/totp.php';
/**
* Platform-admin access is deliberately a separate concept from
* tenant_memberships/saas_user_has_role(): it must never be wired into the
* regular tenant-scoped pages, or the tenant isolation those pages and
* scripts/check-m8-tenant-isolation.php rely on would silently break. It
* only gates the backoffice-*.php pages.
*/
function app_is_platform_admin(PDO $pdo, ?int $userId): bool
{
if ($userId === null) {
return false;
}
$stmt = $pdo->prepare('SELECT 1 FROM platform_admins WHERE user_id = ?');
$stmt->execute([$userId]);
return $stmt->fetchColumn() !== false;
}
/**
* Requires a real SaaS login (any tenant role, or none) plus a
* platform_admins entry. Exits with 403 otherwise.
*
* @return array{user_id: int, email: string, display_name: string}
*/
function app_require_platform_admin(PDO $pdo): array
{
$user = saas_current_user($pdo);
if ($user === null) {
header('Location: login.php');
exit;
}
if (!app_is_platform_admin($pdo, (int)$user['user_id'])) {
http_response_code(403);
exit('Kein Zugriff.');
}
// Das Back-Office sieht alle Mandanten - fuer diese Konten laesst sich ein
// zweiter Faktor erzwingen. Bewusst als Weiterleitung auf die Einrichtung
// statt als harte Sperre: sonst koennte der Schalter den einzigen
// Platform-Admin dauerhaft aussperren.
if (app_totp_required_for_platform_admins() && !app_totp_is_active($pdo, (int)$user['user_id'])) {
header('Location: zwei-faktor-einrichten.php');
exit;
}
return $user;
}
/**
* @return list<array{id: int, slug: string, name: string, status: string, created_at: string, participant_count: int, active_participant_count: int, balance_cents: int}>
*/
function app_backoffice_fetch_tenants(PDO $pdo): array
{
$stmt = $pdo->query(
"SELECT
t.id, t.slug, t.name, t.status, t.created_at,
COALESCE(tb.plan_code, 'free') AS plan_code,
COUNT(p.id) AS participant_count,
COALESCE(SUM(p.active), 0) AS active_participant_count,
COALESCE((
SELECT SUM(le.amount_cents)
FROM ledger_entries le
WHERE le.tenant_id = t.id AND le.voided_at IS NULL
), 0) AS balance_cents
FROM tenants t
LEFT JOIN participants p ON p.tenant_id = t.id
LEFT JOIN tenant_billing tb ON tb.tenant_id = t.id
GROUP BY t.id, t.slug, t.name, t.status, t.created_at, tb.plan_code
ORDER BY t.created_at DESC"
);
return $stmt->fetchAll();
}
/**
* @return array{tenant: array, settings: ?array, members: list<array>, recent_entries: list<array>, recent_audit: list<array>}|null
*/
function app_backoffice_fetch_tenant_detail(PDO $pdo, int $tenantId): ?array
{
$stmt = $pdo->prepare('SELECT id, slug, name, status, timezone, locale, currency_code, created_at FROM tenants WHERE id = ?');
$stmt->execute([$tenantId]);
$tenant = $stmt->fetch();
if ($tenant === false) {
return null;
}
$stmt = $pdo->prepare('SELECT * FROM tenant_settings WHERE tenant_id = ?');
$stmt->execute([$tenantId]);
$settings = $stmt->fetch() ?: null;
$stmt = $pdo->prepare(
'SELECT u.id, u.email, u.display_name, u.status, tm.role, tm.status AS membership_status
FROM tenant_memberships tm
JOIN users u ON u.id = tm.user_id
WHERE tm.tenant_id = ?
ORDER BY tm.role, u.display_name'
);
$stmt->execute([$tenantId]);
$members = $stmt->fetchAll();
$stmt = $pdo->prepare(
'SELECT le.id, le.type, le.amount_cents, le.booked_at, le.source, p.display_name
FROM ledger_entries le
JOIN participants p ON p.id = le.participant_id
WHERE le.tenant_id = ? AND le.voided_at IS NULL
ORDER BY le.booked_at DESC, le.id DESC
LIMIT 20'
);
$stmt->execute([$tenantId]);
$recentEntries = $stmt->fetchAll();
$stmt = $pdo->prepare(
'SELECT a.id, a.action, a.subject_type, a.subject_id, a.created_at, u.display_name AS actor_name
FROM audit_log a
LEFT JOIN users u ON u.id = a.actor_user_id
WHERE a.tenant_id = ?
ORDER BY a.created_at DESC, a.id DESC
LIMIT 20'
);
$stmt->execute([$tenantId]);
$recentAudit = $stmt->fetchAll();
return [
'tenant' => $tenant,
'settings' => $settings,
'members' => $members,
'recent_entries' => $recentEntries,
'recent_audit' => $recentAudit,
];
}