我们在传统的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衔接