基于JWT token 及 AUTH2.0 refresh_token的前后端分离验证模式

时间:2024-03-16 13:00:11

前后端分离的登录验证

我们的程序一般是通过微信扫码来进行登录的,但是在接进前后端分离之后,发现登录验证过程不是很友好,于是查了一些资料。比较推荐用JWT来做一个token的验证实现登录,但是有些文章提到,JWT token会有token失效时间过短造成要重新登录的问题。考虑到这个,参考一些文章在jwt的基础上添加了auth2.0中的refresh token的机制。

关于代码

我们的前后端架构是flask + npm + iview。

验证流程图

为方便理解整个过程的逻辑,特画了下面这个图。
基于JWT token 及 AUTH2.0 refresh_token的前后端分离验证模式

实现代码

JWT token 生成模块

jwt不在这里详解,可以查阅相关资料,构造为 header,payload和Signature。我的代码中是通过itsdangerous模块的TimedJSONWebSignatureSerializer这个JWT生成器来生成jwt token的

access_token,用于登录验证的

from itsdangerous import TimedJSONWebSignatureSerializer as Serializer
def genAccessToken(workId,expires=86400):
    s = Serializer(
        secret_key=current_app.config['SECRET_KEY'],
        expires_in=expires
    )
    return s.dumps({
        'workId': workId,
        'iat': time.time()
    })

secret_key 是生成 Signature的加密字符串 ,用于签证
expires_in 是这个token的过期时间,这里设置为86400秒,即一天
dumps函数中的字典结构是jwt的payload部分,也是我们的有效信息载体部分

refresh_token,用于刷新access_token

鉴于access_token有超时时间,而且为了安全,access_token的超时时间不能过于太长,所以参照auth2.0 的 refresh token机制,这里也添加一个refresh token来讲access_token进行刷新,超时时间为5天

def genRefreshToken(workId,expires=432000):
    s = Serializer(
        secret_key=current_app.config['SECRET_KEY'],
        expires_in=expires
    )
    return s.dumps({
        'workId': workId,
        'iat': time.time()
    })

超时时间设置为5天,也就是说,在refresh token生成后的5天内,access_token一旦超时,那么将会重新生成一个新的access token用于验证

前端的登录信息存储

我们用vuex这个前端的状态管理 来存储验证后的用户信息和验证信息
store/module/user.js:

  state: {
    userName: '',
    firstName: '',
    workId: '',
    access_token: getAccessToken(),
    refresh_token: getRefreshToken(),
    access: ['super_admin'],
    hasGetInfo: false
  },

为避免关闭页面后,token被销毁,我们把access token 和 refresh token存放在浏览器的缓存中
libs/util.js:

export const TOKEN_KEY = 'access_token'
export const REFRESH_TOKEN_KEY = 'refresh_token'
export const setAccessToken = (token) => {
  Cookies.set(TOKEN_KEY, token, {expires: config.cookieExpires || 1})
}
export const getAccessToken = () => {
  const token = Cookies.get(TOKEN_KEY)
  if (token) return token
  else return false
}
export const setRefreshToken = (token) => {
  Cookies.set(REFRESH_TOKEN_KEY, token, {expires: config.cookieExpires || 5})
}
export const getRefreshToken = () => {
  const token = Cookies.get(REFRESH_TOKEN_KEY)
  if (token) return token
  else return false
}

前端的登录,路由钩子beforeEach

在路由钩子beforeEach中,判断是否有登录信息
router/index.js:

router.beforeEach((to, from, next) => {
  iView.LoadingBar.start()
  const token = getAccessToken()
  if (!token && to.name !== LOGIN_PAGE_NAME) {
    // 未登录且要跳转的页面不是登录页
    next({
      name: LOGIN_PAGE_NAME // 跳转到登录页
    })
  } else if (!token && to.name === LOGIN_PAGE_NAME) {
    // 未登陆且要跳转的页面是登录页
    next() // 跳转
  } else if (token && to.name === LOGIN_PAGE_NAME) {
    // 已登录且要跳转的页面是登录页
    next({
      name: homeName // 跳转到homeName页
    })
  } else {
    if (store.state.user.hasGetInfo) {
      turnTo(to, store.state.user.access, next)
    } else {
      store.dispatch('getUserInfo')
        .then(user => {
          // 拉取用户信息,通过用户权限和跳转的页面的name来判断是否有权限访问;access必须是一个数组,如:['super_admin'] ['super_admin', 'admin']
          turnTo(to, store.state.user.access, next)
        })
        .catch(() => {
          setAccessToken('')
          next({
            name: 'login'
          })
        })
    }
  }
})

当在状态管理中没有找到登录信息后,跳到/login到flask进行登录验证

后端flask login接口

login接口在处理一下信息后生成两个token返回给前端

@blueprint.route('/login', methods=['GET'])
def login():
    resp_data = json.dumps({
        'token': genAccessToken(workId).decode("utf-8"),
        'refresh_token': genRefreshToken(workId).decode("utf-8")
    })
    return Response(response=resp_data, status=200, mimetype="application/json")

前端收到token, 存放到缓存中

