搞定用户登录体验:双 Token 认证(Vue+Koa2)从 0 到 1 实现无感刷新

🚀 双Token认证机制:告别频繁登录,实现无感刷新!

💡 引言:为什么我们总是被"登录过期"困扰?

想象一下,你正在沉浸式地刷着短视频,或者在电商网站上挑选心仪的商品,突然,屏幕上弹出一个刺眼的提示:"登录已过期,请重新登录!" 瞬间,好心情烟消云散,你不得不中断手头的操作,重新输入账号密码。这种体验是不是糟糕透顶?就像你正在厨房里大展厨艺,突然停电了一样,所有的努力都白费了。

在Web应用中,为了保障用户数据安全,我们通常会设置会话(Session)或令牌(Token)的有效期。但过短的有效期会频繁打断用户体验,过长的有效期又会增加安全风险。那么,有没有一种方法,既能保证安全,又能让用户"无感"地保持在线呢?答案就是------双Token认证机制 ,配合无感刷新技术,让你的应用像拥有"永动机"一样,持续为用户提供丝滑体验!

🔄 双Token机制:安全与体验的完美结合

双Token机制,顾名思义,就是使用两种不同类型的令牌来管理用户认证状态。它们就像一对"黄金搭档",各司其职,共同守护着你的应用安全和用户体验。

🔑 Access Token(短令牌):日常通行证

Access Token,我们称之为短令牌 ,它的生命周期通常较短(比如15分钟到1小时)。它就像你进入一个高级俱乐部的临时通行证。每次你向服务器发起请求(比如获取个人信息、发布动态),都会带着这个通行证。服务器会快速验证它的有效性,如果有效,就放行;如果无效,就拒绝。由于它有效期短,即使被恶意截获,造成的损失也有限。

🛡️ Refresh Token(长令牌):续命符

Refresh Token,我们称之为长令牌 ,它的生命周期相对较长(比如7天、30天甚至更久)。它就像你俱乐部会员卡的续费凭证。当你的Access Token过期时,你不需要重新登录,而是拿着这个Refresh Token去向服务器"续费",服务器验证Refresh Token有效后,会给你颁发一个新的Access Token(通常还会附带一个新的Refresh Token)。由于它只用于刷新Access Token,并且通常有额外的安全措施(比如只能使用一次,或者绑定IP),所以安全性更高。

💡 工作流程图解

为了更直观地理解双Token的无感刷新流程,我们来看一张流程图:

流程解析:

  1. 用户登录:用户输入账号密码,成功后服务器会返回一个Access Token和一个Refresh Token。
  2. 存储令牌:前端将Access Token存储在内存或Cookie中,Refresh Token存储在HttpOnly Cookie或LocalStorage中。
  3. 日常请求:每次请求API时,前端都会在请求头中携带Access Token。
  4. Access Token过期:如果Access Token过期,服务器会返回401 Unauthorized错误。
  5. 无感刷新:前端捕获到401错误后,会使用Refresh Token向服务器请求刷新。服务器验证Refresh Token的有效性。
  6. 颁发新令牌:如果Refresh Token有效,服务器会颁发新的Access Token和Refresh Token,并返回给前端。
  7. 重试请求:前端拿到新令牌后,更新本地存储,并使用新的Access Token重新发起之前失败的请求。
  8. Refresh Token过期:如果Refresh Token也过期或无效,服务器会返回401/403错误,此时前端会引导用户重新登录。

🔧 前端实现(Vue + Axios):让用户无感续航

前端实现无感刷新的核心在于请求拦截器响应拦截器。我们将使用Vue作为前端框架,Axios作为HTTP请求库。

1. 登录存储令牌

用户登录成功后,将服务器返回的Access Token和Refresh Token存储起来。通常Access Token会存储在内存中(Vuex/Pinia),或者Cookie中,而Refresh Token为了安全考虑,可以存储在HttpOnly的Cookie中,或者LocalStorage中。

javascript 复制代码
// api.js
import axios from 'axios';
​
const service = axios.create({
  baseURL: 'http://localhost:3000',
  timeout: 5000,
});
​
// 登录函数
export const userLogin = async (data) => {
  const res = await service.post('/login', data);
  const { access_token, refresh_token, userInfo } = res.data;
  if (access_token) {
    localStorage.setItem('access_token', access_token); // 示例:存储在LocalStorage
    localStorage.setItem('refresh_token', refresh_token); // 示例:存储在LocalStorage
    // 也可以将access_token存储在Vuex/Pinia或内存中,refresh_token存储在HttpOnly Cookie中
  }
  return res;
};
​
// ... 其他API请求

