Files
webman-buildadmin/app/common/service/GameWebSocketAuthHelper.php
2026-05-27 10:28:39 +08:00

225 lines
8.0 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace app\common\service;
use app\common\facade\Token;
use app\common\library\Auth;
use support\Redis;
use Throwable;
/**
* WebSocket 握手鉴权助手(与 HTTP §1.3 对齐):
*
* 两种合法身份:
* 1) **mobileH5/移动端)**URL Query 必须带 `auth_token` + `user_token`,校验通过后绑定 user_id
* 分发器对 user 级主题bet.win 等)按 user_id 过滤,只发本人。
* 2) **admin后台联调/实时对局页)**URL Query 必须带 `auth_token` + `admin_ws_token`
* `admin_ws_token` 由后台 `wsConfig` 接口签发并写入 Redis短时签名。绑定 user_id=0
* 分发器对该连接不做 user 级过滤,可观测全量推送(用于运维/联调)。
*
* 任一身份通过即可建连;都不满足则拒绝握手。
*
* 返回结构:
* [
* 'ok' => bool,
* 'user_id' => int,
* 'mode' => 'mobile' | 'admin' | '',
* 'admin_id'=> int,
* 'reason' => string,
* 'auth_token' => string,
* 'user_token' => string,
* 'admin_ws_token' => string,
* ]
*/
final class GameWebSocketAuthHelper
{
/** admin_ws_token 在 Redis 中的 key 前缀value 存 admin_idTTL 由 issueAdminWsToken 决定 */
private const ADMIN_TOKEN_REDIS_PREFIX = 'dfw:v1:ws:admin_token:';
private const ADMIN_TOKEN_DEFAULT_TTL = 7200;
/**
* @param array<string, mixed> $query 解析后的 URL Query 参数
* @return array{ok:bool, user_id:int, mode:string, admin_id:int, reason:string, auth_token:string, user_token:string, admin_ws_token:string}
*/
public static function authorize(array $query): array
{
$authToken = self::pickFirstString($query, ['auth_token', 'auth-token', 'authToken']);
$userToken = self::pickFirstString($query, ['user_token', 'user-token', 'userToken', 'token']);
$adminWsToken = self::pickFirstString($query, ['admin_ws_token', 'admin-ws-token', 'adminWsToken']);
// ===== Admin 旁路:只校验 admin_ws_token由后台 wsConfig 签发,已隐含管理员身份) =====
if ($adminWsToken !== '') {
$adminId = self::validateAdminWsToken($adminWsToken);
if ($adminId > 0) {
return [
'ok' => true,
'user_id' => 0,
'mode' => 'admin',
'admin_id' => $adminId,
'reason' => '',
'auth_token' => $authToken,
'user_token' => $userToken,
'admin_ws_token' => $adminWsToken,
];
}
return self::deny('admin-ws-token invalid or expired', $authToken, $userToken, $adminWsToken);
}
// ===== MobileH5必须同时校验 auth-token + user-token =====
if ($authToken === '') {
return self::deny('missing auth-token', '', $userToken, '');
}
$authData = Token::get($authToken);
if (!is_array($authData) || ($authData['type'] ?? '') !== 'auth-token') {
return self::deny('invalid auth-token type', $authToken, $userToken, '');
}
$authExpire = filter_var($authData['expire_time'] ?? 0, FILTER_VALIDATE_INT);
if ($authExpire === false || $authExpire < time()) {
return self::deny('auth-token expired', $authToken, $userToken, '');
}
if ($userToken === '') {
return self::deny('missing user-token', $authToken, '', '');
}
$userData = Token::get($userToken);
if (!is_array($userData) || ($userData['type'] ?? '') !== Auth::TOKEN_TYPE) {
return self::deny('invalid user-token type', $authToken, $userToken, '');
}
$userExpire = filter_var($userData['expire_time'] ?? 0, FILTER_VALIDATE_INT);
if ($userExpire === false || $userExpire < time()) {
return self::deny('user-token expired', $authToken, $userToken, '');
}
$userId = filter_var($userData['user_id'] ?? 0, FILTER_VALIDATE_INT);
if ($userId === false || $userId <= 0) {
return self::deny('user-token has no user_id', $authToken, $userToken, '');
}
return [
'ok' => true,
'user_id' => (int) $userId,
'mode' => 'mobile',
'admin_id' => 0,
'reason' => '',
'auth_token' => $authToken,
'user_token' => $userToken,
'admin_ws_token' => '',
];
}
/**
* 为已登录的后台管理员签发短时 admin-ws-token返回 [token, ttl]。
* 调用方app/admin/controller/test/GameCurrentStatus::wsConfig、app/admin/controller/game/Live::wsConfig
*/
public static function issueAdminWsToken(int $adminId, ?int $ttl = null): array
{
if ($adminId <= 0) {
return ['token' => '', 'ttl' => 0];
}
$ttl = ($ttl !== null && $ttl > 0) ? $ttl : self::ADMIN_TOKEN_DEFAULT_TTL;
try {
$token = bin2hex(random_bytes(20));
} catch (Throwable) {
$token = md5(uniqid('admin_ws_', true) . microtime(true) . random_int(0, PHP_INT_MAX));
}
try {
Redis::setEx(self::ADMIN_TOKEN_REDIS_PREFIX . $token, $ttl, (string) $adminId);
} catch (Throwable) {
return ['token' => '', 'ttl' => 0];
}
return ['token' => $token, 'ttl' => $ttl];
}
/**
* 校验 admin-ws-token返回 admin_id>0 表示有效0 表示无效/过期。
*/
public static function validateAdminWsToken(string $token): int
{
$token = trim($token);
if ($token === '' || strlen($token) > 96) {
return 0;
}
try {
$raw = Redis::get(self::ADMIN_TOKEN_REDIS_PREFIX . $token);
} catch (Throwable) {
return 0;
}
if ($raw === false || $raw === null || $raw === '') {
return 0;
}
$adminId = filter_var($raw, FILTER_VALIDATE_INT);
return $adminId === false ? 0 : (int) $adminId;
}
/**
* 从 ws header 中解析 GET 行 QueryWorkerman 在 onWebSocketConnect($connection, $request) 时
* $request 可能为字符串或对象;为兼容,这里允许直接传 URI Query 字符串)。
*
* @return array<string, string>
*/
public static function parseQueryString(string $queryString): array
{
$queryString = trim($queryString);
if ($queryString === '') {
return [];
}
if ($queryString[0] === '?') {
$queryString = substr($queryString, 1);
}
$out = [];
parse_str($queryString, $out);
$clean = [];
foreach ($out as $k => $v) {
if (!is_string($k)) {
continue;
}
if (is_string($v)) {
$clean[$k] = $v;
} elseif (is_scalar($v)) {
$clean[$k] = (string) $v;
}
}
return $clean;
}
/**
* @param array<string, mixed> $query
* @param list<string> $keys
*/
private static function pickFirstString(array $query, array $keys): string
{
foreach ($keys as $k) {
if (!isset($query[$k])) {
continue;
}
$v = $query[$k];
if (!is_scalar($v)) {
continue;
}
$s = trim((string) $v);
if ($s !== '') {
return $s;
}
}
return '';
}
/**
* @return array{ok:bool, user_id:int, mode:string, admin_id:int, reason:string, auth_token:string, user_token:string, admin_ws_token:string}
*/
private static function deny(string $reason, string $authToken, string $userToken, string $adminWsToken): array
{
return [
'ok' => false,
'user_id' => 0,
'mode' => '',
'admin_id' => 0,
'reason' => $reason,
'auth_token' => $authToken,
'user_token' => $userToken,
'admin_ws_token' => $adminWsToken,
];
}
}