首页
学习
活动
专区
圈层
工具
发布
首页
学习
活动
专区
圈层
工具
MCP广场
社区首页 >专栏 >Next.js 中间件拦截失效:Edge Runtime 中的全局状态共享问题深度剖析

Next.js 中间件拦截失效:Edge Runtime 中的全局状态共享问题深度剖析

原创
作者头像
叶一一
修改2025-08-30 22:49:53
修改2025-08-30 22:49:53
20100
代码可运行
举报
运行总次数:0
代码可运行

引言

在 Next.js 这样的全栈框架中,中间件(Middleware)被广泛用于拦截请求并校验用户登录状态。

而我们的Next.js 项目也是采用此方式,但是最近,我遇到了一个棘手的问题:中间件中的登录状态校验逻辑在 Edge Runtime 环境下出现了异常行为,导致不同用户之间的登录状态相互干扰。

在多端登录场景下,如何确保用户状态的准确性和安全性是一个关键挑战。而现在,我发现我也正经历着这项挑战。

本文将从真实业务场景出发,完整还原一次因Edge Runtime全局变量使用不当导致的多端登录状态冲突事件,深入剖析问题根源,提供可落地的解决方案,希望能为遇到类似问题的开发者提供参考。

一、业务需求:多端登录状态的中间件校验设计

1.1 业务场景描述

我们的应用支持用户在多个设备上同时登录,包括 PC 端和移动端。为了确保账户安全,我们实现了登录状态的实时校验机制:当用户在某一设备上主动登出或会话过期时,其他设备上的会话也应立即失效。

1.2 技术实现方案

我们采用 Next.js 的中间件机制来实现全局的登录状态校验。在中间件中,我们维护了一个全局的用户会话映射表,用于跟踪每个用户的登录状态。

代码语言:javascript
代码运行次数:0
运行
复制
let userSessions = new Map();

/**
 * 中间件函数,用于验证用户身份认证状态
 * @param {Object} request - HTTP请求对象,包含cookies等信息
 * @returns {Object} NextResponse对象,可能是重定向到登录页或继续处理
 */
export function middleware(request) {
  const token = request.cookies.get('auth-token')?.value;

  if (!token) {
    return NextResponse.redirect(new URL('/login', request.url));
  }

  const userId = verifyToken(token);
  if (!userId) {
    return NextResponse.redirect(new URL('/login', request.url));
  }

  // 检查会话是否仍然有效
  const session = userSessions.get(userId);
  if (!session || session.token !== token) {
    return NextResponse.redirect(new URL('/login', request.url));
  }

  return NextResponse.next();
}

/**
 * 验证JWT token的有效性
 * @param {string} token - JWT token字符串
 * @returns {string|null} 验证成功返回用户ID,验证失败返回null
 */
function verifyToken(token) {
  // token 验证逻辑
  try {
    const payload = jwt.verify(token, process.env.JWT_SECRET);
    return payload.userId;
  } catch (error) {
    return null;
  }
}

1.2.1 整体架构与目的

这段代码实现了一个基于 Next.js 的中间件( middleware ),用于验证用户会话的有效性。其主要目的是:

  • 验证用户身份:通过检查请求中的 auth-token Cookie 是否有效。
  • 会话管理:确保用户的会话仍然有效,否则重定向到登录页面。
  • 安全性:防止无效或过期的令牌被用于访问受保护的资源。

1.2.2 关键组件分析

  • userSessions 变量
    • 作用:用于存储当前活跃的用户会话。
    • 数据结构Map 对象,键为 userId ,值为会话对象(包含 token 等信息)。
    • 亮点:使用 Map 而不是普通对象,因为 Map 更适合动态增删键值对,且性能更好。
  • middleware 函数
    • 作用:处理每个传入的请求,验证用户身份和会话有效性。
    • 流程
      • 提取令牌:从请求的 Cookie 中获取 auth-token
      • 令牌检查:如果令牌不存在,直接重定向到登录页面。
      • 令牌验证:调用 verifyToken 函数验证令牌的有效性。
      • 会话检查:确保会话中的令牌与请求中的令牌一致。
      • 响应处理:如果验证通过,继续处理请求;否则重定向到登录页面。
  • verifyToken 函数
    • 作用:验证 JWT(JSON Web Token)的有效性。
    • 实现细节
      • 使用 jwt.verify 方法验证令牌签名。
      • 如果验证成功,返回 payload.userId ;否则返回 null
    • 安全性:依赖 process.env.JWT_SECRET 作为密钥,确保令牌无法被伪造。

