深入 Play 26:签名身份、对象策略与订阅撤销
alice 的签名 session 可以访问对象 1,但访问对象 2 得到 403。bob 的权限恰好相反。alice 订阅对象 1 的 SSE 后撤销权限,下一次事件检查使连接中断;bob 的 WebSocket 在撤销后的下一条消息上失败,客户端却收到 Close 1000。
HTTP 身份、对象访问权、连接终止和业务成功分别有自己的证据。Close 1000 不能覆盖服务端已经记录的授权失败;签名有效也不能替代一次对象级检查。
从 Cookie 字节到请求身份
Play 3.0.6 的固定源码提交为 2e56aff7d4e7a74af61e4bd39ec9e3ed7f300cd6。Java 的 Security.AuthenticatedAction 与默认 Authenticator 先调用 getUsername;有值时向 request attribute 写入 USERNAME 后继续执行,没有值时返回默认 401。默认 getUsername 只读 req.session().get("username")。
这段 Action 没有解析密码,没有查询用户表,也没有读取 X-User。Cookie 到 session Map 的解码发生在更前面的框架边界。JWTCookieDataCodec.decode 通过格式化器解析 JWT 数据;签名异常分支记录警告并返回空 Map。默认认证随后看不到 username,才产生 401。
1 | |
实验使用 JDK 21、Play 3.0.6、Pekko Streams 1.0.3。GovernanceController.login 是公开的合成测试入口,接受 alice 或 bob 后调用 addingToSession,让框架产生 session Cookie。它只在 governance.fixtureLoginEnabled=true 的专项配置中开放;默认应用缺少这个开关时返回 404,已由 JUnit 验证。该端点没有证明任何真实用户的身份,不能作为生产登录代码使用。
修改签名 Cookie 需要实际改变签名字节。历史负测只改 Base64url 最后一个字符,曾得到 200;某些字符变化只改变尾部未使用位,解码出的字节没有变化。最终脚本改签名段首字符,得到 401。输入在文本上不同,不足以证明密码学校验输入已经不同。
对象策略需要覆盖所有入口
有限对象策略保存在 app/authlab/ObjectPolicy.java:alice 对应对象 1,bob 对应对象 2,撤销集合最多两名用户;修改只增加合成计数,不连接数据库。列表按策略逐项过滤,详情和修改在给定 id 上重新校验,订阅在建立前也执行同一策略。
| 输入与操作 | 实测结果 | 能定位的边界 |
|---|---|---|
| 无 Cookie,或仅 X-User/X-Username | 401 | 默认身份入口不采信这些头 |
| 改变 session 签名字节 | 401 | 网络请求经过 Cookie 验签与身份提取 |
| alice / bob 列表 | [1] / [2] |
列表没有输出另一个用户的对象 |
| 两名用户读取自己的对象 / 对方对象 | 200 / 403 | 身份不直接授权任意 id |
| 合法 CSRF token 下修改对方对象 | 403 | CSRF 通过以后仍检查业务访问权 |
| 跨对象 SSE、WebSocket | 403 | 长连接入口也执行对象策略 |
只保护详情页会留下列表泄露;只过滤列表会留下直接访问 id 的入口;只给修改加注解则不能约束一个已经建立的订阅。测试矩阵应以“资源操作”列出路径,而不是以 Controller 类的数量估算授权覆盖。
检查与修改的原子性也属于策略的一部分。下面的完整示例以同一 monitor 保护撤销、检查和计数变更。它采用 Java 8 语法,在本实验 JDK 21 上编译运行;没有自行实现 Cookie 或 JWT 算法。
1 | |
若先调用 allowed,退出同步区后再写数据,撤销可能发生在两步之间。上述计数器把检查和变更放在同一同步操作中,只能约束这一个进程内对象。迁移到数据库时,需要按事务或条件更新重新定义检查与写入的边界,不能认为 Java monitor 已覆盖其他实例和数据库连接。
已有连接上的权限变化
握手授权只说明建立连接那一刻满足条件。SSE 的后续事件和 WebSocket 的后续消息还会读取数据,权限可能已经改变。实验采用逐次检查:SSE 每个事件输出前检查,WebSocket 每条文本消息处理前检查;上限分别为 20 个事件或 20 条消息,连接最多运行五秒。
1 | |
SSE 的事件节流间隔为 150 ms。撤销请求完成后,脚本继续在原连接读取,观察到 ConnectionResetError,终态为 revoked_or_failed。WebSocket 先成功回显 object-2:before,撤销后再发送 after,收到 Close 1000;服务端终态同样为 revoked_or_failed。这证明后续消息没有按正常路径回显,不能把关闭码单独解释为业务成功。
逐次检查仍有时间窗口。SSE 检查通过后已经输出到下游的事件,可能在撤销之后才到达客户端;WebSocket 没有新消息时,也不会立刻触发本策略的下一次检查。要求主动切断时,需要管理连接与权限版本,并定义通知丢失、跨实例传播和在途数据的行为。这些机制没有在当前有限实验中实现。
把订阅检查从 map 内移到创建 Source 前,是一个可执行反例:连接建立后撤销不会再改变已捕获的判断。该改动可使初始握手测试仍然通过,却让原连接继续输出。因此撤销测试必须保持原 socket,不应通过新建连接后的 403 代替。
修改计数与重跑
真实网络矩阵执行四次允许的修改,其中包含 CSRF 与 CORS 对照。越权修改、撤销后的修改都被拒绝,最终计数仍为四。这个计数是合成业务状态,能够证明实验中的拒绝分支没有调用修改逻辑,不能证明真实数据库没有写入。
累计工程入口见第00篇。设置 JDK 21 的 JAVA_HOME 后,在 play-lab 目录执行:
1 | |
脚本分别管理默认安全配置、关闭 CORS 联动绕过的配置、精确信任本地代理的配置,使用临时 loopback 端口并在 finally 终止自建进程。每次运行产生新随机密钥,之前的 Cookie 不应跨进程重用。
历史隔离证据为 evidence/batch26-29/isolated/run-final3/observations.json 的 authorization 节点、同目录 default.log,以及 junit/TEST-GovernanceTest.xml。该隔离基线共 37 项 JUnit 通过,含 3 项治理测试;这不是后来共享工程的累计数量。原始来源摘录在 source-excerpts.txt,精确源文件 SHA256 在 source-receipt.json。
共享接入后的累计验证见 evidence/batch26-29/shared-build.json:49 个不同 JUnit 方法全部执行,0 失败、错误或跳过,stage 成功,五个治理 class 与生产 jar 字节一致。shared-http/observations.json 保留当前工程的授权、安全、流终态与三个进程退出记录;原隔离结果继续作为历史证据。
改动练习:将 alice 的对象从 1 改为 2,更新列表、详情、修改、SSE 与 WebSocket 的预期。只修改列表断言应当留下失败项。第二个练习把撤销检查移动到连接创建前,验证原连接上的撤销场景确实失败,再恢复逐次检查。
| 需求 | 检查位置 | 仍需另外证明 |
|---|---|---|
| 确定请求身份 | 已验签 session 到认证 Action | 真实登录入口如何认证 |
| 访问某个对象 | 每个业务操作的对象策略 | 多实例、数据库的写入原子性 |
| 权限变化影响订阅 | 每事件、每消息再检查 | 在途数据与主动切断延迟 |
上一篇:生命周期与后台任务。下一篇:Web安全与信任边界。
