205 lines
5.9 KiB
PHP
205 lines
5.9 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
namespace app\api\middleware;
|
|
|
|
use support\Log;
|
|
use Throwable;
|
|
use Webman\Http\Request;
|
|
use Webman\Http\Response;
|
|
use Webman\MiddlewareInterface;
|
|
|
|
/**
|
|
* 记录 /api 相关接口的访问参数与响应摘要(敏感头、敏感字段脱敏)
|
|
*/
|
|
class ApiAccessLogMiddleware implements MiddlewareInterface
|
|
{
|
|
private const REQUEST_PARAM_MAX_LEN = 4096;
|
|
|
|
private const RESPONSE_BODY_MAX_LEN = 8192;
|
|
|
|
/** 请求头名称(小写) */
|
|
private const SENSITIVE_HEADER_NAMES = [
|
|
'api-key',
|
|
'auth-token',
|
|
'token',
|
|
'authorization',
|
|
'cookie',
|
|
];
|
|
|
|
/** 参数键名(小写匹配) */
|
|
private const SENSITIVE_PARAM_KEYS = [
|
|
'password',
|
|
'secret',
|
|
'signature',
|
|
'token',
|
|
'api-key',
|
|
'api_key',
|
|
'auth-token',
|
|
'auth_token',
|
|
'old_token',
|
|
];
|
|
|
|
public function process(Request $request, callable $handler): Response
|
|
{
|
|
$startedAt = microtime(true);
|
|
$response = null;
|
|
$thrown = null;
|
|
try {
|
|
$response = $handler($request);
|
|
return $response;
|
|
} catch (Throwable $e) {
|
|
$thrown = $e;
|
|
throw $e;
|
|
} finally {
|
|
$durationMs = round((microtime(true) - $startedAt) * 1000, 3);
|
|
$requestLog = $this->buildRequestLog($request);
|
|
$responseLog = null;
|
|
if ($response instanceof Response) {
|
|
$responseLog = $this->buildResponseLog($response);
|
|
}
|
|
|
|
Log::info('api_access', [
|
|
'request' => $requestLog,
|
|
'response' => $responseLog,
|
|
'duration_ms' => $durationMs,
|
|
'exception' => $thrown ? [
|
|
'class' => $thrown::class,
|
|
'message' => $thrown->getMessage(),
|
|
] : null,
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function buildRequestLog(Request $request): array
|
|
{
|
|
$headers = $request->header();
|
|
if (!is_array($headers)) {
|
|
$headers = [];
|
|
}
|
|
$headersOut = [];
|
|
foreach ($headers as $name => $value) {
|
|
$nameStr = strtolower((string) $name);
|
|
if ($this->isSensitiveHeaderName($nameStr)) {
|
|
$headersOut[$nameStr] = $this->maskToken(is_string($value) ? $value : (string) $value);
|
|
} else {
|
|
$headersOut[$nameStr] = $value;
|
|
}
|
|
}
|
|
|
|
$get = $request->get();
|
|
$post = $request->post();
|
|
$params = [];
|
|
if (is_array($get)) {
|
|
$params = array_merge($params, $get);
|
|
}
|
|
if (is_array($post)) {
|
|
$params = array_merge($params, $post);
|
|
}
|
|
$params = $this->sanitizeForLog($params);
|
|
|
|
$paramsJson = json_encode($params, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE);
|
|
if (!is_string($paramsJson)) {
|
|
$paramsJson = '';
|
|
}
|
|
if (strlen($paramsJson) > self::REQUEST_PARAM_MAX_LEN) {
|
|
$paramsJson = substr($paramsJson, 0, self::REQUEST_PARAM_MAX_LEN) . '...(truncated)';
|
|
}
|
|
|
|
return [
|
|
'method' => $request->method(),
|
|
'path' => $request->path(),
|
|
'uri' => $request->uri(),
|
|
'ip' => method_exists($request, 'getRealIp') ? $request->getRealIp() : ($request->getRemoteIp() ?? ''),
|
|
'content_type' => $request->header('content-type', ''),
|
|
'headers' => $headersOut,
|
|
'params' => $paramsJson,
|
|
'agent_id' => $request->agent_id ?? null,
|
|
'player_id' => $request->player_id ?? null,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function buildResponseLog(Response $response): array
|
|
{
|
|
$raw = method_exists($response, 'rawBody') ? $response->rawBody() : '';
|
|
if (!is_string($raw)) {
|
|
$raw = '';
|
|
}
|
|
$bodyForLog = $raw;
|
|
$decoded = json_decode($raw, true);
|
|
if (is_array($decoded)) {
|
|
$sanitized = $this->sanitizeForLog($decoded);
|
|
$encoded = json_encode($sanitized, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE);
|
|
if (is_string($encoded)) {
|
|
$bodyForLog = $encoded;
|
|
}
|
|
}
|
|
if (strlen($bodyForLog) > self::RESPONSE_BODY_MAX_LEN) {
|
|
$bodyForLog = substr($bodyForLog, 0, self::RESPONSE_BODY_MAX_LEN) . '...(truncated)';
|
|
}
|
|
|
|
return [
|
|
'status' => $response->getStatusCode(),
|
|
'body' => $bodyForLog,
|
|
];
|
|
}
|
|
|
|
private function isSensitiveHeaderName(string $name): bool
|
|
{
|
|
return in_array($name, self::SENSITIVE_HEADER_NAMES, true);
|
|
}
|
|
|
|
private function isSensitiveParamKey(string $keyLower): bool
|
|
{
|
|
foreach (self::SENSITIVE_PARAM_KEYS as $s) {
|
|
if ($keyLower === $s) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private function maskToken(string $value): string
|
|
{
|
|
$t = trim($value);
|
|
if ($t === '') {
|
|
return '';
|
|
}
|
|
if (strlen($t) <= 12) {
|
|
return '***';
|
|
}
|
|
return substr($t, 0, 8) . '***' . substr($t, -4);
|
|
}
|
|
|
|
/**
|
|
* @param mixed $value
|
|
* @return mixed
|
|
*/
|
|
private function sanitizeForLog($value)
|
|
{
|
|
if (!is_array($value)) {
|
|
return $value;
|
|
}
|
|
$out = [];
|
|
foreach ($value as $k => $v) {
|
|
$keyLower = is_string($k) ? strtolower($k) : '';
|
|
if ($keyLower !== '' && $this->isSensitiveParamKey($keyLower)) {
|
|
$out[$k] = '***';
|
|
continue;
|
|
}
|
|
if (is_array($v)) {
|
|
$out[$k] = $this->sanitizeForLog($v);
|
|
continue;
|
|
}
|
|
$out[$k] = $v;
|
|
}
|
|
return $out;
|
|
}
|
|
}
|