Skip to content

无感刷新

应用场景

sh
1. 正在看学习资料,突然告诉你token过期了,该重新登录了。一下回到登录页了,这是大家都不喜欢的。

基本原理

sh
1. 利用双token accessToken和refreshTokne 长短token
2. 在发送请求的时候通过在请求头中添加短token(accessToken)
3. 长token在响应返回短token过期后,用来发送请求刷新token的

实现流程

sh
1. 登录成功后颁发长短token
2. 短token放在请求头中
3. 长token用在短token失效,后端返回短token过期信息后,
	在响应拦截中执行长token验证的请求,发送给后端,
	后端会验证长token有没有过期。

	-	没过期: 返回新的长短token,
	-	过期: 返回长token过期了的信息,前端会返回登录页

总结

实现了

sh
1. 无感刷新
2. 避免多次请求, 长token方法 添加控制器   避免发送多次长token验证
3. 触发短token失效的请求权,会暂存请求,后续token更新完成 继续执行

无感刷新

image-20240401191354552

避免多次刷新

image-20240401190242871

暂存请求 将()=> { resolve(执行的请求) } 箭头函数进行存储

image-20240401185554441

image-20240401190123281

前端 - Vue

常量文件

js
// /config/constants.js
export const ACCESS_TOKEN = 'accessToken'; // 短token字段
export const REFRESH_TOKEN = 'refreshToken'; // 长token字段
export const AUTH = 'Authorization'; // header头部 携带短token
export const PASS = 'pass'; // header头部 携带长token

方法文件

js
// /config/storage.js
// 存储短token
import * as constants from './constants';

// 存储短token
export const setAccessToken = (token) => localStorage.setItem(constants.ACCESS_TOKEN, token);
// 存储长token
export const setRefreshToken = (token) => localStorage.setItem(constants.REFRESH_TOKEN, token);
// 获取短token
export const getAccessToken = () => localStorage.getItem(constants.ACCESS_TOKEN);
// 获取长token
export const getRefreshToken = () => localStorage.getItem(constants.REFRESH_TOKEN);
// 删除短token
export const removeAccessToken = () => localStorage.removeItem(constants.ACCESS_TOKEN);
// 删除长token
export const removeRefreshToken = () => localStorage.removeItem(constants.REFRESH_TOKEN);

// 请求后端测试数据
export const getUserInfoFormServer = () => axios.get('/vue/info');
// 登录验证接口
export const userLoginFormServer = (details) => axios.post('/vue/Login', details);

axios 的请求响应拦截

js
import axios from 'axios';
import * as storage from '../../config/storage';
import * as constants from '../../config/constants';
import { addRequest, refreshToken } from '../refresh';

const server = axios.create({
    baseURL: 'http://localhost:3000/',
    timeout: 5000 * 10,
    headers: { 'Content-Type': 'application/json' },
});

// 请求拦截器
server.interceptors.request.use(
    (config) => {
        // 获取短token  添加进请求头,在短token失效时,再使用长token去请求后端的 /refresh 接口验证长token是不是过期   refreshToken()方法
        let accessToken = storage.getAccessToken(constants.ACCESS_TOKEN);
        config.headers[constants.AUTH] = accessToken;
        return config;
    },
    (error) => {
        return Promise.reject(error);
    },
);
/*响应拦截器*/
server.interceptors.response.use(
    async (response) => {
        // 获取到配置和后端响应的数据
        let { config, data } = response;
        return new Promise((resolve, reject) => {
            // 短token失效
            if (data.code === 403) {
                // 移除失效的短token
                storage.removeAccessToken(constants.ACCESS_TOKEN);
                console.log('短token失效');
                // 把过期请求存储起来,用于请求到新的短token,再次请求,达到无感刷新
                addRequest(() => resolve(server(config)));
                // 携带长token去请求新的token
                refreshToken();
            } else {
                // 有效返回相应的数据
                resolve(data);
            }
        });
    },
    (error) => {
        return Promise.reject(error);
    },
);
export default server;

处理短 token 过期(refresh)

处理短 token 过期

js
export { REFRESH_TOKEN, PASS } from '@/config/constants';
import { getRefreshToken, removeRefreshToken, setAccessToken, setRefreshToken, backToLogin } from '@/config/storage';
import server from './http/index';
let subscribes = []; // 存 被打断的响应函数 后面继续访问
let flag = false; // 设置开关,保证一次只能请求一次短token,防止客户多此操作,多次请求

/*把过期请求添加在数组中*/
export const addRequest = (request) => {
    subscribes.push(request);
};

/*调用过期请求*/
export const retryRequest = () => {
    console.log('重新请求上次中断的数据');
    subscribes.forEach((request) => request());
    subscribes = []; // 清空队列,用于下次中断的请求
};

/*短token过期,携带token去重新请求token*/
export const refreshToken = async () => {
    // console.log('flag--', flag) /*防止重复请求 && 调试*/
    if (!flag) {
        flag = true;
        let r_tk = getRefreshToken(); // 获取长token
        if (r_tk) {
            let res = await server.get(
                '/vue/refresh',
                Object.assign(
                    {},
                    {
                        headers: { PASS: r_tk },
                    },
                ),
            );
            //长token失效,退出登录
            if (res.code === 401) {
                console.log('长token失效');
                flag = false;
                removeRefreshToken();
                // 退出登录
                window.alert('请重新登录');
                backToLogin();
            } else {
                // 存储新的token
                setAccessToken(res.data.accessToken);
                setRefreshToken(res.data.refreshToken);
                flag = false;
                // 重新请求数据
                retryRequest();
            }
        }
    }
};