handlePolarLogin({ commit },info) {
  return new Promise((resolve, reject) => {
    polarLogin(info).then(res => {
      const data = res.data
      commit('setAccessToken',data.token)
      commit('setRefreshToken',data.refresh_token)
      resolve(res)
    }).catch(err => {
      reject(err)
    })
  })
}

使用access token获取数据过程分析

前端在拿到access token之后,后续前端获取数据的请求中都要带上access token。后端的接口则需要判断access token是否超时,payload中的信息是否正确。

前端请求携带access token

为了让每个请求都带上access token,需要在前端的请求拦截器中将access token放到请求的header中

class HttpRequest {
  ...
  getInsideConfig () {
    const config = {
      baseURL: this.baseUrl,
      headers: {
        'Authorization': store.state.user.access_token
      }
    }
    return config
  }

后端验证access token

后端收到请求后,需要验证access token,因为大部分接口都需要验证,所以我们可以将这个验证过程写成一个装饰器

def accessTokenAuth(func):
    @wraps(func)
    def wrapper(*args,**kwargs):
        token = request.headers.get('Authorization')
        if not token:
            return jsonify(u'access token 不存在验证信息!'), 251
        s = Serializer(
            secret_key=current_app.config['SECRET_KEY']
        )
        try:
            data = s.loads(token)
        except SignatureExpired:
            return jsonify(u'access token超时!'), 253
        except BadSignature as e:
            encoded_payload = e.payload
            if encoded_payload is not None:
                try:
                    s.load_payload(encoded_payload)
                except BadData:
                    return jsonify(u'access token被篡改!'), 251
            return jsonify(u'access token错误的验证信息!'), 251
        except:
            return jsonify(u'access token验证失败,未知的错误!'), 251
        if ('workId' not in data):
            return jsonify(u'access token错误的信息载体!'), 251
        if func.__name__ == 'getUserInfo':
            return func(int(data['workId']))
        else:
            return func(*args,**kwargs)
    return wrapper

从request的头部信息中获取access token, 通过secret_key解密获取信息进行验证。
这里我们根据不同的验证结果定义了251和253的状态码,方便标识。

前端请求拦截器处理response

class HttpRequest {
    ...
    interceptors (instance, url, options) {
    ...
        // 响应拦截
        instance.interceptors.response.use(res => {
          let { data, status } = res
          // 检查flask后台的接口状态
          if (status && status === 253) {
            // access_token 超时
            this.refresh = true
            store.dispatch('handleCheckRefreshToken').then(res => {
              // 重新刷新当前页面
              this.request(options)
              history.go(0)
              return Promise.reject(new Error("token超时刷新"))
            },error => {
              return Promise.reject(error)
            })
          }
          this.destroy(url)
          if (status && [250,251,252].includes(status)) {
            // 登出 登录
            store.commit('setAccessToken','')
            store.commit('setRefreshToken','')
            router.push({name: 'login'})
            return Promise.reject(data)
          }
          ...

检测返回的状态码,如果为 253,说明access token超时,需要刷新access token;如果为251,说明验证不通过,则需要重置token,重新登录

刷新 access token

当状态码为253时,前端需要触发进行access token刷新,这个时候需要用到refresh token

handleCheckRefreshToken({ state, commit }) {
  return new Promise((resolve, reject) => {
    checkRefreshToken(state.refresh_token).then(res => {
      const data = res.data
      commit('setAccessToken',data.token)
      resolve(res)
    }).catch(err => {
      reject(err)
    })
  })
},

后端验证refresh token

@blueprint.route('/refreshTokenAuth', methods=['POST'])
def checkRefreshToken():
    data = json.loads(request.data)
    code,info = refreshTokenAuth(data['token'])
    if code:
        resp_data = json.dumps({
            'token': genAccessToken(int(info)).decode("utf-8")
        })
        return Response(response=resp_data, status=200, mimetype="application/json")
    else:
        return jsonify(info), 252
def refreshTokenAuth(token):
    if not token:
        return False,u'refresh token不存在验证信息!'
    s = Serializer(
        secret_key=current_app.config['SECRET_KEY']
    )
    try:
        data = s.loads(token)
    except SignatureExpired:
        return False,u'refresh token超时!'
    except BadSignature as e:
        encoded_payload = e.payload
        if encoded_payload is not None:
            try:
                s.load_payload(encoded_payload)
            except BadData:
                return False,u'refresh token被篡改!'
        return False,u'refresh token错误的验证信息!'
    except:
        return False,u'refresh token验证失败,未知的错误!'
    if ('workId' not in data):
        return False,u'refresh token错误的信息载体!'
    return True,data['workId']

refresh在验证通过之后,会生成新的access token返回给前端;如果没通过或者超时,则会返回252状态码

前端保存新的token

前端收到新的token之后,会把之前的请求重新发送一次,确保之前的请求成功,然后刷新页面:

if (status && status === 253) {
// access_token 超时
this.refresh = true
store.dispatch('handleCheckRefreshToken').then(res => {
  // 重新刷新当前页面
  this.request(options)
  history.go(0)
  return Promise.reject(new Error("token超时刷新"))
},error => {
  return Promise.reject(error)
})
}

整个过程就结束了

结束语

这种方式的验证并不是说绝对安全的,只是有效降低了风险。