如何使用WebSocket开发一套系统(二. 为ws连接加入令牌并验证)

2,839 阅读3分钟

我们在传统的API服务中调用接口时,往往会使用Token的方式验证对方。那么我们使用websocket做开发的话,该如何携带令牌验证彼此身份呢?

如何解决令牌问题
  1. 我最初的想法是在ws连接后面拼接一个令牌,如:const socket = new WebSocket('wss://example.com/path?token=your_token')
  2. 我第二个想法是,在消息中携带令牌作为参数,可是这样就无法在建立连接时验证令牌了,非常不友好,浪费服务器资源
  3. 我最后选择的方案,将令牌添加到WebSocket协议的头部。在WebSocket协议中,定义了一些标准头部,如Sec-WebSocket-KeySec-WebSocket-Protocol,我只需要将令牌放入其中就可以使用了。
// 使用hyperf websocket服务的sec-websocket-protocol协议前
// 需要在config/autoload/server.php中补充配置

[  
    'name' => 'ws',  
    'type' => Server::SERVER_WEBSOCKET,  
    'host' => '0.0.0.0',  
    'port' => 9502,  
    'sock_type' => SWOOLE_SOCK_TCP,  
    'callbacks' => [  
        Event::ON_HAND_SHAKE => [Hyperf\WebSocketServer\Server::class, 'onHandShake'],  
        Event::ON_MESSAGE => [Hyperf\WebSocketServer\Server::class, 'onMessage'],  
        Event::ON_CLOSE => [Hyperf\WebSocketServer\Server::class, 'onClose'],  
    ],  
    'settings' => [  
        Constant::OPTION_OPEN_WEBSOCKET_PROTOCOL => true, // websocket Sec-WebSocket-Protocol 协议  
        Constant::OPTION_HEARTBEAT_CHECK_INTERVAL => 150,  
        Constant::OPTION_HEARTBEAT_IDLE_TIME => 300,  
    ],  
],
PHP中如何生成令牌
  1. 与传统开发一样,首先需要一个登录方法,验证账号密码后生成Token
  2. 如果采用这种方法,我们必须存在一个HTTP请求,用来调用登录接口,拿到令牌后,再使用WS的方式建立连接
  3. HyperfJWT我最后没有走通,有些莫名其妙,所以我干脆从PHP的仓库中找了一个最好用的JWT类库
  4. 安装JWT命令为:
  5. 实现登录方法、令牌生成、令牌解析方法
// 常用方法 app/Util/functions.php
if (! function_exists('jwtEncode')) {  
    /**  
    * 生成令牌.  
    */  
    function jwtEncode(array $extraData): string  
    {  
        $time = time();  
        $payload = [  
        'iat' => $time,  
        'nbf' => $time,  
        'exp' => $time + config('jwt.EXP'),  
        'data' => $extraData,  
        ];  
        return JWT::encode($payload, config('jwt.KEY'), 'HS256');  
    }  
}  
  
if (! function_exists('jwtDecode')) {  
    /**  
    * 解析令牌.  
    */  
    function jwtDecode(string $token): array  
    {  
        $decode = JWT::decode($token, new Key(config('jwt.KEY'), 'HS256'));  
        return (array) $decode;  
    }  
}

// 登录控制器 app/Controller/UserCenter/AuthController.php
<?php  

declare(strict_types=1);  

namespace App\Controller\UserCenter;  

use App\Constants\ErrorCode;  
use App\Service\UserCenter\ManagerServiceInterface;  
use App\Traits\ApiResponse;  
use Hyperf\Di\Annotation\Inject;  
use Hyperf\Di\Container;  
use Hyperf\HttpServer\Contract\RequestInterface;  
use Hyperf\HttpServer\Contract\ResponseInterface;  
use Hyperf\Validation\Contract\ValidatorFactoryInterface;  

class AuthController  
{  
    // HTTP 格式化返回,这部分代码在第7条补充
    use ApiResponse;  

    /**  
    * @Inject  
    * @var ValidatorFactoryInterface  
    */  
    protected ValidatorFactoryInterface $validationFactory;  // 验证器 这部分代码在第6条补充


    /**  
    * @Inject  
    * @var ManagerServiceInterface  
    */  
    protected ManagerServiceInterface $service;  // 业务代码

    /**  
    * @Inject  
    * @var Container  
    */  
    private Container $container;  // 注入的容器


