• 什么是双token无感刷新?
  • 发布于 2个月前
  • 120 热度
    0 评论
前言
平时使用中,是否有过这样的场景,我们经常使用的 app 很久很久都不会让我们重新登录,但如果我们一个礼拜没上线,就需要重新上线了,这就是本篇文章文章要提到的 双token无感刷新 了。

本篇会使用 nestjs + react(umi-request) 做一个示范,让大家体会一下双 token 的魅力。

双token无感刷新
双 token 就是两个 token,当用户登陆的时候,服务端会给客户端两个 token,一个token一般期限为 0.5小时(个人也是感觉太短,自己根据情况,可以适当延长1小时1天都行,越短安全性越高)。

token就是令牌,带着token相当于我们拿着身份证似的,后端可以直接办理事情,客户端使用最短的 token 用来与后台交互,长的token 作为 更新token的时候使用,这样半个小时后,客户端的短token过期时,就用长 token 获取新的 两个token,然后客户端拿着新的 短token 重发原来请求即可。

这样单token也可以,为什么要双token?

单token等过期了就没办法续了,不过期客户端就得保存一个时间戳,在过期之前更换新的 token,但很可能也会出现类似的情况,假设期限7天,你设置5天期限更新,但第六、七天用户没更新,那么第八天就需要重新登录,用户感觉两天没上线就要重新登陆,期限短点也行,但仍然出现另外一个问题,如果你这个 token 被别人劫持拿到了,那么就可以肆意挥霍好几天了,安全性就出现问题了(例如某个场景:某人复制了浏览器存放的信息,到别人那里,就可以简单操作拥有那人的权限了,并且普通人也可以做到),如果是双 token 呢,一般短的,持续时间为 30m,别人即使拿到了,也会很快到期,还得重新想办法拿新的,这就无形之间增加了成本(针对上面案例,对于使用漏洞者的要求更高了),双token的优势就出现了,并且无需前端设置时间校验,只需要出现 401 的时候,重新调用接口,重发请求即可,也方便后台调整过期时间,另外,后台本来也是每次都需要校验token,因此逻辑上算是无缝支持,两端逻辑都很简单。

并且双token比单token除了安全性,逻辑上整体也要简单严谨不少,因此很多人都在使用这一方案,这也是用户体验、开发体验、应用安全的之间互相博弈平衡的结果(当然这也只是针对部分场景出现的方案,未来也许还会改变或者有新的方案,你也会发现,每次新增改变都可以规避一种风险,那同时也会增加开发成本)。

nestjs 代码
除了登录返回两个 token,刷新也是返回两个 token,jwt 的 guard 代码就不写了,那里默认就和之前一样。

话不多说直接上代码:
@ApiOperation({
    summary: '登陆' //堆代码 duidaima.com
})
@Public() // @UseGuards(UserGuard) @Guards()
@APIResponse(TokenDto)
@Post('login')
login(
    @Body() loginInfo: LoginDto,
) {
    return this.authService.login(loginInfo);
}

@ApiOperation({
    summary: '刷新'
})
@APIResponse(TokenDto)
@Post('refresh_token')
refreshToken(
    @ReqUser() user: User,
) {
    return this.authService.refreshToken(user);
}

generateToken(
    user: User
) {
    return {
        access_token: this.jwtService.sign({
            id: user.id
        }, {
            expiresIn: '0.003h' //1小时过期,这里短点方便验证
        }), //将token返回给客户端
        refresh_token: this.jwtService.sign({
            id: user.id,
        }, {
            expiresIn: '7d' //7天过期
        })
    }
}

async login(
    loginInfo: LoginDto,
) {
    let user = await this.userRepository.findOne({
        where: {
            account: loginInfo.account,
        },
    });
    if (!user || user.password !== loginInfo.password) {
        return ResponseData.fail('用户名或者密码不正确')
    }
    return ResponseData.ok({
        user,
        ...this.generateToken(user)
    })
}

