wpmind/modules/api-gateway/includes/Auth/ApiKeyRepository.php
LinuxJoy 36b69d300d security: API Gateway 全量代码审计修复 (59 issues)
Codex 审计发现 124 个问题,修复 59 个 P0-P2 级别问题:

P0 (CRITICAL):
- TOCTOU 竞态: TransientRateStore/SseConcurrencyGuard 改用 wp_cache_add 原子锁
- REST 路由: __return_true → check_bearer_present 轻量鉴权
- Schema 验证: messages/tools/tool_choice 添加完整 validate_callback
- SQL 列名不匹配: upsert 查询对齐 SchemaManager 定义
- SQL 表名插值: 统一使用 %i 占位符

P1 (HIGH):
- IP hash 加盐 (HMAC) + IP 解析统一 (context 共享)
- Header 注入防护 (X-Request-Id/响应头 \r\n 过滤)
- 数值参数范围校验 (temperature/top_p/penalties/max_tokens)
- insert_key 错误处理 + revoke_key 审计日志
- Pipeline finally 保护 + RateLimiter rollback 双 store
- wp_unslash 补全 + strtotime 返回值检查
- RedisRateStore $rid 冒号验证
- UpstreamStreamClient catch \Throwable

P2 (MEDIUM):
- AuditLogRepository DRY (提取 build_where_clause)
- Token 估算 CJK 优化 (mb_strlen)
- ResponseTransformMiddleware null 检查
- 嵌入响应 usage 数据修复
- SchemaManager autoload 优化

25 files changed, ~530 insertions, ~190 deletions

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-09 01:14:12 +08:00

367 lines
7.7 KiB
PHP

