Files
lotteryLaravel/app/Services/PlayerTokenResolver.php
kang a44679665d feat: 增强代理和玩家管理功能
- 在多个控制器中更新权限检查逻辑,确保管理员能够更灵活地管理代理和玩家。
- 在 AdminPlayerStoreController 中引入对玩家创建能力的验证,确保只有具备相应权限的管理员能够创建玩家。
- 更新请求验证逻辑,新增 credit_limit、rebate_rate 和 extra_rebate_rate 字段,以支持更细粒度的玩家管理。
- 在 AgentNodeProfileController 中添加对父代理能力授予的验证,确保子代理的权限在父代理范围内。
- 引入 AgentProfileFieldRules 以简化代理资料更新请求的规则定义,提升代码复用性。
2026-06-04 18:00:50 +08:00

366 lines
14 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\PlayerAuthSource;
use App\Support\PlayerAutoRegistrationDefaults;
use App\Support\PlayerFundingMode;
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 段80018005
*/
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);
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_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->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,
);
}
}
}
/**
* 短效 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,
);
}
}
}