    public function signIn(RequestInterface $request, ResponseInterface $response)  
    {  
        $args = $request->post();  
        $validator = $this->validationFactory->make($args, [  
        'email' => 'bail|required|email',  
        'password' => 'required',  
        ]);  
        if ($validator->fails()) {  
            $errMes = $validator->errors()->first();  
            return $this->fail(ErrorCode::PARAMS_INVALID, $errMes);  
        }  
        try {  
            $manager = $this->service->checkPassport($args['email'], $args['password']);  
            $token = jwtEncode(['uid' => $manager->uid]);  
            $redis = $this->container->get(\Hyperf\Redis\Redis::class);  
            $redis->setex(config('jwt.LOGIN_KEY') . $manager->uid, (int) config('jwt.EXP'), $manager->toJson());  
            return $this->success(compact('token'));  
        } catch (\Exception $e) {  
            return $this->fail(ErrorCode::PARAMS_INVALID, $e->getMessage());  
        }  
    }  
}

  1. 以上代码中,用到了验证器,这里补充一下验证器的安装与配置
// 安装组件
composer require hyperf/validation
// 发布配置
php bin/hyperf.php vendor:publish hyperf/translation
php bin/hyperf.php vendor:publish hyperf/validation
  1. 以上代码中,用到了我自定义的HTTP友好返回,这里补充一下代码
<?php  
  
declare(strict_types=1);  

namespace App\Traits;  
  
use App\Constants\ErrorCode;  
use Hyperf\Context\Context;  
use Hyperf\HttpMessage\Stream\SwooleStream;  
use Hyperf\Utils\Codec\Json;  
use Hyperf\Utils\Contracts\Arrayable;  
use Hyperf\Utils\Contracts\Jsonable;  
use Psr\Http\Message\ResponseInterface;  
  
trait ApiResponse  
{  
    private int $httpCode = 200;  

    private array $headers = [];  

    /**  
    * 设置http返回码  
    * @param int $code http返回码  
    * @return $this  
    */  
    final public function setHttpCode(int $code = 200): self  
    {  
        $this->httpCode = $code;  
        return $this;  
    }  

    /**  
    * 成功响应.  
    * @param mixed $data  
    */  
    public function success($data): ResponseInterface  
    {  
        return $this->respond([  
        'err_no' => ErrorCode::OK,  
        'err_msg' => ErrorCode::getMessage(ErrorCode::OK),  
        'result' => $data,  
        ]);  
    }  

    /**  
    * 错误返回.  
    * @param null|int $err_no 错误业务码  
    * @param null|string $err_msg 错误信息  
    * @param array $data 额外返回的数据  
    */  
    public function fail(int $err_no = null, string $err_msg = null, array $data = []): ResponseInterface  
    {  
        return $this->setHttpCode($this->httpCode == 200 ? 400 : $this->httpCode)  
        ->respond([  
            'err_no' => $err_no ?? ErrorCode::SERVER_ERROR,  
            'err_msg' => $err_msg ?? ErrorCode::getMessage(ErrorCode::SERVER_ERROR),  
            'result' => $data,  
        ]);  
    }  

    /**  
    * 设置返回头部header值  
    * @param mixed $value  
    * @return $this  
    */  
    public function addHttpHeader(string $key, $value): self  
    {  
        $this->headers += [$key => $value];  
        return $this;  
    }  

    /**  
    * 批量设置头部返回.  
    * @param array $headers header数组:[key1 => value1, key2 => value2]  
    * @return $this  
    */  
    public function addHttpHeaders(array $headers = []): self  
    {  
        $this->headers += $headers;  
        return $this;  
    }  

    /**  
    * 获取 Response 对象  
    * @return null|mixed|ResponseInterface  
    */  
    protected function response(): ResponseInterface  
    {  
        $response = Context::get(ResponseInterface::class);  
        foreach ($this->headers as $key => $value) {  
            $response = $response->withHeader($key, $value);  
        }  
        return $response;  
    }  

    /**  
    * @param null|array|Arrayable|Jsonable|string $response  
    */  
    private function respond($response): ResponseInterface  
    {  
        if (is_string($response)) {  
            return $this->response()->withAddedHeader('content-type', 'text/plain')->withBody(new SwooleStream($response));  
        }  

        if (is_array($response) || $response instanceof Arrayable) {  
            return $this->response()  
            ->withAddedHeader('content-type', 'application/json')  
            ->withBody(new SwooleStream(Json::encode($response)));  
        }  

        if ($response instanceof Jsonable) {  
            return $this->response()  
            ->withAddedHeader('content-type', 'application/json')  
            ->withBody(new SwooleStream((string) $response));  
        }  

            return $this->response()->withAddedHeader('content-type', 'text/plain')->withBody(new SwooleStream((string) $response));  
        }  
}
JS中如何传入令牌
// 中括号不能省略
const ws = new WebSocket('ws://0.0.0.0:9502/ws/', ['eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE2ODExMTg5MjMsIm5iZiI6MTY4MTExODkyMywiZXhwIjoxNjgxMjA1MzIzLCJkYXRhIjp7InVpZCI6MTAwMTR9fQ.k1xHAtpnfSvamAUzP2i3-FZvTnsNDn7I9AmKUWsn1rI']);
验证令牌的中间件
// app/Middleware/TokenAuthenticator.php

