北川广海の梦

北川广海の梦

关于JWT遇到的一些前后端问题,用Redis进行解决

web
551
2020-03-02

第一次使用JWT这种方案。 也遇到了不少问题。以下是我的解决方案,希望可以帮助到他人。

1.解决Token过期

我的token 分为两种,一种是AccessToken,一种是RefreshToken。 AccessToken被指定用来访问受保护的API,过期时间短,我的为30分钟。 RefreshToken被用于重新获取,过期时间较长,我的为7天。 重新获取Token是通过中间件实现的。 在中间件中,拿到RefreshToken进行校验, 如果通过则直接返回新的AccessToken与RefreshToken。 RefreshToken可以视情况返回,例如RefreshToken过期时间小于一定时间就生成新的RefreshToken,也可以每次都生成新的RefreshToken。

1.1RefreshToken过期怎么办?

在上面的逻辑中,客户端必须要持续调用刷新Token的API或中间件,RefreshToken在AccessToken被刷新的同时也会被刷新。这样就能实现持续不断的保持Token可用状态。如果RefreshToken过期,说明得到这个Token的用户已经至少于RefrshToken保质期这么长一段时间内没有进行过任何请求。 那么理应要求用户重新登录,获取授权。

2.如何强制某个Token无效?

我的做法是,通过Redis存储来进行标记,过期时间就为Token本身的过期时间。 例如在用户进行登出操作之后,将用户在登出操作提交的AccessToken与RefreshToken存入Redis中。然后在中间件中进行查询,如果AccessToken或者RefreshToken存在,则说明此token是无效的,应该返回401状态码。 关于Token失效的问题,可能是这个解决方案的最大缺点了。 不过仔细一想,token的作用不正是如此吗?就如欢乐谷的套票,只要在规定时间内,持有票据的人是可以任意游玩设施的。只是我们处于业务逻辑的需要,必须让token失去作用。在我个人看来,token其实是保护Web API的一个比较好的方案。借助类似Redis之类的工具,也是不错的。

2.1 登出之前,可能存在多次RefreshToken刷新的过程,如何保证这些RefreshToken也失效?

这个问题也困扰了我很久,目前有两种思路, 一是在RefreshToken尽可能接近过期时间之后再刷新RefreshToken。缩短RefreshToken作用时间,但是这样的话可能出现用户体验感不友好,即使是真正的活跃用户,也可能会出现需要重新登录的情况。

二是在RefreshToken被创建的时候,就以UserId和User-Agent一起作为Key,RefreshToken作为Value,以RefreshToken过期时间 存入Redis当中,每次用户在进行Token刷新的时候,也更新Redis中的value。如果用户Id和UA查询的结果和Refresh携带的RefreshToken结果不符合,则拒绝刷新。

3.用户在进行改密码之类的安全操作之后,如何使所有的Token失效

与第二点的场景不同的是,用户可能在多个设备进行过登录,也就是拿到过多个token。在某一个设备上进行注销,不应该影响其他设备。所以可以直接在注销时,将所使用的token存如黑名单。而用户进行安全操作之后,则应该将用户Id存入Redis,然后在中间件中进行处理,返回401。登录的接口也稍作修改,从Redis中移除Key。

4.前端如何进行用户无察觉的Token刷新

我前端使用的是Angular。有个东西叫拦截器,可以很方便的进行Http请求的可以说叫预处理吧。 在拦截器里,我们可以监测到是否有token,和token是否过期。JWT除了签名部分是加密的,其他的部分是可以被解码的。如果token过期了,我们就在这个请求发送之前,先去服务器刷新一下新的token。然后再用新的token去继续我们的这个请求。

//这个拦截器里就有这样一个方法
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> 
//next参数有一个handle()方法,返回一个Observable对象
//如果不返回,则会使拦截器短路。

这个拦截器咋一看,非常像咱后台的中间件管道。 但是这个方法不是异步的,我也不是特别了解Js的异步。所以不能在调用next.handle()方法之前,等待刷新Token的结果。 后来我看到一个人,提供了这样一个思路。 我们先new一个Subject给拦截器,让他返回Subject。 然后再放心的去请求新的Token。拿到之后,再使原来的请求重现。

    intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
        //如果请求为刷新Token,不做处理
        if (req.headers.has("Re_T")) {
            return next.handle(req);
        }
        let token = localStorage.getItem(AccessToken);
        //如果token没在localStorage中,咱就不管了
        if (token == null) {
            return next.handle(req);
        }
        //如果AccessToken未过期,直接添加Token到请求中
        if (!this.IsTokenExpire(token)) {
            let auth = req.clone({ setHeaders: { "Authorization": "Bearer " + token } })
            return next.handle(auth);
        } else {
            let refreshToken = localStorage.getItem(RefreshToken);
            //如果RefreshToken过期,咱也不管了
            if (this.IsTokenExpire(refreshToken)) {
                this.accountExpire.emit();
                return next.handle(req);
            } else {
                //先生成一个subject对象。
                const subject = new Subject<any>();
                this.reTokenAsync(req, next, subject, refreshToken)
                 // 返回被委托的对象,让真正的业务请求隐匿起来。
                return subject;
            }
        }
    }
    // 重新获取token 
    async reTokenAsync(req: HttpRequest<any>, next: HttpHandler, sub: Subject<any>, refreshToken: string) {
        const res = await this.Refresh(refreshToken);
        localStorage.setItem(AccessToken, res.content.accessToken);
        localStorage.setItem(RefreshToken, res.content.refreshToken);
        const request = req.clone({
            setHeaders: {
                'Authorization': "Bearer " + res.content.accessToken,
            }
        });
        // 让原本请求重新出现
        next.handle(request).pipe(
            tap(data => {
                sub.next(data);  // 数据到达,转达下发
                return data;
            }, (error) => {
                sub.error(error); //数据报错,转达出错
            })
        ).subscribe();   //由于该Observable对象已经没有人去主动订阅它了。所以我们手动订阅一下
    }