Access Token与Refresh Token实战指南:从原理到安全实现

1次阅读
没有评论

共计 4618 个字符,预计需要花费 12 分钟才能阅读完成。

image.webp

背景痛点:为什么需要双令牌机制?

在传统的 Web 应用中,Session-Cookie 方案曾是主流选择。但随着前后端分离架构和移动应用的普及,这种方案暴露出几个关键问题:

Access Token 与 Refresh Token 实战指南:从原理到安全实现

  • 服务器需要维护会话状态,不利于水平扩展
  • CORS 场景下 Cookie 处理复杂
  • 移动端 App 难以原生支持 Cookie 机制

JWT(JSON Web Token)的出现解决了无状态认证的问题,但单纯使用 access token 会带来新的安全隐患:

  1. 长期有效的 token 一旦泄露,攻击者可以永久冒充用户
  2. 缩短 token 有效期会导致用户体验下降(频繁重新登录)
  3. 客户端存储 token 容易受到 XSS 攻击

这就是引入 refresh token 的初衷——通过『短期 access token+ 长期 refresh token』的组合,在安全性和用户体验之间取得平衡。

技术方案对比

Session vs JWT 双令牌

维度 Session 方案 JWT 双令牌方案
服务端状态 需要维护 完全无状态
跨域支持 需要额外配置 原生支持良好
移动端适配 较差 友好
失效即时性 立即生效 依赖 token 过期时间
实现复杂度 较低 中高等

Refresh Token 工作流程

  1. 用户使用凭证(如密码)获取初始的 access/refresh token 对
  2. access token 过期后,客户端使用 refresh token 获取新的 access token
  3. refresh token 通常有较长的有效期(如 7 天),但每次使用后会被替换(rotation 机制)
  4. 如果 refresh token 也过期,则需要重新登录

Node.js 核心实现

基础依赖安装

npm install express jsonwebtoken redis cookie-parser

JWT 签发与验证(HS256)

// utils/jwt.js
const jwt = require('jsonwebtoken');
const SECRET = 'your-256-bit-secret';

// 签发 access token (15 分钟有效期)
function issueAccessToken(userId) {
  return jwt.sign({ sub: userId},
    SECRET,
    {expiresIn: '15m'}
  );
}

// 签发 refresh token (7 天有效期)
function issueRefreshToken(userId) {
  return jwt.sign({ sub: userId, type: 'refresh'},
    SECRET,
    {expiresIn: '7d'}
  );
}

// 验证 token 有效性
function verifyToken(token) {
  try {return jwt.verify(token, SECRET);
  } catch (err) {return null;}
}

Redis 存储模块

// store/redis.js
const redis = require('redis');
const client = redis.createClient();

// 存储 refresh token
async function storeRefreshToken(userId, token) {await client.set(`refresh:${userId}`, token, {EX: 60 * 60 * 24 * 7 // 7 天过期});
}

// 验证 refresh token 是否匹配
async function verifyRefreshToken(userId, token) {const storedToken = await client.get(`refresh:${userId}`);
  return storedToken === token;
}

// 吊销 refresh token
async function revokeRefreshToken(userId) {await client.del(`refresh:${userId}`);
}

Express 路由实现

// routes/auth.js
const express = require('express');
const router = express.Router();
const {
  issueAccessToken,
  issueRefreshToken,
  verifyToken
} = require('../utils/jwt');
const {
  storeRefreshToken,
  verifyRefreshToken
} = require('../store/redis');

// 登录接口
router.post('/login', async (req, res) => {const { username, password} = req.body;

  // 1. 验证用户凭证(示例代码,实际应查数据库)const userId = authenticate(username, password);

  // 2. 签发双令牌
  const accessToken = issueAccessToken(userId);
  const refreshToken = issueRefreshToken(userId);

  // 3. 存储 refresh token
  await storeRefreshToken(userId, refreshToken);

  // 4. 返回响应
  res.cookie('refreshToken', refreshToken, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production'
  });

  res.json({accessToken});
});

// 令牌刷新接口
router.post('/refresh', async (req, res) => {
  const refreshToken = req.cookies.refreshToken;
  if (!refreshToken) return res.sendStatus(401);

  // 1. 验证 refresh token 有效性
  const payload = verifyToken(refreshToken);
  if (!payload || payload.type !== 'refresh') {return res.sendStatus(403);
  }

  // 2. 检查是否与存储的 token 匹配
  const isValid = await verifyRefreshToken(payload.sub, refreshToken);
  if (!isValid) return res.sendStatus(403);

  // 3. 吊销旧 refresh token
  await revokeRefreshToken(payload.sub);

  // 4. 签发新令牌
  const newAccessToken = issueAccessToken(payload.sub);
  const newRefreshToken = issueRefreshToken(payload.sub);

  // 5. 存储新 refresh token
  await storeRefreshToken(payload.sub, newRefreshToken);

  // 6. 返回响应
  res.cookie('refreshToken', newRefreshToken, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production'
  });

  res.json({accessToken: newAccessToken});
});

