feat: API key auth + rate limiting + scope check
This commit is contained in:
@@ -0,0 +1,83 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace AskTamMCP\Core;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticates incoming requests by API key.
|
||||||
|
* Validates key existence, active status, and scope.
|
||||||
|
*/
|
||||||
|
class Auth {
|
||||||
|
|
||||||
|
public const SCOPE_READ = 'read';
|
||||||
|
public const SCOPE_WRITE = 'write';
|
||||||
|
public const SCOPE_ADMIN = 'admin';
|
||||||
|
public const SCOPE_WOOCOMMERCE = 'woocommerce';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate the API key from request headers and check required scope.
|
||||||
|
*
|
||||||
|
* @param \WP_REST_Request $request
|
||||||
|
* @param string $requiredScope
|
||||||
|
* @return \WP_Error|array WP_Error on failure, key row array on success
|
||||||
|
*/
|
||||||
|
public static function validate(\WP_REST_Request $request, string $requiredScope = self::SCOPE_READ): \WP_Error|array {
|
||||||
|
$apiKey = $request->get_header('X-MCP-API-Key');
|
||||||
|
|
||||||
|
if (empty($apiKey)) {
|
||||||
|
return new \WP_Error('missing_api_key', 'X-MCP-API-Key header is required.', ['status' => 401]);
|
||||||
|
}
|
||||||
|
|
||||||
|
global $wpdb;
|
||||||
|
$table = $wpdb->prefix . 'asktam_mcp_keys';
|
||||||
|
$row = $wpdb->get_row(
|
||||||
|
$wpdb->prepare("SELECT * FROM {$table} WHERE api_key = %s AND is_active = 1", $apiKey),
|
||||||
|
ARRAY_A
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!$row) {
|
||||||
|
return new \WP_Error('invalid_api_key', 'Invalid or inactive API key.', ['status' => 401]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate limiting — transient per key, per minute
|
||||||
|
$rateLimitKey = 'asktam_mcp_rl_' . md5($apiKey);
|
||||||
|
$settings = get_option('asktam_mcp_settings', []);
|
||||||
|
$limit = (int) ($settings['rate_limit'] ?? 60);
|
||||||
|
$currentCount = (int) get_transient($rateLimitKey);
|
||||||
|
|
||||||
|
if ($currentCount >= $limit) {
|
||||||
|
return new \WP_Error('rate_limit_exceeded', 'Rate limit exceeded. Retry in 60 seconds.', ['status' => 429]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($currentCount === 0) {
|
||||||
|
set_transient($rateLimitKey, 1, 60);
|
||||||
|
} else {
|
||||||
|
set_transient($rateLimitKey, $currentCount + 1, 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scope check — 'admin' keys get all scopes
|
||||||
|
$scopes = array_map('trim', explode(',', $row['scopes']));
|
||||||
|
if (!in_array($requiredScope, $scopes, true) && !in_array(self::SCOPE_ADMIN, $scopes, true)) {
|
||||||
|
return new \WP_Error(
|
||||||
|
'insufficient_scope',
|
||||||
|
"This key does not have the required scope: {$requiredScope}",
|
||||||
|
['status' => 403]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last_used timestamp (async-ish via shutdown)
|
||||||
|
add_action('shutdown', function () use ($wpdb, $table, $apiKey): void {
|
||||||
|
$wpdb->update($table, ['last_used' => current_time('mysql')], ['api_key' => $apiKey]);
|
||||||
|
});
|
||||||
|
|
||||||
|
return $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a cryptographically secure API key.
|
||||||
|
*/
|
||||||
|
public static function generateKey(): string {
|
||||||
|
return 'mcp_' . bin2hex(random_bytes(24));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user