header('Authorization', ''); if (! is_string($header) || ! str_starts_with($header, 'Bearer ')) { throw new PlayerAuthenticationException( '缺少或非法 Authorization', ErrorCode::PlayerAuthorizationInvalid->value, ); } // 标准:`Authorization: Bearer `,此处去掉前缀 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_at;dev 仅在此处写入 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(); } /** * 短效 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) { throw new PlayerAuthenticationException( '账号已冻结或不可用', ErrorCode::PlayerAccountSuspended->value, 403, ); } } }