feat: audit log + IP lockout + HTTPS enforcement + request timing
This commit is contained in:
+79
-17
@@ -11,40 +11,58 @@ class Auth {
|
||||
public const SCOPE_ADMIN = 'admin';
|
||||
public const SCOPE_WOOCOMMERCE = 'woocommerce';
|
||||
|
||||
/**
|
||||
* Validate request and check scope.
|
||||
* Returns key row on success, WP_Error on failure.
|
||||
*/
|
||||
public static function validate(\WP_REST_Request $request, string $requiredScope = self::SCOPE_READ): \WP_Error|array {
|
||||
$token = self::extractToken($request);
|
||||
private const LOCKOUT_MAX = 10; // failed attempts before lockout
|
||||
private const LOCKOUT_WINDOW = 300; // 5 minutes
|
||||
|
||||
if (empty($token)) {
|
||||
return new \WP_Error('missing_auth', 'Authentication required. Provide Authorization: Bearer <token>', ['status' => 401]);
|
||||
public static function validate(\WP_REST_Request $request, string $requiredScope = self::SCOPE_READ): \WP_Error|array {
|
||||
// HTTPS enforcement
|
||||
$settings = get_option('asktam_mcp_settings', []);
|
||||
if (!empty($settings['require_https']) && !is_ssl()) {
|
||||
return new \WP_Error('https_required', 'HTTPS is required.', ['status' => 403]);
|
||||
}
|
||||
|
||||
$token = self::extractToken($request);
|
||||
$ip = self::getClientIp();
|
||||
|
||||
if (empty($token)) {
|
||||
return new \WP_Error('missing_auth', 'Authentication required.', ['status' => 401]);
|
||||
}
|
||||
|
||||
// Lockout check per IP
|
||||
if (self::isLockedOut($ip)) {
|
||||
return new \WP_Error('locked_out', 'Too many failed attempts. Try again in 5 minutes.', ['status' => 429]);
|
||||
}
|
||||
|
||||
// Resolve via TokenManager (JWT first, raw key fallback)
|
||||
$row = TokenManager::resolve($token);
|
||||
|
||||
if (!$row) {
|
||||
self::recordFailure($ip);
|
||||
return new \WP_Error('invalid_token', 'Invalid or expired token.', ['status' => 401]);
|
||||
}
|
||||
|
||||
// Rate limit — transient per key ID per minute
|
||||
// Clear failure count on success
|
||||
delete_transient('asktam_mcp_fail_' . md5($ip));
|
||||
|
||||
// Rate limit per key per minute
|
||||
$rlKey = 'asktam_mcp_rl_' . $row['id'];
|
||||
$limit = (int) (get_option('asktam_mcp_settings', [])['rate_limit'] ?? 60);
|
||||
$limit = (int) ($settings['rate_limit'] ?? 60);
|
||||
$count = (int) get_transient($rlKey);
|
||||
if ($count >= $limit) {
|
||||
return new \WP_Error('rate_limit', 'Rate limit exceeded. Retry in 60 seconds.', ['status' => 429]);
|
||||
}
|
||||
set_transient($rlKey, $count + 1, 60);
|
||||
|
||||
// Scope check
|
||||
// Scope check — admin supersedes everything
|
||||
$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', "Required scope: {$requiredScope}", ['status' => 403]);
|
||||
}
|
||||
|
||||
// Record last_used async
|
||||
// Attach request timing start for audit log
|
||||
$row['_request_start'] = microtime(true);
|
||||
$row['_ip'] = $ip;
|
||||
|
||||
// Update last_used async
|
||||
add_action('shutdown', function () use ($row): void {
|
||||
global $wpdb;
|
||||
$wpdb->update(
|
||||
@@ -57,17 +75,61 @@ class Auth {
|
||||
return $row;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Audit log
|
||||
// -------------------------------------------------------------------------
|
||||
public static function logRequest(array $authRow, string $tool, bool $success): void {
|
||||
$settings = get_option('asktam_mcp_settings', []);
|
||||
if (empty($settings['log_requests'])) return;
|
||||
|
||||
$durationMs = isset($authRow['_request_start'])
|
||||
? (int) round((microtime(true) - $authRow['_request_start']) * 1000)
|
||||
: 0;
|
||||
|
||||
global $wpdb;
|
||||
$wpdb->insert($wpdb->prefix . 'asktam_mcp_log', [
|
||||
'key_id' => (int) $authRow['id'],
|
||||
'tool' => substr($tool, 0, 100),
|
||||
'ip' => $authRow['_ip'] ?? '',
|
||||
'status' => $success ? 1 : 0,
|
||||
'duration_ms' => min($durationMs, 65535),
|
||||
'created_at' => current_time('mysql'),
|
||||
]);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Lockout helpers
|
||||
// -------------------------------------------------------------------------
|
||||
private static function isLockedOut(string $ip): bool {
|
||||
return (int) get_transient('asktam_mcp_fail_' . md5($ip)) >= self::LOCKOUT_MAX;
|
||||
}
|
||||
|
||||
private static function recordFailure(string $ip): void {
|
||||
$key = 'asktam_mcp_fail_' . md5($ip);
|
||||
$current = (int) get_transient($key);
|
||||
set_transient($key, $current + 1, self::LOCKOUT_WINDOW);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
private static function extractToken(\WP_REST_Request $request): string {
|
||||
$auth = $request->get_header('Authorization');
|
||||
if ($auth && str_starts_with($auth, 'Bearer ')) {
|
||||
return trim(substr($auth, 7));
|
||||
}
|
||||
// X-MCP-API-Key header for OpenAI / direct use
|
||||
if ($auth && str_starts_with($auth, 'Bearer ')) return trim(substr($auth, 7));
|
||||
$xKey = $request->get_header('X-MCP-API-Key');
|
||||
if ($xKey) return trim($xKey);
|
||||
return '';
|
||||
}
|
||||
|
||||
private static function getClientIp(): string {
|
||||
foreach (['HTTP_CF_CONNECTING_IP', 'HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'REMOTE_ADDR'] as $h) {
|
||||
if (!empty($_SERVER[$h])) {
|
||||
return trim(explode(',', $_SERVER[$h])[0]);
|
||||
}
|
||||
}
|
||||
return '0.0.0.0';
|
||||
}
|
||||
|
||||
public static function generateKey(): string {
|
||||
return 'mcp_' . bin2hex(random_bytes(24));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user