avatar

mdo

Hello

  • 首页
  • 知识库
  • 归档
  • 标签
  • 关于
主页 在原生 WebSocket 模式下,增加握手验证逻辑
文章

在原生 WebSocket 模式下,增加握手验证逻辑

发表于 2026-06-18 更新于 2026-06- 18
作者 mdo
33~42 分钟 阅读

在原生 WebSocket 模式下,增加握手验证逻辑有两种主流方式:

  • 方式一:基于 HTTP 握手阶段验证(最安全,在连接建立前直接拦截)。

  • 方式二:基于连接后首包验证(最灵活,适合小程序或部分无法自定义握手 Headers 的客户端)。


🔑 方式一:在 HTTP 握手阶段验证(推荐)

当客户端发起 WebSocket 连接时,会发送一个标准的 HTTP 请求。think-swoole 允许你监听系统的 swoole.onRequest 事件,或直接在自定义启动中拦截升级请求,但更简单、更规范的框架内做法是配置 onRequest 监听,拦截 WebSocket 升级握手。

不过,think-swoole 封装最优雅的方式,是直接监听 Swoole 底层的 handshake

事件。

1. 创建握手事件监听类

使用命令行生成监听类:

bash

php think make:listener WebsocketHandshake

请谨慎使用此类代码。

2. 编写验证逻辑

在 app\listener\WebsocketHandshake.php 中,你可以获取到前端传过来的 URL 参数(Query)、Cookie或 Headers,并进行 Token 校验:

php

namespace app\listener;

use Swoole\Http\Request;
use Swoole\Http\Response;

class WebsocketHandshake
{
    public function handle($params)
    {
        // think-swoole 会将 swoole 的 request 和 response 对象传递过来
        /** @var Request $request */
        $request = $params[0];
        /** @var Response $response */
        $response = $params[1];

        // 1. 获取客户端带来的 Token(假设放在 URL 参数中:ws://127.0.0.1:9501?token=xxxx)
        $token = $request->get['token'] ?? '';

        // 如果你希望从 Headers 中获取,可以使用:
        // $token = $request->header['authorization'] ?? '';

        // 2. 验证 Token 是否合法(这里可以用你的 JWT 或数据库/Redis 校验逻辑)
        if (!$this->checkToken($token)) {
            // 验证失败:返回 401 状态码,并结束响应,拒绝握手
            $response->status(401);
            $response->end('Unauthorized');
            return false; // 返回 false 阻止后续的握手流程
        }

        // 3. 验证通过,允许握手
        // 注意:如果你完全接管了 handshake 监听,你需要手动完成 WebSocket 握手协议应答
        // 为了避免手动写繁琐的握手 Head 应答,通常更建议使用下方的【方式二】或利用中间件思想。
        return true; 
    }
}

请谨慎使用此类代码。

⚠️ 注意:由于 Swoole 的 handshake 事件一旦监听就需要手动编写 Sec-WebSocket-Accept 等复杂的响应头,如果你不想处理复杂的底层协议细节,强烈建议使用下面的方式二。


🛡️ 方式二:连接后“首包验证”(企业项目最常用)

这是目前最安全且不容易出错的做法。允许标准握手通过,但在客户端连接成功后的 3秒内,必须发送一个 auth 验证包。如果没发,或者验证失败,后端直接断开该连接。

1. 注册连接事件

在 config/swoole.php 的 websocket.listen 中增加 connect 监听:

php

'listen' => [
    'connect' => \app\listener\WebsocketConnect::class, // 连接建立
    'message' => \app\listener\WebsocketMessage::class, // 收到消息
],

请谨慎使用此类代码。

2. 连接建立时:启动限时强制验证

在 app\listener\WebsocketConnect::class 中,利用 Swoole 定时器,如果 3 秒内没验证成功则强踢下线:

php

namespace app\listener;

use think\swoole\Websocket;
use think\facade\Cache;

class WebsocketConnect
{
    public function handle(Websocket $websocket)
    {
        $fd = $websocket->getSender();

        // 默认将该连接标记为“未验证”
        Cache::tag('ws_auth')->set("ws_user_{$fd}", false, 10);

        // 设置一个 3 秒的延迟定时器
        \Swoole\Timer::after(3000, function () use ($fd, $websocket) {
            // 3秒后检查是否验证成功
            $isAuthed = Cache::get("ws_user_{$fd}");
            if (!$isAuthed) {
                // 如果还没验证成功,直接强行关闭连接
                $websocket->close($fd);
            }
        });
    }
}

请谨慎使用此类代码。

3. 收到消息时:优先处理验证包

在 app\listener\WebsocketMessage.php 中,校验首包,并在后续通讯中鉴权:

php

namespace app\listener;

use think\swoole\Websocket;
use think\facade\Cache;

