有效令牌不代表拥有文档访问权:设计原理
一句话总结
把认证、权限和所有权分开,并用同样的 404 来隐藏资源是否存在。
为什么需要它
已登录的用户只是改了地址里的文档编号,就读到了别的团队的文档。令牌有效这件事,与有权读取某份特定文档,是两回事。本实验用固定的令牌字典来代替认证服务器。它不是实现签发令牌或 JWT 签名的实验,而是实现认证结果之后的权限边界的实验。
工作原理
请求按照 Bearer 头格式检查、令牌查询、scope 确认、所有者确认的顺序进行。没有认证信息或认证信息有误,返回 401,并发送 WWW-Authenticate 头。身份已确认但没有 read 权限,返回 403。有读取权限的用户请求不存在的文档或别人的文档,两者都是 404。公开响应中只保留 id 和 title,以免泄露内部所有者和成本。
헤더 → 인증 401 → scope 403 → 소유권/존재 404 → 공개 필드 200
阅读契约并预测失败的工作表
下面不是让你把整个实现背下来的答案,而是分步骤的代码评审。每个改动片段都故意破坏了契约。请注意,改动之后,正常用例仍然可能通过。在运行之前,先预测观测哪些输入、异常和状态才能看出差异,实现之后,再把这个预测与结果进行比较。
1. 分离 Bearer 头
bearer(header) 在头部恰好以 'Bearer ' 开头,且后面有一个不含空格的令牌时,返回该令牌。None、空令牌、其他 scheme、多余的空格都是 ValueError。
判断依据:如果把头部随意切成多段,就可能把空格错误当成正常令牌接受下来。
待评审的错误改动片段:
header[6:]
请与包含该片段的函数的公开契约对照。如果仅凭一个成功用例无法区分,就把应被拒绝的输入或失败之后的状态选作观测对象。
2. 复制身份后再返回
principal(token, users) 返回令牌字典中用户的 {id, scopes},但要把 scopes 列表也一并复制。不认识的令牌是 ValueError。
判断依据:如果修改返回的 scopes 会连带改变原用户的权限,不同请求之间的权限就会混在一起。
待评审的错误改动片段:
user["scopes"]
请与包含该片段的函数的公开契约对照。如果仅凭一个成功用例无法区分,就把应被拒绝的输入或失败之后的状态选作观测对象。
3. 精确比较权限
require_scope(user, scope) 在 scopes 中恰好含有 scope 字符串时返回 None,没有时抛出 PermissionError。read-all 不是 read。
判断依据:子串比较会把更长的权限名称误认为另一种权限。
待评审的错误改动片段:
if False:
请与包含该片段的函数的公开契约对照。如果仅凭一个成功用例无法区分,就把应被拒绝的输入或失败之后的状态选作观测对象。
4. 单独确认所有权
visible(user, document) 只有在 document 不是 None,并且 owner 与 user 的 id 完全相同时才返回 True。
判断依据:把资源不存在和他人所有合并成同一个判定。
待评审的错误改动片段:
True
请与包含该片段的函数的公开契约对照。如果仅凭一个成功用例无法区分,就把应被拒绝的输入或失败之后的状态选作观测对象。
5. 用白名单挑选响应字段
public_document(document) 是只含 id 和 title 的新字典。不包含 owner 或 internal_cost。
判断依据:不要从原件中删除字段,而要组装新的响应。
待评审的错误改动片段:
("id", "title", "owner")
请与包含该片段的函数的公开契约对照。如果仅凭一个成功用例无法区分,就把应被拒绝的输入或失败之后的状态选作观测对象。
6. 让错误符合 HTTP 契约
authenticate(header, users) 把 bearer 和 principal 连接起来。ValueError 要变成 HTTPException(401),并且 headers 中 WWW-Authenticate 的值是 Bearer。
判断依据:不要把认证失败和应用错误统统归成一个 500。
待评审的错误改动片段:
HTTPException(403,
请与包含该片段的函数的公开契约对照。如果仅凭一个成功用例无法区分,就把应被拒绝的输入或失败之后的状态选作观测对象。
7. 固定拒绝的顺序
read_document(user, documents, document_id) 在没有 read scope 时是 HTTPException(403),文档不存在或属于别人时是 HTTPException(404),否则就是 public_document 的结果。
判断依据:即使认证之后,scope 和所有权也必须分别确认。
待评审的错误改动片段:
HTTPException(403, "not found")
请与包含该片段的函数的公开契约对照。如果仅凭一个成功用例无法区分,就把应被拒绝的输入或失败之后的状态选作观测对象。
8. 在真实请求中封住边界
create_app(users, documents) 返回一个 FastAPI 应用,它在 GET /documents/{document_id} 中接收 Authorization 头,并调用 authenticate 和 read_document。请用真实请求验证 200、401、403、404 以及非公开字段是否被去除。
判断依据:即使函数各自都对,如果在路径中漏掉了调用,访问控制就不会生效。
待评审的错误改动片段:
authorization: str | None = None
请与包含该片段的函数的公开契约对照。如果仅凭一个成功用例无法区分,就把应被拒绝的输入或失败之后的状态选作观测对象。
在现场相遇的样子
固定的令牌字典是教学用的输入。生产环境的认证,还需要过期、签名、撤销和安全保管。仅仅让 404 保持一致,也并不能消除通过响应时间或访问日志做出的所有推断。还要一并检查被拒绝的请求有没有连原始数据也改掉。
下一项实验要做什么
八个步骤会连成一个可运行的成果。分离 Bearer 头 → 复制身份后再返回 → 精确比较权限 → 单独确认所有权 → 用白名单挑选响应字段 → 让错误符合 HTTP 契约 → 固定拒绝的顺序 → 在真实请求中封住边界。
每一步检查的不是函数或文件是否存在,而是实际的返回值、异常和状态变化。看过正确答案之后,请故意改动边界比较或清理代码,确认哪些测试会失败。请说明前面的测试为什么在下一步中依然保持有效,并写出一条本实验不能保证的生产条件。