Socket.IO 与 Express + Passport 鉴权集成:在握手阶段复用浏览器会话的完整实践
Socket.IO 的连接是独立于 HTTP 页面的长连接,如果服务端不在握手阶段校验身份,任何未登录的客户端都可以直接连上 socket 通道。本文基于 socket.io 官方仓库中的 Passport 集成示例,完整讲解如何在 Express + Passport(passport-local + express-session)应用中,把登录会话复用到 Socket.IO 握手上:包括 onlyForHandshake 中间件的工作原理、engine 中间件链的底层执行机制、登录/登出与 socket 生命周期的联动,以及客户端验证流程。读完本文,你可以将“已登录才能建立 Socket.IO 连接、登出即踢掉该会话所有连接”这一完整鉴权方案落地到自己的项目中。
示例定位与三种模块版本
该示例的目标非常聚焦:从一个标准的 Express + Passport 应用中,把认证上下文(req.user)传递到 Socket.IO 服务端。仓库中同时提供了三种模块形态,代码逻辑完全一致,仅模块语法和工程配置不同:
- CommonJS 版本:examples/passport-example/cjs/index.js
- ES Module 版本:examples/passport-example/esm/index.js
- TypeScript 版本:examples/passport-example/ts/index.ts
以 cjs/package.json 为例,依赖为 express ~4.17.3、express-session ~1.17.2、passport ^0.7.0、passport-local ^1.0.0、socket.io ^4.7.2。运行方式(对应 README 的 "How to use"):
$ npm ci && npm start
然后浏览器访问 http://localhost:3000;通过环境变量 PORT 可以指定其他端口(源码中为 process.env.PORT || 3000)。测试账号为 john / changeit。
整体流程
整个示例的交互链路是:
- 浏览器访问
/,未登录时重定向到/login,渲染 login.html 中的账号密码表单; - 表单 POST 到
/login,由passport.authenticate("local", ...)校验凭据,成功后通过serializeUser把用户对象写入 express-session,并 302 回首页; - 首页 index.html 执行
io()发起 Socket.IO 握手。由于握手请求携带了浏览器 Cookie(session cookie),engine 中间件链能还原出req.user,连接被放行; - 服务端为该 socket 加入
session:<sessionId>和user:<userId>两个房间,并响应whoami事件回传用户名; - 用户提交
/logout表单后,服务端销毁 session,并主动断开与该 session 关联的所有 socket。
服务端路由与 Passport 策略的核心代码如下(摘自 cjs/index.js):
const sessionMiddleware = session({
secret: "changeit",
resave: true,
saveUninitialized: true,
});
app.use(sessionMiddleware);
app.use(bodyParser.urlencoded({ extended: false }));
app.use(passport.session());
app.get("/", (req, res) => {
if (!req.user) {
return res.redirect("/login");
}
res.sendFile(join(__dirname, "index.html"));
});
app.post("/login", passport.authenticate("local", {
successRedirect: "/",
failureRedirect: "/",
}));
passport.use(new LocalStrategy((username, password, done) => {
if (username === "john" && password === "changeit") {
return done(null, { id: 1, username });
} else {
return done(null, false);
}
}));
注意 TS 版本 ts/index.ts 中额外显式调用了 app.use(passport.initialize()),而 JS 版本没有——passport 0.7 起 passport.session() 内部已隐含初始化,显式调用在类型化场景下仍是常见写法。
核心机制:onlyForHandshake 与 engine 中间件
这是整个示例的关键。README "How it works" 中的核心代码完整如下:
function onlyForHandshake(middleware) {
return (req, res, next) => {
const isHandshake = req._query.sid === undefined;
if (isHandshake) {
middleware(req, res, next);
} else {
next();
}
};
}
io.engine.use(onlyForHandshake(sessionMiddleware));
io.engine.use(onlyForHandshake(passport.session()));
io.engine.use(
onlyForHandshake((req, res, next) => {
if (req.user) {
next();
} else {
res.writeHead(401);
res.end();
}
}),
);
三个要点:
- 中间件注册在
io.engine上而非 Socket.IO 层的 namespace middleware 上。io.engine.use(fn)把中间件推入 engine 层的中间件数组,作用于 Engine.IO 的原始 HTTP 请求; - 同一个
sessionMiddleware实例被复用。Express 应用和 Engine.IO 共享同一个 express-session 实例,这是握手阶段能读到同一份 session 的前提; onlyForHandshake保证会话校验只发生在握手请求上。判断依据是req._query.sid === undefined:第一次握手请求没有sid查询参数,而后续轮询/升级请求都会带上握手时分配的sid,此时直接next()跳过,避免每个 poll 请求都跑一遍 session 逻辑。
这个判断与 engine.io 内部对“初始请求”的定义是一致的。在 packages/engine.io/lib/server.ts 中,isInitialRequest 的判定就是 !req._query.sid,而 req._query 本身就是 URL 查询参数:engine 会优先复用上游(如 connect)已经解析好的 req._query,否则从 url.searchParams 构建:
// try to leverage pre-existing `req._query` (e.g: from connect)
if (!req._query) {
req._query = Object.fromEntries(url.searchParams.entries());
}
也就是说,onlyForHandshake 用的 req._query.sid 与 engine 内部区分握手/poll 请求用的是同一个字段,语义完全对齐。
engine 中间件链的底层执行
use() 与中间件的执行逻辑可以直接在源码中验证(packages/engine.io/lib/server.ts):
public use(fn: any) {
this.middlewares.push(fn);
}
protected _applyMiddlewares(req, res, callback) {
if (this.middlewares.length === 0) {
return callback();
}
const apply = (i) => {
this.middlewaresi => {
if (err) {
return callback(err);
}
if (i + 1 < this.middlewares.length) {
apply(i + 1);
} else {
callback();
}
});
};
apply(0);
}
即中间件按注册顺序串联执行,全部 next() 之后才进入握手流程。由于示例中第三个中间件直接对未认证请求 res.writeHead(401); res.end() 而不调用 next(),中间件链在此终止,握手不会发生,客户端会收到 401 并进入 socket.io-client 的失败处理路径(重连)。
源码中还有一个值得注意的实现细节:engine 为 WebSocket upgrade 请求暴露了一个 http.ServerResponse 的子集,并且注释说明“部分中间件(如 express-session)需要等待 writeHead() 调用才会刷出响应头”。这解释了为什么示例中未认证分支要显式调用 res.writeHead(401) 而不是依赖其他写法——这是为了让会话类中间件在握手(包括 upgrade)场景下行为正确。
另一个重要限制也来自这份源码(packages/engine.io/lib/server.ts):一旦 engine 配置了中间件,WebTransport 会话会被主动关闭,因为中间件期望的参数是标准的 IncomingMessage,无法从 WebTransport 的 session 对象构造。换言之,使用了 io.engine.use() 的部署,WebTransport 传输实际上不可用,连接会回退到其他传输方式。
连接建立后:按 session 与 user 建房间
握手通过后的连接处理逻辑:
io.on("connection", (socket) => {
const req = socket.request;
socket.join(`session:${req.session.id}`);
socket.join(`user:${req.user.id}`);
socket.on("whoami", (cb) => {
cb(req.user.username);
});
});
socket.request就是携带了session与user的原始握手请求,这是 engine 中间件“埋”进请求对象的状态,在 socket 层随时可读;session:<sessionId>房间用于“按会话踢人”,user:<userId>房间用于“按用户定向推送”(同一用户多标签页都会命中 user 房间);whoami事件用 ack 回调把req.user.username回给客户端。TS 版本中由于req.user/req.session不属于默认类型,需要socket.request as Request & { user: Express.User }断言,并通过declare global扩展Express.User接口。
登出与 socket 的联动
app.post("/logout", (req, res) => {
const sessionId = req.session.id;
req.session.destroy(() => {
// disconnect all Socket.IO connections linked to this session ID
io.to(`session:${sessionId}`).disconnectSockets();
res.status(204).end();
});
});
登出前先用闭包保存 sessionId(session 销毁后 req.session.id 就不再可靠),销毁回调中再通过 session:<id> 房间调用 disconnectSockets(),把该会话名下的所有 socket(包括其他标签页)一并断开。这实现了“服务端登出即时生效于所有长连接”,而客户端 index.html 中的 socket.on('disconnect') 监听会把状态更新为 disconnected,与 GIF 演示的登出效果对应。
客户端页面逻辑同样值得注意:connect 事件中先显示 socket id,再 socket.emit('whoami', (username) => ...) 通过 ack 回填用户名——这就是服务端 req.user 已正确传递到 socket 层的最直接验证。
适用前提与落地注意事项
- 该方案适用于 同源、基于 session cookie 的部署(握手请求由浏览器自动带上 session Cookie)。跨域场景需要处理 CORS 与
withCredentials,且认证状态必须仍能在握手 HTTP 请求上还原; - 中间件必须通过
io.engine.use()注册,且onlyForHandshake这类“只跑一次”的包装是必要的,否则每个 poll 请求都会重复执行会话逻辑; - 从源码结构看,engine 中间件链在中间件全部
next()之后才允许握手,因此任何在中间件里挂起的异步操作都会阻塞该连接的建立; - 启用了 engine 中间件后,WebTransport 传输不可用,这是 packages/engine.io/lib/server.ts 中明确的实现约束,而非配置项;
- 示例中的
LocalStrategy硬编码账号john/changeit、session({ secret: "changeit" })仅为演示用途,生产环境应替换为真实的用户存储与随机密钥,并考虑用 Socket.IO 层的 namespace middleware(而非 engine 中间件)做更细粒度的按命名空间鉴权。
小结
该示例用不到 120 行代码演示了 Socket.IO 与 Web 会话体系集成的标准姿势:复用同一个 express-session 实例挂到 io.engine.use() 上,用 req._query.sid === undefined 区分握手请求以控制中间件只执行一次,在连接后通过 socket.request 读取认证上下文并按 session/user 维度组织房间,最终在登出时用 disconnectSockets() 精确清理该会话的所有连接。CJS、ESM、TypeScript 三个版本(cjs/index.js、esm/index.js、ts/index.ts)可直接复制运行,配合 packages/engine.io/lib/server.ts 中 _applyMiddlewares 的源码,可以完整理解这条鉴权链路从 HTTP 握手到 socket 房间的全流程。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