安全最佳实践

存储策略

  • access token:存储在内存或临时变量中,不要持久化
  • refresh token:必须使用 HttpOnly+Secure Cookie,防止 XSS 攻击

有效期设置

  • access token:建议 15-30 分钟
  • refresh token:建议 7 天,并实现单次使用机制

防御 CSRF

  1. 为敏感操作添加 CSRF Token
  2. 检查 Origin/Referer 头部
  3. 实现 SameSite Cookie 策略
// 启用 SameSite Strict 模式
app.use(cookieParser({
  sameSite: 'strict',
  secure: process.env.NODE_ENV === 'production'
}));

常见问题解决方案

并发刷新请求

当多个请求同时检测到 access token 过期时,可能触发多次 refresh 请求。解决方案:

  1. 在 Redis 中设置刷新锁(mutex)
  2. 使用内存标记记录正在刷新的状态
// utils/mutex.js
const mutex = new Map();

async function acquireLock(userId) {if (mutex.has(userId)) return false;
  mutex.set(userId, true);
  return true;
}

function releaseLock(userId) {mutex.delete(userId);
}

令牌吊销

当用户主动登出或检测到异常时,需要立即失效令牌:

  1. 维护 access token 黑名单(适用于 JWT 短期有效场景)
  2. 立即删除 refresh token
// 登出接口
router.post('/logout', async (req, res) => {
  const refreshToken = req.cookies.refreshToken;
  if (!refreshToken) return res.sendStatus(204);

  // 解析 payload 获取 userId
  const payload = verifyToken(refreshToken);
  if (payload && payload.sub) {await revokeRefreshToken(payload.sub);
  }

  // 清除 cookie
  res.clearCookie('refreshToken');
  res.sendStatus(204);
});

动手实验

使用 Postman 测试流程

  1. 配置环境变量:
  2. baseUrl: 你的 API 基础地址
  3. username: 测试用户名
  4. password: 测试密码

  5. 创建测试集合:

  6. 登录请求 :POST {{baseUrl}}/auth/login
    {"username": "{{username}}",
      "password": "{{password}}"
    }
  7. 受保护接口 :GET {{baseUrl}}/protected
    添加 Header:Authorization: Bearer {{accessToken}}
  8. 刷新令牌 :POST {{baseUrl}}/auth/refresh

  9. 测试场景:

  10. 正常登录后访问受保护接口
  11. 等待 15 分钟后观察 access token 过期
  12. 自动触发刷新流程
  13. 尝试使用旧的 refresh token(应返回 403)

安全测试

  1. XSS 模拟:尝试通过控制台读取 document.cookie(应看不到 refresh token)
  2. CSRF 模拟:从一个外部域名发起刷新请求(应被 SameSite 策略阻止)
  3. 令牌泄露测试:使用已撤销的 refresh token 尝试刷新(应失败)

总结

通过本文的实践,我们实现了:

  1. 安全的双令牌认证流程
  2. 自动续期机制保障用户体验
  3. 多层防御对抗常见攻击手段

实际生产环境中,还需要考虑:

  • 密钥轮换策略
  • 令牌绑定(Token Binding)增强安全性
  • 监控和异常检测机制

这套方案已在我们的多个生产项目中稳定运行,有效平衡了安全性和开发体验。希望这篇指南能帮助你在自己的项目中实现可靠的认证系统。

正文完
 0
评论(没有评论)