在前后端分离项目中,身份认证是保障系统安全的核心环节。传统单 Token 认证存在过期重新登录、Token 被盗风险高等问题,而双 Token 验证(Access Token + Refresh Token) 结合无感刷新机制,能有效解决这些痛点,提升用户体验与系统安全性。本文将基于 Vue3 前端和 Node.js 后端,从零实现这一完整方案。

一、双 Token 验证核心原理​

在开始编码前,我们需要先明确双 Token 机制的工作流程,理解其设计逻辑:​

Token 类型​

作用​

有效期​

存储位置​

安全性要求​

Access Token​

接口访问凭证,请求需携带​

短期(15-30 分钟)​

内存(如 Pinia)​

较低(过期快,降低泄露风险)​

Refresh Token​

刷新 Access Token 的凭证​

长期(7-30 天)​

HttpOnly Cookie​

极高(禁止前端操作,防 XSS)​

核心流程​

  1. 登录阶段:用户提交账号密码,后端验证通过后,生成一对 Token(Access Token + Refresh Token),并返回给前端;​
  1. 接口请求阶段:前端每次请求需在 Header 中携带 Access Token,后端验证 Token 有效性,有效则返回数据,无效则返回 401;​
  1. 无感刷新阶段:当 Access Token 过期(后端返回 401),前端自动携带 Refresh Token 请求 “刷新接口”,获取新的 Access Token;​
  1. Refresh Token 过期:若 Refresh Token 过期,前端引导用户重新登录。
  2. 二、技术栈准备​

    在开始实现前,确保已搭建好以下环境与依赖:​

    端​

    核心技术 / 依赖​

    用途说明​

    前端(Vue3)​

    Vue3 + Pinia(状态管理) + Axios(请求库)​

    存储 Token、拦截请求 / 响应、实现无感刷新逻辑​

    后端(Node.js)​

    Express(Web 框架) + jsonwebtoken(JWT 库) + cors(跨域处理)​

    生成 Token、验证 Token、处理跨域 Cookie 传递​

    三、后端实现(Node.js)​

    后端核心任务:生成双 Token、验证 Access Token、通过 Refresh Token 刷新 Access Token、配置跨域支持 Cookie 传递。用到了

    bcryptjs,body-parser,cookie-parser,cors,cross-env,dotenv,express, jsonwebtoken,multer,mysql, nodemon, supervisor,看个人需要用到哪些包。下面是文件夹参考

3.1入口文件

//app.js
const express = require('express')
const cors = require('cors')
const bodyParser = require('body-parser')
const cookieParser = require('cookie-parser');
// 引入dotenv加载环境变量
require('dotenv').config({
  path: `.env.${process.env.NODE_ENV || 'development'}` // 根据NODE_ENV加载对应文件
})

const app = express()

// 示例:根据环境变量配置不同行为
if (process.env.NODE_ENV === 'development') {
  // 开发环境:打印请求日志
  app.use((req, res, next) => {
    console.log(`[${new Date().toISOString()}] ${req.method} ${req.url}`);
    next();
  });
}

// 路由和中间件配置...
const login = require('./router/login')
const setting = require('./router/setting')

app.use(bodyParser.json({limit: '50mb'}))
app.use(bodyParser.urlencoded({ extended: false }))
app.use(cookieParser());
app.use(cors({
  origin: (origin, callback) => {
    // 允许的来源(开发环境+生产环境)
    const allowedOrigins = [
      'http://localhost:5174',       // 开发环境
      'https://11qh4075vd771.vicp.fun'  // 实际生产环境域名
    ];
    
    // 允许本地请求(origin为null的情况,如Postman)
    if (!origin) return callback(null, true);
    
    // 检查当前来源是否在允许列表中
    if (allowedOrigins.includes(origin)) {
      callback(null, true); // 允许跨域
    } else {
      callback(new Error('当前域名不允许跨域访问'), false); // 拒绝跨域
    }
  },  // 开发环境本地地址
  credentials: true
}))
app.use(login)
app.use(setting)

// 错误处理:根据环境返回不同信息
app.use((err, req, res, next) => {
  console.error(err.stack);
  if (process.env.NODE_ENV === 'production') {
    res.status(500).send('服务器错误'); // 生产环境隐藏详细错误
  } else {
    res.status(500).send(`错误:${err.message}`); // 开发环境显示详细错误
  }
});