Vue 导航守卫

js
// 全局前置导航守卫
router.beforeEach((to, from, next) => {
    let token = getAccessToken();
    if (token) {
        next();
    } else {
        if (to.path === '/login') {
            next();
        } else {
            next('/login');
        }
    }
});

页面

vue
<script setup>
    import { ref } from 'vue';
    import { loginRules, userLogin } from '@/server/Login';
    import { useRouter } from 'vue-router';
    const details = ref({ username: '', password: '' }); // 提交信息
    const router = useRouter();
    // 登录本地存储token
    const LoginValidate = () => {
        if (loginRules(details.value)) {
            userLogin(details.value).then((res) => {
                if (res.code === 200) {
                    localStorage.setItem('accessToken', res.accessToken);
                    localStorage.setItem('refreshToken', res.refreshToken);
                    router.push('/home');
                } else {
                    alert(res.msg);
                }
            });
        }
    };
</script>
<template>
    <form
        class="login_from"
        @submit.prevent>
        <input
            class="login-username"
            type="text"
            v-model="details.username"
            placeholder="用户名/账号" />
        <input
            class="login-password"
            type="text"
            v-model="details.password"
            placeholder="密码" />
        <button @click="LoginValidate">提交</button>
    </form>
    <!-- 找回账号密码 -->
    <div class="login-forget">
        <span>忘记账号名</span>
        <span>找回密码</span>
    </div>
</template>

后端 - node+express

中间件

验证 token 前端收到 403 直到短 token 过期了,立即使用长 token 获取

js
//    /middleware/jwtAuth.js
const { secret } = require('../utils/token'); // 引入密钥
const jwt = require('jsonwebtoken'); // 我在全局配置过其实这里不用配置了,但是觉得这样可能会减小认知负担就留下了,问就是第六感
// 白名单
const whiteList = ['/vue/Login', '/vue/refresh'];
// 判断是否在白名单中
const isWhiteList = (path) => whiteList.includes(path);

// 中间件
const auth = async (req, res, next) => {
    // 是白名单直接进入路由
    if (isWhiteList(req.path)) return next();

    // 获取请求头中的短token
    const authHeader = req.headers['authorization'];

    // 验证短token过没过期
    jwt.verify(authHeader, secret, (err, decoded) => {
        if (err) {
            // 短token过期了返回信息 403(服务器理解请求,但, 拒绝执行!)
            return res.send({
                code: 403,
                msg: 'token验证失败',
            });
        } else {
            req.user = decoded;
            next();
        }
    });
};

module.exports = auth;

App 配置文件

在配置文件中应用中间件

js
// App.js
var jwtAuth = require('./middleware/jwtAuth');
var cors = require('cors');
var app = express();
//  顺序很重要
app.use(cors()); //1  跨域
app.use(jwtAuth); //2  中间件验证token

路由文件

js
// 登录校验接口
router.post('/Login', async function (req, res, next) {
    let { username, password } = req.body;
    let data = await userModel.find(req.body).lean();
    if (data.length == 0) {
        res.send({
            code: 400,
            msg: '用户不存在',
        });
    } else {
        if (data[0].username === username && data[0].password === password) {
            res.send({
                code: 200,
                msg: '登录成功',
                // 登录成功颁发两个token
                accessToken: accessToken({ username: username }),
                refreshToken: refreshToken({ username: username }),
            });
        } else {
            res.send({
                code: 400,
                msg: '用户名或密码错误',
            });
        }
    }
});

/**
 * 验证长token是否有效?刷新accessTokne : 重新登录
 *
 * 注意: 刷新短tokne的时候 长token也刷新,延续长tokne
 *
 * 这样就可以实现,长时间不活跃才会重新登录
 */

// 验证长token接口
router.get('/refresh', async function (req, res, next) {
    let code,
        msg,
        data = null;
    console.log('执行了refresh');
    let r_tk = req.headers['pass'];
    //解析token 参数 token 密钥 回调函数返回信息
    jwt.verify(r_tk, secret, (error) => {
        if (error) {
            (code = 401), (msg = '长token无效,请重新登录');
        } else {
            (code = 200), (msg = '长token有效,返回新的token');
            data = {
                accessToken: accessToken({ name: 'zs' }),
                refreshToken: refreshToken({ name: 'zs' }),
            };
        }
        res.send({
            code,
            msg: msg ? msg : null,
            data,
        });
    });
});

// 请求数据测试接口
router.get('/info', async function (req, res, next) {
    let data = await userModel.find();
    res.send({
        code: 200,
        msg: '响应成功',
        data: data,
    });
});

常量&方法文件

js
// utils/token.js

const jwt = require('jsonwebtoken'); // 引入jsonwebtoken

/**
 * jwt.sign({存放的数据,通常是用户名等信息},密钥,{expiresIn:过期时间})
 * jwt.verify(token,密钥,(err)=>{执行的回调})
 */
const secret = 'ZY_ML'; // 密钥
const accessTokenTime = 5;
const refreshTokenTime = 10;

// 生成accessToken
const accessToken = (payload) => {
    return jwt.sign(payload, secret, { expiresIn: accessTokenTime });
};
// 生成refreshTokne
const refreshToken = (payload) => {
    return jwt.sign(payload, secret, { expiresIn: refreshTokenTime });
};

// 导出
module.exports = {
    secret,
    accessToken,
    refreshToken,
};