解释 Node.js 中 JWT (JSON Web Token) 的认证和授权机制,以及如何实现 Token 的刷新和撤销。

各位观众老爷们,晚上好!我是你们的老朋友,专门来给大家讲点儿干货的。今天咱们聊聊 Node.js 里面 JWT 这玩意儿,以及怎么让它乖乖地帮你搞定认证和授权,顺带解决 Token 刷新和撤销的问题。

准备好了吗?咱们这就开始了!

一、JWT 是个啥?为啥要用它?

想象一下,你开了一家豪华酒店,客人来入住,你得知道谁是VIP,谁是普通客人,谁是压根没预定的想混进来的。传统的做法是,每次客人想用酒店服务(比如点餐、用健身房),你都要查一下他/她的身份。这多麻烦!

JWT 就好比你给每个客人发了一张房卡,这张卡上写明了客人的身份信息、权限等等。客人拿着这张卡,就可以直接去享受酒店的服务,不需要每次都跑来前台验证身份。酒店的服务员(你的后端服务器)只需要验证一下这张卡是不是真的、有没有过期就行了。

所以,JWT 是一种基于 JSON 的开放标准 (RFC 7519),它定义了一种紧凑且自包含的方式,用于在各方之间安全地传输信息作为 JSON 对象。这个 JSON 对象可以被验证和信任,因为它是经过数字签名的。

简单来说,JWT 就是一个包含信息的、经过加密的字符串,用于身份验证和授权。

为啥要用 JWT 呢?

  • 无状态 (Stateless): 服务器不需要保存 Session 信息,减轻服务器压力。
  • 可扩展性 (Scalability): 由于无状态,更容易进行横向扩展。
  • 跨域 (Cross-Domain): JWT 可以在不同的域之间传递。
  • 安全性 (Security): 通过数字签名保证信息的完整性和不可篡改性。

二、JWT 的结构:三段论

JWT 由三个部分组成,每个部分用 . 分隔:

  1. Header (头部): 描述 JWT 的元数据,通常包含令牌类型 (JWT) 和所使用的签名算法 (例如:HMAC SHA256 或 RSA)。
  2. Payload (载荷): 包含声明 (claims)。声明是关于实体(通常是用户)和其他数据的声明。有三种类型的声明:
    • Registered claims (注册声明): 一组预定义的声明,不是强制性的,但是推荐使用。包括:iss (issuer)、sub (subject)、aud (audience)、exp (expiration time)、nbf (not before)、iat (issued at)、jti (JWT ID)。
    • Public claims (公共声明): 可以由 JWT 的使用者自定义,为了避免冲突,应该在 IANA JSON Web Token Registry 中注册,或者使用一个包含命名空间的 URI。
    • Private claims (私有声明): 用于在应用程序之间共享信息。
  3. Signature (签名): 为了验证消息的完整性,使用头部中指定的算法对头部、载荷和密钥进行签名。

举个栗子:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
  • Header: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 => { "alg": "HS256", "typ": "JWT" } (Base64 解码后)
  • Payload: eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ => { "sub": "1234567890", "name": "John Doe", "iat": 1516239022 } (Base64 解码后)
  • Signature: SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

三、Node.js 中 JWT 的使用:代码说话

咱们用 jsonwebtoken 这个库来玩转 JWT。

1. 安装:

npm install jsonwebtoken

2. 生成 JWT (Sign):

const jwt = require('jsonwebtoken');

// 定义用户数据
const user = {
  id: 123,
  username: 'testuser',
  role: 'admin'
};

// 密钥 (务必保密!)
const secretKey = 'ThisIsMySuperSecretKey'; // 换成更复杂的!

// 生成 JWT
const token = jwt.sign(user, secretKey, {
  expiresIn: '1h' // 设置过期时间为 1 小时
});

console.log('Generated Token:', token);

代码解释:

  • jwt.sign(payload, secretOrPrivateKey, [options]):生成 JWT 的方法。
    • payload:要放入 JWT 中的数据。
    • secretOrPrivateKey:用于签名的密钥。生产环境务必使用强密码!
    • options:配置选项,例如过期时间 (expiresIn)、算法 (algorithm) 等。
  • expiresIn:可以设置多种格式的时间,例如:'1h' (1 小时), '30m' (30 分钟), '7d' (7 天), '1y' (1 年)。

