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 { $jwtPlain = $this->unwrapOpaqueToJwtString($token); if ($this->peekAuthSourceFromJwt($jwtPlain) === PlayerAuthSource::LOTTERY_NATIVE) { $player = $this->resolveNativeJwt($jwtPlain); } else { $siteCode = $this->partnerSiteConfigResolver->peekSiteCodeFromJwt($jwtPlain); if ($siteCode === null) { throw new PlayerAuthenticationException('JWT 缺少站点标识', ErrorCode::PlayerTokenInvalid->value); } $siteConfig = $this->partnerSiteConfigResolver->resolveBySiteCode($siteCode); if (! $siteConfig->enabled) { throw new PlayerAuthenticationException('站点已停用', ErrorCode::PlayerAccountSuspended->value, 403); } $secret = $siteConfig->ssoJwtSecret; if (! is_string($secret) || $secret === '') { throw new PlayerAuthenticationException( 'SSO 未配置(站点 '.$siteCode.')', ErrorCode::PlayerSsoSecretNotConfigured->value, 503, ); } $player = $this->resolveSsoJwt($jwtPlain, $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->resolveSsoJwt($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 resolveNativeJwt(string $jwt): Player { $secret = (string) config('lottery.player_auth.native.secret', ''); if ($secret === '') { throw new PlayerAuthenticationException( '原生登录未配置', ErrorCode::PlayerSsoSecretNotConfigured->value, 503, ); } $alg = (string) config('lottery.player_auth.jwt.algorithm', 'HS256'); try { /** @var object $claims */ $claims = JWT::decode($jwt, new Key($secret, $alg)); } catch (\Throwable) { throw new PlayerAuthenticationException('Token 无效或已过期', ErrorCode::PlayerTokenInvalid->value); } $this->assertNativeJwtTemporalPolicy($claims); $playerIdKey = (string) config('lottery.player_auth.native.claim_player_id', 'player_id'); $authKey = (string) config('lottery.player_auth.native.claim_auth_source', 'auth_source'); $playerId = (int) data_get($claims, $playerIdKey, 0); $authSource = data_get($claims, $authKey); if ($playerId <= 0 || $authSource !== PlayerAuthSource::LOTTERY_NATIVE) { throw new PlayerAuthenticationException('JWT 缺少玩家标识', ErrorCode::PlayerTokenInvalid->value); } $player = Player::query()->find($playerId); if ($player === null || ! $player->isLotteryNative()) { throw new PlayerAuthenticationException('玩家不存在', ErrorCode::PlayerNotRegistered->value); } $player->forceFill(['last_login_at' => now()])->save(); return $player->refresh(); } private function resolveSsoJwt(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 = [ ...PlayerAutoRegistrationDefaults::profileFields(), 'auth_source' => PlayerAuthSource::MAIN_SITE_SSO, 'funding_mode' => PlayerFundingMode::WALLET, 'default_currency' => LotterySettings::defaultCurrency(), '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(); } private function peekAuthSourceFromJwt(string $jwt): ?string { $parts = explode('.', trim($jwt)); if (count($parts) !== 3) { return null; } $payload = json_decode($this->base64UrlDecode($parts[1]), true); if (! is_array($payload)) { return null; } $authKey = (string) config('lottery.player_auth.native.claim_auth_source', 'auth_source'); $value = $payload[$authKey] ?? null; return is_string($value) ? $value : null; } private function base64UrlDecode(string $segment): string { $remainder = strlen($segment) % 4; if ($remainder > 0) { $segment .= str_repeat('=', 4 - $remainder); } $decoded = base64_decode(strtr($segment, '-_', '+/'), true); return is_string($decoded) ? $decoded : ''; } /** * @param object $claims */ private function assertNativeJwtTemporalPolicy(object $claims): void { if (! isset($claims->exp) || ! is_numeric($claims->exp)) { throw new PlayerAuthenticationException('JWT 缺少过期时间', ErrorCode::PlayerTokenInvalid->value); } $maxTtl = (int) config('lottery.player_auth.native.ttl_seconds', 28800); if (isset($claims->iat) && is_numeric($claims->iat)) { $iat = (int) $claims->iat; $exp = (int) $claims->exp; if ($exp - $iat > $maxTtl) { throw new PlayerAuthenticationException( 'JWT 有效期超过允许的 '.(string) $maxTtl.' 秒', ErrorCode::PlayerTokenInvalid->value, ); } } } /** * 短效 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, ); } } }