<?php  
  
namespace App\Middleware;  
  
use App\Constants\ErrorCode;  
use App\Constants\Websocket;  
use App\Model\UserCenter\HsmfManager;  
use Exception;  
use Firebase\JWT\ExpiredException;  
use Hyperf\Redis\Redis;  
use Hyperf\Utils\ApplicationContext;  
use Hyperf\WebSocketServer\Context;  
use Swoole\Http\Request;  
  
class TokenAuthenticator  
{  
  
    public function authenticate(Request $request): string  
    {  
        $token = $request->header[Websocket::SecWebsocketProtocol] ?? '';  
        $redis = ApplicationContext::getContainer()->get(Redis::class);  
        try {  
            $tokenData = jwtDecode($token);  
            if (! isset($tokenData['data'])) {  
                throw new Exception('', ErrorCode::ILLEGAL_TOKEN);  
            }  
            $data = (array) $tokenData['data'];  
            $identifier = (new HsmfManager())->getJwtIdentifier();  
            if (! isset($data[$identifier])) {  
                throw new Exception('', ErrorCode::ILLEGAL_TOKEN);  
            }  
            Context::set(Websocket::MANAGER_UID, $data[$identifier]);  
            $tokenStr = (string) $redis->get(config('jwt.LOGIN_KEY') . $data[$identifier]);  
            if (empty($tokenStr)) throw new Exception('', ErrorCode::EXPIRED_TOKEN);  
            return $tokenStr;  
        }catch (ExpiredException $exception) {  
            throw new Exception('', ErrorCode::EXPIRED_TOKEN);  
        }catch (Exception $exception) {  
            throw new Exception('', ErrorCode::ILLEGAL_TOKEN);  
        }  
    }  
}
如何使用这个中间件来验证令牌呢?
// 此处摘抄app/Controller/WebSocketController.php中的部分代码,详细内容请看昨天的文章

public function onOpen($server, $request): void  
{  
    try {  
        $token = $this->authenticator->authenticate($request);  // 验证令牌
        if (empty($token)) {  
            $this->sender->disconnect($request->fd);  
            return;  
        }  
        $this->onOpenBase($server, $request);  
    }catch (\Exception $e){  
        $this->logger->error(sprintf("\r\n [message] %s \r\n [line] %s \r\n [file] %s \r\n [trace] %s", $e->getMessage(), $e->getLine(), $e->getFile(), $e->getTraceAsString()));  
        $this->send($server, $request->fd, $this->failJson($e->getCode()));  
        $this->sender->disconnect($request->fd);  
        return;  
    }  
}
如何建立客户端与服务端的心跳机制?
  1. 其实这个问题早已困扰我许久,在websocket协议中,存在一个叫“控制帧”的概念,按理说是可以通过发送控制帧$frame->opcode建立心跳的,但是我查阅许多资料,也咨询了ChatGPT,做了大量的测试后,发现这条路走不通(主要是前端无法实现,后端可以实现),可能是我前端能力不足,希望有前端大牛能够指点迷津。
// 以下是控制帧的值
class Opcode  
{  
    public const CONTINUATION = 0;  

    public const TEXT = 1;  

    public const BINARY = 2;  

    public const CLOSE = 8;  

    public const PING = 9;  // 客户端发送PING

    public const PONG = 10;  // 服务端发送PONG
}
  1. 于是我只好退而求其次,使用定时发送PINGPONG的方案,来检测与服务端的连接是否正常
// 此处摘抄app/Controller/WebSocketController.php中的部分代码,详细内容请看昨天的文章
public function onMessage($server, $frame): void  
{  
    if ($this->opened) {  
        if ($frame->data === 'ping') {  
            $this->send($server, $frame->fd, 'pong');  
        }else{  
            $this->onMessageBase($server, $frame);  
        }  
    }  
}
此时,我们已经成功的、完善的建立了客户端与服务端的websocket连接