2025年6月

现状

  • 主路由 192.168.2.1 关闭 DHCP
  • OpenWrt 软路由 (192.168.2.2) 提供 DHCP 服务,DHCP 下发的网关为 192.168.2.2, 下发的 DNS 为 192.168.2.2
  • OpenWrt 软路由 (192.168.2.2) 安装 Tailscale,已设置 Exit Node + Subnet Route 192.168.2.0/24
  • 网络 -> 防火墙 -> 区域:已存在 tailscale -> lan 的 IP 动伪装(Masquerading)
  • 网络 -> 接口:只有 lan 和 tailscale 两个接口

问题

  • 从公网连上 Tailscale 并指定 OpenWrt 为 Exit Node 后,只能访问 192.168.2.2,192.168.2.0/24 网段下的其他主机和公网全部超时

解决

使用 tcpdump 抓包发现是回程的包没有正确返回,确定是 SNAT 的问题。
手动加入 iptables -t nat -I POSTROUTING -s 100.64.0.0/10 -o br-lan -j MASQUERADE 之后问题解决, LAN 和公网均可借助 OpenWrt 访问

但是我使用的 openwrt 比较新,已经不再推荐使用 iptables 管理防火墙,所以还要寻找一下替代方案。

在 o3 的帮助下,终于找到可行的做法,操作步骤如下

  1. 登录 LuCI → 「网络 ▸ 防火墙」
    这里会看到 概览 / 端口转发 / 流量规则 / NAT 规则 四个标签。
    “NAT 规则”正是专门用来写 SNAT/MASQUERADE 的页面。
  2. 切到 「NAT 规则」 标签页,点页面底部 「添加」。
    会弹出一个带 3 个子标签的表单:常规设置 / 高级设置 / 时间限制。
    这些字段的名字与含义可在官方手册中对应到 UCI 配置项,
    其中“出口设备”“动作”等就是我们需要填的要素。
  3. 常规设置 里填写

    字段选择 / 填写值说明
    名称CGNAT-masq任意易辨识的名字
    限制地址族IPv4只处理 IPv4
    协议任意与 iptables 命令里的“全部协议”一致
    出口区域未指定(或保持默认)MASQUERADE 不要求指定 zone
    源地址100.64.0.0/10对应 -s 参数
    动作MASQUERADE – 自动改写为出口接口 IP与 iptables 目标一致
  4. 点击 高级设置 子标签,再设置

    字段选择值
    出口设备br-lan对应 -o br-lan;这个字段只在高级设置里出现

    其他保持默认即可。
    (如果你的接口名字不同,请按实际桥接口名选取。)

    config nat
     option name   'CGNAT-masq'
     option family 'ipv4'
     option hook   'postrouting'
     list   match  'ip saddr 100.64.0.0/10'
     list   match  'oifname "br-lan"'
     option target 'MASQUERADE'

    firewall4 随后会把它转译为 nft 规则
    ip saddr 100.64.0.0/10 oifname "br-lan" masquerade
    写入 table inet fw4 chain srcnat,效果与原 iptables 指令完全一致。

生效检查

SSH 到路由器执行:

nft list table inet fw4 | grep 100.64

应能看到:

ip saddr 100.64.0.0/10 oifname "br-lan" masquerade

为什么要提供一个生命周期短的 Access Token

主要是出于 安全性可控性 的考虑,虽然看起来多了一步“刷新”,但整体上能大幅降低风险并提升灵活度:

  1. 降低令牌泄露后的风险

    • 如果你只发一个超长生命周期的 Access Token,一旦它被截获,不论是网络中间人攻击、XSS 漏洞还是客户端泄密,攻击者都能在很长一段时间内肆意调用你的 API。
    • 而短生命周期(比如 5–15 分钟)的 Access Token 即使被拿到,也只能在极短的窗口期内使用,过期后就作废,大部分攻击都来不及实施。
  2. 更灵活的撤销与控制

    • 假设用户强制登出、改了密码、或者你的风控系统发现异常行为,你需要“立即”让已有令牌失效。
    • 如果只有一个超长寿命的 Token,你几乎没法撤销——你只能把它加入黑名单(需要网关实时查黑名单,性能和一致性都成问题)。
    • 而有了 Refresh Token 机制,Access Token 过期后客户端必须去校验 Refresh Token 才能拿新令牌。你只要在服务端将该 Refresh Token 作废,此后所有刷新尝试都会失败,Access Token 再也无法续期。
  3. 遵循 OAuth/OpenID Connect 最佳实践

    • 业界公认的做法就是“短命令牌 + 长续期凭证(Refresh Token)”。几乎所有主流授权服务(Auth0、Okta、Keycloak、Google、Facebook)都在这么做。
    • 这样可以在保证用户体验(自动刷新令牌)与安全风控(滚动作废、快速撤销)之间取得平衡。
  4. 减少频繁认证的用户体验成本

    • 如果只发一个超短命令牌,没续期机制,客户端就要在令牌过期前强制用户再登录,体验糟糕;
    • 如果发超长命令牌又担心泄露风险,那就落到同时要做黑名单和实时校验,复杂度和运维成本均高。
  5. Refresh Token 的额外安全措施

    • 你可以对 Refresh Token 做 一次性、可旋转(Rotating Refresh Token),以及 绑定客户端指纹IP/设备限制 等,使得它本身即便泄露也更难被滥用。
    • 并且 Refresh Token 通常只会在后端安全环境(或浏览器的 HttpOnly Cookie)中使用,不容易被窃取。

