From 6a73d760c47764dd67e90f7216dd429fd5c6abe2 Mon Sep 17 00:00:00 2001 From: sindoring Date: Mon, 17 Aug 2026 20:25:40 +0400 Subject: [PATCH] wip --- .env.example | 2 + .../Api/LauncherGameTicketController.php | 70 ++++++++++ app/Services/LauncherGameTicketService.php | 89 +++++++++++++ config/moonwell.php | 2 + openapi.json | 75 +++++++++++ routes/api.php | 3 + .../Feature/LauncherGameTicketFeatureTest.php | 125 ++++++++++++++++++ 7 files changed, 366 insertions(+) create mode 100644 app/Http/Controllers/Api/LauncherGameTicketController.php create mode 100644 app/Services/LauncherGameTicketService.php create mode 100644 tests/Feature/LauncherGameTicketFeatureTest.php diff --git a/.env.example b/.env.example index 3a19d0a..efbc8ec 100644 --- a/.env.example +++ b/.env.example @@ -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}" diff --git a/app/Http/Controllers/Api/LauncherGameTicketController.php b/app/Http/Controllers/Api/LauncherGameTicketController.php new file mode 100644 index 0000000..b4e51d9 --- /dev/null +++ b/app/Http/Controllers/Api/LauncherGameTicketController.php @@ -0,0 +1,70 @@ + []]], + 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'); + } +} diff --git a/app/Services/LauncherGameTicketService.php b/app/Services/LauncherGameTicketService.php new file mode 100644 index 0000000..9eae58e --- /dev/null +++ b/app/Services/LauncherGameTicketService.php @@ -0,0 +1,89 @@ +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.'); + } + } +} diff --git a/config/moonwell.php b/config/moonwell.php index 795b19e..1ce78ad 100644 --- a/config/moonwell.php +++ b/config/moonwell.php @@ -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), diff --git a/openapi.json b/openapi.json index c4f30ea..c46485b 100644 --- a/openapi.json +++ b/openapi.json @@ -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": [ diff --git a/routes/api.php b/routes/api.php index 78db427..119f28c 100644 --- a/routes/api.php +++ b/routes/api.php @@ -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', '.*'); diff --git a/tests/Feature/LauncherGameTicketFeatureTest.php b/tests/Feature/LauncherGameTicketFeatureTest.php new file mode 100644 index 0000000..000b67d --- /dev/null +++ b/tests/Feature/LauncherGameTicketFeatureTest.php @@ -0,0 +1,125 @@ + '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; + } +}