JWT 身份验证:JSON Web Token 的工作原理

· 12 分钟阅读

目录

什么是 JSON Web Token?

JSON Web Token (JWT) 是一种紧凑、URL 安全的方式,用于在两方之间表示声明。JWT 已成为现代 Web 应用程序、API 和微服务架构中身份验证和授权的事实标准。

当用户登录时,服务器生成一个包含用户信息的 JWT 并将其发送给客户端。然后客户端在后续请求中包含此令牌以证明其身份。可以把它想象成一个数字护照,包含您的凭证,并且可以在不每次都回调发行机构的情况下进行验证。

与传统的基于会话的身份验证(服务器在内存或数据库中维护会话状态)不同,JWT 是无状态的。验证令牌所需的所有信息都包含在令牌本身中。这种架构决策带来了几个优势:

您可以使用我们的 JWT 解码器工具检查和解码任何 JWT 以查看其内容,而无需编写任何代码。这在调试身份验证问题或了解令牌包含哪些数据时特别有用。

专业提示: JWT 默认是签名的,而不是加密的。任何人都可以解码和读取 JWT 的内容。切勿在 JWT 负载中存储敏感信息,如密码、信用卡号或社会安全号码。

JWT 结构详解

JWT 由三个不同的部分组成,用点(.)分隔:头部、负载和签名。以下是一个真实的 JWT 示例:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiZW1haWwiOiJqb2huQGV4YW1wbGUuY29tIiwicm9sZSI6ImFkbWluIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjE1MTYyNDI2MjJ9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

让我们分解每个组件,以了解它们如何协同工作以创建安全、可验证的令牌。

1. 头部

头部通常包含两个关键字段,定义应如何处理令牌:

{
  "alg": "HS256",
  "typ": "JWT"
}

然后将此 JSON 对象进行 Base64Url 编码,形成 JWT 的第一部分。编码使其 URL 安全且紧凑,便于在 HTTP 头中传输。

2. 负载

负载包含声明 — 关于用户和附加元数据的陈述。这是您存储要传输的实际数据的地方:

{
  "sub": "1234567890",
  "name": "John Doe",
  "email": "[email protected]",
  "role": "admin",
  "iat": 1516239022,
  "exp": 1516242622
}

声明分为三类:

注册声明(标准化且推荐):

公共声明是应在 IANA JSON Web Token 注册表中定义或使用抗冲突名称(如 URL)的自定义声明。

私有声明是各方之间约定的自定义声明,如 rolepermissionsdepartment

3. 签名

签名使 JWT 安全且防篡改。它是通过获取编码的头部、编码的负载、密钥,并应用头部中指定的算法来创建的:

HMACSHA256(
  base64UrlEncode(header) + "." + base64UrlEncode(payload),
  secret
)

签名有两个关键目的:

  1. 完整性验证: 确保令牌自签发以来未被修改
  2. 身份验证: 证明令牌是由有权访问密钥的人创建的

如果有人试图修改头部或负载,签名验证将失败,令牌将被拒绝。

JWT 身份验证的工作原理

了解完整的身份验证流程对于正确实现 JWT 至关重要。以下是分步过程:

步骤 1: 用户登录

用户将其凭证(用户名和密码)提交到身份验证端点。服务器根据数据库验证这些凭证。

步骤 2: 令牌生成

如果凭证有效,服务器生成包含用户信息和声明的 JWT。服务器使用密钥(对于对称算法)或私钥(对于非对称算法)对令牌进行签名。

步骤 3: 令牌传递

服务器将 JWT 发送回客户端,通常在响应正文中。客户端存储此令牌以供将来请求使用。

步骤 4: 经过身份验证的请求

对于后续请求,客户端使用 Bearer 模式在 Authorization 头中包含 JWT:

Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

步骤 5: 令牌验证

服务器接收请求,从头部提取 JWT,并使用密钥或公钥验证签名。如果有效,服务器从负载中提取用户信息并处理请求。

步骤 6: 令牌过期

当令牌过期时,客户端必须获取新令牌,可以通过重新身份验证或使用刷新令牌(在下一节中介绍)。

快速提示: 始终在服务器端验证 exp 声明,即使您在客户端检查过期时间。客户端检查可以被绕过,但服务器端验证是权威的。

访问令牌与刷新令牌

一个强大的 JWT 身份验证系统通常使用两种类型的令牌协同工作:访问令牌和刷新令牌。理解这种区别对于构建安全的应用程序至关重要。

访问令牌

访问令牌是短期的 JWT(通常为 15 分钟到 1 小时),授予对受保护资源的访问权限。它们包含授权请求所需的用户身份和权限。

特征:

刷新令牌

刷新令牌是长期令牌(数天到数月),专门用于获取新的访问令牌。它们被安全存储,仅发送到令牌刷新端点。

