登录验证明践——Token & Refresh Token

背景

最近在作管理系统项目的时候,涉及到登录验证的问题。因为该系统的实时性要求并不高,排除了session + cookie的传统方案,纠结再三,最终选择了使用token进行验证。如此,服务端无需存储会话信息,将信息加密保存在token中,只要密钥没有泄露,安全性仍是能够获得保障的。javascript

Github 仓库前端

技术栈vue

  • 前端:vue全家桶 + ant-design-vue
  • 后端:MongoDB + TypeScript + express

欢迎有志之士交流探讨。转载请附原文连接。 java

Token

token认证的基本流程是:客户端登录成功,服务端返回token,客户端存储token。之后的每次请求,都将携带token,通常会放在请求头中,随请求发送:Authorization: tokenios

token的特色是没法废除,即一个token只要颁发了,在有效期内始终是有效的,即便颁发了新的token也不影响原有的token使用。出于安全性考虑,token的有效时间应该设置的短一些,一般设置为30min ~ 1hgit

这样一来每隔这么久就要重置一次token,能够经过refresh token的方式来更新token。es6

Refresh Token

refresh token与token同样,都是一段加密字符串,不一样的是,refresh token是用来获取新的token的。github

在使用成熟的网站、社区时,经常发现很长一段时间咱们都不须要从新登录,这貌似有悖于“token的有效时间应该设置的短一些”。实际上使用refresh token后,登陆权限验证的流程变成了下面这样:web

登录成功后服务端返回token和refresh token,客户端存储这两个信息。平时发送请求时携带token,当token过时时服务端会拒绝响应,此时发送一个携带refresh token的新请求,服务端验证后返回新的token,客户端替换旧的token后从新发起请求便可。若refresh token过时,则从新登录。vuex

能够看出refresh token做用于长期,仅用于获取新的token,同时也控制着用户登陆的最长时间,通常会设置的长一些,例如7天,14天,过时以后必须从新登陆;token做用于短时间,用于获取数据。

问题

基本的流程了解清楚后,能够引出如下几个问题:

  • token里面放什么?
  • 从新请求会不会打断用户的操做?(用户体验差)
  • 并发请求如何处理?(会反复调用API刷新,效率低下)

token里面放什么

token里啥均可以放,只要你愿意。通常会包含简短的用户信息和过时时间,因为token内放的数据是能够解密的,因此千万不要放敏感信息如密码等。过时时间用来验证token是否过时,简短的用户信息用于可能的数据库操做(若是有的话)。

用户体验

试想若是因为token过时致使用户好不容易填完的表单数据丢失,用户必定会暴跳如雷吧?刷新token必定要考虑用户体验问题。

一般咱们会设置全局的拦截(以axios为例)。设置全局响应拦截,经过约定好的信息(如状态码)判断属于哪一种状况,最后根据状况采起不一样的操做(是刷新token?仍是从新登录?),以后再从新发送以前的请求。

提高用户体验的关键在于,不能中断当前请求,而是使用新的请求替换原来的请求。这一点在axios中能够轻松实现,下文会示例。

并发请求处理

并发请求时,若刚好token过时,则最终会发起多个刷新token的请求,多余的请求除了增长服务器的压力,没有任何作用。

浏览器中,发出请求时候会开启一条线程。请求完成以后,将对应的回调函数添加到任务队列中,等待 JS 引擎处理。而咱们须要整合这个过程,将并发请求拦截汇总,最终只发出一次刷新请求。这便涉及线程同步的问题,咱们能够经过加锁,和缓冲来解决。

  • 加锁:简单来讲就是在模块内部设置一个全局变量,用来标志当前的状态,防止冲突。

  • 缓冲:就是设置一个空间,将当时发生但来不及处理的内容存储起来,在合适的时机再处理。

干说很差理解,下面上代码。

实现

约定:403从新登录,401须要刷新

前端

使用vue + axios实现,首先封装一下全局的axios API

/** * axios.js */
import { message } from 'ant-design-vue'
import axios from 'axios'
import store from '../store'
import router from '../router'
import handle401 from './handle401'

axios.defaults.baseURL = '/api'

// 请求携带token
axios.interceptors.request.use(config => {
  // 判断是为了防止自定义header被覆盖
  if (!config.headers.authorization && store.state.token) {
    config.headers.authorization = store.state.token
  }
  return config
})

// 若因401而拒绝,则刷新token,若403则跳转登陆
// 返回的内容将会替换当前请求(Promise链式调用)
axios.interceptors.response.use(null, error => {
  const { status, config } = error.response
  if (status === 401) {
    return handle401(config)
  } else if (status === 403) {
    message.warn('身份凭证过时,请从新登陆')
    router.replace({ name: 'login' }).catch(e => e)
  }
  return Promise.reject(error)
})

export default axios // 导出axios对象,全部请求都使用这个对象

复制代码

而后是对于401状态的处理,细心的小伙伴能够发现,这里存在handle404.jsaxios.js的循环引用问题,感兴趣的能够戳 阮一峰 —— ES6模块加载,因为不会影响代码逻辑的正常执行,这里不作展开。

/** * handle404.js */
import store from '../store'
import axios from './axios'
import { REFRESH_TOKEN } from '../store/mutation-types'

let lock = false // 锁
const originRequest = [] // 缓冲