3. 验证 JWT (Verify):

const jwt = require('jsonwebtoken');

// 假设你从请求头中获取到 Token
const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MTIzLCJ1c2VybmFtZSI6InRlc3R1c2VyIiwicm9sZSI6ImFkbWluIiwiaWF0IjoxNjc4ODgxODg1LCJleHAiOjE2Nzg4ODU0ODV9.X2b-nL9aMv_4n1kU9q17h8iLz3o7g9xQ2p9Yk8Z0g3A'; // 替换成实际的 Token
const secretKey = 'ThisIsMySuperSecretKey'; // 换成更复杂的!

// 验证 JWT
jwt.verify(token, secretKey, (err, decoded) => {
  if (err) {
    console.error('Token verification failed:', err.message);
    // 处理验证失败的情况 (例如:Token 过期、无效签名)
  } else {
    console.log('Token is valid. Decoded payload:', decoded);
    // 从 decoded 中获取用户信息
    const userId = decoded.id;
    const username = decoded.username;
    const role = decoded.role;
    console.log(`User ID: ${userId}, Username: ${username}, Role: ${role}`);
  }
});

代码解释:

  • jwt.verify(token, secretOrPublicKey, [options], callback):验证 JWT 的方法。
    • token:要验证的 JWT 字符串。
    • secretOrPublicKey:用于验证签名的密钥。必须与生成 JWT 时使用的密钥相同。
    • callback:回调函数,用于处理验证结果。
      • err:如果验证失败,则包含错误信息。
      • decoded:如果验证成功,则包含解码后的 payload 数据。

4. 在 Express.js 中使用 JWT 进行身份验证:中间件来帮忙

const express = require('express');
const jwt = require('jsonwebtoken');

const app = express();
const port = 3000;

const secretKey = 'ThisIsMySuperSecretKey'; // 换成更复杂的!

// 身份验证中间件
const authenticateToken = (req, res, next) => {
  const authHeader = req.headers['authorization'];
  const token = authHeader && authHeader.split(' ')[1]; // 从 Authorization 头中获取 Token (Bearer <token>)

  if (token == null) {
    return res.sendStatus(401); // 未提供 Token
  }

  jwt.verify(token, secretKey, (err, user) => {
    if (err) {
      return res.sendStatus(403); // Token 无效
    }

    req.user = user; // 将用户信息添加到请求对象中
    next(); // 继续执行下一个中间件或路由处理程序
  });
};

// 模拟数据库中的用户
const users = [
  { id: 1, username: 'user1', password: 'password1' },
  { id: 2, username: 'user2', password: 'password2' }
];

// 登录路由
app.post('/login', (req, res) => {
  // 模拟用户验证
  const user = users.find(u => u.username === req.body.username && u.password === req.body.password);

  if (user) {
    // 生成 JWT
    const token = jwt.sign({ id: user.id, username: user.username }, secretKey, { expiresIn: '1h' });
    res.json({ token: token });
  } else {
    res.sendStatus(401); // 认证失败
  }
});

// 需要身份验证的路由
app.get('/protected', authenticateToken, (req, res) => {
  res.json({ message: 'Protected route accessed!', user: req.user }); // req.user 中包含用户信息
});

app.listen(port, () => {
  console.log(`Server listening at http://localhost:${port}`);
});

代码解释:

  • authenticateToken 中间件:
    • Authorization 请求头中获取 Token。
    • 如果 Token 不存在,返回 401 (Unauthorized)。
    • 验证 Token 的有效性。
    • 如果 Token 无效,返回 403 (Forbidden)。
    • 如果 Token 有效,将解码后的用户信息添加到 req.user 中,并调用 next() 继续执行。
  • /protected 路由:
    • 先经过 authenticateToken 中间件进行身份验证。
    • 只有通过身份验证的用户才能访问该路由。

四、Token 刷新 (Refresh Token):让你的 Token 续命

