在原生 WebSocket 模式下,增加握手验证逻辑
在原生 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);
};
请谨慎使用此类代码。
⚠️ 避坑提示
不能在
handshake阶段使用$websocket->push():此时 WebSocket 连接尚未完全建立成功(还在 HTTP 升级阶段),使用 Swoole 原生的$response->end()是唯一中断或完成响应的手段。多进程共享数据问题:如果验证通过后需要记录用户的
uid和fd的映射关系,千万不要使用普通的 PHP 全局变量(如$_SESSION或类静态属性),因为 Swoole 是多进程架构。建议在验证通过后,将用户信息写入 Redis 或 Swoole\Table 中。