268 lines
9.9 KiB
PHP
268 lines
9.9 KiB
PHP
<?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) **mobile(H5/移动端)**:必须带 **`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_id,TTL 由 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);
|
||
}
|
||
|
||
// ===== Mobile(H5):必须同时校验 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, '');
|
||
}
|
||
|
||
$deviceError = MobileAuthDeviceService::validateUserDeviceSession($authToken, (int) $userId);
|
||
if ($deviceError !== null) {
|
||
return self::deny($deviceError, $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 连接 URL(Query 固定为 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 连接 URL(Query 固定为 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 行 Query(Workerman 在 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,
|
||
];
|
||
}
|
||
}
|