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 兼容旧客户端的下划线/驼峰别名(解析时仍可读,拼 URL 时勿用) */ private const LEGACY_AUTH_KEYS = ['auth_token', 'authToken']; /** @var list */ private const LEGACY_USER_KEYS = ['user_token', 'userToken', 'token']; /** @var list */ 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 $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, ''); } 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 $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 */ 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 $query * @param list $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, ]; } }