restore acore_auth
This commit is contained in:
@@ -8,6 +8,7 @@ use App\Services\AzerothCoreSrpService;
|
||||
use App\Services\VMangosAccountRegistrar;
|
||||
use App\Services\VMangosAccountService;
|
||||
use App\Services\VMangosSrpService;
|
||||
use App\Services\WebAccountPasswordService;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -19,6 +20,16 @@ class VMangosAccountIntegrationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private array $dumpFiles = [];
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
foreach ($this->dumpFiles as $path) {
|
||||
unlink($path);
|
||||
}
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
@@ -44,6 +55,7 @@ class VMangosAccountIntegrationTest extends TestCase
|
||||
$table->dateTime('joindate');
|
||||
$table->dateTime('last_login')->nullable();
|
||||
$table->boolean('locked')->default(false);
|
||||
$table->string('last_ip')->default('0.0.0.0');
|
||||
$table->boolean('online')->default(false);
|
||||
$table->string('sessionkey')->nullable();
|
||||
});
|
||||
@@ -246,6 +258,149 @@ class VMangosAccountIntegrationTest extends TestCase
|
||||
}
|
||||
}
|
||||
|
||||
public function test_auth_dump_dry_run_then_import_and_first_login_upgrade_password(): void
|
||||
{
|
||||
config(['moonwell.portal.bot_account_prefix' => 'RNDBOT']);
|
||||
$path = $this->authDump([
|
||||
['id' => 42, 'username' => 'SINDORING', 'gmlevel' => 3],
|
||||
['id' => 99, 'username' => 'RNDBOT99'],
|
||||
]);
|
||||
$auth = DB::connection('vmangos_auth');
|
||||
$auth->table('realmlist')->insert(['id' => 1]);
|
||||
$this->artisan('vmangos:import-azerothcore-accounts', ['dump' => $path, '--dry-run' => true])
|
||||
->expectsOutput('Created: 1; linked: 0; already imported: 0; bots skipped: 1.')->assertSuccessful();
|
||||
$this->assertSame(0, User::count());
|
||||
$this->assertSame(0, $auth->table('account')->count());
|
||||
|
||||
$this->artisan('vmangos:import-azerothcore-accounts', ['dump' => $path])->assertSuccessful();
|
||||
$webUser = User::where('name', 'SINDORING')->firstOrFail();
|
||||
$encoded = $webUser->password;
|
||||
$this->assertStringStartsWith('acsrp1:SINDORING:', $encoded);
|
||||
$game = $auth->table('account')->where('username', 'SINDORING')->first();
|
||||
$this->assertNotSame(42, $game->id);
|
||||
$this->assertDatabaseHas('site_credentials', [
|
||||
'account_id' => $game->id, 'legacy_account_id' => 42, 'web_user_id' => $webUser->id,
|
||||
'salt_hex' => null, 'verifier_hex' => null,
|
||||
], 'vmangos_auth');
|
||||
$this->assertDatabaseHas('account_access', ['id' => $game->id, 'RealmID' => -1, 'gmlevel' => 3], 'vmangos_auth');
|
||||
$this->assertDatabaseHas('realmcharacters', ['acctid' => $game->id, 'realmid' => 1], 'vmangos_auth');
|
||||
$this->assertSame('2025-01-02 03:04:05', $webUser->created_at->format('Y-m-d H:i:s'));
|
||||
$this->assertFalse(app(VMangosSrpService::class)->credentialsMatch('SINDORING', 'OldPass123', $game->s, $game->v));
|
||||
$accounts = app(VMangosAccountService::class);
|
||||
$user = $accounts->findUserByUsername('sindoring');
|
||||
$this->assertFalse($accounts->validatePassword($user, 'WrongPass'));
|
||||
$this->assertSame($encoded, $webUser->fresh()->password);
|
||||
|
||||
$this->post('/login', ['username' => 'sindoring', 'password' => 'OldPass123'])->assertRedirect();
|
||||
$this->assertAuthenticated();
|
||||
$this->assertTrue(Hash::check('OldPass123', $webUser->fresh()->password));
|
||||
$this->assertSame($game->v, $auth->table('account')->where('id', $game->id)->value('v'));
|
||||
$accounts->updatePassword($user, 'NewPass123');
|
||||
$hash = $webUser->fresh()->password;
|
||||
$this->artisan('vmangos:import-azerothcore-accounts', ['dump' => $path])
|
||||
->expectsOutput('Created: 0; linked: 0; already imported: 1; bots skipped: 1.')->assertSuccessful();
|
||||
$this->assertSame($hash, $webUser->fresh()->password);
|
||||
$this->assertSame($game->v, $auth->table('account')->where('id', $game->id)->value('v'));
|
||||
$this->assertTrue($accounts->validatePassword($user, 'NewPass123'));
|
||||
$this->assertFalse($accounts->validatePassword($user, 'OldPass123'));
|
||||
}
|
||||
|
||||
public function test_auth_dump_links_legacy_game_account_and_revokes_old_tokens(): void
|
||||
{
|
||||
$auth = DB::connection('vmangos_auth');
|
||||
$gameId = $auth->table('account')->insertGetId([
|
||||
'username' => 'SINDORING', 'email' => 'sindoring@example.com',
|
||||
's' => 'previous-salt', 'v' => 'previous-verifier', 'sessionkey' => 'old-session',
|
||||
'joindate' => '2025-01-01 00:00:00',
|
||||
]);
|
||||
$auth->table('site_credentials')->insert([
|
||||
'account_id' => $gameId, 'legacy_account_id' => 42, 'salt_hex' => str_repeat('A', 64),
|
||||
'verifier_hex' => str_repeat('B', 64),
|
||||
]);
|
||||
$auth->table('account_access')->insert(['id' => $gameId, 'RealmID' => -1, 'gmlevel' => 2]);
|
||||
$auth->table('launcher_ticket')->insert(['account_id' => $gameId]);
|
||||
DB::table('oauth_access_tokens')->insert([
|
||||
'id' => 'legacy-token', 'user_id' => 42, 'client_id' => 'legacy-client', 'scopes' => '[]', 'revoked' => false,
|
||||
]);
|
||||
DB::table('oauth_refresh_tokens')->insert(['id' => 'legacy-refresh', 'access_token_id' => 'legacy-token', 'revoked' => false]);
|
||||
$this->artisan('vmangos:import-azerothcore-accounts', ['dump' => $this->authDump([['id' => 42, 'username' => 'SINDORING', 'gmlevel' => 3]])])
|
||||
->expectsOutput('Created: 0; linked: 1; already imported: 0; bots skipped: 0.')->assertSuccessful();
|
||||
$this->assertSame(1, $auth->table('account')->count());
|
||||
$this->assertSame(0, $auth->table('launcher_ticket')->count());
|
||||
$this->assertNull($auth->table('account')->value('sessionkey'));
|
||||
$this->assertNotSame('previous-verifier', $auth->table('account')->value('v'));
|
||||
$this->assertDatabaseHas('oauth_access_tokens', ['id' => 'legacy-token', 'revoked' => true]);
|
||||
$this->assertDatabaseHas('oauth_refresh_tokens', ['id' => 'legacy-refresh', 'revoked' => true]);
|
||||
$accounts = app(VMangosAccountService::class);
|
||||
$user = $accounts->findUserByUsername('sindoring');
|
||||
$this->assertSame(2, $user->gmLevel);
|
||||
$this->assertTrue($accounts->validatePassword($user, 'oldpass123'));
|
||||
$this->assertTrue(Hash::check('oldpass123', User::findOrFail($user->webUserId)->password));
|
||||
}
|
||||
|
||||
public function test_auth_dump_restores_users_with_shared_or_missing_email(): void
|
||||
{
|
||||
$path = $this->authDump([
|
||||
['id' => 1, 'username' => 'FIRST', 'email' => 'shared@example.com', 'locked' => 1],
|
||||
['id' => 2, 'username' => 'SECOND', 'email' => 'shared@example.com'],
|
||||
['id' => 3, 'username' => 'THIRD', 'email' => ''],
|
||||
]);
|
||||
$this->artisan('vmangos:import-azerothcore-accounts', ['dump' => $path])->assertSuccessful();
|
||||
$this->assertSame(3, User::whereNull('email')->count());
|
||||
$this->assertDatabaseHas('account', ['username' => 'FIRST', 'email' => 'shared@example.com', 'locked' => true, 'last_ip' => '127.0.0.1'], 'vmangos_auth');
|
||||
$this->assertDatabaseHas('account', ['username' => 'SECOND', 'email' => 'shared@example.com'], 'vmangos_auth');
|
||||
$accounts = app(VMangosAccountService::class);
|
||||
$this->assertTrue($accounts->validatePassword($accounts->findUserByUsername('third'), 'OldPass123'));
|
||||
}
|
||||
|
||||
public function test_auth_dump_conflict_preflight_does_not_partially_import(): void
|
||||
{
|
||||
User::create(['name' => 'Second', 'email' => 'second@example.com', 'password' => 'CurrentPass123']);
|
||||
$path = $this->authDump([['id' => 1, 'username' => 'FIRST'], ['id' => 2, 'username' => 'SECOND']]);
|
||||
$this->artisan('vmangos:import-azerothcore-accounts', ['dump' => $path])
|
||||
->expectsOutput('An unlinked web user already has this name for legacy ID 2; resolve the mapping first.')->assertFailed();
|
||||
$this->assertSame(1, User::count());
|
||||
$this->assertSame(0, DB::connection('vmangos_auth')->table('account')->count());
|
||||
}
|
||||
|
||||
public function test_auth_dump_is_fully_parsed_before_any_writes(): void
|
||||
{
|
||||
$path = $this->authDump([['id' => 42, 'username' => 'SINDORING']]);
|
||||
file_put_contents($path, gzencode(gzdecode(file_get_contents($path)).'INSERT INTO `account` VALUES ('));
|
||||
$this->artisan('vmangos:import-azerothcore-accounts', ['dump' => $path])->assertFailed();
|
||||
$this->assertSame(0, User::count());
|
||||
$this->assertSame(0, DB::connection('vmangos_auth')->table('account')->count());
|
||||
}
|
||||
|
||||
public function test_stale_legacy_password_cannot_overwrite_password_reset(): void
|
||||
{
|
||||
$this->artisan('vmangos:import-azerothcore-accounts', ['dump' => $this->authDump([['id' => 42, 'username' => 'SINDORING']])])->assertSuccessful();
|
||||
$stale = User::firstOrFail();
|
||||
User::findOrFail($stale->id)->update(['password' => 'ResetPass123']);
|
||||
$this->assertFalse(app(WebAccountPasswordService::class)->matches($stale, 'OldPass123'));
|
||||
$this->assertTrue(Hash::check('ResetPass123', $stale->fresh()->password));
|
||||
}
|
||||
|
||||
private function authDump(array $accounts): string
|
||||
{
|
||||
$sql = "USE `acore_auth`;\nDROP TABLE IF EXISTS `users`;\n";
|
||||
foreach ($accounts as $account) {
|
||||
$id = $account['id'];
|
||||
$username = $account['username'];
|
||||
$email = $account['email'] ?? strtolower($username).'@example.com';
|
||||
[$salt, $verifier] = app(AzerothCoreSrpService::class)->makeRegistrationData($username, 'OldPass123');
|
||||
$locked = $account['locked'] ?? 0;
|
||||
$level = $account['gmlevel'] ?? 0;
|
||||
$sql .= "INSERT INTO `account` (`id`,`username`,`email`,`salt`,`verifier`,`joindate`,`locked`,`last_ip`) VALUES ($id,'$username','$email',0x$salt,0x$verifier,'2025-01-02 03:04:05',$locked,'127.0.0.1');\n";
|
||||
$sql .= "INSERT INTO `account_access` (`id`,`gmlevel`,`RealmID`) VALUES ($id,$level,-1);\n";
|
||||
}
|
||||
$path = tempnam(sys_get_temp_dir(), 'auth-dump-');
|
||||
file_put_contents($path, gzencode($sql));
|
||||
$this->dumpFiles[] = $path;
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
public function test_vmangos_dates_use_configured_timezone_in_profile_and_admin(): void
|
||||
{
|
||||
config(['moonwell.timezone' => 'Europe/Samara']);
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Services\AzerothCoreAccountDumpReader;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use RuntimeException;
|
||||
|
||||
class AzerothCoreAccountDumpReaderTest extends TestCase
|
||||
{
|
||||
private array $files = [];
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
foreach ($this->files as $path) {
|
||||
unlink($path);
|
||||
}
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function test_reads_mysqldump_column_order_and_escaped_binary_without_hex_blob(): void
|
||||
{
|
||||
$bytes = "\0\n\r\t\x08\x1a'\"\\".substr(hash('sha256', 'fixture', true), 0, 23);
|
||||
$literal = strtr($bytes, [
|
||||
"\0" => '\\0', "\n" => '\\n', "\r" => '\\r', "\t" => '\\t',
|
||||
"\x08" => '\\b', "\x1a" => '\\Z', "'" => "\\'", '"' => '\\"', '\\' => '\\\\',
|
||||
]);
|
||||
$sql = <<<SQL
|
||||
-- mysqldump without --hex-blob
|
||||
USE `acore_auth`;
|
||||
CREATE TABLE `account` (
|
||||
`id` int unsigned NOT NULL,
|
||||
`username` varchar(32) NOT NULL,
|
||||
`salt` binary(32) NOT NULL,
|
||||
`verifier` binary(32) NOT NULL,
|
||||
`email` varchar(255) DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `email_index` (`email`)
|
||||
) ENGINE=InnoDB;
|
||||
INSERT INTO `account` VALUES (42,'SINDORING',_binary '$literal','$literal',NULL),(43,'SECOND',0x00,X'ff','quoted''mail');
|
||||
CREATE TABLE `account_access` (`id` int NOT NULL, `gmlevel` tinyint NOT NULL, `RealmID` int NOT NULL);
|
||||
INSERT INTO `account_access` VALUES (42,3,-1);
|
||||
SQL;
|
||||
$rows = iterator_to_array((new AzerothCoreAccountDumpReader)->rows($this->file($sql)));
|
||||
$this->assertCount(3, $rows);
|
||||
$this->assertSame('account', $rows[0]['table']);
|
||||
$this->assertSame(['id' => '42', 'username' => 'SINDORING', 'salt' => $bytes, 'verifier' => $bytes, 'email' => null], $rows[0]['row']);
|
||||
$this->assertSame("\0", $rows[1]['row']['salt']);
|
||||
$this->assertSame("\xff", $rows[1]['row']['verifier']);
|
||||
$this->assertSame("quoted'mail", $rows[1]['row']['email']);
|
||||
$this->assertSame(['table' => 'account_access', 'row' => ['id' => '42', 'gmlevel' => '3', 'RealmID' => '-1']], $rows[2]);
|
||||
}
|
||||
|
||||
public function test_gzip_filters_source_database_and_ignores_unrelated_sql(): void
|
||||
{
|
||||
$sql = <<<'SQL'
|
||||
USE `other_auth`;
|
||||
INSERT INTO `account` (`id`,`username`) VALUES (1,'OTHER');
|
||||
DROP DATABASE `moonwell-web`;
|
||||
USE `acore_auth`;
|
||||
INSERT IGNORE INTO `account` (`id`,`username`,`email`) VALUES (42,_utf8mb4 'SINDORING','semicolon;value');
|
||||
INSERT INTO `other_auth`.`account_access` (`id`,`gmlevel`) VALUES (1,3);
|
||||
SQL;
|
||||
$rows = iterator_to_array((new AzerothCoreAccountDumpReader)->rows($this->file(gzencode($sql))));
|
||||
$this->assertSame([['table' => 'account', 'row' => ['id' => '42', 'username' => 'SINDORING', 'email' => 'semicolon;value']]], $rows);
|
||||
}
|
||||
|
||||
#[DataProvider('invalidStatements')]
|
||||
public function test_rejects_unsupported_or_incomplete_account_sql(string $sql): void
|
||||
{
|
||||
$this->expectException(RuntimeException::class);
|
||||
iterator_to_array((new AzerothCoreAccountDumpReader)->rows($this->file($sql)));
|
||||
}
|
||||
|
||||
public static function invalidStatements(): array
|
||||
{
|
||||
return [
|
||||
'missing column definitions' => ["INSERT INTO `account` VALUES (1,'PLAYER');"],
|
||||
'column count mismatch' => ["INSERT INTO `account` (`id`) VALUES (1,'PLAYER');"],
|
||||
'truncated' => ['INSERT INTO `account` (`id`) VALUES (1'],
|
||||
'expressions' => ["INSERT INTO `account` (`salt`) VALUES (UNHEX('abcd'));"],
|
||||
'appended SQL' => ['INSERT INTO `account` (`id`) VALUES (1); DROP DATABASE `web`;'],
|
||||
];
|
||||
}
|
||||
|
||||
private function file(string $contents): string
|
||||
{
|
||||
$path = tempnam(sys_get_temp_dir(), 'auth-parser-');
|
||||
file_put_contents($path, $contents);
|
||||
$this->files[] = $path;
|
||||
|
||||
return $path;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user