相关
之前写过关于token的文章Token - 服务端身份验证的流行方案,是基于当时的实现方案来写的。后来进行了设计的review,被提出有下面的问题:
- 如果token的算法泄漏,别人可以伪造出token,而服务端无法识别
- 刷新token时使用了token和摘要,但是对摘要没有过期的机制。
- 无法做到互斥登录,同一个账号可能在多个设备上登录。
新方案
针对上面的问题,我拉来公司的两位同事进行讨论,将得出的方案作为token2.0版本。
如何做到防止伪造,就是说即使算法泄露,服务端也能识别出来。要做到这一点,服务端就要缓存token的存根,当请求到达时,比较请求中的token和服务端同一个用户的token存根是否匹配。如果匹配,验证通过;如果不匹配,表示token是伪造的;或者账号已经在另外的设备上登录,从而挤掉了当前的token,让用户重新登录。所以这个方案,顺便解决了第3个问题。
服务端缓存token,可以使用redis,以userId为key,以详细用户数据和token为value。收到请求时,需要从token中解析出userId,这样才能进一步根据userId从缓存中查数据。但是有一个问题是如何判断token的有效期。
在前一篇文章中,从token解析出token的创建时间,然后加上有效期,跟系统的当前时间进行比较。如果大于当前时间,表示有效,否则表示过期。但是既然服务端已经把token放入缓存了,可以直接使用缓存的有效期来表示token的有效期。一句话:如果根据token解析出来的userId从缓存中查不出来什么,表示token已经过期。
参考了微信登录,他们是基于OAuth2.0标准来做的。当时也对OAuth2.0进行了调研,结果是它是用来为第三方请求资源时提供的一种授权和身份验证机制。简单来说,它的时序图是下面的样子:
因为我们需要维护自己的用户体系,而且只有一个系统,没有必要搞这么多事,所以最后决定放弃OAuth2.0,自己实现身份验证机制,这其实也是参考了之前的一个项目的做法。
微信登录中提到了两个token:access_token和refresh_token。access_token用来请求业务的时候进行身份验证。当access_token过期时,可以使用refresh_token来刷新token(即请求新的token),直到refresh_token也过期了,那么第三方应用需要重新请求授权。access_token有较短的有效期,一般2个小时,refresh_token有效期较长,有30天。
映射到我们的需求里,就是APP登录的时候,生成access_token和refresh_token,一同返回给APP。当access_token失效时,APP使用refresh_token来请求刷新token。如果refresh_token过期,需要用户重新登录。这里提到的refresh_token,和上文中所说的摘要是同一个作用。
这个方案用来解决第2个问题,但是针对refresh_token的有效期的实现,我是有点纠结的。如果像access_token一样,使用服务器缓存来控制refresh_token的有效期,redis需要缓存一个月的时间,注意是每一个用户。
所以我选择了另外的方案,即对于refresh_token,我们采用之前的方案。根据解析出来的创建时间,加上配置的有效期,跟当前时间做对比。从而判断是否已经过期。
这样又会有前面提到的问题,如果refresh_token的算法泄漏,那么别人可以自己生成refresh_token来做任何事。面对这个问题,实现机制在刷新token时会验证token的缓存是否依然存在,如果存在就拒绝刷新token。但是在用户2个小时没有请求的情况下,其access_token已经从缓存中移除,这个时机就不能防止伪造的refresh_token了。所以我们只是缓解了这个问题,并没有真正解决它。
针对这个问题,我们有另外的一些想法:
为什么算法会泄漏?内部的工作人员职业道德有问题,那他可以直接修改数据库啊,还搞什么伪造?说到底这是一个员工职业道德的问题,是防不住的。
这段话说服我了,当前的工作还有很多,这一块不是最紧急的,暂时就这样吧。我总结下关于refresh_token的两种做法,供读者们根据自己的情况选择:
- refresh_token也在服务端缓存,refresh_token的有效期过长,缓存中有大量的长期数据。
- refresh_token基于算法来判断是否过期,在用户的token已过期的情况下,别人可以用伪造的refresh_token来刷新token,从而请求资源。
设计
生产token
用户在注册、登录之后,服务器根据userId、devicecode[1]来生成accessToken和refreshToken,其中accessToken放入缓存中,以userId为key。服务端返回accessToken和refreshToken给app。
[1] devicecode是设备指纹,由app端提供。作为生成token的干扰码的一部分,另外一部分是服务端的配置项。
token验证
在拦截器中拦截,从请求数据中读取accessToken和devicecode,解密出userId。对于之后的几种状态,做一下解释:
- accessToken无效
解析失败,accessToken和devicecode不匹配,应该让用户重新登录 - accessToken过期
根据userId获取缓存失败,APP应该尝试发起刷新token的请求。 - accessToken不匹配
缓存中的accessToken和请求中的accessToken不匹配,应该让用户重新登录
通过验证的进行业务处理,否则返回拒绝及拒绝的原因给app。
还有一点值得一提,服务端提供的接口中,有需要登录的,有不需要登录的,还有一种是可选的,即如果登录了能返回更详细的信息;否则只返回基本的信息。比如说排行榜,如果用户没有登录,那就是返回简单的排行榜;如果用户已经登录了,服务端还会告诉你排行榜中哪一个是你。
所以token的身份验证,拆分到了两个拦截器,一个拦截所有请求,负责解析token;一个拦截需要登录的接口,拒绝未登录用户的请求:
accessToken解析拦截器的设计思想:
拦截所有的请求。请求中可以没有accessToken,但是只要有,就必须保证合法、没有过期、和缓存中的存根匹配,否则就滚。
登录拦截器的设计思想:
只拦截需要登录的请求。请求必须通过了accessToken解析拦截器,而且是有accessToken。
刷新token
服务端从请求中读取refreshToken和devicecode,解密出userId。
解密失败,直接返回失败信息。
解密成功,但是根据userId可以从缓存中读到accessToken,表示accessToken还未过期,不应该请求刷新token,所以也会直接拒绝。
解密成功,但是refreshToken已经过期,拒绝。
ok的话就生产token,其中accessToken是一定重新生成的,但是如果refreshToken不是即将过期的情况下,是直接返回同一个refreshToken,而不是重新生成。这是为了避免服务端产生过多的有效的refreshToken,如果refreshToken相当于对app端的授权,服务端不应该无限制的发放授权,只有在前一个授权快要过期了,才发一个新的。