175 lines
6.8 KiB
PHP
175 lines
6.8 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 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 段(8001–8005)。
|
||
*/
|
||
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->resolveJwt($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;
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
// 与主站约定 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();
|
||
}
|
||
|
||
private function assertPlayerActive(Player $player): void
|
||
{
|
||
if ((int) $player->status !== self::PLAYER_STATUS_ACTIVE) {
|
||
throw new PlayerAuthenticationException(
|
||
'账号已冻结或不可用',
|
||
ErrorCode::PlayerAccountSuspended->value,
|
||
403,
|
||
);
|
||
}
|
||
}
|
||
}
|