Unifi-Voucher-Tool/includes/ApiKey.php
Claude c7f9b7d39c
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)
2026-06-05 20:47:26 +00:00

103 lines
3.8 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
/**
* ApiKey Erzeugung & Verifizierung von API-Schlüsseln für die REST-API.
*
* Schlüsselformat: uvt_<prefix(8)><secret(32)>
* Gespeichert wird nur der SHA-256-Hash plus ein indexierbares Präfix der
* Klartext-Schlüssel wird dem Admin genau einmal bei der Erstellung gezeigt.
*/
class ApiKey {
/** Neuen Schlüssel erzeugen. Gibt plain/prefix/hash zurück. */
public static function generate() {
$prefix = bin2hex(random_bytes(4)); // 8 Zeichen
$secret = bin2hex(random_bytes(16)); // 32 Zeichen
$plain = 'uvt_' . $prefix . $secret;
return [
'plain' => $plain,
'prefix' => $prefix,
'hash' => hash('sha256', $plain),
];
}
/**
* Verifiziert einen Klartext-Schlüssel gegen die DB. Aktualisiert
* last_used_at und gibt den Key-Datensatz zurück oder false.
*/
public static function verify($plain, $db) {
if (!is_string($plain) || strpos($plain, 'uvt_') !== 0 || strlen($plain) < 44) {
return false;
}
$prefix = substr($plain, 4, 8);
$hash = hash('sha256', $plain);
try {
$row = $db->fetchOne(
"SELECT * FROM api_keys WHERE key_prefix = ? AND is_active = 1",
[$prefix]
);
} catch (\Exception $e) {
return false;
}
if (!$row || !hash_equals($row['key_hash'], $hash)) {
return false;
}
try {
$db->query("UPDATE api_keys SET last_used_at = NOW() WHERE id = ?", [$row['id']]);
} catch (\Exception $e) { /* ignore */ }
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 = [];
if (function_exists('getallheaders')) {
foreach (getallheaders() as $k => $v) {
$headers[strtolower($k)] = $v;
}
}
if (!empty($headers['authorization']) && preg_match('/Bearer\s+(\S+)/i', $headers['authorization'], $m)) {
return $m[1];
}
if (!empty($headers['x-api-key'])) {
return trim($headers['x-api-key']);
}
if (!empty($_SERVER['HTTP_X_API_KEY'])) {
return trim($_SERVER['HTTP_X_API_KEY']);
}
if (!empty($_SERVER['HTTP_AUTHORIZATION']) && preg_match('/Bearer\s+(\S+)/i', $_SERVER['HTTP_AUTHORIZATION'], $m)) {
return $m[1];
}
return null;
}
}