Voocii博客
首页博客AI 热榜作品集读书友链工具关于


Voocii© 2026. Built with Next.js & tRPC.
GitHubXEmailRSS

实战:Secure Sharing Link 设计与实现复盘

工作总结rick-hayekrick-hayek2024年10月13日

一、问题背景

我们的系统是一个典型的"登录后可用"的商用 SaaS:客户登录后创建工程方案,发布给内部或外部的审核者(stakeholder)审核、评论、签核。

问题在于:审核者不一定有账号。方案通常发给客户的领导、合作方,甚至一次性的外部顾问,为每个这样的人开正式账号(走 OIDC 认证、分配角色权限)成本太高、也没必要——他们可能一辈子只审这一次。

所以需求本质是:

在不给对方开正式账号的前提下,让对方通过一个"临时通行证"访问系统里本来受保护的一个具体资源,并且能完成一套完整的操作(查看、评论、审核),行为要能落到系统正常的数据模型里。

这类需求业内有一个通用名字:Secure Sharing Link / Magic Link(安全外链),Google Docs 的"知道链接的任何人可查看"、DocuSign 的签署邮件链接、SharePoint/OneDrive 的访客共享链接,本质都是同一套模式的商业化实现。

二、我的实现方案

2.1 核心思路

原有认证体系基于 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/>完成查看/评论/审核操作]
  • 临时链接 = 正常的方案详情页 URL + 一个 JWT 参数(如 ?token=xxx)
  • 页面加载时的判断逻辑:
    1. 先走原来的登录态检查(是否是已登录用户,不变)
    2. 未登录 → 检查 URL 是否带 token 参数
    3. 有 → 做基础格式校验(是不是一个结构合法的 JWT)
    4. 合法 → 正常发起获取方案数据的 API 请求,把 token 带在请求里
    5. 服务端在这几个特定 API(获取方案详情、提交评论、提交审核意见)上,加一层"临时令牌"校验逻辑,校验通过则放行,并把这次操作归属到 JWT 里记录的审核者身份上

2.2 前端

  • 发送临时链接的界面:客户填收件人邮箱,系统自动生成邮件内容(对客户透明,实际内容在后端拼装)
  • 管理临时链接列表:查看已发放链接的状态(未使用/已使用/已过期/已作废),支持主动作废某个链接

2.3 后端

  • 创建链接 API:
    • 生成 JWT,payload 至少包含:收件人邮箱、过期时间、目标方案 ID、允许的操作范围(权限)
    • 用系统私钥/密钥签名
    • 加密JWT
    • 把 JWT(或其唯一标识)存入数据库,作为"可查表"
    • 触发发邮件
  • 鉴权中间件:在方案详情、评论、审核意见这几个 API 上,校验 JWT 签名、是否过期、数据库里对应记录的状态(未撤销)、以及权限范围是否覆盖当前操作
  • 后台 worker:定时扫描数据库,清理过期/已撤销的 JWT 记录

这套东西整体跑起来是没问题的,客户和审核者的使用体验也是通的,实际上这个方案也上线平稳运行一段时间了。只不过后来我有点后知后觉,就是突然有一天意识到类似这种需求,应该早就有成熟的方案。于是我开始调研分析比较我的方案跟它们的差距。

三、和成熟方案的对照

3.1 相似的地方(做对了的部分)

我的实现成熟方案里的对应做法
JWT 里带过期时间、目标资源 ID、权限范围这就是标准的"限定作用域的令牌"(scoped token)思路,DocuSign、Dropbox 分享链接都是这么做的
数据库存一份 JWT 记录,可以查状态、可撤销这是关键的正确决策。很多人第一次做 JWT 会觉得"JWT 天生无状态,不用查库",但涉及可撤销的安全场景,业界公认必须配合服务端存储(这个模式有个专门名字,见下面 4.2)
定时清理过期/作废记录常规做法,没问题
特定几个 API 单独加校验中间件,而不是改全局认证逻辑这个隔离做得对,避免了对主认证链路的入侵式修改

3.2 值得改进/容易被忽视的地方

1. Token 应该是"一次性 + 短期会话",而不是长期有效的通行证

我的 JWT 有效期设置得比较长(2 天内随时可用),并且没有区分"首次兑换(redeem)"和"后续访问",会有一个问题:这个 JWT 一旦泄露(邮件被转发、链接被截图分享、邮箱被盗),在有效期内任何人都可以拿它反复访问,且行为无法和"真实审核者"区分开。

