feat(player-auth): add JWT TTL check and AES wrapped token support

1. 新增JWT有效期校验,限制exp-iat最大时长并支持强制校验iat字段
2. 新增AES-GCM密文Token解包能力,支持非标准JWT格式的令牌传递
3. 新增相关配置项和环境变量,可灵活调整校验策略
This commit is contained in:
2026-05-14 09:37:52 +08:00
parent 9d3d086adc
commit c9c1fecfcf
4 changed files with 148 additions and 2 deletions

View File

@@ -198,6 +198,12 @@ LOTTERY_JWT_ALGORITHM=HS256
LOTTERY_JWT_CLAIM_SITE_CODE=site_code LOTTERY_JWT_CLAIM_SITE_CODE=site_code
# JWT 内表示主站玩家标识的 claim 名 # JWT 内表示主站玩家标识的 claim 名
LOTTERY_JWT_CLAIM_SITE_PLAYER_ID=site_player_id LOTTERY_JWT_CLAIM_SITE_PLAYER_ID=site_player_id
# JWT 允许的最长有效窗exp-iat 不得超过此值;默认 3005 分钟)
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到期需重新登录 # 管理端登录Sanctum PAT 有效天数(签发时刻起),至少 1到期需重新登录
ADMIN_API_TOKEN_TTL_DAYS=7 ADMIN_API_TOKEN_TTL_DAYS=7

View File

@@ -7,6 +7,7 @@ use Firebase\JWT\Key;
use App\Models\Player; use App\Models\Player;
use App\Lottery\ErrorCode; use App\Lottery\ErrorCode;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use App\Support\PlayerTokenAesUnwrap;
use Illuminate\Database\QueryException; use Illuminate\Database\QueryException;
use App\Exceptions\PlayerAuthenticationException; use App\Exceptions\PlayerAuthenticationException;
@@ -65,7 +66,7 @@ final class PlayerTokenResolver
); );
} }
$player = $this->resolveJwt($token, $secret); $player = $this->resolveJwtOrAesWrappedJwt($token, $secret);
} }
$this->assertPlayerActive($player); $this->assertPlayerActive($player);
@@ -103,6 +104,36 @@ final class PlayerTokenResolver
return $player; 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 private function resolveJwt(string $jwt, string $secret): Player
{ {
$alg = (string) config('lottery.player_auth.jwt.algorithm', 'HS256'); $alg = (string) config('lottery.player_auth.jwt.algorithm', 'HS256');
@@ -115,6 +146,8 @@ final class PlayerTokenResolver
throw new PlayerAuthenticationException('Token 无效或已过期', ErrorCode::PlayerTokenInvalid->value); throw new PlayerAuthenticationException('Token 无效或已过期', ErrorCode::PlayerTokenInvalid->value);
} }
$this->assertJwtTemporalPolicy($claims);
// 与主站约定 JWT 里字段名;若主站用 sub/iss 等可改 env LOTTERY_JWT_CLAIM_* // 与主站约定 JWT 里字段名;若主站用 sub/iss 等可改 env LOTTERY_JWT_CLAIM_*
$siteKey = (string) config('lottery.player_auth.jwt.claim_site_code', 'site_code'); $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'); $pidKey = (string) config('lottery.player_auth.jwt.claim_site_player_id', 'site_player_id');
@@ -161,6 +194,48 @@ final class PlayerTokenResolver
return $player->refresh(); return $player->refresh();
} }
/**
* 短效 SSOJWT 须有 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 private function assertPlayerActive(Player $player): void
{ {
if ((int) $player->status !== self::PLAYER_STATUS_ACTIVE) { if ((int) $player->status !== self::PLAYER_STATUS_ACTIVE) {

View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\Support;
/**
* URL/Query 传入的 AES-256-GCM 密文 Token 解包为内层 JWT 明文字符串。
*
* 约定Base64( IV(12) ciphertext tag(16) ),明文为 UTF-8 JWT三点分段
* 密钥:`config('lottery.player_auth.aes.key_base64')` Base64 解码后为 32 字节。
*/
final class PlayerTokenAesUnwrap
{
/**
* @return 内层 JWT null(未启用/格式错/解密失败)
*/
public static function tryUnwrap(string $opaque): ?string
{
$keyB64 = config('lottery.player_auth.aes.key_base64');
if (! is_string($keyB64) || trim($keyB64) === '') {
return null;
}
$keyRaw = base64_decode(trim($keyB64), true);
if (! is_string($keyRaw) || strlen($keyRaw) !== 32) {
return null;
}
$trim = trim($opaque);
if ($trim === '') {
return null;
}
$bin = base64_decode(strtr($trim, '-_', '+/'), true);
if ($bin === false || strlen($bin) < 12 + 1 + 16) {
return null;
}
$iv = substr($bin, 0, 12);
$tag = substr($bin, -16);
$cipher = substr($bin, 12, -16);
$plain = openssl_decrypt($cipher, 'aes-256-gcm', $keyRaw, OPENSSL_RAW_DATA, $iv, $tag);
if (! is_string($plain) || $plain === '') {
return null;
}
return self::looksLikeJwt($plain) ? $plain : null;
}
private static function looksLikeJwt(string $s): bool
{
return preg_match('/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/', $s) === 1;
}
}

View File

@@ -39,7 +39,12 @@ return [
| |
| dev_bypass仅当 APP_ENV∈{local, testing} LOTTERY_PLAYER_AUTH_DEV_BYPASS=true 时, | dev_bypass仅当 APP_ENV∈{local, testing} LOTTERY_PLAYER_AUTH_DEV_BYPASS=true 时,
| 允许 Authorization: Bearer dev:{players.id}(否则 dev: 会被当成 JWT 解析并报 8002 | 允许 Authorization: Bearer dev:{players.id}(否则 dev: 会被当成 JWT 解析并报 8002
| jwt.* :主站签发的 JWT 内取站点、玩家字段的路径名(与主站约定一致);验签通过后若无映射行则自动建档 | jwt.* :主站签发的 JWT验签通过后若无映射行则自动建档
| max_ttl_seconds :允许 (exp-iat) 最大秒数(默认 300=5 分钟),与「短效 Token」对齐
| require_iat_claim true 时必须带 iat否则拒绝不建档
|
| aes.key_base64 可选。32 字节原始密钥再做 Base64 写入 env LOTTERY_PLAYER_TOKEN_AES_KEY
| 有值时 Bearer 串(非 xxx.yyy.zzz 外形)会先尝试 AES-GCM 解包为内层 JWT 再验签。
*/ */
'player_auth' => [ 'player_auth' => [
'dev_bypass' => env('LOTTERY_PLAYER_AUTH_DEV_BYPASS', false), 'dev_bypass' => env('LOTTERY_PLAYER_AUTH_DEV_BYPASS', false),
@@ -47,6 +52,11 @@ return [
'algorithm' => env('LOTTERY_JWT_ALGORITHM', 'HS256'), 'algorithm' => env('LOTTERY_JWT_ALGORITHM', 'HS256'),
'claim_site_code' => env('LOTTERY_JWT_CLAIM_SITE_CODE', 'site_code'), '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'), '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'),
], ],
], ],