Foundation for extending the Bearer API beyond create-only:
- api_keys gains a scope column (read | read_write); baseline schema updated
and the column applied to the live DB. Existing keys default to
read_write so the hwmon create key keeps working.
- ApiKeyModel: createKey() takes a validated scope; validateKey() always
surfaces scope (defaults read_write); getAllKeys() is paginated
({keys,total,page,perPage}, key_hash stripped).
- ApiKeyAuth: expose getKeyContext() (scope/key_name/created_by/api_key_id)
and requireScope() (403 on insufficient scope); existing return values
unchanged.
- create_ticket_api.php: require read_write scope (a read key can't create).
- Admin /admin/api-keys: scope selector on the create form, a scope column,
and pagination (revoked keys were stacking up).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
288 lines
8.4 KiB
PHP
288 lines
8.4 KiB
PHP
<?php
|
|
|
|
/**
|
|
* ApiKeyModel - Handles API key generation and validation
|
|
*/
|
|
class ApiKeyModel
|
|
{
|
|
private $conn;
|
|
|
|
public function __construct($conn)
|
|
{
|
|
$this->conn = $conn;
|
|
}
|
|
|
|
/**
|
|
* Generate a new API key
|
|
*
|
|
* @param string $keyName Descriptive name for the key
|
|
* @param int $createdBy User ID who created the key
|
|
* @param int|null $expiresInDays Number of days until expiration (null for no expiration)
|
|
* @param string $scope Access scope: 'read' or 'read_write' (default 'read_write')
|
|
* @return array Array with 'success', 'api_key' (plaintext), 'key_prefix', 'scope', 'error'
|
|
*/
|
|
public function createKey($keyName, $createdBy, $expiresInDays = null, $scope = 'read_write')
|
|
{
|
|
// Validate the requested scope — only the two known values are allowed
|
|
if (!in_array($scope, ['read', 'read_write'], true)) {
|
|
return [
|
|
'success' => false,
|
|
'error' => "Invalid scope: must be 'read' or 'read_write'"
|
|
];
|
|
}
|
|
|
|
// Generate random API key (32 bytes = 64 hex characters)
|
|
$apiKey = bin2hex(random_bytes(32));
|
|
|
|
// Create key prefix (first 8 characters) for identification
|
|
$keyPrefix = substr($apiKey, 0, 8);
|
|
|
|
// Hash the API key for storage
|
|
$keyHash = hash('sha256', $apiKey);
|
|
|
|
// Calculate expiration date if specified
|
|
$expiresAt = null;
|
|
if ($expiresInDays !== null) {
|
|
$expiresAt = date('Y-m-d H:i:s', strtotime("+$expiresInDays days"));
|
|
}
|
|
|
|
// Insert API key into database
|
|
$stmt = $this->conn->prepare(
|
|
"INSERT INTO api_keys (key_name, key_hash, key_prefix, scope, created_by, expires_at) "
|
|
. "VALUES (?, ?, ?, ?, ?, ?)"
|
|
);
|
|
$stmt->bind_param("ssssis", $keyName, $keyHash, $keyPrefix, $scope, $createdBy, $expiresAt);
|
|
|
|
if ($stmt->execute()) {
|
|
$keyId = $this->conn->insert_id;
|
|
$stmt->close();
|
|
|
|
return [
|
|
'success' => true,
|
|
'api_key' => $apiKey, // Return plaintext key ONCE
|
|
'key_prefix' => $keyPrefix,
|
|
'key_id' => $keyId,
|
|
'scope' => $scope,
|
|
'expires_at' => $expiresAt
|
|
];
|
|
} else {
|
|
$error = $this->conn->error;
|
|
$stmt->close();
|
|
|
|
return [
|
|
'success' => false,
|
|
'error' => $error
|
|
];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate an API key
|
|
*
|
|
* @param string $apiKey Plaintext API key to validate
|
|
* @return array|null API key record if valid, null if invalid
|
|
*/
|
|
public function validateKey($apiKey)
|
|
{
|
|
if (empty($apiKey)) {
|
|
return null;
|
|
}
|
|
|
|
// Hash the provided key
|
|
$keyHash = hash('sha256', $apiKey);
|
|
|
|
// Query for matching key
|
|
$stmt = $this->conn->prepare(
|
|
"SELECT * FROM api_keys WHERE key_hash = ? AND is_active = 1"
|
|
);
|
|
$stmt->bind_param("s", $keyHash);
|
|
$stmt->execute();
|
|
$result = $stmt->get_result();
|
|
|
|
if ($result->num_rows === 0) {
|
|
$stmt->close();
|
|
return null;
|
|
}
|
|
|
|
$keyData = $result->fetch_assoc();
|
|
$stmt->close();
|
|
|
|
// Ensure a scope is always present. On an un-migrated database the column
|
|
// does not exist yet (or is null), in which case we treat the key as
|
|
// full-access so existing integrations keep working.
|
|
if (!isset($keyData['scope']) || $keyData['scope'] === null || $keyData['scope'] === '') {
|
|
$keyData['scope'] = 'read_write';
|
|
}
|
|
|
|
// Check expiration
|
|
if ($keyData['expires_at'] !== null) {
|
|
$expiresAt = strtotime($keyData['expires_at']);
|
|
if ($expiresAt < time()) {
|
|
return null; // Key has expired
|
|
}
|
|
}
|
|
|
|
// Update last_used timestamp
|
|
$this->updateLastUsed($keyData['api_key_id']);
|
|
|
|
return $keyData;
|
|
}
|
|
|
|
/**
|
|
* Update last_used timestamp for an API key
|
|
*
|
|
* @param int $keyId API key ID
|
|
* @return bool Success status
|
|
*/
|
|
private function updateLastUsed($keyId)
|
|
{
|
|
$stmt = $this->conn->prepare("UPDATE api_keys SET last_used = NOW() WHERE api_key_id = ?");
|
|
$stmt->bind_param("i", $keyId);
|
|
$success = $stmt->execute();
|
|
$stmt->close();
|
|
return $success;
|
|
}
|
|
|
|
/**
|
|
* Revoke an API key (set is_active to false)
|
|
*
|
|
* @param int $keyId API key ID
|
|
* @return bool Success status
|
|
*/
|
|
public function revokeKey($keyId)
|
|
{
|
|
$stmt = $this->conn->prepare("UPDATE api_keys SET is_active = 0 WHERE api_key_id = ?");
|
|
$stmt->bind_param("i", $keyId);
|
|
$success = $stmt->execute();
|
|
$stmt->close();
|
|
return $success;
|
|
}
|
|
|
|
/**
|
|
* Delete an API key permanently
|
|
*
|
|
* @param int $keyId API key ID
|
|
* @return bool Success status
|
|
*/
|
|
public function deleteKey($keyId)
|
|
{
|
|
$stmt = $this->conn->prepare("DELETE FROM api_keys WHERE api_key_id = ?");
|
|
$stmt->bind_param("i", $keyId);
|
|
$success = $stmt->execute();
|
|
$stmt->close();
|
|
return $success;
|
|
}
|
|
|
|
/**
|
|
* Get a page of API keys (for admin panel)
|
|
*
|
|
* Active keys are listed first, then newest first within each group.
|
|
*
|
|
* @param int $page 1-based page number
|
|
* @param int $perPage Rows per page
|
|
* @return array ['keys' => array, 'total' => int, 'page' => int, 'perPage' => int]
|
|
*/
|
|
public function getAllKeys($page = 1, $perPage = 20)
|
|
{
|
|
// Normalise pagination inputs
|
|
$page = max(1, (int)$page);
|
|
$perPage = (int)$perPage;
|
|
if ($perPage < 1) {
|
|
$perPage = 20;
|
|
}
|
|
$offset = ($page - 1) * $perPage;
|
|
|
|
// Total count for pagination controls
|
|
$total = 0;
|
|
$countResult = $this->conn->query("SELECT COUNT(*) AS total FROM api_keys");
|
|
if ($countResult) {
|
|
$countRow = $countResult->fetch_assoc();
|
|
$total = (int)($countRow['total'] ?? 0);
|
|
$countResult->free();
|
|
}
|
|
|
|
$stmt = $this->conn->prepare(
|
|
"SELECT ak.*, u.username, u.display_name
|
|
FROM api_keys ak
|
|
LEFT JOIN users u ON ak.created_by = u.user_id
|
|
ORDER BY ak.is_active DESC, ak.created_at DESC
|
|
LIMIT ? OFFSET ?"
|
|
);
|
|
$stmt->bind_param("ii", $perPage, $offset);
|
|
$stmt->execute();
|
|
$result = $stmt->get_result();
|
|
|
|
$keys = [];
|
|
while ($row = $result->fetch_assoc()) {
|
|
// Remove key_hash from response for security
|
|
unset($row['key_hash']);
|
|
$keys[] = $row;
|
|
}
|
|
|
|
$stmt->close();
|
|
|
|
return [
|
|
'keys' => $keys,
|
|
'total' => $total,
|
|
'page' => $page,
|
|
'perPage' => $perPage
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Get API key by ID
|
|
*
|
|
* @param int $keyId API key ID
|
|
* @return array|null API key record (without hash) or null if not found
|
|
*/
|
|
public function getKeyById($keyId)
|
|
{
|
|
$stmt = $this->conn->prepare(
|
|
"SELECT ak.*, u.username, u.display_name
|
|
FROM api_keys ak
|
|
LEFT JOIN users u ON ak.created_by = u.user_id
|
|
WHERE ak.api_key_id = ?"
|
|
);
|
|
$stmt->bind_param("i", $keyId);
|
|
$stmt->execute();
|
|
$result = $stmt->get_result();
|
|
|
|
if ($result->num_rows > 0) {
|
|
$key = $result->fetch_assoc();
|
|
// Remove key_hash from response for security
|
|
unset($key['key_hash']);
|
|
$stmt->close();
|
|
return $key;
|
|
}
|
|
|
|
$stmt->close();
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Get keys created by a specific user
|
|
*
|
|
* @param int $userId User ID
|
|
* @return array Array of API key records
|
|
*/
|
|
public function getKeysByUser($userId)
|
|
{
|
|
$stmt = $this->conn->prepare(
|
|
"SELECT * FROM api_keys WHERE created_by = ? ORDER BY created_at DESC"
|
|
);
|
|
$stmt->bind_param("i", $userId);
|
|
$stmt->execute();
|
|
$result = $stmt->get_result();
|
|
|
|
$keys = [];
|
|
while ($row = $result->fetch_assoc()) {
|
|
// Remove key_hash from response for security
|
|
unset($row['key_hash']);
|
|
$keys[] = $row;
|
|
}
|
|
|
|
$stmt->close();
|
|
return $keys;
|
|
}
|
|
}
|