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

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