成熟方案(DocuSign、以及 better-auth 的 one-time-token 插件模式)通常做法是二段式:

  • 邮件里的链接本身是一次性的(redeem 后立即失效,或者第一次使用时才换发一个"会话态"的凭证)
  • 首次打开后,用这次性 token 换一个短期会话(比如浏览器 Cookie 或短期 Session Token),后续在有效期内的操作用这个会话态凭证,而不是反复复用邮件里那个原始 JWT
  • 这样即使原始链接后来被别人翻出来重放,也只能拿到"已经用过一次"的失效凭证

在Secure Link管理界面里有"是否已 redeem"这个状态字段,但这个状态没有真的在服务端强制生效,即 redeem 后原 token 还是可用,这里 UI 展示 redeem 似乎(我不是记得很清楚了)只是用于给客户管理的一个标记。

2. JWT 本身不要携带过多敏感信息,且要考虑"Token 会出现在日志/浏览器历史/Referer 里"

因为我把(加密后的) JWT 放在 URL query string 里,它天然会:

  • 被浏览器历史记录保存
  • 出现在服务器访问日志(access log)里
  • 如果页面里有外链,可能通过 Referer header 泄露给第三方
  • Web Server、API Gateway、CDN、WAF 和反向代理日志;

业界对这个问题的通用建议:

  • Payload 里绝不要放邮箱之外的敏感业务数据,够用就行
  • 服务端日志要对这类带 token 的 URL 做脱敏(只记录 jti/token 的哈希,不记录原文)
  • 如果可能,改成首次点击后立即通过 307/302 跳转把 token 从 URL 挪进 Cookie(HttpOnly + Secure),后续请求不再依赖 URL 里的原始 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. 签名算法与密钥管理

  • 确认用的是非对称签名(RS256/ES256):如果这个临时 JWT 校验逻辑将来可能下沉到网关/CDN 层做校验,用非对称签名会更安全,因为校验方只需要公钥。这个做的没问题。
  • 没有密钥轮换(key rotation)机制:如果私钥泄露,需要加上将所有已发出的临时链接批量失效。虽然已经有数据库这一层兜底,可以通过一个"全局失效版本号"字段快速做批量吊销,属于加分项,可以补上。

6. 邮件本身的安全性不在我的控制范围内,但要有意识

临时链接的安全边界本质上依赖"只有收件人能看到这封邮件",但邮件转发、共享邮箱、企业邮件网关扫描(有些安全网关会自动预取邮件里的链接做扫描)都可能导致 token 被消耗或泄露。这也是为什么"一次性 token → 换发会话"这个二段式设计格外重要——如果因为安全网关自动点击链接就把宝贵的一次性名额用掉,正常用户反而打不开了,需要有相应的容错(比如允许扫描请求和真人首次访问都能各自拿到一次机会,或者用短暂的"宽限窗口")。

3.3 有无"重大"安全隐患?

从我的整个方案实现来说,没有直接导致数据泄露或越权的架构性硬伤(比如没做签名校验、没做实时过期校验,没有数据库二次验证这类问题)。但有些细节需要补上:

  1. "作废/redeem 后要确保服务端强制失效"——如果只是数据库状态字段更新了,但校验逻辑没有每次都去查这个状态,等于没有真正撤销能力。
  2. 严格校验资源 ID 绑定:防止篡改 URL 里的方案 ID 做越权访问。
  3. "一次性 + 短期会话":必须实现这个,提升安全。

四、改进以及后续

整个工作已经完成,大不可能重新来,但可以复用的成熟实现思路,一些可以立即改进和长期迭代升级的地方:

  • OAuth 2.0 Token Exchange(RFC 8693):把"临时访客"建模成一种特殊的 OAuth 客户端类型,通过 token exchange 换取范围受限的 access token,跟现有的 OIDC 体系可以更自然地融合,而不是完全绕过它
  • Phantom Token / Split Token 模式:外部暴露的是一个不透明(opaque)的短 token,只有网关内部才把它换成真正的 JWT 交给后端 API,外部彻底看不到 JWT 内容,减少泄露面 - 短期内实现
  • 一次性令牌 + 会话态升级(better-auth 的 one-time-token 插件、Firebase 的 email link 登录都是这个模式)- 可以立刻实现

参考资料

  • RFC 9700 — Best Current Practice for OAuth 2.0 Security
  • RFC 8725 — JSON Web Token Best Current Practices
  • RFC 9449 — OAuth 2.0 Demonstrating Proof of Possession (DPoP)

评论 (0)

暂无评论,快来抢沙发吧!

目录
  • 一、问题背景
  • 二、我的实现方案
  • 三、和成熟方案的对照
  • 四、改进以及后续