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

@@ -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();
}
/**
* 短效 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
{
if ((int) $player->status !== self::PLAYER_STATUS_ACTIVE) {