特征:

为什么同时使用两者?

这种双令牌方法平衡了安全性和用户体验:

方面 仅访问令牌 访问令牌 + 刷新令牌
安全性 较低(长期令牌存在风险) 较高(短期访问令牌)
用户体验 较好(无需重新身份验证) 较好(无缝令牌刷新)
撤销 困难(无状态) 可能(刷新令牌在数据库中)
网络开销 较低 略高(刷新请求)
实现复杂度 简单 中等

令牌刷新流程

  1. 客户端检测到访问令牌已过期或即将过期
  2. 客户端将刷新令牌发送到 /auth/refresh 端点
  3. 服务器根据数据库验证刷新令牌
  4. 服务器生成新的访问令牌(以及可选的新刷新令牌)
  5. 客户端接收并存储新令牌
  6. 客户端使用新的访问令牌重试原始请求

专业提示: 实现刷新令牌轮换 — 每次使用刷新令牌时发放新的刷新令牌并使旧令牌失效。这限制了刷新令牌被泄露时的损害。

Node.js 实现指南

让我们使用 Node.js、Express 和 jsonwebtoken 库构建一个完整的 JWT 身份验证系统。此实现包括访问令牌和刷新令牌。

安装

npm install express jsonwebtoken bcrypt dotenv

环境配置

创建一个包含密钥的 .env 文件:

ACCESS_TOKEN_SECRET=your-super-secret-access-key-change-this
REFRESH_TOKEN_SECRET=your-super-secret-refresh-key-change-this
ACCESS_TOKEN_EXPIRY=15m
REFRESH_TOKEN_EXPIRY=7d

令牌生成

const jwt = require('jsonwebtoken');

function generateAccessToken(user) {
  return jwt.sign(
    { 
      userId: user.id,
      email: user.email,
      role: user.role 
    },
    process.env.ACCESS_TOKEN_SECRET,
    { expiresIn: process.env.ACCESS_TOKEN_EXPIRY }
  );
}

function generateRefreshToken(user) {
  return jwt.sign(
    { userId: user.id },
    process.env.REFRESH_TOKEN_SECRET,
    { expiresIn: process.env.REFRESH_TOKEN_EXPIRY }
  );
}

登录端点

const bcrypt = require('bcrypt');

app.post('/auth/login', async (req, res) => {
  try {
    const { email, password } = req.body;
    
    // 在数据库中查找用户
    const user = await User.findOne({ email });
    if (!user) {
      return res.status(401).json({ error: '凭证无效' });
    }
    
    // 验证密码
    const validPassword = await bcrypt.compare(password, user.password);
    if (!validPassword) {
      return res.status(401).json({ error: '凭证无效' });
    }
    
    // 生成令牌
    const accessToken = generateAccessToken(user);
    const refreshToken = generateRefreshToken(user);
    
    // 在数据库中存储刷新令牌
    await RefreshToken.create({
      token: refreshToken,
      userId: user.id,
      expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
    });
    
    res.json({
      accessToken,
      refreshToken,
      user: {
        id: user.id,
        email: user.email,
        name: user.name
      }
    });
  } catch (error) {
    res.status(500).json({ error: '服务器错误' });
  }
});

身份验证中间件

function authenticateToken(req, res, next) {
  const authHeader = req.headers['authorization'];
  const token = authHeader && authHeader.split(' ')[1];
  
  if (!token) {
    return res.status(401).json({ error: '需要访问令牌' });
  }
  
  jwt.verify(token, process.env.ACCESS_TOKEN_SECRET, (err, user) => {
    if (err) {
      return res.status(403).json({ error: '令牌无效或已过期' });
    }
    
    req.user = user;
    next();
  });
}

// 使用方法
app.get('/api/protected', authenticateToken, (req, res) => {
  res.json({ 
    message: '受保护的数据',
    user: req.user 
  });
});

令牌刷新端点

app.post('/auth/refresh', async (req, res) => {
  const { refreshToken } = req.body;
  
  if (!refreshToken) {
    return res.status(401).json({ error: '需要刷新令牌' });
  }
  
  try {
    // 验证刷新令牌是否存在于数据库中
    const storedToken = await RefreshToken.findOne({ token: refreshToken });
    if (!storedToken) {
      return res.status(403).json({ error: '刷新令牌无效' });
    }
    
    // 验证令牌签名
    jwt.verify(refreshToken, process.env.REFRESH_TOKEN_SECRET, async (err, user) => {
      if (err) {
        return res.status(403).json({ error: '刷新令牌无效' });
      }
      
      // 获取用户数据
      const userData = await User.findById(user.userId);
      
      // 生成新的访问令牌
      const accessToken = generateAccessToken(userData);
      
      res.json({ accessToken });