1.2.3 关键设计决策

  • 会话管理
    • 使用 Map 存储会话信息,便于快速查找和更新。
    • 每次请求都会检查会话是否仍然有效,防止会话劫持。
  • 错误处理
    • 如果令牌无效或会话过期,统一重定向到登录页面,避免暴露具体错误信息(如“令牌无效”或“会话过期”),提升安全性。
  • 模块化设计
    • 将令牌验证逻辑封装在 verifyToken 函数中,便于复用和维护。

二、问题表现:异常的用户状态覆盖现象

2.1 异常表现

系统上线后,测试过程中发现一系列无法解释的现象:

2.1.1 现象1:用户状态互相覆盖

  • 用户A(账号:userA)在PC端登录成功后正常操作。
  • 用户B(账号:userB)在手机端登录成功后,PC端操作突然提示"账号在其他设备登录,已被迫下线"。
  • 查看日志发现,userA的请求上下文中出现了userB的用户ID。

2.1.2 现象2:数据访问越权

  • userB登录后,访问了仅userA有权限的报表页面,系统未拦截。
  • 数据库审计日志显示,该请求携带的用户ID为userA,但实际操作用户是userB。

2.1.3 现象3:间歇性登录失效

  • 单用户操作时偶尔出现"未登录"提示,刷新后恢复正常。
  • 高峰期(10+用户同时在线)问题发生频率显著提高。

2.2 错误日志片段

中间件添加调试日志后,捕获到如下关键信息:

代码语言:javascript
代码运行次数:0
运行
复制
[10:23:45] 请求来自IP: 192.168.1.100,路径: /dashboard,token对应userA,globalUser设置为userA
[10:23:47] 请求来自IP: 192.168.1.101,路径: /dashboard,token对应userB,globalUser设置为userB
[10:23:48] 请求来自IP: 192.168.1.100,路径: /reports,token对应userA,读取globalUser为userB → 触发"异地登录"检测

日志分析:userA的第二次请求(10:23:48)中,尽管携带的是userA的token,但全局变量globalUser已被10:23:47的userB请求覆盖,导致系统误判userA的账号在其他设备登录。

三、问题排查:从现象到本质的深度溯源

3.1 第一步:日志分析与现象确认

我们首先增加了详细的调试日志来追踪问题:

代码语言:javascript
代码运行次数:0
运行
复制
// 增加调试日志
let userSessions = new Map();

/**
 * 中间件函数,用于处理请求的身份验证和会话检查
 * @param {Object} request - HTTP请求对象,包含URL、cookies等信息
 * @returns {NextResponse} 重定向响应或继续处理的响应
 */
export function middleware(request) {
  console.log('Middleware execution start', {
    url: request.url,
    timestamp: new Date().toISOString(),
  });

  const token = request.cookies.get('auth-token')?.value;
  console.log('Token from request:', token);

  // 验证令牌是否存在,不存在则重定向到登录页面
  if (!token) {
    console.log('No token found, redirecting to login');
    return NextResponse.redirect(new URL('/login', request.url));
  }

  const userId = verifyToken(token);
  console.log('Verified userId:', userId);

  // 验证令牌是否有效,无效则重定向到登录页面
  if (!userId) {
    console.log('Invalid token, redirecting to login');
    return NextResponse.redirect(new URL('/login', request.url));
  }

  // 检查会话是否仍然有效
  const session = userSessions.get(userId);
  console.log('Current session in store:', session);
  console.log('Expected token:', token);

  // 验证会话中的令牌与请求令牌是否匹配,不匹配则重定向到登录页面
  if (!session || session.token !== token) {
    console.log('Session mismatch, redirecting to login');
    return NextResponse.redirect(new URL('/login', request.url));
  }

  console.log('Authentication successful');
  return NextResponse.next();
}

