From c7f9b7d39cb411bd991e071423c2674ed8a2ced5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 5 Jun 2026 20:47:26 +0000 Subject: [PATCH] API-Reife: Scopes (read/write), Rate-Limit pro Key, OpenAPI-Spec - ApiKey::hasScope + checkRateLimit (Fixed-Window/min via api_key_hits) - bootstrap erzwingt Rate-Limit (429) und api_require_scope() in Endpunkten - admin/api_keys.php: Scope-Auswahl + Limit beim Erstellen, Anzeige in Tabelle - api/openapi.php: OpenAPI-3.0-Spec (Import in Postman/Swagger) - Schema 0003 (api_keys.scope, api_keys.rate_limit, Tabelle api_key_hits) --- admin/api_keys.php | 24 +++++++++++--- api/bootstrap.php | 14 +++++++++ api/openapi.php | 77 +++++++++++++++++++++++++++++++++++++++++++++ api/sites.php | 1 + api/vouchers.php | 2 ++ includes/ApiKey.php | 32 +++++++++++++++++++ 6 files changed, 146 insertions(+), 4 deletions(-) create mode 100644 api/openapi.php diff --git a/admin/api_keys.php b/admin/api_keys.php index ae531ea..a2bbbfb 100644 --- a/admin/api_keys.php +++ b/admin/api_keys.php @@ -28,10 +28,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['create_key'])) { if ($name === '') { $error = __('error_name_req'); } else { + $scope = ($_POST['scope'] ?? 'write') === 'read' ? 'read' : 'write'; + $rate = max(0, (int)($_POST['rate_limit'] ?? 0)); $k = ApiKey::generate(); $db->execute( - "INSERT INTO api_keys (name, key_prefix, key_hash, created_by) VALUES (?, ?, ?, ?)", - [$name, $k['prefix'], $k['hash'], $_SESSION['user_id']] + "INSERT INTO api_keys (name, key_prefix, key_hash, scope, rate_limit, created_by) VALUES (?, ?, ?, ?, ?, ?)", + [$name, $k['prefix'], $k['hash'], $scope, $rate, $_SESSION['user_id']] ); $auth->writeAuditLog($_SESSION['user_id'], 'api_key_create', 'api_key', null, "API-Key '$name' erstellt"); $newKey = $k['plain']; @@ -104,10 +106,21 @@ code { font-family:monospace; background:var(--bg-hover); padding:2px 6px; borde

Neuen API-Schlüssel erstellen

-
+
+
+ + +
+
+ + +
@@ -118,11 +131,13 @@ code { font-family:monospace; background:var(--bg-hover); padding:2px 6px; borde

Noch keine API-Schlüssel angelegt.

- + + + @@ -147,6 +162,7 @@ curl -X POST https://IHRE-DOMAIN/api/vouchers.php \ # Sites auflisten curl https://IHRE-DOMAIN/api/sites.php -H "X-API-Key: uvt_…" +

OpenAPI-Spezifikation (Import in Postman/Swagger): /api/openapi.php

diff --git a/api/bootstrap.php b/api/bootstrap.php index 33c85c8..5bf4e75 100644 --- a/api/bootstrap.php +++ b/api/bootstrap.php @@ -37,3 +37,17 @@ $apiKeyRow = ApiKey::verify(ApiKey::fromRequest(), $db); if (!$apiKeyRow) { api_json(['error' => 'unauthorized', 'message' => 'Gültiger API-Schlüssel erforderlich (Authorization: Bearer …)'], 401); } + +// Rate-Limit pro Schlüssel +if (!ApiKey::checkRateLimit($apiKeyRow, $db)) { + header('Retry-After: 60'); + api_json(['error' => 'rate_limited', 'message' => 'Rate-Limit überschritten. Bitte später erneut versuchen.'], 429); +} + +/** Erzwingt einen Scope für den aktuellen Schlüssel. */ +function api_require_scope($needed) { + global $apiKeyRow; + if (!ApiKey::hasScope($apiKeyRow, $needed)) { + api_json(['error' => 'forbidden', 'message' => "Schlüssel hat keinen '$needed'-Scope"], 403); + } +} diff --git a/api/openapi.php b/api/openapi.php new file mode 100644 index 0000000..790e9bf --- /dev/null +++ b/api/openapi.php @@ -0,0 +1,77 @@ + '3.0.3', + 'info' => [ + 'title' => 'UniFi Voucher Tool API', + 'version' => '1.0.0', + 'description' => 'REST-API zum Erstellen und Abrufen von WLAN-Vouchers. Authentifizierung per API-Schlüssel (Authorization: Bearer … oder X-API-Key).', + ], + 'servers' => [['url' => $base]], + 'components' => [ + 'securitySchemes' => [ + 'bearerAuth' => ['type' => 'http', 'scheme' => 'bearer'], + 'apiKeyAuth' => ['type' => 'apiKey', 'in' => 'header', 'name' => 'X-API-Key'], + ], + ], + 'security' => [['bearerAuth' => []], ['apiKeyAuth' => []]], + 'paths' => [ + '/sites.php' => [ + 'get' => [ + 'summary' => 'Aktive Sites auflisten', + 'description' => 'Erfordert Scope read.', + 'responses' => ['200' => ['description' => 'Liste der Sites']], + ], + ], + '/vouchers.php' => [ + 'get' => [ + 'summary' => 'Voucher einer Site auflisten', + 'description' => 'Erfordert Scope read.', + 'parameters' => [[ + 'name' => 'site_id', 'in' => 'query', 'required' => true, + 'schema' => ['type' => 'integer'], + ]], + 'responses' => ['200' => ['description' => 'Liste der Voucher']], + ], + 'post' => [ + 'summary' => 'Voucher erstellen', + 'description' => 'Erfordert Scope write.', + 'requestBody' => [ + 'required' => true, + 'content' => ['application/json' => ['schema' => [ + 'type' => 'object', + 'required' => ['site_id', 'name'], + 'properties' => [ + 'site_id' => ['type' => 'integer'], + 'name' => ['type' => 'string'], + 'max_uses' => ['type' => 'integer', 'default' => 1], + 'expire_minutes' => ['type' => 'integer', 'default' => 480], + 'qos' => ['type' => 'object', 'properties' => [ + 'down' => ['type' => 'integer', 'description' => 'Download kbit/s'], + 'up' => ['type' => 'integer', 'description' => 'Upload kbit/s'], + 'quota_mb' => ['type' => 'integer', 'description' => 'Datenkontingent MB'], + ]], + ], + ]]], + ], + 'responses' => [ + '201' => ['description' => 'Voucher erstellt'], + '401' => ['description' => 'Nicht authentifiziert'], + '403' => ['description' => 'Fehlender Scope'], + '429' => ['description' => 'Rate-Limit überschritten'], + ], + ], + ], + ], +]; + +echo json_encode($spec, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT); diff --git a/api/sites.php b/api/sites.php index 7bc0ceb..2d746a9 100644 --- a/api/sites.php +++ b/api/sites.php @@ -7,6 +7,7 @@ require_once __DIR__ . '/bootstrap.php'; if ($_SERVER['REQUEST_METHOD'] !== 'GET') { api_json(['error' => 'method_not_allowed'], 405); } +api_require_scope('read'); $sites = $db->fetchAll("SELECT id, name, site_id FROM sites WHERE is_active = 1 ORDER BY name"); api_json(['sites' => array_map(function ($s) { diff --git a/api/vouchers.php b/api/vouchers.php index e8f29c6..f36b924 100644 --- a/api/vouchers.php +++ b/api/vouchers.php @@ -16,6 +16,7 @@ require_once __DIR__ . '/../includes/Notifier.php'; $method = $_SERVER['REQUEST_METHOD']; if ($method === 'GET') { + api_require_scope('read'); $siteId = (int)($_GET['site_id'] ?? 0); if ($siteId <= 0) { api_json(['error' => 'invalid_request', 'message' => 'site_id erforderlich'], 400); @@ -29,6 +30,7 @@ if ($method === 'GET') { } if ($method === 'POST') { + api_require_scope('write'); $body = api_body(); $siteId = (int)($body['site_id'] ?? 0); $name = trim((string)($body['name'] ?? '')); diff --git a/includes/ApiKey.php b/includes/ApiKey.php index 6115ac7..59b731d 100644 --- a/includes/ApiKey.php +++ b/includes/ApiKey.php @@ -46,6 +46,38 @@ class ApiKey { return $row; } + /** + * Fixed-Window-Rate-Limit pro Schlüssel (Anfragen/Minute). rate_limit = 0 + * bedeutet unbegrenzt. Gibt true zurück, wenn die Anfrage erlaubt ist. + */ + public static function checkRateLimit($row, $db) { + $limit = (int)($row['rate_limit'] ?? 0); + if ($limit <= 0) { + return true; + } + try { + // alte Treffer (>60s) aufräumen + $db->query("DELETE FROM api_key_hits WHERE api_key_id = ? AND hit_at < DATE_SUB(NOW(), INTERVAL 60 SECOND)", [$row['id']]); + $cnt = $db->fetchOne("SELECT COUNT(*) AS c FROM api_key_hits WHERE api_key_id = ?", [$row['id']]); + if ($cnt && (int)$cnt['c'] >= $limit) { + return false; + } + $db->query("INSERT INTO api_key_hits (api_key_id) VALUES (?)", [$row['id']]); + } catch (\Exception $e) { + return true; // Bei Fehlern (z.B. Tabelle fehlt) nicht blockieren + } + return true; + } + + /** Prüft, ob der Schlüssel den geforderten Scope hat ('read' < 'write'). */ + public static function hasScope($row, $needed) { + $scope = $row['scope'] ?? 'write'; + if ($needed === 'read') { + return in_array($scope, ['read', 'write'], true); + } + return $scope === 'write'; + } + /** Liest den Schlüssel aus dem Request (Authorization: Bearer / X-API-Key). */ public static function fromRequest() { $headers = [];
NamePräfixStatusZuletzt genutztErstellt von
NamePräfixScopeLimitStatusZuletzt genutztErstellt von
uvt_