This commit is contained in:
2026-08-17 20:25:40 +04:00
parent 9e36265f81
commit 6a73d760c4
7 changed files with 366 additions and 0 deletions
+2
View File
@@ -131,6 +131,8 @@ MOONWELL_CLIENT_URL_TTL=30
MOONWELL_REALM_STATUS_HOST=play.moon-well.online
MOONWELL_REALM_STATUS_TIMEOUT=0.5
MOONWELL_REALM_STATUS_CACHE=15
MOONWELL_LAUNCHER_GAME_TICKET_TTL=60
MOONWELL_CLIENT_BUILD=12340
VITE_APP_NAME="${APP_NAME}"
@@ -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.');
}
}
}
+2
View File
@@ -39,6 +39,8 @@ return [
'base_path' => env('MOONWELL_LAUNCHER_BASE_PATH', 'World of Warcraft'),
'manifest_key' => env('MOONWELL_LAUNCHER_MANIFEST_KEY', 'manifest.json'),
'download_ttl' => (int) env('MOONWELL_LAUNCHER_DOWNLOAD_TTL', 60),
'game_ticket_ttl' => (int) env('MOONWELL_LAUNCHER_GAME_TICKET_TTL', 60),
'client_build' => (int) env('MOONWELL_CLIENT_BUILD', 12340),
'realm_status_host' => env('MOONWELL_REALM_STATUS_HOST'),
'realm_status_timeout' => (float) env('MOONWELL_REALM_STATUS_TIMEOUT', 0.5),
'realm_status_cache' => (int) env('MOONWELL_REALM_STATUS_CACHE', 15),
+75
View File
@@ -404,6 +404,81 @@
]
}
},
"/api/launcher/game-ticket": {
"post": {
"tags": [
"Launcher Auth"
],
"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.",
"operationId": "7b54b9b05714077ff61483260a0e64a7",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"required": [
"client_build"
],
"properties": {
"client_build": {
"type": "integer",
"example": 12340
}
},
"type": "object"
}
}
}
},
"responses": {
"200": {
"description": "Ticket issued",
"content": {
"application/json": {
"schema": {
"required": [
"account",
"ticket",
"expires_at"
],
"properties": {
"account": {
"type": "string",
"example": "PLAYERONE"
},
"ticket": {
"type": "string",
"pattern": "^[A-Z0-9]{16}$",
"example": "7W6D4M2Q9K8R3X5Z"
},
"expires_at": {
"type": "string",
"format": "date-time"
}
},
"type": "object"
}
}
}
},
"401": {
"description": "Launcher authentication required"
},
"422": {
"description": "Client build is not allowed"
},
"429": {
"description": "Too many requests"
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"/api/launcher/news": {
"get": {
"tags": [
+3
View File
@@ -4,6 +4,7 @@ use App\Http\Controllers\Api\GameBalanceController;
use App\Http\Controllers\Api\LauncherAccountController;
use App\Http\Controllers\Api\LauncherAuthController;
use App\Http\Controllers\Api\LauncherController;
use App\Http\Controllers\Api\LauncherGameTicketController;
use App\Http\Controllers\Api\LauncherNewsController;
use App\Http\Controllers\Api\LauncherRealmController;
use App\Http\Controllers\Api\LauncherRegistrationController;
@@ -19,6 +20,8 @@ Route::get('launcher/realms', [LauncherRealmController::class, 'index'])
Route::middleware('auth:api')->prefix('launcher')->group(function () {
Route::get('account', [LauncherAccountController::class, 'show']);
Route::post('game-ticket', [LauncherGameTicketController::class, 'store'])
->middleware('throttle:10,1');
Route::get('manifest', [LauncherController::class, 'manifest']);
Route::get('download/{path}', [LauncherController::class, 'download'])
->where('path', '.*');
@@ -0,0 +1,125 @@
<?php
namespace Tests\Feature;
use App\Models\GameAccount;
use App\Services\AzerothCoreSrpService;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;
class LauncherGameTicketFeatureTest extends TestCase
{
private const string SESSION_TOKEN = 'launcher-session-access-token';
protected function setUp(): void
{
parent::setUp();
config([
'app.key' => 'base64:'.base64_encode(str_repeat('m', 32)),
'auth.guards.api' => [
'driver' => 'session',
'provider' => 'game_accounts_eloquent',
],
'moonwell.auth_connection' => 'azerothcore_auth',
'moonwell.launcher.game_ticket_ttl' => 60,
'moonwell.launcher.client_build' => 12340,
'database.connections.azerothcore_auth' => [
'driver' => 'sqlite',
'database' => ':memory:',
'prefix' => '',
],
]);
DB::purge('azerothcore_auth');
Schema::connection('azerothcore_auth')->create('launcher_ticket', function (Blueprint $table): void {
$table->unsignedInteger('account_id')->primary();
$table->binary('generation_id')->unique();
$table->binary('srp_salt');
$table->binary('srp_verifier');
$table->unsignedSmallInteger('client_build');
$table->binary('launcher_session_hash')->nullable();
$table->timestamp('issued_at');
$table->timestamp('expires_at');
});
}
public function test_authenticated_launcher_receives_ticket_and_database_only_stores_srp_data(): void
{
$account = $this->authenticate(7, 'PlayerOne');
$response = $this->postJson('/api/launcher/game-ticket', ['client_build' => 12340], [
'Authorization' => 'Bearer '.self::SESSION_TOKEN,
])->assertOk()->assertHeader('Cache-Control', 'no-store, private');
$ticket = $response->json('ticket');
$response->assertJsonPath('account', 'PLAYERONE');
$this->assertMatchesRegularExpression('/^[A-Z0-9]{16}$/', $ticket);
$this->assertNotNull($response->json('expires_at'));
$row = DB::connection('azerothcore_auth')->table('launcher_ticket')->where('account_id', $account->id)->first();
$this->assertNotNull($row);
$this->assertSame(16, strlen($row->generation_id));
$this->assertSame(32, strlen($row->srp_salt));
$this->assertSame(32, strlen($row->srp_verifier));
$this->assertSame(12340, $row->client_build);
$this->assertSame(hash('sha256', self::SESSION_TOKEN, true), $row->launcher_session_hash);
$this->assertTrue(app(AzerothCoreSrpService::class)->credentialsMatch(
'PLAYERONE',
$ticket,
bin2hex($row->srp_salt),
bin2hex($row->srp_verifier),
));
$this->assertStringNotContainsString($ticket, serialize($row));
}
public function test_new_generation_revokes_previous_ticket(): void
{
$account = $this->authenticate(9, 'DevPlayer');
$headers = ['Authorization' => 'Bearer '.self::SESSION_TOKEN];
$firstTicket = $this->postJson('/api/launcher/game-ticket', ['client_build' => 12340], $headers)
->assertOk()->json('ticket');
$firstGeneration = DB::connection('azerothcore_auth')->table('launcher_ticket')
->where('account_id', $account->id)->value('generation_id');
$secondTicket = $this->postJson('/api/launcher/game-ticket', ['client_build' => 12340], $headers)
->assertOk()->json('ticket');
$row = DB::connection('azerothcore_auth')->table('launcher_ticket')
->where('account_id', $account->id)->first();
$srp = app(AzerothCoreSrpService::class);
$this->assertNotSame($firstTicket, $secondTicket);
$this->assertNotSame($firstGeneration, $row->generation_id);
$this->assertFalse($srp->credentialsMatch('DEVPLAYER', $firstTicket, bin2hex($row->srp_salt), bin2hex($row->srp_verifier)));
$this->assertTrue($srp->credentialsMatch('DEVPLAYER', $secondTicket, bin2hex($row->srp_salt), bin2hex($row->srp_verifier)));
}
public function test_ticket_endpoint_requires_launcher_authentication(): void
{
$this->postJson('/api/launcher/game-ticket', ['client_build' => 12340])->assertUnauthorized();
}
public function test_ticket_endpoint_rejects_wrong_client_build(): void
{
$this->authenticate(10, 'PlayerTwo');
$this->postJson('/api/launcher/game-ticket', ['client_build' => 99999], [
'Authorization' => 'Bearer '.self::SESSION_TOKEN,
])->assertUnprocessable();
$this->assertSame(0, DB::connection('azerothcore_auth')->table('launcher_ticket')->count());
}
private function authenticate(int $id, string $username): GameAccount
{
$account = new GameAccount;
$account->id = $id;
$account->username = $username;
$this->actingAs($account, 'api');
return $account;
}
}