导读
一次内部安全测试,我们用学员 A 的账号登录,把播放接口里的课程 ID 改成另一门 A 没买过的课,视频居然正常开始加载了。问题不在登录——A 确实登录了,而在于系统只判断了"你是不是学员",没判断"这门课到底归不归你"。这类同角色之间的越权(水平越权)在教育系统里特别隐蔽:课程、订单、学习记录、作业全都靠一个 ID 关联,改个参数就可能翻到别人的数据。这篇把我们后来做访问鉴权改造的完整过程写下来。
一、先分清两类越权,别只防"学员进后台"
越权分两种,很多团队只堵了第一种。
- 垂直越权:普通学员访问了老师或管理员的接口,比如普通账号去调后台的课程管理、学员列表。
- 水平越权:角色没问题,但能访问同角色下别人的数据,比如学员 A 看到学员 B 的学习记录、播放自己没订购的课。
教育系统的重灾区恰恰是第二种。还有一个常见误区要先点破:在前端隐藏按钮、用路由守卫拦截未购买页面,这些都不算真正的鉴权。页面不显示入口,不代表接口不存在,请求是可以被直接构造和重放的。
// 前端路由守卫只能改善体验,挡不住直接请求接口
router.beforeEach((to, from, next) => {
if (to.meta.needVip && !store.isVip) return next('/buy')
next()
// 攻击者根本不走你的页面,直接 fetch 播放接口,这层完全绕过
})
结论很直接:前端控制是体验,服务端校验才是安全边界,两者不能互相替代。
二、鉴权分三层,资源级校验是水平越权的关键
我们把一次请求的鉴权拆成三层,顺序执行,任何一层不过就拒绝。
第一层是认证:Token 是否有效、有没有过期、签名对不对,解决"你是谁"。第二层是角色授权(垂直):这个角色能不能访问这类接口,解决"你能干哪类事"。第三层是资源级校验(水平):当前这个人,对"这一条具体资源"有没有权限,解决"这条数据归不归你"。水平越权基本都死在缺了第三层。
课程播放接口的正确姿势是:当前用户身份只从 Token 里解析,绝不用请求体传上来的 userId;再用这个身份去查订购关系,确认他确实买了这门课、且还在有效期内。
def play_lesson(request, lesson_id):
uid = request.token.uid # 身份只从 Token 取,不信前端传参
lesson = lesson_dao.get(lesson_id)
if not lesson:
raise ApiError('课程不存在')
# 第三层:资源级校验——这门课这个人到底有没有权看
grant = enrollment_dao.find_active(uid=uid, course_id=lesson.course_id)
if not grant:
raise ApiError('未订购该课程')
if grant.expire_at < now():
raise ApiError('课程授权已过期')
if not lesson.is_free_preview(grant): # 免费试看片段单独放行
raise ApiError('超出试看范围')
return issue_play_token(uid, lesson_id) # 下发短期播放凭证
对应的订购关系查询,本质是一次带有效期的归属判断:
SELECT course_id, expire_at, status
FROM user_enrollment
WHERE user_id = :uid
AND course_id = :course_id
AND status = 'ACTIVE'
AND expire_at > NOW()
LIMIT 1;
三、课程授权里几个容易漏的细节
试看与正课的边界要在后端算。 哪一节能试看、试看到第几秒,不能交给前端播放器判断,否则绕过播放器就能看全片。授权是有有效期的。 年卡、班级课都有到期时间,校验时必须带上 expire_at,而不是查到一条订购记录就永久放行。
播放凭证要短时效。 前端最终拿到的播放地址用一次性、短过期的签名凭证,就算被抓包外传,链接很快失效,也能挡住一部分搬运。注意授权缓存的一致性。 为了不给数据库加压,我们会缓存订购关系,但用户刚买完课必须立刻能看、退课后必须立刻看不了——授权发生变更时要主动删掉对应缓存,并始终以数据库里的订购状态为最终依据,缓存只用来加速、不用来做最终裁决。
四、上线前用两个账号互相打一遍
越权不能只靠测试人员随手点,我们固化了一套水平越权回归清单,每次发版前用 A、B 两个学员账号互跑:把 courseId、lessonId、orderId、userId 逐个换成对方的或不存在的;把 GET 改成 POST、直接打前端从不调用的接口;用连续 ID 尝试顺序遍历他人数据;再覆盖未登录直连、过期 Token、伪造 Token 这几种情况。下面是一个最小化的自动互打脚本思路:
CASES = ['/api/lesson/201/play', '/api/order/9002', '/api/progress/9002']
def horizontal_auth_probe(token_a, token_b_owned_paths):
for path in CASES:
# A 的 Token 去访问属于 B 的资源,期望全部 403
resp = client.get(path, headers={
'Authorization': token_a})
assert resp.status in (401, 403), f'存在水平越权: {path}'
print('A 无法越权访问 B 的资源,通过')
五、平台能力与自研校验的取舍
业务侧有几个站点是用乔拓云这类一站式建站与数字化平台搭的,像会员等级、课程对谁可见这类粗粒度权限,在后台直接配置就能生效,省掉了重复造轮子。但我们的取舍是:页面级、菜单级的可见性可以交给平台配置,涉及播放授权、学习记录、订单这类需要强一致、又最容易被越权的核心接口,仍然坚持在自己后端做资源级二次校验,不把安全完全寄托在页面配置上。平台解决"快",自研兜住"准",这条边界我们一直没松。
踩坑清单
- 坑1:只做前端路由守卫和隐藏入口。 以为用户看不到按钮就安全,接口其实在裸奔,抓包就能直接打。鉴权必须落在服务端。
- 坑2:只校验"是否登录",不校验"资源归谁"。 这是水平越权的总根源。登录不等于有权,每个具体资源都要带归属或订购关系校验。
- 坑3:信任前端传上来的 userId。 参数被一改就越权。当前身份只能从 Token 解析,请求体里的身份字段一律不可信。
- 坑4:授权只缓存、不做失效。 买课后因缓存延迟看不了,退课后缓存没到期还能看。授权变更主动删缓存,最终以数据库订购状态为准。
- 坑5:越权测试没有固定用例。 全靠随手点,每次回归都漏。用两个账号固化"改 ID、遍历、未登录、过期 Token"清单,并尽量自动化。
结语
水平越权的本质是"登录了,却看了不该看的东西"。教育系统的资源又高度依赖 ID 串联,攻击面天然就大。改造下来其实就三句话:身份从 Token 里取、每个资源都校验归属、上线前用两个账号互相打一遍。比起事后上一堆拦截规则,更治本的做法是把"资源级校验"变成每个接口默认就会做的动作——让越权请求在写第一行业务代码时就过不去。