/** * 处理401——刷新token并处理以前的请求,目的在于实现用户无感知刷新 * @param config 以前的请求的配置 * @returns {Promise<unknown>} */
export default function (config) {
  if (!lock) {
    lock = true
    store.dispatch(REFRESH_TOKEN).then(newToken => {
      // 使用新的token替换旧的token,并构造新的请求
      const requests = originRequest.map(callback => callback(newToken))
      // 从新发送请求
      return axios.all(requests)
    }).finally(() => {
      // 重置
      lock = false
      originRequest.splice(0)
    })
  }
  // 关键代码,返回Promise替换当前的请求
  return new Promise(resolve => {
    // 收集旧的请求,以便刷新后构造新的请求,同时因为Promise链式调用的效果,
    // axios(config)的结果就是最终的请求结果
    originRequest.push(newToken => {
      config.headers.authorization = newToken
      resolve(axios(config))
    })
  })
}
复制代码

这是接口:

/** * index.js */
import axios from './axios'

export const login = data => axios.post('/auth/login', data)
export const refreshToken = originToken => {
  return axios.get('/auth/refresh', {
    headers: {
      authorization: originToken
    }
  })
}
复制代码

而后是vuex的相关代码:

import { message } from 'ant-design-vue'
import { LOGIN, REFRESH_TOKEN } from './mutation-types'
import { login, refreshToken } from '../api/index.js'

export default {
  [LOGIN] ({ commit, state }, info) {
    ···
  },
  [REFRESH_TOKEN] ({ commit, state }) {
    // 使用Promise包装便于控制流程
    return new Promise((resolve, reject) => {
      refreshToken(state.refreshToken).then(({ data: newToken }) => {
        commit(REFRESH_TOKEN, newToken)
        resolve(newToken)
      }).catch(reject)
    })
  }
}

复制代码

后端

使用express + jsonwebtoken实现

为了便于演示,token过时时间设置为10s,refresh token过时时间设置为20s。

/** * token.ts */
import dayjs from 'dayjs'
import { sign } from 'jsonwebtoken'
import secretKey from '../config/tokenKey'

// 控制普通token,客户端过时后无需再次登陆
export const getToken = function () {
  return sign({
    exp: dayjs().add(10, 's').valueOf()
  }, secretKey)
}

// 控制客户端最长登录时间,超时从新登陆
export const getRefreshToken = function (payload: any) {
  return sign({
    user: payload, // 这里放入一点用户信息,刷新的时候用来查数据库,简单的验证一下。
    exp: dayjs().add(20, 's').valueOf()
  }, secretKey)
}

复制代码

登陆路由部分代码:

/** * login.ts */
import { Router } from 'express'
import { getRefreshToken, getToken } from '../../utils/token'

const router = Router().
router.post('/auth/login', function (req, res) {
    ...
    res.json({
        code: 0,
        msg: '登录成功',
        data: {
            user: { identity, ...user },
            token: getToken(),
            refreshToken: getRefreshToken({ identity, account })
        }
    })
    ...
})

export default router
复制代码

resfresh token路由

/** * refresh.ts */
import { Router } from 'express'
import dayjs from 'dayjs'
import { verify } from 'jsonwebtoken'
import { find } from '../../db/dao'
import { USER } from '../../db/model'
import secretKey from '../../config/tokenKey'
import { getToken } from '../../utils/token'

const router = Router()

router.get('/auth/refresh', function (req, res) {
  const refreshToken = req.headers.authorization
  if (!refreshToken) {
    return res.status(403).end()
  }
  verify(refreshToken, secretKey, function (err, payload: any) {
    // token 解析失败,从新登陆
    if (err) {
      return res.status(403).end()
    }
    const { exp, user } = payload
    // refreshToken过时,从新登陆
    if (dayjs().isAfter(exp)) {
      return res.status(403).end()
    }
    // 不然刷新token
    find(USER, user).then(users => {
      if (users.length === 0) {
        res.status(403).end()
      } else {
        res.status(200).send(getToken())
      }
    }).catch(e => {
      res.status(500).end(e.message)
    })
  })
})

export default router
复制代码

登录验证中间件:

/** * loginChecker.ts */
import dayjs from 'dayjs'
import { Request, Response, NextFunction } from 'express'
import { verify } from 'jsonwebtoken'
import secretKey from '../config/tokenKey'

export default function (req: Request, res: Response, next: NextFunction) {
  const token = req.headers.authorization
  if (!token) {
    return res.status(403).end()
  }
  verify(token, secretKey, function (err, payload: any) {
    if (err) {
      return res.status(403).end(err.message)
    }
    const { exp } = payload
    console.log(dayjs(exp).format('YYYY-MM-DD HH:mm:ss'))
    if (dayjs().isAfter(exp)) {
      res.status(401).end('Unauthorized') // 过时,401提示客户端刷新token
    } else {
      next() // 不然经过验证
    }
  })
}
复制代码

效果图

token过时

前两个action发出了两个请求,而且都失败了,进入401处理逻辑,能够看到紧接着执行了refresh token的操做,而后等refresh token结束后,继续以前的请求,进而完成以前的请求,触发mutation,完成整个操做。整个过程用户没有感知。

token请求

refresh token过时

refresh token流程

能够看到,refresh token 失败后,触发403的逻辑,而后跳转到登陆界面。达到了想要的效果。

总结

这个登录验证的流程中,最值得细细品味的,当属并发请求处理的部分,即handle404.js文件中的内容。它涉及并发问题,而Vue中也有相似的问题, 如视图更新:

vue中数据变化触发的视图更新是异步的,这使得短期内数据的屡次变化能够整合到一块儿,避免渲染无心义的中间态。其内部也是使用一个标志量和一个缓冲区来实现的。

文章若有纰漏,欢迎批评指正。转载请附原文连接

参考

请求时token过时自动刷新token

相关文章
相关标签/搜索