- 更新多个控制器和服务,使用 LotterySettings 服务获取彩票相关配置,如默认币种、开奖间隔、下注窗口等,提升代码一致性与可维护性。 - 移除 .env.example 中不再使用的配置项,建议通过后台管理进行设置。
265 lines
9.9 KiB
PHP
265 lines
9.9 KiB
PHP
<?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;
|
||
use App\Services\Integration\PartnerSiteConfigResolver;
|
||
|
||
/**
|
||
* 从请求头解析玩家身份,返回已落库的 {@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 段(8001–8005)。
|
||
*/
|
||
final class PlayerTokenResolver
|
||
{
|
||
/** players.status:与迁移注释一致 */
|
||
private const PLAYER_STATUS_ACTIVE = 0;
|
||
|
||
public function __construct(
|
||
private readonly PartnerSiteConfigResolver $partnerSiteConfigResolver,
|
||
) {}
|
||
|
||
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 {
|
||
$jwtPlain = $this->unwrapOpaqueToJwtString($token);
|
||
$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->resolveJwt($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->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' => 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();
|
||
}
|
||
|
||
/**
|
||
* 短效 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,
|
||
);
|
||
}
|
||
}
|
||
}
|