|
|
<?php
|
|
|
|
|
|
namespace App\Http\Middleware;
|
|
|
|
|
|
use App\Models\AuditLog;
|
|
|
use Closure;
|
|
|
use Illuminate\Http\Request;
|
|
|
use Illuminate\Support\Facades\Log;
|
|
|
use Symfony\Component\HttpFoundation\File\UploadedFile as SymfonyUploadedFile;
|
|
|
use Symfony\Component\HttpFoundation\Response;
|
|
|
|
|
|
class AuditLogMiddleware
|
|
|
{
|
|
|
public function handle(Request $request, Closure $next): Response
|
|
|
{
|
|
|
/** @var Response $response */
|
|
|
$response = $next($request);
|
|
|
|
|
|
if (!$this->shouldLog($request)) {
|
|
|
return $response;
|
|
|
}
|
|
|
|
|
|
$user = $request->user();
|
|
|
try {
|
|
|
AuditLog::create([
|
|
|
'user_id' => $user?->id,
|
|
|
'username' => $user?->username ?: $user?->name,
|
|
|
'role' => $user?->role,
|
|
|
'method' => strtoupper($request->method()),
|
|
|
'path' => '/'.ltrim($request->path(), '/'),
|
|
|
'action' => strtoupper($request->method()).' '.$request->path(),
|
|
|
'status_code' => (int) $response->getStatusCode(),
|
|
|
'ip' => $request->ip(),
|
|
|
'user_agent' => substr((string) $request->userAgent(), 0, 500),
|
|
|
'request_payload' => $this->sanitizePayload($request->all()),
|
|
|
]);
|
|
|
} catch (\Throwable $e) {
|
|
|
Log::warning('audit_log_write_failed', [
|
|
|
'path' => $request->path(),
|
|
|
'message' => $e->getMessage(),
|
|
|
]);
|
|
|
}
|
|
|
|
|
|
return $response;
|
|
|
}
|
|
|
|
|
|
private function shouldLog(Request $request): bool
|
|
|
{
|
|
|
if ($request->isMethod('GET')) {
|
|
|
return false;
|
|
|
}
|
|
|
|
|
|
if ($request->is('api/audit-logs*')) {
|
|
|
return false;
|
|
|
}
|
|
|
|
|
|
// 任意 multipart 文件请求不参与审计(避免路径前缀不一致时仍序列化 UploadedFile)
|
|
|
if ($request->files->count() > 0) {
|
|
|
return false;
|
|
|
}
|
|
|
|
|
|
// 仍按路由名兜底(无文件字段但走上传 URL 的极端情况)
|
|
|
if ($request->is('api/upload') || $request->is('api/h5/upload') || $request->is('*/api/upload') || $request->is('*/api/h5/upload')) {
|
|
|
return false;
|
|
|
}
|
|
|
|
|
|
return true;
|
|
|
}
|
|
|
|
|
|
private function sanitizePayload(array $payload): array
|
|
|
{
|
|
|
$sensitive = ['password', 'password_confirmation', 'token', 'access_token'];
|
|
|
$walk = function ($value) use (&$walk, $sensitive) {
|
|
|
if ($value instanceof SymfonyUploadedFile) {
|
|
|
return [
|
|
|
'_upload' => true,
|
|
|
'client_name' => $value->getClientOriginalName(),
|
|
|
'size' => $value->getSize(),
|
|
|
'mime' => $value->getClientMimeType(),
|
|
|
];
|
|
|
}
|
|
|
if (is_object($value)) {
|
|
|
if ($value instanceof \DateTimeInterface) {
|
|
|
return $value->format('c');
|
|
|
}
|
|
|
if ($value instanceof \JsonSerializable) {
|
|
|
return $walk($value->jsonSerialize());
|
|
|
}
|
|
|
|
|
|
return ['_object' => $value::class];
|
|
|
}
|
|
|
if (!is_array($value)) {
|
|
|
return $value;
|
|
|
}
|
|
|
$result = [];
|
|
|
foreach ($value as $k => $v) {
|
|
|
if (is_string($k) && in_array(strtolower($k), $sensitive, true)) {
|
|
|
$result[$k] = '***';
|
|
|
} else {
|
|
|
$result[$k] = $walk($v);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
return $result;
|
|
|
};
|
|
|
|
|
|
return $walk($payload);
|
|
|
}
|
|
|
}
|
|
|
|