SSF0SSF0
首页
前端
  • Node
  • Go
  • Python
  • MySql
  • Bash
  • Git
  • Docker
  • VuePress
  • CI/CD
  • 服务器
  • 网站
  • 学习资料
  • 软件
  • 开源项目
Timeline
Github
标签
分类
首页
前端
  • Node
  • Go
  • Python
  • MySql
  • Bash
  • Git
  • Docker
  • VuePress
  • CI/CD
  • 服务器
  • 网站
  • 学习资料
  • 软件
  • 开源项目
Timeline
Github
标签
分类
  • HTML

    • html1
    • html2
  • CSS

    • Flex 布局常见问题与解决方案
  • JavaScript

    • 数据类型及引用问题
    • 处理 Blob 类型文件下载问题总结
    • localStorage 与 sessionStorage 区别
    • JavaScript 中的 script 标签问题详解
    • JavaScript 中的 this 指向问题详解
    • SessionStorage踩坑记录
    • 动态加载 JS 脚本方法对比
    • 浏览器页面关闭场景下的数据上报
    • HTTP流式接口详解与封装实践
    • JavaScript 中 this 绑定与解构:为什么 Pinia Store 可以解构,但 Class 实例方法不行?
  • es6

    • Promise
    • es6 模块导出方式全解析
    • reduce
    • forEach 中使用 async/await 的异步陷阱
  • Vue2

    • created VS mounted 发起异步请求
    • vue2-2
  • Vue3

    • Vite + Vue3 修改 element-plus 源码
    • Vue v-if 与 v-show
    • Vue3 ref 获取组件
    • Vue3 路由传参
    • 父子组件与组件里 Hooks 加载顺序
    • 第三方组件传入参数TS提示
    • Vue3 Props 在 Hooks 中的响应性处理
    • Vue Router 的两种历史模式及部署配置详解
    • 在Vue 3项目中顺利集成Tailwind CSS的完整指南
    • Vue Scoped CSS 深度解析
    • Vue 3 Teleport 组件完全指南:从原理到实战
    • Vant List 组件中双向绑定 loading 的注意事项
    • Vue 3 + Vite 中的路径别名与静态资源处理深度解析
    • 获取图片高度
    • 深入理解 Pinia:storeToRefs vs toRefs - 避开 TypeScript 类型推断的坑
  • webSocket

    • WebSocket 鉴权入门(实战版):握手票据 / 首帧鉴权 + 连接级 cid
  • Electron

    • 快速构建 electron + vue3 项目
  • TS

    • TS 泛型
    • 记录模板使用断言的问题
    • type 与 interface
  • WebPack

    • Webpack 介绍
  • Vite

    • Vite CLI 常见命令
    • vite 与 webpack 比较
    • Vite base 配置与 public 静态资源路径问题
  • 客户端数据库

    • IndexedDB 索引陷阱:字段重命名后索引返回旧数据
    • IndexedDB 事务陷阱与最佳实践:TransactionInactiveError 完全解析
  • UniApp

    • uni-app + Monorepo + 微信小程序热更新失效问题排查与结论总结
    • Monorepo + uni-app 开发中监听 / 热更新机制差异与解决方案
    • uni-app 运行与编译原理梳理
    • 为什么 uni-app 在 Monorepo 中必须做「环境区分」,而 Web 项目却不用?
  • 项目工程

    • 前端代码风格
    • Vue3 项目规范
    • npm 镜像问题
    • 包管理工具
    • 使用 engines 限制开发环境依赖
    • 打包与shell交互指定模式
    • Monorepo示例
    • PC 项目兼容 uni-app:基于 pnpm Monorepo 的工程化重构实践
    • Vue3 + Vite + pnpm Monorepo 迁移问题指南
    • 前端工程化:深入理解 npm 依赖管理机制
    • pnpm 修改依赖源码打包报错
    • 代码片段
  • 移动端

    • 浏览器跳转外部应用时请求状态码问题
  • 记录一下小程序
  • 控制浏览器正确保存网站账号密码的技巧
  • 信息泄露
  • 使用Gulp实现前端项目哈希化压缩打包
  • 前端缓存策略与文件哈希化的关联解析
  • 浏览器刷新方式与 Vue 生命周期、缓存行为解析
  • 浏览器消息铃声实现记录

WebSocket 鉴权入门(实战版):握手票据 / 首帧鉴权 + 连接级 cid

这是一篇把我们前面讨论过的要点合并整理后的入门文档。重点强调两件事:

  1. 鉴权该怎么做(握手票据 vs 首帧鉴权);

  2. 每条连接都有唯一 cid,用于区分与观测连接(而“谁在用这条连接”由鉴权绑定的 userId 决定)。


1 WebSocket 与 HTTP 的根本区别

  • HTTP API:短连接、无状态。每次请求来就处理,返回后连接即结束,服务端不用“记住这条连接”。

  • WebSocket:长连接、双向通信。握手成功后,服务端会一直持有该连接(直到关闭),并在“连接上下文”里保存会话信息(如 cid、userId、订阅集等)。

