Files
webman-buildadmin/app/common/service/GameWebSocketAuthHelper.php

263 lines
9.7 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 对齐):
*
* 两种合法身份URL Query 参数名统一为连字符,与 HTTP 请求头一致):
* 1) **mobileH5/移动端)**:必须带 **`auth-token`** + **`user-token`**,校验通过后绑定 user_id
* 分发器对 user 级主题bet.win 等)按 user_id 过滤,只发本人。
* 2) **admin后台联调/实时对局页)**:必须带 **`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
{
/** WebSocket 握手 Query 标准参数名(与 HTTP 头 auth-token / user-token 一致,一律用连字符) */
public const QUERY_AUTH_TOKEN = 'auth-token';
public const QUERY_USER_TOKEN = 'user-token';
public const QUERY_ADMIN_WS_TOKEN = 'admin-ws-token';
/** @var list<string> 兼容旧客户端的下划线/驼峰别名(解析时仍可读,拼 URL 时勿用) */
private const LEGACY_AUTH_KEYS = ['auth_token', 'authToken'];
/** @var list<string> */
private const LEGACY_USER_KEYS = ['user_token', 'userToken', 'token'];
/** @var list<string> */
private const LEGACY_ADMIN_KEYS = ['admin_ws_token', 'adminWsToken'];
/** 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, array_merge([self::QUERY_AUTH_TOKEN], self::LEGACY_AUTH_KEYS));
$userToken = self::pickFirstString($query, array_merge([self::QUERY_USER_TOKEN], self::LEGACY_USER_KEYS));
$adminWsToken = self::pickFirstString($query, array_merge([self::QUERY_ADMIN_WS_TOKEN], self::LEGACY_ADMIN_KEYS));
// ===== 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;
}
/**
* 拼装移动端 WebSocket 连接 URLQuery 固定为 auth-token、user-token
*
* @param array<string, string> $extraQuery 其它 Query如 device_id、lang
*/
public static function buildMobileConnectUrl(string $baseWsUrl, string $authToken, string $userToken, array $extraQuery = []): string
{
return \app\common\library\admin\WebSocketConfigHelper::appendTokensToWsUrl($baseWsUrl, [
self::QUERY_AUTH_TOKEN => $authToken,
self::QUERY_USER_TOKEN => $userToken,
], $extraQuery);
}
/**
* 拼装后台 WebSocket 连接 URLQuery 固定为 admin-ws-token
*/
public static function buildAdminConnectUrl(string $baseWsUrl, string $adminWsToken): string
{
return \app\common\library\admin\WebSocketConfigHelper::appendTokensToWsUrl($baseWsUrl, [
self::QUERY_ADMIN_WS_TOKEN => $adminWsToken,
]);
}
/**
* 从 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,
];
}
}