wip
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\GameAccount;
|
||||
use App\Services\LauncherGameTicketService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
class LauncherGameTicketController extends Controller
|
||||
{
|
||||
public function __construct(private readonly LauncherGameTicketService $tickets) {}
|
||||
|
||||
#[OA\Post(
|
||||
path: '/api/launcher/game-ticket',
|
||||
summary: 'Issue a single-use game login ticket',
|
||||
description: 'Creates short-lived SRP credentials for authserver. A new ticket atomically revokes the previous generation. The plaintext ticket is never stored.',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Launcher Auth'],
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
content: new OA\JsonContent(
|
||||
required: ['client_build'],
|
||||
properties: [
|
||||
new OA\Property(property: 'client_build', type: 'integer', example: 12340),
|
||||
],
|
||||
),
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Ticket issued',
|
||||
content: new OA\JsonContent(
|
||||
required: ['account', 'ticket', 'expires_at'],
|
||||
properties: [
|
||||
new OA\Property(property: 'account', type: 'string', example: 'PLAYERONE'),
|
||||
new OA\Property(property: 'ticket', type: 'string', pattern: '^[A-Z0-9]{16}$', example: '7W6D4M2Q9K8R3X5Z'),
|
||||
new OA\Property(property: 'expires_at', type: 'string', format: 'date-time'),
|
||||
],
|
||||
),
|
||||
),
|
||||
new OA\Response(response: 401, description: 'Launcher authentication required'),
|
||||
new OA\Response(response: 422, description: 'Client build is not allowed'),
|
||||
new OA\Response(response: 429, description: 'Too many requests'),
|
||||
],
|
||||
)]
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$clientBuild = (int) $request->validate([
|
||||
'client_build' => [
|
||||
'required',
|
||||
'integer',
|
||||
Rule::in([(int) config('moonwell.launcher.client_build', 12340)]),
|
||||
],
|
||||
])['client_build'];
|
||||
|
||||
/** @var GameAccount $account */
|
||||
$account = $request->user();
|
||||
$launcherSessionToken = $request->bearerToken();
|
||||
|
||||
abort_if($launcherSessionToken === null, 401);
|
||||
|
||||
return response()
|
||||
->json($this->tickets->issue($account, $launcherSessionToken, $clientBuild))
|
||||
->header('Cache-Control', 'no-store');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\GameAccount;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use RuntimeException;
|
||||
|
||||
class LauncherGameTicketService
|
||||
{
|
||||
private const string ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
|
||||
private const int TICKET_LENGTH = 16;
|
||||
|
||||
public function __construct(private readonly AzerothCoreSrpService $srp) {}
|
||||
|
||||
/**
|
||||
* @return array{account: string, ticket: string, expires_at: string}
|
||||
*/
|
||||
public function issue(GameAccount $account, string $launcherSessionToken, int $clientBuild): array
|
||||
{
|
||||
$username = $this->srp->normalizeUsername((string) $account->username);
|
||||
$this->assertCompatibleAccountName($username);
|
||||
|
||||
$ticket = $this->randomTicket();
|
||||
[$saltHex, $verifierHex] = $this->srp->makeRegistrationData($username, $ticket);
|
||||
$generationId = random_bytes(16);
|
||||
$sessionHash = hash('sha256', $launcherSessionToken, true);
|
||||
$issuedAt = CarbonImmutable::now('UTC');
|
||||
$ttl = max(5, min(60, (int) config('moonwell.launcher.game_ticket_ttl', 60)));
|
||||
$expiresAt = $issuedAt->addSeconds($ttl);
|
||||
$connection = DB::connection((string) config('moonwell.auth_connection'));
|
||||
|
||||
$connection->transaction(function () use (
|
||||
$connection,
|
||||
$account,
|
||||
$generationId,
|
||||
$saltHex,
|
||||
$verifierHex,
|
||||
$clientBuild,
|
||||
$sessionHash,
|
||||
$issuedAt,
|
||||
$expiresAt,
|
||||
): void {
|
||||
$connection->table('launcher_ticket')->updateOrInsert(
|
||||
['account_id' => (int) $account->id],
|
||||
[
|
||||
'generation_id' => $generationId,
|
||||
'srp_salt' => hex2bin($saltHex),
|
||||
'srp_verifier' => hex2bin($verifierHex),
|
||||
'client_build' => $clientBuild,
|
||||
'launcher_session_hash' => $sessionHash,
|
||||
'issued_at' => $issuedAt,
|
||||
'expires_at' => $expiresAt,
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
return [
|
||||
'account' => $username,
|
||||
'ticket' => $ticket,
|
||||
'expires_at' => $expiresAt->format('Y-m-d\\TH:i:s\\Z'),
|
||||
];
|
||||
}
|
||||
|
||||
private function randomTicket(): string
|
||||
{
|
||||
$ticket = '';
|
||||
$lastIndex = strlen(self::ALPHABET) - 1;
|
||||
|
||||
for ($i = 0; $i < self::TICKET_LENGTH; $i++) {
|
||||
$ticket .= self::ALPHABET[random_int(0, $lastIndex)];
|
||||
}
|
||||
|
||||
return $ticket;
|
||||
}
|
||||
|
||||
private function assertCompatibleAccountName(string $username): void
|
||||
{
|
||||
if (
|
||||
$username === ''
|
||||
|| strlen($username) > 17
|
||||
|| preg_match('/^[!-<>-~]+$/D', $username) !== 1
|
||||
) {
|
||||
throw new RuntimeException('Game account is not compatible with the 3.3.5a authentication protocol.');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user