Server 层:从 Middleware 到 Repository 的逐层拦截
安全校验的最终位置在后端(UI 层的显隐只是体验)。一个请求穿过一条流水线,每一层管一件事:Middleware 认身份和角色 → Router 声明这个 API 要什么 capability → Controller 只编排、不判权 → Service 带上业务上下文 →
Domain 执行业务规则 → Repository 按 scope 过滤数据。
1 · 请求在六层流水线上的行程
请求可能在三处被挡下:Router 判「这个角色一般能不能做」,Domain 判「这个业务状态允不允许」,Repository 判「这一条记录在不在我的 scope 里」。
2 · can / assert / scope 三个原语
| API | 问的问题 | 用在哪 |
|---|---|---|
can(key) |
有没有这条能力?返回 boolean |
UI 显隐、分支判断 |
assert(key) |
没有就抛 403,有就继续 | Router / Service 守卫,拦截无权限的请求 |
scope(key) |
这条能力的数据范围是什么(Full/Group/Self)? | Repository 拼 WHERE 条件 |
注 · ScopeResolver 把 scope(key) 得到的范围翻译成具体的查询约束——输入 (capability, 当前用户),输出 { where, 或 拒绝 }。它是「对谁做」在数据层的落地点:Self → WHERE owner_id = me、Group → WHERE group_id = me.group、Full → 无约束。
警示 · 对象级授权(object-level)必须下沉到数据层。Router 的 assert(order:cancel) 只回答「这个用户一般能不能取消订单」;但「能不能取消这一张订单——它是不是在我的 scope 里」要等真的取到这条记录才知道。所以 scope 过滤放在 Repository:要么进 WHERE(查不到 =
不存在),要么取出后断言 owner === me。只在 Router 拦截,等于只做了类级授权,会遗漏「越权访问他人某一条记录」的情况。
建议 · 分层各司其职,职责不互相渗透:Controller 只做编排、不写权限判断;业务规则(「done 的单不能取消」)属于 Domain,不要放进 Controller 或 SQL;Repository 只负责 scope 过滤,不涉及业务。职责划分清楚,权限逻辑才便于排查、便于测试。
3 · 换一个 id 的越权
Router 上那句 assert(commerce:order:read) 与 URL 里的 id 无关,同一个角色的任何请求它都放行。于是把 /api/orders/1001 改成 1002 会发生什么,完全由数据层那道 scope 过滤决定:过滤在,查不到就是 404;过滤不在,别人的订单原样返回。这类缺陷在 OWASP API Security Top 10
里排第一位,称作 BOLA(broken object level authorization),旧称 IDOR。
前端的隐藏与置灰对它没有任何作用,因为请求可以手写。同样漏在这一层的还有两种写法:批量端点 GET /api/orders?ids=1001,1002,1003 逐个 id 取数据却只在入口 assert 一次;PATCH 接口把请求体整个灌进实体,让攻击者顺手改掉 owner_id。
警示 · 拦下之后返回哪个状态码是一次信息泄漏的权衡。403 承认这个 id 存在,攻击者据此可以逐个扫描出有效 id 的范围;404 把「无权」与「不存在」合并成同一个回答,扫描便拿不到信号。代价是排障时两种情况分不开,所以真实原因要落在服务端日志里,而不是响应体里。
4 · 参考文献
- OWASP. (2023). API1:2023 Broken Object Level Authorization. OWASP API Security Top 10. 对象级授权缺失为何长期居首,以及批量端点与对象 id 枚举的攻击面。owasp.org