通过日志分析,我们确认了问题确实存在:userSessions 这个全局变量在不同请求之间被共享,导致会话信息被覆盖。

3.2 第二步:环境差异分析

我们怀疑问题与 Next.js 的运行环境有关。Next.js 支持多种运行时环境:

  • Node.js Runtime:传统的 Node.js 环境。
  • Edge Runtime:基于 Web API 的轻量级运行时。
代码语言:javascript
代码运行次数:0
运行
复制
export default {
  experimental: {
    runtime: 'edge' // 或 'nodejs'
  }
}

3.3 第三步:并发测试验证

我们编写了一个简单的测试脚本来模拟并发请求:

代码语言:javascript
代码运行次数:0
运行
复制
async function simulateConcurrentRequests() {
  const users = [
    { id: 'user1', token: 'token1' },
    { id: 'user2', token: 'token2' },
    { id: 'user3', token: 'token3' },
  ];

  // 并发发送请求
  const promises = users.map(user =>
    fetch('http://localhost:3000/api/protected', {
      headers: {
        Cookie: `auth-token=${user.token}`,
      },
    }),
  );

  const responses = await Promise.all(promises);
  responses.forEach((res, index) => {
    console.log(`User ${index + 1} status:`, res.status);
  });
}

测试结果证实了我们的猜测:在 Edge Runtime 环境下,全局变量确实会在多个请求间共享。

3.4 第四步:根本原因确认

通过深入研究 Next.js 文档和 Edge Runtime 规范,我们确认了问题的根本原因:

在 Edge Runtime 中,为了提高性能和资源利用率,多个请求可能会共享同一个执行环境,这导致全局变量在请求间被共享。

四、解决方案设计与实现

4.1 方案一:移除全局状态依赖

最直接的解决方案是避免在中间件中使用全局变量:

代码语言:javascript
代码运行次数:0
运行
复制
// 重构版本
import { NextResponse } from 'next/server';

/**
 * 中间件函数,用于验证用户身份认证状态
 * 检查请求中的认证令牌,验证用户会话有效性
 * @param {Request} request - Next.js 请求对象,包含cookies和其他请求信息
 * @returns {NextResponse} 重定向响应或继续处理的响应
 */
export function middleware(request) {
  const token = request.cookies.get('auth-token')?.value;

  if (!token) {
    return NextResponse.redirect(new URL('/login', request.url));
  }

  const userId = verifyToken(token);
  if (!userId) {
    return NextResponse.redirect(new URL('/login', request.url));
  }

  // 不再依赖全局状态,直接验证 token 的有效性
  if (!isValidSession(token, userId)) {
    return NextResponse.redirect(new URL('/login', request.url));
  }

  return NextResponse.next();
}

/**
 * 验证JWT令牌并提取用户ID
 * @param {string} token - JWT认证令牌
 * @returns {string|null} 解析出的用户ID,如果验证失败则返回null
 */
function verifyToken(token) {
  try {
    const payload = jwt.verify(token, process.env.JWT_SECRET);
    return payload.userId;
  } catch (error) {
    return null;
  }
}

/**
 * 验证会话的有效性
 * 通过外部存储验证会话状态,确保令牌未被撤销
 * @param {string} token - JWT认证令牌
 * @param {string} userId - 用户ID
 * @returns {boolean} 会话是否有效
 */
function isValidSession(token, userId) {
  // 通过外部存储(如 Redis)验证会话有效性
  // 这里简化为直接验证 token
  try {
    const payload = jwt.verify(token, process.env.JWT_SECRET);
    return payload.userId === userId && payload.exp > Date.now() / 1000;
  } catch (error) {
    return false;
  }
}

/**
 * 中间件配置对象
 * 定义需要应用此中间件的路由匹配规则
 */
export const config = {
  matcher: ['/dashboard/:path*', '/profile/:path*'],
};

4.1.1 整体架构

主要功能是验证用户的身份认证令牌( token ),并根据验证结果决定是否允许用户访问受保护的路由(如 /dashboard/profile )。

  • 消除全局状态依赖,确保中间件的无状态性。
  • 通过外部存储系统管理会话状态。
  • 利用 JWT 的过期时间机制实现会话管理。

