我们的系统是一个典型的"登录后可用"的商用 SaaS:客户登录后创建工程方案,发布给内部或外部的审核者(stakeholder)审核、评论、签核。
问题在于:审核者不一定有账号。方案通常发给客户的领导、合作方,甚至一次性的外部顾问,为每个这样的人开正式账号(走 OIDC 认证、分配角色权限)成本太高、也没必要——他们可能一辈子只审这一次。
所以需求本质是:
在不给对方开正式账号的前提下,让对方通过一个"临时通行证"访问系统里本来受保护的一个具体资源,并且能完成一套完整的操作(查看、评论、审核),行为要能落到系统正常的数据模型里。
这类需求业内有一个通用名字:Secure Sharing Link / Magic Link(安全外链),Google Docs 的"知道链接的任何人可查看"、DocuSign 的签署邮件链接、SharePoint/OneDrive 的访客共享链接,本质都是同一套模式的商业化实现。
原有认证体系基于 OIDC。为了不改动主认证流程,我选择"旁路"的方式:
flowchart TD
A[审核者点击邮件里的临时链接] --> B{是否已登录系统?}
B -- 是 --> C[走原有 OIDC 登录态<br/>正常访问流程]
B -- 否 --> D{URL 是否带 token 参数?}
D -- 否 --> E[跳转正常登录/无权限页]
D -- 是 --> F[前端校验 token 基础格式]
F -- 格式非法 --> E
F -- 格式合法 --> G[携带 token 调用方案详情/<br/>评论/审核意见 API]
G --> H[服务端: 校验签名/过期/<br/>数据库状态/权限范围]
H -- 校验失败 --> I[拒绝访问, 返回 401/403]
H -- 校验通过 --> J[以 token 中记录的审核者身份<br/>完成查看/评论/审核操作]
?token=xxx)token 参数这套东西整体跑起来是没问题的,客户和审核者的使用体验也是通的,实际上这个方案也上线平稳运行一段时间了。只不过后来我有点后知后觉,就是突然有一天意识到类似这种需求,应该早就有成熟的方案。于是我开始调研分析比较我的方案跟它们的差距。
| 我的实现 | 成熟方案里的对应做法 |
|---|---|
| JWT 里带过期时间、目标资源 ID、权限范围 | 这就是标准的"限定作用域的令牌"(scoped token)思路,DocuSign、Dropbox 分享链接都是这么做的 |
| 数据库存一份 JWT 记录,可以查状态、可撤销 | 这是关键的正确决策。很多人第一次做 JWT 会觉得"JWT 天生无状态,不用查库",但涉及可撤销的安全场景,业界公认必须配合服务端存储(这个模式有个专门名字,见下面 4.2) |
| 定时清理过期/作废记录 | 常规做法,没问题 |
| 特定几个 API 单独加校验中间件,而不是改全局认证逻辑 | 这个隔离做得对,避免了对主认证链路的入侵式修改 |
1. Token 应该是"一次性 + 短期会话",而不是长期有效的通行证
我的 JWT 有效期设置得比较长(2 天内随时可用),并且没有区分"首次兑换(redeem)"和"后续访问",会有一个问题:这个 JWT 一旦泄露(邮件被转发、链接被截图分享、邮箱被盗),在有效期内任何人都可以拿它反复访问,且行为无法和"真实审核者"区分开。
成熟方案(DocuSign、以及 better-auth 的 one-time-token 插件模式)通常做法是二段式:
redeem 后立即失效,或者第一次使用时才换发一个"会话态"的凭证)在Secure Link管理界面里有"是否已 redeem"这个状态字段,但这个状态没有真的在服务端强制生效,即 redeem 后原 token 还是可用,这里 UI 展示 redeem 似乎(我不是记得很清楚了)只是用于给客户管理的一个标记。
2. JWT 本身不要携带过多敏感信息,且要考虑"Token 会出现在日志/浏览器历史/Referer 里"
因为我把(加密后的) JWT 放在 URL query string 里,它天然会:
Referer header 泄露给第三方业界对这个问题的通用建议:
jti/token 的哈希,不记录原文)由于 JWT 是 bearer token,拿到它的人通常就能使用它。RFC 9449 也明确区分了普通 bearer token 与 sender-constrained token:普通 bearer token 只要持有即可使用,而 DPoP 等机制通过密钥证明降低被盗 Token 的滥用风险。RFC 9449
**3. 没有明确 Token 的 audience 和 issuer
如果只检查签名和过期时间,而不检查 iss、aud,可能出现“一个系统签发的合法 Token 被另一个系统接受”的混淆问题。
应该至少包含并验证:
{
"iss": "https://app.example.com",
"aud": "secure-review-api",
"sub": "external-reviewer:token-id",
"jti": "unique-id",
"iat": 1722412800,
"exp": 1722585600
}
4. 更进一步方案:一次性邀请链接 / Magic Link 系统生成一个随机、短期、单用途的 opaque token,通过邮件发给审核者。审核者点击后,服务端兑换 Token,创建一个受限 session。
推荐结构:
邮件链接中的值:随机字符串 R
数据库中保存:H(R) + 审核者身份(email) + 方案 ID + 权限 + 过期时间 + 状态
服务端不必把用户身份、权限和方案信息全部放进 URL,也不必保存原始 Token。因为数据库中保存的是哈希,即使数据库被读出,攻击者也不能直接使用链接。
链接只用于第一次兑换:
flowchart LR
L[邮件中的一次性链接] --> R[兑换 Endpoint]
R --> V[验证 token hash / 过期 / 状态]
V --> S[创建短期 Secure Review Session]
S --> API[后续 API 使用 HttpOnly Session Cookie]
API --> P[逐请求检查方案和权限]
兑换成功后,立即从地址栏移除 Token:
GET /review/consume?token=R
→ Set-Cookie: review_session=S; HttpOnly; Secure; SameSite=Lax
→ 302 /designs/1088
后续请求只带 session cookie,不再把 R 反复暴露在 URL、Referer、浏览器历史和代理日志中。这显然比“每个 API 都携带 URL 中的 JWT”更稳妥。
5. 签名算法与密钥管理
6. 邮件本身的安全性不在我的控制范围内,但要有意识
临时链接的安全边界本质上依赖"只有收件人能看到这封邮件",但邮件转发、共享邮箱、企业邮件网关扫描(有些安全网关会自动预取邮件里的链接做扫描)都可能导致 token 被消耗或泄露。这也是为什么"一次性 token → 换发会话"这个二段式设计格外重要——如果因为安全网关自动点击链接就把宝贵的一次性名额用掉,正常用户反而打不开了,需要有相应的容错(比如允许扫描请求和真人首次访问都能各自拿到一次机会,或者用短暂的"宽限窗口")。
从我的整个方案实现来说,没有直接导致数据泄露或越权的架构性硬伤(比如没做签名校验、没做实时过期校验,没有数据库二次验证这类问题)。但有些细节需要补上:
整个工作已经完成,大不可能重新来,但可以复用的成熟实现思路,一些可以立即改进和长期迭代升级的地方:
暂无评论,快来抢沙发吧!