增强 I · JWT 鉴权(堵越权读他人 Store)—— 开发文档(面试向)#
这篇讲为什么这么做、做了哪些取舍,不讲代码。读完能用大白话复述。 对应
docs/BACKEND_ENHANCEMENT.md的 I 块(P1 部分:只做 JWT 鉴权;限流 / 版本 / 幂等是 P2,不在本次)。
一句话概括#
修一个真实的越权读漏洞:原来 user_id 由前端经请求体 / URL 段传入、没有任何校验——随手把
GET /api/preferences/alice 改成 /bob 就能读到别人的长期偏好 Store,把任务里的 user_id 改成别人
就能冒名往他名下写偏好。修法是后端最标准的一招:身份只认 token 里签过名的 sub,绝不信前端传来的
user_id。
打个比方:原来进门只要你自报「我是 alice」就放行;现在要出示一张盖了章、改不了的工牌,名字 以工牌为准。
1. 背景:这不是「完整度打磨」,是已经存在的洞#
I 块原本在计划里整体是 P1/P2 混合的「API 生产化加固」(鉴权 / 限流 / 版本 / 幂等)。我把鉴权单独 拎出来提到 P1 先做,其余维持 P2。判据很简单:
- 限流 / 版本 / 幂等是「防滥用 / 平滑演进」——锦上添花。
- 越权读是「能直接读到别人的数据」——是安全洞,不是打磨。优先级严格更高。
现状的洞具体在哪:user_id 全程是前端可控输入,后端拿来就当身份用(读偏好、写偏好)。这是教科书级的
IDOR(不安全的直接对象引用)——把标识符当授权用。所以本块只干一件事:把身份的来源从「前端
传入」换成「token 解析」。
2. 方案:身份只认 token 的 sub,前端传的 user_id 一律不信#
核心就两个动作:
- 验签取身份。带
Authorization: Bearer <jwt>,后端用密钥验签、解出sub(= user_id)。验签 锁死 HS256 单一算法(防alg=none/ HS-RS 算法混淆攻击——这是 JWT 最经典的绕过),并要求 token 必须带exp(防一枚泄漏的无过期 token 永久有效)。 - 两处接入。①
GET /api/preferences/{user_id}:token 的 sub 与 URL 段不一致即 403——这正是 要堵的越权读。②POST /api/task:跑任务的 user_id 一律取 token 的 sub,忽略请求体里传的—— 杜绝冒名把偏好写进他人名下。
为什么把「合流身份」单列成一个 resolve_identity 函数而不是内联:身份从哪来是安全关键决策点,
显式命名一处、让「开启时只信 token、关闭时退回现状」这条规则一眼可查、可测,比散在各 endpoint 里的
auth_uid or req.user_id 更难写错。
3. graceful 开关:默认关,保 demo 连续性,但开了就真安全#
现有前端不带 token。如果硬性强制 JWT,课程 demo 当场打不开。所以用 env AUTH_ENABLED 控制:
- 关闭(默认):保持现状(user_id 信前端传入),但启动打一条 warning「鉴权未开、存在越权读 风险」——让人知道这是个已知的、临时放行的状态,不是「以为安全其实没做」。
- 开启:强制 token,身份只认 sub,越权读 403。
这与 E/F 块「env 开关 + 优雅降级 + 诚实标注」是同一套风格:能力做进去了,但默认不破坏 demo,要安全 一开即得。
4. 几个把细节做对的地方(含 review 抓到的)#
- 发证口单独一个开关,别让「开鉴权」反手捅开洞(review 抓到的最严重一条)。配套有个
POST /api/auth/token的开发态发证口(只认 user_id、不验密码,纯为让鉴权链路能端到端测)。 它本质是个「冒名工厂」——谁都能给任意 user_id 领一张工牌。如果它和AUTH_ENABLED共用一个 开关,那「开启鉴权」会同时暴露这个工厂:攻击者领一张 sub=victim 的 token,照样读 victim 的 偏好——刚堵的洞原样捅开,鉴权成了安全剧场。所以单列AUTH_DEV_TOKEN开关:AUTH_ENABLED=true单开就是真安全(无发证口),本地 / 测试要发 token 才额外开它,生产必须关、换真实 OAuth / 密码 登录。 - 缺密钥要启动即报错,不能拖到每请求 500(review 抓到的)。开了鉴权却忘配
JWT_SECRET,如果拖到 请求时才在验签处抛,会变成「服务起来了但每个请求 500」的自残式故障。所以启动时就校验:开了鉴权又没 密钥 → fail-fast,服务直接起不来,逼运维当场修。 - 绝不用弱默认密钥。漏配密钥宁可报错也不给个默认值兜底——弱默认密钥等于「谁都能伪造 token」,比 不鉴权还危险。
- 身份只来自一处。开启后,请求体 / URL 里的 user_id 一律不当身份用(
resolve_identity只取 token 的 sub),从机制上断掉「换个地方传 user_id 绕过」的可能。
5. 范围与诚实边界(重要:别误读为「全锁了」)#
本块只堵 user_id 维度的越权读 Store——这是计划点名的真实漏洞。thread 维度的资源仍未做属主 校验,诚实标注清楚:
GET /api/history/{tid}、GET /api/files/{tid}/{name}、WS /ws/{tid}、POST /api/task/{tid}/cancel、POST /api/upload这些都按 thread_id 寻址、不校验属主。谁知道某个 thread_id(connect-first 下它 由前端生成、会出现在 URL / 事件流里)就能读其历史 / 产物 / 实时事件、甚至取消其任务。- 要堵这半边,需要一张 thread_id → owner(user_id) 归属表(任务创建时落库,各 thread 接口校验 属主)。这是个有状态、要考虑匿名会话 / 重启持久化的独立增量——本块不做半成品归属表,先把 点名的 Store 洞封死,把 thread 维度明确记为下一步。半个错的授权比没有更危险,宁可标清楚边界。
- 真实身份认证(密码 / OAuth / 刷新令牌)留作业,与 refdocs「真实平台 OAuth 不覆盖」一致。本块做 的是授权(authZ:token 的 sub 可信、改不了),认证(authN:怎么证明你真是 alice)只给了个 开发态 stub。
6. 一句话面试总结#
I 块体现的是**「分清优先级、把安全洞和打磨分开」:在「鉴权 / 限流 / 版本 / 幂等」一堆生产化项里, 精准识别出越权读是真漏洞、其余是打磨**,单独提到 P1 先堵。修法是标准的「身份只认签名 token、不信 前端传入」,并把 JWT 最经典的几个坑(alg 混淆、无 exp、弱默认密钥、缺密钥静默)都按对了;最关键的一手 是把无密码发证口单列开关,避免「开了鉴权反而捅开洞」的自相矛盾。同时诚实标注 thread 维度尚未 做属主校验——堵真洞,但不假装全锁了。