小结

  • 短生命周期的 Access Token:快速到期,暴露窗口小。
  • 长生命周期的 Refresh Token:隐蔽存储,可控续期,方便撤销。

二者结合,才能在 安全性用户体验 之间取得最佳平衡——既不会让你频繁弹框让用户重登录,也不会让攻击者长期持有有效凭证。

为什么不担心 Refresh Token 泄露呢

虽然 Refresh Token 生命周期更长,但我们并不是“完全不担心”它的泄露,而是通过一系列设计和防护手段,把它的风险降到可控、可检测的程度。关键在于:

  1. 安全存储

    • HttpOnly + Secure Cookie:在浏览器端把 Refresh Token 放在只读且只能通过 HTTPS 访问的 Cookie 里,JS 无法读取,也不随非安全连接暴露。
    • 安全保管:移动端或服务端应用通常把它存在受信任的存储(Keychain、Keystore、Vault),避免曝光给前端。
  2. 绑定使用环境

    • Client Binding:每个 Refresh Token 都和特定的客户端 ID/密钥(或证书)绑定,只能由它自己来使用。即使被窃取,离开了原来的客户端就无法成功请求新令牌。
    • 设备指纹:可以额外记录设备标识(IP、User-Agent、TLS 信息等),对比不符就拒绝刷新。
  3. 旋转与一次性

    • Rotating Refresh Tokens:每次刷新后,服务端发回一个全新的 Refresh Token,并让旧的立即失效。这意味着即便被截获,也只可用一次且生命周期极短。
    • 防重放:你可以在服务端记录上一次的令牌 ID(jti),对重复使用进行拒绝。
  4. 可撤销与监控

    • 黑名单/白名单:一旦检测到异常,比如同一 Refresh Token 在两地几乎同时使用,就能主动撤销它,并通知用户重新登录。
    • 速率限制:对刷新接口加严格调用频率限制,暴力猜测或批量攻击会被挡下。
  5. 最小权限原则

    • Scope 限制:Refresh Token 可以只用于刷新令牌,不携带直接访问资源的权限。真正调用 API 还是要用短期 Access Token。
    • Audience 限定:只允许在特定的授权服务器和资源服务间使用,不要让它流到其他系统。
  6. 监控与告警

    • 实时监控刷新失败率、地理位置和设备变化。一旦发现可疑行为,自动阻断并要求重新登录。

小结

  • Access Token 泄露 -> 窃取者仅能在很短时间内调用 API,风险窗口小。
  • Refresh Token 泄露 -> 因为它存放更安全、且有绑定、旋转、撤销、监控等多重保护,就算被拿到,也很难拿去继续换取新令牌,或者只能换一次很快就失效。

通过上述多层防御,即使长期凭证(Refresh Token)生命周期更长,其风险也在可控范围内,而用户体验依然流畅。

给 Refresh Token 做的安全措施也可以用于 Access Token,为什么不用

确实可以把“Refresh Token 的安全机制”也用到 Access Token(JWT)上,但这样做往往会丧失 JWT 最大的两个优势:高效可缓存的无状态验证。具体来说:

  1. 频率 vs. 负载

    • Access Token 会在每次 API 请求中带上,服务端通常只做一次本地签名验证(HMAC/公钥验签)就可以判断它是否合法,几乎没有额外开销。
    • 如果要对每个 Access Token 都做「环境绑定」「黑名单检查」「一次性旋转」等,就要在内存/Redis/数据库里查一遍,会给高并发 API 带来很大延迟和运维负担。
  2. 生命周期与撤销策略

    • Access Token 本身被设计成短命(比如 5–15 分钟),过期后即自动失效,所以大多数场景下不需要做专门的“撤销”——你让它到期就行。
    • 如果你对 Access Token 也要做“撤销”或“旋转”,那就得维护一份「当前有效的 jti 列表」或「黑名单」,每一次请求前都要查询,这又回到状态化令牌的模式,没有必要。
  3. 缓存与性能

    • 典型做法是把公钥/JWKs 缓存在内存,签名验证不再访问外部系统,API 响应几乎无感知。
    • 一旦加入「每次验证都要打缓存层」或「读写会话表」的步骤,不仅吞吐下降,还可能引入可用性风险(缓存挂了、DB 慢了都影响 API)。
  4. 折衷做法:黑名单 + 短命令牌

    • 如果你确实需要对 Access Token 做紧急撤销,可以维护一个「最近被撤销令牌的黑名单」,但只缓存剩余有效期很短的一小部分 jti,查黑名单命中率低,性能影响也有限。
    • 再配合 Access Token 本身极短的生命周期,整体依然比把所有保护都并到每次请求上轻量得多。

总结

  • 技术上可行,把 Refresh Token 上的安全手段都搬到 Access Token 上;
  • 但实践中一般不用,因为会让每天成千上万次的 API 调用都要跑一次 DB/缓存查状态,击垮你的高性能场景。

因此,我们才把 Access Token 设计成短命+本地验签、Refresh Token 设计成长命+状态化管理,二者配合,既满足了安全、撤销、环境绑定等需求,也保证了高并发 API 的性能体验。