class WebsocketMessage
{
    public function handle(Websocket $websocket, $data)
    {
        $fd = $websocket->getSender();
        $payload = json_decode($data, true);

        // 1. 检查当前连接是否已经通过验证
        $isAuthed = Cache::get("ws_user_{$fd}");

        // 2. 如果未验证,当前包必须是 auth 验证包
        if (!$isAuthed) {
            if (isset($payload['event']) && $payload['event'] === 'auth') {
                $token = $payload['data']['token'] ?? '';
                
                if ($this->validateToken($token)) {
                    // 验证通过,更新缓存状态,并绑定用户 UID(假设从 token 中解出 uid 进内存)
                    Cache::tag('ws_auth')->set("ws_user_{$fd}", true, 86400);
                    
                    $websocket->push(json_encode(['event' => 'auth_success', 'msg' => '认证成功']));
                    return;
                }
            }
            
            // 凭证错误或不是验证包,直接拒绝并断开
            $websocket->push(json_encode(['event' => 'auth_fail', 'msg' => '认证失败']));
            $websocket->close($fd);
            return;
        }

        // 3. 已验证连接,正常处理后续业务逻辑
        switch ($payload['event']) {
            case 'chat':
                // 聊天逻辑...
                break;
        }
    }

    private function validateToken($token) {
        // 你的 Token 验证逻辑
        return $token === 'valid_token_here';
    }
}

请谨慎使用此类代码。

4. 前端配合发送首包

前端在 onopen 触发时,必须立刻发送带有 token 的验证包:

javascript

const ws = new WebSocket("ws://127.0.0.1:9501");

ws.onopen = () => {
    // 1. 连接成功后,立刻发送认证包
    ws.send(JSON.stringify({
        event: "auth",
        data: { token: "valid_token_here" }
    }));
};

ws.onmessage = (event) => {
    const res = JSON.parse(event.data);
    if (res.event === 'auth_success') {
        console.log('解锁全部功能');
        // 2. 认证成功后,才能开始发送其他业务数据
        ws.send(JSON.stringify({ event: "chat", data: "hello" }));
    }
};

请谨慎使用此类代码。


请问你更倾向于使用请求阶段(方式一)还是首包验证(方式二)?如果选择方式一,需要我为你提供完整的 Swoole 原生 Sec-WebSocket-Accept 握手头拼接代码吗?

方式一

选择方式一(在 HTTP 握手阶段验证)是最安全的做法,可以在不合法的连接进入 WebSocket 逻辑前将其拦截,从而节省服务器的内存和 CPU 资源。

在 think-swoole 中,如果要在原生 WebSocket 模式下自己接管 handshake,必须由你手动计算并返回符合 WebSocket 协议规范的响应头(主要是 Sec-WebSocket-Accept 加密校验),否则浏览器会报 Connection closed before receiving a handshake response 错误。

以下是完整的实现步骤和代码:

🛠️ 完整实现步骤

1. 注册 Handshake 监听

打开 config/swoole.php,在 websocket.listen 中添加 handshake 事件监听:

php

'listen' => [
    'handshake' => \app\listener\WebsocketHandshake::class, // 关键:在这里拦截
    'message'   => \app\listener\WebsocketMessage::class,
],

请谨慎使用此类代码。

2. 编写握手验证与协议响应类

创建 app\listener\WebsocketHandshake.php,并写入以下代码。代码中包含了 Token 鉴权逻辑 和 标准的 WebSocket 握手协议加密计算:

php

namespace app\listener;

use Swoole\Http\Request;
use Swoole\Http\Response;

class WebsocketHandshake
{
    public function handle($params)
    {
        // think-swoole 会把底层 Swoole 的 Request 和 Response 作为一个数组或对象打包传过来
        // 通常 $params[0] 是 Request, $params[1] 是 Response
        $request  = is_array($params) ? $params[0] : $params;
        $response = is_array($params) ? $params[1] : $params;

        if (!$request instanceof Request || !$response instanceof Response) {
            return false;
        }

        // ==========================================
        // 1. 鉴权逻辑:从 URL 参数中获取 token 
        // 客户端连接示例:ws://127.0.0.1:9501?token=my_secret_token
        // ==========================================
        $token = $request->get['token'] ?? '';

        if (!$this->checkToken($token)) {
            // 鉴权失败:返回 401 状态码并拒绝连接
            $response->status(401);
            $response->end('Unauthorized: Invalid Token');
            return false; // 返回 false 阻止后续连接
        }

        // ==========================================
        // 2. 协议校验:验证是否为标准的 WebSocket 升级请求
        // ==========================================
        $secWebSocketKey = $request->header['sec-websocket-key'] ?? null;
        if (empty($secWebSocketKey) || strlen($secWebSocketKey) !== 24) {
            $response->status(400);
            $response->end('Bad Request');
            return false;
        }

        // ==========================================
        // 3. 核心:计算 WebSocket 握手签名 (RFC 6455 规范)
        // ==========================================
        // 固定 GUID 字符串,不可更改
        $guid = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
        // 计算原本应答的 Key
        $acceptKey = base64_encode(sha1($secWebSocketKey . $guid, true));

        // ==========================================
        // 4. 设置标准握手响应头并结束响应
        // ==========================================
        $headers = [
            'Upgrade'               => 'websocket',
            'Connection'            => 'Upgrade',
            'Sec-WebSocket-Accept'  => $acceptKey,
            'Sec-WebSocket-Version' => '13',
        ];

        // 如果客户端带有子协议(Sec-WebSocket-Protocol),原样返回,否则部分浏览器会报错
        if (isset($request->header['sec-websocket-protocol'])) {
            $headers['Sec-WebSocket-Protocol'] = $request->header['sec-websocket-protocol'];
        }

        // 写入响应头
        foreach ($headers as $key => $val) {
            $response->header($key, $val);
        }

        // 设置状态码为 101 Switching Protocols
        $response->status(101);
        $response->end(); // 必须调用 end 结束 HTTP 响应

        // ==========================================
        // 5. 极其重要:
        // 手动触发框架底层的连接成功逻辑,并返回 true
        // ==========================================
        $fd = $request->fd;
        // 延迟一小会儿(或通过底层对应的框架服务)将该 fd 状态在 Swoole 中转为 WebSocket 成功状态
        // 在 Swoole 4+ 中,一旦完成 101 状态码的 end(),当前 fd 会自动升级为 WebSocket 连通状态
        
        return true; 
    }

