后台鉴权我写了七层判断,还是被一个 `$_GET['uid']` 绕进了管理员中心
上周有个做工具站的朋友找我吐槽,说他后台用了 JWT + Redis 黑名单,自认固若金汤,结果被人用普通用户 Cookie 直接访问了 `/admin/user/edit?id=1`,顺手把超管密码改了。听完我后背发凉——这场景我太熟了,三年前我栽在同一个坑里,只不过当时用的是 Session,对方改的是 `$_GET['uid']`。
那阵子我刚从原生 PHP 切到 ThinkPHP6,照着文档装了 `think-auth`,登录后把用户 ID 写进 Session,后台每个控制器开头判断 `session('admin_id')` 是否存在。听起来没毛病对吧?直到我在某个快捷入口的 URL 里顺手带了 `?uid=1`,而那个页面的开发者之前为了调试方便,写了段 `if (input('uid')) session('admin_id', input('uid'))`。生产环境忘了删。一个普通用户从论坛签名档点进这个链接,Session 直接被覆盖,后台大门敞开。
这事给我烙下三个后遗症,至今写鉴权还会条件反射检查:
第一,身份凭证和权限凭证必须分开存、分开验。 现在我的做法是:登录成功发双 Token,Access Token 只证明"你是谁",里面塞用户 ID 和角色标识;但真正能进后台的,是另一段存在服务端 Redis 的权限快照,Key 里带用户 ID + 登录时间戳 + 客户端指纹。每次请求比对三层:Token 合法性、Redis 里该用户是否有后台路由权限、当前请求 URI 是否在权限快照的白名单里。任何一层对不上,直接 401 并清掉 Token。那朋友的问题就出在他只验了"你是谁",没问"你能干嘛"。
第二,所有写操作必须过 CSRF Token,而且 Token 得和 Session 绑定。 我以前图省事,只在表单页加了 `{:token_field()}`,AJAX 提交却忘了带 `__token__`,结果被自己写的接口校验拦在外面。更离谱的是有次前后端分离,我把 CSRF Token 存在 LocalStorage,结果 XSS 一打一个准。现在我的规矩是:非幂等请求一律用双重 Cookie 验证,SameSite=Lax 打底,敏感操作再弹一次图形验证码。Token 有效期压到 15 分钟,后台闲置超时就踢。
第三,SQL 注入防护不能全指望参数绑定,复杂查询要人工过一遍。 去年接手一个老项目,看到段代码差点心梗:`Db::query("SELECT * FROM admin_log WHERE action LIKE '%".input('keyword')."%'")`。参数绑定用了,但 LIKE 拼接把引号全暴露在外面。我现在的习惯是:所有用户输入进查询前,先过一遍白名单正则,LIKE 场景强制用 `whereLike` 并转义通配符;涉及排序字段的,直接枚举允许列,输入不在白名单就抛异常。ThinkPHP 的 `whereRaw` 我基本禁用,真有复杂 SQL 必须走审核。
还有个细思极恐的:很多站点的"权限不足"提示会暴露路由存在性。我之前测试时故意访问 `/admin/finance/withdraw`,低权限账号返回 403,但访问 `/admin/finance/withdraw_audit` 返回 404——攻击者瞬间知道后者是个真实接口,只是你没权限。现在全站统一返回 404,日志里记真实原因,前端不给任何区分。
写到这想起个事:你们有没有遇到过"鉴权通过了,但数据越权"的情况?比如用户 A 能编辑用户 B 的订单,因为接口只验了"登录了没",没验"这单是不是你的"。我去年为此加了一层数据范围过滤器,每个模型关联查询自动带 `where('user_id', $currentUserId)`,管理员角色才跳过。代价是部分联查性能掉了 15%,但睡得着了。
安全这事,说到底是在"方便自己"和"防住别人"之间找平衡点。我现在的底线是:任何能进后台的入口,都得假设攻击者已经拿到了一个合法用户的 Cookie。在这个前提下重新盘你的鉴权链,漏洞往往藏在你觉得"应该不会有人这么干"的地方。