无感刷新token,延长token时长--实践

802 阅读3分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第1天,

为什么要刷新token

token在2h会过期,过期后就要重新进行登录。

这两个小时是根据用户打开页面登录后计算的,中途如果两个小时都没使用,再次进来就要重新登陆。

长期使用用户也需要频繁登录,造成困扰。

如何刷新token

看到了很多方法,大致有以下几种,每种方法都有优劣。

方法一: 后端返回过期时间,前端判断当前token是否过期,过期请求刷新token。

缺点:恶意篡改时间就会拦截失败;后端需要额外提供一个token过期时间字段

方法二:定时器,定时刷新token

缺点:消耗资源

方法三:在响应拦截器中,判断token过期后,调用刷新token接口

缺点:如果发多个请求,token过期后就会使请求时间延长,请求超时用户体验不好。

方法四:改良版方法三。在响应拦截器中写节流定时器,调用刷新token接口。

方法五:纯后端:使用两个token,一个是真正的access_token时效短(2h),另外一个refresh_token时效长(7day)用于刷新token。用redis存储access_token,设置过期时间。

情况1:refresh_token过期,重新登录获取access_token

情况2:refresh_token没过期,access_token过期,不用登录,后端刷新access_token。

一开始我是用了方法4,后来考虑不想让前端涉及这块,就实践了方法五。以下放下两种方法的实践。

实践--方法四:改良版方法三。在响应拦截器中写节流定时器,调用刷新token接口

前端部分

util.js。一个节流函数

/**
 * 节流函数
 * @param {Function} fn
 * @param {Number} wait
 * @returns {Function}
 */
 export function throttle(fn, wait) {
  var context, args
  var previous = 0

  return function () {
    var now = +new Date()
    context = this
    args = arguments
    if (now - previous > wait) {
      fn.apply(context, args)
      previous = now
    }
  }
}
token.js 刷新token方法,如果过期会返回新的token
import { throttle } from './util'
import store from '@/store'

import storage from 'store'
import { refreshToken } from '@/api/user'
import { ACCESS_TOKEN } from '@/store/mutation-types'

export const refreshAccessToken = throttle(async function() {
    const token = storage.get(ACCESS_TOKEN)
    try {
        const res = await refreshToken(token || '')

    console.log('refreshAccessToken', res)
        if (res.code === 0) {
        // // 设置客户端的token
        storage.set(ACCESS_TOKEN, res.data, 2 * 60 * 60 * 1000)
        // // 设置vuex的token
        store.commit('SET_TOKEN', res.data)
        }
    } catch (e) {
        console.log(e)
    }
}, 1000*60*30)

export default refreshAccessToken
request.js 在拦截器中调用
// request interceptor
request.interceptors.request.use(config => {
  const token = storage.get(ACCESS_TOKEN)
  // 如果 token 存在
  // 让每个请求携带自定义 token 请根据实际情况自行修改
  if (token) {
    config.headers.Authorization = 'Bearer ' + token
  }
  if (!config.url.startsWith('/user/login')) {
    refreshAccessToken()
  }
  return config
}, errorHandler)

后端代码

user.js
我token是使用jwt签发的,jwt.verify用于验证之前token是否过期,每30min刷新token返回前端,如果token过期就说明用户超过2h30min没有shi'y要重新登陆了。
async refreshToken(token) {
    const { app } = this;
    try {
      const decoded = app.jwt.verify(token, app.config.jwt.secret);
      console.log('decoded', decoded);
      return app.jwt.sign({
        ticket: decoded.ticket,
        accountId: decoded.accountId,
        yachid: decoded.yachid,
        teamId: decoded.teamId,
        name: decoded.name,
      },
      app.config.jwt.secret,
      {
        expiresIn: '50s',
      });
    } catch (e) {
      this.ctx.loggerError(e, 'YACH', '刷新token');
      return {};
    }

  }

方法五:纯后端:使用两个token,一个是真正的access_token时效短(2h),另外一个refresh_token时效长(7day)用于刷新token。用redis存储access_token,设置过期时间。

后端使用node写的,使用的egg.js.这里的ticket对应的是access_token,jwt生成的token是refresh_token。

思路:

  1. 登录时ticket存在redis中,过期时间设置2h

  2. 在需要ticket的接口先进行刷新判断操作,刷新ticket方法写在中间件中

  3. 刷新ticket方法内容,判断ticket是否快过期或者已过期,如果是则重新请求ticket存储。

service/user.js

这里controller代码就省略不放出来了


//用户登录后
  async login(params) {
    const { app } = this;
    try {
      const result = await this.request(`${this.serverUrl}/api/v1/sso/verify`, { data: params });    //获取用户信息接口
      if (result.errcode === 0) {
        const ticket = params.ticket,
          accountId = result.data.account_id;
          //查找用户如果数据库中没有就插入
        const data = await this.findOrInsertUserInfo(ticket, accountId);  
        // redis中存储ticket
        await app.redis.set('ticket', ticket, 'EX', 60 * 60 * 2);
        //签发一个refresh_token,七天后过期,过期后请求状态码都返回401
        return app.jwt.sign({
          ticket,
          accountId,
          yachid: data.yachid,
          teamId: data.teamId,
          name: data.name,
        },
        app.config.jwt.secret,
        {
          expiresIn: '7d',
        });
      }
      this.ctx.throw(403, result.errmsg);
    } catch (e) {
      this.ctx.loggerError(e, 'YACH', '验证用户');
      return {};
    }

  }
  
  
  
  //获取ticket
   async getTicket() {
    const { appid, appkey } = this.yachConfig;
    try {
      const result = await this.request(`${this.serverUrl}/basic/get_ticket`, { data: { appid, appkey } });
      if (result.errcode === 0) {
        return result.ticket;
      }
      return '';

    } catch (e) {
      this.ctx.loggerError(e, 'YACH', '获取ticket');
      return '';
    }
  }

middleware/refreshToken.js

'use strict';
let state = true;
module.exports = (options, app) => {
  return async function(ctx, next) {
    const res = await app.redis.ttl('ticket');
    if (res < (60 * 10) && state) { // 濒临过期时间
      // 更新ticket
      state = false;
      const ticket = await ctx.service.user.getTicket();
      ctx.state.user.ticket = ticket;
      // 重新设置redis中ticket为2h
      await app.redis.setex('ticket', 60 * 60 * 2, ticket);
      state = true;
    }

    await next();
  };
};

router.js

'use strict';

/**
 * @param {Egg.Application} app - egg application
 */
module.exports = app => {
  const { router, controller } = app;
  //引入中间件中刷新方法
  const refreshToken = app.middleware.refreshToken();

  // user
  //在指定路由(需要用到ticket的接口)用刷新方法
  router.get('/api/user/getUserInfo', refreshToken, controller.user.getUserInfo);
  router.get('/api/user/updateUserInfo', refreshToken, controller.user.updateUserInfo);

};

总结

最后用了方法五,个人觉得挺合适,前端不用涉及,也不会像定时任务那么消耗性能。