From 9f8080cefee2b7f93f41f449be1e4a90bbcc14ad Mon Sep 17 00:00:00 2001 From: kang Date: Fri, 8 May 2026 14:41:01 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20JWT=20=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E4=B8=8E=E5=BC=80=E5=8F=91=E7=8E=AF=E5=A2=83=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=EF=BC=8C=E6=9B=B4=E6=96=B0=20API=20=E8=B7=AF=E7=94=B1?= =?UTF-8?q?=E4=B8=8E=E4=B8=AD=E9=97=B4=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 8 ++ .../PlayerAuthenticationException.php | 22 ++++ .../Api/V1/Admin/PingController.php | 5 +- .../Controllers/Api/V1/HealthController.php | 4 +- .../Api/V1/Player/MeController.php | 33 ++++++ .../Api/V1/Player/PingController.php | 5 +- app/Http/Middleware/EnsureAdminApi.php | 4 +- app/Http/Middleware/EnsurePlayerApi.php | 17 ++- app/Models/Player.php | 34 ++++++ app/Models/PlayerWallet.php | 36 ++++++ app/Providers/AppServiceProvider.php | 8 +- app/Services/PlayerTokenResolver.php | 110 ++++++++++++++++++ app/Support/ApiResponse.php | 5 +- bootstrap/app.php | 3 + composer.json | 3 +- composer.lock | 65 ++++++++++- config/lottery.php | 16 +++ routes/api.php | 19 ++- 18 files changed, 383 insertions(+), 14 deletions(-) create mode 100644 app/Exceptions/PlayerAuthenticationException.php create mode 100644 app/Http/Controllers/Api/V1/Player/MeController.php create mode 100644 app/Models/Player.php create mode 100644 app/Models/PlayerWallet.php create mode 100644 app/Services/PlayerTokenResolver.php diff --git a/.env.example b/.env.example index 78fb960..77fc5a3 100644 --- a/.env.example +++ b/.env.example @@ -73,6 +73,14 @@ VITE_APP_NAME="${APP_NAME}" # 默认结算币种(PRD:NPR) LOTTERY_DEFAULT_CURRENCY=NPR +# 本地开发:Bearer dev:{数据库 players.id}(仅 APP_ENV=local 且为 true 时生效) +LOTTERY_PLAYER_AUTH_DEV_BYPASS=false + +# JWT 内站点/玩家字段名(与主站签发约定一致) +# LOTTERY_JWT_ALGORITHM=HS256 +# LOTTERY_JWT_CLAIM_SITE_CODE=site_code +# LOTTERY_JWT_CLAIM_SITE_PLAYER_ID=site_player_id + # 主站 SSO / 钱包(名称可按实际接口调整) # MAIN_SITE_BASE_URL= # MAIN_SITE_SSO_JWT_SECRET= diff --git a/app/Exceptions/PlayerAuthenticationException.php b/app/Exceptions/PlayerAuthenticationException.php new file mode 100644 index 0000000..1906291 --- /dev/null +++ b/app/Exceptions/PlayerAuthenticationException.php @@ -0,0 +1,22 @@ +version(); + $payload['laravel'] = app()->version(); // 仅本地/调试 } return ApiResponse::success($payload); diff --git a/app/Http/Controllers/Api/V1/Player/MeController.php b/app/Http/Controllers/Api/V1/Player/MeController.php new file mode 100644 index 0000000..3990c2f --- /dev/null +++ b/app/Http/Controllers/Api/V1/Player/MeController.php @@ -0,0 +1,33 @@ +lotteryPlayer(); + // 理论上不会为 null(路由已套 EnsurePlayerApi);保留断言便于排查配置错误 + abort_if($player === null, 500, 'lottery_player missing'); + + return ApiResponse::success([ + 'id' => $player->id, + 'site_code' => $player->site_code, + 'site_player_id' => $player->site_player_id, + 'username' => $player->username, + 'nickname' => $player->nickname, + 'default_currency' => $player->default_currency, + 'status' => $player->status, + ]); + } +} diff --git a/app/Http/Controllers/Api/V1/Player/PingController.php b/app/Http/Controllers/Api/V1/Player/PingController.php index 8ff80e4..b7d3fb6 100644 --- a/app/Http/Controllers/Api/V1/Player/PingController.php +++ b/app/Http/Controllers/Api/V1/Player/PingController.php @@ -6,7 +6,10 @@ use App\Http\Controllers\Controller; use App\Support\ApiResponse; use Illuminate\Http\JsonResponse; -/** 探路由用,上线前可删除或改为需登录 */ +/** + * 无需登录:仅供网关/前端确认「玩家 API 前缀」可达。 + * 路由:GET /api/v1/player/ping + */ class PingController extends Controller { public function __invoke(): JsonResponse diff --git a/app/Http/Middleware/EnsureAdminApi.php b/app/Http/Middleware/EnsureAdminApi.php index db0d045..0742e60 100644 --- a/app/Http/Middleware/EnsureAdminApi.php +++ b/app/Http/Middleware/EnsureAdminApi.php @@ -7,7 +7,9 @@ use Illuminate\Http\Request; use Symfony\Component\HttpFoundation\Response; /** - * 后台 API:后续在此校验管理员登录(如 Sanctum)与 RBAC。 + * 后台 API 守卫:后续在此注入 Sanctum(admin_users)与权限校验。 + * + * 当前为占位直通,勿在生产暴露敏感 admin 路由前长期保持空实现。 */ class EnsureAdminApi { diff --git a/app/Http/Middleware/EnsurePlayerApi.php b/app/Http/Middleware/EnsurePlayerApi.php index 2ca6567..28d072a 100644 --- a/app/Http/Middleware/EnsurePlayerApi.php +++ b/app/Http/Middleware/EnsurePlayerApi.php @@ -2,17 +2,32 @@ namespace App\Http\Middleware; +use App\Exceptions\PlayerAuthenticationException; +use App\Services\PlayerTokenResolver; +use App\Support\ApiResponse; use Closure; use Illuminate\Http\Request; use Symfony\Component\HttpFoundation\Response; /** - * 玩家端 API:后续在此校验 SSO / Bearer Token,并解析当前 players.id。 + * 玩家端受保护路由前置:解析 Authorization,失败时直接返回 { code, msg, data },不进入控制器。 + * + * 成功后在 request 上挂 `lottery_player`,控制器内使用 `$request->lotteryPlayer()` + *(由 AppServiceProvider 注册的宏,返回 ?Player)。 */ class EnsurePlayerApi { public function handle(Request $request, Closure $next): Response { + try { + $player = app(PlayerTokenResolver::class)->resolve($request); + } catch (PlayerAuthenticationException $e) { + return ApiResponse::error($e->getMessage(), $e->lotteryCode, null, $e->httpStatus); + } + + // 使用 attributes,避免与 Laravel 内置 input 混淆 + $request->attributes->set('lottery_player', $player); + return $next($request); } } diff --git a/app/Models/Player.php b/app/Models/Player.php new file mode 100644 index 0000000..7a29fb0 --- /dev/null +++ b/app/Models/Player.php @@ -0,0 +1,34 @@ + 'datetime', + ]; + } + + public function wallets(): HasMany + { + return $this->hasMany(PlayerWallet::class); + } +} diff --git a/app/Models/PlayerWallet.php b/app/Models/PlayerWallet.php new file mode 100644 index 0000000..de73315 --- /dev/null +++ b/app/Models/PlayerWallet.php @@ -0,0 +1,36 @@ + 'integer', + 'frozen_balance' => 'integer', + 'version' => 'integer', + ]; + } + + public function player(): BelongsTo + { + return $this->belongsTo(Player::class); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 452e6b6..eef6601 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,6 +2,8 @@ namespace App\Providers; +use App\Models\Player; +use Illuminate\Http\Request; use Illuminate\Support\ServiceProvider; class AppServiceProvider extends ServiceProvider @@ -19,6 +21,10 @@ class AppServiceProvider extends ServiceProvider */ public function boot(): void { - // + // 仅在通过 EnsurePlayerApi 后可用;未走中间件时为 null + Request::macro('lotteryPlayer', function (): ?Player { + /** @var Request $this */ + return $this->attributes->get('lottery_player'); + }); } } diff --git a/app/Services/PlayerTokenResolver.php b/app/Services/PlayerTokenResolver.php new file mode 100644 index 0000000..81964c9 --- /dev/null +++ b/app/Services/PlayerTokenResolver.php @@ -0,0 +1,110 @@ +header('Authorization', ''); + if (! is_string($header) || ! str_starts_with($header, 'Bearer ')) { + throw new PlayerAuthenticationException('缺少或非法 Authorization', 8001); + } + + // 标准:`Authorization: Bearer `,此处去掉前缀 7 字节 "Bearer " + $token = trim(substr($header, 7)); + if ($token === '') { + throw new PlayerAuthenticationException('Token 为空', 8001); + } + + // 本地 dev: 优先于 JWT,避免未配密钥时仍能测需登录接口 + if ($this->devBypassAllowed() && str_starts_with($token, 'dev:')) { + return $this->resolveDevToken($token); + } + + // 与 .env 中 MAIN_SITE_SSO_JWT_SECRET 一致,用于 firebase/php-jwt 验签 + $secret = config('lottery.main_site.sso_jwt_secret'); + if (! is_string($secret) || $secret === '') { + throw new PlayerAuthenticationException('SSO 未配置(MAIN_SITE_SSO_JWT_SECRET)', 8004, 503); + } + + return $this->resolveJwt($token, $secret); + } + + private function devBypassAllowed(): bool + { + return (bool) config('lottery.player_auth.dev_bypass') + && app()->environment('local'); + } + + private function resolveDevToken(string $token): Player + { + if (! preg_match('/^dev:(\d+)$/', $token, $m)) { + throw new PlayerAuthenticationException('开发 Token 格式应为 dev:{玩家ID}', 8002); + } + + $player = Player::query()->find((int) $m[1]); + if ($player === null) { + throw new PlayerAuthenticationException('玩家不存在', 8003); + } + + return $player; + } + + private function resolveJwt(string $jwt, string $secret): Player + { + $alg = (string) config('lottery.player_auth.jwt.algorithm', 'HS256'); + + try { + /** @var object $claims */ + $claims = JWT::decode($jwt, new Key($secret, $alg)); + } catch (\Throwable $e) { + // 签名错误、exp 过期、格式损坏等均归 8002 + throw new PlayerAuthenticationException('Token 无效或已过期', 8002); + } + + // 与主站约定 JWT 里字段名;若主站用 sub/iss 等可改 env LOTTERY_JWT_CLAIM_* + $siteKey = (string) config('lottery.player_auth.jwt.claim_site_code', 'site_code'); + $pidKey = (string) config('lottery.player_auth.jwt.claim_site_player_id', 'site_player_id'); + + $siteCode = data_get($claims, $siteKey); + $sitePlayerId = data_get($claims, $pidKey); + + if (! is_string($siteCode) || $siteCode === '' || ! is_string($sitePlayerId) || $sitePlayerId === '') { + throw new PlayerAuthenticationException('JWT 缺少站点或玩家标识', 8002); + } + + // 首期:库中必须先有该行;若需「首次进入自动建档」可在此处 firstOrCreate + $player = Player::query() + ->where('site_code', $siteCode) + ->where('site_player_id', $sitePlayerId) + ->first(); + + if ($player === null) { + throw new PlayerAuthenticationException('玩家未建档', 8003); + } + + return $player; + } +} diff --git a/app/Support/ApiResponse.php b/app/Support/ApiResponse.php index 1a3041b..4d5f2e1 100644 --- a/app/Support/ApiResponse.php +++ b/app/Support/ApiResponse.php @@ -5,7 +5,10 @@ namespace App\Support; use Illuminate\Http\JsonResponse; /** - * 与 PRD / docs/04-领域字典与编码规范.md 对齐:{ code, msg, data }。 + * 对外 API 统一 JSON 结构:{ code, msg, data }。 + * + * - code=0 表示成功;非 0 为业务码(见 docs/04-领域字典与编码规范.md)。 + * - error() 的 HTTP 状态可与 code 独立(如鉴权失败 401 + code 8001)。 */ final class ApiResponse { diff --git a/bootstrap/app.php b/bootstrap/app.php index ed46bfb..9c6ec00 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -7,13 +7,16 @@ use Illuminate\Foundation\Configuration\Middleware; return Application::configure(basePath: dirname(__DIR__)) ->withRouting( web: __DIR__.'/../routes/web.php', + // 自动加前缀 `api` + middleware `api`,见 routes/api.php api: __DIR__.'/../routes/api.php', commands: __DIR__.'/../routes/console.php', health: '/up', ) ->withMiddleware(function (Middleware $middleware): void { $middleware->alias([ + // 玩家端需登录路由使用;解析 Bearer → Player 'lottery.player' => \App\Http\Middleware\EnsurePlayerApi::class, + // 后台 API 预留:Sanctum / RBAC 'lottery.admin' => \App\Http\Middleware\EnsureAdminApi::class, ]); }) diff --git a/composer.json b/composer.json index 45c03fb..c41cca7 100644 --- a/composer.json +++ b/composer.json @@ -10,6 +10,7 @@ "license": "MIT", "require": { "php": "^8.3", + "firebase/php-jwt": "^6.11", "laravel/framework": "^13.7", "laravel/tinker": "^3.0" }, @@ -87,4 +88,4 @@ }, "minimum-stability": "stable", "prefer-stable": true -} \ No newline at end of file +} diff --git a/composer.lock b/composer.lock index e4c2c6c..4e769d8 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "a9f356437f9d4d09ea0629df7da9db01", + "content-hash": "709df8b7c1a41b9d01918fea32398dfb", "packages": [ { "name": "brick/math", @@ -406,6 +406,69 @@ }, "time": "2025-03-06T22:45:56+00:00" }, + { + "name": "firebase/php-jwt", + "version": "v6.11.1", + "source": { + "type": "git", + "url": "https://github.com/googleapis/php-jwt.git", + "reference": "d1e91ecf8c598d073d0995afa8cd5c75c6e19e66" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/googleapis/php-jwt/zipball/d1e91ecf8c598d073d0995afa8cd5c75c6e19e66", + "reference": "d1e91ecf8c598d073d0995afa8cd5c75c6e19e66", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "require-dev": { + "guzzlehttp/guzzle": "^7.4", + "phpspec/prophecy-phpunit": "^2.0", + "phpunit/phpunit": "^9.5", + "psr/cache": "^2.0||^3.0", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0" + }, + "suggest": { + "ext-sodium": "Support EdDSA (Ed25519) signatures", + "paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present" + }, + "type": "library", + "autoload": { + "psr-4": { + "Firebase\\JWT\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Neuman Vong", + "email": "neuman+pear@twilio.com", + "role": "Developer" + }, + { + "name": "Anant Narayanan", + "email": "anant@php.net", + "role": "Developer" + } + ], + "description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.", + "homepage": "https://github.com/firebase/php-jwt", + "keywords": [ + "jwt", + "php" + ], + "support": { + "issues": "https://github.com/googleapis/php-jwt/issues", + "source": "https://github.com/googleapis/php-jwt/tree/v6.11.1" + }, + "time": "2025-04-09T20:32:01+00:00" + }, { "name": "fruitcake/php-cors", "version": "v1.4.0", diff --git a/config/lottery.php b/config/lottery.php index 2c126fc..05a0fd5 100644 --- a/config/lottery.php +++ b/config/lottery.php @@ -12,4 +12,20 @@ return [ 'wallet_timeout' => (int) env('MAIN_SITE_WALLET_TIMEOUT', 10), ], + /* + | player_auth:配合 app/Services/PlayerTokenResolver.php + | + | dev_bypass:仅当 APP_ENV=local 且 LOTTERY_PLAYER_AUTH_DEV_BYPASS=true 时, + | 允许 Authorization: Bearer dev:{players.id} + | jwt.* :主站签发的 JWT 内取站点、玩家字段的路径名(与主站约定一致) + */ + 'player_auth' => [ + 'dev_bypass' => env('LOTTERY_PLAYER_AUTH_DEV_BYPASS', false), + 'jwt' => [ + 'algorithm' => env('LOTTERY_JWT_ALGORITHM', 'HS256'), + 'claim_site_code' => env('LOTTERY_JWT_CLAIM_SITE_CODE', 'site_code'), + 'claim_site_player_id' => env('LOTTERY_JWT_CLAIM_SITE_PLAYER_ID', 'site_player_id'), + ], + ], + ]; diff --git a/routes/api.php b/routes/api.php index d773e83..70f7d1d 100644 --- a/routes/api.php +++ b/routes/api.php @@ -2,25 +2,34 @@ use App\Http\Controllers\Api\V1\Admin\PingController as AdminPingController; use App\Http\Controllers\Api\V1\HealthController; +use App\Http\Controllers\Api\V1\Player\MeController; use App\Http\Controllers\Api\V1\Player\PingController as PlayerPingController; use Illuminate\Support\Facades\Route; /* -| 全局前缀已由 bootstrap 注册为 /api,本文件内为相对路径。 -| 玩家端:/api/v1/player/... -| 后台: /api/v1/admin/... +| Laravel 默认在 bootstrap 中为 api 文件加前缀 `api`,故实际 URL: +| /api/v1/health +| /api/v1/player/... +| /api/v1/admin/... */ Route::prefix('v1')->group(function (): void { + // 探活:无鉴权 Route::get('health', HealthController::class)->name('api.v1.health'); - Route::middleware('lottery.player') - ->prefix('player') + Route::prefix('player') ->name('api.v1.player.') ->group(function (): void { + // 不需 Bearer Route::get('ping', PlayerPingController::class)->name('ping'); + + // 需 Bearer:PlayerTokenResolver + EnsurePlayerApi + Route::middleware('lottery.player')->group(function (): void { + Route::get('me', MeController::class)->name('me'); + }); }); + // 后台 API 前缀;中间件 lottery.admin 内预留 Sanctum / RBAC Route::middleware('lottery.admin') ->prefix('admin') ->name('api.v1.admin.')