SSO 我调你老味 04: 过期控制和 JWT

白白是一名现居上海市的人士。在本文中,她终于意识到了安全性还是挺重要的。

前言

在支持用户注册以后,service 一炮打响,地球人都知道。

还没来得及为滔天的流量高兴,service 的副作用就出现了。

谁动了我的奶酪?

有了 access_token,用户们不需要每次刷新网页都输入一遍密码了,好。

但是有人发现这个 access_token 没有有效期限,坏。

然后就有人发现自己的帐号被不知道谁盗用了,坏坏。

不知道怎么清除 cookie 的用户发现退出重进了以后还是会被别人盗号,坏坏坏。

我家走上了信息土石路!

因为每次验证 access_token 都要涉及数据库查询,登录速度慢得天怒人怨。

小白的好诡秘全都找她吐槽来了,她恨不得自己从来没有开放过注册功能。

Solution?

在一阵搜索以后,小白惊奇地发现两个问题居然可以一次解决,这个方案就是

JWT!

JWT 的全名叫 Json Web Token,是一种无状态的令牌。

状…状态?

传统的会话是有状态的 (stateful)。在 01-03 的系统设计中,基于 access_token 的会话也是有状态的。

这个「状态」,指的是服务器会保存与会话有关的信息。这个方法的好处和坏处都很明显:

  • 好处:会话发出的请求可以只包含本次请求需要的简短内容。
  • 坏处:服务器需要对于每个会话保存相应的信息,极大地减小鲁棒性和处理效率。

例如,要完成对某项机密资源的访问,有状态的请求可能会长这样:

1
2
3
/secret/super-secret-resource

Session-ID: awawawawowowowowo

而无状态的 (stateless) 的请求可能会长这样:

1
2
3
4
5
6
7
/secret/super-secret-resource

Role: admin
Name: abaibai
Id: 0x0d000720
LoginTime: Sun Jun 8 00:00:00 GMT+08:00 (China Standard Time)
LoginExpiresAt: Wed Sep 17 23:59:59 GMT+08:00 (China Standard Time)

(注意:这个请求是被解码的,并且一定不是安全的最佳实践。一般的服务会使用 JWT 进行无状态的管理,这将是接下来要讨论的内容。)

那我问你,无状态就这么好吗?

使用像 JWT 这样的无状态令牌的好处和坏处也都很明显。

  • 好处:服务器资源对 JWT 是八十万对六十万,优势在我(这是真的),可以极大地减少服务器的运算需求。
  • 坏处:如果用户在 JWT 预定失效时间前注销,撤销 JWT 的有效性是一个问题。

总体而言,JWT 的好处大于坏处,所以多了不说少了不唠,开干!

JWT 的组成呢那我问你

JWT 在解码后由三部分组成:header, payloadsignature

header 一般包含 JWT 的加密算法和类别。一般来说类别就是 JWT

payload 一般包含 JWT 的主要数据。

signature 包含认证服务器对 JWT 的签名,不然 JWT 该什么阿猫阿狗都能生成了。

那我问你,你的 Redis 怎么亮亮的?

解决 JWT 令牌的有效性实时更改问题的一个可行方法还是拿数据库,而且是「快的」数据库。

Redis 是 in-memory 的,再快也没它快了,美塌咧。

小白决定让 JWT 的 payload 包含一个 ID (jti),比如 aaaaaaaa-bbcc-ddee-ff00-123456789abc

按照 RFC 7519 的 §4.1,小白决定再给 JWT 加一个 exp 字段,用来规定 JWT 的到期时间。

这样,JWT Payload 解码后大概会长这样:

1
2
3
4
5
6
{
  "exp": 1758114514,
  "sub": 123456789,
  "name": "abaibai",
  "jti": "aaaaaaaa-bbcc-ddee-ff00-123456789abc"
}

假如用户在 time = 1758113514, 也就是 Wed Sep 17 2025 20:51:54 GMT+0800 (China Standard Time) 时注销了,那么就把这个 ID 加到 Redis 数据库中,标记它是无效的,并且将它在 Redis 数据库的 TTL 设置得比 exp 时间戳长一小点,例如 1758114524

接下来,在每次验证的时候向 Redis 数据库查询 id 是否存在,如果存在就阻止用户的登录就行了。

小白觉得这个想法特别好,但是上线了一段时间以后发现盗号现象还是时有发生:exp 还是太长了,只要攻击者能在这段时间内拿到 JWT,那么还是可以伪装普通用户的身份。于是小白决定使用

短期 Access Token + 长期 Refresh Token!

既然问题出在 exp 太长,那把 exp 调短不就行了嘛。

但是这样带来的问题是一般通过的、携带 Access Token 的用户也会需要频繁登录,这样小白说不定会被喷死,坏。

这个时候她的救星来了:Refresh Token!

首先,小白在用户每次登录的时候都创建一个 Refresh Token,并且让其具有较长的有效期(例如 7 天)。这个 Refresh Token 会以 HttpOnly; Secure; SameSite=strict 的 Cookie 方式存储在本地。

此后,对于每个受保护的页面 /protected/page,使用 use client,这样就可以使用 React State 来管理 Access Token 了,这样:

  • 攻击者一般拿不到 Refresh Token: JS 拿不到存有 Refresh Token 的 Cookie.
  • 攻击者就算拿到了 Access Token,exp 也很短;更不用说拿到 Access Token 也是很难的。
  • 如果攻击者非常不幸地两个都拿到了,那就该直接 sudo rm -rf /* 跑路了(

Refresh Token 会在页面对后端请求失败的时候出手:如果失败的原因是 Access Token 过期,而 Refresh Token 仍然有效,那就直接用 Refresh Token 向 /api/auth 发出请求,更新 Access Token 就行。

这下,小白感觉这个 service 的认证管理应该够安全和强力 (robust) 了,她应该考虑点别的了。

以 CC BY-NC-SA 4.0 许可证分发