应在全局中间件中于$next($request)后通过$response->getCode()捕获401/403响应,记录URL、IP、脱敏后的Authorization头、请求方法及参数,并存入auth_fail_log表;须区分401(身份缺失)与403(越权),通过$request->attr('auth_reason')补充判定依据,避免依赖Auth::check()失败回调或钩子机制。

中间件里怎么捕获401/403响应并记录原始请求
ThinkPHP 默认不记录未授权访问,Auth 或 Token 验证失败后直接返回 401/403,但请求上下文(如 URL、IP、Header)已经丢失。必须在响应发出前拦截,不能等到 app_end。
推荐做法:写一个全局中间件,在 $next($request) 后读取响应码,只对 401/403 做审计记录:
- 调用
$response->getCode()判断是否为401或403 - 用
$request->url()和$request->ip()获取基础信息 - 关键字段必须包括:
$request->header('authorization')(或X-Api-Token)、$request->method()、$request->param()(注意:此处是已解析参数,非原始体) - 避免记录完整
authorization值,可截取前 10 字符 +***脱敏,如Bearer eyJhbGciOi*** - 写入数据库表
auth_fail_log,字段建议:id、url、method、ip、auth_header_snippet、param_json、created_at
为什么不能只靠 Auth::check() 失败回调来记录
Auth::check() 返回 false 只代表校验逻辑走通但未通过,并不等于本次请求最终返回了 401——比如你后续又手动 return json(['code'=>401]),或者中间件顺序错乱导致权限判断被跳过。真正可信的信号只有响应状态码本身。
常见错误:
立即学习“PHP免费学习笔记(深入)”;
- 在登录验证中间件里
if (!Auth::check()) { Log::write(...); return json(401); }—— 这会漏掉 JWT 过期、签名失效、白名单绕过等非Auth::check()覆盖的失败场景 - 把日志逻辑放在
Auth类的自定义 guard 里 —— guard 是复用实例,多请求并发时$this->lastRequest会被覆盖 - 依赖
Hook::listen('auth_fail')却没注册对应行为类,或钩子名拼写错误(如auth_failedvsauth_fail)
如何区分“未登录”和“越权访问”并分别计数
401 和 403 语义不同,混在一起统计会掩盖真实风险。401 表示身份缺失(token 为空/格式错误),403 表示身份存在但无权限(如普通用户调用管理员接口)。需按响应码拆开存,且补充权限判定依据。
实操要点:
- 不要仅靠响应码分类:有些接口可能统一返回 401,实际是 RBAC 拒绝。应在权限检查点(如
Auth::guard()->can('delete user'))失败时,主动设置请求属性:$request->withAttr('auth_reason', 'rbac_denied') - 中间件中优先读
$request->attr('auth_reason'),其次 fallback 到$response->getCode() - 数据库字段加
reason(ENUM: 'missing_token', 'invalid_token', 'rbac_denied', 'route_not_in_permission')和auth_type('jwt', 'session', 'api_key') - 查询时用
Db::name('auth_fail_log')->where('reason', 'rbac_denied')->count()即可定位越权集中点
Redis 缓存键设计不当会导致漏记或重复计数
有人想用 Redis 统计“某 IP 一小时内 401 次数”,但 key 写成 auth:fail:20260817:192.168.1.100,结果每小时生成新 key,无法滚动窗口统计。更糟的是,如果多个中间件并发调用 Cache::inc() 而没设 TTL,缓存永不过期,数据失真。
正确方式:
- key 必须不含日期,用 TTL 控制生命周期:
auth:fail:ip:192.168.1.100+Cache::inc($key, 1, 3600) - 若需区分 token 类型,加一层命名空间:
auth:fail:jwt:192.168.1.100,避免 session 和 jwt 计数互相干扰 - 别在中间件里反复
Cache::get($key)再Cache::set($key, $val+1)—— 这不是原子操作,高并发下会丢计数;必须用Cache::inc() - Redis 计数只是临时热数据,每日定时任务应把
auth:fail:*扫描结果批量落库,再Cache::clear('auth:fail:*')
最易被忽略的一点:401/403 请求往往伴随大量扫描行为(如遍历 /api/admin/ 下所有路径),这类请求通常不带合法 token,但 IP 可能是代理池里的高频出口。只记单次请求不够,得结合滑动窗口识别 IP 级异常频次——而这必须在记录原始日志之后,用异步任务二次分析。



