async refreshToken(
    user: User,
) {
    //获取新token
    return this.generateToken(user)
}
这样我们的后台就写好了,就等前端来测试了。

前端代码
使用 umi-request 先顶一个一个全局 request 把,实际使用一般用代理,prefix一般不写,或者只写接口前缀
export const request = extend({
    prefix: "http://localhost:3000/api",
    timeout: 15000,
    requestType: 'form',
});
再写个登录和刷新的接口
export async function loginUser(account: string, password: string) {
    let res = await request.post('/user/login', {
        data: {
            account,
            password
        }
    })
    if (Math.floor(res.status / 200) === 2) {
        localStorage.setItem('access_token', res.data.access_token)
        localStorage.setItem('refresh_token', res.data.refresh_token)
    }
    return res
}

export async function refreshToken() {
    let res = await request.post('/user/refresh_token', {
        headers: {
            'token': localStorage.getItem('refresh_token') || ''
        } || {},
    })
    localStorage.setItem('access_token', res.data?.access_token)
    localStorage.setItem('refresh_token', res.data?.refresh_token)
    return res
}
比那些我们的请求拦截器,当 header中不存在 token 时,我们加入 token 即可(登陆时加不加都不影响,不用可以加代码)。
//请求request请求数据拦截器,其发生在我们写的请求之后,实际发出请求之前
request.interceptors.request.use((url, options) => {
    //假设请求钱我们需要将一些令牌统一放到header中(根据实际需要处理)
    //将token和加密后的sign放到一起
    let headers: any = options.headers
    if (!headers.token) {
        headers['token'] = localStorage.getItem('access_token')
    }
    //设置完成后,要返回我们的的参数
    return {
        url,
        options: {
            ...options,
            headers
        }
    }
})
编写我们的响应拦截器,当 401 时,我们就拦截我们的响应,这时我们就开始使用长token 直接刷新token,为什么上面要加入判断不包含刷新接口呢,为了避免我们的 长token 也过期,那时再 401 就死循环了,或者后台故障也会死循环。

这里会发现我们刷新了token后,然后直接调用了我们原来的接口(这样就可以无缝衔接了),只不过 umi-request不可以直接重发,我们就直接根据类型请求我们的接口就行了。

这样就完了么,并没有,这样我们的请求的 401 的接口就真的 401 失败了,前面 两个await 就是为了能够让新token获取的新内容,覆盖掉初始调用接口的response(前提是我们的接口要设置 getResponse: true),这样就会额外返回一个 interceptors 中独有的 response了,然后我们直接返回新的 reponse,用户真的无感刷新了(ps:如果失败了,也只是正常失败,而不是401,除非两个token都过期才会401)。
request.interceptors.response.use(async (response, options) => {
    if (response.status === 401 && !options.url.includes('/refresh_token')) {
        //为什么await,因为这样,上一个401的请求就会被阻塞,我们此时直接重新refresh,并且重新请求
        let res = await refreshToken(options)
        //处理请求,没看到直接重新请求的类
        //这里code是我专门给接口准备的罢了,就是为了区分接口,实际上使用自己的成功码即可
        if (res.code === 1) {
            if (options.method === "GET") {
                let res = await request.get(options.url, {
                    params: options.params,
                    headers: {
                        ...options.headers,
                        token: ''
                    },
                    getResponse: true
                })
                return res.response
            } else if (options.method === "POST") {
                let res = await request.post(options.url, {
                    data: options.data,
                    headers: {
                        ...options.headers,
                        token: ''
                    },
                    getResponse: true,
                })
                return res.response
            }
        }
    }
    return response;
})
看看运行效果,虽然浏览器有 401 出现,但是接口数据正常返回了,就说厉害不厉害

最后
需要的话就用起来吧,稳得一批,如果是前端,这个长 token 就别七天了,最好一两天就过期,避免别人登陆自己的账号搞事情,哈哈。
用户评论