双Token实现无感刷新

0 阅读3分钟

一、为什么需要无感刷新?

想象一下你正在刷视频,突然提示"登录已过期,请重新登录",需要退出当前页面重新输入密码。这样的体验非常糟糕!无感刷新就是为了解决这个问题:让用户在不知不觉中完成身份续期,保持长时间在线状态。

二、双Token机制原理

我们使用两个令牌:

  1. 短令牌:access_token(1小时):用于日常请求
  2. 长令牌:refresh_token(7天):专门用来刷新令牌

工作流程:

用户登录 → 获取双令牌 → access_token过期 → 用refresh_token获取新的双令牌 → 自动续期

三、前端实现(Vue + Axios)

1. 登录存储令牌
const login = async () => {
  const res = await userLogin(user); //账号密码
  // 保存双令牌到本地
  localStorage.setItem('access_token', res.access_token);
  localStorage.setItem('refresh_token', res.refresh_token);
}
2. 请求自动携带令牌

通过请求拦截器自动添加认证头:

api.interceptors.request.use(config => {
  const access_token = localStorage.getItem('access_token');
  if (access_token) {
    config.headers.Authorization = `Bearer ${access_token}`;
  }
  return config;
})
3. 智能令牌刷新

响应拦截器发现401登录过期的错误时自动请求刷新

验证长令牌是否失效

  • 失效重定向到登录页面
  • 未失效重新获取双令牌并重新发起请求
api.interceptors.response.use(
  (response) => {
    return response
  },
  async (error) => {  // 响应失败
    const { data, status, config } = error.response;
    if (status === 401 && config.url !== '/refresh') {
      // 刷新token
      const res = await refreshToken()  // 校验的函数
      if (res.status === 200) {  // token刷新成功
        // 重新将刚刚失败的请求发送出去
        return api(config)
      } else {
        // 重定向到登录页  router.push('/login')
        window.location.href = '/login'
      }
    }
  }
)

四、后端实现(Node.js + Express)

1. 生成双令牌
// 生成1小时有效的access_token
const access_token = generateToken(user, '1h');
// 生成7天有效的refresh_token
const refresh_token = generateToken(user, '7d');
2. 令牌刷新接口
app.get('/refresh', (req, res) => {
  const oldRefreshToken = req.query.token;
  try {
    // 验证refresh_token有效性
    const userData = verifyToken(oldRefreshToken);
    // 生成新双令牌
    const newAccessToken = generateToken(userData, '1h');
    const newRefreshToken = generateToken(userData, '7d');
    res.json({ access_token: newAccessToken, refresh_token: newRefreshToken });
  } catch (error) {
    res.status(401).send('令牌已失效');
  }
})

五、完整代码

1. 前端代码
<template>
  <div v-if="!isLogin">
    <button @click="login">登录</button>
  </div>

  <div v-else>
    <h1>登录成功</h1>
    <p>欢迎回来,{{ username }}</p>
    <p>您的邮箱:{{ email }}</p>
  </div>


  <!-- home -->
   <div v-if="isLogin">
    <button @click="getHomeData">获取首页数据</button>
   </div>
</template>

<script setup>
import { ref } from 'vue'
import { userLogin, getHomeDataApi } from './api.js'

const isLogin = ref(false)
const username = ref('')
const email = ref('')
const password = ref('')


const login = async() => {
  username.value = 'zs'
  email.value = '123@qq.com'
  password.value = '123'

  const res = await userLogin({username: username.value, email: email.value, password: password.value})
  console.log(res)
  const {access_token, refresh_token, userInfo} = res.data
  if (access_token) {
    isLogin.value = true
  }
  localStorage.setItem('access_token', access_token)
  localStorage.setItem('refresh_token', refresh_token)
}


const getHomeData = async() => {
  const res = await getHomeDataApi()
  console.log(res)
}


</script>

<style lang="css" scoped>

</style> 
// api.js
import axios from 'axios'

const api = axios.create({
  baseURL: 'http://localhost:3000',
  timeout: 3000,  
})

// 请求拦截器
api.interceptors.request.use(config => {
  const access_token = localStorage.getItem('access_token');
  if (access_token) {
    config.headers.Authorization = `Bearer ${access_token}`;
  }
  return config;
})