const PORT = process.env.PORT || 3001; // 从环境变量获取端口,默认3001
app.listen(PORT, () => {
  console.log(`Server running in ${process.env.NODE_ENV || 'development'} mode on port ${PORT}`);
});

3.2 token.js


//双token
const jwt = require('jsonwebtoken');
const JWT_SECRET = '6666QQ..!!'; // 生产环境请使用更安全的密钥
const REFRESH_SECRET = '666666^'; // 刷新令牌专用密钥

// 生成Access Token(短期有效)
const generateAccessToken = (user) => {
    return jwt.sign(user, JWT_SECRET, { expiresIn: '1000s' }); // 1000秒过期
};

// 生成Refresh Token(长期有效)
const generateRefreshToken = (user) => {
    return jwt.sign(user, REFRESH_SECRET, { expiresIn: '1d' }); // 1天过期
};

// 验证Access Token中间件
const authenticateJWT = (req, res, next) => {
    
    const authHeader = req.headers['authorization'];
    const token = authHeader && authHeader.split(' ')[1];
    

    if (!token) {
        
        return res.sendStatus(401); // 未提供Token
    }

    jwt.verify(token, JWT_SECRET, (err, user) => {
        
        if (err) {
            if (err.name === 'TokenExpiredError') {
                
                return res.status(401).send({ message: '令牌已过期' });
            }
            return res.status(403).send({ message: '令牌无效' });
        }
        req.user = user;
        next();
    });
};

// 验证Refresh Token
const verifyRefreshToken = (token) => {
    try {
        return jwt.verify(token, REFRESH_SECRET);
    } catch (err) {
        return null;
    }
};

module.exports = {
    JWT_SECRET,
    REFRESH_SECRET,
    authenticateJWT,
    generateAccessToken,
    generateRefreshToken,
    verifyRefreshToken
};



3.3 接口示例(api>login.js)

let db = require("../db/index");
const { encrypt, decrypt } = require("../verify/AESHelper");
const {
    generateAccessToken,
    generateRefreshToken,
    verifyRefreshToken
} = require('../verify/token');

exports.adminpswlogin = (req, res) => {
    const { phone, password } = req.body;
    // console.log(phone, password, decrypt(password), '传递的数据')

    var sql = `select * from admin where phone='${phone}'`;
    db.query(sql, [], (err, data) => {
        if (err) {
            console.log("出错了", err);
            return res.send("错误" + err.message);
        }

        const user = data.find(u => u.phone === phone && decrypt(u.password) === decrypt(password));
        console.log(user, '找到的数据')

        if (!user) {
            console.log(user);
            res.send({ status: 400 }); // 账号或密码错误
        } else {
            // 用户信息 payload
            const payload = {
                phone: user.phone,
                id: user.id,
                level: user.level
            };

            // 生成双Token
            const accessToken = generateAccessToken(payload);
            const refreshToken = generateRefreshToken(payload);

            // 存储refreshToken到数据库
            const updateSql = `UPDATE admin SET refresh_token = ? WHERE id = ?`;
            db.query(updateSql, [refreshToken, user.id], (err) => {
                if (err) {
                    console.log("存储refreshToken失败", err);
                    return res.send({ status: 500, message: "登录失败" });
                }
                console.log('当前环境:', process.env.NODE_ENV);
                // 设置HttpOnly Cookie存储refreshToken
                res.cookie('refreshToken', refreshToken, {
                    httpOnly: true,       // 仅HTTP(S)协议访问,防止JS读取
                    secure: true,
                    sameSite: 'None', // 跨域开发时放宽 sameSite
                    maxAge: 1 * 24 * 60 * 60 * 1000 // 1天有效期
                   
                });

                // 返回结果(仅返回accessToken)
                res.send({
                    status: 200,
                    accessToken,  // Access Token
                    phone: user.phone,
                    level: user.level,
                    username: user.username,
                    id: user.id,
                    accstatus: user.status
                });
            });
        }
    });
};