4.1.2 关键设计决策

  • 无状态设计
    • 代码不依赖全局状态,而是通过每次请求验证 token 的有效性,确保安全性和可扩展性。
  • 模块化验证
    • token 验证和会话验证拆分为独立的函数( verifyTokenisValidSession ),便于维护和测试。
  • 安全性
    • 使用 JWT (JSON Web Token)进行身份验证,并通过环境变量 process.env.JWT_SECRET 存储密钥,避免硬编码敏感信息。

4.1.3 具体实现分析

  • 中间件函数 middleware
    • 作用
      • 处理每个请求,验证用户的 token 是否有效。
    • 逻辑流程
      • 从请求的 cookies 中获取 auth-token
      • 如果 token 不存在,重定向到 /login
      • 调用 verifyToken 验证 token 的有效性,获取 userId
      • 如果 userId 无效,重定向到 /login
      • 调用 isValidSession 验证会话是否有效(如 token 是否过期)。
      • 如果会话无效,重定向到 /login
      • 如果所有验证通过,允许请求继续( NextResponse.next() )。
  • 辅助函数 verifyToken
    • 作用
      • 验证 token 是否有效,并提取 userId
    • 实现细节
      • 使用 jwt.verify 方法验证 token ,如果验证失败,返回 null
      • 密钥从环境变量 process.env.JWT_SECRET 获取。
  • 辅助函数 isValidSession
    • 作用
      • 验证会话是否有效(如 token 是否过期或用户是否匹配)。
    • 实现细节
      • 再次验证 token ,并检查 payload.userId 是否与传入的 userId 匹配。
      • 检查 token 的过期时间( payload.exp )是否大于当前时间。
  • 配置对象 config
    • 作用
      • 定义中间件生效的路由规则。
    • 实现细节
      • matcher 指定了需要保护的路径(如 /dashboard/:path*/profile/:path* )。

4.2 方案二:使用外部会话存储

对于更复杂的会话管理需求,我们可以引入外部存储系统:

代码语言:javascript
代码运行次数:0
运行
复制
// 使用 Redis 存储会话
import { NextResponse } from 'next/server';
import Redis from 'ioredis';

// 注意:在 Edge Runtime 中需要使用兼容的 Redis 客户端
const redis = new Redis(process.env.REDIS_URL);

/**
 * 中间件函数,用于验证用户身份和会话状态
 * @param {Request} request - HTTP 请求对象,包含 cookies 和 URL 信息
 * @returns {NextResponse} 重定向响应或继续处理的响应
 */
export async function middleware(request) {
  const token = request.cookies.get('auth-token')?.value;

  if (!token) {
    return NextResponse.redirect(new URL('/login', request.url));
  }

  const userId = verifyToken(token);
  if (!userId) {
    return NextResponse.redirect(new URL('/login', request.url));
  }

  // 从 Redis 获取会话信息
  const sessionData = await redis.get(`session:${userId}`);
  if (!sessionData) {
    return NextResponse.redirect(new URL('/login', request.url));
  }

  const session = JSON.parse(sessionData);
  if (session.token !== token) {
    return NextResponse.redirect(new URL('/login', request.url));
  }

  return NextResponse.next();
}

/**
 * 验证 JWT token 并提取用户 ID
 * @param {string} token - JWT token 字符串
 * @returns {string|null} 验证成功时返回用户 ID,失败时返回 null
 */
function verifyToken(token) {
  try {
    const payload = jwt.verify(token, process.env.JWT_SECRET);
    return payload.userId;
  } catch (error) {
    return null;
  }
}

该方案主要是验证用户的身份和会话状态,确保只有通过身份验证的用户才能访问受保护的资源。如果验证失败,用户会被重定向到登录页面。

4.2.1 整体架构

  • 依赖导入
    • 使用了 NextResponse 来处理 HTTP 响应。
    • 通过 ioredis 库连接到 Redis 数据库,用于存储和检索会话数据。
  • Redis 初始化
    • 通过环境变量 REDIS_URL 连接到 Redis 实例。
  • 中间件函数
    • 检查请求中的 auth-token Cookie。
    • 验证令牌的有效性。
    • 从 Redis 中检索会话数据。
    • 根据验证结果决定是否允许请求继续或重定向到登录页面。

