在 Next.js 这样的全栈框架中,中间件(Middleware)被广泛用于拦截请求并校验用户登录状态。
而我们的Next.js 项目也是采用此方式,但是最近,我遇到了一个棘手的问题:中间件中的登录状态校验逻辑在 Edge Runtime 环境下出现了异常行为,导致不同用户之间的登录状态相互干扰。
在多端登录场景下,如何确保用户状态的准确性和安全性是一个关键挑战。而现在,我发现我也正经历着这项挑战。
本文将从真实业务场景出发,完整还原一次因Edge Runtime全局变量使用不当导致的多端登录状态冲突事件,深入剖析问题根源,提供可落地的解决方案,希望能为遇到类似问题的开发者提供参考。
我们的应用支持用户在多个设备上同时登录,包括 PC 端和移动端。为了确保账户安全,我们实现了登录状态的实时校验机制:当用户在某一设备上主动登出或会话过期时,其他设备上的会话也应立即失效。
我们采用 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;
}
}
这段代码实现了一个基于 Next.js
的中间件( middleware
),用于验证用户会话的有效性。其主要目的是:
auth-token
Cookie 是否有效。userSessions
变量Map
对象,键为 userId
,值为会话对象(包含 token
等信息)。Map
而不是普通对象,因为 Map
更适合动态增删键值对,且性能更好。middleware
函数auth-token
。verifyToken
函数验证令牌的有效性。verifyToken
函数jwt.verify
方法验证令牌签名。payload.userId
;否则返回 null
。process.env.JWT_SECRET
作为密钥,确保令牌无法被伪造。Map
存储会话信息,便于快速查找和更新。verifyToken
函数中,便于复用和维护。系统上线后,测试过程中发现一系列无法解释的现象:
中间件添加调试日志后,捕获到如下关键信息:
[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的账号在其他设备登录。
我们首先增加了详细的调试日志来追踪问题:
// 增加调试日志
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
这个全局变量在不同请求之间被共享,导致会话信息被覆盖。
我们怀疑问题与 Next.js 的运行环境有关。Next.js 支持多种运行时环境:
export default {
experimental: {
runtime: 'edge' // 或 'nodejs'
}
}
我们编写了一个简单的测试脚本来模拟并发请求:
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 环境下,全局变量确实会在多个请求间共享。
通过深入研究 Next.js 文档和 Edge Runtime 规范,我们确认了问题的根本原因:
在 Edge Runtime 中,为了提高性能和资源利用率,多个请求可能会共享同一个执行环境,这导致全局变量在请求间被共享。
最直接的解决方案是避免在中间件中使用全局变量:
// 重构版本
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*'],
};
主要功能是验证用户的身份认证令牌( token
),并根据验证结果决定是否允许用户访问受保护的路由(如 /dashboard
或 /profile
)。
token
的有效性,确保安全性和可扩展性。token
验证和会话验证拆分为独立的函数( verifyToken
和 isValidSession
),便于维护和测试。JWT
(JSON Web Token)进行身份验证,并通过环境变量 process.env.JWT_SECRET
存储密钥,避免硬编码敏感信息。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*
)。对于更复杂的会话管理需求,我们可以引入外部存储系统:
// 使用 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;
}
}
该方案主要是验证用户的身份和会话状态,确保只有通过身份验证的用户才能访问受保护的资源。如果验证失败,用户会被重定向到登录页面。
NextResponse
来处理 HTTP 响应。ioredis
库连接到 Redis 数据库,用于存储和检索会话数据。REDIS_URL
连接到 Redis 实例。auth-token
Cookie。session:${userId}
。verifyToken
函数检查,失败时返回 null
。/login
页面,确保未授权用户无法访问受保护资源。middleware
函数:request
包含请求的详细信息(如 Cookie)。verifyToken
函数:jwt.verify
验证令牌的有效性。payload.userId
或 null
(验证失败时)。redis
实例:token
和 sessionData
:token
:从 Cookie 中提取的 JWT 令牌。sessionData
:从 Redis 中检索的会话信息。为了提高性能,我们可以引入缓存机制:
/**
* 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();
为了增强安全性,我们实现了 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、关键设计决策
setex
和 get
方法实现黑名单功能,确保数据具有过期时间。logoutUser
方法中,通过 redis.keys
获取用户的所有会话,并批量删除,减少网络开销。通过这次问题的排查和解决,我们深入了解了 Next.js Edge Runtime 的特性以及全局状态管理的潜在风险。问题的根本原因在于 Edge Runtime 为了性能优化而共享执行环境,导致全局变量在多个请求间产生冲突。
我们的解决方案包括:
这次经历提醒我们在设计系统时需要充分考虑运行环境的特性,特别是在使用新兴技术栈时要深入理解其底层机制。对于 Next.js 开发者而言,理解 Edge Runtime 与传统 Node.js Runtime 的差异至关重要,这有助于避免类似的全局状态共享问题。
在实际开发中,我们应该始终遵循"无状态优先"的设计原则,特别是在中间件和 serverless 函数中,避免依赖全局变量来维护状态,从而构建更加稳定和可扩展的应用系统。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。