连接建立 ≠ 身份确认:

  • 服务端总能区分“每一条连接”(有独立的 socket 对象),这是连接层;

  • 但不知道“这条连接是谁(哪个用户)”,直到鉴权完成为止,这是身份层。


2 连接级 cid:区分连接的“身份证”

WebSocket 协议本身没有连接 ID。因此建议在服务端为每条新连接生成一个唯一 cid(如 UUID),并写入连接上下文,用途:

  • 在未鉴权阶段也能可靠地区分和观测连接(日志、限流、告警)。

  • 鉴权通过后,将 userId 绑定到同一条连接,不改变 cid。

  • 断开时用 cid 做清理;重连时生成新的 cid。

  • 生产中建议所有日志/指标都打上 cid;需要时再加上 userId 串联业务。

区分靠 cid,识别靠 userId。千万别把客户端自报的 cid 当作权威;应以**服务端生成的 cid**为准。


3 两种常见鉴权方案

方案一:握手阶段携带一次性短票据(推荐)

做法

  1. 前端通过 HTTPS 调 /auth/ws-ticket 获取一次性、30–60 秒有效的 ticket。

  2. 建立连接:wss://example.com/ws?ticket=<ticket>。

  3. 服务端在 HTTP Upgrade 握手时校验 ticket(签名/时效/未使用),通过即作废,并把 userId 绑定到连接上下文。

优点

  • 握手即鉴权:连接一建立就知道“这条线是谁”,可立即推送用户数据。

  • 失败直接拒绝升级(不会占用 WS 资源)。

  • 浏览器 WS 不支持自定义请求头,查询参数是通用手段。

注意

  • 不要把长期 JWT 放 URL,改用一次性短票据;全程 wss://;校验 Origin 白名单。

  • 多实例部署时,把 userId -> socketIds 路由写入 Redis/NATS 便于跨实例转发。


方案二:连接后首帧发送鉴权消息(需要状态机)

做法

  1. 连接建立后,服务端立刻生成 cid,将连接置为 pending(未鉴权):

    • 仅允许 auth 消息;

    • 设 5–10 秒鉴权超时;

    • 对 pending 做连接数/速率限制(防滥用)。

  2. 客户端首帧发送 {type:'auth', token:<JWT>};服务端校验:

    • 通过 → ws.ctx = { cid, userId, ... },迁移到 authenticated;

    • 失败/超时 → close(4401)。

优点

  • token 不暴露在 URL;适合必须在应用层协议统一鉴权/续期的场景。

注意

  • 必须实现未鉴权状态机与超时控制;鉴权前不得给该连接推送用户数据。

  • 同样需要 wss:// 与 Origin 校验。


4 握手只发生一次:ticket 过期不影响已连接

  • HTTP Upgrade 仅在“新建连接”时发生一次。

  • 带着 ticket 的 URL 只在握手时使用;握手成功后进入 WS 帧流,不会再回到 Upgrade。

  • 因此 ticket 过期不影响已建立的连接,只影响新建/重连。


5 全链路时序(含 cid)

流程图


6 代码骨架(TypeScript + Node.js ws)

服务端:cid + pending/authed 管理(适配方案二;方案一可省略 pending)

import http from "http";
import { WebSocketServer } from "ws";
import { randomUUID } from "crypto";

const server = http.createServer();
const wss = new WebSocketServer({ server });

type Ctx = { cid: string; userId?: string; subs: Set<string> };

const cidMap = new Map<string, WebSocket>(); // cid -> ws
const userMap = new Map<string, Set<WebSocket>>(); // userId -> Set<ws>

wss.on("connection", (ws, req) => {
  // 1) 连接建立就分配 cid(连接层标识)
  (ws as any).ctx = { cid: randomUUID(), subs: new Set<string>() } as Ctx;
  const { cid } = (ws as any).ctx;
  cidMap.set(cid, ws);

  // 可选:回给前端,用于日志串联
  ws.send(JSON.stringify({ type: "hello", cid }));

  // 2) ——若你用方案二:设置鉴权超时,仅允许 'auth' —— //
  const authTimeout = setTimeout(() => {
    if (!(ws as any).ctx.userId) ws.close(4401, "auth timeout");
  }, 10_000);

  ws.on("message", (raw) => {
    let msg: any;
    try {
      msg = JSON.parse(String(raw));
    } catch {
      return;
    }

    // 未鉴权:只接受首帧 auth
    if (!(ws as any).ctx.userId) {
      if (msg.type !== "auth") return ws.close(4401, "auth required");
      const payload = verifyToken(msg.token); // TODO: 自己实现
      if (!payload) return ws.close(4401, "invalid token");

      // 3) 绑定身份(身份层)
      (ws as any).ctx.userId = payload.sub;
      clearTimeout(authTimeout);
      if (!userMap.has(payload.sub)) userMap.set(payload.sub, new Set());
      userMap.get(payload.sub)!.add(ws);

      ws.send(JSON.stringify({ type: "auth_ok" }));
      return;
    }

    // 4) 已鉴权:业务处理(身份来自 ws.ctx,不信任消息体携带的 userId/cid)
    handleBusiness((ws as any).ctx, msg, ws);
  });

  ws.on("close", () => {
    clearTimeout(authTimeout);
    cidMap.delete(cid);
    const userId = (ws as any).ctx.userId;
    if (userId) {
      const set = userMap.get(userId);
      set?.delete(ws);
      if (set && set.size === 0) userMap.delete(userId);
    }
  });
});

