Files
lotteryLaravel/app/Services/PlayerTokenResolver.php

175 lines
6.8 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 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 段80018005
*/
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_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;
}
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,
);
}
}
}