MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

基于JWT的用户身份验证中间件

2021-02-254.8k 阅读

1. 理解 JWT

JSON Web Token(JWT)是一种开放标准(RFC 7519),它定义了一种紧凑且自包含的方式,用于在各方之间作为 JSON 对象安全地传输信息。这种信息可以被验证和信任,因为它是数字签名的。JWT 通常由三部分组成:头部(Header)、载荷(Payload)和签名(Signature)。

1.1 JWT 的结构

  • 头部(Header):通常由两部分组成:令牌的类型(即 JWT)和所使用的签名算法,如 HMAC SHA256 或 RSA。例如:
{
  "alg": "HS256",
  "typ": "JWT"
}

这个 JSON 对象会被 Base64Url 编码,形成 JWT 的第一部分。

  • 载荷(Payload):是 JWT 中存放实际需要传递的数据的部分。它可以包含公开声明(public claims)、私有声明(private claims)和注册声明(registered claims)。注册声明是一组预定义的声明,如 iss(签发者)、exp(过期时间)、sub(主题)等。例如:
{
  "sub": "1234567890",
  "name": "John Doe",
  "iat": 1516239022
}

同样,这个 JSON 对象会被 Base64Url 编码,形成 JWT 的第二部分。

  • 签名(Signature):要创建签名部分,需要使用编码后的头部、编码后的载荷、一个密钥(secret)和头部中指定的签名算法。例如,如果使用 HMAC SHA256 算法,签名将按如下方式创建:
HMACSHA256(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
  secret
)

签名用于验证消息在传输过程中没有被更改,并且在使用私钥签名的情况下,还可以验证 JWT 的发送者的身份。

1.2 JWT 的工作原理

在用户登录成功后,后端服务器会生成一个 JWT,其中包含用户的相关信息(如用户 ID、用户名等)以及其他元数据(如过期时间)。这个 JWT 会被返回给客户端。客户端在后续的请求中,会将这个 JWT 包含在请求头(通常是 Authorization 头)中发送给后端服务器。后端服务器接收到请求后,会验证 JWT 的签名以及其他元数据(如过期时间)。如果验证通过,服务器就知道该请求是由合法用户发起的,并且可以从 JWT 中获取用户的相关信息进行后续的业务处理。

2. 为什么使用基于 JWT 的用户身份验证中间件

2.1 无状态性

基于 JWT 的身份验证是无状态的。这意味着服务器不需要在内存中存储关于用户会话的任何信息。每个请求都包含了服务器验证用户身份所需的所有信息(即 JWT)。这种无状态性使得应用程序更容易进行水平扩展,因为服务器不需要共享用户会话状态。例如,在一个使用负载均衡器的分布式系统中,任何一个服务器实例都可以处理请求,而不需要依赖其他服务器来获取用户的会话信息。

2.2 跨域友好

JWT 可以很方便地在不同的域之间传递。由于它是一个自包含的令牌,客户端可以将其包含在跨域请求中,而不需要依赖于复杂的跨域认证机制。这对于构建单页应用(SPA)和微服务架构非常有用,因为这些架构通常涉及到多个不同域的服务之间的交互。

2.3 易于理解和实现

JWT 的结构简单,并且有大量的开源库可以用于生成、验证和解析 JWT。无论是在前端还是后端,实现基于 JWT 的身份验证都相对容易。这使得开发人员可以快速地将身份验证功能集成到应用程序中,而不需要花费大量时间去研究复杂的身份验证协议。

3. 基于 JWT 的用户身份验证中间件的实现

我们将以 Node.js 和 Express 框架为例,展示如何实现一个基于 JWT 的用户身份验证中间件。首先,确保你已经安装了 expressjsonwebtoken 这两个包。你可以通过以下命令进行安装:

npm install express jsonwebtoken

3.1 生成 JWT

在用户登录成功后,我们需要生成一个 JWT 并返回给客户端。以下是一个简单的用户登录路由示例,它生成 JWT 并返回给客户端:

const express = require('express');
const jwt = require('jsonwebtoken');
const app = express();
const secretKey = 'your-secret-key';

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