// 刷新AccessToken接口 - 从Cookie获取refreshToken
exports.refreshToken = (req, res) => {
    // 从请求体中获取用户 ID(前端需要传递 userId)
    if (process.env.NODE_ENV === 'production') {
        // 1. 从Cookie中获取refreshToken
        const refreshToken = req.cookies.refreshToken;

        // 2. 检查refreshToken是否存在
        if (!refreshToken) {
            return res.send({
                status: 401,
                message: 'refreshToken不存在,请重新登录'
            });
        }

        try {
            // 3. 验证refreshToken的有效性
            const decoded = verifyRefreshToken(refreshToken); // 假设verifyRefreshToken是验证refreshToken的函数

            // 4. 从数据库查询该refreshToken是否存在(防止已注销的token被复用)
            const sql = `SELECT * FROM admin WHERE id = ? AND refresh_token = ?`;
            db.query(sql, [decoded.id, refreshToken], (err, data) => {
                if (err) {
                    console.log('查询refreshToken失败', err);
                    return res.send({ status: 500, message: '服务器错误' });
                }

                // 5. 验证数据库中是否存在匹配的记录
                if (data.length === 0) {
                    return res.send({
                        status: 401,
                        message: 'refreshToken无效或已过期,请重新登录'
                    });
                }

                // 6. 生成新的accessToken(使用相同的用户信息)
                const newAccessToken = generateAccessToken({
                    phone: decoded.phone,
                    id: decoded.id,
                    level: decoded.level
                });

                // 7. 返回新的accessToken
                res.send({
                    status: 200,
                    accessToken: newAccessToken,
                    message: 'accessToken刷新成功'
                });
            });

        } catch (error) {
            // 处理token验证失败(如过期、篡改等)
            console.log('refreshToken验证失败', error);
            return res.send({
                status: 401,
                message: 'refreshToken已过期,请重新登录'
            });
        }
    } else {
        const { userId } = req.body;
        console.log(userId,'刷新token的参数');
        
        if (!userId) {
            return res.send({ status: 400, message: "缺少用户 ID" });
        }

        // 从数据库查询该用户的 refresh_token
        const sql = `SELECT refresh_token FROM admin WHERE id = ?`;
        db.query(sql, [userId], (err, data) => {
            if (err) {
                console.log("数据库查询错误", err);
                return res.send({ status: 500, message: "服务器错误" });
            }

            if (data.length === 0) {
                return res.send({ status: 404, message: "未找到该用户" });
            }

            const storedRefreshToken = data[0].refresh_token;
            if (!storedRefreshToken) {
                return res.send({ status: 401, message: "该用户无有效身份令牌" });
            }

            // 验证 refreshToken
            const decoded = verifyRefreshToken(storedRefreshToken);
            if (!decoded) {
                return res.send({ status: 401, message: "身份令牌无效" });
            }

            // 生成新的 accessToken
            const newAccessToken = generateAccessToken({
                phone: decoded.phone, // 假设 payload 中有 phone
                id: decoded.id,
                level: decoded.level
            });

            res.send({ status: 200, accessToken: newAccessToken });
        });
    }

};
// 登出接口 - 清除Cookie
exports.logout = (req, res) => {
    const { id } = req.body;

    // 清除数据库中的refreshToken
    const sql = `UPDATE admin SET refresh_token = NULL WHERE id = ?`;
    db.query(sql, [id], (err) => {
        if (err) {
            return res.send({ status: 500, message: "登出失败" });
        }

        // 清除Cookie
        res.clearCookie('refreshToken');
        res.send({ status: 200, message: "登出成功" });
    });
};

//查找admin表的所有数据
exports.getadmin = (req, res) => {
    var sql = `select * from admin`;
    db.query(sql, [], (err, data) => {
        if (err) {
            console.log("出错了", err);
            return res.send("错误" + err.message);
        }
        res.send({ status: 200, data });
    });
};

router>login.js

const express = require('express')
const router = express.Router()
const login = require('../api/login')
const {authenticateJWT}=require('../verify/token')


router.post('/adminpswlogin',login.adminpswlogin)
router.post('/refreshToken',login.refreshToken)
router.post('/getadmin',authenticateJWT,login.getadmin)
router.post('/logout', authenticateJWT, login.logout); 

module.exports = router

四、前端实现

前端的核心工作是:管理 Access Token、拦截请求自动携带 Token、拦截 401 响应实现无感刷新、控制路由访问权限。

4.1axios封装

import axios from 'axios';
import { ElMessage, ElMessageBox } from 'element-plus'; 
import router from '@/router/index';

