当前位置: 首页 > article >正文

双token无感刷新nodejs+vue3(保姆级教程)

什么是双 Token 无感刷新?

双 Token 无感刷新机制使用两个不同的 token 来管理用户的身份验证和会话。通常情况下,这两个 token 是:

  1. 访问 Token(Access Token):用于访问受保护的资源,通常具有较短的有效期(如 15 分钟到 1 小时)。当用户进行 API 请求时,附带此 token 以证明其身份。

  2. 刷新 Token(Refresh Token):用于获取新的访问 token,通常具有较长的有效期(如几天到几个月)。刷新 token 不会频繁发送到服务器,而是在访问 token 过期后用于请求新的访问 token。

工作流程

  1. 用户登录:用户通过用户名和密码登录,服务器验证凭据后生成访问 token 和刷新 token,并将其返回给客户端。

  2. 使用访问 Token:客户端使用访问 token 进行 API 请求。当访问 token 过期时,客户端将无法访问受保护的资源。

  3. 无感刷新

    • 当访问 token 过期时,客户端会自动使用刷新 token 请求新的访问 token,而无需用户重新登录。
    • 服务器验证刷新 token 的有效性,如果有效,则生成新的访问 token 并返回给客户端。
    • 客户端使用新的访问 token 继续进行 API 请求。
  4. 刷新 Token 的管理

    • 刷新 token 也可以设置过期时间,通常在用户长时间不活动时使其失效。
    • 服务器可以在刷新 token 失效时要求用户重新登录。

优势

  1. 用户体验:用户在使用应用时不需要频繁登录,提供了无缝的体验。

  2. 安全性:访问 token 的有效期较短,可以降低 token 被盗用的风险。即使访问 token 被盗,攻击者也只能在短时间内使用它。

  3. 灵活性:可以根据需要调整访问 token 和刷新 token 的有效期,以平衡安全性和用户体验。

  4. 支持登出:当用户选择登出时,可以使刷新 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


http://www.kler.cn/a/381704.html

相关文章:

  • 在 CentOS 8 系统上安装 Jenkins 的全过程
  • 【Java基础-26.1】Java中的方法重载与方法重写:区别与使用场景
  • 云手机+YouTube:改变通信世界的划时代技术
  • Golang的容器化技术实践总结
  • clickhouse复现修复 结构需要清理 错误 structure need clean
  • Android 之 List 简述
  • 【Eclipse系列】Eclipse版本与jdk对应版本
  • MySQL 安装与配置
  • 大数据-204 数据挖掘 机器学习理论 - 混淆矩阵 sklearn 决策树算法评价
  • 如何用pycharm连接sagemath?
  • FPGA跨时钟域处理方法
  • 【MATLAB源码-第206期】基于matlab的差分进化算法(DE)机器人栅格路径规划,输出做短路径图和适应度曲线。
  • 独显装完ubuntu后启动黑屏显示/dev/sda:clean files blocks的解决方案
  • 基于java+SpringBoot+Vue的微服务在线教育系统设计与实现
  • 指标+AI+BI:构建数据分析新范式丨2024袋鼠云秋季发布会回顾
  • 循环神经网络RNN文本分类
  • Gitlab自动化相关脚本
  • 国标GB28181视频平台EasyCVR私有化视频平台工地防盗视频监控系统方案
  • PHP的四大安全策略
  • 第二次web前端作业(西安欧鹏)
  • Web前端第二次作业
  • Docker:介绍与安装
  • LangChain教程 - 创建 ReAct 风格智能代理
  • 【ShuQiHere】️ 深入了解 ADB(Android Debug Bridge):您的 Android 开发利器!
  • Rust常用数据结构教程 Rust中的数据结构
  • STM32滴答时钟是否每次计时1ms都要中断一下,更新ms数