Next.js 中间件拦截失效:Edge Runtime 中的全局状态共享问题深度剖析
引言
在 Next.js 这样的全栈框架中,中间件(Middleware)被广泛用于拦截请求并校验用户登录状态。
而我们的Next.js 项目也是采用此方式,但是最近,我遇到了一个棘手的问题:中间件中的登录状态校验逻辑在 Edge Runtime 环境下出现了异常行为,导致不同用户之间的登录状态相互干扰。
在多端登录场景下,如何确保用户状态的准确性和安全性是一个关键挑战。而现在,我发现我也正经历着这项挑战。
本文将从真实业务场景出发,完整还原一次因Edge Runtime全局变量使用不当导致的多端登录状态冲突事件,深入剖析问题根源,提供可落地的解决方案,希望能为遇到类似问题的开发者提供参考。
一、业务需求:多端登录状态的中间件校验设计
1.1 业务场景描述
我们的应用支持用户在多个设备上同时登录,包括 PC 端和移动端。为了确保账户安全,我们实现了登录状态的实时校验机制:当用户在某一设备上主动登出或会话过期时,其他设备上的会话也应立即失效。
1.2 技术实现方案
我们采用 Next.js 的中间件机制来实现全局的登录状态校验。在中间件中,我们维护了一个全局的用户会话映射表,用于跟踪每个用户的登录状态。
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-tokenCookie 是否有效。 - 会话管理:确保用户的会话仍然有效,否则重定向到登录页面。
- 安全性:防止无效或过期的令牌被用于访问受保护的资源。
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 错误日志片段
中间件添加调试日志后,捕获到如下关键信息:
[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 第一步:日志分析与现象确认
我们首先增加了详细的调试日志来追踪问题:
// 增加调试日志
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 的轻量级运行时。
export default {
experimental: {
runtime: 'edge' // 或 'nodejs'
}
}
3.3 第三步:并发测试验证
我们编写了一个简单的测试脚本来模拟并发请求:
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 方案一:移除全局状态依赖
最直接的解决方案是避免在中间件中使用全局变量:
// 重构版本
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验证和会话验证拆分为独立的函数(verifyToken和isValidSession),便于维护和测试。
- 安全性:
- 使用
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 方案二:使用外部会话存储
对于更复杂的会话管理需求,我们可以引入外部存储系统:
// middleware.js - 使用 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-tokenCookie。 - 验证令牌的有效性。
- 从 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.userId或null(验证失败时)。
redis实例:
- 用于与 Redis 数据库交互,存储和检索会话数据。
token和sessionData:
token:从 Cookie 中提取的 JWT 令牌。sessionData:从 Redis 中检索的会话信息。
五、性能优化与最佳实践
5.1 缓存策略优化
为了提高性能,我们可以引入缓存机制:
/**
* 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.2 安全性增强措施
5.2.1 Token 黑名单机制
为了增强安全性,我们实现了 token 黑名单机制:
/**
* 安全管理器类
* 负责处理令牌黑名单、会话管理和用户登出等安全相关功能
*/
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 的
setex和get方法实现黑名单功能,确保数据具有过期时间。
- 令牌哈希化:
- 使用 SHA-256 哈希算法对令牌进行加密存储,避免直接存储原始令牌,提高安全性。
- 批量操作:
- 在
logoutUser方法中,通过redis.keys获取用户的所有会话,并批量删除,减少网络开销。
结语
通过这次问题的排查和解决,我们深入了解了 Next.js Edge Runtime 的特性以及全局状态管理的潜在风险。问题的根本原因在于 Edge Runtime 为了性能优化而共享执行环境,导致全局变量在多个请求间产生冲突。
我们的解决方案包括:
- 消除全局状态依赖:重构中间件为无状态设计。
- 引入外部存储:使用 Redis 等外部系统管理会话状态。
- 实现缓存机制:通过内存缓存提高访问性能。
- 增强安全措施:实现 token 黑名单和会话注销机制。
这次经历提醒我们在设计系统时需要充分考虑运行环境的特性,特别是在使用新兴技术栈时要深入理解其底层机制。对于 Next.js 开发者而言,理解 Edge Runtime 与传统 Node.js Runtime 的差异至关重要,这有助于避免类似的全局状态共享问题。
在实际开发中,我们应该始终遵循"无状态优先"的设计原则,特别是在中间件和 serverless 函数中,避免依赖全局变量来维护状态,从而构建更加稳定和可扩展的应用系统。
- 点赞
- 收藏
- 关注作者
评论(0)