Token 总有过期的时候,总不能让用户每次都重新登录吧?这时候就需要 Refresh Token 出马了。

思路:

  1. 用户登录成功后,服务器不仅返回 Access Token (用于访问受保护的资源),还返回一个 Refresh Token。
  2. Access Token 的过期时间设置得比较短 (例如:15 分钟)。
  3. 当 Access Token 过期后,客户端使用 Refresh Token 向服务器请求新的 Access Token。
  4. 服务器验证 Refresh Token 的有效性,如果有效,则生成新的 Access Token 和 Refresh Token (可选),并返回给客户端。
  5. Refresh Token 的过期时间可以设置得比较长 (例如:1 个月)。

代码示例:

const express = require('express');
const jwt = require('jsonwebtoken');
const { v4: uuidv4 } = require('uuid'); // 用于生成 Refresh Token 的 ID

const app = express();
const port = 3000;

const secretKey = 'ThisIsMySuperSecretKey'; // 换成更复杂的!
const refreshTokenSecretKey = 'ThisIsMyRefreshTokenSecretKey'; // Refresh Token 的密钥也要单独设置!

// 模拟数据库存储 Refresh Token (生产环境需要使用数据库)
const refreshTokens = {};

// 登录路由
app.post('/login', (req, res) => {
  // 模拟用户验证 (省略)
  const user = { id: 1, username: 'testuser' };

  // 生成 Access Token
  const accessToken = jwt.sign({ id: user.id, username: user.username }, secretKey, { expiresIn: '15m' });

  // 生成 Refresh Token
  const refreshTokenId = uuidv4(); // 生成唯一的 Refresh Token ID
  const refreshToken = jwt.sign({ id: user.id, username: user.username, tokenId: refreshTokenId }, refreshTokenSecretKey, { expiresIn: '1M' }); // 1 个月过期

  // 存储 Refresh Token (生产环境存数据库)
  refreshTokens[refreshTokenId] = { userId: user.id, token: refreshToken };

  res.json({ accessToken: accessToken, refreshToken: refreshToken });
});

// 刷新 Token 路由
app.post('/refresh', (req, res) => {
  const refreshToken = req.body.refreshToken;

  if (!refreshToken) {
    return res.sendStatus(401); // 未提供 Refresh Token
  }

  jwt.verify(refreshToken, refreshTokenSecretKey, (err, user) => {
    if (err) {
      console.error('Refresh token verification failed:', err.message);
      return res.sendStatus(403); // Refresh Token 无效
    }

    const refreshTokenId = user.tokenId;

    // 检查 Refresh Token 是否存在于数据库中
    if (!refreshTokens[refreshTokenId] || refreshTokens[refreshTokenId].token !== refreshToken) {
      console.log('Invalid refresh token: token not found or does not match.');
      return res.sendStatus(403); // Refresh Token 不存在或不匹配
    }

    // 删除旧的 Refresh Token
    delete refreshTokens[refreshTokenId];

    // 生成新的 Access Token
    const accessToken = jwt.sign({ id: user.id, username: user.username }, secretKey, { expiresIn: '15m' });

    // 生成新的 Refresh Token (可选)
    const newRefreshTokenId = uuidv4();
    const newRefreshToken = jwt.sign({ id: user.id, username: user.username, tokenId: newRefreshTokenId }, refreshTokenSecretKey, { expiresIn: '1M' });

    // 存储新的 Refresh Token (生产环境存数据库)
    refreshTokens[newRefreshTokenId] = { userId: user.id, token: newRefreshToken };

    res.json({ accessToken: accessToken, refreshToken: newRefreshToken });
  });
});

app.listen(port, () => {
  console.log(`Server listening at http://localhost:${port}`);
});

代码解释:

  • /login 路由:生成 Access Token 和 Refresh Token,并将 Refresh Token 存储在内存中 (生产环境需要存储到数据库)。
  • /refresh 路由:
    • 验证 Refresh Token 的有效性。
    • 检查 Refresh Token 是否存在于数据库中。
    • 删除旧的 Refresh Token。
    • 生成新的 Access Token。
    • 生成新的 Refresh Token (可选)。
    • 存储新的 Refresh Token (生产环境需要存储到数据库)。

