Files
lotteryLaravel/app/Services/PlayerTokenResolver.php
kang c9c1fecfcf feat(player-auth): add JWT TTL check and AES wrapped token support
1. 新增JWT有效期校验,限制exp-iat最大时长并支持强制校验iat字段
2. 新增AES-GCM密文Token解包能力,支持非标准JWT格式的令牌传递
3. 新增相关配置项和环境变量,可灵活调整校验策略
2026-05-14 09:37:52 +08:00

250 lines
9.3 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Services;
use Firebase\JWT\JWT;
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;
/**
* 从请求头解析玩家身份,返回已落库的 {@see Player}。
*
* ## Dev bypass开发绕过与正式 JWT 的互斥关系
*
* | 条件 | Authorization 示例 | 行为 |
* | :--- | :--- | :--- |
* | `LOTTERY_PLAYER_AUTH_DEV_BYPASS=true` 且 `APP_ENV` 为 `local` 或 `testing`,且 token 以 `dev:` 开头 | `Bearer dev:12` | **仅走 dev**:按 `players.id` 查库,不验 JWT不要求配置 `MAIN_SITE_SSO_JWT_SECRET`。 |
* | 同上环境且开关为 true但 token **不是** `dev:` | `Bearer eyJ...` | **走正式 JWT**:验签、建档逻辑见下;`dev:` 前缀不会当作 JWT 解析。 |
* | `dev_bypass` 为 false 或非 local/testing | `Bearer dev:12` | **不走 dev**:整段当作 JWT 解码必然失败8002。 |
* | 任意环境 | `Bearer eyJ...` | **走正式 JWT**:必须配置 `MAIN_SITE_SSO_JWT_SECRET`;验签成功后按 `site_code` + `site_player_id` **首次自动建档**PRD并刷新 `last_login_at`。 |
*
* ## 正式 JWT主站 SSO
*
* - 成功验签后:若库中无映射行则 `firstOrCreate`(默认币种 `lottery.default_currency`、status=active
* - 并发首登可能触发唯一键冲突,会捕获后重查。
* - 解析成功后校验 {@see Player::status}:非 active 时抛 {@see ErrorCode::PlayerAccountSuspended}HTTP 403
*
* 错误码见 {@see ErrorCode} 玩家 SSO 段80018005
*/
final class PlayerTokenResolver
{
/** players.status与迁移注释一致 */
private const PLAYER_STATUS_ACTIVE = 0;
public function resolve(Request $request): Player
{
$header = $request->header('Authorization', '');
if (! is_string($header) || ! str_starts_with($header, 'Bearer ')) {
throw new PlayerAuthenticationException(
'缺少或非法 Authorization',
ErrorCode::PlayerAuthorizationInvalid->value,
);
}
// 标准:`Authorization: Bearer <token>`,此处去掉前缀 7 字节 "Bearer "
$token = trim(substr($header, 7));
if ($token === '') {
throw new PlayerAuthenticationException('Token 为空', ErrorCode::PlayerAuthorizationInvalid->value);
}
// 仅当 dev bypass 开启且 token 形如 dev:{id} 时走开发分支;否则一律按 JWT 处理
if ($this->devBypassAllowed() && str_starts_with($token, 'dev:')) {
$player = $this->resolveDevToken($token);
} else {
// 与 .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',
ErrorCode::PlayerSsoSecretNotConfigured->value,
503,
);
}
$player = $this->resolveJwtOrAesWrappedJwt($token, $secret);
}
$this->assertPlayerActive($player);
// 正式 JWT 已在 resolveJwt 内写入 last_login_atdev 仅在此处写入
if ($this->devBypassAllowed() && str_starts_with($token, 'dev:')) {
$player->forceFill(['last_login_at' => now()])->save();
return $player->refresh();
}
return $player;
}
private function devBypassAllowed(): bool
{
return (bool) config('lottery.player_auth.dev_bypass')
&& app()->environment(['local', 'testing']);
}
private function resolveDevToken(string $token): Player
{
if (! preg_match('/^dev:(\d+)$/', $token, $m)) {
throw new PlayerAuthenticationException(
'开发 Token 格式应为 dev:{玩家ID}',
ErrorCode::PlayerTokenInvalid->value,
);
}
$player = Player::query()->find((int) $m[1]);
if ($player === null) {
throw new PlayerAuthenticationException('玩家不存在', ErrorCode::PlayerNotRegistered->value);
}
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');
try {
/** @var object $claims */
$claims = JWT::decode($jwt, new Key($secret, $alg));
} catch (\Throwable $e) {
// 签名错误、exp 过期、格式损坏等均归 8002
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');
$siteCode = data_get($claims, $siteKey);
$sitePlayerId = data_get($claims, $pidKey);
if (! is_string($siteCode) || $siteCode === '' || ! is_string($sitePlayerId) || $sitePlayerId === '') {
throw new PlayerAuthenticationException('JWT 缺少站点或玩家标识', ErrorCode::PlayerTokenInvalid->value);
}
$now = now();
$defaults = [
'username' => null,
'nickname' => null,
'default_currency' => (string) config('lottery.default_currency', 'NPR'),
'status' => self::PLAYER_STATUS_ACTIVE,
'last_login_at' => $now,
];
try {
$player = Player::query()->firstOrCreate(
[
'site_code' => $siteCode,
'site_player_id' => $sitePlayerId,
],
$defaults,
);
} catch (QueryException $e) {
// 并发首登时可能重复插入唯一键,改为加载已有行
$player = Player::query()
->where('site_code', $siteCode)
->where('site_player_id', $sitePlayerId)
->first();
if ($player === null) {
throw $e;
}
}
if (! $player->wasRecentlyCreated) {
$player->forceFill(['last_login_at' => $now])->save();
}
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) {
throw new PlayerAuthenticationException(
'账号已冻结或不可用',
ErrorCode::PlayerAccountSuspended->value,
403,
);
}
}
}