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:
@@ -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 不得超过此值;默认 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;到期需重新登录
|
# 管理端登录:Sanctum PAT 有效天数(签发时刻起),至少 1;到期需重新登录
|
||||||
ADMIN_API_TOKEN_TTL_DAYS=7
|
ADMIN_API_TOKEN_TTL_DAYS=7
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 短效 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
|
private function assertPlayerActive(Player $player): void
|
||||||
{
|
{
|
||||||
if ((int) $player->status !== self::PLAYER_STATUS_ACTIVE) {
|
if ((int) $player->status !== self::PLAYER_STATUS_ACTIVE) {
|
||||||
|
|||||||
55
app/Support/PlayerTokenAesUnwrap.php
Normal file
55
app/Support/PlayerTokenAesUnwrap.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user