215 lines
7.6 KiB
PHP
215 lines
7.6 KiB
PHP
<?php
|
||
|
||
namespace App\Services\Player;
|
||
|
||
use Firebase\JWT\JWT;
|
||
use App\Models\Player;
|
||
use App\Lottery\ErrorCode;
|
||
use App\Support\PlayerAuthSource;
|
||
use Illuminate\Support\Facades\DB;
|
||
use Illuminate\Support\Facades\Hash;
|
||
use App\Events\PlayerSessionReplacedBroadcast;
|
||
use App\Exceptions\PlayerAuthenticationException;
|
||
|
||
final class PlayerNativeAuthService
|
||
{
|
||
public function __construct(
|
||
private readonly NativeJwtSecretGuard $nativeJwtSecretGuard,
|
||
) {}
|
||
|
||
/**
|
||
* @return array{access_token: string, expires_in: int, token_type: string, player: array<string, mixed>}
|
||
*/
|
||
public function login(string $siteCode, string $username, string $password): array
|
||
{
|
||
$username = trim($username);
|
||
$siteCode = trim($siteCode);
|
||
if ($username === '' || $password === '') {
|
||
throw new PlayerAuthenticationException(
|
||
'账号或密码错误',
|
||
ErrorCode::PlayerCredentialsInvalid->value,
|
||
);
|
||
}
|
||
|
||
$player = $this->resolveNativePlayer($siteCode, $username);
|
||
|
||
if ($player === null || ! is_string($player->password_hash) || $player->password_hash === '') {
|
||
throw new PlayerAuthenticationException(
|
||
'账号或密码错误',
|
||
ErrorCode::PlayerCredentialsInvalid->value,
|
||
);
|
||
}
|
||
|
||
if ($player->login_locked_until !== null && $player->login_locked_until->isFuture()) {
|
||
throw new PlayerAuthenticationException(
|
||
'登录已锁定',
|
||
ErrorCode::PlayerLoginLocked->value,
|
||
403,
|
||
);
|
||
}
|
||
|
||
if ((int) $player->status !== 0) {
|
||
throw new PlayerAuthenticationException(
|
||
'账号已冻结',
|
||
ErrorCode::PlayerAccountSuspended->value,
|
||
403,
|
||
);
|
||
}
|
||
|
||
if (! Hash::check($password, $player->password_hash)) {
|
||
$this->recordFailedLogin($player);
|
||
|
||
throw new PlayerAuthenticationException(
|
||
'账号或密码错误',
|
||
ErrorCode::PlayerCredentialsInvalid->value,
|
||
);
|
||
}
|
||
|
||
$ttl = (int) config('lottery.player_auth.native.ttl_seconds', 28800);
|
||
|
||
/** @var array{token: string, player: Player, session_version: int} $login */
|
||
$login = DB::transaction(function () use ($player, $password, $ttl): array {
|
||
$locked = Player::query()->lockForUpdate()->find($player->id);
|
||
if ($locked === null
|
||
|| ! $locked->isLotteryNative()
|
||
|| ! is_string($locked->password_hash)
|
||
|| ! Hash::check($password, $locked->password_hash)) {
|
||
throw new PlayerAuthenticationException(
|
||
'账号或密码错误',
|
||
ErrorCode::PlayerCredentialsInvalid->value,
|
||
);
|
||
}
|
||
|
||
if ($locked->login_locked_until !== null && $locked->login_locked_until->isFuture()) {
|
||
throw new PlayerAuthenticationException(
|
||
'登录已锁定',
|
||
ErrorCode::PlayerLoginLocked->value,
|
||
403,
|
||
);
|
||
}
|
||
|
||
if ((int) $locked->status !== 0) {
|
||
throw new PlayerAuthenticationException(
|
||
'账号已冻结',
|
||
ErrorCode::PlayerAccountSuspended->value,
|
||
403,
|
||
);
|
||
}
|
||
|
||
$sessionVersion = (int) ($locked->native_session_version ?? 0) + 1;
|
||
$locked->forceFill([
|
||
'login_failed_count' => 0,
|
||
'login_locked_until' => null,
|
||
'last_login_at' => now(),
|
||
'native_session_version' => $sessionVersion,
|
||
])->save();
|
||
|
||
return [
|
||
'token' => $this->issueToken($locked, $ttl),
|
||
'player' => $locked->refresh(),
|
||
'session_version' => $sessionVersion,
|
||
];
|
||
});
|
||
|
||
event(new PlayerSessionReplacedBroadcast(
|
||
(int) $login['player']->id,
|
||
$login['session_version'],
|
||
(int) floor(microtime(true) * 1000),
|
||
));
|
||
|
||
$player = $login['player'];
|
||
|
||
return [
|
||
'access_token' => $login['token'],
|
||
'expires_in' => $ttl,
|
||
'token_type' => 'Bearer',
|
||
'player' => [
|
||
'id' => (int) $player->id,
|
||
'site_code' => $player->site_code,
|
||
'username' => $player->username,
|
||
'nickname' => $player->nickname,
|
||
'funding_mode' => $player->funding_mode,
|
||
'auth_source' => $player->auth_source,
|
||
],
|
||
];
|
||
}
|
||
|
||
public function issueToken(Player $player, ?int $ttlSeconds = null): string
|
||
{
|
||
$secret = $this->nativeJwtSecretGuard->validatedSecret();
|
||
|
||
$ttl = $ttlSeconds ?? (int) config('lottery.player_auth.native.ttl_seconds', 28800);
|
||
$now = time();
|
||
$playerIdKey = (string) config('lottery.player_auth.native.claim_player_id', 'player_id');
|
||
$authKey = (string) config('lottery.player_auth.native.claim_auth_source', 'auth_source');
|
||
|
||
$payload = [
|
||
$playerIdKey => (int) $player->id,
|
||
$authKey => PlayerAuthSource::LOTTERY_NATIVE,
|
||
'token_version' => (int) ($player->native_token_version ?? 0),
|
||
'session_version' => (int) ($player->native_session_version ?? 0),
|
||
'site_code' => (string) $player->site_code,
|
||
'iat' => $now,
|
||
'exp' => $now + $ttl,
|
||
];
|
||
|
||
return JWT::encode($payload, $secret, (string) config('lottery.player_auth.jwt.algorithm', 'HS256'));
|
||
}
|
||
|
||
private function recordFailedLogin(Player $player): void
|
||
{
|
||
$max = (int) config('lottery.player_auth.native.max_login_attempts', 8);
|
||
$lockMinutes = (int) config('lottery.player_auth.native.lock_minutes', 15);
|
||
|
||
// 并发场景下两个错误登录请求都拿到旧 login_failed_count 各自 +1 时,会出现 last-write-wins
|
||
// 丢失一次失败计数(影响锁定判定准确性)。用 lockForUpdate 取最新计数后再写。
|
||
\DB::transaction(function () use ($player, $max, $lockMinutes): void {
|
||
$latest = Player::query()
|
||
->whereKey((int) $player->id)
|
||
->lockForUpdate()
|
||
->first();
|
||
|
||
if ($latest === null) {
|
||
return;
|
||
}
|
||
|
||
$count = (int) $latest->login_failed_count + 1;
|
||
|
||
$updates = ['login_failed_count' => $count];
|
||
if ($count >= $max) {
|
||
$updates['login_locked_until'] = now()->addMinutes($lockMinutes);
|
||
$updates['login_failed_count'] = 0;
|
||
}
|
||
|
||
Player::query()
|
||
->whereKey((int) $player->id)
|
||
->update($updates);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 彩票端登录:玩家只填账号密码,不传站点编号。
|
||
* 若请求带了 site_code(部署绑站)则优先在该站查找;否则按账号全局匹配唯一彩票原生玩家。
|
||
*/
|
||
private function resolveNativePlayer(string $siteCode, string $username): ?Player
|
||
{
|
||
$query = Player::query()
|
||
->where('username', $username)
|
||
->where('auth_source', PlayerAuthSource::LOTTERY_NATIVE);
|
||
|
||
if ($siteCode !== '') {
|
||
$scoped = (clone $query)->where('site_code', $siteCode)->first();
|
||
if ($scoped !== null) {
|
||
return $scoped;
|
||
}
|
||
}
|
||
|
||
$candidates = $query->get();
|
||
if ($candidates->count() === 1) {
|
||
return $candidates->first();
|
||
}
|
||
|
||
return null;
|
||
}
|
||
}
|