<?php
/**
* API Key Repository
*
* Database access layer for API keys.
*
* @package WPMind\Modules\ApiGateway\Auth
* @since 1.0.0
*/
declare(strict_types=1);
namespace WPMind\Modules\ApiGateway\Auth;
/**
* Class ApiKeyRepository
*
* CRUD operations for the wpmind_api_keys table.
*/
class ApiKeyRepository {
/**
* Cache group for API key metadata.
*
* @var string
*/
private const CACHE_GROUP = 'wpmind_api_keys';
/**
* Cache TTL in seconds.
*
* @var int
*/
private const CACHE_TTL = 60;
/**
* Get the full table name.
*
* @return string
*/
private static function table(): string {
global $wpdb;
return $wpdb->prefix . 'wpmind_api_keys';
}
/**
* Invalidate the cache for a given key_id.
*
* @param string $key_id The 12-character key identifier.
*/
private static function invalidate_cache( string $key_id ): void {
wp_cache_delete( $key_id, self::CACHE_GROUP );
}
/**
* Insert a new API key row.
*
* @param array $data Column => value pairs.
* @return int Inserted row ID.
*/
public static function insert_key( array $data ): int {
global $wpdb;
$format = [];
foreach ( $data as $col => $val ) {
if ( is_int( $val ) ) {
$format[] = '%d';
} elseif ( is_float( $val ) ) {
$format[] = '%f';
} else {
$format[] = '%s';
}
}
$result = $wpdb->insert( self::table(), $data, $format );
if ( false === $result ) {
return 0;
}
return (int) $wpdb->insert_id;
}
/**
* Find a key row by its unique key_id.
*
* @param string $key_id The 12-character key identifier.
* @return array|null Row as associative array, or null if not found.
*/
public static function find_by_key_id( string $key_id ): ?array {
// Check cache first.
$cached = wp_cache_get( $key_id, self::CACHE_GROUP );
if ( is_array( $cached ) ) {
return $cached;
}
global $wpdb;
$row = $wpdb->get_row(
$wpdb->prepare(
"SELECT * FROM %i WHERE key_id = %s LIMIT 1",
self::table(),
$key_id
),
ARRAY_A
);
if ( is_array( $row ) ) {
wp_cache_set( $key_id, $row, self::CACHE_GROUP, self::CACHE_TTL );
return $row;
}
return null;
}
/**
* Update the last_used_at timestamp for a key.
*
* @param string $key_id The 12-character key identifier.
*/
public static function update_last_used( string $key_id ): void {
global $wpdb;
$wpdb->update(
self::table(),
[
'last_used_at' => current_time( 'mysql', true ),
'updated_at' => current_time( 'mysql', true ),
],
[ 'key_id' => $key_id ],
[ '%s', '%s' ],
[ '%s' ]
);
self::invalidate_cache( $key_id );
}
/**
* Revoke an API key.
*
* @param string $key_id The 12-character key identifier.
* @param int $actor_user_id The user performing the revocation.
* @param string $reason Reason for revocation.
*/
public static function revoke_key( string $key_id, int $actor_user_id, string $reason ): void {
global $wpdb;
$now = current_time( 'mysql', true );
$wpdb->update(
self::table(),
[
'status' => 'revoked',
'revoked_at' => $now,
'updated_at' => $now,
],
[ 'key_id' => $key_id ],
[ '%s', '%s', '%s' ],
[ '%s' ]
);
$audit_table = $wpdb->prefix . 'wpmind_api_audit_log';
$wpdb->insert(
$audit_table,
[
'event_type' => 'key_revoked',
'key_id' => $key_id,
'actor_user_id' => $actor_user_id,
'detail_json' => wp_json_encode( [ 'reason' => $reason ] ),
'created_at' => $now,
],
[ '%s', '%s', '%d', '%s', '%s' ]
);
self::invalidate_cache( $key_id );
}
/**
* List API keys with pagination.
*
* @param int $page Page number (1-based).
* @param int $per_page Items per page.
* @return array Array of row arrays.
*/
public static function list_keys( int $page = 1, int $per_page = 20 ): array {
global $wpdb;
$offset = max( 0, ( $page - 1 ) * $per_page );
$results = $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM %i ORDER BY created_at DESC LIMIT %d OFFSET %d",
self::table(),
$per_page,
$offset
),
ARRAY_A
);
return is_array( $results ) ? $results : [];
}
/**
* Count total API keys.
*
* @return int Total number of keys.
*/
public static function count_keys(): int {
global $wpdb;
return (int) $wpdb->get_var(
$wpdb->prepare( "SELECT COUNT(*) FROM %i", self::table() )
);
}
/**
* Count active API keys.
*
* @return int Number of active keys.
*/
public static function count_active_keys(): int {
global $wpdb;
return (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM %i WHERE status = %s",
self::table(),
'active'
)
);
}
/**
* Get total request count for the current month across all keys.
*
* @return int Total requests this month.
*/
public static function get_month_total_requests(): int {
global $wpdb;
$usage_table = $wpdb->prefix . 'wpmind_api_key_usage';
$window_month = gmdate( 'Y-m' );
return (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COALESCE(SUM(request_count), 0) FROM %i WHERE window_month = %s",
$usage_table,
$window_month
)
);
}
/**
* Update editable fields for an API key.
*
* @param string $key_id The 12-character key identifier.
* @param array $data Column => value pairs (whitelisted).
* @return bool True on success, false on failure.
*/
public static function update_key( string $key_id, array $data ): bool {
global $wpdb;
$allowed = [
'name',
'rpm_limit',
'tpm_limit',
'concurrency_limit',
'monthly_budget_usd',
'ip_whitelist',
'expires_at',
];
$update = [];
$format = [];
foreach ( $data as $col => $val ) {
if ( ! in_array( $col, $allowed, true ) ) {
continue;
}
switch ( $col ) {
case 'name':
$update[ $col ] = sanitize_text_field( (string) $val );
$format[] = '%s';
break;
case 'rpm_limit':
case 'tpm_limit':
case 'concurrency_limit':
$update[ $col ] = absint( $val );
$format[] = '%d';
break;
case 'monthly_budget_usd':
$update[ $col ] = max( 0.0, (float) $val );
$format[] = '%f';
break;
case 'ip_whitelist':
$update[ $col ] = is_array( $val )
? wp_json_encode( $val )
: sanitize_text_field( (string) $val );
$format[] = '%s';
break;
case 'expires_at':
$update[ $col ] = empty( $val ) ? null : sanitize_text_field( (string) $val );
$format[] = '%s';
break;
}
}
if ( empty( $update ) ) {
return false;
}
$update['updated_at'] = current_time( 'mysql', true );
$format[] = '%s';
$result = $wpdb->update(
self::table(),
$update,
[ 'key_id' => $key_id ],
$format,
[ '%s' ]
);
self::invalidate_cache( $key_id );
return $result !== false;
}
/**
* List all keys with current month usage, excluding secret columns.
*
* @return array Array of key rows with usage data.
*/
public static function list_all_with_usage(): array {
global $wpdb;
$keys_table = self::table();
$usage_table = $wpdb->prefix . 'wpmind_api_key_usage';
$window_month = gmdate( 'Y-m' );
$results = $wpdb->get_results(
$wpdb->prepare(
"SELECT k.id, k.key_id, k.key_prefix, k.name, k.owner_user_id,
k.rpm_limit, k.tpm_limit, k.concurrency_limit,
k.monthly_budget_usd, k.ip_whitelist, k.status,
k.last_used_at, k.expires_at, k.revoked_at,
k.created_at, k.updated_at,
COALESCE(u.request_count, 0) AS usage_request_count,
COALESCE(u.total_tokens, 0) AS usage_total_tokens,
COALESCE(u.total_cost_usd, 0) AS usage_total_cost_usd
FROM %i AS k
LEFT JOIN %i AS u ON k.key_id = u.key_id AND u.window_month = %s
ORDER BY k.created_at DESC",
$keys_table,
$usage_table,
$window_month
),
ARRAY_A
);
return is_array( $results ) ? $results : [];
}
}