双token无感刷新nodejs+vue3(保姆级教程)
什么是双 Token 无感刷新?
双 Token 无感刷新机制使用两个不同的 token 来管理用户的身份验证和会话。通常情况下,这两个 token 是:
-
访问 Token(Access Token):用于访问受保护的资源,通常具有较短的有效期(如 15 分钟到 1 小时)。当用户进行 API 请求时,附带此 token 以证明其身份。
-
刷新 Token(Refresh Token):用于获取新的访问 token,通常具有较长的有效期(如几天到几个月)。刷新 token 不会频繁发送到服务器,而是在访问 token 过期后用于请求新的访问 token。
工作流程
-
用户登录:用户通过用户名和密码登录,服务器验证凭据后生成访问 token 和刷新 token,并将其返回给客户端。
-
使用访问 Token:客户端使用访问 token 进行 API 请求。当访问 token 过期时,客户端将无法访问受保护的资源。
-
无感刷新:
- 当访问 token 过期时,客户端会自动使用刷新 token 请求新的访问 token,而无需用户重新登录。
- 服务器验证刷新 token 的有效性,如果有效,则生成新的访问 token 并返回给客户端。
- 客户端使用新的访问 token 继续进行 API 请求。
-
刷新 Token 的管理:
- 刷新 token 也可以设置过期时间,通常在用户长时间不活动时使其失效。
- 服务器可以在刷新 token 失效时要求用户重新登录。
优势
-
用户体验:用户在使用应用时不需要频繁登录,提供了无缝的体验。
-
安全性:访问 token 的有效期较短,可以降低 token 被盗用的风险。即使访问 token 被盗,攻击者也只能在短时间内使用它。
-
灵活性:可以根据需要调整访问 token 和刷新 token 的有效期,以平衡安全性和用户体验。
-
支持登出:当用户选择登出时,可以使刷新 token 失效,从而阻止用户重新获取访问 token。
话不多说直接上代码
后端:nodejs下的express框架
app.js文件:
// 引入所需的模块
var createError = require('http-errors'); // 用于创建 HTTP 错误
var express = require('express'); // 引入 Express 框架
var path = require('path'); // 用于处理文件和目录路径
var cookieParser = require('cookie-parser'); // 用于解析 Cookie
var logger = require('morgan'); // 用于记录请求日志
const jwt = require('jsonwebtoken'); // 用于处理 JSON Web Tokens
const dotenv = require('dotenv'); // 用于加载环境变量
dotenv.config(); // 加载 .env 文件中的环境变量
const cors = require('cors'); // 用于处理跨域请求
// 引入路由模块
var indexRouter = require('./routes/index'); // 主路由
var usersRouter = require('./routes/users'); // 用户相关路由
var loginRouter = require('./routes/login'); // 登录相关路由
// 创建 Express 应用
var app = express();
// 使用 CORS 中间件,允许跨域请求
app.use(cors());
// 自定义中间件,用于处理 JWT 验证
app.use((req, res, next) => {
// 定义不需要验证的路径
let pathArr = [
'/login/userLogin', // 用户登录路径
'/login/refresh' // 刷新 token 的路径
]
// 如果请求路径在不需要验证的路径中,直接调用 next() 继续处理
if (pathArr.includes(req.path)) {
return next()
}
// 获取请求头中的 accessToken 和 refreshToken
const accessToken = req.headers.accesstoken; // 注意:这里的 'accesstoken' 虽然前端传过来是驼峰命名法'accessToken'
const refreshToken = req.headers.refreshtoken; //但是这里也要全部小写,http的机制导致
// 判断 refreshToken 是否过期
try {
jwt.verify(refreshToken, 'WANGJIALONG'); // 验证 refreshToken
} catch (error) {
console.log(error); // 打印错误信息
return res.status(403).send({ message: 'Forbidden' }); // 如果验证失败,返回 403 Forbidden
}
// 如果没有 accessToken,返回 401 Unauthorized
if (!accessToken) {
return res.status(401).send({ message: 'Unauthorized' });
}
// 验证 accessToken
try {
const user = jwt.verify(accessToken, 'WANGJIALONG'); // 验证 accessToken
res.locals.user = user; // 将用户信息存储在 res.locals 中,供后续中间件使用
return next(); // 验证成功,调用 next() 继续处理请求
} catch (error) {
return res.status(401).send({ message: 'Unauthorized' }); // 如果验证失败,返回 401 Unauthorized
}
})
// 设置视图引擎
app.set('views', path.join(__dirname, 'views')); // 设置视图文件夹路径
app.set('view engine', 'ejs'); // 设置视图引擎为 EJS
// 使用中间件
app.use(logger('dev')); // 记录请求日志
app.use(express.json()); // 解析 JSON 格式的请求体
app.use(express.urlencoded({ extended: false })); // 解析 URL 编码的请求体
app.use(cookieParser()); // 解析 Cookie
app.use(express.static(path.join(__dirname, '/upload'))); // 设置静态文件目录
// 使用路由
app.use('/', indexRouter); // 主路由
app.use('/users', usersRouter); // 用户路由
app.use('/login', loginRouter); // 登录路由
// 捕获 404 错误并转发到错误处理器
app.use(function (req, res, next) {
next(createError(404)); // 创建 404 错误
});
// 错误处理器
app.use(function (err, req, res, next) {
// 设置 locals,仅在开发环境中提供错误信息
res.locals.message = err.message; // 错误信息
res.locals.error = req.app.get('env') === 'development' ? err : {}; // 在开发环境中提供完整错误信息
// 渲染错误页面
res.status(err.status || 500); // 设置响应状态
res.render('error'); // 渲染错误页面
});
// 导出应用
module.exports = app; // 导出 Express 应用实例
ps:这里需要注意的是 白名单的使用,对于不需要进行token验证的路由统一放到一个数组中,如果检测到包含不需要验证的路由,直接next()通过即可。
ps:前端发送过来的请求头中是利用驼峰命名法传递的,但是在后端接受时,统一小写,这是http的机制导致的,切记!!!一定小写,否则报错。
login.js文件
var express = require('express');
var router = express.Router();
let jwt = require('jsonwebtoken');
let { userModel } = require('../model/model');
const ACCESS_TOKEN_EXPIRATION = 5; //访问令牌有效期
const REFRESH_TOKEN_EXPIRATION = '1d'; //刷新令牌有效期
const SECRET_KEY = 'WANGJIALONG';
const refreshTokenMap = new Map();
// 生成函数令牌
function generateToken(name, expiration) {
return jwt.sign({ name }, SECRET_KEY, { expiresIn: expiration });
}
// 封装生成短token和长token
function getToken(name) {
let accessToken = generateToken(name, ACCESS_TOKEN_EXPIRATION); //短Token
let refreshToken = generateToken(name, REFRESH_TOKEN_EXPIRATION); //长Token
const refreshTokens = refreshTokenMap.get(name) || [];
refreshTokens.push(refreshToken);
refreshTokenMap.set(name, refreshTokens);
return {
accessToken,
refreshToken,
};
}
//=================================>账号密码登录
router.post('/userLogin', async (req, res) => {
const { username, password } = req.body; // 直接解构请求体
let user = await userModel.findOne({ username }); // 根据用户名查找用户
if (!user) {
return res.status(200).send({ message: '账号错误', code: 1 }); // 账号不存在
}
if (user.password !== password) { // 验证密码
return res.status(200).send({ message: '密码错误', code: 2 });
}
let { accessToken, refreshToken } = getToken(user.username); // 使用用户名生成令牌
res.status(200).send({
data: user,
accessToken,
refreshToken,
message: '登录成功',
code: 200,
});
});
// 刷新短token
router.get('/refresh', async (req, res) => {
const refreshToken = req.headers.refreshtoken;
if (!refreshToken) {
res.status(403).send('Forbidden');
}
try {
const { name } = jwt.verify(refreshToken, SECRET_KEY);
const accessToken = generateToken(name, ACCESS_TOKEN_EXPIRATION);
res.status(200).send({ accessToken });
} catch (error) {
console.log('长token已过期');
res.status(403).send('Forbidden');
}
});
router.post('/add', async (req, res) => {
await userModel.create(req.body)
res.send({
code: 200
})
})
module.exports = router;
这里我将生成token封装了一个函数,方便以后调用。这里为了方便测试,我的短token设置了5秒过期,长token设置了1天过期(在实际开发中短token设置十几分钟到一个小时,长token设置几天甚至一个月)
对于前端我是用的vue3框架
这里需要注意的点是需要引入axios的axios-retry库,用于实现请求重试机制,避免代码运行过程中出现一些意外情况导致出现某些问题,这时代码会自动重新尝试请求,无需用户操作,提高用户体验。
这里封装了axios,用于响应401,403状态码。401则为未登录,403为未授权。以下代码我都进行了详细注释!
axios.js文件:
import axios from 'axios'; // 引入 axios 库,用于进行 HTTP 请求
import axiosRetry from 'axios-retry'; // 引入 axios-retry 库,用于实现请求重试机制
import router from './router'; // 引入 Vue Router 实例,用于页面导航
// 创建一个 axios 实例
export function createAxios(option = {}) {
return axios.create({
...option, // 将传入的选项合并到 axios 实例中
});
}
// 创建一个名为 houseApi 的 axios 实例,设置基本 URL 和超时时间
export const houseApi = createAxios({
baseURL: 'http://localhost:3000', // 设定基础 URL
timeout: 5000, // 请求超时时间设置为 5000 毫秒(5 秒)
});
/**
* 重试机制
*/
let retryCount = 0; // 初始化重试计数
const customRetryCondition = async (error) => {
// 自定义重试条件
if (axios.isAxiosError(error) && error.response?.status !== 200) {
// 如果是 Axios 错误且响应状态不是 200
if (error.response?.status === 403) {
// 如果后端返回 403(禁止访问)
localStorage.removeItem('accessToken'); // 移除 accessToken
localStorage.removeItem('refreshToken'); // 移除 refreshToken
console.log('请重新登录'); // 打印提示信息
router.push('/login'); // 跳转到登录页面
return false; // 不重试
}
if (error.response?.status === 401) {
// 如果后端返回 401(未授权)
await refresh(); // 尝试刷新 token
console.log('刷新token'); // 打印提示信息
return true; // 允许重试
}
retryCount++; // 增加重试计数
console.log(`第${retryCount}次重试`); // 打印当前重试次数
return (
error.response.status >= 500 || // 如果响应状态是 500 或以上
(error.response.status < 500 && error.response?.status !== 401) // 或者状态小于 500 但不等于 401
);
}
return false; // 如果不符合条件,则不重试
};
// 配置 axios 实例的重试机制
axiosRetry(houseApi, {
retries: 3, // 设置最多重试次数为 3 次
retryCondition: customRetryCondition, // 使用自定义的重试条件
retryDelay: axiosRetry.exponentialDelay, // 使用指数退避算法设置重试延迟
});
/**
* 请求拦截器
*/
houseApi.interceptors.request.use(
async function (config) {
console.log('开始请求'); // 打印请求开始信息
const accessToken = localStorage.getItem('accessToken'); // 从 localStorage 获取 accessToken
const refreshToken = localStorage.getItem('refreshToken'); // 从 localStorage 获取 refreshToken
config.headers.accessToken = accessToken ? accessToken : ''; // 设置请求头中的 accessToken
config.headers.refreshToken = refreshToken ? refreshToken : ''; // 设置请求头中的 refreshToken
return config; // 返回配置
},
function (error) {
return Promise.reject(error); // 拒绝请求错误
}
);
/**
* 响应拦截器
*/
houseApi.interceptors.response.use(
async function (response) {
if (response.status === 200) {
return response; // 如果响应状态是 200,返回响应
} else {
return Promise.reject(response.data.message || '未知错误'); // 否则拒绝并返回错误信息
}
},
function (error) {
if (error && error.response) {
// 如果有响应错误
switch (error.response.status) {
case 400:
error.message = '错误请求'; // 处理 400 错误
break;
case 401:
error.message = '未授权,请重新登录'; // 处理 401 错误
break;
case 403:
error.message = '拒绝访问'; // 处理 403 错误
localStorage.removeItem('accessToken'); // 移除 accessToken
localStorage.removeItem('refreshToken'); // 移除 refreshToken
router.push('/login'); // 跳转到登录页面
break;
case 404:
error.message = '请求错误,未找到该资源'; // 处理 404 错误
break;
case 405:
error.message = '请求方法未允许'; // 处理 405 错误
break;
case 408:
error.message = '请求超时'; // 处理 408 错误
break;
case 500:
error.message = '服务器端出错'; // 处理 500 错误
break;
case 501:
error.message = '网络未实现'; // 处理 501 错误
break;
case 502:
error.message = '网络错误'; // 处理 502 错误
break;
case 503:
error.message = '服务不可用'; // 处理 503 错误
break;
case 504:
error.message = '网络超时'; // 处理 504 错误
break;
case 505:
error.message = 'http版本不支持该请求'; // 处理 505 错误
break;
default:
error.message = `连接错误${error.response.status}`; // 处理其他未知错误
}
} else {
error.message = '连接服务器失败'; // 如果没有响应,打印连接失败信息
}
return Promise.reject(error.message); // 拒绝并返回错误信息
}
);
// 重新刷新token
async function refresh() {
let res = await houseApi.get('/login/refresh'); // 发送请求以刷新 token
localStorage.setItem('accessToken', res.data.accessToken); // 将新的 accessToken 存储到 localStorage
}
export default houseApi; // 导出 houseApi 实例
接下来呢就是常规的登录代码和登录进去的首页代码了:
Login.vue:
<template>
<div class="login-container">
<h1 class="login-title">Login</h1>
<div class="input-group">
<input v-model="username" class="login-input" placeholder="Username" />
<input v-model="password" type="password" class="login-input" placeholder="Password" />
<button @click="login" class="login-button">Login</button>
</div>
<pre v-if="errorMessage" class="error-message">{{ errorMessage }}</pre>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import houseApi from '../axios'
const username = ref('')
const password = ref('')
const errorMessage = ref(null)
const router = useRouter()
const login = async () => {
errorMessage.value = null // 重置错误消息
try {
const response = await houseApi.post('/login/userLogin', {
username: username.value,
password: password.value
})
console.log(response)
if (response.data.code === 1) {
alert(response.data.message)
} else if (response.data.code === 2) {
alert(response.data.message)
} else {
localStorage.setItem('accessToken', response.data.accessToken)
localStorage.setItem('refreshToken', response.data.refreshToken)
router.push('/home') // 登录成功后跳转
}
} catch (error) {
// 处理错误
if (error.response) {
errorMessage.value = error.response.data.message || 'Login failed'
} else {
errorMessage.value = 'An unexpected error occurred'
}
}
}
</script>
<style scoped>
body {
margin: 0;
padding: 0;
font-family: 'Arial', sans-serif;
background: linear-gradient(135deg, #74ebd5, #9face6);
}
.login-container {
max-width: 400px;
margin: 100px auto;
padding: 20px;
background: white;
border-radius: 10px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
text-align: center;
}
.login-title {
font-size: 2em;
margin-bottom: 20px;
color: #333;
}
.input-group {
margin: 10px 0;
}
.login-input {
width: 100%;
padding: 10px;
margin: 10px 0;
border: 1px solid #ddd;
border-radius: 5px;
transition: border-color 0.3s;
}
.login-input:focus {
border-color: #74ebd5;
outline: none;
}
.login-button {
width: 100%;
padding: 10px;
background: #74ebd5;
border: none;
border-radius: 5px;
color: white;
font-weight: bold;
cursor: pointer;
transition: background 0.3s;
}
.login-button:hover {
background: #9face6;
}
.error-message {
color: red;
margin-top: 10px;
font-size: 0.9em;
}
</style>
Home.vue:
<template>
<div class="form-container">
<h2 class="form-title">添加用户</h2>
<input v-model="username" type="text" placeholder="用户名" class="form-input" />
<input v-model="password" type="password" placeholder="密码" class="form-input" />
<input v-model="phone" type="text" placeholder="手机号" class="form-input" />
<button @click="add" class="form-button">添加</button>
<pre v-if="errorMessage" class="error-message">{{ errorMessage }}</pre>
</div>
</template>
<script setup>
import { ref } from 'vue';
import houseApi from '../axios';
const username = ref('');
const password = ref('');
const phone = ref('');
const errorMessage = ref(null);
const add = async () => {
errorMessage.value = null; // 重置错误消息
try {
const { data: { code, message } } = await houseApi.post('/login/add', {
username: username.value,
password: password.value,
phone: phone.value
});
if (code === 200) {
alert('用户添加成功');
// 可以清空输入框或其他操作
} else {
errorMessage.value = message || '添加失败';
}
} catch (error) {
errorMessage.value = error.response?.data?.message || '网络错误';
}
};
</script>
<style scoped>
body {
background: linear-gradient(135deg, #74ebd5, #9face6);
}
.form-container {
max-width: 400px;
margin: 100px auto;
padding: 20px;
background: white;
border-radius: 10px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
text-align: center;
}
.form-title {
font-size: 1.5em;
margin-bottom: 20px;
color: #333;
}
.form-input {
width: 100%;
padding: 10px;
margin: 10px 0;
border: 1px solid #ddd;
border-radius: 5px;
transition: border-color 0.3s;
}
.form-input:focus {
border-color: #74ebd5;
outline: none;
}
.form-button {
width: 100%;
padding: 10px;
background: #74ebd5;
border: none;
border-radius: 5px;
color: white;
font-weight: bold;
cursor: pointer;
transition: background 0.3s;
}
.form-button:hover {
background: #9face6;
}
.error-message {
color: red;
margin-top: 10px;
font-size: 0.9em;
text-align: left;
}
</style>
完整代码
下载代码请复制以下命令到终端:
git clone https://gitee.com/wjl001123/token_two.git