diff --git a/.dockerignore b/.dockerignore index f64e0c7..92b0c96 100644 --- a/.dockerignore +++ b/.dockerignore @@ -11,12 +11,7 @@ public/build public/hot public/storage bootstrap/cache/*.php -storage/*.key -storage/backups -storage/logs/* -storage/framework/cache/data/* -storage/framework/sessions/* -storage/framework/views/* +storage tests compose*.yaml README.md diff --git a/.env.example b/.env.example index 3caa163..72b981d 100644 --- a/.env.example +++ b/.env.example @@ -82,6 +82,8 @@ WWWGROUP=1000 SAIL_XDEBUG_MODE=off SAIL_XDEBUG_CONFIG="client_host=host.docker.internal" +# Display timezone for vMaNGOS account dates; database and tickets use UTC. +VMANGOS_TIMEZONE=UTC VMANGOS_AUTH_CONNECTION=vmangos_auth VMANGOS_DOCKER_NETWORK=vmangos-local_default VMANGOS_AUTH_DB_HOST=db @@ -89,11 +91,6 @@ VMANGOS_AUTH_DB_PORT=3306 VMANGOS_AUTH_DB_DATABASE=realmd VMANGOS_AUTH_DB_USERNAME=vmangos VMANGOS_AUTH_DB_PASSWORD=change-me -LEGACY_AUTH_DB_HOST=ac-database -LEGACY_AUTH_DB_PORT=3306 -LEGACY_AUTH_DB_DATABASE=acore_auth -LEGACY_AUTH_DB_USERNAME=root -LEGACY_AUTH_DB_PASSWORD= VMANGOS_AUTH_DB_SOCKET= VMANGOS_AUTH_DB_CHARSET=utf8mb4 VMANGOS_AUTH_DB_COLLATION=utf8mb4_unicode_ci diff --git a/README.md b/README.md index 299688d..1b302ed 100644 --- a/README.md +++ b/README.md @@ -51,25 +51,42 @@ npm run build нуждаются в отдельных наборах файлов и настройках манифеста: выбор ОС API пока не выполняет. -- Регистрация создаёт случайный постоянный игровой пароль в `account.s`/`account.v`, SRP-данные пароля сайта в `site_credentials`, запись в `realmcharacters` для каждого реалма и доступ в `account_access`. -- Вход на сайт и через API лаунчера сверяет пароль с `site_credentials`. Смена пароля в кабинете обновляет только эти данные. +- Регистрация сохраняет пользователя и хеш пароля в `web.users`, создаёт случайный постоянный игровой пароль в `account.s`/`account.v`, связь `site_credentials.web_user_id`, запись в `realmcharacters` для каждого реалма и доступ в `account_access`. +- Вход на сайт и через API лаунчера проверяет хеш `web.users.password` для связанного пользователя. Смена пароля в кабинете обновляет `web.users.password`, сохраняя игровой пароль. Для аккаунтов, импортированных предыдущей версией, до связывания с `web.users` поддерживаются уже сохранённые данные `site_credentials`; отдельная старая база для этого не нужна. - Админка меняет уровень доступа в `account_access`. Для чтения персонажей используется `characters.played_time_total`. - Список реалмов берётся из `realmd.realmlist`; доступность определяется проверкой игрового TCP-порта. - Игровой клиент: 1.12.1, build 5875. Лаунчер получает одноразовый билет через `POST /api/launcher/game-ticket` с Bearer-токеном и `{"client_build":5875}` и передаёт возвращённые `account` и `ticket` клиенту как логин и пароль. Билет действителен не более 60 секунд; новый билет отзывает предыдущий. Модифицированный `realmd` из `/home/sindo/core` допускает вход в игру только с активным билетом. -Для импорта аккаунтов прежнего сайта из AzerothCore настройте `LEGACY_AUTH_DB_*`, затем выполните: +Источник импорта — таблица `users` основной базы сайта (`DB_*`, в продакшене `DB_DATABASE=web`). Логин берётся из `users.name`, email — из `users.email`. Подключение к старой базе AzerothCore не требуется. Перед импортом сделайте резервную копию `web` и `realmd`. Если поддержка ticket ещё не установлена, примените `/home/sindo/core/sql/migrations/20261003120000_logon.sql` к `realmd` и пересоберите ядро. Затем выполните: ```bash +./vendor/bin/sail artisan config:clear ./vendor/bin/sail artisan migrate --force ./vendor/bin/sail artisan vmangos:import-web-accounts --dry-run ./vendor/bin/sail artisan vmangos:import-web-accounts +./vendor/bin/sail artisan config:cache ``` -Команда переносит веб-верификаторы без знания исходных паролей, создаёт игровые аккаунты со случайными паролями и при повторном запуске пропускает уже перенесённых пользователей. При первом импорте она отзывает прежние API-токены и сессии сайта, поскольку прежние ID аккаунтов могут указывать на другого пользователя. Аккаунты `RNDBOT` пропускаются; для их переноса есть `--include-bots`. Перед запуском `realmd` примените миграцию `/home/sindo/core/sql/migrations/20261003120000_logon.sql` к `realmd` и пересоберите ядро. +Команда не меняет `web.users.password`: создаёт игровые аккаунты со случайными паролями и сохраняет связь по ID веб-пользователя. При повторном запуске уже связанные пользователи пропускаются. Имена должны содержать 3–16 латинских букв или цифр; конфликты имён и email требуют исправления до импорта. Существующий игровой аккаунт с тем же именем и совместимым email связывается с веб-пользователем, его игровому паролю задаётся новое случайное значение; игровые права сохраняются. Новые аккаунты получают `VMANGOS_ACCOUNT_GMLEVEL` (по умолчанию 0). При первом импорте команда отзывает прежние API-токены и сессии сайта, поскольку прежние ID аккаунтов могут указывать на другого пользователя. Аккаунты `RNDBOT` пропускаются; для их переноса есть `--include-bots`. + +Для отображения даты регистрации и последнего входа в vMaNGOS задайте часовой пояс IANA в `.env`, например: + +```dotenv +VMANGOS_TIMEZONE=Europe/Samara +``` + +По умолчанию используется `UTC`. Подключения к БД vMaNGOS читают и записывают время в UTC; настройка меняет отображение дат в кабинете и админке. Срок действия `launcher_ticket` всегда рассчитывается в UTC. После изменения выполните `php artisan config:cache` (или `./vendor/bin/sail artisan config:cache` для Sail). Настройка не меняет часовой пояс ОС ядра и код зоны реалма `realmlist.timezone`. Магазин по-прежнему использует отдельную базу `store` через `STORE_DB_*`. Эти таблицы не входят в vMaNGOS. Игровая валюта сайта хранится в дополнительных таблицах `realmd` после миграции Laravel; для работы внутри игры нужна отдельная интеграция ядра с API из [`docs/game-balance-integration.md`](docs/game-balance-integration.md). -Перед выдачей клиента загрузите архив 1.12.1 в S3 и укажите его ключ в `MOONWELL_CLIENT_OBJECT_KEY`. Для обновлений лаунчера разместите файлы под `MOONWELL_LAUNCHER_BASE_PATH` и новый манифест по ключу `MOONWELL_LAUNCHER_MANIFEST_KEY`. Старые файлы клиента 3.3.5a для vMaNGOS не подходят. +Кнопка «Скачать лаунчер» использует `MOONWELL_CLIENT_OBJECT_KEY`: укажите +`moonwell_launcher_setup.exe`, чтобы выдавать Windows-установщик из S3. +После изменения выполните `php artisan config:clear` или пересоберите кеш +конфигурации production-приложения. Для обновлений самого лаунчера служит +подписанный `/appcast.xml`; он ссылается на установщик в каталоге +`moonwell_launcher/releases//`. Игровые файлы и их манифест используют +отдельные `MOONWELL_LAUNCHER_BASE_PATH` и `MOONWELL_LAUNCHER_MANIFEST_KEY`, как +описано выше. Старые файлы клиента 3.3.5a для vMaNGOS не подходят. ## Проверка diff --git a/app/Console/Commands/ImportWebAccountsToVMangos.php b/app/Console/Commands/ImportWebAccountsToVMangos.php index b6f2973..94f7b71 100644 --- a/app/Console/Commands/ImportWebAccountsToVMangos.php +++ b/app/Console/Commands/ImportWebAccountsToVMangos.php @@ -3,6 +3,7 @@ namespace App\Console\Commands; use App\Services\VMangosSrpService; +use Carbon\CarbonImmutable; use Illuminate\Console\Command; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\File; @@ -12,102 +13,123 @@ class ImportWebAccountsToVMangos extends Command { protected $signature = 'vmangos:import-web-accounts {--dry-run : Show counts without writing} {--include-bots : Import RNDBOT accounts too}'; - protected $description = 'Create vMaNGOS accounts for existing web users, preserving their web passwords and using random game passwords'; + protected $description = 'Create vMaNGOS accounts from web.users with random game passwords; keep web passwords unchanged'; public function handle(VMangosSrpService $srp): int { - $source = DB::connection(config('moonwell.legacy_auth_connection')); + $source = DB::connection(); $target = DB::connection(config('moonwell.auth_connection')); - $query = $source->table('account')->selectRaw('id, username, email, locked, joindate, HEX(salt) AS salt_hex, HEX(verifier) AS verifier_hex')->orderBy('id'); - - if (! $this->option('include-bots')) { - $query->where('username', 'not like', config('moonwell.portal.bot_account_prefix').'%'); + $query = $source->table('users')->select('id', 'name', 'email', 'created_at')->orderBy('id'); + $prefix = strtoupper(trim((string) config('moonwell.portal.bot_account_prefix'))); + if (! $this->option('include-bots') && $prefix !== '') { + $query->whereRaw('UPPER(name) NOT LIKE ?', [$prefix.'%']); } - $created = 0; - $linked = 0; - $skipped = 0; - foreach ($query->cursor() as $legacy) { - $username = $srp->normalizeUsername($legacy->username); + $users = $query->get(); + $names = []; + foreach ($users as $user) { + $username = $srp->normalizeUsername($user->name); if (preg_match('/^[A-Z0-9]{3,16}$/D', $username) !== 1) { - throw new RuntimeException("Unsupported legacy account name for ID {$legacy->id}."); + $this->error("Unsupported web.users name for ID {$user->id}; expected 3–16 Latin letters or digits."); + + return self::FAILURE; } + if (isset($names[$username])) { + $this->error("Duplicate normalized username for web.users IDs {$names[$username]} and {$user->id}."); - $existing = $target->table('account')->where('username', $username)->first(['id']); - $credential = $existing ? $target->table('site_credentials')->where('account_id', $existing->id)->first() : null; - if ($credential) { - if ((int) $credential->legacy_account_id !== (int) $legacy->id) { - throw new RuntimeException("Account name conflict for legacy ID {$legacy->id}."); - } - $skipped++; - continue; + return self::FAILURE; } - - if ($this->option('dry-run')) { - $existing ? $linked++ : $created++; - continue; - } - - $target->transaction(function () use ($target, $source, $srp, $legacy, $username, $existing): void { - // Only the SRP verifier is stored; the random plaintext is discarded. - [$gameSalt, $gameVerifier] = $srp->makeRegistrationData($username, bin2hex(random_bytes(16))); - if ($existing) { - $accountId = (int) $existing->id; - $target->table('account')->where('id', $accountId)->update([ - 's' => $gameSalt, - 'v' => $gameVerifier, - 'sessionkey' => null, - 'email' => $legacy->email, - 'locked' => (int) $legacy->locked, - ]); - } else { - $accountId = (int) $target->table('account')->insertGetId([ - 'username' => $username, - 's' => $gameSalt, - 'v' => $gameVerifier, - 'email' => $legacy->email, - 'locked' => (int) $legacy->locked, - 'expansion' => 0, - 'joindate' => $legacy->joindate, - ]); - } - - $target->table('site_credentials')->insert([ - 'account_id' => $accountId, - 'legacy_account_id' => (int) $legacy->id, - 'salt_hex' => $legacy->salt_hex, - 'verifier_hex' => $legacy->verifier_hex, - 'created_at' => now(), - 'updated_at' => now(), - ]); - - foreach ($target->table('realmlist')->pluck('id') as $realmId) { - $target->table('realmcharacters')->insertOrIgnore(['realmid' => (int) $realmId, 'acctid' => $accountId, 'numchars' => 0]); - } - - $gmLevel = (int) $source->table('account_access')->where('id', $legacy->id)->max('gmlevel'); - if ($gmLevel > 0) { - $target->table('account_access')->updateOrInsert( - ['id' => $accountId, 'RealmID' => -1], - ['gmlevel' => $gmLevel], - ); - } - }, 3); - - $existing ? $linked++ : $created++; + $names[$username] = $user->id; } - if (! $this->option('dry-run') && ($created + $linked) > 0) { - // Old bearer tokens and sessions contain AzerothCore IDs, which may - // now identify a different vMaNGOS account. Force fresh login. - DB::table('oauth_access_tokens')->where('revoked', false)->update(['revoked' => true]); - DB::table('oauth_refresh_tokens')->where('revoked', false)->update(['revoked' => true]); - if (config('session.driver') === 'database') { - DB::table(config('session.table', 'sessions'))->delete(); - } elseif (config('session.driver') === 'file') { - foreach (File::files(config('session.files')) as $sessionFile) { - File::delete($sessionFile->getPathname()); + try { + [$created, $linked, $skipped] = $source->transaction(fn (): array => $target->transaction(function () use ($target, $source, $srp, $users): array { + $created = $linked = $skipped = 0; + $plans = []; + foreach ($users as $user) { + $username = $srp->normalizeUsername($user->name); + $mapping = $target->table('site_credentials')->where('web_user_id', $user->id)->first(); + $existing = $target->table('account')->where('username', $username)->lockForUpdate()->first(['id', 'email']); + if ($mapping) { + if (! $existing || (int) $mapping->account_id !== (int) $existing->id) { + throw new RuntimeException("Account name changed or mapping conflict for web.users ID {$user->id}."); + } + $skipped++; + + continue; + } + + $credential = $existing ? $target->table('site_credentials')->where('account_id', $existing->id)->first() : null; + 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) { + throw new RuntimeException("Existing game account email differs for web.users ID {$user->id}; resolve the conflict before importing."); + } + + $existing ? $linked++ : $created++; + $plans[] = [$user, $username, $existing, $credential]; } + + if ($this->option('dry-run')) { + return [$created, $linked, $skipped]; + } + + foreach ($plans as [$user, $username, $existing, $credential]) { + // Discard the random plaintext. Web authentication reads users.password. + [$salt, $verifier] = $srp->makeRegistrationData($username, bin2hex(random_bytes(16))); + if ($existing) { + $accountId = (int) $existing->id; + $target->table('account')->where('id', $accountId)->update([ + 's' => $salt, 'v' => $verifier, 'sessionkey' => null, 'email' => $user->email, + ]); + $target->table('launcher_ticket')->where('account_id', $accountId)->delete(); + } else { + $accountId = (int) $target->table('account')->insertGetId([ + 'username' => $username, 's' => $salt, 'v' => $verifier, + 'email' => $user->email, 'expansion' => 0, + 'joindate' => $user->created_at + ? CarbonImmutable::parse($user->created_at, config('app.timezone'))->utc()->toDateTimeString() + : now('UTC')->toDateTimeString(), + ]); + $target->table('account_access')->insert([ + 'id' => $accountId, + 'RealmID' => (int) config('moonwell.registration.realm_id'), + 'gmlevel' => (int) config('moonwell.registration.gmlevel'), + ]); + } + + $target->table('site_credentials')->updateOrInsert(['account_id' => $accountId], [ + 'web_user_id' => (int) $user->id, + 'salt_hex' => null, 'verifier_hex' => null, + 'created_at' => $credential?->created_at ?? now('UTC'), + 'updated_at' => now('UTC'), + ]); + foreach ($target->table('realmlist')->pluck('id') as $realmId) { + $target->table('realmcharacters')->insertOrIgnore(['realmid' => (int) $realmId, 'acctid' => $accountId, 'numchars' => 0]); + } + } + + if (! $this->option('dry-run') && ($created + $linked) > 0) { + // Old sessions contain IDs from the former authentication source. + $source->table('oauth_access_tokens')->where('revoked', false)->update(['revoked' => true]); + $source->table('oauth_refresh_tokens')->where('revoked', false)->update(['revoked' => true]); + if (config('session.driver') === 'database') { + $source->table(config('session.table', 'sessions'))->delete(); + } + } + + return [$created, $linked, $skipped]; + })); + } 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 $sessionFile) { + File::delete($sessionFile->getPathname()); } } diff --git a/app/Http/Controllers/Api/LauncherAuthController.php b/app/Http/Controllers/Api/LauncherAuthController.php index a50b828..179a853 100644 --- a/app/Http/Controllers/Api/LauncherAuthController.php +++ b/app/Http/Controllers/Api/LauncherAuthController.php @@ -13,8 +13,7 @@ class LauncherAuthController extends Controller { public function __construct( private readonly VMangosAccountService $accounts, - ) { - } + ) {} #[OA\Post( path: '/api/launcher/login', @@ -27,7 +26,7 @@ class LauncherAuthController extends Controller required: ['username', 'password'], properties: [ new OA\Property(property: 'username', type: 'string', minLength: 3, maxLength: 16, pattern: '^[A-Za-z0-9]+$', example: 'Player'), - new OA\Property(property: 'password', type: 'string', minLength: 3, maxLength: 16, example: 'secret123'), + new OA\Property(property: 'password', type: 'string', minLength: 3, maxLength: 255, example: 'secret123'), ], ), ), diff --git a/app/Http/Requests/ChangePasswordRequest.php b/app/Http/Requests/ChangePasswordRequest.php index e77cdc7..6fb655f 100644 --- a/app/Http/Requests/ChangePasswordRequest.php +++ b/app/Http/Requests/ChangePasswordRequest.php @@ -2,6 +2,7 @@ namespace App\Http\Requests; +use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; class ChangePasswordRequest extends FormRequest @@ -12,12 +13,12 @@ class ChangePasswordRequest extends FormRequest } /** - * @return array|string> + * @return array|string> */ public function rules(): array { return [ - 'current_password' => ['required', 'string', 'max:16'], + 'current_password' => ['required', 'string', 'max:255'], 'password' => ['required', 'string', 'min:8', 'max:16', 'confirmed', 'regex:/^(?=.*[A-Za-z])(?=.*\d).+$/'], ]; } @@ -29,7 +30,7 @@ class ChangePasswordRequest extends FormRequest { return [ 'current_password.required' => 'Укажи текущий пароль.', - 'current_password.max' => 'Текущий пароль не должен быть длиннее 16 символов.', + 'current_password.max' => 'Текущий пароль не должен быть длиннее 255 символов.', 'password.required' => 'Укажи новый пароль.', 'password.min' => 'Новый пароль должен содержать минимум 8 символов.', 'password.max' => 'Новый пароль не должен быть длиннее 16 символов.', diff --git a/app/Http/Requests/LoginRequest.php b/app/Http/Requests/LoginRequest.php index e757504..e6cd05a 100644 --- a/app/Http/Requests/LoginRequest.php +++ b/app/Http/Requests/LoginRequest.php @@ -2,6 +2,7 @@ namespace App\Http\Requests; +use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; class LoginRequest extends FormRequest @@ -12,13 +13,13 @@ class LoginRequest extends FormRequest } /** - * @return array|string> + * @return array|string> */ public function rules(): array { return [ 'username' => ['required', 'string', 'min:3', 'max:16', 'regex:/^[A-Za-z0-9]+$/'], - 'password' => ['required', 'string', 'min:3', 'max:16'], + 'password' => ['required', 'string', 'min:3', 'max:255'], ]; } @@ -34,7 +35,7 @@ class LoginRequest extends FormRequest 'username.regex' => 'Логин может содержать только латинские буквы и цифры.', 'password.required' => 'Укажи пароль.', 'password.min' => 'Пароль должен содержать минимум 3 символа.', - 'password.max' => 'Пароль не должен быть длиннее 16 символов.', + 'password.max' => 'Пароль не должен быть длиннее 255 символов.', ]; } } diff --git a/app/Models/GameAccountUser.php b/app/Models/GameAccountUser.php index 3498d87..f130906 100644 --- a/app/Models/GameAccountUser.php +++ b/app/Models/GameAccountUser.php @@ -2,9 +2,9 @@ namespace App\Models; +use App\Support\VMangosTime; use App\Support\WowData; use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract; -use Illuminate\Support\Carbon; class GameAccountUser implements AuthenticatableContract { @@ -19,8 +19,8 @@ class GameAccountUser implements AuthenticatableContract public readonly ?string $joinedAt, public readonly string $saltHex, public readonly string $verifierHex, - ) { - } + public readonly ?int $webUserId = null, + ) {} public function getAuthIdentifierName(): string { @@ -47,9 +47,7 @@ class GameAccountUser implements AuthenticatableContract return ''; } - public function setRememberToken($value): void - { - } + public function setRememberToken($value): void {} public function getRememberTokenName(): string { @@ -78,8 +76,6 @@ class GameAccountUser implements AuthenticatableContract private function formatDate(?string $value): string { - return $value && ! str_starts_with($value, '0000-00-00') - ? Carbon::parse($value)->format('d.m.Y H:i') - : '—'; + return VMangosTime::format($value); } } diff --git a/app/Services/VMangosAccountRegistrar.php b/app/Services/VMangosAccountRegistrar.php index 4779a30..ae54586 100644 --- a/app/Services/VMangosAccountRegistrar.php +++ b/app/Services/VMangosAccountRegistrar.php @@ -3,15 +3,14 @@ namespace App\Services; use App\Exceptions\DuplicateGameAccountException; +use App\Models\User; use Illuminate\Database\ConnectionInterface; use Illuminate\Database\QueryException; use Illuminate\Support\Facades\DB; class VMangosAccountRegistrar { - public function __construct(private readonly VMangosSrpService $srp, private readonly AzerothCoreSrpService $siteSrp) - { - } + public function __construct(private readonly VMangosSrpService $srp) {} /** * @return array{id: int, username: string} @@ -19,21 +18,18 @@ class VMangosAccountRegistrar public function register(string $username, string $email, string $password): array { $normalizedUsername = $this->srp->normalizeUsername($username); - $normalizedPassword = $this->srp->normalizePassword($password); - $normalizedEmail = $this->srp->normalizeEmail($email); + $normalizedEmail = strtolower(trim($email)); [$saltHex, $verifierHex] = $this->srp->makeRegistrationData($normalizedUsername, bin2hex(random_bytes(16))); - [$siteSaltHex, $siteVerifierHex] = $this->siteSrp->makeRegistrationData($normalizedUsername, $normalizedPassword); $connection = DB::connection(config('moonwell.auth_connection')); - return $connection->transaction(function () use ( + return DB::transaction(fn (): array => $connection->transaction(function () use ( $connection, $normalizedEmail, $normalizedUsername, $saltHex, $verifierHex, - $siteSaltHex, - $siteVerifierHex, + $password, ): array { if ($this->accountExists($connection, 'username', $normalizedUsername)) { throw DuplicateGameAccountException::forUsername(); @@ -47,6 +43,28 @@ class VMangosAccountRegistrar throw DuplicateGameAccountException::forEmail(); } + if (User::whereRaw('UPPER(name) = ?', [$normalizedUsername])->exists()) { + throw DuplicateGameAccountException::forUsername(); + } + + if (User::whereRaw('LOWER(email) = ?', [$normalizedEmail])->exists()) { + throw DuplicateGameAccountException::forEmail(); + } + + try { + $webUser = User::create([ + 'name' => $normalizedUsername, + 'email' => $normalizedEmail, + 'password' => $password, + ]); + } catch (QueryException $exception) { + if ($this->isDuplicateKeyException($exception)) { + throw DuplicateGameAccountException::forEmail(); + } + + throw $exception; + } + try { $accountId = (int) $connection->table('account')->insertGetId([ 'username' => $normalizedUsername, @@ -54,7 +72,7 @@ class VMangosAccountRegistrar 'v' => $verifierHex, 'expansion' => 0, 'email' => $normalizedEmail, - 'joindate' => now()->toDateTimeString(), + 'joindate' => now('UTC')->toDateTimeString(), ]); } catch (QueryException $exception) { if ($this->isDuplicateKeyException($exception)) { @@ -66,8 +84,7 @@ class VMangosAccountRegistrar $connection->table('site_credentials')->insert([ 'account_id' => $accountId, - 'salt_hex' => $siteSaltHex, - 'verifier_hex' => $siteVerifierHex, + 'web_user_id' => $webUser->id, 'created_at' => now(), 'updated_at' => now(), ]); @@ -89,7 +106,7 @@ class VMangosAccountRegistrar 'id' => $accountId, 'username' => $normalizedUsername, ]; - }, 3); + })); } private function accountExists(ConnectionInterface $connection, string $column, string $value): bool diff --git a/app/Services/VMangosAccountService.php b/app/Services/VMangosAccountService.php index 2ec9d32..af61b81 100644 --- a/app/Services/VMangosAccountService.php +++ b/app/Services/VMangosAccountService.php @@ -3,11 +3,13 @@ namespace App\Services; use App\Models\GameAccountUser; +use App\Models\User; +use App\Support\VMangosTime; use App\Support\WowData; use Illuminate\Database\Query\Builder; -use Illuminate\Support\Carbon; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Hash; class VMangosAccountService { @@ -15,16 +17,14 @@ 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) {} public function findUserById(int $accountId): ?GameAccountUser { $record = $this->authConnection() ->table('account as a') ->join('site_credentials as c', 'c.account_id', '=', 'a.id') - ->selectRaw('a.id, a.username, a.email, a.locked, a.last_login, a.joindate, c.salt_hex, c.verifier_hex') + ->selectRaw('a.id, a.username, a.email, a.locked, a.last_login, a.joindate, c.salt_hex, c.verifier_hex, c.web_user_id') ->where('a.id', $accountId) ->first(); @@ -42,7 +42,7 @@ class VMangosAccountService $record = $this->authConnection() ->table('account as a') ->join('site_credentials as c', 'c.account_id', '=', 'a.id') - ->selectRaw('a.id, a.username, a.email, a.locked, a.last_login, a.joindate, c.salt_hex, c.verifier_hex') + ->selectRaw('a.id, a.username, a.email, a.locked, a.last_login, a.joindate, c.salt_hex, c.verifier_hex, c.web_user_id') ->where('a.username', $this->srp->normalizeUsername($username)) ->first(); @@ -57,6 +57,13 @@ class VMangosAccountService public function validatePassword(GameAccountUser $user, string $password): bool { + if ($user->webUserId !== null) { + $hash = User::find($user->webUserId)?->password; + + return $hash !== null && Hash::check($password, $hash); + } + + // Compatibility for accounts imported before web.users became the source. if ($user->saltHex === '' || $user->verifierHex === '') { return false; } @@ -66,6 +73,12 @@ class VMangosAccountService public function updatePassword(GameAccountUser $user, string $newPassword): void { + if ($user->webUserId !== null) { + User::findOrFail($user->webUserId)->update(['password' => $newPassword]); + + return; + } + [$saltHex, $verifierHex] = $this->siteSrp->makeRegistrationData($user->username, $newPassword); $this->authConnection()->update( @@ -75,7 +88,7 @@ class VMangosAccountService } /** - * @return \Illuminate\Support\Collection> + * @return Collection> */ public function listAccounts(?string $search = null, string $accountTab = self::ACCOUNT_TAB_PLAYERS): Collection { @@ -126,7 +139,7 @@ class VMangosAccountService } /** - * @return \Illuminate\Support\Collection> + * @return Collection> */ public function charactersForAccount(int $accountId): Collection { @@ -145,7 +158,7 @@ class VMangosAccountService /** * @param array $accountIds - * @return \Illuminate\Support\Collection>> + * @return Collection>> */ private function charactersForAccountIds(array $accountIds): Collection { @@ -209,25 +222,26 @@ class VMangosAccountService private function mapUser(object $record, int $gmLevel): GameAccountUser { + $webUser = $record->web_user_id !== null ? User::find($record->web_user_id) : null; + return new GameAccountUser( id: (int) $record->id, username: $record->username, - email: (string) $record->email, - regMail: (string) $record->email, + email: (string) ($webUser?->email ?? $record->email), + regMail: (string) ($webUser?->email ?? $record->email), gmLevel: $gmLevel, locked: (bool) $record->locked, lastLoginAt: $record->last_login, joinedAt: $record->joindate, saltHex: strtoupper((string) $record->salt_hex), verifierHex: strtoupper((string) $record->verifier_hex), + webUserId: $record->web_user_id !== null ? (int) $record->web_user_id : null, ); } private function formatDate(?string $value): string { - return $value && ! str_starts_with($value, '0000-00-00') - ? Carbon::parse($value)->format('d.m.Y H:i') - : '—'; + return VMangosTime::format($value); } private function baseAccountsQuery(?string $search = null): Builder @@ -279,7 +293,7 @@ class VMangosAccountService return (int) config('moonwell.registration.realm_id', -1); } - public function onlinePlayerCount(): int|null + public function onlinePlayerCount(): ?int { try { return (int) $this->charactersConnection() diff --git a/app/Support/VMangosTime.php b/app/Support/VMangosTime.php new file mode 100644 index 0000000..ef2c115 --- /dev/null +++ b/app/Support/VMangosTime.php @@ -0,0 +1,16 @@ +setTimezone(config('moonwell.timezone', 'UTC'))->format('d.m.Y H:i') + : '—'; + } +} diff --git a/config/database.php b/config/database.php index 96e3528..bb11a97 100644 --- a/config/database.php +++ b/config/database.php @@ -66,6 +66,7 @@ return [ 'vmangos_auth' => [ 'driver' => 'mysql', + 'timezone' => '+00:00', // vMaNGOS uses zero timestamps such as account.last_login. 'modes' => ['NO_ENGINE_SUBSTITUTION'], 'url' => env('VMANGOS_AUTH_DB_URL'), @@ -86,21 +87,9 @@ return [ ]) : [], ], - 'legacy_auth' => [ - 'driver' => 'mysql', - 'host' => env('LEGACY_AUTH_DB_HOST', '127.0.0.1'), - 'port' => env('LEGACY_AUTH_DB_PORT', '13307'), - 'database' => env('LEGACY_AUTH_DB_DATABASE', 'acore_auth'), - 'username' => env('LEGACY_AUTH_DB_USERNAME', 'root'), - 'password' => env('LEGACY_AUTH_DB_PASSWORD', ''), - 'charset' => 'utf8mb4', - 'collation' => 'utf8mb4_unicode_ci', - 'prefix' => '', - 'strict' => true, - ], - 'vmangos_characters' => [ 'driver' => 'mysql', + 'timezone' => '+00:00', 'modes' => ['NO_ENGINE_SUBSTITUTION'], 'url' => env('VMANGOS_CHARACTERS_DB_URL'), 'host' => env('VMANGOS_CHARACTERS_DB_HOST', env('VMANGOS_AUTH_DB_HOST', '127.0.0.1')), diff --git a/config/moonwell.php b/config/moonwell.php index f1409d1..71946f1 100644 --- a/config/moonwell.php +++ b/config/moonwell.php @@ -3,7 +3,7 @@ return [ 'auth_connection' => env('VMANGOS_AUTH_CONNECTION', 'vmangos_auth'), 'characters_connection' => env('VMANGOS_CHARACTERS_CONNECTION', 'vmangos_characters'), - 'legacy_auth_connection' => env('LEGACY_AUTH_CONNECTION', 'legacy_auth'), + 'timezone' => env('VMANGOS_TIMEZONE', 'UTC'), 'registration' => [ 'require_invite_code' => (bool) env('MOONWELL_REGISTRATION_REQUIRE_INVITE', true), diff --git a/database/migrations/2026_10_04_000001_add_web_user_id_to_vmangos_site_credentials.php b/database/migrations/2026_10_04_000001_add_web_user_id_to_vmangos_site_credentials.php new file mode 100644 index 0000000..b884fc6 --- /dev/null +++ b/database/migrations/2026_10_04_000001_add_web_user_id_to_vmangos_site_credentials.php @@ -0,0 +1,31 @@ +environment('testing') ? config('database.default') : config('moonwell.auth_connection'); + } + + public function up(): void + { + Schema::connection($this->getConnection())->table('site_credentials', function (Blueprint $table): void { + // web.users lives in another database, so no cross-database foreign key. + $table->unsignedBigInteger('web_user_id')->nullable()->unique(); + $table->char('salt_hex', 64)->nullable()->change(); + $table->char('verifier_hex', 64)->nullable()->change(); + }); + } + + public function down(): void + { + Schema::connection($this->getConnection())->table('site_credentials', function (Blueprint $table): void { + $table->dropUnique(['web_user_id']); + $table->dropColumn('web_user_id'); + }); + } +}; diff --git a/resources/js/Pages/Auth/Login.vue b/resources/js/Pages/Auth/Login.vue index f83ded8..f7203e9 100644 --- a/resources/js/Pages/Auth/Login.vue +++ b/resources/js/Pages/Auth/Login.vue @@ -76,7 +76,7 @@ function submit() { v-model="form.password" type="password" autocomplete="current-password" - maxlength="16" + maxlength="255" placeholder="Твой пароль" required /> diff --git a/resources/js/Pages/Cabinet/Index.vue b/resources/js/Pages/Cabinet/Index.vue index 1710ede..817ac49 100644 --- a/resources/js/Pages/Cabinet/Index.vue +++ b/resources/js/Pages/Cabinet/Index.vue @@ -238,7 +238,7 @@ function updatePassword() {