diff --git a/server/.env.example b/server/.env.example index 8294542..0159bc2 100644 --- a/server/.env.example +++ b/server/.env.example @@ -17,10 +17,14 @@ REDIS_PASSWORD = '' REDIS_DB = 0 # API 鉴权与用户(可选,不填则用默认值) -# API_AUTH_TOKEN_EXP = 86400 +# authToken 签名密钥(必填,与客户端约定,用于 signature 校验) + API_AUTH_TOKEN_SECRET = xF75oK91TQj13s0UmNIr1NBWMWGfflNO +# authToken 时间戳允许误差秒数,防重放,默认 300 + API_AUTH_TOKEN_TIME_TOLERANCE = 300 + API_AUTH_TOKEN_EXP = 86400 # API_USER_TOKEN_EXP = 604800 -# API_USER_CACHE_EXPIRE = 604800 -# API_USER_ENCRYPT_KEY = dafuweng_api_user_cache_key_32 + API_USER_CACHE_EXPIRE = 86400 + API_USER_ENCRYPT_KEY = Wj818SK8dhKBKNOY3PUTmZfhQDMCXEZi # 验证码配置,支持cache|session CAPTCHA_MODE = cache diff --git a/server/app/api/controller/AuthTokenController.php b/server/app/api/controller/AuthTokenController.php index 56a45c0..96ea83c 100644 --- a/server/app/api/controller/AuthTokenController.php +++ b/server/app/api/controller/AuthTokenController.php @@ -7,23 +7,61 @@ use support\Request; use support\Response; use Tinywan\Jwt\JwtToken; use plugin\saiadmin\basic\OpenController; +use app\api\util\ReturnCode; /** * API 鉴权 Token 接口 + * 仅支持 GET,必传参数:signature、secret、device、time,签名规则:signature = md5(device . secret . time) * 后续所有 /api 接口调用均需在请求头携带此接口返回的 auth-token */ class AuthTokenController extends OpenController { /** * 获取 auth-token - * GET 或 POST /api/authToken + * GET /api/authToken + * 参数:signature(签名)、secret(密钥)、device(设备标识)、time(时间戳,秒),四者均为必传且非空 */ public function index(Request $request): Response { + if (strtoupper($request->method()) !== 'GET') { + return $this->fail('仅支持 GET 请求', ReturnCode::EMPTY_PARAMS); + } + + $param = $request->get(); + $signature = trim((string) ($param['signature'] ?? '')); + $secret = trim((string) ($param['secret'] ?? '')); + $device = trim((string) ($param['device'] ?? '')); + $time = trim((string) ($param['time'] ?? '')); + + if ($signature === '' || $secret === '' || $device === '' || $time === '') { + return $this->fail('signature、secret、device、time 均为必传且不能为空', ReturnCode::EMPTY_PARAMS); + } + + $serverSecret = trim((string) config('api.auth_token_secret', '')); + if ($serverSecret === '') { + return $this->fail('服务未配置 API_AUTH_TOKEN_SECRET', ReturnCode::EMPTY_PARAMS); + } + if ($secret !== $serverSecret) { + return $this->fail('密钥错误', ReturnCode::EMPTY_PARAMS); + } + + $tolerance = (int) config('api.auth_token_time_tolerance', 300); + $now = time(); + $ts = is_numeric($time) ? (int) $time : 0; + if ($ts <= 0 || abs($now - $ts) > $tolerance) { + return $this->fail('时间戳无效或已过期', ReturnCode::EMPTY_PARAMS); + } + + $sign = $this->getAuthToken($device, $serverSecret, $time); + if ($sign !== $signature) { + return $this->fail('签名验证失败', ReturnCode::EMPTY_PARAMS); + } + $exp = config('api.auth_token_exp', 86400); $tokenResult = JwtToken::generateToken([ 'id' => 0, 'plat' => 'api', + 'device' => $device, 'access_exp' => $exp, ]); @@ -32,4 +70,17 @@ class AuthTokenController extends OpenController 'expires_in' => $tokenResult['expires_in'], ]); } + + /** + * 生成签名:signature = md5(device . secret . time) + * + * @param string $device 设备标识 + * @param string $secret 密钥(来自配置) + * @param string $time 时间戳 + * @return string + */ + private function getAuthToken(string $device, string $secret, string $time): string + { + return md5($device . $secret . $time); + } } diff --git a/server/app/api/controller/GameController.php b/server/app/api/controller/GameController.php index 2d5e265..ef6bd5d 100644 --- a/server/app/api/controller/GameController.php +++ b/server/app/api/controller/GameController.php @@ -5,9 +5,9 @@ namespace app\api\controller; use support\Request; use support\Response; -use app\api\logic\UserLogic; use app\api\logic\GameLogic; use app\api\logic\PlayStartLogic; +use app\api\logic\UserLogic; use app\api\util\ReturnCode; use app\dice\model\play_record\DicePlayRecord; use app\dice\model\player\DicePlayer; @@ -23,27 +23,12 @@ class GameController extends OpenController /** * 购买抽奖券 * POST /api/game/buyLotteryTickets - * header: auth-token, user-token + * header: auth-token, user-token(由 CheckUserTokenMiddleware 注入 request->user_id) * body: count = 1 | 5 | 10(1次/100coin, 5次/500coin, 10次/1000coin) - * 记录钱包流水,并更新缓存中玩家的 total_draw_count、paid_draw_count、free_draw_count、coin */ public function buyLotteryTickets(Request $request): Response { - $token = $request->header('user-token'); - if (empty($token)) { - $auth = $request->header('authorization'); - if ($auth && stripos($auth, 'Bearer ') === 0) { - $token = trim(substr($auth, 7)); - } - } - if (empty($token)) { - return $this->fail('请携带 user-token', ReturnCode::MISSING_TOKEN); - } - $userId = UserLogic::getUserIdFromToken($token); - if ($userId === null) { - return $this->fail('user-token 无效或已过期', ReturnCode::TOKEN_TIMEOUT); - } - + $userId = UserLogic::getUserIdFromRequest($request) ?? 0; $count = (int) $request->post('count', 0); if (!in_array($count, [1, 5, 10], true)) { return $this->fail('购买抽奖券错误', ReturnCode::EMPTY_PARAMS); @@ -79,27 +64,12 @@ class GameController extends OpenController /** * 开始游戏(抽奖一局) * POST /api/game/playStart - * header: auth-token, user-token + * header: auth-token, user-token(由 CheckUserTokenMiddleware 注入 request->user_id) * body: rediction 必传,0=无 1=中奖 - * 余额不足时返回 code=200、message=玩家当前余额不足无法开启对局;超时返回 code=200、message=服务超时,并记录 status=0 */ public function playStart(Request $request): Response { - $token = $request->header('user-token'); - if (empty($token)) { - $auth = $request->header('authorization'); - if ($auth && stripos($auth, 'Bearer ') === 0) { - $token = trim(substr($auth, 7)); - } - } - if (empty($token)) { - return $this->fail('请携带 user-token', ReturnCode::MISSING_TOKEN); - } - $userId = UserLogic::getUserIdFromToken($token); - if ($userId === null) { - return $this->fail('user-token 无效或已过期', ReturnCode::TOKEN_TIMEOUT); - } - + $userId = UserLogic::getUserIdFromRequest($request) ?? 0; $rediction = $request->post('rediction'); if ($rediction === '' || $rediction === null) { return $this->fail('请传递 rediction 参数', ReturnCode::EMPTY_PARAMS); diff --git a/server/app/api/controller/UserController.php b/server/app/api/controller/UserController.php index 47691bc..20b4782 100644 --- a/server/app/api/controller/UserController.php +++ b/server/app/api/controller/UserController.php @@ -64,49 +64,26 @@ class UserController extends OpenController /** * 退出登录 * POST /api/user/logout - * header: user-token(或 Authorization: Bearer ) - * 将当前 user-token 加入黑名单,之后该 token 无法再用于获取 user_id + * header: user-token(由 CheckUserTokenMiddleware 校验并注入 request->userToken) */ public function logout(Request $request): Response { - $token = $request->header('user-token'); - if (empty($token)) { - $auth = $request->header('authorization'); - if ($auth && stripos($auth, 'Bearer ') === 0) { - $token = trim(substr($auth, 7)); - } + $token = $request->userToken ?? UserLogic::getTokenFromRequest($request); + if ($token === '' || !UserLogic::logout($token)) { + return $this->fail('退出失败或 token 已失效', ReturnCode::TOKEN_TIMEOUT); } - if (empty($token)) { - return $this->fail('请携带 user-token', ReturnCode::MISSING_TOKEN); - } - if (UserLogic::logout($token)) { - return $this->success('已退出登录'); - } - return $this->fail('退出失败或 token 已失效', ReturnCode::TOKEN_TIMEOUT); + return $this->success('已退出登录'); } /** * 获取当前用户信息 * GET /api/user/info - * header: user-token(或 Authorization: Bearer ) + * header: user-token(由 CheckUserTokenMiddleware 校验并注入 request->user_id) * 返回:id, username, phone, uid, name, coin, total_draw_count */ public function info(Request $request): Response { - $token = $request->header('user-token'); - if (empty($token)) { - $auth = $request->header('authorization'); - if ($auth && stripos($auth, 'Bearer ') === 0) { - $token = trim(substr($auth, 7)); - } - } - if (empty($token)) { - return $this->fail('请携带 user-token', ReturnCode::MISSING_TOKEN); - } - $userId = UserLogic::getUserIdFromToken($token); - if ($userId === null) { - return $this->fail('user-token 无效或已过期', ReturnCode::TOKEN_TIMEOUT); - } + $userId = UserLogic::getUserIdFromRequest($request) ?? 0; $user = UserLogic::getCachedUser($userId); if (empty($user)) { return $this->fail('用户不存在', ReturnCode::EMPTY_PARAMS); @@ -122,30 +99,16 @@ class UserController extends OpenController } /** - * 获取钱包余额(仅读缓存,不查库,低延迟) + * 获取钱包余额(优先读缓存,缓存未命中时从库拉取并回写缓存) * GET /api/user/balance - * header: user-token(或 Authorization: Bearer ) - * 返回:coin, phone, username(登录时已写入缓存,本接口只从缓存读取) + * header: user-token(由 CheckUserTokenMiddleware 校验并注入 request->user_id) */ public function balance(Request $request): Response { - $token = $request->header('user-token'); - if (empty($token)) { - $auth = $request->header('authorization'); - if ($auth && stripos($auth, 'Bearer ') === 0) { - $token = trim(substr($auth, 7)); - } - } - if (empty($token)) { - return $this->fail('请携带 user-token', ReturnCode::MISSING_TOKEN); - } - $userId = UserLogic::getUserIdFromToken($token); - if ($userId === null) { - return $this->fail('user-token 无效或已过期', ReturnCode::TOKEN_TIMEOUT); - } - $user = UserCache::getUser($userId); + $userId = UserLogic::getUserIdFromRequest($request) ?? 0; + $user = UserLogic::getCachedUser($userId); if (empty($user)) { - return $this->fail('缓存已过期,请重新登录', ReturnCode::TOKEN_TIMEOUT); + return $this->fail('用户不存在', ReturnCode::EMPTY_PARAMS); } $coin = $user['coin'] ?? 0; if (is_string($coin) && is_numeric($coin)) { @@ -161,26 +124,12 @@ class UserController extends OpenController /** * 玩家钱包流水 * GET /api/user/walletRecord - * header: user-token(或 Authorization: Bearer ) + * header: user-token(由 CheckUserTokenMiddleware 校验并注入 request->user_id) * 参数: page 页码(默认1), limit 每页条数(默认10), create_time_min/create_time_max 创建时间范围(可选) */ public function walletRecord(Request $request): Response { - $token = $request->header('user-token'); - if (empty($token)) { - $auth = $request->header('authorization'); - if ($auth && stripos($auth, 'Bearer ') === 0) { - $token = trim(substr($auth, 7)); - } - } - if (empty($token)) { - return $this->fail('请携带 user-token', ReturnCode::MISSING_TOKEN); - } - $userId = UserLogic::getUserIdFromToken($token); - if ($userId === null) { - return $this->fail('user-token 无效或已过期', ReturnCode::TOKEN_TIMEOUT); - } - + $userId = UserLogic::getUserIdFromRequest($request) ?? 0; $page = (int) $request->post('page', 1); $limit = (int) $request->post('limit', 10); if ($page < 1) { @@ -217,26 +166,12 @@ class UserController extends OpenController /** * 游玩记录 * GET /api/user/playGameRecord - * header: user-token(或 Authorization: Bearer ) + * header: user-token(由 CheckUserTokenMiddleware 校验并注入 request->user_id) * 参数: page 页码(默认1), limit 每页条数(默认10), create_time_min/create_time_max 创建时间范围(可选) */ public function playGameRecord(Request $request): Response { - $token = $request->header('user-token'); - if (empty($token)) { - $auth = $request->header('authorization'); - if ($auth && stripos($auth, 'Bearer ') === 0) { - $token = trim(substr($auth, 7)); - } - } - if (empty($token)) { - return $this->fail('请携带 user-token', ReturnCode::MISSING_TOKEN); - } - $userId = UserLogic::getUserIdFromToken($token); - if ($userId === null) { - return $this->fail('user-token 无效或已过期', ReturnCode::TOKEN_TIMEOUT); - } - + $userId = UserLogic::getUserIdFromRequest($request) ?? 0; $page = (int) $request->post('page', 1); $limit = (int) $request->post('limit', 10); if ($page < 1) { diff --git a/server/app/api/logic/UserLogic.php b/server/app/api/logic/UserLogic.php index 8f7ce4c..9f0bd39 100644 --- a/server/app/api/logic/UserLogic.php +++ b/server/app/api/logic/UserLogic.php @@ -123,6 +123,41 @@ class UserLogic return $result['access_token']; } + /** + * 从请求中解析 user-token(header: user-token 或 Authorization: Bearer) + * @param object $request 需有 header(string $name) 方法 + */ + public static function getTokenFromRequest(object $request): string + { + $token = $request->header('user-token') ?? ''; + if ($token !== '') { + return trim((string) $token); + } + $auth = $request->header('authorization'); + if ($auth && stripos($auth, 'Bearer ') === 0) { + return trim(substr($auth, 7)); + } + return ''; + } + + /** + * 从请求获取当前用户 ID:优先 request->user_id,否则从 header 的 user-token 解析 + * 中间件未正确注入时仍可兜底解析 + * @param object $request 需有 user_id 属性及 header() 方法 + */ + public static function getUserIdFromRequest(object $request): ?int + { + $id = $request->user_id ?? null; + if ($id !== null && (int) $id > 0) { + return (int) $id; + } + $token = self::getTokenFromRequest($request); + if ($token === '') { + return null; + } + return self::getUserIdFromToken($token); + } + /** * 根据 user-token 获取 user_id(不写缓存,仅解析 JWT) * 若 token 已通过退出接口加入黑名单,返回 null diff --git a/server/app/api/middleware/CheckApiAuthMiddleware.php b/server/app/api/middleware/CheckApiAuthMiddleware.php deleted file mode 100644 index 387e2e9..0000000 --- a/server/app/api/middleware/CheckApiAuthMiddleware.php +++ /dev/null @@ -1,75 +0,0 @@ -path(), '/'); - if ($this->isWhitelist($path)) { - return $handler($request); - } - - $token = $request->header('auth-token'); - if (empty($token)) { - $auth = $request->header('authorization'); - if ($auth && stripos($auth, 'Bearer ') === 0) { - $token = trim(substr($auth, 7)); - } - } - if (empty($token)) { - throw new ApiException('请携带 auth-token', ReturnCode::MISSING_TOKEN); - } - - try { - // ACCESS_TOKEN = 1(JwtToken 内部私有常量) - $decoded = JwtToken::verify(1, $token); - $extend = $decoded['extend'] ?? []; - if (($extend['plat'] ?? '') !== 'api') { - throw new ApiException('auth-token 无效', ReturnCode::TOKEN_TIMEOUT); - } - } catch (JwtTokenExpiredException $e) { - Log::error('auth-token 已过期, 报错信息'. $e); - throw new ApiException('auth-token 已过期', ReturnCode::TOKEN_TIMEOUT); - } catch (JwtTokenException $e) { - Log::error('auth-token 无效, 报错信息'. $e); - throw new ApiException($e->getMessage() ?: 'auth-token 无效', ReturnCode::TOKEN_TIMEOUT); - } catch (\Throwable $e) { - Log::error('auth-token 校验失败, 报错信息'. $e); - throw new ApiException('auth-token 校验失败', ReturnCode::TOKEN_TIMEOUT); - } - - return $handler($request); - } - - private function isWhitelist(string $path): bool - { - foreach (self::WHITELIST as $prefix) { - if ($path === $prefix || str_starts_with($path, $prefix . '/')) { - return true; - } - } - return false; - } -} diff --git a/server/app/api/middleware/CheckAuthTokenMiddleware.php b/server/app/api/middleware/CheckAuthTokenMiddleware.php new file mode 100644 index 0000000..72503a4 --- /dev/null +++ b/server/app/api/middleware/CheckAuthTokenMiddleware.php @@ -0,0 +1,102 @@ +,且必须通过 JWT 签名与 plat=api 校验 + */ +class CheckAuthTokenMiddleware implements MiddlewareInterface +{ + /** 不需要 auth-token 的路径 */ + private const WHITELIST = [ + 'api/authToken', + ]; + + /** JWT 至少为 xxx.yyy.zzz 三段 */ + private const JWT_PARTS_MIN = 3; + + public function process(Request $request, callable $handler): Response + { + $path = trim((string) $request->path(), '/'); + if ($this->isWhitelist($path)) { + return $handler($request); + } + + $token = $this->getAuthTokenFromRequest($request); + if ($token === '') { + throw new ApiException('请携带 auth-token', ReturnCode::MISSING_TOKEN); + } + + if (!$this->looksLikeJwt($token)) { + throw new ApiException('auth-token 格式无效', ReturnCode::TOKEN_TIMEOUT); + } + + $decoded = $this->verifyAuthToken($token); + $extend = $decoded['extend'] ?? []; + if (($extend['plat'] ?? '') !== 'api') { + throw new ApiException('auth-token 无效(非 API 凭证)', ReturnCode::TOKEN_TIMEOUT); + } + + return $handler($request); + } + + private function getAuthTokenFromRequest(Request $request): string + { + $token = $request->header('auth-token'); + if ($token !== null && $token !== '') { + return trim((string) $token); + } + $auth = $request->header('authorization'); + if ($auth && stripos($auth, 'Bearer ') === 0) { + return trim(substr($auth, 7)); + } + return ''; + } + + private function looksLikeJwt(string $token): bool + { + $parts = explode('.', $token); + return count($parts) >= self::JWT_PARTS_MIN; + } + + /** + * 校验 auth-token 有效性(签名、过期、iss 等),无效或过期必抛 ApiException + */ + private function verifyAuthToken(string $token): array + { + try { + return JwtToken::verify(1, $token); + } catch (JwtTokenExpiredException $e) { + Log::error('auth-token 已过期, 报错信息' . $e); + throw new ApiException('auth-token 已过期', ReturnCode::TOKEN_TIMEOUT); + } catch (JwtTokenException $e) { + Log::error('auth-token 无效, 报错信息' . $e); + throw new ApiException($e->getMessage() ?: 'auth-token 无效', ReturnCode::TOKEN_TIMEOUT); + } catch (\Throwable $e) { + Log::error('auth-token 校验失败, 报错信息' . $e); + throw new ApiException('auth-token 校验失败', ReturnCode::TOKEN_TIMEOUT); + } + } + + private function isWhitelist(string $path): bool + { + foreach (self::WHITELIST as $prefix) { + if ($path === $prefix || str_starts_with($path, $prefix . '/')) { + return true; + } + } + return false; + } +} diff --git a/server/app/api/middleware/CheckUserTokenMiddleware.php b/server/app/api/middleware/CheckUserTokenMiddleware.php new file mode 100644 index 0000000..30d1a81 --- /dev/null +++ b/server/app/api/middleware/CheckUserTokenMiddleware.php @@ -0,0 +1,42 @@ +,校验通过后将 user_id、userToken 写入 request 供控制器使用 + */ +class CheckUserTokenMiddleware implements MiddlewareInterface +{ + public function process(Request $request, callable $handler): Response + { + $token = $request->header('user-token'); + if (empty($token)) { + $auth = $request->header('authorization'); + if ($auth && stripos($auth, 'Bearer ') === 0) { + $token = trim(substr($auth, 7)); + } + } + if (empty($token)) { + throw new ApiException('请携带 user-token', ReturnCode::MISSING_TOKEN); + } + + $userId = UserLogic::getUserIdFromToken($token); + if ($userId === null) { + throw new ApiException('user-token 无效或已过期', ReturnCode::TOKEN_TIMEOUT); + } + + $request->user_id = $userId; + $request->userToken = $token; + + return $handler($request); + } +} diff --git a/server/config/api.php b/server/config/api.php index ddc2548..d8fe2c1 100644 --- a/server/config/api.php +++ b/server/config/api.php @@ -3,6 +3,10 @@ * API 鉴权与用户相关配置 */ return [ + // auth-token 签名密钥(与客户端约定,用于 /api/authToken 的 signature 校验,必填) + 'auth_token_secret' => env('API_AUTH_TOKEN_SECRET', ''), + // auth-token 时间戳允许误差(秒),防重放,默认 300 秒 + 'auth_token_time_tolerance' => (int) env('API_AUTH_TOKEN_TIME_TOLERANCE', 300), // auth-token 有效期(秒),默认 24 小时 'auth_token_exp' => (int) env('API_AUTH_TOKEN_EXP', 86400), // user-token 有效期(秒),默认 7 天 diff --git a/server/config/route.php b/server/config/route.php index c8ee3c3..5881e43 100644 --- a/server/config/route.php +++ b/server/config/route.php @@ -13,22 +13,29 @@ */ use Webman\Route; -use app\api\middleware\CheckApiAuthMiddleware; +use app\api\middleware\CheckAuthTokenMiddleware; +use app\api\middleware\CheckUserTokenMiddleware; -// API 路由:需先调用 /api/authToken 获取 auth-token,请求时携带 header: auth-token 或 Authorization: Bearer +// 仅需 auth-token 的路由组(authToken 接口在中间件内白名单跳过) Route::group('/api', function () { Route::any('/authToken', [app\api\controller\AuthTokenController::class, 'index']); - Route::post('/user/login', [app\api\controller\UserController::class, 'login']); - Route::post('/user/register', [app\api\controller\UserController::class, 'register']); - Route::post('/user/logout', [app\api\controller\UserController::class, 'logout']); - Route::get('/user/info', [app\api\controller\UserController::class, 'info']); - Route::get('/user/balance', [app\api\controller\UserController::class, 'balance']); - Route::get('/user/walletRecord', [app\api\controller\UserController::class, 'walletRecord']); - Route::get('/user/playGameRecord', [app\api\controller\UserController::class, 'playGameRecord']); - Route::post('/game/buyLotteryTickets', [app\api\controller\GameController::class, 'buyLotteryTickets']); - Route::get('/game/lotteryPool', [app\api\controller\GameController::class, 'lotteryPool']); - Route::post('/game/playStart', [app\api\controller\GameController::class, 'playStart']); -})->middleware([CheckApiAuthMiddleware::class]); - - + Route::any('/user/login', [app\api\controller\UserController::class, 'login']); + Route::any('/user/register', [app\api\controller\UserController::class, 'register']); +})->middleware([ + CheckAuthTokenMiddleware::class, +]); +// 需 auth-token + user-token 的路由组 +Route::group('/api', function () { + Route::any('/user/logout', [app\api\controller\UserController::class, 'logout']); + Route::any('/user/info', [app\api\controller\UserController::class, 'info']); + Route::any('/user/balance', [app\api\controller\UserController::class, 'balance']); + Route::any('/user/walletRecord', [app\api\controller\UserController::class, 'walletRecord']); + Route::any('/user/playGameRecord', [app\api\controller\UserController::class, 'playGameRecord']); + Route::any('/game/buyLotteryTickets', [app\api\controller\GameController::class, 'buyLotteryTickets']); + Route::any('/game/lotteryPool', [app\api\controller\GameController::class, 'lotteryPool']); + Route::any('/game/playStart', [app\api\controller\GameController::class, 'playStart']); +})->middleware([ + CheckAuthTokenMiddleware::class, + CheckUserTokenMiddleware::class, +]); diff --git a/server/support/Request.php b/server/support/Request.php index 7f80595..b7f0a31 100644 --- a/server/support/Request.php +++ b/server/support/Request.php @@ -20,6 +20,11 @@ namespace support; */ class Request extends \Webman\Http\Request { + /** 由 CheckUserTokenMiddleware 注入:当前用户 ID */ + public ?int $user_id = null; + + /** 由 CheckUserTokenMiddleware 注入:当前 user-token 原始字符串 */ + public ?string $userToken = null; /** * 获取参数增强方法