双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的无感刷新流程,我们来看一张流程图:

image.png

流程解析:

  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中。

// 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添加到请求头中。

// 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过期)时,我们利用响应拦截器,在不打断用户操作的情况下,悄悄地进行令牌刷新。

// 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,并设置不同的有效期。

// 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: '[email protected]' },
];
​
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。

// 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

本站提供的所有下载资源均来自互联网,仅提供学习交流使用,版权归原作者所有。如需商业使用,请联系原作者获得授权。 如您发现有涉嫌侵权的内容,请联系我们 邮箱:[email protected]