// 响应拦截器
api.interceptors.response.use(
  (response) => {
    return response
  },
  async (error) => {  // 响应失败
    const { data, status, config } = error.response;
    if (status === 401 && config.url !== '/refresh') {
      // 刷新token
      const res = await refreshToken()
      if (res.status === 200) {  // token刷新成功
        // 重新将刚刚失败的请求发送出去
        return api(config)
      } else {
        // 重定向到登录页  router.push('/login')
        window.location.href = '/login'
      }
    }
  }
)


export const userLogin = (data) => {
  return api.post('/login', data)
}

export const getHomeDataApi = () => {
  return api.get('/home')
}

async function refreshToken() {
  const res = await api.get('/refresh', {
    params: {
      token: localStorage.getItem('refresh_token')
    }
  })
  localStorage.setItem('access_token', res.data.access_token)
  localStorage.setItem('refresh_token', res.data.refresh_token)
  return res
}
2. 后端代码
server.js
const express = require('express');
const app = express();
const port = 3000;
app.use(express.json());  // 解析 JSON 格式的请求体
const jwtToken = require('./token.js');
const cors = require('cors');

app.use(cors())


const users = [
  { username: 'zs', password: '123', email: '123@qq.com' },
  { username: 'ls', password: '456', email: '456@qq.com' }
]




app.get('/', (req, res) => {
  res.send('Hello World!');
});

app.post('/login', (req, res) => {
  const { username, password } = req.body;
  const user = users.find(user => user.username === username);
  if (!user) {
    return res.status(404).json({status: 'error', message: '用户不存在'});
  }
  if (user.password !== password) {
    return res.status(401).json({status: 'error', message: '密码错误'});
  }

  // 生成两个 token
  const access_token = jwtToken.generateToken(user, '1h');
  const refresh_token = jwtToken.generateToken(user, '7d');

  res.json({
    userInfo: {
      username: user.username,
      email: user.email
    },
    access_token,
    refresh_token
  })


})

// 需要token 认证的路由
app.get('/home', (req, res) => {
  const authorization = req.headers.authorization;
  if (!authorization) {
    return res.status(401).json({status: 'error', message: '未登录'});
  }

  try {
    const token = authorization.split(' ')[1];  // 'Bearer esdadfadadxxxxxxxxx'
    const data = jwtToken.verifyToken(token);
    res.json({ status: 'success', message: '验证成功', data: data });
  } catch (error) {
    return res.status(401).json({status: error, message: 'token失效,请重新登录'});
  }

})

// 刷新 token
app.get('/refresh', (req, res) => {
  const { token } = req.query;

  try {
    const data = jwtToken.verifyToken(token);
    const access_token = jwtToken.generateToken(data, '1h');
    const refresh_token = jwtToken.generateToken(data, '7d');
    res.json({ status: 'success', message: '刷新成功', access_token, refresh_token });
  } catch (error) {
    return res.status(401).json({status: error, message: 'token失效,请重新登录'});
  }
})




app.listen(port, () => {
  console.log(`Example app listening on port ${port}`);
})
// token.js
const jwt = require('jsonwebtoken');

// 生成 token
function generateToken(user, expiresIn) {
  const payload = {
    username: user.username,
    email: user.email
  };
  const secret = 'my_secret_key';
  const options = {
    expiresIn: expiresIn
  };
  return jwt.sign(payload, secret, options);
}

// 验证 token
function verifyToken(token) {
  const secret = 'my_secret_key';
  const decoded = jwt.verify(token, secret);
  return decoded;
}

module.exports = {
  generateToken,
  verifyToken
};

六、流程图解

用户发起请求 → 携带access_token → 服务端验证
       ↓ 无效/过期
触发401错误 → 前端拦截 → 发起refresh_token刷新请求
       ↓ 刷新成功
更新本地令牌 → 重新发送原请求 → 用户无感知
       ↓ 刷新失败
跳转登录页面 → 需要重新认证

七、安全注意事项

  1. refresh_token要长期有效,但也不能太长:通常设置7-30天有效期
  2. 使用HTTPS:防止令牌被中间人窃取
  3. 不要明文存储令牌:使用浏览器localStorage要确保XSS防护
  4. 设置合理有效期:根据业务需求平衡安全与体验