最近在作管理系统项目的时候,涉及到登录验证的问题。因为该系统的实时性要求并不高,排除了session + cookie的传统方案,纠结再三,最终选择了使用token进行验证。如此,服务端无需存储会话信息,将信息加密保存在token中,只要密钥没有泄露,安全性仍是能够获得保障的。javascript
技术栈vue
欢迎有志之士交流探讨。转载请附原文连接。 java
token认证的基本流程是:客户端登录成功,服务端返回token,客户端存储token。之后的每次请求,都将携带token,通常会放在请求头中,随请求发送:Authorization: token
。ios
token的特色是没法废除,即一个token只要颁发了,在有效期内始终是有效的,即便颁发了新的token也不影响原有的token使用。出于安全性考虑,token的有效时间应该设置的短一些,一般设置为30min ~ 1h
。git
这样一来每隔这么久就要重置一次token,能够经过refresh token的方式来更新token。es6
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里啥均可以放,只要你愿意。通常会包含简短的用户信息和过时时间,因为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.js
和axios.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() // 不然经过验证
}
})
}
复制代码
前两个action发出了两个请求,而且都失败了,进入401处理逻辑,能够看到紧接着执行了refresh token的操做,而后等refresh token结束后,继续以前的请求,进而完成以前的请求,触发mutation,完成整个操做。整个过程用户没有感知。
能够看到,refresh token 失败后,触发403的逻辑,而后跳转到登陆界面。达到了想要的效果。
这个登录验证的流程中,最值得细细品味的,当属并发请求处理的部分,即handle404.js
文件中的内容。它涉及并发问题,而Vue中也有相似的问题, 如视图更新:
vue中数据变化触发的视图更新是异步的,这使得短期内数据的屡次变化能够整合到一块儿,避免渲染无心义的中间态。其内部也是使用一个标志量和一个缓冲区来实现的。
文章若有纰漏,欢迎批评指正。转载请附原文连接。