4.2.2 关键设计决策

  • Redis 存储会话
    • 使用 Redis 存储会话数据,确保会话状态可以在多个服务器实例之间共享(适用于分布式系统)。
    • 会话数据以 JSON 格式存储,键为 session:${userId}
  • 令牌验证
    • 使用 JWT(JSON Web Token)验证用户身份。
    • 令牌的有效性通过 verifyToken 函数检查,失败时返回 null
  • 重定向逻辑
    • 任何验证失败的情况都会触发重定向到 /login 页面,确保未授权用户无法访问受保护资源。

4.2.3 参数解析

  • middleware 函数
    • 核心逻辑入口,处理请求并执行验证流程。
    • 参数 request 包含请求的详细信息(如 Cookie)。
  • verifyToken 函数
    • 使用 jwt.verify 验证令牌的有效性。
    • 返回 payload.userIdnull (验证失败时)。
  • redis 实例
    • 用于与 Redis 数据库交互,存储和检索会话数据。
  • token sessionData
    • token :从 Cookie 中提取的 JWT 令牌。
    • sessionData :从 Redis 中检索的会话信息。

五、性能优化与最佳实践

5.1 会话管理优化流程

5.2 缓存策略优化

为了提高性能,我们可以引入缓存机制:

代码语言:javascript
代码运行次数:0
运行
复制
/**
 * Session管理器类
 * 负责管理用户会话数据,提供缓存机制以提高访问性能
 */
class SessionManager {
  constructor() {
    this.cache = new Map();
    this.cacheExpiry = new Map();
    this.cacheTTL = 5 * 60 * 1000; // 5分钟缓存
  }

  /**
   * 获取用户会话数据
   * 首先检查内存缓存,如果缓存未命中或已过期,则从Redis获取数据并更新缓存
   * @param {string} userId - 用户ID
   * @returns {Promise<Object|null>} 用户会话数据对象,如果不存在则返回null
   */
  async getSession(userId) {
    // 检查缓存
    const now = Date.now();
    if (this.cache.has(userId)) {
      const expiry = this.cacheExpiry.get(userId);
      if (now < expiry) {
        return this.cache.get(userId);
      } else {
        // 缓存过期,清理
        this.cache.delete(userId);
        this.cacheExpiry.delete(userId);
      }
    }

    // 从 Redis 获取并缓存
    const sessionData = await redis.get(`session:${userId}`);
    if (sessionData) {
      const session = JSON.parse(sessionData);
      this.cache.set(userId, session);
      this.cacheExpiry.set(userId, now + this.cacheTTL);
      return session;
    }

    return null;
  }

  /**
   * 使指定用户的缓存失效
   * @param {string} userId - 用户ID
   */
  invalidateCache(userId) {
    this.cache.delete(userId);
    this.cacheExpiry.delete(userId);
  }
}

const sessionManager = new SessionManager();

5.3 安全性增强措施

5.3.1 Token 黑名单机制

为了增强安全性,我们实现了 token 黑名单机制:

代码语言:javascript
代码运行次数:0
运行
复制
/**
 * 安全管理器类
 * 负责处理令牌黑名单、会话管理和用户登出等安全相关功能
 */
class SecurityManager {
  /**
   * 将令牌添加到黑名单中
   * @param {string} token - 需要加入黑名单的令牌
   * @param {number} expiry - 令牌过期时间(秒)
   * @returns {Promise<void>}
   */
  async addToBlacklist(token, expiry) {
    const tokenHash = createHash('sha256').update(token).digest('hex');
    await redis.setex(`blacklist:${tokenHash}`, expiry, '1');
  }

  /**
   * 检查令牌是否在黑名单中
   * @param {string} token - 需要检查的令牌
   * @returns {Promise<boolean>} 令牌是否在黑名单中
   */
  async isTokenBlacklisted(token) {
    const tokenHash = createHash('sha256').update(token).digest('hex');
    const result = await redis.get(`blacklist:${tokenHash}`);
    return result === '1';
  }

