token 是什么
本文所说的 token 其实应该叫 JSON Web Token 简称 JWT 是一种开放标准(RFC 7519),用于在网络应用环境间安全地将信息作为 JSON 对象传输。
这里说的 token === jwt
在前后端分离的项目中 token 通常用于用户的身份识别和校验
token 的本质是一个加密后的字符串,可以通过密钥进行解密
常见的 token 格式大概如下图
JWT 由三个部分组成,每个部分用点(.
)分隔:
- Header(头部) :包含令牌的类型(即 JWT)和所使用的签名算法(如 HMAC SHA256 或 RSA
- Payload(负载) :通常是一个 json 对象
- Signature(签名) :用于验证消息在传输过程中没有被更改,并且对于使用私钥签名的令牌,还可以验证发送者的身份。
什么是双 token 机制
双 tokne 机制从字面意思上理解需要两个 token 事实上呢也确实是这样,不过两者有一些差别
一般会把两个 token 分别命名为 accessToken
refreshToken
也称为长短 token
accessToken: 有效期短,一般设置的请求的 header Authorization
字段
refreshToken: 有效期长,当 accessToken
过期时重新签发一个新的 accessToken
下边画个图
大概就是 accessToken
的有效期一般设置的很短 ,refreshToken
的有效期会长一些比如一个周
如果 accessToken
过期了,就去请求接口刷新 accessToken
这时需要检查 refreshToken
是否过期
没有过期就返回新的 accessToken
然后客户端再次发起请求
refreshToken
过期了就需要重新进行登录
解决了什么问题
使用单个 token 的时候一旦被拦截就可以在 token 有效期内一直使用
而长 token 加短 token 的方式可以一定程度的上避免这种情况,同时长 token 也可以保证活跃的用户不用重复登录,假如你每天都访问掘金 每天都要登录的话 会不会🐎人呢?
上边流程图里可以看到每次刷新 accessToken
时会同时刷新 refreshToken
,为什么不直接增加 refreshToken
的过期时间呢?
增加过期时间当然是可以做到的,但这也会导致 refreshToken
和上边的单 token 一样的问题
而每次都刷新的话即使 refreshToken
被截获,重新请求后也会生成新的导致被拦截的 refreshToken
失效
但是你要说万无一失那也不可能,只是相对安全一些,接下来让我们看看 gpt 怎么说!
如何实现
我们使用 express 来模拟服务端实现一个双 token 的 demo 实现
服务端
我们先来实现下服务端
使用 express
启动过一个服务并设置静态文件夹用来展示我们的前端 index.html 页面
使用 jsonwebtoken
生成 accessToken
refreshToken
使用 cookie-parser
将 refreshToken
设置到 cookie
中并设置 js 无法读取等安全限制
js
const express = require('express');
const jwt = require('jsonwebtoken');
const cookieParser = require('cookie-parser');
const path = require('path');
const app = express();
// 设置静态文件目录
app.use(express.static(path.join(__dirname, 'public')));
// 定义根路由返回 index.html
app.get('/', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'index.html'));
});
app.use(express.json());
app.use(cookieParser());
// 密钥,用于签名和验证 JWT
const ACCESS_TOKEN_SECRET = '516AE41C-9658-220A-C2EE-698EFB7546CC';
const REFRESH_TOKEN_SECRET = 'FB3C5606-A097-B63F-ED27-113985FB5905';
// 模拟用户数据库
const users = [
{ id: 1, username: 'user1', password: 'pw1' },
{ id: 2, username: 'user2', password: 'pw2' }
];
// 生成访问令牌和刷新令牌
function generateToken(userId) {
const accessToken = jwt.sign({ userId }, ACCESS_TOKEN_SECRET, { expiresIn: '10s' });
const refreshToken = jwt.sign({ userId }, REFRESH_TOKEN_SECRET, { expiresIn: '30s' });
return { accessToken, refreshToken };
}
// 获取存储刷新令牌的 cookie 键
function getCookieRefreshTokenKey(userId) {
return `refreshToken-${userId}`;
}
// 存储令牌到用户 id 的映射(这里使用一个简单的对象模拟)
const accessTokens = {};
// 设置 token 信息
function setTokenInfo(res, userId) {
const { accessToken, refreshToken } = generateToken(userId);
accessTokens[accessToken] = userId;
// 将刷新令牌存储在 HTTP-only cookie 中
res.cookie(getCookieRefreshTokenKey(userId), refreshToken, { httpOnly: true, secure: true, sameSite: 'strict' });
res.json({ accessToken });
}
// 登录接口
app.post('/login', (req, res) => {
const { username, password } = req.body;
const user = users.find(u => u.username === username && u.password === password);
if (!user) {
return res.status(400).json({ message: '用户不存在!' });
}
setTokenInfo(res, user.id);
});
// 获取 header 中的 token
function getHeaderToken(req) {
const fullToken = req.headers.authorization;
if (!fullToken) {
return { code: -1, message: 'token 不存在!' };
}
const token = fullToken.split(' ')[1];
if (!token) {
return { code: -1, message: '未知的 token!' };
}
return { code: 0, message: token, userId: accessTokens[token] };
}
// 刷新访问令牌接口
app.post('/refresh-token', (req, res) => {
const accessTokenInfo = getHeaderToken(req);
if (accessTokenInfo.code !== 0) {
return res.status(403).json({ message: accessTokenInfo.message });
}
try {
jwt.verify(accessTokenInfo.message, ACCESS_TOKEN_SECRET);
} catch (err) {
if (err.name === 'TokenExpiredError') {
const refreshToken = req.cookies[getCookieRefreshTokenKey(accessTokenInfo.userId)];
if (!refreshToken) {
return res.status(403).json({ message: 'refreshToken 不存在!' });
}
try {
// 刷新 token 验证成功返回新的 token
jwt.verify(refreshToken, REFRESH_TOKEN_SECRET);
return setTokenInfo(res, accessTokenInfo.userId);
} catch (err) {
return res.status(403).json({ message: '未知的 refreshToken' });
}
}
return res.status(403).json({ message: '未知的 token!' });
}
// 上边无异常返回新的 token
return setTokenInfo(res, accessTokenInfo.userId);
});
// 退出登录成功
function logoutSuccess(res, accessTokenInfo) {
res.clearCookie(getCookieRefreshTokenKey(accessTokenInfo.userId));
delete accessTokens[accessTokenInfo.message];
res.json({ message: 'Logged out successfully' });
}
// 登出接口
app.post('/logout', (req, res) => {
const accessTokenInfo = getHeaderToken(req);
if (accessTokenInfo.code !== 0) {
return res.status(403).json({ message: accessTokenInfo.message });
}
// 获取到用户才可以正常退出
try {
jwt.verify(accessTokenInfo.message, ACCESS_TOKEN_SECRET);
return logoutSuccess(res, accessTokenInfo);
} catch (err) {
if (err.name === 'TokenExpiredError') {
return logoutSuccess(res, accessTokenInfo);
}
return res.status(403).json({ message: '未知的 token!' });
}
});
// 模拟获取数据
app.get('/data/:id', (req, res) => {
const accessTokenInfo = getHeaderToken(req);
if (accessTokenInfo.code !== 0) {
return res.status(403).json({ message: accessTokenInfo.message });
}
try {
jwt.verify(accessTokenInfo.message, ACCESS_TOKEN_SECRET);
return res.json({ message: '获取成功!', data: new Date() });
} catch (err) {
return res.status(401).json({ message: 'token 已过期' });
}
});
// 启动服务器
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server successfully started`);
console.log(`Server: http://127.0.0.1:${PORT}`);
});
token 存储在 cookie
和 localStorage
的区别
存储在 cookie
中:
- 优点 :存储在
cookie
中不需要手动进行传递,浏览器会自动在每个请求中包含cookie
。可以设置HttpOnly
标志,使cookie
无法被 JavaScript 读取防止 XSS(跨站脚本攻击)。 - 限制 :
cookie
的大小有限制(通常为 4KB),使用HttpOnly
标志,前端 js 无法读取和操作cookie
。cookie
是与域名绑定的,不支持分布式后端服务(除非使用共享cookie
的机制)。
存储在 localStorage
中:
- 优点 :
localStorage
可以存储更多的数据(5m+),并且支持分布式应用,因为数据存储在客户端,可以手动传递给不同的后端服务。 - 限制 :
localStorage
中的数据可以被 JavaScript 读取和操作,因此存在 XSS 攻击的风险。localStorage
中的数据需要手动在请求中传递。
客户端
客户端实现主要是对请求库的封装这里拿 axios 举例
js
const axiosInstance = axios.create({
baseURL: 'http://127.0.0.1:3000',
withCredentials: true
});
let refreshing = false;
let queue = []
async function refreshToken(originalRequest) {
// 请求刷新 token 接口
return axiosInstance.post('/refresh-token').then(response => {
// 获取新的 access token
const accessToken = response.data.accessToken;
// 存储 access token
localStorage.setItem('accessToken', accessToken);
// 设置 Authorization header
axiosInstance.defaults.headers.common['Authorization'] = `Bearer ${accessToken}`;
// 重新执行 401 函数
queue.forEach(cb => cb());
// // 执行完毕后清空队列
queue = []
return axiosInstance(originalRequest);
}).catch((err) => {
// 获取新的 access token 失败,清除 access token 并重新登录
localStorage.removeItem('accessToken');
delete axiosInstance.defaults.headers.common['Authorization'];
queue = []
return Promise.reject(err)
}).finally(() => {
refreshing = false;
})
}
axiosInstance.interceptors.response.use(
response => response,
async error => {
const originalRequest = error.config;
if (error.response.status === 401) {
// 将 401 的错误的请求加入队列
if (refreshing) {
return new Promise((resolve) => {
queue.push(() => resolve(axiosInstance(originalRequest)));
});
}
if (!refreshing) {
refreshing = true;
return await refreshToken(originalRequest)
}
}
return Promise.reject(error);
}
);
axiosInstance.interceptors.request.use(
config => {
const accessToken = localStorage.getItem('accessToken');
if (accessToken) {
config.headers['Authorization'] = `Bearer ${accessToken}`;
}
return config;
},
error => Promise.reject(error)
);
window.axiosInstance = axiosInstance;
主要逻辑是在接口请求 401
时,将请求缓存到一个数组中,然后发起 refresh-token
请求
axiosInstance(originalRequest)
originalRequest 是本次请求的配置直接传给 axios 实例就可以再次发起同样的请求
同时将 refreshing
变量设置为 true
防止多次调用 refresh-token
接口
当 token 刷新完成后将 accessToken
存储到 localStorage
, 同步将 accessToken
设置请求 headers 的 Authorization
的值(Bearer 固定写法)
执行 queue
数组中缓存的数据,执行完成后清空数组
refreshToken 函数为什么要执行 axiosInstance(originalRequest) 并返回 ? 因为第一个发生 401 的请求没有被添加到
queue
数组中
todo
请求去重
具体效果
accessToken 10s 过期, refreshToken 30s 过期
点击登录
单个请求 401
多个请求 401
refreshToken 403 过期重新登录
总结
本文介绍了 token 是什么,全称是 JSON Web Token 简称 JWT 是一种开放标准,本质上是一个加密后的字符串可以使用密钥解密
然后通过一张图介绍了双 token 的执行流程以及双 token 有什么好处
然后对双 token 机制进行了实践包含前端后端实现