    /**
     * 自定义 Token 验证方法
     */
    private function checkToken(string $token): bool
    {
        // 替换为你的真实业务逻辑(如 JWT 解密、查询 Redis 等)
        return $token === 'my_secret_token';
    }
}

请谨慎使用此类代码。

3. 前端客户端连接配合

由于是在 HTTP 握手阶段验证,前端必须在建立连接的初始 URL 中带上你的身份凭证。

javascript

// 正确的连接:带上有效的 token
const ws = new WebSocket("ws://127.0.0.1:9501?token=my_secret_token");

ws.onopen = () => {
    console.log("握手验证成功,连接已建立!");
    ws.send(JSON.stringify({ event: 'hello' }));
};

ws.onmessage = (event) => {
    console.log("收到消息:", event.data);
};

ws.onerror = (error) => {
    console.error("WebSocket 报错:", error);
};

ws.onclose = (event) => {
    // 如果 token 错误,这里会触发,且会看到控制台网络请求返回 401 状态码
    console.log("连接关闭,状态码:", event.code);
};

请谨慎使用此类代码。


⚠️ 避坑提示

  1. 不能在 handshake 阶段使用 $websocket->push():此时 WebSocket 连接尚未完全建立成功(还在 HTTP 升级阶段),使用 Swoole 原生的 $response->end() 是唯一中断或完成响应的手段。

  2. 多进程共享数据问题:如果验证通过后需要记录用户的 uid 和 fd 的映射关系,千万不要使用普通的 PHP 全局变量(如 $_SESSION 或类静态属性),因为 Swoole 是多进程架构。建议在验证通过后,将用户信息写入 Redis 或 Swoole\Table 中。

技术
握手
许可协议:  CC BY 4.0
分享

相关文章

7月 26, 2026

完美地解决 TP3 老系统数据的平滑读取

为了彻底、完美地解决 TP3 老系统数据的平滑读取,必须解决两个核心痛点: TP3 的盲猜反序列化机制(标量存原样,数组/对象存原生 serialize,同时兼容 json)。 TP3 与 TP6 的缓存前缀(Prefix)不一致。 以下是为你量身定制的终极落地解决方案,通

7月 26, 2026

thinkphp3 redis序列化和反序列化

在老系统重构迁移至 ThinkPHP 6 (TP6) 的过程中,ThinkPHP 3 (TP3) 的 Redis 序列化历史遗留问题是最核心的连环坑之一。 TP3 的 S('key', $value) 缓存方法和底层 Redis 驱动,在存储非字符串(如数组、对象)时,有一套独特的序列化行为。如果不

7月 24, 2026

Table 空间极易发生哈希冲突并溢出

既然你没有在业务中主动调用 Room,只使用了全局广播,那么这个问题就非常明确了:即使你不用房间功能,think-swoole 只要开启了 WebSocket,底层就会强行初始化并注入一个名为 Room 的驱动组件。 [1] 框架底层默认使用 table 类型来作为 Room 的存储介质。当进行全局

下一篇

前端使用的不是socket.io客户端 ,如何实现握手

上一篇

通过监听 request 事件来拦截和验证 WebSocket 握手

最近更新

  • 完美地解决 TP3 老系统数据的平滑读取
  • thinkphp3 redis序列化和反序列化
  • Table 空间极易发生哈希冲突并溢出
  • 将监控程序直接跑在云端
  • AI 驱动型 Facebook 群组关键词监控 Chrome 浏览器插件

热门标签

API CodeGeex Gitkraken Management Manticore Premiere Sublime Swoole ThinkPHP ThinkPHP5

目录

©2026 mdo. 保留部分权利。