2. 请求自动携带令牌

通过Axios的请求拦截器,在每次发送请求前,自动将Access Token添加到请求头中。

javascript 复制代码
// api.js (续)
​
// 请求拦截器
service.interceptors.request.use(
  (config) => {
    const accessToken = localStorage.getItem('access_token');
    if (accessToken) {
      config.headers.Authorization = `Bearer ${accessToken}`;
    }
    return config;
  },
  (error) => {
    return Promise.reject(error);
  }
);
​
// ...

3. 智能令牌刷新

这是无感刷新的核心逻辑。当服务器返回401错误(表示Access Token过期)时,我们利用响应拦截器,在不打断用户操作的情况下,悄悄地进行令牌刷新。

javascript 复制代码
// api.js (续)
​
let isRefreshing = false; // 标记是否正在刷新Token
let requests = []; // 存储刷新Token期间的请求
​
// 刷新Token的函数
async function refreshTokenRequest() {
  try {
    const refreshToken = localStorage.getItem('refresh_token');
    if (!refreshToken) {
      // 如果没有Refresh Token,直接跳转登录页
      window.location.href = '/login';
      return Promise.reject('No refresh token found');
    }
    const res = await service.get('/refresh', {
      params: { token: refreshToken },
    });
    const { access_token, refresh_token } = res.data;
    localStorage.setItem('access_token', access_token);
    localStorage.setItem('refresh_token', refresh_token);
    return Promise.resolve(access_token);
  } catch (error) {
    // Refresh Token也失效,跳转登录页
    window.location.href = '/login';
    return Promise.reject(error);
  }
}
​
// 响应拦截器
service.interceptors.response.use(
  (response) => {
    return response;
  },
  async (error) => {
    const { config, response } = error;
    if (response && response.status === 401) {
      // 避免/refresh接口本身因为401而无限循环刷新
      if (config.url === '/refresh') {
        window.location.href = '/login';
        return Promise.reject(error);
      }
​
      // 如果正在刷新,则将当前请求加入队列等待
      if (isRefreshing) {
        return new Promise((resolve) => {
          requests.push(() => {
            resolve(service(config));
          });
        });
      }
​
      isRefreshing = true;
      try {
        const newAccessToken = await refreshTokenRequest();
        // 刷新成功后,将队列中的请求重新发起
        requests.forEach((cb) => cb(newAccessToken));
        requests = []; // 清空队列
        // 重新发起之前失败的请求
        return service(config);
      } catch (refreshError) {
        // 刷新失败,跳转登录页
        window.location.href = '/login';
        return Promise.reject(refreshError);
      } finally {
        isRefreshing = false;
      }
    }
    return Promise.reject(error);
  }
);
​
export default service;

代码解析:

  • isRefreshing 标志位:防止在短时间内因多个请求同时401而触发多次Token刷新,造成不必要的资源浪费和潜在的竞态条件。
  • requests 队列:当Token正在刷新时,所有后续的API请求都会被暂停并加入到这个队列中。一旦Token刷新成功,队列中的请求会被重新执行。
  • refreshTokenRequest 函数:专门用于向后端发起Refresh Token请求,获取新的Access Token和Refresh Token。

⚙️ 后端实现(Node.js + Koa2):令牌的生成与刷新

后端主要负责Access Token和Refresh Token的生成、验证以及刷新逻辑。这里我们以Node.js + Koa2为例,并使用jsonwebtoken库来处理JWT(JSON Web Token)。

1. 生成双令牌

用户登录成功后,后端会根据用户信息生成Access Token和Refresh Token,并设置不同的有效期。

