关于 OAuth2.0 的 Refresh Token + Access Token 双 Token 机制的一己之见
关于 OAuth2.0 的 Refresh Token + Access Token 双 Token 机制的一己之见
前言
写这篇文章的动机是遇到了一个十分常见的需求——登录续期。在相当长的一段时间内我都对这种双 Token 机制嗤之以鼻,认为纯粹是多此一举——单 Token 可能泄露,Refresh Token 照样可能泄露。
登录续期这个需求很简单,无非就是 重新下发 Token / 延长现有 Session 有效期,与此同时也会引入很多安全问题,直到真正开始处理这些问题的时候我才意识到了“他这么设计肯定有他的理由”这回事。OK,让我们来掰扯掰扯 Session 方案到双 Token 方案的那些事。
Session 方案
Session 方案非常简单,服务端给客户端下发一个 Session ID,这个 Session ID 存放在 HttpOnly Cookie 中,HttpOnly 保证了 Session ID 不会被 XSS 攻击获取,因为它根本无法通过 JS 拿到。同时,由于 Session 是有状态的,服务端可以立刻让客户端下线,即使认证过的 Session ID 泄露了也不至于束手无策。
这种方案的登录续期也很简单,直接延长当前 Session 有效期就行,具体来说,每当用户发起一次有效请求,服务器就将 Session 的过期时间重置为当前时间加上预设时长。
好处说完了,坏处呢?
劣势
首先是老生常谈的扩展性问题,Session 需要存储在本地内存或者类似 Redis 的外部集中缓存中,前者必须保证用户请求都被分配到同一个服务器上,但会限制负载均衡策略,且导致故障转移时丢失会话,后者增加了网络开销。
你可能会说:我能保证我绝对不会扩展!那,每次更新你总得重启服务吧,如果把 Session 存在 Redis 又多了点网络开销。你说你不在意,嘛,行叭……
然后是安全性,为了用户体验 Session 有效期一般不会太短,在使用了上文提到了登录续期之后一个 Session 可以存活相当长的时间。此时一旦 Session ID 泄露后果不堪设想,攻击者可以从容不迫地做他的小动作,而你可能完全察觉不到。为了解决这一问题,通常可以采用这些办法:Session ID 异常检测比如现在常见的异地登录(但是实现复杂)、给 Session ID 设置强制过期时间(但会严重影响用户体验)、敏感操作二次验证(但这个其实不属于这里的主题)等。
可以看到并没有什么低成本的 Session ID 泄露检测措施……
单 Token 的 JWT
为了解决扩展性问题,有人想到了把用户信息和签名信息用 JWT 弄成一个 Token 放在客户端的办法,这样服务端就完全不用存放这些 Session ID,鉴权的时候只需要通过签名验证一下 JWT 是否有效,然后从 JWT 取出数据即可。于是不管负载均衡服务把用户带到了哪个服务端上,都可以照常进行服务。
然し,这种方案完全没有解决 Session 的安全性问题,反倒引入了更多安全问题。
比如,为了保证用户体验,Token 有效期一般不会太短,结合上述登录续期那更是有可能出现 Token 泄露之后就无计可施的场景,因为你完全没有任何手段使用户登出。为了获得对 Token 的控制权,我们可以用 Redis 存一下黑名单 Token……吗?真这么做了不就又走回了有状态的老路吗……
于是相信机智的你一定想到了,我把 Token 的访问有效期和续期有效期分开,让访问有效期短点,只在续期的时候验证这个 Token 是否合法,不就行了吗?这样既解决了每次调用接口都要访问 Redis 的问题(变成了只在续期的时候访问 Redis),又解决了扩展性的问题。另外,我也可以把 Token 放在 HttpOnly 且 SameSite=Strict 的 Cookie 里,防止 XSS 和 CSRF 攻击。
你说没有泄漏检测机制?我在 Token 里加一个 family 字段,所有由这个 Token 续期得到的新 Token 都会携带相同的 family,旧的 Token 续期时即被撤销,服务端要是检测到有人试图用一个旧的 Token(判定方法为同一 family,这种行为可能出现在攻击者拿到了 token 并续期得到了新 token,用户尝试用旧 token 续期)续期,立刻触发安全机制,把这个 family 对应的 Token 撤销(从 Redis 里删了),简直安全性拉满!
恭喜你,离 AT+RT 双 Token 方案只有一步之遥了!
Access Token + Refresh Token
我们不妨更进一步,把这个单一的 Token 中用于访问的部分拆分为 Access Token,续期的部分作为 Refresh Token,整个实现思路大致如下:
Access Token 即常规访问 Token,短有效期,可以放一些用户数据,同时可以把 Access Token 放在请求头,简化 CORS 配置同时防止 CSRF 攻击(但 Access Token 放在内存或者 Web Storage 里又有可能会导致 XSS 攻击)。
Refresh Token 专用于登录续期,会在 Redis 里存储一份,当且仅当客户端提供的 Refresh Token 存在于 Redis 才算有效。
Refresh Token 具有一个 family 字段,所有由这个 Token 续期得到的新 Token 都会携带相同的 family,旧的 Token 续期时即被撤销,服务端要是检测到有人试图用一个旧的 Token(判定方法为同一 family,这种行为可能出现在攻击者拿到了 token 并续期得到了新 token,用户尝试用旧 token 续期)续期,立刻触发安全机制,把这个 family 对应的 Token 撤销(从 Redis 里删了)。Refresh Token 可以通过 HttpOnly + Samesite=Strict 的 Cookie 下发,还可以通过 Path 控制作用域,防 CSRF 和 XSS。
这么做有几个好处:
- 不论是普通的资源服务还是鉴权中心,都只需要验证自己拿到的 Token 的 exp 字段和签名,验证的逻辑相同
- 语义更加清晰,权限更加明确,你的流量可能经过网络上 CDN、WAF 甚至是第三方业务服务,让一个不需要续期权力的服务接触到具有续期能力的 Token 并不安全,此时只让不受信任的服务接触 Access Token 显然风险更小
- (方案比较成熟,方便抄作业……但感觉在这里这么写会挨打……)
关于安全性,Access Token 即使泄露了,留给攻击者使用的时间也很短,Refresh Token 泄露有机会被检测到,有足够的战略纵深(
尾声
为什么出现了双 Token 方案?简而言之就是因为人们既想要 Session 的控制权,又想要单 JWT Token 的无状态和性能,同时还希望有一定的安全性。以前看了很多文章,都在重复说什么单点登录,什么资源服务器,但这类场景本身仅存在于一些较大规模的项目,不能解释为什么 AT+RT 方案应用如此广泛,因此一直想不通为什么要这么复杂,真正到自己上手设计的时候才意识到又重复发明了一遍类似的方案。
当然,这也只是我近几天在实践中得出的一些看法,可能会有疏漏,多多指教。