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>
231 lines
6.3 KiB
PHP
231 lines
6.3 KiB
PHP
<?php
|
|
|
|
/**
|
|
* ApiKeyAuth - Handles API key authentication for external services
|
|
*/
|
|
|
|
require_once dirname(__DIR__) . '/models/ApiKeyModel.php';
|
|
require_once dirname(__DIR__) . '/models/UserModel.php';
|
|
|
|
class ApiKeyAuth
|
|
{
|
|
private $apiKeyModel;
|
|
private $userModel;
|
|
private $conn;
|
|
|
|
/**
|
|
* Context of the API key validated by the most recent authenticate()/
|
|
* verifyOptional() call, or null if none succeeded.
|
|
*
|
|
* @var array|null
|
|
*/
|
|
private $keyContext = null;
|
|
|
|
public function __construct($conn)
|
|
{
|
|
$this->conn = $conn;
|
|
$this->apiKeyModel = new ApiKeyModel($conn);
|
|
$this->userModel = new UserModel($conn);
|
|
}
|
|
|
|
/**
|
|
* Store the validated key's context for later scope/attribution checks.
|
|
*
|
|
* @param array $keyData Row returned by ApiKeyModel::validateKey()
|
|
*/
|
|
private function setKeyContext(array $keyData)
|
|
{
|
|
$this->keyContext = [
|
|
'scope' => $keyData['scope'] ?? 'read_write',
|
|
'key_name' => $keyData['key_name'] ?? null,
|
|
'created_by' => $keyData['created_by'] ?? null,
|
|
'api_key_id' => $keyData['api_key_id'] ?? null,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Get the context of the authenticated API key.
|
|
*
|
|
* @return array|null ['scope', 'key_name', 'created_by', 'api_key_id'] or null
|
|
*/
|
|
public function getKeyContext(): ?array
|
|
{
|
|
return $this->keyContext;
|
|
}
|
|
|
|
/**
|
|
* Enforce that the authenticated key satisfies the required scope.
|
|
*
|
|
* A 'read' key satisfies only 'read'; a 'read_write' key satisfies both
|
|
* 'read' and 'read_write'. On failure a 403 JSON error is sent and the
|
|
* script exits.
|
|
*
|
|
* @param string $needed Required scope ('read' or 'read_write')
|
|
*/
|
|
public function requireScope(string $needed): void
|
|
{
|
|
$current = $this->keyContext['scope'] ?? null;
|
|
|
|
// 'read_write' can do anything; 'read' can only satisfy a 'read' need.
|
|
$ok = ($current === 'read_write')
|
|
|| ($current === 'read' && $needed === 'read');
|
|
|
|
if (!$ok) {
|
|
$this->sendForbidden(
|
|
'API key scope "' . ($current ?? 'none') . '" is insufficient; "'
|
|
. $needed . '" is required'
|
|
);
|
|
exit;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Authenticate using API key from Authorization header
|
|
*
|
|
* @return array User data for system user
|
|
* @throws Exception if authentication fails
|
|
*/
|
|
public function authenticate()
|
|
{
|
|
// Get Authorization header
|
|
$authHeader = $this->getAuthorizationHeader();
|
|
|
|
if (empty($authHeader)) {
|
|
$this->sendUnauthorized('Missing Authorization header');
|
|
exit;
|
|
}
|
|
|
|
// Check if it's a Bearer token
|
|
if (!preg_match('/^Bearer\s+(.+)$/i', $authHeader, $matches)) {
|
|
$this->sendUnauthorized('Invalid Authorization header format. Expected: Bearer <api_key>');
|
|
exit;
|
|
}
|
|
|
|
$apiKey = $matches[1];
|
|
|
|
// Validate API key
|
|
$keyData = $this->apiKeyModel->validateKey($apiKey);
|
|
|
|
if (!$keyData) {
|
|
$this->sendUnauthorized('Invalid or expired API key');
|
|
exit;
|
|
}
|
|
|
|
// Record key context (scope / attribution) for callers to inspect.
|
|
$this->setKeyContext($keyData);
|
|
|
|
// Get system user (or the user who created the key)
|
|
$user = $this->userModel->getSystemUser();
|
|
|
|
if (!$user) {
|
|
$this->sendUnauthorized('System user not found');
|
|
exit;
|
|
}
|
|
|
|
// Add API key info to user data for logging
|
|
$user['api_key_id'] = $keyData['api_key_id'];
|
|
$user['api_key_name'] = $keyData['key_name'];
|
|
|
|
return $user;
|
|
}
|
|
|
|
/**
|
|
* Get Authorization header from various sources
|
|
*
|
|
* @return string|null Authorization header value
|
|
*/
|
|
private function getAuthorizationHeader()
|
|
{
|
|
// Try different header formats
|
|
if (isset($_SERVER['HTTP_AUTHORIZATION'])) {
|
|
return $_SERVER['HTTP_AUTHORIZATION'];
|
|
}
|
|
|
|
if (isset($_SERVER['REDIRECT_HTTP_AUTHORIZATION'])) {
|
|
return $_SERVER['REDIRECT_HTTP_AUTHORIZATION'];
|
|
}
|
|
|
|
// Check for Authorization in getallheaders if available
|
|
if (function_exists('getallheaders')) {
|
|
$headers = getallheaders();
|
|
if (isset($headers['Authorization'])) {
|
|
return $headers['Authorization'];
|
|
}
|
|
if (isset($headers['authorization'])) {
|
|
return $headers['authorization'];
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Send 401 Unauthorized response
|
|
*
|
|
* @param string $message Error message
|
|
*/
|
|
private function sendUnauthorized($message)
|
|
{
|
|
header('HTTP/1.1 401 Unauthorized');
|
|
header('Content-Type: application/json');
|
|
echo json_encode([
|
|
'success' => false,
|
|
'error' => 'Unauthorized',
|
|
'message' => $message
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Send 403 Forbidden response (e.g. insufficient scope)
|
|
*
|
|
* @param string $message Error message
|
|
*/
|
|
private function sendForbidden($message)
|
|
{
|
|
header('HTTP/1.1 403 Forbidden');
|
|
header('Content-Type: application/json');
|
|
echo json_encode([
|
|
'success' => false,
|
|
'error' => 'Forbidden',
|
|
'message' => $message
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Verify API key without throwing errors (for optional auth)
|
|
*
|
|
* @return array|null User data or null if not authenticated
|
|
*/
|
|
public function verifyOptional()
|
|
{
|
|
$authHeader = $this->getAuthorizationHeader();
|
|
|
|
if (empty($authHeader)) {
|
|
return null;
|
|
}
|
|
|
|
if (!preg_match('/^Bearer\s+(.+)$/i', $authHeader, $matches)) {
|
|
return null;
|
|
}
|
|
|
|
$apiKey = $matches[1];
|
|
$keyData = $this->apiKeyModel->validateKey($apiKey);
|
|
|
|
if (!$keyData) {
|
|
return null;
|
|
}
|
|
|
|
// Record key context (scope / attribution) for callers to inspect.
|
|
$this->setKeyContext($keyData);
|
|
|
|
$user = $this->userModel->getSystemUser();
|
|
|
|
if ($user) {
|
|
$user['api_key_id'] = $keyData['api_key_id'];
|
|
$user['api_key_name'] = $keyData['key_name'];
|
|
}
|
|
|
|
return $user;
|
|
}
|
|
}
|