const service = axios.create({
    baseURL: '地址', 

    timeout: 15000, 
    withCredentials: true 
});

let isRefreshing = false;
let requestQueue = [];

service.interceptors.request.use(
    (config) => {
        const isAuth = config.headers.isAuth !== false; 

        if (isAuth) {
            const accessToken = localStorage.getItem('access_token');
            if (accessToken) {
                config.headers['Authorization'] = `Bearer ${accessToken}`;
            } else if (!config._isRefreshRequest) {
                ElMessage.error('请先登录');
                return Promise.reject(new Error('未授权'));
            }
        }
        
        delete config.headers.isAuth;
        return config;
    },
    (error) => Promise.reject(error)
);

service.interceptors.response.use(
    (response) => {
        console.log('响应数据:', response);
        // 这里假设后端返回的有效数据在 response.data 中,且数据里有 status 字段
        const res = response.data;
        // 统一处理后端返回格式,判断 res 里的 status
        if (res.status !== 200) {
            ElMessage.error(res.message || '操作失败');
            return Promise.reject(new Error(res.message || '操作失败'));
        }
        // 只返回数据部分
        return res;
    },
    async (error) => {
        const originalRequest = error.config;

        if (error.response?.status === 401 && !originalRequest._retry) {
            originalRequest._retry = true;

            if (!originalRequest._isRefreshRequest && !isRefreshing) {
                isRefreshing = true;
                
                try {
                    const refreshResponse = await service.post(
                        '/refreshToken', 
                        {userId:1}, 
                        { _isRefreshRequest: true, headers: { isAuth: false } }
                    );
                    console.log('刷新令牌响应数据:', refreshResponse);
                    
                    // const res = refreshResponse.data;
                    if (refreshResponse.status === 200 && refreshResponse.accessToken) {
                        localStorage.setItem('access_token', refreshResponse.accessToken);
                        
                        requestQueue.forEach(cb => cb(refreshResponse.accessToken));
                        requestQueue = [];
                        
                        originalRequest.headers['Authorization'] = `Bearer ${refreshResponse.accessToken}`;
                        return service(originalRequest);
                    } else {
                        throw new Error('刷新令牌失败或返回格式错误');
                    }
                } catch (refreshError) {
                    localStorage.removeItem('access_token');
                    
                    const currentPath = router.currentRoute.value.fullPath;
                    ElMessageBox.confirm(
                        '登录已过期,请重新登录', 
                        '提示', 
                        {
                            confirmButtonText: '重新登录',
                            cancelButtonText: '取消',
                            type: 'warning'
                        }
                    ).then(() => {
                        router.push(`/login?redirect=${currentPath}`);
                    }).catch(() => {
                        // 用户取消登录,不做操作
                    });
                    
                    return Promise.reject(refreshError);
                } finally {
                    isRefreshing = false;
                }
            } else {
                return new Promise((resolve) => {
                    requestQueue.push((newToken) => {
                        originalRequest.headers['Authorization'] = `Bearer ${newToken}`;
                        resolve(service(originalRequest));
                    });
                });
            }
        }

        if (error.response) {
            const errorMsg = error.response.data?.message || 
                           error.response.statusText || 
                           `请求错误: ${error.response.status}`;
            
            switch (error.response.status) {
                case 403:
                    ElMessage.error('没有权限访问');
                    break;
                case 404:
                    ElMessage.error('请求的资源不存在');
                    break;
                case 500:
                    ElMessage.error('服务器内部错误');
                    break;
                default:
                    ElMessage.error(errorMsg);
            }
        } else if (error.message.includes('timeout')) {
            ElMessage.error('请求超时,请稍后重试');
        } else {
            ElMessage.error('网络连接错误,请检查网络');
        }
        
        return Promise.reject(error);
    }
);

const http = {
    get: (url, params = {}, isAuth = true) => service.get(url, { params, headers: { isAuth } }),
    post: (url, data = {}, isAuth = true) => service.post(url, data, { headers: { isAuth } }),
    put: (url, data = {}, isAuth = true) => service.put(url, data, { headers: { isAuth } }),
    delete: (url, params = {}, isAuth = true) => service.delete(url, { params, headers: { isAuth } }),
   
};

export default http;

刷新的关键

使用

更多推荐