diff --git a/.env.example b/.env.example index 7fb3127..a240f3b 100644 --- a/.env.example +++ b/.env.example @@ -198,6 +198,12 @@ LOTTERY_JWT_ALGORITHM=HS256 LOTTERY_JWT_CLAIM_SITE_CODE=site_code # JWT 内表示主站玩家标识的 claim 名 LOTTERY_JWT_CLAIM_SITE_PLAYER_ID=site_player_id +# JWT 允许的最长有效窗(秒):exp-iat 不得超过此值;默认 300(5 分钟) +LOTTERY_JWT_MAX_TTL_SECONDS=300 +# 是否要求 JWT 含 iat(建议 true,与短效 Token 策略一致) +LOTTERY_JWT_REQUIRE_IAT=true +# 可选:32 字节 AES 密钥再 Base64;用于 URL 传递的密文 Token 解包为内层 JWT +# LOTTERY_PLAYER_TOKEN_AES_KEY= # 管理端登录:Sanctum PAT 有效天数(签发时刻起),至少 1;到期需重新登录 ADMIN_API_TOKEN_TTL_DAYS=7 diff --git a/app/Services/PlayerTokenResolver.php b/app/Services/PlayerTokenResolver.php index 223a8da..46fb44d 100644 --- a/app/Services/PlayerTokenResolver.php +++ b/app/Services/PlayerTokenResolver.php @@ -7,6 +7,7 @@ use Firebase\JWT\Key; use App\Models\Player; use App\Lottery\ErrorCode; use Illuminate\Http\Request; +use App\Support\PlayerTokenAesUnwrap; use Illuminate\Database\QueryException; use App\Exceptions\PlayerAuthenticationException; @@ -65,7 +66,7 @@ final class PlayerTokenResolver ); } - $player = $this->resolveJwt($token, $secret); + $player = $this->resolveJwtOrAesWrappedJwt($token, $secret); } $this->assertPlayerActive($player); @@ -103,6 +104,36 @@ final class PlayerTokenResolver return $player; } + /** + * URL/Query Opaque:支持内联 JWT,或 AES-GCM 包裹的内层 JWT(见 {@see PlayerTokenAesUnwrap})。 + */ + private function resolveJwtOrAesWrappedJwt(string $opaque, string $secret): Player + { + $jwtPlain = $this->unwrapOpaqueToJwtString($opaque); + + return $this->resolveJwt($jwtPlain, $secret); + } + + /** + * 已是 `xxx.yyy.zzz` 则视为明文 JWT;否则在配置了 AES 密钥时尝试解包。 + */ + private function unwrapOpaqueToJwtString(string $token): string + { + $trimmed = trim($token); + if ($this->looksLikeCompactJwt($trimmed)) { + return $trimmed; + } + + $inner = PlayerTokenAesUnwrap::tryUnwrap($trimmed); + + return $inner ?? $trimmed; + } + + private function looksLikeCompactJwt(string $token): bool + { + return preg_match('/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/', $token) === 1; + } + private function resolveJwt(string $jwt, string $secret): Player { $alg = (string) config('lottery.player_auth.jwt.algorithm', 'HS256'); @@ -115,6 +146,8 @@ final class PlayerTokenResolver throw new PlayerAuthenticationException('Token 无效或已过期', ErrorCode::PlayerTokenInvalid->value); } + $this->assertJwtTemporalPolicy($claims); + // 与主站约定 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'); @@ -161,6 +194,48 @@ final class PlayerTokenResolver return $player->refresh(); } + /** + * 短效 SSO:JWT 须有 exp(由 decode 校验),可选要求 iat,且 exp-iat 不得超过配置秒数。 + * + * @param object $claims stdClass from firebase/php-jwt + */ + private function assertJwtTemporalPolicy(object $claims): void + { + $maxTtl = (int) config('lottery.player_auth.jwt.max_ttl_seconds', 300); + $requireIat = (bool) config('lottery.player_auth.jwt.require_iat_claim', true); + + if (! isset($claims->exp) || ! is_numeric($claims->exp)) { + throw new PlayerAuthenticationException( + 'JWT 缺少过期时间', + ErrorCode::PlayerTokenInvalid->value, + ); + } + + $exp = (int) $claims->exp; + + if ($requireIat) { + if (! isset($claims->iat) || ! is_numeric($claims->iat)) { + throw new PlayerAuthenticationException( + 'JWT 缺少签发时间', + ErrorCode::PlayerTokenInvalid->value, + ); + } + $iat = (int) $claims->iat; + if ($exp <= $iat) { + throw new PlayerAuthenticationException( + 'JWT 时间与签名无效', + ErrorCode::PlayerTokenInvalid->value, + ); + } + if ($exp - $iat > $maxTtl) { + throw new PlayerAuthenticationException( + 'JWT 有效期超过允许的 '.(string) $maxTtl.' 秒', + ErrorCode::PlayerTokenInvalid->value, + ); + } + } + } + private function assertPlayerActive(Player $player): void { if ((int) $player->status !== self::PLAYER_STATUS_ACTIVE) { diff --git a/app/Support/PlayerTokenAesUnwrap.php b/app/Support/PlayerTokenAesUnwrap.php new file mode 100644 index 0000000..457a7d9 --- /dev/null +++ b/app/Support/PlayerTokenAesUnwrap.php @@ -0,0 +1,55 @@ + [ 'dev_bypass' => env('LOTTERY_PLAYER_AUTH_DEV_BYPASS', false), @@ -47,6 +52,11 @@ return [ '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'), + 'max_ttl_seconds' => max(1, min(3600, (int) env('LOTTERY_JWT_MAX_TTL_SECONDS', 300))), + 'require_iat_claim' => filter_var(env('LOTTERY_JWT_REQUIRE_IAT', true), FILTER_VALIDATE_BOOLEAN), + ], + 'aes' => [ + 'key_base64' => env('LOTTERY_PLAYER_TOKEN_AES_KEY'), ], ],