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)
This commit is contained in:
Claude 2026-06-05 20:47:26 +00:00
parent ba121d60e6
commit c7f9b7d39c
No known key found for this signature in database
6 changed files with 146 additions and 4 deletions

View file

@ -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
<h2>Neuen API-Schlüssel erstellen</h2>
<form method="post" style="display:flex;gap:12px;align-items:flex-end;flex-wrap:wrap;">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
<div style="flex:1;min-width:220px;">
<div style="flex:2;min-width:200px;">
<label class="muted" style="display:block;margin-bottom:6px;">Bezeichnung</label>
<input class="input" type="text" name="name" placeholder="z.B. Buchungssystem, Terminal Foyer" required>
</div>
<div style="flex:1;min-width:130px;">
<label class="muted" style="display:block;margin-bottom:6px;">Berechtigung</label>
<select class="input" name="scope">
<option value="write">Lesen + Erstellen</option>
<option value="read">Nur Lesen</option>
</select>
</div>
<div style="flex:1;min-width:120px;">
<label class="muted" style="display:block;margin-bottom:6px;">Limit (Anfr./min)</label>
<input class="input" type="number" name="rate_limit" min="0" value="0" title="0 = unbegrenzt">
</div>
<button class="btn btn-primary" type="submit" name="create_key">Erstellen</button>
</form>
</div>
@ -118,11 +131,13 @@ code { font-family:monospace; background:var(--bg-hover); padding:2px 6px; borde
<p class="muted">Noch keine API-Schlüssel angelegt.</p>
<?php else: ?>
<table>
<tr><th>Name</th><th>Präfix</th><th>Status</th><th>Zuletzt genutzt</th><th>Erstellt von</th><th></th></tr>
<tr><th>Name</th><th>Präfix</th><th>Scope</th><th>Limit</th><th>Status</th><th>Zuletzt genutzt</th><th>Erstellt von</th><th></th></tr>
<?php foreach ($keys as $k): ?>
<tr>
<td><?= htmlspecialchars($k['name']) ?></td>
<td><code>uvt_<?= htmlspecialchars($k['key_prefix']) ?>…</code></td>
<td><?= ($k['scope'] ?? 'write') === 'read' ? 'nur Lesen' : 'Lesen+Erstellen' ?></td>
<td><?= (int)($k['rate_limit'] ?? 0) === 0 ? '∞' : (int)$k['rate_limit'] . '/min' ?></td>
<td><span class="badge <?= $k['is_active'] ? 'b-on' : 'b-off' ?>"><?= $k['is_active'] ? 'aktiv' : 'gesperrt' ?></span></td>
<td class="muted"><?= $k['last_used_at'] ? htmlspecialchars($k['last_used_at']) : '' ?></td>
<td class="muted"><?= htmlspecialchars($k['creator'] ?? '') ?></td>
@ -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_…"</pre>
<p class="muted" style="margin-top:12px;">OpenAPI-Spezifikation (Import in Postman/Swagger): <a href="../api/openapi.php" target="_blank">/api/openapi.php</a></p>
</div>
</div><!-- /main-content -->

View file

@ -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);
}
}

77
api/openapi.php Normal file
View file

@ -0,0 +1,77 @@
<?php
/**
* OpenAPI 3.0 Spezifikation der REST-API (zum Import in Postman/Swagger).
* GET /api/openapi.php
*/
header('Content-Type: application/json; charset=utf-8');
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
$base = $scheme . '://' . $host . rtrim(dirname($_SERVER['SCRIPT_NAME']), '/');
$spec = [
'openapi' => '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);

View file

@ -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) {

View file

@ -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'] ?? ''));

View file

@ -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 = [];