引言
我们知道,http是无状态协议,这也就意味着,我们在登录页面完成了登录,转身调其它接口,服务器是不知道用户是否完成登录的。 当然你要说调其它接口的时候,把账号密码也带上!!也不是不行,就是容易泄漏密码,加重服务器压力,其次是容易挨揍。😂😂
效果展示
Session-Cookie机制和Token机制?
百度百科:
- Token在计算机身份认证中是令牌(临时)的意思.
- Cookie类型为“小型文本文件”,是某些网站为了辨别用户身份,进行Session跟踪而储存在用户本地终端上的数据(通常经过加密),由用户客户端计算机暂时或永久保存的信息。
Session-Cookie认证流程:
- 客户端账号密码进行登录
- 服务器验证通过后,创建Session对象,并存入该用户相关信息
- 服务器返回Session对象的唯一标识sessionid, 并写入Cookie
- 客户端同源下的其它请求主动携带Cookie
- 服务器收到请求携带的Cookie, 再去验证
弊端:
- 受同源策略约束。无法直接在子应用中共享
- 分布式集群情况下无法保证用户的会话数据是一致的
显然:Session-Cookie不是最优解。所以就引入了👻👻Token👻👻
Token 认证流程
- 客户端账号密码登录
- 服务器验证通过后,通过jwt鉴权生成Token
- 客户端进行Token存储,之后每次请求都携带(一般放请求头上)
- 服务端校验是否过期、有效
优势:
- 它只是客户端与服务端的一种自定义约定,和同源策略没有关系
- 不受分布式集群影响
- 不需要服务器维护会话状态
- 可以在不同系统中共享
弊端:
Token一旦生成,没有过期之前服务器都是认的,即使生成新的token。旧的依然能用。
Token如果设置有效期很长不安全,很短会存在用户刷着刷着,告诉用户Token过期需要重新登录。当然这种情况我们是不允许出现的。所以就有了无感刷新Token
无感刷新Token的实现
在Token认证的基础上,用户完成登录返回两个token,一个短期的access_token,一个长期的refresh_token。短期access_token过期,使用长期refresh_token换取新token,也就是会发三次请求,第一次401,第二次换token,第三次重新请求。如果网速正常、接口正常基本就能无感知实现刷新token了。效果如下图
access_token:用作接口请求令牌,一般有效期十几分钟、几小时(具体看公司需求) refresh_token:在短期token过期后,换取新Token,一般是几天、一周
本文将从前端到后端手把手实现无感刷新的整个流程包括踩坑实录😂😂😂,全文有点长,请耐心看完,预计需要5-10分钟。源码放在末尾了,自己测试通过,有需要的自行食用👻👻👻!
前端部分
项目:Nuxt3 + antdv + Vue3 + pinia
- 客户端输入账号密码进行登录
- 登录成功进行两个Token的存储
- 客户端做请求拦截,在请求头统一带上Token
- 客户端做响应拦截,服务端返回401,token过期
- 客户端尝试使用refreshToken调刷新token接口
- 刷新成功:重新调原来失败的401的接口
- 刷新失败:重定向到登录页进行登录
请求拦截
- axios请求拦截器:在发起请求之前做一次拦截。判断是否存在token,存在就是写入header头
import { tokenService } from '@/utils/auth'
import { config } from '@/baseConfig'
const service = axios.create({
baseURL: config.baseUrl,
withCredentials: false,
})
// 请求拦截
service.interceptors.request.use(
(config) => {
const token = tokenService.token
if (token) config.headers.token = token
return config
},
(error) => {
return Promise.reject(error.response)
},
)
响应拦截(重点)
- 响应头中存在token 、refresh-token 就把它存起来 (这里会有小坑,后面会讲到:在浏览器的控制台可以看到服务器返回的自定义header:token但是拦截器里拿不到的解决)
- 判断http status=401 (这里可以选择ststus=200然后自定义返回code:401什么的,和后端约好就行)
- 如果判断为401 => 取一下refreshtoken, 如果没有直接跳转登录
- 有refreshToken 调换取token接口刷新token
- 刷新成功: 重新调一下原失败接口
- 刷新失败再跳登录就好了
// 响应拦截
service.interceptors.response.use(
(response: AxiosResponse): AxiosResponse => {
if (response.headers['token']) {
tokenService.setToken(response.headers['token'])
}
if (response.headers['refresh-token']) {
tokenService.setRefreshToken(response.headers['refresh-token'])
}
if (response.status === 200) {
return response
} else {
throw new Error(response.status.toString())
}
},
async (responseErr: AxiosError) => {
const status = responseErr.response?.status
const msg = responseErr.response?.data?.msg || ''
switch (status) {
case 401: // "Unauthorized"
// 做换取token操作
const originRequestConf = responseErr.config
if (tokenService.refreshToken) { // 存在refreshToken
const isSuceess = await refreshToken()
console.log(isSuceess, '刷新Token')
if (isSuceess && originRequestConf) {
originRequestConf.headers['refreshtoken'] = tokenService.refreshToken
// 重新发一次原来的请求
return service(originRequestConf)
}
}
message.error(msg)
location.href = location.origin + '/login'
return Promise.reject(responseErr)
default:
return Promise.reject(responseErr)
}
},
)
登录和刷新token
登录成功跳转createroom页面
// 登录逻辑
const onFinish = debounce(async (values: any) => {
try {
const {
code = -1,
msg = "",
userInfo = "",
} = await onLogin(values);
if (code !== 0) {
msg && message.error(msg || '连接报错,请刷新页面!');
return;
} else {
if (checkType.value === 'login') {
router.push({
path: '/createroom',
})
} else {
emit('changeActiveKay', {})
}
}
message.success(msg);
} catch (error) {
console.log(error)
}
}, 500);
refreshToken函数token 过期调刷新token接口
tokenService是封装的一个类,专门设置存储token和移除的。
// 刷新token
import http from '@/utils/request/index'
import { tokenService } from '@/utils/auth'
const refreshToken = async(): Promise<boolean> => {
const token = tokenService.refreshToken
const { code } = await http.post({url: urls.refreshToken, headers: {'refresh-token': token}})
return code === 0
}
后端部分
项目 express + jsonwebtoken + middleware
- 接口的实现:登录、刷新token、获取列表数据
- 鉴权的实现:jwt生成token + middleware 中间件
jsonwebtoken的基本使用
const jwt = require('jsonwebtoken');
// key
const secretKey = 'your-secret-key';
// 数据
const payload = { userId: 12345 };
// 生成token expiresIn:可以直接
const token = jwt.sign(payload, secretKey, { expiresIn: '1h' });
// 校验是否有效
const decoded = jwt.verify(token, secretKey);
中间件鉴权
// middleware/auth.js
const jwt = require('jsonwebtoken');
const { SERET_KEY } = require('./../config');
/**
* 校验token 是否有效
* @param {*} req
* @param {*} res
* @param {*} next
*/
const auth = async (req, res, next) => {
try {
const token = req.headers?.token;
if (!token) {
sendErr();
return;
}
jwt.verify(token, SERET_KEY, (err, data) => {
console.log(data);
if (err) {
console.log('。过期');
sendErr();
return;
}
next();
return;
});
!token && sendErr();
} catch (error) {
sendErr();
}
function sendErr() {
res.status(401).send({
code: 401,
msg: 'token 过期,或失效'
});
}
};
使用express快速搭建node服务
// server.js
const express = require('express');
const app = express();
const cors = require('cors');
const http = require('http');
const userRouters = require('./routes/user.js');
app.use(cors()); // 跨域
app.use((req, res, next) => {
// 配置了:axios 响应拦截器才能拿到
res.setHeader('Access-Control-Expose-Headers', "token, refresh-token");
next(); // 让请求继续到下一个中间件或路由处理程序
});
app.use('/user', userRouters);
const server = http.createServer(app);
// 启动服务器
const PORT = process.env.PORT || 3000;
server.listen(PORT, () => {
console.log(`服务器正在运行,端口:${PORT}`);
});
接口实现
登录接口
- 拿到前端传入的账户和密码,去查数据库
- 校验通过 => 使用jwt 生成token => 写入响应头 => 返回给前端
// routes/user.js
const express = require('express');
const { v4: uuidv4 } = require('uuid');
const router = express.Router();
const jwt = require('jsonwebtoken');
const MySQL = require('../utils/mysql'); // 导入MySQL类
const { SERET_KEY, REFRESH_KEY } = require('./../config')
const config = {
host: 'xx.xx.xx.xx',
user: 'xxx',
password: 'xxx',
database: 'chat',
}
const db = new MySQL(config)
router.post('/login', async (req, res) => {
console.log(req.path)
const { username, password } = req.body
try {
if (username && password) {
db.connect()
const sql = `SELECT * FROM user_table WHERE username = ? AND password =?;`
const sql2 = `SELECT * FROM user_table WHERE username = ? `
const queryhasUser = await db.query(sql, [username, password])
const queryUser = await db.query(sql2, [username])
console.log(queryhasUser, queryUser)
const resObj = {
code: -1,
userInfo: null,
msg: '用户不存在'
}
if (queryhasUser?.length && queryUser?.length) { // 账号、密码匹配上了
const user = {
username: username,
id: queryhasUser[0]?.did
}
const token = jwt.sign(user, SERET_KEY, { expiresIn: '1h' });
const refreshToken = jwt.sign(user, REFRESH_KEY, { expiresIn: '7d' });
res.setHeader('token', token)
res.setHeader('refresh-token', refreshToken)
resObj.userInfo = queryhasUser[0]
resObj.msg = '登录成功'
resObj.code=0
resObj.refreshToken=refreshToken
resObj.token = token
} else if (queryUser?.length) { // 密码不正确
resObj.msg = '密码不正确'
}
res.send(resObj)
} else {
res.send({
code: -1,
msg: '请输入用户名、密码'
})
}
} catch (error) {
console.log(JSON.stringify(error))
res.send({
code: -1,
msg: '报错了' + JSON.stringify(error)
})
} finally {
db.disconnect()
}
})
刷新token接口
- 拿到refreshtoken => 校验是否有效
- 有效=>重新生成新token, 无效=> 直接返回
router.post('/refreshToken', async(req, res) => {
console.log(req.path)
const refreshToken = req.headers['refresh-token']
console.log(req.headers)
try {
const decoded = jwt.verify(refreshToken, REFRESH_KEY);
const user = {
id: decoded?.id,
username: decoded?.username
}
// 签发新token
const token = jwt.sign(user, SERET_KEY, { expiresIn: 10 });
res.setHeader('token', token)
res.send({
code: 0,
token: token
})
} catch (error) {
res.send({
code: 1,
error: error,
msg: 'token 过期或无效'
})
}
})
获取列表数据接口
- 引进中间键auth做鉴权,只在需要认证的接口使用
- 校验通过 => 放行; 不通过 返回401
- app.get('/getAllRoomInfo', [auth], async (req, res) => {})
const {auth} = require('./middleware/auth.js')
let roomMap = new Map();
app.get('/getAllRoomInfo', auth, async (req, res) => {
if (!redisCkient.size) {
return res.send(JSON.stringify([]));
}
const roomInfo = [];
roomMap.forEach((value, key) => {
value && roomInfo.push(value);
});
res.send({
code: 0,
data: roomInfo
});
});
踩坑实录
1.axios拦截器中无法直接获取,服务端写入的token
需要在后端配置一下:Access-Control-Expose-Headers
app.use((req, res, next) => {
res.setHeader('Access-Control-Expose-Headers', "token, refreshToken");
next(); // 让请求继续到下一个中间件或路由处理程序
});
后端配置上之后,确实有了,但是,我的小驼峰不见了,refreshToken
无奈~~改成 refresh-token
2. 响应拦截中判断问题,导致死循环
就是 列表接口和刷新接口,来回调,死循环,,,复现不出来!!尴尬,是在响应拦截中条件错误导致。
3. mysql使用
- 开始使用的是mysql8因为加密方式升级,导致连不上,然后使用mysql2就解决了
- mysql连接一段时间后,会自己断开,重连有时候又出现问题。后面使用链接池来解决了
修正后代码如下:
const mysql = require('mysql2/promise');
const config = {
host: 'xxx.xx.xxx.xx',
user: 'xxx',
password: 'xxx',
database: 'xxx',
connectionLimit: 10, // 连接池最大链接数
}
// 创建数据库连接池
const pool = mysql.createPool(config);
// 查询函数
async function query(sql, values) {
try {
const connection = await pool.getConnection();
const [rows, fields] = await connection.query(sql, values);
connection.release();
return [rows, fields];
} catch (error) {
throw error;
}
}
总结
以上就是无感刷新token的实现了。实现的方式大同小异,但是核心是不变的。我们再回顾一下,整个流程:
- 客户端发起登录请求
- 服务端校验 => 通过:使用jwt生成token和refreshtoken并写入响应头
- 客户端收到token和refreshtoken进行保存
- 客户端基于axios做请求拦截、响应拦截
- 请求拦截:判断是否存在token,存在写入请求头
- 响应拦截:401 做换取token操作=>拿refreshtokd调刷新token接口=>校验通过生成新的token=>客户端重新发起失败的请求=>用户正常展示列表接口数据
- 服务端校验的接口使用middleware中间件进行拦截 => 校验通过放行
源码
结语:
如果本文对你有收获,麻烦动动发财的小手,点点关注、点点赞!!!👻👻👻
因为收藏===会了
如果有不对、可以优化的地方欢迎在评论区指出,谢谢👾👾👾