我们在传统的API服务中调用接口时,往往会运用
Token
的办法验证对方。那么我们运用websocket
做开发的话,该怎么带着令牌验证互相身份呢?
怎么解决令牌问题
- 我开始的主意是在ws衔接后边拼接一个令牌,如:
const socket = new WebSocket('wss://example.com/path?token=your_token')
- 我第二个主意是,在消息中带着令牌作为参数,但是这样就无法在树立衔接时验证令牌了,非常不友爱,浪费服务器资源
- 我最后挑选的计划,将令牌添加到WebSocket协议的头部。在
WebSocket
协议中,界说了一些规范头部,如Sec-WebSocket-Key
和Sec-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中怎么生成令牌
- 与传统开发一样,首先需要一个登录办法,验证账号密码后生成
Token
- 假如采用这种办法,我们有必要存在一个HTTP恳求,用来调用登录接口,拿到令牌后,再运用WS的办法树立衔接
-
Hyperf
的JWT
我最后没有走通,有些不可思议,所以我干脆从PHP的仓库中找了一个最好用的JWT
类库 - 装置JWT指令为:
- 完成登录办法、令牌生成、令牌解析办法
// 常用办法 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());
}
}
}
- 以上代码中,用到了验证器,这里弥补一下验证器的装置与装备
// 装置组件
composer require hyperf/validation
// 发布装备
php bin/hyperf.php vendor:publish hyperf/translation
php bin/hyperf.php vendor:publish hyperf/validation
- 以上代码中,用到了我自界说的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;
}
}
怎么树立客户端与服务端的心跳机制?
- 其实这个问题早已困扰我许久,在
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
}
- 于是我只好退而求其次,运用定时发送
PING
与PONG
的计划,来检测与服务端的衔接是否正常
// 此处摘抄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);
}
}
}