fix(security, core): Auth-Pflicht für Ingest-APIs, 500er-Ursachen beheben, Agenten-Workflow
Sicherheit
- install_db.php war ohne Authentifizierung erreichbar und setzte bei jedem
Aufruf das Admin-Passwort auf einen fest im Code stehenden Wert zurück.
Jetzt Auth-Pflicht; ein Konto wird nur bei leerer Benutzertabelle angelegt.
- Stored XSS im Bugtracker-Detail-Modal: Titel, Beschreibung, Fehlermeldung,
Stacktrace und Kommentare gingen ungefiltert durch innerHTML.
- report.php, projects.php und das Veröffentlichen von Releases verlangen jetzt
zwingend ein Token. Publish war zuvor völlig ungeschützt.
- CSRF-Token in allen Formularen, Session-Regenerierung nach Login,
Drosselung fehlgeschlagener Anmeldeversuche.
- Zugangsdaten aus der Versionskontrolle entfernt (Serverdaten.txt,
config.php, .htpasswd, deploy_config.json). Historie enthält sie weiterhin,
Rotation erforderlich (siehe docs/UPGRADE.md).
- Token-Validierung nur noch über SHA-256-Hash; expires_at wird ausgewertet.
Behobene 500er
- Audit::log() war in index.php weder eingebunden noch importiert. Jeder
Klick auf "Aktivierung freigeben" endete in einem Fatal Error.
- Derselbe benannte PDO-Platzhalter mehrfach je Statement (:id in
revokeToken/deleteToken, :q siebenfach in der Volltextsuche). Bei
EMULATE_PREPARES=false ist das nicht zulässig und warf HY093.
- Migration 005 nutzte dynamisches SQL, dessen Semikolons in String-Literalen
vom alten explode(';')-Installer als Statement-Ende gelesen wurden. Sie
schlug still fehl, wodurch push_id/target_agent/tags dauerhaft fehlten.
- Monitor-Umbenennung ohne Transaktion, verschachtelte Transaktionen im
RateLimiter.
Funktionale Korrekturen
- Der Watchdog-Evaluator fehlte vollständig: Monitor-Zustände änderten sich nur
beim Eintreffen eines Heartbeats, ein ausgefallenes System blieb dauerhaft
"up". Erster Lauf auf dem Produktivsystem: 7 von 10 Monitoren waren
tatsächlich seit über einem Tag nicht erreichbar.
- Das Feld "os" fehlte im Monitor-Dialog, wurde aber gespeichert und löschte
damit bei jedem Speichern das Betriebssystem.
- Der Resolve-Dialog existierte im HTML nicht; der Button war funktionslos.
- Versionsvergleich erfolgte lexikografisch, wodurch 1.9.0 als neuer galt
als 1.10.0.
- Schreiboperationen meldeten Erfolg auch für nicht existierende IDs.
- Post/Redirect/Get gegen doppelte Einträge beim Neuladen.
Neue Struktur
- src/bootstrap.php mit PSR-4-Autoloader ersetzt die require-Ketten.
- Core: Config, Http, Csrf, ApiAuth, Logger, Migrator, ErrorReporter.
- Migrator mit zeichenweisem SQL-Parser, dc_migrations und Baseline-Verfahren,
damit bestehende Installationen keine Beispieldaten zurückbekommen.
Agenten-Workflow
- Claim/Lease: Items werden exklusiv übernommen, damit nicht zwei Agenten am
selben Problem arbeiten. action=next holt und reserviert in einem Zug.
- Idempotenz über client_ref, Deduplizierung auch für Feature Requests,
Erkennung von Regressionen, automatische Eskalation des Schweregrads.
- Strukturierter Code-Kontext (repo_url, commit_sha, file_path, line_no).
- Delta-Abfragen über updated_since, Pagination, Bulk-Update.
- Beim Veröffentlichen eines Releases schließen sich Items mit passendem
resolved_in_build selbst.
- Ausgehende Webhooks mit HMAC-Signatur, /api/health, /api/openapi.json.
- Unbehandelte Fehler meldet die Plattform in ihren eigenen Bugtracker.
WebUI
- Serverseitige Filterung mit Pagination statt Rendern aller Datensätze.
- Migrations-Schranke, Evaluator-Warnung, Übersicht aktiver Agenten.
Zeitstempel liegen in der Datenbank durchgängig in UTC und werden für die
Anzeige in die App-Zeitzone umgerechnet.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
a21536f495
commit
e7fbc85db4
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Deploymentcenter\Core;
|
||||
|
||||
use PDO;
|
||||
|
||||
/**
|
||||
* Einheitliche Authentifizierung fuer alle API-Endpunkte.
|
||||
*
|
||||
* Drei akzeptierte Wege, in dieser Reihenfolge geprueft:
|
||||
* 1. Shared Key aus der Konfiguration (Server-zu-Server, z. B. Cron)
|
||||
* 2. Aktive WebUI-Session (nur wenn ausdruecklich erlaubt)
|
||||
* 3. Master-/Sub-Token aus dc_tokens mit passendem Scope
|
||||
*
|
||||
* Frueher hatte jeder Endpunkt seine eigene Variante davon - mit jeweils
|
||||
* leicht abweichendem Verhalten. Diese Klasse ersetzt alle.
|
||||
*/
|
||||
final class ApiAuth
|
||||
{
|
||||
/**
|
||||
* Erzwingt Authentifizierung mit einem bestimmten Scope.
|
||||
* Bricht die Anfrage bei Fehlschlag mit 401 ab.
|
||||
*
|
||||
* @return array{method:string,actor:string,token:?array,project_slug:?string,environment:?string}
|
||||
*/
|
||||
public static function requireScope(
|
||||
PDO $db,
|
||||
string $scope,
|
||||
?string $environment = null,
|
||||
bool $allowSession = true
|
||||
): array {
|
||||
$context = self::resolve($db, $scope, $environment, $allowSession);
|
||||
|
||||
if ($context === null) {
|
||||
Http::fail(
|
||||
401,
|
||||
'unauthorized',
|
||||
sprintf('Authentifizierung erforderlich. Erwartet wird ein Token mit dem Recht "%s".', $scope),
|
||||
null,
|
||||
['required_scope' => $scope]
|
||||
);
|
||||
}
|
||||
|
||||
return $context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wie requireScope(), bricht aber nicht ab, sondern liefert null.
|
||||
*
|
||||
* @return array{method:string,actor:string,token:?array,project_slug:?string,environment:?string}|null
|
||||
*/
|
||||
public static function resolve(
|
||||
PDO $db,
|
||||
string $scope,
|
||||
?string $environment = null,
|
||||
bool $allowSession = true
|
||||
): ?array {
|
||||
$presented = Http::bearerToken();
|
||||
|
||||
// 1. Shared Key
|
||||
$sharedKey = (string)Config::get('security.shared_key', '');
|
||||
if ($presented !== null && $sharedKey !== '' && hash_equals($sharedKey, $presented)) {
|
||||
return [
|
||||
'method' => 'shared_key',
|
||||
'actor' => 'system:shared-key',
|
||||
'token' => null,
|
||||
'project_slug' => null,
|
||||
'environment' => null,
|
||||
];
|
||||
}
|
||||
|
||||
// 2. WebUI-Session
|
||||
if ($allowSession && Auth::isLoggedIn()) {
|
||||
return [
|
||||
'method' => 'session',
|
||||
'actor' => Auth::username(),
|
||||
'token' => null,
|
||||
'project_slug' => null,
|
||||
'environment' => null,
|
||||
];
|
||||
}
|
||||
|
||||
// 3. Agenten-Token
|
||||
if ($presented !== null) {
|
||||
$manager = new TokenManager($db);
|
||||
$token = $manager->validateToken($presented, $scope, $environment);
|
||||
|
||||
if (is_array($token)) {
|
||||
$name = isset($token['name']) && $token['name'] !== ''
|
||||
? (string)$token['name']
|
||||
: (string)$token['token_id'];
|
||||
|
||||
return [
|
||||
'method' => 'token',
|
||||
'actor' => 'agent:' . $name,
|
||||
'token' => $token,
|
||||
'project_slug' => self::stringOrNull($token['project_slug'] ?? null),
|
||||
'environment' => self::stringOrNull($token['environment'] ?? null),
|
||||
];
|
||||
}
|
||||
|
||||
Logger::warning('Token abgelehnt', [
|
||||
'scope' => $scope,
|
||||
'ip' => Http::clientIp(),
|
||||
'path' => Http::path(),
|
||||
]);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stellt sicher, dass ein projektgebundenes Token nur auf sein eigenes
|
||||
* Projekt zugreift. Bricht sonst mit 403 ab.
|
||||
*
|
||||
* @param array{project_slug:?string,method:string} $context
|
||||
*/
|
||||
public static function enforceProject(array $context, ?string $requestedSlug): void
|
||||
{
|
||||
$bound = $context['project_slug'] ?? null;
|
||||
|
||||
if ($bound === null || $bound === '') {
|
||||
return; // Token ist nicht projektgebunden
|
||||
}
|
||||
|
||||
if ($requestedSlug === null || $requestedSlug === '' || $requestedSlug === $bound) {
|
||||
return;
|
||||
}
|
||||
|
||||
Http::fail(
|
||||
403,
|
||||
'project_forbidden',
|
||||
sprintf('Dieses Token ist an das Projekt "%s" gebunden und darf nicht auf "%s" zugreifen.', $bound, $requestedSlug),
|
||||
null,
|
||||
['bound_project' => $bound]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Liefert den Projekt-Slug, auf den eine Anfrage eingeschraenkt werden muss,
|
||||
* oder null bei uneingeschraenktem Zugriff.
|
||||
*
|
||||
* @param array{project_slug:?string} $context
|
||||
*/
|
||||
public static function projectFilter(array $context): ?string
|
||||
{
|
||||
$bound = $context['project_slug'] ?? null;
|
||||
return is_string($bound) && $bound !== '' ? $bound : null;
|
||||
}
|
||||
|
||||
private static function stringOrNull($value): ?string
|
||||
{
|
||||
if (!is_string($value)) {
|
||||
return null;
|
||||
}
|
||||
return $value === '' ? null : $value;
|
||||
}
|
||||
}
|
||||
+217
-19
@@ -1,18 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Deploymentcenter\Core;
|
||||
|
||||
use PDO;
|
||||
|
||||
class Auth
|
||||
/**
|
||||
* Session- und Anmeldeverwaltung fuer das WebUI.
|
||||
*
|
||||
* Haerteung gegenueber der Erstfassung:
|
||||
* - Session-Cookie mit HttpOnly, SameSite=Lax und Secure (bei HTTPS)
|
||||
* - session_regenerate_id() nach erfolgreicher Anmeldung (Session Fixation)
|
||||
* - Leerlauf- und Absolut-Timeout
|
||||
* - Anmeldeversuche werden gezaehlt und pro IP gedrosselt
|
||||
*/
|
||||
final class Auth
|
||||
{
|
||||
/** Leerlauf-Timeout in Sekunden (2 Stunden). */
|
||||
private const IDLE_TIMEOUT = 7200;
|
||||
|
||||
/** Absolutes Session-Maximum in Sekunden (12 Stunden). */
|
||||
private const ABSOLUTE_TIMEOUT = 43200;
|
||||
|
||||
/** Fehlversuche pro IP, bevor gesperrt wird. */
|
||||
private const MAX_ATTEMPTS = 10;
|
||||
|
||||
/** Sperrfenster in Sekunden. */
|
||||
private const LOCKOUT_WINDOW = 900;
|
||||
|
||||
public static function startSession(): void
|
||||
{
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
$config = require __DIR__ . '/../../config/config.php';
|
||||
session_name($config['security']['session_name'] ?? 'DC_SESSION_ID');
|
||||
session_start();
|
||||
if (session_status() !== PHP_SESSION_NONE) {
|
||||
return;
|
||||
}
|
||||
|
||||
$secure = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off';
|
||||
|
||||
session_set_cookie_params([
|
||||
'lifetime' => 0,
|
||||
'path' => '/',
|
||||
'domain' => '',
|
||||
'secure' => $secure,
|
||||
'httponly' => true,
|
||||
'samesite' => 'Lax',
|
||||
]);
|
||||
|
||||
session_name((string)Config::get('security.session_name', 'DC_SESSION_ID'));
|
||||
session_start();
|
||||
|
||||
self::enforceTimeouts();
|
||||
}
|
||||
|
||||
public static function isLoggedIn(): bool
|
||||
@@ -23,39 +60,200 @@ class Auth
|
||||
|
||||
public static function requireLogin(): void
|
||||
{
|
||||
if (!self::isLoggedIn()) {
|
||||
header('Location: /login.php');
|
||||
exit;
|
||||
if (self::isLoggedIn()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!headers_sent()) {
|
||||
header('Location: /login.php');
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
public static function username(): string
|
||||
{
|
||||
self::startSession();
|
||||
$name = $_SESSION['dc_username'] ?? null;
|
||||
return is_string($name) && $name !== '' ? $name : 'admin';
|
||||
}
|
||||
|
||||
public static function userId(): int
|
||||
{
|
||||
self::startSession();
|
||||
return (int)($_SESSION['dc_user_id'] ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prueft die Zugangsdaten und startet bei Erfolg eine frische Session.
|
||||
*/
|
||||
public static function login(PDO $db, string $username, string $password): bool
|
||||
{
|
||||
self::startSession();
|
||||
$stmt = $db->prepare('SELECT id, username, password_hash FROM dc_users WHERE username = :u');
|
||||
|
||||
$ip = Http::clientIp();
|
||||
|
||||
if (self::isLockedOut($db, $ip)) {
|
||||
Logger::warning('Anmeldung gesperrt (zu viele Fehlversuche)', ['ip' => $ip, 'username' => $username]);
|
||||
return false;
|
||||
}
|
||||
|
||||
$stmt = $db->prepare('SELECT id, username, password_hash FROM dc_users WHERE username = :u LIMIT 1');
|
||||
$stmt->execute([':u' => $username]);
|
||||
$user = $stmt->fetch();
|
||||
|
||||
if ($user && password_verify($password, $user['password_hash'])) {
|
||||
$_SESSION['dc_user_id'] = $user['id'];
|
||||
$_SESSION['dc_username'] = $user['username'];
|
||||
return true;
|
||||
$found = is_array($user) && isset($user['password_hash']);
|
||||
// Auch ohne Treffer wird ein Hash berechnet, damit die Antwortzeit
|
||||
// keinen Rueckschluss auf die Existenz des Kontos erlaubt.
|
||||
$hash = $found ? (string)$user['password_hash'] : self::dummyHash();
|
||||
|
||||
$verified = password_verify($password, $hash);
|
||||
|
||||
if (!$verified || !$found) {
|
||||
self::recordAttempt($db, $ip, $username, false);
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
// Passwort-Hash bei Bedarf auf das aktuelle Verfahren heben.
|
||||
if (password_needs_rehash($hash, PASSWORD_DEFAULT)) {
|
||||
$upd = $db->prepare('UPDATE dc_users SET password_hash = :h WHERE id = :id');
|
||||
$upd->execute([':h' => password_hash($password, PASSWORD_DEFAULT), ':id' => $user['id']]);
|
||||
}
|
||||
|
||||
session_regenerate_id(true);
|
||||
|
||||
$_SESSION['dc_user_id'] = (int)$user['id'];
|
||||
$_SESSION['dc_username'] = (string)$user['username'];
|
||||
$_SESSION['dc_login_at'] = time();
|
||||
$_SESSION['dc_last_seen'] = time();
|
||||
|
||||
self::recordAttempt($db, $ip, $username, true);
|
||||
Logger::info('Anmeldung erfolgreich', ['username' => $user['username'], 'ip' => $ip]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static function logout(): void
|
||||
{
|
||||
self::startSession();
|
||||
|
||||
$_SESSION = [];
|
||||
if (ini_get("session.use_cookies")) {
|
||||
|
||||
if (ini_get('session.use_cookies')) {
|
||||
$params = session_get_cookie_params();
|
||||
setcookie(session_name(), '', time() - 42000,
|
||||
$params["path"], $params["domain"],
|
||||
$params["secure"], $params["httponly"]
|
||||
);
|
||||
setcookie(session_name(), '', [
|
||||
'expires' => time() - 42000,
|
||||
'path' => $params['path'],
|
||||
'domain' => $params['domain'],
|
||||
'secure' => $params['secure'],
|
||||
'httponly' => $params['httponly'],
|
||||
'samesite' => 'Lax',
|
||||
]);
|
||||
}
|
||||
|
||||
session_destroy();
|
||||
}
|
||||
|
||||
/**
|
||||
* Verbleibende Sperrzeit in Sekunden, oder 0 wenn nicht gesperrt.
|
||||
*/
|
||||
public static function lockoutSeconds(PDO $db, string $ip): int
|
||||
{
|
||||
try {
|
||||
$stmt = $db->prepare('
|
||||
SELECT COUNT(*) AS failures, MAX(attempted_at) AS last_attempt
|
||||
FROM dc_login_attempts
|
||||
WHERE ip = :ip
|
||||
AND success = 0
|
||||
AND attempted_at > (UTC_TIMESTAMP() - INTERVAL ' . self::LOCKOUT_WINDOW . ' SECOND)
|
||||
');
|
||||
$stmt->execute([':ip' => self::packIp($ip)]);
|
||||
$row = $stmt->fetch();
|
||||
|
||||
if (!is_array($row) || (int)$row['failures'] < self::MAX_ATTEMPTS) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$last = isset($row['last_attempt']) ? strtotime((string)$row['last_attempt'] . ' UTC') : false;
|
||||
if ($last === false) {
|
||||
return self::LOCKOUT_WINDOW;
|
||||
}
|
||||
|
||||
$remaining = self::LOCKOUT_WINDOW - (time() - $last);
|
||||
return $remaining > 0 ? $remaining : 0;
|
||||
} catch (\Throwable $e) {
|
||||
// Fehlt die Tabelle (Migration noch nicht gelaufen), darf die
|
||||
// Anmeldung nicht blockiert werden.
|
||||
Logger::warning('Lockout-Pruefung nicht moeglich', ['error' => $e->getMessage()]);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private static function isLockedOut(PDO $db, string $ip): bool
|
||||
{
|
||||
return self::lockoutSeconds($db, $ip) > 0;
|
||||
}
|
||||
|
||||
private static function recordAttempt(PDO $db, string $ip, string $username, bool $success): void
|
||||
{
|
||||
try {
|
||||
$stmt = $db->prepare('
|
||||
INSERT INTO dc_login_attempts (ip, username, success, attempted_at)
|
||||
VALUES (:ip, :username, :success, UTC_TIMESTAMP())
|
||||
');
|
||||
$stmt->execute([
|
||||
':ip' => self::packIp($ip),
|
||||
':username' => mb_substr($username, 0, 64),
|
||||
':success' => $success ? 1 : 0,
|
||||
]);
|
||||
|
||||
// Bei Erfolg die Fehlversuche dieser IP zuruecksetzen.
|
||||
if ($success) {
|
||||
$del = $db->prepare('DELETE FROM dc_login_attempts WHERE ip = :ip AND success = 0');
|
||||
$del->execute([':ip' => self::packIp($ip)]);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Logger::warning('Anmeldeversuch nicht protokolliert', ['error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
/** Gueltiger Hash gegen einen Zufallswert, nur fuer Timing-Angleichung. */
|
||||
private static function dummyHash(): string
|
||||
{
|
||||
static $hash = null;
|
||||
if ($hash === null) {
|
||||
$hash = password_hash(bin2hex(random_bytes(16)), PASSWORD_DEFAULT);
|
||||
}
|
||||
return $hash;
|
||||
}
|
||||
|
||||
private static function packIp(string $ip): string
|
||||
{
|
||||
$packed = @inet_pton($ip);
|
||||
return $packed === false ? str_repeat("\0", 16) : $packed;
|
||||
}
|
||||
|
||||
private static function enforceTimeouts(): void
|
||||
{
|
||||
if (empty($_SESSION['dc_user_id'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$now = time();
|
||||
$loginAt = (int)($_SESSION['dc_login_at'] ?? $now);
|
||||
$lastSeen = (int)($_SESSION['dc_last_seen'] ?? $now);
|
||||
|
||||
$expired = ($now - $lastSeen) > self::IDLE_TIMEOUT
|
||||
|| ($now - $loginAt) > self::ABSOLUTE_TIMEOUT;
|
||||
|
||||
if ($expired) {
|
||||
$_SESSION = [];
|
||||
session_destroy();
|
||||
session_start();
|
||||
return;
|
||||
}
|
||||
|
||||
$_SESSION['dc_last_seen'] = $now;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Deploymentcenter\Core;
|
||||
|
||||
/**
|
||||
* Zugriff auf die geladene Konfiguration ueber Punktpfade, z. B. Config::get('db.host').
|
||||
*/
|
||||
final class Config
|
||||
{
|
||||
/** @var array<string,mixed> */
|
||||
private static array $data = [];
|
||||
|
||||
/** @param array<string,mixed> $data */
|
||||
public static function load(array $data): void
|
||||
{
|
||||
self::$data = $data;
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public static function all(): array
|
||||
{
|
||||
return self::$data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $path Punktseparierter Pfad, z. B. "security.shared_key"
|
||||
* @param mixed $default
|
||||
* @return mixed
|
||||
*/
|
||||
public static function get(string $path, $default = null)
|
||||
{
|
||||
$current = self::$data;
|
||||
foreach (explode('.', $path) as $segment) {
|
||||
if (!is_array($current) || !array_key_exists($segment, $current)) {
|
||||
return $default;
|
||||
}
|
||||
$current = $current[$segment];
|
||||
}
|
||||
return $current;
|
||||
}
|
||||
|
||||
/** Liefert die Datenbank-Zugangsdaten als Array. */
|
||||
public static function db(): array
|
||||
{
|
||||
$db = self::get('db', []);
|
||||
return is_array($db) ? $db : [];
|
||||
}
|
||||
|
||||
public static function isDebug(): bool
|
||||
{
|
||||
return (bool)self::get('app.debug', false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Deploymentcenter\Core;
|
||||
|
||||
/**
|
||||
* CSRF-Schutz fuer alle zustandsaendernden Formulare im WebUI.
|
||||
*
|
||||
* Das Token haengt an der Session und ist fuer deren Lebensdauer stabil.
|
||||
* API-Endpunkte, die per Bearer-Token authentifizieren, brauchen keinen
|
||||
* CSRF-Schutz - dort gibt es keinen Cookie, der automatisch mitgeschickt wird.
|
||||
*/
|
||||
final class Csrf
|
||||
{
|
||||
private const SESSION_KEY = 'dc_csrf_token';
|
||||
|
||||
public static function token(): string
|
||||
{
|
||||
Auth::startSession();
|
||||
|
||||
if (empty($_SESSION[self::SESSION_KEY]) || !is_string($_SESSION[self::SESSION_KEY])) {
|
||||
$_SESSION[self::SESSION_KEY] = bin2hex(random_bytes(32));
|
||||
}
|
||||
|
||||
return $_SESSION[self::SESSION_KEY];
|
||||
}
|
||||
|
||||
/** Fertiges verstecktes Formularfeld. */
|
||||
public static function field(): string
|
||||
{
|
||||
return '<input type="hidden" name="csrf_token" value="'
|
||||
. htmlspecialchars(self::token(), ENT_QUOTES, 'UTF-8') . '">';
|
||||
}
|
||||
|
||||
/** Prueft ein uebergebenes Token gegen die Session. */
|
||||
public static function isValid(?string $candidate): bool
|
||||
{
|
||||
Auth::startSession();
|
||||
|
||||
$expected = $_SESSION[self::SESSION_KEY] ?? null;
|
||||
if (!is_string($expected) || $expected === '' || !is_string($candidate) || $candidate === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return hash_equals($expected, $candidate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Erzwingt ein gueltiges CSRF-Token bei POST-Anfragen.
|
||||
* Bricht die Anfrage mit 419 ab, wenn es fehlt oder falsch ist.
|
||||
*/
|
||||
public static function requireValid(): void
|
||||
{
|
||||
if (strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET')) !== 'POST') {
|
||||
return;
|
||||
}
|
||||
|
||||
$candidate = $_POST['csrf_token'] ?? Http::header('x-csrf-token');
|
||||
if (is_array($candidate)) {
|
||||
$candidate = null;
|
||||
}
|
||||
|
||||
if (!self::isValid(is_string($candidate) ? $candidate : null)) {
|
||||
Logger::warning('CSRF-Pruefung fehlgeschlagen', [
|
||||
'ip' => Http::clientIp(),
|
||||
'path' => Http::path(),
|
||||
]);
|
||||
Http::fail(419, 'csrf_invalid', 'Sicherheits-Token abgelaufen oder ungueltig. Bitte Seite neu laden.');
|
||||
}
|
||||
}
|
||||
}
|
||||
+90
-20
@@ -1,46 +1,116 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Deploymentcenter\Core;
|
||||
|
||||
use PDO;
|
||||
use PDOException;
|
||||
use RuntimeException;
|
||||
|
||||
class Db
|
||||
/**
|
||||
* Zentrale PDO-Verbindung.
|
||||
*
|
||||
* Die Session-Zeitzone wird fest auf UTC gesetzt. Damit liefert NOW() echte
|
||||
* UTC-Werte - passend zu den Spalten, die auf _utc enden - und alle
|
||||
* Zeitvergleiche (Watchdog-Evaluator, Lease-Ablauf) rechnen auf derselben
|
||||
* Basis. Die Darstellung im WebUI erfolgt in der App-Zeitzone.
|
||||
*/
|
||||
final class Db
|
||||
{
|
||||
private static ?PDO $instance = null;
|
||||
|
||||
public static function init(array $config): PDO
|
||||
public static function init(array $config = []): PDO
|
||||
{
|
||||
if (self::$instance === null) {
|
||||
$dbCfg = isset($config['db']) ? $config['db'] : $config;
|
||||
$dsn = sprintf('mysql:host=%s;dbname=%s;charset=%s', $dbCfg['host'], $dbCfg['dbname'], $dbCfg['charset'] ?? 'utf8mb4');
|
||||
|
||||
$options = [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
];
|
||||
if (self::$instance !== null) {
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
try {
|
||||
self::$instance = new PDO($dsn, $dbCfg['username'], $dbCfg['password'], $options);
|
||||
} catch (PDOException $e) {
|
||||
throw new \Exception('Database connection failed: ' . $e->getMessage());
|
||||
if ($config === []) {
|
||||
$dbConfig = Config::db();
|
||||
} elseif (isset($config['db']) && is_array($config['db'])) {
|
||||
$dbConfig = $config['db'];
|
||||
} else {
|
||||
$dbConfig = $config;
|
||||
}
|
||||
|
||||
foreach (['host', 'dbname', 'username'] as $required) {
|
||||
if (!isset($dbConfig[$required]) || $dbConfig[$required] === '') {
|
||||
throw new RuntimeException('Datenbank-Konfiguration unvollstaendig: ' . $required . ' fehlt.');
|
||||
}
|
||||
}
|
||||
|
||||
$dsn = sprintf(
|
||||
'mysql:host=%s;dbname=%s;charset=%s',
|
||||
$dbConfig['host'],
|
||||
$dbConfig['dbname'],
|
||||
$dbConfig['charset'] ?? 'utf8mb4'
|
||||
);
|
||||
|
||||
$options = [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
PDO::ATTR_STRINGIFY_FETCHES => false,
|
||||
];
|
||||
|
||||
try {
|
||||
$pdo = new PDO($dsn, (string)$dbConfig['username'], (string)($dbConfig['password'] ?? ''), $options);
|
||||
$pdo->exec("SET time_zone = '+00:00'");
|
||||
} catch (PDOException $e) {
|
||||
// Die Originalmeldung kann Host und Benutzername enthalten und
|
||||
// gehoert deshalb ins Log, nicht in die Exception-Kette nach aussen.
|
||||
Logger::error('Datenbankverbindung fehlgeschlagen', ['error' => $e->getMessage()]);
|
||||
throw new RuntimeException('Datenbankverbindung fehlgeschlagen.', 0, $e);
|
||||
}
|
||||
|
||||
self::$instance = $pdo;
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
public static function connect(array $config): PDO
|
||||
/** Alias fuer init(); historisch an mehreren Stellen verwendet. */
|
||||
public static function connect(array $config = []): PDO
|
||||
{
|
||||
return self::init($config);
|
||||
}
|
||||
|
||||
public static function getInstance(): PDO
|
||||
{
|
||||
if (self::$instance === null) {
|
||||
$config = require __DIR__ . '/../../config/config.php';
|
||||
return self::init($config);
|
||||
return self::$instance ?? self::init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fuehrt einen Callback in einer Transaktion aus.
|
||||
* Verschachtelte Aufrufe laufen in der bereits offenen Transaktion mit.
|
||||
*
|
||||
* @template T
|
||||
* @param callable(PDO):T $callback
|
||||
* @return T
|
||||
*/
|
||||
public static function transaction(callable $callback)
|
||||
{
|
||||
$pdo = self::getInstance();
|
||||
|
||||
if ($pdo->inTransaction()) {
|
||||
return $callback($pdo);
|
||||
}
|
||||
return self::$instance;
|
||||
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
$result = $callback($pdo);
|
||||
$pdo->commit();
|
||||
return $result;
|
||||
} catch (\Throwable $e) {
|
||||
if ($pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/** Aktueller UTC-Zeitstempel im MySQL-DATETIME-Format. */
|
||||
public static function nowUtc(): string
|
||||
{
|
||||
return gmdate('Y-m-d H:i:s');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Deploymentcenter\Core;
|
||||
|
||||
use Deploymentcenter\Modules\Bugtracker\BugRepo;
|
||||
|
||||
/**
|
||||
* Meldet unbehandelte Fehler des Deploymentcenters in seinen eigenen Bugtracker.
|
||||
*
|
||||
* Damit taucht ein 500er kuenftig selbst im Dashboard auf, statt gesucht werden
|
||||
* zu muessen. Der Reporter ist bewusst extrem defensiv: schlaegt er fehl,
|
||||
* darf das die urspruengliche Fehlerbehandlung nicht stoeren.
|
||||
*/
|
||||
final class ErrorReporter
|
||||
{
|
||||
private static bool $reported = false;
|
||||
|
||||
public static function report(\Throwable $e): void
|
||||
{
|
||||
self::submit(
|
||||
self::titleFor($e->getMessage(), basename($e->getFile()), $e->getLine()),
|
||||
$e->getMessage(),
|
||||
$e->getFile() . ':' . $e->getLine() . "\n" . $e->getTraceAsString(),
|
||||
get_class($e)
|
||||
);
|
||||
}
|
||||
|
||||
/** @param array{type:int,message:string,file:string,line:int} $err */
|
||||
public static function reportFatal(array $err): void
|
||||
{
|
||||
self::submit(
|
||||
self::titleFor($err['message'], basename($err['file']), $err['line']),
|
||||
$err['message'],
|
||||
$err['file'] . ':' . $err['line'],
|
||||
'FatalError'
|
||||
);
|
||||
}
|
||||
|
||||
private static function submit(string $title, string $message, string $trace, string $class): void
|
||||
{
|
||||
// Nur ein Report pro Request, und niemals rekursiv.
|
||||
if (self::$reported) {
|
||||
return;
|
||||
}
|
||||
self::$reported = true;
|
||||
|
||||
try {
|
||||
$slug = (string)Config::get('bugtracker.self_project', 'deploymentcenter');
|
||||
if ($slug === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$db = Db::getInstance();
|
||||
$repo = new BugRepo($db);
|
||||
|
||||
$repo->reportItem([
|
||||
'project_slug' => $slug,
|
||||
'type' => 'bug',
|
||||
'title' => $title,
|
||||
'description' => sprintf(
|
||||
"Automatisch erfasster Laufzeitfehler.\n\nPfad: %s\nMethode: %s\nException: %s",
|
||||
Http::path(),
|
||||
Http::method(),
|
||||
$class
|
||||
),
|
||||
'error_message' => $message,
|
||||
'stack_trace' => $trace,
|
||||
'environment' => Config::isDebug() ? 'development' : 'production',
|
||||
'severity' => 'high',
|
||||
'build_version' => (string)Config::get('app.version', 'unknown'),
|
||||
'created_by' => 'system:self-report',
|
||||
'tags' => 'selfreport,runtime',
|
||||
]);
|
||||
} catch (\Throwable $ignored) {
|
||||
// Selbstmeldung ist bestenfalls hilfreich, niemals kritisch.
|
||||
@error_log('ErrorReporter fehlgeschlagen: ' . $ignored->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static function titleFor(string $message, string $file, int $line): string
|
||||
{
|
||||
$short = trim(preg_replace('/\s+/', ' ', $message) ?? $message);
|
||||
if (mb_strlen($short) > 150) {
|
||||
$short = mb_substr($short, 0, 147) . '...';
|
||||
}
|
||||
return sprintf('%s (%s:%d)', $short, $file, $line);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Deploymentcenter\Core;
|
||||
|
||||
/**
|
||||
* Einheitliche HTTP-Ein- und Ausgabe fuer alle API-Endpunkte.
|
||||
*
|
||||
* Alle Antworten folgen der Form:
|
||||
* Erfolg: { "status": "success", ...Daten }
|
||||
* Fehler: { "status": "error", "error": { "code": "...", "message": "..." } }
|
||||
*
|
||||
* Der Fehlercode ist stabil und maschinenlesbar - Agenten sollen darauf
|
||||
* reagieren, nicht auf den Klartext der Nachricht.
|
||||
*/
|
||||
final class Http
|
||||
{
|
||||
private static bool $jsonMode = false;
|
||||
private static ?array $headerCache = null;
|
||||
private static ?array $bodyCache = null;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Ausgabe
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Startet eine JSON-Antwort und setzt Sicherheits- sowie CORS-Header.
|
||||
*
|
||||
* @param string[] $methods Erlaubte HTTP-Methoden fuer CORS.
|
||||
* @param bool $publicCors true = beliebige Herkunft (nur fuer reine
|
||||
* Token-Endpunkte ohne Cookie-Auth).
|
||||
*/
|
||||
public static function beginJson(array $methods = ['GET', 'POST', 'OPTIONS'], bool $publicCors = false): void
|
||||
{
|
||||
self::$jsonMode = true;
|
||||
|
||||
if (headers_sent()) {
|
||||
return;
|
||||
}
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
header('Cache-Control: no-store');
|
||||
|
||||
if ($publicCors) {
|
||||
// Bewusst ohne Access-Control-Allow-Credentials: diese Endpunkte
|
||||
// authentifizieren ausschliesslich per Token-Header, niemals per Cookie.
|
||||
header('Access-Control-Allow-Origin: *');
|
||||
header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Agent-Token, X-Master-Token, Idempotency-Key');
|
||||
header('Access-Control-Allow-Methods: ' . implode(', ', $methods));
|
||||
header('Access-Control-Max-Age: 600');
|
||||
}
|
||||
|
||||
if (self::method() === 'OPTIONS') {
|
||||
http_response_code(204);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
/** Sendet eine Erfolgsantwort und beendet die Anfrage. */
|
||||
public static function ok(array $data = [], int $status = 200): void
|
||||
{
|
||||
self::send(array_merge(['status' => 'success'], $data), $status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sendet eine Fehlerantwort und beendet die Anfrage.
|
||||
*
|
||||
* Der Exception-Text wird nur bei app.debug = true ausgeliefert;
|
||||
* andernfalls landet er ausschliesslich im Log.
|
||||
*/
|
||||
public static function fail(
|
||||
int $status,
|
||||
string $code,
|
||||
string $message,
|
||||
?\Throwable $e = null,
|
||||
array $extra = []
|
||||
): void {
|
||||
if ($e !== null) {
|
||||
Logger::exception($e);
|
||||
}
|
||||
|
||||
$error = ['code' => $code, 'message' => $message];
|
||||
if ($e !== null && Config::isDebug()) {
|
||||
$error['debug'] = [
|
||||
'exception' => get_class($e),
|
||||
'message' => $e->getMessage(),
|
||||
'file' => $e->getFile(),
|
||||
'line' => $e->getLine(),
|
||||
];
|
||||
}
|
||||
if ($extra !== []) {
|
||||
$error = array_merge($error, $extra);
|
||||
}
|
||||
|
||||
if (!self::$jsonMode) {
|
||||
self::sendHtmlError($status, $message);
|
||||
}
|
||||
|
||||
self::send(['status' => 'error', 'error' => $error], $status);
|
||||
}
|
||||
|
||||
private static function send(array $payload, int $status): void
|
||||
{
|
||||
if (!headers_sent()) {
|
||||
http_response_code($status);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
}
|
||||
|
||||
$json = json_encode(
|
||||
$payload,
|
||||
JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT | JSON_INVALID_UTF8_SUBSTITUTE
|
||||
);
|
||||
|
||||
echo $json === false
|
||||
? '{"status":"error","error":{"code":"encoding_error","message":"Antwort nicht kodierbar."}}'
|
||||
: $json;
|
||||
|
||||
exit;
|
||||
}
|
||||
|
||||
private static function sendHtmlError(int $status, string $message): void
|
||||
{
|
||||
if (!headers_sent()) {
|
||||
http_response_code($status);
|
||||
header('Content-Type: text/html; charset=utf-8');
|
||||
}
|
||||
echo '<!doctype html><meta charset="utf-8"><title>Fehler ' . $status . '</title>'
|
||||
. '<body style="font-family:system-ui,sans-serif;background:#0a0d14;color:#EDEFF5;padding:3rem;">'
|
||||
. '<h1 style="font-size:1.4rem;">Fehler ' . $status . '</h1>'
|
||||
. '<p style="color:#8B93A7;">' . htmlspecialchars($message, ENT_QUOTES, 'UTF-8') . '</p>'
|
||||
. '<p style="color:#8B93A7;font-size:.85rem;">Details stehen im Server-Log unter var/log/.</p>'
|
||||
. '</body>';
|
||||
exit;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Eingabe
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
public static function method(): string
|
||||
{
|
||||
return strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET'));
|
||||
}
|
||||
|
||||
public static function path(): string
|
||||
{
|
||||
$uri = (string)($_SERVER['REQUEST_URI'] ?? '/');
|
||||
$path = parse_url($uri, PHP_URL_PATH);
|
||||
return is_string($path) ? $path : '/';
|
||||
}
|
||||
|
||||
/**
|
||||
* Alle Request-Header mit kleingeschriebenen Namen.
|
||||
*
|
||||
* Bewusst aus $_SERVER aufgebaut statt ueber getallheaders(): letzteres
|
||||
* existiert nicht in jeder SAPI und liefert die Schreibweise des Clients.
|
||||
*
|
||||
* @return array<string,string>
|
||||
*/
|
||||
public static function headers(): array
|
||||
{
|
||||
if (self::$headerCache !== null) {
|
||||
return self::$headerCache;
|
||||
}
|
||||
|
||||
$headers = [];
|
||||
foreach ($_SERVER as $key => $value) {
|
||||
if (!is_string($key) || !is_scalar($value)) {
|
||||
continue;
|
||||
}
|
||||
if (strncmp($key, 'HTTP_', 5) === 0) {
|
||||
$name = strtolower(str_replace('_', '-', substr($key, 5)));
|
||||
$headers[$name] = (string)$value;
|
||||
}
|
||||
}
|
||||
// Diese beiden kommen ohne HTTP_-Praefix an.
|
||||
foreach (['CONTENT_TYPE' => 'content-type', 'CONTENT_LENGTH' => 'content-length'] as $src => $dst) {
|
||||
if (isset($_SERVER[$src]) && is_scalar($_SERVER[$src])) {
|
||||
$headers[$dst] = (string)$_SERVER[$src];
|
||||
}
|
||||
}
|
||||
|
||||
self::$headerCache = $headers;
|
||||
return $headers;
|
||||
}
|
||||
|
||||
public static function header(string $name): ?string
|
||||
{
|
||||
$headers = self::headers();
|
||||
$key = strtolower($name);
|
||||
return isset($headers[$key]) && $headers[$key] !== '' ? $headers[$key] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ermittelt das Agenten-Token aus X-Agent-Token, X-Master-Token
|
||||
* oder einem Authorization-Bearer-Header.
|
||||
*/
|
||||
public static function bearerToken(): ?string
|
||||
{
|
||||
foreach (['x-agent-token', 'x-master-token', 'x-license-key', 'x-watchdog-key'] as $name) {
|
||||
$value = self::header($name);
|
||||
if ($value !== null) {
|
||||
return trim($value);
|
||||
}
|
||||
}
|
||||
|
||||
$auth = self::header('authorization');
|
||||
if ($auth !== null && preg_match('/^\s*Bearer\s+(\S+)/i', $auth, $m) === 1) {
|
||||
return trim($m[1]);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request-Body als Array.
|
||||
*
|
||||
* JSON hat Vorrang. Ist der Body leer, wird auf $_POST zurueckgefallen.
|
||||
* Ist der Body vorhanden, aber kein gueltiges JSON, wird abgebrochen -
|
||||
* ein stiller Fallback wuerde nur schwer auffindbare Fehler erzeugen.
|
||||
*/
|
||||
public static function body(): array
|
||||
{
|
||||
if (self::$bodyCache !== null) {
|
||||
return self::$bodyCache;
|
||||
}
|
||||
|
||||
$raw = file_get_contents('php://input');
|
||||
if ($raw === false || trim($raw) === '') {
|
||||
self::$bodyCache = is_array($_POST) ? $_POST : [];
|
||||
return self::$bodyCache;
|
||||
}
|
||||
|
||||
$contentType = strtolower((string)self::header('content-type'));
|
||||
if (
|
||||
str_contains($contentType, 'application/x-www-form-urlencoded')
|
||||
|| str_contains($contentType, 'multipart/form-data')
|
||||
) {
|
||||
self::$bodyCache = is_array($_POST) ? $_POST : [];
|
||||
return self::$bodyCache;
|
||||
}
|
||||
|
||||
$decoded = json_decode($raw, true);
|
||||
if (!is_array($decoded)) {
|
||||
self::fail(400, 'invalid_json', 'Request-Body ist kein gueltiges JSON-Objekt.');
|
||||
}
|
||||
|
||||
self::$bodyCache = $decoded;
|
||||
return self::$bodyCache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest einen Wert aus Body oder Query-String (Body hat Vorrang).
|
||||
*
|
||||
* @param mixed $default
|
||||
* @return mixed
|
||||
*/
|
||||
public static function input(string $key, $default = null)
|
||||
{
|
||||
$body = self::body();
|
||||
if (array_key_exists($key, $body)) {
|
||||
return $body[$key];
|
||||
}
|
||||
if (isset($_GET[$key])) {
|
||||
return $_GET[$key];
|
||||
}
|
||||
return $default;
|
||||
}
|
||||
|
||||
/** Getrimmter String-Wert aus Body oder Query, oder null wenn leer. */
|
||||
public static function str(string $key, ?string $default = null): ?string
|
||||
{
|
||||
$value = self::input($key, null);
|
||||
if ($value === null || is_array($value)) {
|
||||
return $default;
|
||||
}
|
||||
$value = trim((string)$value);
|
||||
return $value === '' ? $default : $value;
|
||||
}
|
||||
|
||||
public static function int(string $key, int $default = 0): int
|
||||
{
|
||||
$value = self::input($key, null);
|
||||
if ($value === null || is_array($value)) {
|
||||
return $default;
|
||||
}
|
||||
return (int)$value;
|
||||
}
|
||||
|
||||
/** IP des Clients; Proxy-Header werden bewusst ignoriert (faelschbar). */
|
||||
public static function clientIp(): string
|
||||
{
|
||||
$ip = $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1';
|
||||
return is_string($ip) && $ip !== '' ? $ip : '127.0.0.1';
|
||||
}
|
||||
|
||||
/** Basis-URL der Installation, z. B. https://dc.mhdf.de */
|
||||
public static function baseUrl(): string
|
||||
{
|
||||
$configured = (string)Config::get('app.url', '');
|
||||
if ($configured !== '') {
|
||||
return rtrim($configured, '/');
|
||||
}
|
||||
$https = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off';
|
||||
$host = (string)($_SERVER['HTTP_HOST'] ?? 'localhost');
|
||||
return ($https ? 'https' : 'http') . '://' . $host;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Deploymentcenter\Core;
|
||||
|
||||
/**
|
||||
* Minimaler Datei-Logger. Schlaegt das Schreiben fehl, wird still auf
|
||||
* error_log() ausgewichen - Logging darf niemals die Anwendung stoppen.
|
||||
*/
|
||||
final class Logger
|
||||
{
|
||||
private static ?string $resolvedDir = null;
|
||||
|
||||
public static function error(string $message, array $context = []): void
|
||||
{
|
||||
self::write('ERROR', $message, $context);
|
||||
}
|
||||
|
||||
public static function warning(string $message, array $context = []): void
|
||||
{
|
||||
self::write('WARN', $message, $context);
|
||||
}
|
||||
|
||||
public static function info(string $message, array $context = []): void
|
||||
{
|
||||
self::write('INFO', $message, $context);
|
||||
}
|
||||
|
||||
public static function exception(\Throwable $e): void
|
||||
{
|
||||
self::write('ERROR', sprintf(
|
||||
'%s: %s in %s:%d',
|
||||
get_class($e),
|
||||
$e->getMessage(),
|
||||
$e->getFile(),
|
||||
$e->getLine()
|
||||
), ['trace' => $e->getTraceAsString()]);
|
||||
}
|
||||
|
||||
private static function write(string $level, string $message, array $context = []): void
|
||||
{
|
||||
$line = sprintf(
|
||||
"[%s] %s %s%s\n",
|
||||
gmdate('Y-m-d H:i:s'),
|
||||
$level,
|
||||
$message,
|
||||
$context !== [] ? ' ' . self::encodeContext($context) : ''
|
||||
);
|
||||
|
||||
$dir = self::dir();
|
||||
if ($dir !== null) {
|
||||
$file = $dir . '/dc-' . gmdate('Y-m-d') . '.log';
|
||||
if (@file_put_contents($file, $line, FILE_APPEND | LOCK_EX) !== false) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@error_log(rtrim($line));
|
||||
}
|
||||
|
||||
private static function encodeContext(array $context): string
|
||||
{
|
||||
$json = json_encode($context, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE);
|
||||
return $json === false ? '{}' : $json;
|
||||
}
|
||||
|
||||
private static function dir(): ?string
|
||||
{
|
||||
if (self::$resolvedDir !== null) {
|
||||
return self::$resolvedDir === '' ? null : self::$resolvedDir;
|
||||
}
|
||||
|
||||
$dir = defined('DC_VAR') ? DC_VAR . '/log' : null;
|
||||
if ($dir === null) {
|
||||
self::$resolvedDir = '';
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!is_dir($dir) && !@mkdir($dir, 0775, true) && !is_dir($dir)) {
|
||||
self::$resolvedDir = '';
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!is_writable($dir)) {
|
||||
self::$resolvedDir = '';
|
||||
return null;
|
||||
}
|
||||
|
||||
self::$resolvedDir = $dir;
|
||||
return $dir;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Deploymentcenter\Core;
|
||||
|
||||
use PDO;
|
||||
use PDOException;
|
||||
|
||||
/**
|
||||
* Migrations-Runner.
|
||||
*
|
||||
* Ersetzt das frueher genutzte explode(';') - das zerlegte jede Migration,
|
||||
* die ein Semikolon innerhalb eines String-Literals enthielt, in Fragmente
|
||||
* und liess sie (durch verschlucktes catch) still fehlschlagen.
|
||||
*
|
||||
* Angewendete Migrationen werden in dc_migrations vermerkt und nicht erneut
|
||||
* ausgefuehrt. Fehler brechen den Lauf ab, statt ignoriert zu werden.
|
||||
*/
|
||||
final class Migrator
|
||||
{
|
||||
/**
|
||||
* MySQL-Fehlercodes, die bei additiven Migrationen unkritisch sind:
|
||||
* Objekt existiert bereits. Alles andere ist ein echter Fehler.
|
||||
*/
|
||||
private const TOLERATED_ERRORS = [
|
||||
1022, // Duplicate key
|
||||
1050, // Table already exists
|
||||
1060, // Duplicate column name
|
||||
1061, // Duplicate key name
|
||||
1062, // Duplicate entry for key
|
||||
1091, // Can't DROP; check that column/key exists
|
||||
1826, // Duplicate foreign key constraint name
|
||||
];
|
||||
|
||||
/**
|
||||
* Migrationen, die auf einer bereits bestehenden Installation nur vermerkt,
|
||||
* aber nicht ausgefuehrt werden.
|
||||
*
|
||||
* Grund: schema.sql und Migration 004 legen nicht nur Tabellen an, sondern
|
||||
* spielen auch Beispieldaten ein (Demo-Lizenzen, Demo-Monitore, Demo-Bugs).
|
||||
* Auf einer produktiv genutzten Datenbank wuerden diese Zeilen dadurch
|
||||
* wieder auftauchen, nachdem sie geloescht wurden - die Seeds arbeiten mit
|
||||
* festen IDs und ON DUPLICATE KEY UPDATE. Die Struktur, die sie erzeugen,
|
||||
* ist auf einer bestehenden Installation ohnehin vorhanden.
|
||||
*/
|
||||
private const BASELINE_ONLY = [
|
||||
'000_schema',
|
||||
'004_unified_tokens_and_bugtracker',
|
||||
'v2_hardware_id',
|
||||
];
|
||||
|
||||
/**
|
||||
* Fuehrt Schema und ausstehende Migrationen aus.
|
||||
*
|
||||
* @return array{applied:list<array<string,mixed>>,skipped:list<string>,baselined:list<string>,failed:?array<string,mixed>}
|
||||
*/
|
||||
public static function migrate(PDO $db): array
|
||||
{
|
||||
self::ensureMigrationsTable($db);
|
||||
|
||||
$applied = self::appliedVersions($db);
|
||||
$result = ['applied' => [], 'skipped' => [], 'baselined' => [], 'failed' => null];
|
||||
|
||||
// Bestehende Installation ohne Migrationsvermerk: Strukturmigrationen
|
||||
// als Ausgangsstand vermerken, statt sie samt Beispieldaten auszufuehren.
|
||||
if ($applied === [] && self::isExistingInstall($db)) {
|
||||
$files = self::discoverFiles();
|
||||
foreach (self::BASELINE_ONLY as $version) {
|
||||
if (isset($files[$version])) {
|
||||
self::recordApplied($db, $version, hash('sha256', 'baseline'), 0, 0);
|
||||
$result['baselined'][] = $version;
|
||||
}
|
||||
}
|
||||
|
||||
Logger::info('Bestehende Installation als Ausgangsstand vermerkt', [
|
||||
'versions' => $result['baselined'],
|
||||
]);
|
||||
|
||||
$applied = self::appliedVersions($db);
|
||||
}
|
||||
|
||||
foreach (self::discoverFiles() as $version => $path) {
|
||||
if (isset($applied[$version])) {
|
||||
$result['skipped'][] = $version;
|
||||
continue;
|
||||
}
|
||||
|
||||
$sql = @file_get_contents($path);
|
||||
if ($sql === false) {
|
||||
$result['failed'] = ['version' => $version, 'error' => 'Datei nicht lesbar: ' . $path];
|
||||
return $result;
|
||||
}
|
||||
|
||||
$statements = self::splitStatements($sql);
|
||||
$executed = 0;
|
||||
$tolerated = 0;
|
||||
$started = microtime(true);
|
||||
|
||||
foreach ($statements as $index => $statement) {
|
||||
try {
|
||||
$db->exec($statement);
|
||||
$executed++;
|
||||
} catch (PDOException $e) {
|
||||
$code = self::driverErrorCode($e);
|
||||
if ($code !== null && in_array($code, self::TOLERATED_ERRORS, true)) {
|
||||
$tolerated++;
|
||||
continue;
|
||||
}
|
||||
|
||||
Logger::error('Migration fehlgeschlagen', [
|
||||
'version' => $version,
|
||||
'statement' => $index + 1,
|
||||
'sql' => mb_substr($statement, 0, 400),
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
$result['failed'] = [
|
||||
'version' => $version,
|
||||
'statement' => $index + 1,
|
||||
'error' => $e->getMessage(),
|
||||
'sql' => mb_substr($statement, 0, 400),
|
||||
];
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
|
||||
$durationMs = (int)round((microtime(true) - $started) * 1000);
|
||||
self::recordApplied($db, $version, hash('sha256', $sql), $executed, $durationMs);
|
||||
|
||||
$result['applied'][] = [
|
||||
'version' => $version,
|
||||
'statements' => $executed,
|
||||
'tolerated' => $tolerated,
|
||||
'duration_ms' => $durationMs,
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktuell in der Datenbank vermerkte Migrationsversionen.
|
||||
*
|
||||
* Legt die Tabelle bewusst NICHT an - die Methode wird bei jedem
|
||||
* Seitenaufruf des Dashboards ausgefuehrt und soll dabei kein DDL absetzen.
|
||||
* Fehlt die Tabelle, gelten schlicht alle Migrationen als ausstehend.
|
||||
*/
|
||||
public static function status(PDO $db): array
|
||||
{
|
||||
$all = array_keys(self::discoverFiles());
|
||||
|
||||
if (!self::migrationsTableExists($db)) {
|
||||
return ['applied' => [], 'pending' => $all];
|
||||
}
|
||||
|
||||
$applied = self::appliedVersions($db);
|
||||
|
||||
return [
|
||||
'applied' => array_values($applied),
|
||||
'pending' => array_values(array_diff($all, array_keys($applied))),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Erkennt eine bereits produktiv genutzte Datenbank.
|
||||
*
|
||||
* Kriterium: die Kerntabelle dc_projects existiert und enthaelt Zeilen.
|
||||
* Bei einer leeren Datenbank trifft das nicht zu, dort laeuft schema.sql
|
||||
* regulaer durch und legt auch die Beispieldaten an.
|
||||
*/
|
||||
private static function isExistingInstall(PDO $db): bool
|
||||
{
|
||||
try {
|
||||
$stmt = $db->query("SHOW TABLES LIKE 'dc_projects'");
|
||||
if ($stmt === false || $stmt->fetchColumn() === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (int)$db->query('SELECT COUNT(*) FROM dc_projects')->fetchColumn() > 0;
|
||||
} catch (\Throwable $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static ?bool $tableExistsCache = null;
|
||||
|
||||
private static function migrationsTableExists(PDO $db): bool
|
||||
{
|
||||
if (self::$tableExistsCache !== null) {
|
||||
return self::$tableExistsCache;
|
||||
}
|
||||
|
||||
try {
|
||||
$stmt = $db->query("SHOW TABLES LIKE 'dc_migrations'");
|
||||
self::$tableExistsCache = $stmt !== false && $stmt->fetchColumn() !== false;
|
||||
} catch (\Throwable $e) {
|
||||
self::$tableExistsCache = false;
|
||||
}
|
||||
|
||||
return self::$tableExistsCache;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private static function ensureMigrationsTable(PDO $db): void
|
||||
{
|
||||
// Der Existenz-Cache in migrationsTableExists() waere sonst veraltet,
|
||||
// wenn status() vor migrate() im selben Request lief.
|
||||
self::$tableExistsCache = true;
|
||||
|
||||
$db->exec('
|
||||
CREATE TABLE IF NOT EXISTS dc_migrations (
|
||||
version VARCHAR(190) NOT NULL PRIMARY KEY,
|
||||
checksum CHAR(64) NOT NULL,
|
||||
statements INT NOT NULL DEFAULT 0,
|
||||
duration_ms INT NOT NULL DEFAULT 0,
|
||||
applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
');
|
||||
}
|
||||
|
||||
/** @return array<string,array<string,mixed>> */
|
||||
private static function appliedVersions(PDO $db): array
|
||||
{
|
||||
$rows = $db->query('SELECT version, checksum, applied_at FROM dc_migrations')->fetchAll();
|
||||
$out = [];
|
||||
foreach ($rows ?: [] as $row) {
|
||||
$out[(string)$row['version']] = $row;
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
private static function recordApplied(
|
||||
PDO $db,
|
||||
string $version,
|
||||
string $checksum,
|
||||
int $statements,
|
||||
int $durationMs
|
||||
): void {
|
||||
$stmt = $db->prepare('
|
||||
INSERT INTO dc_migrations (version, checksum, statements, duration_ms, applied_at)
|
||||
VALUES (:version, :checksum, :statements, :duration, NOW())
|
||||
ON DUPLICATE KEY UPDATE
|
||||
checksum = VALUES(checksum),
|
||||
statements = VALUES(statements),
|
||||
duration_ms = VALUES(duration_ms),
|
||||
applied_at = VALUES(applied_at)
|
||||
');
|
||||
$stmt->execute([
|
||||
':version' => $version,
|
||||
':checksum' => $checksum,
|
||||
':statements' => $statements,
|
||||
':duration' => $durationMs,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Basis-Schema plus alle Migrationen, in Ausfuehrungsreihenfolge.
|
||||
*
|
||||
* @return array<string,string> version => absoluter Pfad
|
||||
*/
|
||||
private static function discoverFiles(): array
|
||||
{
|
||||
$files = [];
|
||||
|
||||
$schema = DC_ROOT . '/sql/schema.sql';
|
||||
if (is_file($schema)) {
|
||||
$files['000_schema'] = $schema;
|
||||
}
|
||||
|
||||
$dir = DC_ROOT . '/sql/migrations';
|
||||
if (is_dir($dir)) {
|
||||
$found = glob($dir . '/*.sql') ?: [];
|
||||
sort($found, SORT_NATURAL);
|
||||
foreach ($found as $path) {
|
||||
$files[basename($path, '.sql')] = $path;
|
||||
}
|
||||
}
|
||||
|
||||
return $files;
|
||||
}
|
||||
|
||||
private static function driverErrorCode(PDOException $e): ?int
|
||||
{
|
||||
$info = $e->errorInfo;
|
||||
if (is_array($info) && isset($info[1]) && is_numeric($info[1])) {
|
||||
return (int)$info[1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zerlegt ein SQL-Skript in einzelne Statements.
|
||||
*
|
||||
* Beachtet String-Literale ('...', "..."), Backtick-Bezeichner sowie
|
||||
* Zeilen- und Blockkommentare, damit Semikolons darin nicht als
|
||||
* Statement-Ende missverstanden werden.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function splitStatements(string $sql): array
|
||||
{
|
||||
$statements = [];
|
||||
$buffer = '';
|
||||
$length = strlen($sql);
|
||||
$i = 0;
|
||||
|
||||
$inSingle = false;
|
||||
$inDouble = false;
|
||||
$inBacktick = false;
|
||||
$inLineComment = false;
|
||||
$inBlockComment = false;
|
||||
|
||||
while ($i < $length) {
|
||||
$char = $sql[$i];
|
||||
$next = ($i + 1 < $length) ? $sql[$i + 1] : '';
|
||||
|
||||
if ($inLineComment) {
|
||||
if ($char === "\n") {
|
||||
$inLineComment = false;
|
||||
$buffer .= $char;
|
||||
}
|
||||
$i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($inBlockComment) {
|
||||
if ($char === '*' && $next === '/') {
|
||||
$inBlockComment = false;
|
||||
$i += 2;
|
||||
continue;
|
||||
}
|
||||
$i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($inSingle || $inDouble || $inBacktick) {
|
||||
$buffer .= $char;
|
||||
|
||||
// Backslash-Escape innerhalb von Strings (nicht in Backticks).
|
||||
if ($char === '\\' && !$inBacktick && $next !== '') {
|
||||
$buffer .= $next;
|
||||
$i += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
$quote = $inSingle ? "'" : ($inDouble ? '"' : '`');
|
||||
if ($char === $quote) {
|
||||
if ($next === $quote) {
|
||||
// Verdoppeltes Anfuehrungszeichen = Escape, bleibt im String.
|
||||
$buffer .= $next;
|
||||
$i += 2;
|
||||
continue;
|
||||
}
|
||||
$inSingle = $inDouble = $inBacktick = false;
|
||||
}
|
||||
|
||||
$i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ausserhalb von Strings und Kommentaren
|
||||
if ($char === '-' && $next === '-') {
|
||||
$after = ($i + 2 < $length) ? $sql[$i + 2] : "\n";
|
||||
if ($after === ' ' || $after === "\t" || $after === "\n" || $after === "\r") {
|
||||
$inLineComment = true;
|
||||
$i += 2;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if ($char === '#') {
|
||||
$inLineComment = true;
|
||||
$i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($char === '/' && $next === '*') {
|
||||
$inBlockComment = true;
|
||||
$i += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($char === "'") {
|
||||
$inSingle = true;
|
||||
$buffer .= $char;
|
||||
$i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($char === '"') {
|
||||
$inDouble = true;
|
||||
$buffer .= $char;
|
||||
$i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($char === '`') {
|
||||
$inBacktick = true;
|
||||
$buffer .= $char;
|
||||
$i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($char === ';') {
|
||||
$trimmed = trim($buffer);
|
||||
if ($trimmed !== '') {
|
||||
$statements[] = $trimmed;
|
||||
}
|
||||
$buffer = '';
|
||||
$i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$buffer .= $char;
|
||||
$i++;
|
||||
}
|
||||
|
||||
$trimmed = trim($buffer);
|
||||
if ($trimmed !== '') {
|
||||
$statements[] = $trimmed;
|
||||
}
|
||||
|
||||
return $statements;
|
||||
}
|
||||
}
|
||||
+332
-125
@@ -1,21 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Deploymentcenter\Core;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use PDO;
|
||||
|
||||
class TokenManager
|
||||
/**
|
||||
* Verwaltung der Master-/Sub-Token-Hierarchie in dc_tokens.
|
||||
*
|
||||
* Korrekturen gegenueber der Erstfassung:
|
||||
* - revokeToken()/deleteToken() nutzen getrennte Platzhalter. Derselbe
|
||||
* benannte Parameter zweimal im Statement ist bei ATTR_EMULATE_PREPARES=false
|
||||
* nicht zulaessig und warf HY093.
|
||||
* - validateToken() vergleicht ausschliesslich den SHA-256-Hash, nicht mehr
|
||||
* zusaetzlich den Klartext.
|
||||
* - expires_at wird ausgewertet; die Spalte existierte, wurde aber ignoriert.
|
||||
* - Scopes unterstuetzen Praefix-Wildcards (bugtracker:* deckt bugtracker:report ab).
|
||||
*
|
||||
* Hinweis: raw_token wird weiterhin gespeichert, damit das WebUI Tokens
|
||||
* nachtraeglich anzeigen und kopieren kann. Das ist eine bewusste Abwaegung
|
||||
* fuer ein Ein-Administrator-Werkzeug hinter Login. Wer das nicht moechte,
|
||||
* setzt store_raw_tokens = false; dann ist das Token nur einmalig bei der
|
||||
* Erstellung sichtbar.
|
||||
*/
|
||||
final class TokenManager
|
||||
{
|
||||
public const OWNER_TYPES = ['license', 'project', 'host', 'dev_agent', 'custom'];
|
||||
public const ENVIRONMENTS = ['production', 'development', 'all'];
|
||||
|
||||
public const KNOWN_SCOPES = [
|
||||
'*',
|
||||
'bugtracker:report',
|
||||
'bugtracker:read',
|
||||
'bugtracker:manage',
|
||||
'watchdog:ping',
|
||||
'watchdog:read',
|
||||
'updateservice:read',
|
||||
'updateservice:publish',
|
||||
'tokens:provision',
|
||||
];
|
||||
|
||||
private PDO $db;
|
||||
private bool $storeRaw;
|
||||
|
||||
public function __construct(PDO $db)
|
||||
{
|
||||
$this->db = $db;
|
||||
$this->storeRaw = (bool)Config::get('security.store_raw_tokens', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new Master Token.
|
||||
*/
|
||||
// ------------------------------------------------------------------
|
||||
// Erstellung
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
public function createMasterToken(
|
||||
string $name,
|
||||
?string $projectSlug = null,
|
||||
@@ -23,47 +62,58 @@ class TokenManager
|
||||
string $ownerType = 'custom',
|
||||
?string $ownerIdentity = null,
|
||||
array $scopes = ['*'],
|
||||
string $environment = 'all'
|
||||
string $environment = 'all',
|
||||
?string $expiresAt = null
|
||||
): array {
|
||||
$tokenId = 'tok_m_' . bin2hex(random_bytes(8));
|
||||
$rawToken = 'dc_master_' . bin2hex(random_bytes(20));
|
||||
$tokenHash = hash('sha256', $rawToken);
|
||||
$name = trim($name);
|
||||
if ($name === '') {
|
||||
throw new InvalidArgumentException('Token-Bezeichnung darf nicht leer sein.');
|
||||
}
|
||||
|
||||
$tokenId = 'tok_m_' . bin2hex(random_bytes(8));
|
||||
$rawToken = 'dc_master_' . bin2hex(random_bytes(24));
|
||||
|
||||
$stmt = $this->db->prepare('
|
||||
INSERT INTO dc_tokens (
|
||||
token_id, parent_token_id, token_hash, raw_token, name,
|
||||
project_slug, license_key, owner_type, owner_identity,
|
||||
type, scopes, environment, created_at
|
||||
type, scopes, environment, expires_at, created_at
|
||||
) VALUES (
|
||||
:id, NULL, :hash, :raw, :name,
|
||||
:proj, :lic, :type, :identity,
|
||||
"master", :scopes, :env, NOW()
|
||||
:proj, :lic, :owner_type, :identity,
|
||||
"master", :scopes, :env, :expires, UTC_TIMESTAMP()
|
||||
)
|
||||
');
|
||||
|
||||
$stmt->execute([
|
||||
':id' => $tokenId,
|
||||
':hash' => $tokenHash,
|
||||
':raw' => $rawToken,
|
||||
':name' => $name,
|
||||
':proj' => !empty($projectSlug) ? $projectSlug : null,
|
||||
':lic' => !empty($licenseKey) ? $licenseKey : null,
|
||||
':type' => in_array($ownerType, ['license', 'project', 'host', 'dev_agent', 'custom']) ? $ownerType : 'custom',
|
||||
':identity' => !empty($ownerIdentity) ? $ownerIdentity : null,
|
||||
':scopes' => json_encode(!empty($scopes) ? $scopes : ['*']),
|
||||
':env' => in_array($environment, ['production', 'development', 'all']) ? $environment : 'all',
|
||||
':id' => $tokenId,
|
||||
':hash' => hash('sha256', $rawToken),
|
||||
':raw' => $this->storeRaw ? $rawToken : null,
|
||||
':name' => $name,
|
||||
':proj' => self::nullIfEmpty($projectSlug),
|
||||
':lic' => self::nullIfEmpty($licenseKey),
|
||||
':owner_type' => in_array($ownerType, self::OWNER_TYPES, true) ? $ownerType : 'custom',
|
||||
':identity' => self::nullIfEmpty($ownerIdentity),
|
||||
':scopes' => json_encode(self::normalizeScopes($scopes, ['*'])),
|
||||
':env' => in_array($environment, self::ENVIRONMENTS, true) ? $environment : 'all',
|
||||
':expires' => self::nullIfEmpty($expiresAt),
|
||||
]);
|
||||
|
||||
Logger::info('Master-Token erstellt', ['token_id' => $tokenId, 'name' => $name]);
|
||||
|
||||
return [
|
||||
'token_id' => $tokenId,
|
||||
'raw_token' => $rawToken,
|
||||
'name' => $name,
|
||||
'type' => 'master',
|
||||
'token_id' => $tokenId,
|
||||
'raw_token' => $rawToken,
|
||||
'name' => $name,
|
||||
'type' => 'master',
|
||||
'scopes' => self::normalizeScopes($scopes, ['*']),
|
||||
'environment' => $environment,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Provision a Sub-Token using a Master-Token.
|
||||
* Erzeugt ein Sub-Token aus einem gueltigen Master-Token.
|
||||
* Rechte und Umgebung koennen dabei nur eingeschraenkt, nie erweitert werden.
|
||||
*/
|
||||
public function provisionSubToken(
|
||||
string $rawMasterToken,
|
||||
@@ -72,171 +122,328 @@ class TokenManager
|
||||
array $requestedScopes = [],
|
||||
string $environment = 'all'
|
||||
): array {
|
||||
$masterHash = hash('sha256', $rawMasterToken);
|
||||
|
||||
$stmt = $this->db->prepare('
|
||||
SELECT * FROM dc_tokens
|
||||
WHERE (token_hash = :hash OR raw_token = :raw)
|
||||
AND type = "master"
|
||||
AND revoked = 0
|
||||
');
|
||||
$stmt->execute([':hash' => $masterHash, ':raw' => $rawMasterToken]);
|
||||
$master = $stmt->fetch();
|
||||
$master = $this->findByRawToken($rawMasterToken);
|
||||
|
||||
if (!$master) {
|
||||
throw new \InvalidArgumentException('Invalid or revoked Master Token.');
|
||||
if ($master === null || $master['type'] !== 'master' || (int)$master['revoked'] === 1) {
|
||||
throw new InvalidArgumentException('Ungueltiges oder widerrufenes Master-Token.');
|
||||
}
|
||||
|
||||
$masterScopes = json_decode($master['scopes'], true) ?: ['*'];
|
||||
if (self::isExpired($master)) {
|
||||
throw new InvalidArgumentException('Master-Token ist abgelaufen.');
|
||||
}
|
||||
|
||||
// Determine effective scopes
|
||||
$effectiveScopes = [];
|
||||
if (in_array('*', $masterScopes)) {
|
||||
$effectiveScopes = !empty($requestedScopes) ? $requestedScopes : ['*'];
|
||||
$masterScopes = self::decodeScopes($master['scopes']);
|
||||
$requested = self::normalizeScopes($requestedScopes, []);
|
||||
|
||||
if ($requested === []) {
|
||||
$effectiveScopes = $masterScopes;
|
||||
} elseif (in_array('*', $masterScopes, true)) {
|
||||
$effectiveScopes = $requested;
|
||||
} else {
|
||||
if (empty($requestedScopes)) {
|
||||
$effectiveScopes = $masterScopes;
|
||||
} else {
|
||||
$effectiveScopes = array_intersect($requestedScopes, $masterScopes);
|
||||
}
|
||||
// Nur Rechte durchreichen, die das Master-Token tatsaechlich besitzt.
|
||||
$effectiveScopes = array_values(array_filter(
|
||||
$requested,
|
||||
static fn(string $scope): bool => self::scopeSatisfied($masterScopes, $scope)
|
||||
));
|
||||
}
|
||||
|
||||
if (empty($effectiveScopes)) {
|
||||
throw new \InvalidArgumentException('Requested scopes are not allowed by this Master Token.');
|
||||
if ($effectiveScopes === []) {
|
||||
throw new InvalidArgumentException('Die angeforderten Rechte deckt dieses Master-Token nicht ab.');
|
||||
}
|
||||
|
||||
// Determine effective environment
|
||||
$effectiveEnv = $environment;
|
||||
if ($master['environment'] !== 'all') {
|
||||
$effectiveEnv = $master['environment'];
|
||||
}
|
||||
// Ist das Master-Token auf eine Umgebung festgelegt, gilt diese zwingend.
|
||||
$effectiveEnv = $master['environment'] !== 'all'
|
||||
? (string)$master['environment']
|
||||
: (in_array($environment, self::ENVIRONMENTS, true) ? $environment : 'all');
|
||||
|
||||
$subTokenId = 'tok_s_' . bin2hex(random_bytes(8));
|
||||
$rawSubToken = 'dc_sub_' . bin2hex(random_bytes(20));
|
||||
$subHash = hash('sha256', $rawSubToken);
|
||||
$subTokenId = 'tok_s_' . bin2hex(random_bytes(8));
|
||||
$rawSubToken = 'dc_sub_' . bin2hex(random_bytes(24));
|
||||
|
||||
$ins = $this->db->prepare('
|
||||
$stmt = $this->db->prepare('
|
||||
INSERT INTO dc_tokens (
|
||||
token_id, parent_token_id, token_hash, raw_token, name,
|
||||
project_slug, license_key, owner_type, owner_identity,
|
||||
type, scopes, environment, created_at
|
||||
type, scopes, environment, expires_at, created_at
|
||||
) VALUES (
|
||||
:id, :parent_id, :hash, :raw, :name,
|
||||
:proj, :lic, :owner_type, :identity,
|
||||
"sub", :scopes, :env, NOW()
|
||||
"sub", :scopes, :env, :expires, UTC_TIMESTAMP()
|
||||
)
|
||||
');
|
||||
|
||||
$ins->execute([
|
||||
':id' => $subTokenId,
|
||||
':parent_id' => $master['token_id'],
|
||||
':hash' => $subHash,
|
||||
':raw' => $rawSubToken,
|
||||
':name' => $name,
|
||||
':proj' => $master['project_slug'],
|
||||
':lic' => $master['license_key'],
|
||||
':owner_type'=> $master['owner_type'],
|
||||
':identity' => !empty($instanceIdentity) ? $instanceIdentity : $master['owner_identity'],
|
||||
':scopes' => json_encode(array_values($effectiveScopes)),
|
||||
':env' => $effectiveEnv,
|
||||
$stmt->execute([
|
||||
':id' => $subTokenId,
|
||||
':parent_id' => $master['token_id'],
|
||||
':hash' => hash('sha256', $rawSubToken),
|
||||
':raw' => $this->storeRaw ? $rawSubToken : null,
|
||||
':name' => trim($name) !== '' ? trim($name) : 'Auto-Provisioned Sub-Token',
|
||||
':proj' => $master['project_slug'],
|
||||
':lic' => $master['license_key'],
|
||||
':owner_type' => $master['owner_type'],
|
||||
':identity' => self::nullIfEmpty($instanceIdentity) ?? $master['owner_identity'],
|
||||
':scopes' => json_encode($effectiveScopes),
|
||||
':env' => $effectiveEnv,
|
||||
// Ein Sub-Token ueberlebt sein Master-Token nicht.
|
||||
':expires' => $master['expires_at'],
|
||||
]);
|
||||
|
||||
Logger::info('Sub-Token provisioniert', [
|
||||
'token_id' => $subTokenId,
|
||||
'parent' => $master['token_id'],
|
||||
]);
|
||||
|
||||
return [
|
||||
'token_id' => $subTokenId,
|
||||
'raw_token' => $rawSubToken,
|
||||
'name' => $name,
|
||||
'scopes' => array_values($effectiveScopes),
|
||||
'environment'=> $effectiveEnv,
|
||||
'type' => 'sub',
|
||||
'token_id' => $subTokenId,
|
||||
'raw_token' => $rawSubToken,
|
||||
'name' => $name,
|
||||
'scopes' => $effectiveScopes,
|
||||
'environment' => $effectiveEnv,
|
||||
'expires_at' => $master['expires_at'],
|
||||
'type' => 'sub',
|
||||
];
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Validierung
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Validate any Token (Master or Sub) and check cascading revocation of parent tokens.
|
||||
* Prueft ein Token auf Gueltigkeit, Rechte und Umgebung.
|
||||
*
|
||||
* @return array<string,mixed>|null Der Token-Datensatz oder null.
|
||||
*/
|
||||
public function validateToken(string $rawToken, ?string $requiredScope = null, ?string $environment = null): ?array
|
||||
{
|
||||
$hash = hash('sha256', $rawToken);
|
||||
$token = $this->findByRawToken($rawToken);
|
||||
|
||||
$stmt = $this->db->prepare('
|
||||
SELECT t.*, p.revoked as parent_revoked
|
||||
FROM dc_tokens t
|
||||
LEFT JOIN dc_tokens p ON t.parent_token_id = p.token_id
|
||||
WHERE (t.token_hash = :hash OR t.raw_token = :raw)
|
||||
AND t.revoked = 0
|
||||
');
|
||||
$stmt->execute([':hash' => $hash, ':raw' => $rawToken]);
|
||||
$token = $stmt->fetch();
|
||||
|
||||
if (!$token) {
|
||||
if ($token === null || (int)$token['revoked'] === 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Cascading Revocation Check
|
||||
if ($token['type'] === 'sub' && !empty($token['parent_token_id']) && (int)$token['parent_revoked'] === 1) {
|
||||
// Kaskadierende Sperre: ein widerrufenes Master-Token entwertet seine Kinder.
|
||||
if ($token['type'] === 'sub' && (int)($token['parent_revoked'] ?? 0) === 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Scope Check
|
||||
if ($requiredScope !== null) {
|
||||
$scopes = json_decode($token['scopes'], true) ?: [];
|
||||
if (!in_array('*', $scopes) && !in_array($requiredScope, $scopes)) {
|
||||
return null;
|
||||
}
|
||||
if (self::isExpired($token)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($requiredScope !== null && !self::scopeSatisfied(self::decodeScopes($token['scopes']), $requiredScope)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Environment Check
|
||||
if ($environment !== null && $token['environment'] !== 'all' && $token['environment'] !== $environment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Update Last Used Timestamp
|
||||
$upd = $this->db->prepare('UPDATE dc_tokens SET last_used_at = NOW() WHERE id = :id');
|
||||
$upd->execute([':id' => $token['id']]);
|
||||
$this->touch((string)$token['token_id']);
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a Token (Master or Sub). If Master, cascade revokes all child Sub-Tokens via DB foreign key or query.
|
||||
*/
|
||||
public function revokeToken(string $tokenId): bool
|
||||
/** Sucht ein Token ausschliesslich ueber den Hash des Klartextwerts. */
|
||||
public function findByRawToken(string $rawToken): ?array
|
||||
{
|
||||
$stmt = $this->db->prepare('UPDATE dc_tokens SET revoked = 1 WHERE token_id = :id OR parent_token_id = :id');
|
||||
return $stmt->execute([':id' => $tokenId]);
|
||||
$rawToken = trim($rawToken);
|
||||
if ($rawToken === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$stmt = $this->db->prepare('
|
||||
SELECT t.*, COALESCE(p.revoked, 0) AS parent_revoked
|
||||
FROM dc_tokens t
|
||||
LEFT JOIN dc_tokens p ON t.parent_token_id = p.token_id
|
||||
WHERE t.token_hash = :hash
|
||||
LIMIT 1
|
||||
');
|
||||
$stmt->execute([':hash' => hash('sha256', $rawToken)]);
|
||||
$row = $stmt->fetch();
|
||||
|
||||
return is_array($row) ? $row : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Permanently delete a Token (Master or Sub). If Master, child Sub-Tokens are deleted via cascade.
|
||||
*/
|
||||
public function deleteToken(string $tokenId): bool
|
||||
private function touch(string $tokenId): void
|
||||
{
|
||||
$stmt = $this->db->prepare('DELETE FROM dc_tokens WHERE token_id = :id OR parent_token_id = :id');
|
||||
return $stmt->execute([':id' => $tokenId]);
|
||||
try {
|
||||
$stmt = $this->db->prepare('UPDATE dc_tokens SET last_used_at = UTC_TIMESTAMP() WHERE token_id = :id');
|
||||
$stmt->execute([':id' => $tokenId]);
|
||||
} catch (\Throwable $e) {
|
||||
// Die Nutzungsstatistik darf keinen Request scheitern lassen.
|
||||
Logger::warning('last_used_at nicht aktualisiert', ['token_id' => $tokenId]);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Verwaltung
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get all Master Tokens with child count.
|
||||
* Widerruft ein Token und alle davon abgeleiteten Sub-Tokens.
|
||||
* Getrennte Platzhalter, da derselbe Parametername sonst HY093 ausloest.
|
||||
*/
|
||||
public function revokeToken(string $tokenId): int
|
||||
{
|
||||
$stmt = $this->db->prepare('
|
||||
UPDATE dc_tokens
|
||||
SET revoked = 1
|
||||
WHERE token_id = :token_id OR parent_token_id = :parent_id
|
||||
');
|
||||
$stmt->execute([':token_id' => $tokenId, ':parent_id' => $tokenId]);
|
||||
|
||||
$count = $stmt->rowCount();
|
||||
Logger::info('Token widerrufen', ['token_id' => $tokenId, 'affected' => $count]);
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/** Loescht ein Token samt Sub-Tokens dauerhaft. */
|
||||
public function deleteToken(string $tokenId): int
|
||||
{
|
||||
$stmt = $this->db->prepare('
|
||||
DELETE FROM dc_tokens
|
||||
WHERE token_id = :token_id OR parent_token_id = :parent_id
|
||||
');
|
||||
$stmt->execute([':token_id' => $tokenId, ':parent_id' => $tokenId]);
|
||||
|
||||
$count = $stmt->rowCount();
|
||||
Logger::info('Token geloescht', ['token_id' => $tokenId, 'affected' => $count]);
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/** @return list<array<string,mixed>> */
|
||||
public function getAllMasterTokens(): array
|
||||
{
|
||||
$stmt = $this->db->query('
|
||||
SELECT m.*, COUNT(s.id) as sub_token_count
|
||||
FROM dc_tokens m
|
||||
LEFT JOIN dc_tokens s ON m.token_id = s.parent_token_id
|
||||
WHERE m.type = "master"
|
||||
GROUP BY m.id
|
||||
SELECT m.*, COUNT(s.id) AS sub_token_count
|
||||
FROM dc_tokens m
|
||||
LEFT JOIN dc_tokens s ON m.token_id = s.parent_token_id
|
||||
WHERE m.type = "master"
|
||||
GROUP BY m.id
|
||||
ORDER BY m.created_at DESC
|
||||
');
|
||||
return $stmt->fetchAll() ?: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all Tokens (Master & Sub).
|
||||
*/
|
||||
/** @return list<array<string,mixed>> */
|
||||
public function getAllTokens(): array
|
||||
{
|
||||
$stmt = $this->db->query('SELECT * FROM dc_tokens ORDER BY created_at DESC');
|
||||
return $stmt->fetchAll() ?: [];
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Hilfsfunktionen
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Rechte, die andere Rechte einschliessen.
|
||||
*
|
||||
* Wer Items bearbeiten darf, muss sie auch lesen koennen - sonst ist das
|
||||
* Recht wertlos. Ohne diese Zuordnung braeuchte jedes Token beide Eintraege
|
||||
* einzeln, und ein im WebUI mit "Bugtracker Manage" erzeugtes Token
|
||||
* scheiterte an jeder Abfrage.
|
||||
*
|
||||
* @var array<string,list<string>>
|
||||
*/
|
||||
private const IMPLIED_SCOPES = [
|
||||
'bugtracker:manage' => ['bugtracker:read', 'bugtracker:report'],
|
||||
'bugtracker:report' => ['bugtracker:read'],
|
||||
'updateservice:publish' => ['updateservice:read'],
|
||||
'watchdog:evaluate' => ['watchdog:read'],
|
||||
'watchdog:ping' => ['watchdog:read'],
|
||||
];
|
||||
|
||||
/**
|
||||
* Prueft, ob eine Scope-Liste ein konkretes Recht abdeckt.
|
||||
*
|
||||
* "*" deckt alles ab, "bugtracker:*" alle bugtracker-Rechte, und
|
||||
* uebergeordnete Rechte schliessen die jeweils schwaecheren ein.
|
||||
*
|
||||
* @param list<string> $granted
|
||||
*/
|
||||
public static function scopeSatisfied(array $granted, string $required): bool
|
||||
{
|
||||
foreach ($granted as $scope) {
|
||||
if ($scope === '*' || $scope === $required) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Praefix-Wildcard: "bugtracker:*" deckt "bugtracker:read" ab
|
||||
if (str_ends_with($scope, ':*')) {
|
||||
$prefix = substr($scope, 0, -1);
|
||||
if (str_starts_with($required, $prefix)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (in_array($required, self::IMPLIED_SCOPES[$scope] ?? [], true)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $raw
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function decodeScopes($raw): array
|
||||
{
|
||||
if (is_array($raw)) {
|
||||
return self::normalizeScopes($raw, ['*']);
|
||||
}
|
||||
if (!is_string($raw) || $raw === '') {
|
||||
return ['*'];
|
||||
}
|
||||
$decoded = json_decode($raw, true);
|
||||
return is_array($decoded) ? self::normalizeScopes($decoded, ['*']) : ['*'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $scopes
|
||||
* @param list<string> $fallback
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function normalizeScopes($scopes, array $fallback): array
|
||||
{
|
||||
if (!is_array($scopes)) {
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
$clean = [];
|
||||
foreach ($scopes as $scope) {
|
||||
if (!is_string($scope)) {
|
||||
continue;
|
||||
}
|
||||
$scope = trim($scope);
|
||||
if ($scope !== '' && !in_array($scope, $clean, true)) {
|
||||
$clean[] = $scope;
|
||||
}
|
||||
}
|
||||
|
||||
return $clean === [] ? $fallback : $clean;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $token */
|
||||
public static function isExpired(array $token): bool
|
||||
{
|
||||
$expires = $token['expires_at'] ?? null;
|
||||
if ($expires === null || $expires === '') {
|
||||
return false;
|
||||
}
|
||||
$ts = strtotime((string)$expires . ' UTC');
|
||||
return $ts !== false && $ts < time();
|
||||
}
|
||||
|
||||
private static function nullIfEmpty(?string $value): ?string
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
$value = trim($value);
|
||||
return $value === '' ? null : $value;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user