111 lines
4.3 KiB
PHP
111 lines
4.3 KiB
PHP
<?php
|
||
|
||
namespace App\Services;
|
||
|
||
use App\Exceptions\PlayerAuthenticationException;
|
||
use App\Models\Player;
|
||
use Firebase\JWT\JWT;
|
||
use Firebase\JWT\Key;
|
||
use Illuminate\Http\Request;
|
||
|
||
/**
|
||
* 从请求头解析玩家身份,返回已落库的 {@see Player}。
|
||
*
|
||
* 两种模式(互斥优先级:先判断 dev,再走 JWT):
|
||
* 1) 开发绕过:当 `LOTTERY_PLAYER_AUTH_DEV_BYPASS=true` 且运行环境为 `local` 或 **`testing`**(PHPUnit)时,
|
||
* 接受 `Authorization: Bearer dev:{players.id}`,直连主键查库(**禁止在生产开启**)。
|
||
* 2) 生产:使用 `MAIN_SITE_SSO_JWT_SECRET` 验签 JWT(默认 HS256),
|
||
* 从 payload 读取 `site_code`、`site_player_id`(字段名可 env 覆盖)再查 players 表。
|
||
*
|
||
* 错误码约定(见中间件返回 JSON 的 code):
|
||
* - 8001:无 Bearer / Token 空 / Header 格式不对
|
||
* - 8002:JWT 无效、过期或缺少站点/玩家字段
|
||
* - 8003:数据库无对应玩家(未建档)
|
||
* - 8004:未配置 MAIN_SITE_SSO_JWT_SECRET(HTTP 503)
|
||
*/
|
||
final class PlayerTokenResolver
|
||
{
|
||
public function resolve(Request $request): Player
|
||
{
|
||
$header = $request->header('Authorization', '');
|
||
if (! is_string($header) || ! str_starts_with($header, 'Bearer ')) {
|
||
throw new PlayerAuthenticationException('缺少或非法 Authorization', 8001);
|
||
}
|
||
|
||
// 标准:`Authorization: Bearer <token>`,此处去掉前缀 7 字节 "Bearer "
|
||
$token = trim(substr($header, 7));
|
||
if ($token === '') {
|
||
throw new PlayerAuthenticationException('Token 为空', 8001);
|
||
}
|
||
|
||
// 本地 dev: 优先于 JWT,避免未配密钥时仍能测需登录接口
|
||
if ($this->devBypassAllowed() && str_starts_with($token, 'dev:')) {
|
||
return $this->resolveDevToken($token);
|
||
}
|
||
|
||
// 与 .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)', 8004, 503);
|
||
}
|
||
|
||
return $this->resolveJwt($token, $secret);
|
||
}
|
||
|
||
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}', 8002);
|
||
}
|
||
|
||
$player = Player::query()->find((int) $m[1]);
|
||
if ($player === null) {
|
||
throw new PlayerAuthenticationException('玩家不存在', 8003);
|
||
}
|
||
|
||
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 无效或已过期', 8002);
|
||
}
|
||
|
||
// 与主站约定 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 缺少站点或玩家标识', 8002);
|
||
}
|
||
|
||
// 首期:库中必须先有该行;若需「首次进入自动建档」可在此处 firstOrCreate
|
||
$player = Player::query()
|
||
->where('site_code', $siteCode)
|
||
->where('site_player_id', $sitePlayerId)
|
||
->first();
|
||
|
||
if ($player === null) {
|
||
throw new PlayerAuthenticationException('玩家未建档', 8003);
|
||
}
|
||
|
||
return $player;
|
||
}
|
||
}
|