app.post('/login', (req, res) => {
  const { username, password } = req.body;
  const user = users.find(u => u.username === username && u.password === password);
  if (user) {
    const payload = { id: user.id, username: user.username };
    const token = jwt.sign(payload, secretKey, { expiresIn: '1h' });
    res.json({ token });
  } else {
    res.status(401).json({ message: 'Invalid credentials' });
  }
});

在上述代码中,当用户提供正确的用户名和密码时,我们使用 jsonwebtoken 库的 sign 方法生成一个 JWT。这个 JWT 包含了用户的 ID 和用户名,并设置了过期时间为 1 小时。

3.2 验证 JWT 的中间件

接下来,我们创建一个中间件来验证请求中包含的 JWT。这个中间件会检查请求头中的 Authorization 头,如果存在且格式正确,它会验证 JWT 的签名和过期时间。如果验证通过,它会将 JWT 中的用户信息挂载到 req.user 对象上,以便后续的路由处理函数使用。

const jwtMiddleware = (req, res, next) => {
  const token = req.headers['authorization'];
  if (!token) {
    return res.status(401).json({ message: 'Token is missing' });
  }
  const parts = token.split(' ');
  if (parts.length!== 2 || parts[0]!== 'Bearer') {
    return res.status(401).json({ message: 'Invalid token format' });
  }
  const tokenToVerify = parts[1];
  jwt.verify(tokenToVerify, secretKey, (err, decoded) => {
    if (err) {
      return res.status(401).json({ message: 'Invalid token' });
    }
    req.user = decoded;
    next();
  });
};

上述中间件首先检查 Authorization 头是否存在,如果不存在则返回 401 错误。然后它检查 Authorization 头的格式是否为 Bearer <token>,如果格式不正确也返回 401 错误。接着,它使用 jsonwebtoken 库的 verify 方法验证 JWT 的签名和过期时间。如果验证成功,它将 JWT 中的用户信息(即 decoded 对象)挂载到 req.user 上,并调用 next() 将控制权交给下一个中间件或路由处理函数。

3.3 使用中间件保护路由

最后,我们展示如何使用这个中间件来保护需要用户认证的路由。例如,我们有一个 /protected 路由,只有经过认证的用户才能访问:

app.get('/protected', jwtMiddleware, (req, res) => {
  res.json({ message: 'This is a protected route', user: req.user });
});

在这个例子中,我们在 /protected 路由的处理函数之前添加了 jwtMiddleware。这样,当用户访问 /protected 路由时,中间件会先验证 JWT,如果验证通过,用户才能访问该路由并获取响应。

4. 高级特性与优化

4.1 刷新令牌

JWT 的一个常见问题是它的过期时间。一旦 JWT 过期,用户需要重新登录才能获取新的令牌。为了避免频繁的用户登录,我们可以引入刷新令牌(Refresh Token)机制。刷新令牌是一个长期有效的令牌,用于获取新的 JWT。

当 JWT 过期时,客户端可以使用刷新令牌向服务器请求一个新的 JWT。服务器验证刷新令牌的有效性,如果有效,它会生成一个新的 JWT 并返回给客户端。以下是一个简单的实现示例:

// 模拟存储刷新令牌
const refreshTokens = [];

app.post('/refresh-token', (req, res) => {
  const refreshToken = req.body.refreshToken;
  if (!refreshToken ||!refreshTokens.includes(refreshToken)) {
    return res.status(401).json({ message: 'Invalid refresh token' });
  }
  const user = { id: 1, username: 'user1' };
  const newToken = jwt.sign({ id: user.id, username: user.username }, secretKey, { expiresIn: '1h' });
  res.json({ token: newToken });
});

在用户登录成功时,除了生成 JWT 外,还生成一个刷新令牌并返回给客户端。客户端将刷新令牌存储在安全的地方(如 HTTP-only Cookie)。当 JWT 过期时,客户端使用刷新令牌向 /refresh - token 路由请求新的 JWT。

4.2 令牌存储与安全

