diff --git a/.dockerignore b/.dockerignore index 92b0c96..bea6d05 100644 --- a/.dockerignore +++ b/.dockerignore @@ -15,3 +15,5 @@ storage tests compose*.yaml README.md +*.sql +*.sql.gz diff --git a/.gitignore b/.gitignore index 7628482..9f10585 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,8 @@ /.vscode /.zed /auth.json +*.sql.gz +/acore_auth.sql /node_modules /public/build /public/hot diff --git a/README.md b/README.md index e629628..cb22ccb 100644 --- a/README.md +++ b/README.md @@ -111,7 +111,7 @@ npm run build - Список реалмов берётся из `realmd.realmlist`; доступность определяется проверкой игрового TCP-порта. - Игровой клиент: 1.12.1, build 5875. Лаунчер получает одноразовый билет через `POST /api/launcher/game-ticket` с Bearer-токеном и `{"client_build":5875}` и передаёт возвращённые `account` и `ticket` клиенту как логин и пароль. Билет действителен не более 60 секунд; новый билет отзывает предыдущий. Модифицированный `realmd` из `/home/sindo/core` допускает вход в игру только с активным билетом. -Источник импорта — таблица `users` основной базы сайта (`DB_*`, в продакшене `DB_DATABASE=web`). Логин берётся из `users.name`, email — из `users.email`. Подключение к старой базе AzerothCore не требуется. Перед импортом сделайте резервную копию `web` и `realmd`. Если поддержка ticket ещё не установлена, примените `/home/sindo/core/sql/migrations/20261003120000_logon.sql` к `realmd` и пересоберите ядро. Затем выполните: +Источник импорта — таблица `users` основной базы сайта (`DB_*`; используйте фактическое `DB_DATABASE`, например `moonwell-web`). Логин берётся из `users.name`, email — из `users.email`. Подключение к старой базе AzerothCore не требуется. Перед импортом сделайте резервную копию основной базы сайта и `realmd`. Если поддержка ticket ещё не установлена, примените `/home/sindo/core/sql/migrations/20261003120000_logon.sql` к `realmd` и пересоберите ядро. Затем выполните: ```bash ./vendor/bin/sail artisan config:clear @@ -123,6 +123,13 @@ npm run build Команда не меняет `web.users.password`: создаёт игровые аккаунты со случайными паролями и сохраняет связь по ID веб-пользователя. При повторном запуске уже связанные пользователи пропускаются. Имена должны содержать 3–16 латинских букв или цифр; конфликты имён и email требуют исправления до импорта. Существующий игровой аккаунт с тем же именем и совместимым email связывается с веб-пользователем, его игровому паролю задаётся новое случайное значение; игровые права сохраняются. Новые аккаунты получают `VMANGOS_ACCOUNT_GMLEVEL` (по умолчанию 0). При первом импорте команда отзывает прежние API-токены и сессии сайта, поскольку прежние ID аккаунтов могут указывать на другого пользователя. Аккаунты `RNDBOT` пропускаются; для их переноса есть `--include-bots`. +Если `users` в веб-дампе пустая, а прежние аккаунты хранились в AzerothCore, +используйте `php artisan vmangos:import-azerothcore-accounts /путь/acore_auth.sql.gz --dry-run`, +затем повторите без `--dry-run`. Команда создаёт веб-пользователей, сохраняет +возможность входа прежним паролем и создаёт связанные аккаунты vMaNGOS. +При первом успешном входе старый SRP заменяется Laravel-хешем. +[Инструкция для production и описание переноса](docs/restore-azerothcore-accounts.md). + Для отображения даты регистрации и последнего входа в vMaNGOS задайте часовой пояс IANA в `.env`, например: ```dotenv diff --git a/app/Console/Commands/ImportAzerothCoreAccounts.php b/app/Console/Commands/ImportAzerothCoreAccounts.php new file mode 100644 index 0000000..331cc56 --- /dev/null +++ b/app/Console/Commands/ImportAzerothCoreAccounts.php @@ -0,0 +1,203 @@ +rows((string) $this->argument('dump'), (string) $this->option('database')) as $record) { + $row = $record['row']; + if ($record['table'] === 'account_access') { + $id = (int) ($row['id'] ?? 0); + $level = (int) ($row['gmlevel'] ?? 0); + if ($id < 1 || $level < 0 || $level > 255) { + throw new RuntimeException('Invalid account_access row in the auth backup.'); + } + $levels[$id] = max($levels[$id] ?? 0, $level); + + continue; + } + $username = $gameSrp->normalizeUsername((string) ($row['username'] ?? '')); + if (! $this->option('include-bots') && $prefix !== '' && str_starts_with($username, $prefix)) { + $bots++; + + continue; + } + $id = (int) ($row['id'] ?? 0); + if ($id < 1 || preg_match('/^[A-Z0-9]{3,16}$/D', $username) !== 1) { + throw new RuntimeException("Unsupported legacy account ID or name: ID {$id}."); + } + if (isset($accounts[$id]) || isset($names[$username])) { + throw new RuntimeException("Duplicate legacy ID or username in the auth backup: ID {$id}."); + } + if (! isset($row['salt'], $row['verifier']) || strlen($row['salt']) !== 32 || strlen($row['verifier']) !== 32) { + throw new RuntimeException("Missing or invalid AzerothCore SRP credentials for legacy ID {$id}."); + } + $email = strtolower(trim((string) ($row['email'] ?? ''))); + if ($email === '') { + $email = strtolower(trim((string) ($row['reg_mail'] ?? ''))); + } + if (strlen($email) > 255) { + throw new RuntimeException("The source email does not fit web.users for legacy ID {$id}."); + } + $joined = (string) ($row['joindate'] ?? ''); + try { + $joined = $joined !== '' && ! str_starts_with($joined, '0000-00-00') + ? CarbonImmutable::parse($joined, 'UTC')->toDateTimeString() + : now('UTC')->toDateTimeString(); + } catch (\Exception) { + throw new RuntimeException("Invalid registration date for legacy ID {$id}."); + } + $lastIp = (string) ($row['last_ip'] ?? '0.0.0.0'); + if (strlen($lastIp) > 30) { + throw new RuntimeException("The source last_ip does not fit vMaNGOS for legacy ID {$id}."); + } + $accounts[$id] = [ + 'id' => $id, 'username' => $username, 'email' => $email, 'joined' => $joined, + 'locked' => (bool) ($row['locked'] ?? false), 'last_ip' => $lastIp, + 'password' => $passwords->legacyCredential($username, bin2hex($row['salt']), bin2hex($row['verifier'])), + ]; + $names[$username] = $id; + if ($email !== '') { + $emails[$email] = ($emails[$email] ?? 0) + 1; + } + } + if ($accounts === []) { + throw new RuntimeException('No eligible AzerothCore accounts found; check the dump and --database option.'); + } + + [$created, $linked, $skipped, $missingEmails] = $web->transaction(fn (): array => $auth->transaction(function () use ($web, $auth, $gameSrp, $accounts, $levels, $emails): array { + $plans = []; + $created = $linked = $skipped = $missingEmails = 0; + foreach ($accounts as $source) { + $game = $auth->table('account')->where('username', $source['username'])->lockForUpdate()->first(['id', 'email']); + $credential = $game ? $auth->table('site_credentials')->where('account_id', $game->id)->first() : null; + $legacyMapping = $auth->table('site_credentials')->where('legacy_account_id', $source['id'])->first(); + if ($legacyMapping && (! $game || (int) $legacyMapping->account_id !== (int) $game->id)) { + throw new RuntimeException("Legacy ID mapping conflict for ID {$source['id']}."); + } + if ($credential?->legacy_account_id !== null && (int) $credential->legacy_account_id !== $source['id']) { + throw new RuntimeException("Game account belongs to another legacy ID: {$source['id']}."); + } + if ($credential?->web_user_id !== null) { + $existingWeb = $web->table('users')->where('id', $credential->web_user_id)->first(['name']); + if (! $existingWeb || strtoupper(trim($existingWeb->name)) !== $source['username']) { + throw new RuntimeException("Existing web mapping is missing or belongs to another user for legacy ID {$source['id']}."); + } + // Preserve passwords changed after the original migration. + $skipped++; + + continue; + } + if ($web->table('users')->whereRaw('UPPER(name) = ?', [$source['username']])->exists()) { + throw new RuntimeException("An unlinked web user already has this name for legacy ID {$source['id']}; resolve the mapping first."); + } + if ($game && $game->email && $source['email'] !== '' && strcasecmp(trim($game->email), $source['email']) !== 0) { + throw new RuntimeException("Existing game account email differs for legacy ID {$source['id']}."); + } + $webEmail = $source['email']; + if ($webEmail === '' || $emails[$webEmail] > 1 || $web->table('users')->whereRaw('LOWER(email) = ?', [$webEmail])->exists()) { + // Original contact data remains in realmd.account.email. + $webEmail = null; + $missingEmails++; + } + $plans[] = [$source, $game, $credential, $webEmail]; + $game ? $linked++ : $created++; + } + if ($this->option('dry-run')) { + return [$created, $linked, $skipped, $missingEmails]; + } + + foreach ($plans as [$source, $game, $credential, $webEmail]) { + // Insert directly: the User hashed cast would hash the SRP encoding. + $webId = $web->table('users')->insertGetId([ + 'name' => $source['username'], 'email' => $webEmail, 'password' => $source['password'], + 'created_at' => $source['joined'], 'updated_at' => now('UTC'), + ]); + [$salt, $verifier] = $gameSrp->makeRegistrationData($source['username'], bin2hex(random_bytes(16))); + if ($game) { + $accountId = (int) $game->id; + $auth->table('account')->where('id', $accountId)->update([ + 's' => $salt, 'v' => $verifier, 'sessionkey' => null, 'email' => $source['email'], + ]); + $auth->table('launcher_ticket')->where('account_id', $accountId)->delete(); + } else { + $accountId = (int) $auth->table('account')->insertGetId([ + 'username' => $source['username'], 'email' => $source['email'], + 's' => $salt, 'v' => $verifier, 'expansion' => 0, + 'joindate' => $source['joined'], 'locked' => $source['locked'], 'last_ip' => $source['last_ip'], + ]); + $auth->table('account_access')->insert([ + 'id' => $accountId, 'RealmID' => (int) config('moonwell.registration.realm_id'), + 'gmlevel' => $levels[$source['id']] ?? (int) config('moonwell.registration.gmlevel'), + ]); + } + $auth->table('site_credentials')->updateOrInsert(['account_id' => $accountId], [ + 'legacy_account_id' => $source['id'], 'web_user_id' => $webId, + 'salt_hex' => null, 'verifier_hex' => null, + 'created_at' => $credential?->created_at ?? now('UTC'), 'updated_at' => now('UTC'), + ]); + foreach ($auth->table('realmlist')->pluck('id') as $realmId) { + $auth->table('realmcharacters')->insertOrIgnore(['realmid' => (int) $realmId, 'acctid' => $accountId, 'numchars' => 0]); + } + } + if ($plans !== []) { + $web->table('oauth_access_tokens')->where('revoked', false)->update(['revoked' => true]); + $web->table('oauth_refresh_tokens')->where('revoked', false)->update(['revoked' => true]); + if (config('session.driver') === 'database') { + $web->table(config('session.table', 'sessions'))->delete(); + } + } + + return [$created, $linked, $skipped, $missingEmails]; + })); + } catch (QueryException $exception) { + // Do not print INSERT bindings: they contain password verifiers. + $this->error('Database operation failed (SQLSTATE '.$exception->getCode().'). Check migrations and connectivity.'); + + return self::FAILURE; + } catch (RuntimeException $exception) { + $this->error($exception->getMessage()); + + return self::FAILURE; + } + + if (! $this->option('dry-run') && ($created + $linked) > 0 && config('session.driver') === 'file') { + foreach (File::files(config('session.files')) as $file) { + File::delete($file->getPathname()); + } + } + $this->info("Created: {$created}; linked: {$linked}; already imported: {$skipped}; bots skipped: {$bots}."); + if ($missingEmails > 0) { + $this->warn("Web users without a unique email: {$missingEmails}; original emails are retained in game accounts."); + } + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/ImportWebAccountsToVMangos.php b/app/Console/Commands/ImportWebAccountsToVMangos.php index 94f7b71..566925d 100644 --- a/app/Console/Commands/ImportWebAccountsToVMangos.php +++ b/app/Console/Commands/ImportWebAccountsToVMangos.php @@ -63,7 +63,7 @@ class ImportWebAccountsToVMangos extends Command if ($credential?->web_user_id !== null) { throw new RuntimeException("Account already belongs to another web user; web.users ID {$user->id}."); } - if ($existing && $existing->email && strcasecmp(trim($existing->email), trim($user->email)) !== 0) { + if ($existing && $existing->email && $user->email !== null && strcasecmp(trim($existing->email), trim($user->email)) !== 0) { throw new RuntimeException("Existing game account email differs for web.users ID {$user->id}; resolve the conflict before importing."); } diff --git a/app/Services/AzerothCoreAccountDumpReader.php b/app/Services/AzerothCoreAccountDumpReader.php new file mode 100644 index 0000000..b04b4fe --- /dev/null +++ b/app/Services/AzerothCoreAccountDumpReader.php @@ -0,0 +1,201 @@ +}> */ + public function rows(string $path, string $sourceDatabase = 'acore_auth'): Generator + { + if (! is_file($path) || ! is_readable($path)) { + throw new RuntimeException('The auth backup file is not readable.'); + } + $stream = fopen($path, 'rb'); + if ($stream === false) { + throw new RuntimeException('Could not open the auth backup.'); + } + $compressed = fread($stream, 2) === "\x1f\x8b"; + if ($compressed) { + fclose($stream); + $stream = fopen('compress.zlib://'.$path, 'rb'); + } else { + rewind($stream); + } + if ($stream === false) { + throw new RuntimeException('Could not open the auth backup.'); + } + + $database = null; + $schemas = []; + $pending = ''; + $kind = $table = null; + try { + while (($line = fgets($stream)) !== false) { + if ($pending === '') { + if (preg_match('/^\s*USE\s+`([^`]+)`\s*;/i', $line, $match)) { + $database = $match[1]; + + continue; + } + if (! preg_match('/^\s*(CREATE TABLE(?: IF NOT EXISTS)?|INSERT(?: IGNORE)? INTO)\s+(?:`([^`]+)`\.)?`(account|account_access)`(?=\s|\()/i', $line, $match)) { + continue; + } + $statementDatabase = $match[2] !== '' ? $match[2] : $database; + if ($statementDatabase !== null && $statementDatabase !== $sourceDatabase) { + continue; + } + $kind = str_starts_with(strtoupper($match[1]), 'CREATE') ? 'create' : 'insert'; + $table = strtolower($match[3]); + } + $pending .= $line; + if (! preg_match('/;\s*$/D', $line)) { + continue; + } + if ($kind === 'create') { + preg_match_all('/(?:\(\s*|,\s*)`([A-Za-z0-9_]+)`\s+[A-Za-z]/', $pending, $columns); + $schemas[$table] = $columns[1]; + } else { + foreach ($this->insertRows($pending, $schemas[$table] ?? []) as $row) { + yield ['table' => $table, 'row' => $row]; + } + } + $pending = ''; + } + if ($pending !== '') { + throw new RuntimeException('The auth backup has a truncated account SQL statement.'); + } + } finally { + fclose($stream); + } + } + + /** @return Generator> */ + private function insertRows(string $sql, array $schema): Generator + { + if (! preg_match('/^\s*INSERT(?: IGNORE)? INTO\s+(?:`[^`]+`\.)?`(?:account|account_access)`\s*(?:\(([^)]*)\))?\s*VALUES\s*/i', $sql, $header)) { + throw new RuntimeException('Unsupported account INSERT syntax in the auth backup.'); + } + $columns = $schema; + if (isset($header[1]) && trim($header[1]) !== '') { + $columns = array_map(fn (string $value): string => trim($value, " \t\r\n`"), explode(',', $header[1])); + } + if ($columns === [] || count($columns) !== count(array_unique($columns))) { + throw new RuntimeException('The auth backup must include account table definitions or explicit INSERT column names.'); + } + $offset = strlen($header[0]); + $length = strlen($sql); + while ($offset < $length) { + $this->skipWhitespace($sql, $offset); + if (($sql[$offset] ?? '') === ';') { + $offset++; + $this->skipWhitespace($sql, $offset); + if ($offset !== $length) { + throw new RuntimeException('Unexpected SQL after the account INSERT.'); + } + + return; + } + if (($sql[$offset] ?? '') !== '(') { + throw new RuntimeException('Malformed account tuple in the auth backup.'); + } + $offset++; + $values = []; + while (true) { + $values[] = $this->value($sql, $offset); + $this->skipWhitespace($sql, $offset); + $delimiter = $sql[$offset++] ?? ''; + if ($delimiter === ')') { + break; + } + if ($delimiter !== ',') { + throw new RuntimeException('Malformed account value in the auth backup.'); + } + } + if (count($values) !== count($columns)) { + throw new RuntimeException('Account column count differs from the auth backup schema.'); + } + yield array_combine($columns, $values); + $this->skipWhitespace($sql, $offset); + if (($sql[$offset] ?? '') === ',') { + $offset++; + } elseif (($sql[$offset] ?? '') !== ';') { + throw new RuntimeException('Malformed account INSERT ending in the auth backup.'); + } + } + throw new RuntimeException('Unterminated account INSERT in the auth backup.'); + } + + private function value(string $sql, int &$offset): ?string + { + $this->skipWhitespace($sql, $offset); + if (preg_match('/\G(?:_binary|_utf8mb4|_utf8)\s*/i', $sql, $prefix, 0, $offset)) { + $offset += strlen($prefix[0]); + } + $hexQuoted = strtolower($sql[$offset] ?? '') === 'x' && ($sql[$offset + 1] ?? '') === "'"; + if ($hexQuoted) { + $offset++; + } + $quote = $sql[$offset] ?? ''; + if ($quote === "'" || $quote === '"') { + $offset++; + $result = ''; + while ($offset < strlen($sql)) { + $character = $sql[$offset++]; + if ($character === $quote) { + if (($sql[$offset] ?? '') === $quote) { + $result .= $quote; + $offset++; + + continue; + } + if ($hexQuoted) { + if (strlen($result) % 2 !== 0 || ($result !== '' && ! ctype_xdigit($result))) { + throw new RuntimeException('Invalid quoted binary literal in the auth backup.'); + } + + return hex2bin($result); + } + + return $result; + } + if ($character === '\\') { + $escaped = $sql[$offset++] ?? ''; + $result .= match ($escaped) { + '0' => "\0", 'n' => "\n", 'r' => "\r", 't' => "\t", 'b' => "\x08", 'Z' => "\x1a", + '%', '_' => '\\'.$escaped, + default => $escaped, + }; + } else { + $result .= $character; + } + } + throw new RuntimeException('Unterminated account string in the auth backup.'); + } + $start = $offset; + while ($offset < strlen($sql) && $sql[$offset] !== ',' && $sql[$offset] !== ')') { + $offset++; + } + $token = trim(substr($sql, $start, $offset - $start)); + if (strcasecmp($token, 'NULL') === 0) { + return null; + } + if (preg_match('/^0x([0-9a-f]+)$/Di', $token, $match) && strlen($match[1]) % 2 === 0) { + return hex2bin($match[1]); + } + if (preg_match('/^-?\d+(?:\.\d+)?$/D', $token)) { + return $token; + } + throw new RuntimeException('Unsupported account value literal in the auth backup.'); + } + + private function skipWhitespace(string $sql, int &$offset): void + { + while ($offset < strlen($sql) && ctype_space($sql[$offset])) { + $offset++; + } + } +} diff --git a/app/Services/VMangosAccountService.php b/app/Services/VMangosAccountService.php index af61b81..52a7a18 100644 --- a/app/Services/VMangosAccountService.php +++ b/app/Services/VMangosAccountService.php @@ -9,7 +9,6 @@ use App\Support\WowData; use Illuminate\Database\Query\Builder; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; -use Illuminate\Support\Facades\Hash; class VMangosAccountService { @@ -17,7 +16,11 @@ class VMangosAccountService public const ACCOUNT_TAB_BOTS = 'bots'; - public function __construct(private readonly VMangosSrpService $srp, private readonly AzerothCoreSrpService $siteSrp) {} + public function __construct( + private readonly VMangosSrpService $srp, + private readonly AzerothCoreSrpService $siteSrp, + private readonly WebAccountPasswordService $webPasswords, + ) {} public function findUserById(int $accountId): ?GameAccountUser { @@ -58,9 +61,9 @@ class VMangosAccountService public function validatePassword(GameAccountUser $user, string $password): bool { if ($user->webUserId !== null) { - $hash = User::find($user->webUserId)?->password; + $webUser = User::find($user->webUserId); - return $hash !== null && Hash::check($password, $hash); + return $webUser !== null && $this->webPasswords->matches($webUser, $password); } // Compatibility for accounts imported before web.users became the source. diff --git a/app/Services/WebAccountPasswordService.php b/app/Services/WebAccountPasswordService.php new file mode 100644 index 0000000..cef347b --- /dev/null +++ b/app/Services/WebAccountPasswordService.php @@ -0,0 +1,57 @@ +password; + if (! str_starts_with($stored, self::LEGACY_PREFIX)) { + return Hash::check($password, $stored); + } + + if (! preg_match('/^acsrp1:([A-Z0-9]{3,16}):([A-F0-9]{64}):([A-F0-9]{64})$/D', $stored, $parts) + || ! $this->srp->credentialsMatch($parts[1], $password, $parts[2], $parts[3])) { + return false; + } + + // Upgrade only the credential we just verified. A concurrent password + // change must not be overwritten or authenticate the old password. + $updated = DB::connection($user->getConnectionName())->table('users') + ->where('id', $user->id)->where('password', $stored) + ->update(['password' => Hash::make($password), 'updated_at' => now()]); + if ($updated > 0) { + return true; + } + + $fresh = $user->fresh(); + + return $fresh !== null + && ! str_starts_with((string) $fresh->password, self::LEGACY_PREFIX) + && Hash::check($password, $fresh->password); + } +} diff --git a/database/migrations/2026_10_04_000002_allow_missing_email_for_legacy_web_users.php b/database/migrations/2026_10_04_000002_allow_missing_email_for_legacy_web_users.php new file mode 100644 index 0000000..835f494 --- /dev/null +++ b/database/migrations/2026_10_04_000002_allow_missing_email_for_legacy_web_users.php @@ -0,0 +1,27 @@ +string('email')->nullable()->change(); + }); + } + + public function down(): void + { + if (DB::table('users')->whereNull('email')->exists()) { + throw new RuntimeException('Fill missing user emails before rolling back this migration.'); + } + Schema::table('users', function (Blueprint $table): void { + $table->string('email')->nullable(false)->change(); + }); + } +}; diff --git a/docs/restore-azerothcore-accounts.md b/docs/restore-azerothcore-accounts.md new file mode 100644 index 0000000..459b5e6 --- /dev/null +++ b/docs/restore-azerothcore-accounts.md @@ -0,0 +1,129 @@ +# Восстановление аккаунтов из `acore_auth.sql.gz` + +Если в восстановленной базе сайта `users` пустая, а старый сайт создавал +пользователей в `acore_auth.account`, импортируйте аккаунты из резервной копии +авторизации AzerothCore. Команда читает обычный SQL-дамп `mysqldump` или gzip +напрямую. Подключение к старому серверу БД не требуется. + +## Что переносится + +- Логин и прежний пароль для сайта и лаунчера. Исходные `salt`/`verifier` + временно сохраняются в `users.password` в формате `acsrp1:…`. При первом + успешном входе они заменяются Laravel-хешем введённого пароля. +- Новый игровой аккаунт vMaNGOS со случайным техническим паролем и связью + `site_credentials.web_user_id`. Вход в игру выполняется через `launcher_ticket`. +- Дата регистрации, email, `locked`, `last_ip`. Новые игровые аккаунты получают + максимальный `gmlevel` из старого `account_access` для настроенного + `VMANGOS_ACCOUNT_REALM_ID`. Права уже существующих игровых аккаунтов сохраняются. + +AzerothCore проверял пароль без учёта регистра. После первого успешного входа +на сайт или в лаунчер пароль становится чувствительным к регистру: используйте +то написание, с которым выполнен этот вход. + +При пустом, повторяющемся или уже занятом email веб-пользователь создаётся +с `email=NULL`; исходный контакт остаётся в `realmd.account.email`. Если +`account.email` пуст, используется `reg_mail`. Для обычной новой регистрации +email по-прежнему обязателен. + +Персонажи, баны, магазин и игровые балансы из этого дампа не переносятся. +`RNDBOT` пропускаются; `--include-bots` включает их. Имена должны содержать +3–16 латинских букв или цифр. + +## Production + +Выполняйте в каталоге проекта сайта, где лежат `.env`, `compose.prod.yaml` +и `acore_auth.sql.gz`. + +1. Сделайте резервные копии текущей основной базы сайта (`DB_DATABASE`, в данном + развёртывании `moonwell-web`) и vMaNGOS `realmd`. +2. Доставьте обновлённый код на сервер и пересоберите контейнеры: + + ```bash + ./scripts/deploy-production.sh + ``` + + Если используется SSH-туннель к ядру, можно указать + `./scripts/deploy-production.sh --ssh-tunnel`. Скрипт сам включает этот режим + при `VMANGOS_AUTH_DB_HOST=core-db-tunnel`. Он также выполняет миграции, + включая разрешение пустого email в `users`. + +3. Задайте функцию для команд Compose: + + ```bash + prod() { docker compose -f compose.prod.yaml "$@"; } + ``` + + Для SSH-туннеля используйте **вместо неё**: + + ```bash + prod() { docker compose -f compose.prod.yaml -f compose.prod.ssh-tunnel.yaml "$@"; } + ``` + +4. Проверьте gzip и скопируйте дамп в PHP-контейнер: + + ```bash + gzip -t acore_auth.sql.gz + prod cp acore_auth.sql.gz app:/tmp/acore_auth.sql.gz + prod exec -T --user root app sh -c 'chown app:app /tmp/acore_auth.sql.gz && chmod 600 /tmp/acore_auth.sql.gz' + ``` + + Файл из корня проекта на хосте сам по себе не виден внутри production + контейнера. Дампы исключены из Docker-образа и Git. + +5. Выполните проверку без записи: + + ```bash + prod exec -T --user app app php artisan vmangos:import-azerothcore-accounts /tmp/acore_auth.sql.gz --dry-run + ``` + + Ожидаемый результат: `Created: N; linked: N; already imported: N; bots skipped: N.` + `Created` — новые игровые аккаунты, `linked` — существующие игровые аккаунты, + для которых будет создан веб-пользователь. При конфликтах команда выводит + причину и завершается с ошибкой до начала записи. Если имя исходной базы + отличается от `acore_auth`, добавьте `--database=ИМЯ_ИСХОДНОЙ_БАЗЫ`. + +6. На время записи приостановите вход и регистрацию на сайте: + + ```bash + prod exec -T --user app app php artisan down + prod exec -T --user app app php artisan vmangos:import-azerothcore-accounts /tmp/acore_auth.sql.gz + ``` + + После завершения включите сайт и удалите копию дампа из контейнера: + + ```bash + prod exec -T --user app app php artisan up + prod exec -T --user app app rm /tmp/acore_auth.sql.gz + ``` + + Если импорт завершился ошибкой, сохраните вывод для диагностики; `artisan up` + возвращает сайт из режима обслуживания. Конфликты проверяются заранее, + но две базы на разных серверах не имеют общей транзакции: при сбое соединения + во время записи проверьте обе базы перед повторным запуском. + +7. Войдите на сайт прежним логином и паролем. После первого входа пользователя + поле `users.password` станет обычным Laravel-хешем. + +## Повторный запуск + +Уже связанные с существующим веб-пользователем игровые аккаунты пропускаются. +Команда сохраняет их текущие пароли и права. Она не заменяет пароль существующего +несвязанного веб-пользователя: такой конфликт нужно разрешить отдельно. + +При создании связей отзываются прежние API-токены и сессии сайта. При связывании +существующего игрового аккаунта меняется его технический игровой пароль, +очищаются `sessionkey` и активный `launcher_ticket`. + +Отдельно запускать `vmangos:import-web-accounts` после этой команды не нужно: +веб-пользователи и связи с vMaNGOS уже созданы. + +## Локальная проверка + +```bash +./vendor/bin/sail artisan migrate --force +./vendor/bin/sail artisan vmangos:import-azerothcore-accounts /var/www/html/acore_auth.sql.gz --dry-run +./vendor/bin/sail artisan vmangos:import-azerothcore-accounts /var/www/html/acore_auth.sql.gz +``` + +Sail монтирует рабочий каталог проекта целиком, поэтому локальный дамп в корне +проекта доступен по этому пути. diff --git a/tests/Feature/VMangosAccountIntegrationTest.php b/tests/Feature/VMangosAccountIntegrationTest.php index f607ed5..5adc4d7 100644 --- a/tests/Feature/VMangosAccountIntegrationTest.php +++ b/tests/Feature/VMangosAccountIntegrationTest.php @@ -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']); diff --git a/tests/Unit/AzerothCoreAccountDumpReaderTest.php b/tests/Unit/AzerothCoreAccountDumpReaderTest.php new file mode 100644 index 0000000..51117c7 --- /dev/null +++ b/tests/Unit/AzerothCoreAccountDumpReaderTest.php @@ -0,0 +1,95 @@ +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 = <<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; + } +}