五、Token 撤销 (Token Revocation):让 Token 失效

有时候,我们需要让 Token 提前失效,比如用户主动注销、密码重置、或者发现 Token 泄露。

思路:

  1. 维护一个已撤销 Token 的黑名单 (Blacklist)。
  2. 每次验证 Token 时,先检查 Token 是否在黑名单中。如果在,则认为 Token 无效。

代码示例:

const express = require('express');
const jwt = require('jsonwebtoken');

const app = express();
const port = 3000;

const secretKey = 'ThisIsMySuperSecretKey'; // 换成更复杂的!

// 模拟数据库存储已撤销的 Token (生产环境需要使用数据库)
const revokedTokens = new Set();

// 身份验证中间件 (修改)
const authenticateToken = (req, res, next) => {
  const authHeader = req.headers['authorization'];
  const token = authHeader && authHeader.split(' ')[1];

  if (token == null) {
    return res.sendStatus(401);
  }

  // 检查 Token 是否在黑名单中
  if (revokedTokens.has(token)) {
    console.log('Token has been revoked.');
    return res.sendStatus(403); // Token 已被撤销
  }

  jwt.verify(token, secretKey, (err, user) => {
    if (err) {
      console.error('Token verification failed:', err.message);
      return res.sendStatus(403); // Token 无效
    }

    req.user = user;
    next();
  });
};

// 登出路由 (撤销 Token)
app.post('/logout', authenticateToken, (req, res) => {
  const authHeader = req.headers['authorization'];
  const token = authHeader && authHeader.split(' ')[1];

  // 将 Token 添加到黑名单
  revokedTokens.add(token);

  console.log('Token revoked:', token);
  res.sendStatus(204); // No Content
});

// 需要身份验证的路由
app.get('/protected', authenticateToken, (req, res) => {
  res.json({ message: 'Protected route accessed!', user: req.user });
});

app.listen(port, () => {
  console.log(`Server listening at http://localhost:${port}`);
});

代码解释:

  • revokedTokens:使用 Set 来存储已撤销的 Token。使用 Set 可以高效地检查 Token 是否在黑名单中。
  • authenticateToken 中间件:在验证 Token 之前,先检查 Token 是否在 revokedTokens 中。
  • /logout 路由:将 Token 添加到 revokedTokens 中。

六、JWT 最佳实践:一些注意事项

  • 使用强密钥: 密钥是 JWT 安全性的关键,务必使用足够长的、随机的、难以猜测的密钥。
  • 保护密钥: 不要将密钥硬编码到代码中,也不要将密钥存储在版本控制系统中。可以使用环境变量、配置文件、或者专门的密钥管理系统来存储密钥。
  • 设置合理的过期时间: Access Token 的过期时间应该设置得比较短,Refresh Token 的过期时间可以设置得比较长。
  • 使用 HTTPS: 确保在传输 JWT 时使用 HTTPS,防止 Token 被窃取。
  • 验证 Token 来源: 验证 Token 的 iss (issuer) 和 aud (audience) 声明,确保 Token 来自可信的来源。
  • 考虑使用 JWE: 如果需要在 JWT 中包含敏感信息,可以考虑使用 JWE (JSON Web Encryption) 对 JWT 进行加密。
  • 监控和审计: 监控 JWT 的使用情况,并进行审计,及时发现和处理安全问题。

七、总结:JWT 的力量

JWT 提供了一种简单、安全、可扩展的方式来实现身份验证和授权。通过合理地使用 JWT,可以构建更加健壮和安全的 Web 应用程序。

  • JWT 是一个标准: 遵循 RFC 7519 规范。
  • JWT 是一个字符串: 由 Header、Payload 和 Signature 三部分组成。
  • JWT 是一个令牌: 用于身份验证和授权。
  • JWT 是一个解决方案: 用于解决无状态身份验证和授权的问题。

希望今天的讲座能帮助大家更好地理解和使用 JWT。记住,安全无小事,多注意细节,才能让你的应用更加安全可靠!

如果大家还有什么问题,欢迎提问,我会尽力解答。下次再见!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注