server.listen(8080);

// 广播/定向推送
function sendToUser(userId: string, message: any) {
  const set = userMap.get(userId);
  if (!set) return;
  const data = JSON.stringify(message);
  for (const ws of set) if (ws.readyState === ws.OPEN) ws.send(data);
}

若使用方案一(握手即鉴权),在 server.on('upgrade') 时先校验并作废 ticket,成功才 handleUpgrade(),随后直接设置 ws.ctx.userId = payload.userId 即可,基本不需要 pending 状态机。

客户端:断线重连 + 快照/增量

let ws: WebSocket | null = null;
let retry = 0;
let lastEventId: string | null = null;
const subs = new Set<string>(["orders"]);

async function getTicket() {
  const r = await fetch("/auth/ws-ticket", { method: "POST" });
  return r.json() as Promise<{ ticket: string; expireAt: number }>;
}

async function connect() {
  const { ticket } = await getTicket();
  ws = new WebSocket(
    `wss://example.com/ws?ticket=${encodeURIComponent(ticket)}`
  );

  ws.onopen = () => {
    retry = 0;
    for (const topic of subs) {
      ws!.send(
        JSON.stringify({
          type: "subscribe",
          topic,
          snapshot: true,
          since: lastEventId,
        })
      );
    }
  };

  ws.onmessage = (ev) => {
    const msg = JSON.parse(ev.data);
    if (msg.eventId) lastEventId = msg.eventId; // 记录递增ID便于断线补偿
    // TODO: 业务处理
  };

  ws.onclose = () => {
    const delay = Math.min(30000, 1000 * 2 ** retry++) + Math.random() * 300;
    setTimeout(connect, delay);
  };
}

connect();

7 常见问答(FAQ)

Q1:cid 是协议自带的吗?

不是。原生 WS 没有连接 ID。cid 应由服务端在 connection 事件里自行生成并绑定到连接上下文。

Q2:为何不要在每条消息里带 userId?

身份来自“这条连接是谁”(ws.ctx.userId),每条消息带 userId 容易被伪造,还会重复冗余。除非你做每消息签名(复杂度高),否则不要把 userId 当作消息体凭据。

Q3:ticket 过期会影响已建立的连接吗?

不会。Upgrade 只发生在建立连接时。ticket 过期只影响新建/重连。

Q4:access token 很短,需要在连接内续期吗?

看需求。最简单是断线重连即可;若希望无缝续期,可以设计 reauth { token:newJWT } 消息更新 ws.ctx,或服务端发 reauth_required 提示前端重连。


8 最佳实践清单

  • [ ] 全程 wss://,并校验 Origin 白名单

  • [ ] 服务端生成唯一 cid,贯穿整个连接生命周期(日志/指标都打上 cid)

  • [ ] 握手即鉴权(推荐):URL 查询参数携带一次性短票据,校验通过即作废

  • [ ] 首帧鉴权(可选):未鉴权只允许 auth,5–10s 超时,对 pending 做限流/限并发

  • [ ] 鉴权通过后将 { userId, roles, subs } 绑定到连接上下文(后续消息不再带凭据)

  • [ ] 快照 + 增量:subscribe(snapshot:true, since:lastEventId?)

  • [ ] 心跳(ping/pong)与背压阈值(send 队列长度限制)

  • [ ] 集群路由:Redis/NATS 维护 userId -> {instance, socketIds/cids},跨实例转发

  • [ ] 关闭码:4401(auth failed/timeout)、4403(reauth required)、1012(service restart)

  • [ ] 日志串联:必要时把 cid 回给前端用于问题定位,但不要把客户端自报 cid 当权威


9 TL;DR

  • 每条连接都有唯一 cid(服务端生成):用于区分连接、日志追踪与限流排障。

  • 鉴权把“谁在用这条连接”绑定到连接上下文(userId);后续消息无需再带身份。

  • 默认为握手票据方案;特殊场景用首帧鉴权但要写好 pending 状态机与超时。

  • 断线重连 = 新 cid;重放快照或按 lastEventId 补增量恢复状态。

最后更新时间:
贡献者: 何风顺