  /**
   * 用户登出操作,将用户所有会话令牌加入黑名单并删除会话
   * @param {string} userId - 用户ID
   * @param {string} currentToken - 当前令牌
   * @returns {Promise<void>}
   */
  async logoutUser(userId, currentToken) {
    // 获取用户所有会话
    const userSessions = await redis.keys(`session:${userId}*`);

    // 将所有 token 加入黑名单
    for (const sessionKey of userSessions) {
      const sessionData = await redis.get(sessionKey);
      if (sessionData) {
        const session = JSON.parse(sessionData);
        const ttl = await redis.ttl(sessionKey);
        await this.addToBlacklist(session.token, ttl);
      }
    }

    // 删除所有会话
    if (userSessions.length > 0) {
      await redis.del(...userSessions);
    }
  }
}

const securityManager = new SecurityManager();

1、核心功能

该方案用于管理用户会话和令牌的黑名单功能。主要功能包括:

  • 将令牌加入黑名单。
  • 检查令牌是否在黑名单中。
  • 登出用户并清理其所有会话。

代码的核心依赖是 Redis,用于存储和管理会话数据及黑名单。

2、关键设计决策

  • 使用 Redis 作为存储后端
    • Redis 是一个高性能的内存数据库,适合处理频繁的读写操作(如会话管理和黑名单检查)。
    • 使用 Redis 的 setexget 方法实现黑名单功能,确保数据具有过期时间。
  • 令牌哈希化
    • 使用 SHA-256 哈希算法对令牌进行加密存储,避免直接存储原始令牌,提高安全性。
  • 批量操作
    • logoutUser 方法中,通过 redis.keys 获取用户的所有会话,并批量删除,减少网络开销。

结语

通过这次问题的排查和解决,我们深入了解了 Next.js Edge Runtime 的特性以及全局状态管理的潜在风险。问题的根本原因在于 Edge Runtime 为了性能优化而共享执行环境,导致全局变量在多个请求间产生冲突。

我们的解决方案包括:

  • 消除全局状态依赖:重构中间件为无状态设计。
  • 引入外部存储:使用 Redis 等外部系统管理会话状态。
  • 实现缓存机制:通过内存缓存提高访问性能。
  • 增强安全措施:实现 token 黑名单和会话注销机制。

这次经历提醒我们在设计系统时需要充分考虑运行环境的特性,特别是在使用新兴技术栈时要深入理解其底层机制。对于 Next.js 开发者而言,理解 Edge Runtime 与传统 Node.js Runtime 的差异至关重要,这有助于避免类似的全局状态共享问题。

在实际开发中,我们应该始终遵循"无状态优先"的设计原则,特别是在中间件和 serverless 函数中,避免依赖全局变量来维护状态,从而构建更加稳定和可扩展的应用系统。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 引言
  • 一、业务需求:多端登录状态的中间件校验设计
    • 1.1 业务场景描述
    • 1.2 技术实现方案
      • 1.2.1 整体架构与目的
      • 1.2.2 关键组件分析
      • 1.2.3 关键设计决策
  • 二、问题表现:异常的用户状态覆盖现象
    • 2.1 异常表现
      • 2.1.1 现象1:用户状态互相覆盖
      • 2.1.2 现象2:数据访问越权
      • 2.1.3 现象3:间歇性登录失效
    • 2.2 错误日志片段
  • 三、问题排查:从现象到本质的深度溯源
    • 3.1 第一步:日志分析与现象确认
    • 3.2 第二步:环境差异分析
    • 3.3 第三步:并发测试验证
    • 3.4 第四步:根本原因确认
  • 四、解决方案设计与实现
    • 4.1 方案一:移除全局状态依赖
      • 4.1.1 整体架构
      • 4.1.2 关键设计决策
      • 4.1.3 具体实现分析
    • 4.2 方案二:使用外部会话存储
      • 4.2.1 整体架构
      • 4.2.2 关键设计决策
      • 4.2.3 参数解析
  • 五、性能优化与最佳实践
    • 5.1 会话管理优化流程
    • 5.2 缓存策略优化
    • 5.3 安全性增强措施
      • 5.3.1 Token 黑名单机制
  • 结语
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档