wip
This commit is contained in:
@@ -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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user