ini 复制代码
// utils/jwt.js
const jwt = require('jsonwebtoken');
const secret = 'your_secret_key'; // 生产环境中请使用更复杂的密钥,并妥善保管
​
// 生成Token
function generateToken(payload, expiresIn) {
  return jwt.sign(payload, secret, { expiresIn });
}
​
// 验证Token
function verifyToken(token) {
  return jwt.verify(token, secret);
}
​
module.exports = { generateToken, verifyToken };
​
// app.js (Koa2 示例)
const Koa = require('koa');
const Router = require('@koa/router');
const bodyParser = require('koa-bodyparser');
const cors = require('@koa/cors');
const { generateToken, verifyToken } = require('./utils/jwt');
​
const app = new Koa();
const router = new Router();
​
app.use(cors());
app.use(bodyParser());
​
const users = [
  { id: 1, username: 'testuser', password: 'password123', email: 'test@example.com' },
];
​
router.post('/login', async (ctx) => {
  const { username, password } = ctx.request.body;
  const user = users.find((u) => u.username === username && u.password === password);
​
  if (!user) {
    ctx.status = 401;
    ctx.body = { message: '用户名或密码错误' };
    return;
  }
​
  const payload = { id: user.id, username: user.username };
  const accessToken = generateToken(payload, '1h'); // 1小时有效期
  const refreshToken = generateToken(payload, '7d'); // 7天有效期
​
  ctx.body = {
    message: '登录成功',
    access_token: accessToken,
    refresh_token: refreshToken,
    userInfo: { id: user.id, username: user.username, email: user.email },
  };
});
​
// ... 其他需要认证的路由
​
app.use(router.routes()).use(router.allowedMethods());
​
app.listen(3000, () => {
  console.log('Server running on http://localhost:3000');
});

2. 令牌刷新接口

后端提供一个专门的接口用于刷新令牌。这个接口只接收Refresh Token,并验证其有效性。如果有效,就颁发新的Access Token和Refresh Token。

ini 复制代码
// app.js (Koa2 示例 续)
​
router.get('/refresh', async (ctx) => {
  const { token: oldRefreshToken } = ctx.query;
​
  if (!oldRefreshToken) {
    ctx.status = 401;
    ctx.body = { message: 'Refresh Token缺失' };
    return;
  }
​
  try {
    const decoded = verifyToken(oldRefreshToken);
    const payload = { id: decoded.id, username: decoded.username };
    const newAccessToken = generateToken(payload, '1h');
    const newRefreshToken = generateToken(payload, '7d');
​
    ctx.body = {
      message: 'Token刷新成功',
      access_token: newAccessToken,
      refresh_token: newRefreshToken,
    };
  } catch (error) {
    ctx.status = 401;
    ctx.body = { message: 'Refresh Token无效或已过期,请重新登录' };
  }
});
​
// 示例:一个需要认证的API
router.get('/profile', async (ctx) => {
  const authHeader = ctx.headers.authorization;
  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    ctx.status = 401;
    ctx.body = { message: '未提供Access Token' };
    return;
  }
​
  const accessToken = authHeader.split(' ')[1];
  try {
    const decoded = verifyToken(accessToken);
    ctx.body = { message: `欢迎,${decoded.username}!这是您的个人资料。`, user: decoded };
  } catch (error) {
    ctx.status = 401;
    ctx.body = { message: 'Access Token无效或已过期' };
  }
});
​
// ...

✨ 总结与思考

双Token认证机制结合无感刷新,无疑是提升用户体验和应用安全性的一个优雅解决方案。它通过将令牌分为短效和长效两种,实现了安全与便利的平衡。当Access Token过期时,用户无需感知,系统会在后台悄悄地完成令牌刷新,确保用户体验的连贯性。

当然,在实际应用中,还有一些细节需要考虑:

  • Refresh Token的存储:为了最高安全性,Refresh Token最好存储在HttpOnly的Cookie中,这样可以防止XSS攻击获取到它。如果存储在LocalStorage,需要前端开发者特别注意防范XSS。
  • Refresh Token的单次使用:为了防止Refresh Token被重放攻击,可以考虑实现Refresh Token的单次使用机制。每次刷新后,旧的Refresh Token立即失效,并颁发新的Refresh Token。
  • 黑名单机制:当用户主动登出或发现Refresh Token被盗用时,应立即将其加入黑名单,使其失效。

希望通过这篇博客,你能对双Token认证机制和无感刷新有更深入的理解。告别频繁登录,让你的应用拥有更流畅的用户体验吧!

📚 参考资料

What Are Refresh Tokens and How to Use Them Securely - Auth0 Blog

JWT Security Best Practices - Curity

相关推荐
崔庆才丨静觅4 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60614 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了4 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅4 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅5 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅5 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment5 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅6 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊6 小时前
jwt介绍
前端
爱敲代码的小鱼6 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax