'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; } }