首页
/ Socket.IO 与 Express + Passport 鉴权集成:在握手阶段复用浏览器会话的完整实践

Socket.IO 与 Express + Passport 鉴权集成:在握手阶段复用浏览器会话的完整实践

2026-09-04 11:20:26作者:宗隆裙

Socket.IO 的连接是独立于 HTTP 页面的长连接,如果服务端不在握手阶段校验身份,任何未登录的客户端都可以直接连上 socket 通道。本文基于 socket.io 官方仓库中的 Passport 集成示例,完整讲解如何在 Express + Passport(passport-local + express-session)应用中,把登录会话复用到 Socket.IO 握手上:包括 onlyForHandshake 中间件的工作原理、engine 中间件链的底层执行机制、登录/登出与 socket 生命周期的联动,以及客户端验证流程。读完本文,你可以将“已登录才能建立 Socket.IO 连接、登出即踢掉该会话所有连接”这一完整鉴权方案落地到自己的项目中。

Passport 示例:登录后可建立 Socket.IO 连接并显示用户名

示例定位与三种模块版本

该示例的目标非常聚焦:从一个标准的 Express + Passport 应用中,把认证上下文(req.user)传递到 Socket.IO 服务端。仓库中同时提供了三种模块形态,代码逻辑完全一致,仅模块语法和工程配置不同:

cjs/package.json 为例,依赖为 express ~4.17.3express-session ~1.17.2passport ^0.7.0passport-local ^1.0.0socket.io ^4.7.2。运行方式(对应 README 的 "How to use"):

$ npm ci && npm start

然后浏览器访问 http://localhost:3000;通过环境变量 PORT 可以指定其他端口(源码中为 process.env.PORT || 3000)。测试账号为 john / changeit

整体流程

整个示例的交互链路是:

  1. 浏览器访问 /,未登录时重定向到 /login,渲染 login.html 中的账号密码表单;
  2. 表单 POST 到 /login,由 passport.authenticate("local", ...) 校验凭据,成功后通过 serializeUser 把用户对象写入 express-session,并 302 回首页;
  3. 首页 index.html 执行 io() 发起 Socket.IO 握手。由于握手请求携带了浏览器 Cookie(session cookie),engine 中间件链能还原出 req.user,连接被放行;
  4. 服务端为该 socket 加入 session:<sessionId>user:<userId> 两个房间,并响应 whoami 事件回传用户名;
  5. 用户提交 /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();
    }
  }),
);

三个要点:

  1. 中间件注册在 io.engine 上而非 Socket.IO 层的 namespace middleware 上io.engine.use(fn) 把中间件推入 engine 层的中间件数组,作用于 Engine.IO 的原始 HTTP 请求;
  2. 同一个 sessionMiddleware 实例被复用。Express 应用和 Engine.IO 共享同一个 express-session 实例,这是握手阶段能读到同一份 session 的前提;
  3. 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 就是携带了 sessionuser 的原始握手请求,这是 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/changeitsession({ 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.jsesm/index.jsts/index.ts)可直接复制运行,配合 packages/engine.io/lib/server.ts_applyMiddlewares 的源码,可以完整理解这条鉴权链路从 HTTP 握手到 socket 房间的全流程。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.12 K
2.72 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
528
588
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
906
1.83 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
854
1.34 K
docsdocs
暂无描述
Markdown
891
5.79 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.53 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.34 K
1.45 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
988
506
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
540
384