在客户端,JWT 的存储位置很关键。由于 JWT 包含用户的敏感信息,不应该将其存储在本地存储(Local Storage)中,因为本地存储容易受到 XSS 攻击。推荐的做法是将 JWT 存储在 HTTP-only Cookie 中,这样可以防止 JavaScript 访问 Cookie,从而减少 XSS 攻击的风险。

在服务器端,密钥(secret key)的管理也非常重要。密钥应该是一个足够长且随机的字符串,并且要妥善保管,避免泄露。另外,对于使用 HTTPS 的应用程序,密钥的传输和存储更加安全。

4.3 多环境配置

在实际的开发中,我们可能需要在不同的环境(如开发、测试、生产)中使用不同的密钥。为了实现这一点,我们可以使用环境变量来存储密钥。在 Node.js 中,可以使用 dotenv 库来加载环境变量。首先安装 dotenv

npm install dotenv

然后在项目的根目录下创建一个 .env 文件,在其中定义密钥:

SECRET_KEY=your - secret - key - for - development

在代码中加载环境变量:

const dotenv = require('dotenv');
dotenv.config();
const secretKey = process.env.SECRET_KEY;

这样,在不同的环境中,我们只需要修改 .env 文件或设置相应的环境变量,就可以使用不同的密钥。

5. 常见问题与解决方案

5.1 令牌过期处理

当用户的 JWT 过期时,客户端会收到 401 错误。在单页应用中,我们可以在全局的错误处理机制中捕获这个错误,并提示用户重新登录或使用刷新令牌获取新的 JWT。例如,在 Vue.js 应用中,可以使用 axios 的拦截器来处理:

import axios from 'axios';

axios.interceptors.response.use(
  response => response,
  error => {
    if (error.response.status === 401) {
      // 处理 JWT 过期,提示用户重新登录或刷新令牌
    }
    return Promise.reject(error);
  }
);

5.2 安全漏洞防范

除了前面提到的防止 XSS 攻击和妥善管理密钥外,还需要防范其他安全漏洞。例如,防止暴力破解攻击,可以对登录尝试次数进行限制。可以使用 Redis 等缓存工具来记录用户的登录尝试次数,当达到一定次数时,暂时禁止该用户登录。

const redis = require('redis');
const client = redis.createClient();

app.post('/login', (req, res) => {
  const { username } = req.body;
  client.get(`login_attempts:${username}`, (err, attempts) => {
    if (err) throw err;
    if (attempts && parseInt(attempts) >= 5) {
      return res.status(429).json({ message: 'Too many login attempts. Try again later.' });
    }
    // 正常登录逻辑
    //...
    if (loginSuccess) {
      client.del(`login_attempts:${username}`);
    } else {
      client.incr(`login_attempts:${username}`);
      client.expire(`login_attempts:${username}`, 3600); // 1 小时内有效
    }
  });
});

5.3 性能优化

随着应用程序的用户量增加,验证 JWT 的性能可能成为一个问题。为了提高性能,可以考虑使用缓存来存储已经验证过的 JWT。例如,使用 Redis 缓存,当一个 JWT 被验证通过后,将其存储在 Redis 中,并设置一个较短的过期时间。下次验证相同的 JWT 时,先从 Redis 中查找,如果存在且未过期,则直接通过验证,而不需要再次进行签名验证。

app.use(jwtMiddleware, (req, res, next) => {
  const token = req.headers['authorization'].split(' ')[1];
  client.get(`valid_jwt:${token}`, (err, isValid) => {
    if (err) throw err;
    if (isValid === 'true') {
      next();
    } else {
      // 正常的 JWT 验证逻辑
      //...
      if (jwtVerifySuccess) {
        client.setex(`valid_jwt:${token}`, 300, 'true'); // 5 分钟内有效
        next();
      } else {
        res.status(401).json({ message: 'Invalid token' });
      }
    }
  });
});

通过以上步骤,我们详细介绍了基于 JWT 的用户身份验证中间件的原理、实现、高级特性、优化以及常见问题与解决方案。希望这些内容能帮助你在后端开发中构建安全、高效的用户身份验证系统。