1 Commits

Author SHA1 Message Date
sindoring 3405057e5b wip 2026-08-17 20:24:17 +04:00
13 changed files with 841 additions and 607 deletions
@@ -0,0 +1,49 @@
-- DB update 2026_07_22_00 -> 2026_08_16_00
-- MoonWell launcher-only authentication.
ALTER TABLE `account`
ADD COLUMN `session_auth` enum('none','launcher','dev') NOT NULL DEFAULT 'none' AFTER `session_key`;
UPDATE `account`
SET `session_key` = NULL,
`session_auth` = 'none';
CREATE TABLE `launcher_ticket` (
`account_id` int unsigned NOT NULL,
`generation_id` binary(16) NOT NULL,
`srp_salt` binary(32) NOT NULL,
`srp_verifier` binary(32) NOT NULL,
`client_build` smallint unsigned NOT NULL,
`launcher_session_hash` binary(32) DEFAULT NULL,
`issued_at` datetime(6) NOT NULL,
`expires_at` datetime(6) NOT NULL,
PRIMARY KEY (`account_id`),
UNIQUE KEY `uq_launcher_ticket_generation` (`generation_id`),
KEY `idx_launcher_ticket_expiry` (`expires_at`),
CONSTRAINT `fk_launcher_ticket_account`
FOREIGN KEY (`account_id`) REFERENCES `account` (`id`) ON DELETE CASCADE,
CONSTRAINT `chk_launcher_ticket_expiry`
CHECK (`expires_at` > `issued_at` AND `expires_at` <= `issued_at` + INTERVAL 60 SECOND)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE `launcher_ticket_consumption` (
`generation_id` binary(16) NOT NULL,
`account_id` int unsigned NOT NULL,
`claim_nonce` binary(16) NOT NULL,
`consumed_at` datetime(6) NOT NULL,
PRIMARY KEY (`generation_id`),
UNIQUE KEY `uq_launcher_ticket_claim_nonce` (`claim_nonce`),
KEY `idx_launcher_ticket_consumption_account` (`account_id`, `consumed_at`),
CONSTRAINT `fk_launcher_ticket_consumption_account`
FOREIGN KEY (`account_id`) REFERENCES `account` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE `launcher_dev_account` (
`account_id` int unsigned NOT NULL,
`allowed_ip` varchar(45) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`comment` varchar(255) NOT NULL DEFAULT '',
`enabled` tinyint unsigned NOT NULL DEFAULT 1,
PRIMARY KEY (`account_id`, `allowed_ip`),
CONSTRAINT `fk_launcher_dev_account_account`
FOREIGN KEY (`account_id`) REFERENCES `account` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+371
View File
@@ -0,0 +1,371 @@
# Контракт авторизации MoonWell через лаунчер
Статус документа: обязательный контракт для Release-сборок.
Ключевые слова **ДОЛЖЕН**, **НЕЛЬЗЯ**, **СЛЕДУЕТ** и **МОЖЕТ** описывают
обязательность требований.
## 1. Цель и границы доверия
Игровой клиент World of Warcraft 3.3.5a остаётся стандартным SRP6-клиентом,
но вместо постоянного пароля получает короткоживущий одноразовый ticket от
MoonWell Launcher. Наличие или отсутствие полей логина в GlueXML не является
границей безопасности. Решение о допустимости входа всегда принимает
`authserver`.
В поток входят четыре компонента:
1. backend аутентифицирует пользователя и выпускает game ticket;
2. launcher передаёт game account name и ticket только через environment block
нового `Wow.exe`;
3. MoonWell.dll забирает значения до загрузки GlueXML и инициирует обычный SRP;
4. `authserver` использует временные salt/verifier и атомарно погашает ticket
после успешного `CMD_AUTH_LOGON_PROOF`.
## 2. Термины
- **backend account** — учётная запись пользователя в сервисах MoonWell;
- **game account** — имя учётной записи AzerothCore, передаваемое в поле `I`
`CMD_AUTH_LOGON_CHALLENGE`;
- **launcher session** — аутентифицированная сессия launcher с backend;
- **ticket** — одноразовый временный пароль для SRP;
- **ticket generation** — уникальный внутренний идентификатор выпуска ticket,
используемый для отзыва и атомарного погашения;
- **dev account** — явно разрешённая сервером учётная запись с постоянным SRP
verifier для локальной разработки.
## 3. Формат launcher credentials
Launcher передаёт ровно две обязательные переменные:
```text
MOONWELL_LAUNCH_ACCOUNT=<game account name>
MOONWELL_LAUNCH_TICKET=<single-use SRP password>
```
### 3.1 `MOONWELL_LAUNCH_ACCOUNT`
- непустая строка печатного ASCII (`0x21..0x7E`), без пробельных символов;
- не содержит `NUL`, `=` и управляющих символов;
- верхний предел безопасного чтения из environment — 320 байт;
- перед SRP приводится к той же канонической форме, что и имя в AzerothCore,
то есть к ASCII uppercase;
- backend и `authserver` сравнивают каноническое имя, а не display name.
Ограничение 320 байт является только пределом входного буфера MoonWell.dll.
Текущий протокол и этот репозиторий не поддерживают game account такой длины:
`I_len` имеет размер `uint8`, `AuthSession.cpp` принимает challenge лишь с
добавкой до 16 байт, а `AccountMgr` использует `MAX_ACCOUNT_STR = 17`.
Поэтому backend **ДОЛЖЕН** отображать длинную backend account на отдельное
короткое game account name, совместимое с текущим сервером (не более 17 байт).
Расширение этого лимита является отдельным изменением протокола/сервера и не
входит в настоящий контракт.
### 3.2 `MOONWELL_LAUNCH_TICKET`
- ровно 16 ASCII-символов;
- допустимый алфавит: `A-Z` и `0-9`;
- генерируется CSPRNG с равномерным rejection sampling;
- энтропия полного пространства `36^16` — около 82,7 бит;
- срок действия: не более 60 секунд от момента выпуска;
- одноразовый;
- новый выпуск атомарно отзывает предыдущий активный ticket game account.
Ни один компонент **НЕ ДОЛЖЕН** нормализовать, менять регистр или обрезать
ticket. Для SRP ticket используется в точности как получен.
## 4. Контракт backend
Конкретный HTTP route может определяться backend, но операция выпуска имеет
следующую семантику:
```json
{
"launcher_session_id": "opaque launcher session identifier",
"client_build": 12340
}
```
Успешный ответ:
```json
{
"account": "GAMEACCOUNT",
"ticket": "7Q9AV4R2M8ZK3W1P",
"expires_at": "2026-08-16T12:34:56Z"
}
```
Требования к операции:
- запрос разрешён только из действующей аутентифицированной launcher session;
- требуемая пользователю MFA завершается до выпуска ticket;
- backend проверяет право launcher session входить в указанный game account;
- `client_build` должен входить в разрешённый список;
- выпуск и отзыв предыдущей generation выполняются одной транзакцией;
- ответ передаётся только по TLS и содержит `Cache-Control: no-store`;
- ticket не возвращается повторно и не попадает в URL, telemetry, analytics,
tracing, audit payload, exception text или access log;
- rate limit применяется как минимум к пользователю, game account, launcher
session и IP;
- время жизни вычисляется по серверному времени. Часы клиента не являются
источником истины.
Рекомендуемые машинные ошибки: `UNAUTHENTICATED`, `ACCOUNT_NOT_ALLOWED`,
`CLIENT_BUILD_NOT_ALLOWED`, `RATE_LIMITED`, `GAME_ACCOUNT_NOT_COMPATIBLE` и
`INTERNAL_ERROR`. Ошибка не должна содержать ticket или verifier.
## 5. Хранение ticket
Предпочтительный вариант — не хранить открытый ticket. Backend вычисляет
временные SRP salt и verifier тем же кодом/алгоритмом, что
`Acore::Crypto::SRP6::MakeRegistrationData(canonicalAccount, ticket)`, сохраняет
их вместе с generation и уничтожает открытый ticket после формирования ответа.
Минимальная логическая запись:
```text
account_id UNIQUE
generation_id UNIQUE, unpredictable
srp_salt BINARY(32)
srp_verifier BINARY(32)
launcher_session_hash nullable/optional binding
client_build required binding
issued_at server timestamp
expires_at server timestamp, <= issued_at + 60 seconds
consumed_at nullable
revoked_at nullable
```
Активна только запись, для которой одновременно выполнено:
```text
consumed_at IS NULL
AND revoked_at IS NULL
AND expires_at > server_now
```
Если архитектура временно требует хранения открытого ticket, он должен быть
зашифрован отдельным ротируемым ключом, недоступным read-only потребителям БД,
и удалён при погашении/истечении. Хеш ticket сам по себе недостаточен для
построения SRP verifier после выпуска.
## 6. Запуск `Wow.exe`
Launcher **ДОЛЖЕН**:
1. получить ticket непосредственно перед запуском;
2. построить отдельный Unicode environment block для дочернего процесса,
сохранив необходимые штатные переменные и добавив две MoonWell-переменные;
3. вызвать `CreateProcessW` с `CREATE_UNICODE_ENVIRONMENT`;
4. не добавлять credentials в command line;
5. сразу после возврата `CreateProcessW` затереть все собственные изменяемые
буферы с ticket через гарантированно не оптимизируемую операцию, например
`SecureZeroMemory`;
6. уничтожить созданный environment block независимо от результата запуска.
Launcher **НЕ ДОЛЖЕН** менять собственное глобальное окружение через
`SetEnvironmentVariable`, поскольку это создаёт окно утечки и гонки при
параллельных запусках. Переменные должны существовать только в явно собранном
environment block конкретного дочернего процесса.
Ticket нельзя помещать в аргументы, файлы, реестр, crash metadata или логи.
Дамп памяти процесса остаётся привилегированной атакой; MoonWell.dll должна
минимизировать время жизни plaintext и очищать временные буферы.
## 7. Контракт MoonWell.dll и GlueXML
MoonWell.dll до загрузки GlueXML:
1. читает обе launcher-переменные в ограниченные изменяемые буферы;
2. немедленно удаляет их из окружения `Wow.exe`;
3. валидирует обе переменные целиком;
4. передаёт валидную пару GlueXML только в памяти процесса;
5. очищает исходные и промежуточные буферы после начала SRP-входа.
Пара считается валидной только если присутствуют и корректны обе переменные.
Частичная, пустая, слишком длинная или синтаксически неверная пара полностью
отбрасывается. Нельзя переходить к ручному логину из-за ошибки credentials в
Release.
Состояния интерфейса:
| Сборка/вход | `AccountLoginUI` | Основное действие |
| --- | --- | --- |
| Release, валидная пара | скрыт | один вызов `DefaultServerLogin(account, ticket)` |
| Release, пары нет/она невалидна | поля, password и save options скрыты | открыть launcher |
| Debug и `MOONWELL_DEV_LOGIN=1` | прежняя форма видима | штатный ручной SRP-вход |
| Debug без dev-флага | как Release | открыть launcher |
При каждом не-dev запуске клиент удаляет ранее сохранённые account/password и
отключает параметры их сохранения до отображения UI. Повторный автоматический
вызов `DefaultServerLogin` в рамках одного процесса запрещён.
`MOONWELL_DEV_LOGIN` читается и удаляется тем же ранним нативным кодом. Он
учитывается только Debug-сборкой MoonWell.dll; Release-сборка всегда его
игнорирует.
## 8. Открытие launcher
Кнопка «Авторизоваться»:
1. ищет `MoonWellLauncher.exe` в каталоге фактически запущенного `Wow.exe`;
2. если файл найден, запускает его без credentials;
3. иначе открывает `moonwell://authorize?source=client` системным обработчиком.
Путь нельзя брать из current working directory. Перед запуском следует
проверять издателя Authenticode/ожидаемую подпись launcher.
Установщик регистрирует URI scheme только для текущего пользователя в
`HKCU\Software\Classes\moonwell`. Команда обработчика должна корректно заключать
путь executable и аргумент URI в кавычки. Launcher рассматривает URI как
недоверенный ввод, разрешает только известные host/action/query keys и никогда
не принимает credentials через URI.
## 9. Обязательный контракт `authserver`
### 9.1 `CMD_AUTH_LOGON_CHALLENGE`
После обычного разбора и канонизации game account сервер:
1. загружает account, блокировки и режим авторизации;
2. для обычного account ищет активную launcher ticket generation;
3. проверяет expiry, `client_build` и дополнительные привязки;
4. если активной generation нет, возвращает общий отказ до обращения к
постоянным account salt/verifier;
5. сохраняет `generation_id` в объекте конкретной `AuthSession`;
6. строит SRP challenge только из временных salt/verifier этой generation.
Для launcher-only account SRP challenge не должен запрашивать интерактивный
TOTP/security token: MFA является свойством launcher/backend-сессии и должна
быть завершена до выпуска ticket. Иначе скрытый GlueXML не сможет закончить
автоматический вход. Штатный AzerothCore TOTP может оставаться только в явно
описанном dev-потоке с видимой формой.
Для отсутствующего, истёкшего, отозванного или уже использованного ticket
следует возвращать одинаковый протокольный результат, например
`WOW_FAIL_UNKNOWN_ACCOUNT`, чтобы не создавать oracle состояния ticket.
### 9.2 `CMD_AUTH_LOGON_PROOF`
После успешной криптографической проверки proof, TOTP (если применяется) и
версии клиента сервер **ДОЛЖЕН** в одной транзакции:
1. выполнить условное погашение именно generation из текущей `AuthSession`:
```sql
UPDATE launcher_ticket
SET consumed_at = CURRENT_TIMESTAMP(6)
WHERE account_id = ?
AND generation_id = ?
AND consumed_at IS NULL
AND revoked_at IS NULL
AND expires_at > CURRENT_TIMESTAMP(6);
```
2. убедиться, что изменена ровно одна строка;
3. сохранить новую game session key и штатные login metadata;
4. зафиксировать транзакцию;
5. только после commit отправить клиенту успешный `AUTH_LOGON_PROOF`.
Если условное погашение изменило ноль строк или commit не удался, сервер
возвращает общий отказ и не создаёт авторизованную game session. Это обеспечивает
отказ повторному proof даже если два challenge успели получить одну generation.
Неуспешный proof не погашает ticket, но существующие механизмы rate limit и
защиты от перебора продолжают действовать. Ticket всё равно истекает не позднее
60 секунд после выпуска.
### 9.3 Постоянный verifier и dev-доступ
Для обычных accounts `authserver` никогда не выбирает и не проверяет постоянные
`account.salt/account.verifier`. При миграции их следует сделать nullable и
удалить у launcher-only accounts либо заменить отдельным режимом хранения,
который невозможно случайно использовать в launcher-ветке.
Ручной SRP разрешён только если серверная политика независимо подтверждает все
условия:
- account присутствует в явном dev allowlist;
- account имеет режим `DEV_SRP`;
- источник соответствует VPN/IP allowlist;
- production-конфигурация не разрешает dev-режим по умолчанию.
Клиентский Debug-флаг не передаётся authserver и не участвует в решении.
### 9.4 Reconnect
`CMD_AUTH_RECONNECT_*` может использовать session key только от ранее успешно
погашенного ticket или разрешённого dev-входа. При включённом reconnect сервер
должен хранить происхождение/время session key и отклонить legacy session keys,
созданные до внедрения этой политики. Reconnect не оживляет ticket и не позволяет
новому процессу выполнить новый logon без нового ticket.
## 10. Состояния ticket и гонки
Допустимые переходы:
```text
ISSUED -> CONSUMED
ISSUED -> REVOKED (выпущена новая generation)
ISSUED -> EXPIRED (server_now >= expires_at)
```
`CONSUMED`, `REVOKED` и `EXPIRED` — терминальные состояния. Cleanup может
физически удалить терминальные записи позже, но не должен превращать их обратно
в активные.
Выдача новой generation не обязана завершать уже созданную игровую сессию, но
обязана сделать proof старой незавершённой generation невозможным. Это
обеспечивается сравнением `generation_id` при погашении.
## 11. Логи и наблюдаемость
Разрешено логировать только:
- внутренний account id либо необратимо псевдонимизированный идентификатор;
- generation id в хешированном/усечённом виде;
- тип события: issued, rejected, consumed, revoked, expired;
- код результата, build и тайминги без секретов.
Запрещено логировать ticket, SRP verifier, environment block, полный launcher
session token и HTTP response body. Фильтрация секретов должна применяться также
к debug-логам, SQL tracing, crash reports и APM.
## 12. Критерии приёмки
Реализация считается соответствующей контракту, если автоматические или
интеграционные тесты подтверждают:
1. валидный свежий ticket даёт ровно один успешный вход;
2. повторный proof и второй процесс с тем же ticket получают отказ;
3. новый ticket отзывает предыдущий, включая уже полученный старый challenge;
4. ticket старше 60 секунд отклоняется по серверному времени;
5. прямой Release-запуск не показывает и не использует ручные credentials;
6. одна отсутствующая/невалидная environment-переменная не запускает SRP;
7. переменные удалены из окружения до GlueXML и отсутствуют в дочерних
процессах, созданных позже;
8. Release игнорирует `MOONWELL_DEV_LOGIN=1`;
9. Debug dev-login работает только для server-side allowlisted account и
разрешённого источника;
10. обычный account с корректным старым постоянным паролем получает отказ;
11. ticket, URL и command line отсутствуют во всех проверяемых логах;
12. параллельные proof одной generation дают ровно один success;
13. запрещённый client build и несоответствующая launcher-session binding дают
общий отказ без fallback на постоянный verifier;
14. legacy session keys не позволяют обойти launcher-only политику reconnect.
15. launcher-only challenge не требует интерактивного TOTP после запуска
`DefaultServerLogin`.
## 13. Локальный запуск разработчика
Поддерживаемая команда:
```powershell
.\run.ps1 -Env local -DeveloperLogin
```
Скрипт собирает Debug runtime, формирует отдельный environment block и добавляет
`MOONWELL_DEV_LOGIN=1` только запускаемому процессу. Он не изменяет глобальное
окружение пользователя и не делает server-side account dev-разрешённым.
Debug DLL не включается в пользовательские Release-дистрибутивы.
+84
View File
@@ -0,0 +1,84 @@
# Эксплуатация launcher-авторизации
Нормативный протокол описан в
[`LauncherAuthorizationContract.md`](LauncherAuthorizationContract.md). Этот
документ относится к реализованной в данном репозитории серверной части.
## Что входит в репозиторий
- `authserver` использует временные SRP salt/verifier для всех обычных
аккаунтов и не делает fallback на постоянный пароль;
- SQL update `2026_08_16_00.sql` добавляет активные ticket, журнал погашений,
точный IP allowlist для dev-аккаунтов и provenance session key;
- `issue-launcher-ticket.sh` является reference adapter для backend и локальной
интеграции. Production backend должен реализовать ту же операцию внутри
своего доверенного процесса, включая аутентификацию launcher session и rate
limiting;
- reconnect и вход в worldserver разрешены только session key с provenance
`launcher` или `dev`.
Исходников MoonWell Launcher, MoonWell.dll, GlueXML и backend в этом
репозитории нет. Их реализация должна следовать отдельным разделам контракта.
## Выпуск ticket
База должна быть запущена и обновлена штатным `ac-db-import`:
```bash
./issue-launcher-ticket.sh PLAYER --build 12340 --ttl 60
```
Успешный результат — одна JSON-строка в stdout. Она содержит plaintext ticket
и должна быть сразу передана launcher по TLS с `Cache-Control: no-store`.
Диагностика выводится в stderr. Новый вызов для того же account атомарно
заменяет предыдущую активную generation.
Скрипт не является публичным HTTP endpoint. Нельзя предоставлять игрокам
доступ к его запуску или к MySQL credentials.
## Dev allowlist
Постоянный verifier используется только при совпадении account и точного
remote IP с активной строкой allowlist:
```sql
INSERT INTO launcher_dev_account (account_id, allowed_ip, comment)
SELECT id, '10.20.30.40', 'local developer over VPN'
FROM account
WHERE username = 'DEVACCOUNT'
ON DUPLICATE KEY UPDATE enabled = 1, comment = VALUES(comment);
```
Отключение:
```sql
UPDATE launcher_dev_account
SET enabled = 0
WHERE account_id = (SELECT id FROM account WHERE username = 'DEVACCOUNT')
AND allowed_ip = '10.20.30.40';
```
CIDR и wildcard намеренно не поддерживаются: allowlist требует точного IP.
## Миграция и откат
SQL update очищает существующие `account.session_key`, чтобы legacy reconnect
не обходил launcher-only политику. После применения старые клиенты должны
получить новый launcher ticket.
Миграция безопасности намеренно не имеет автоматического fallback. Перед
развёртыванием должны быть готовы backend issuer и Release-клиент. Для
аварийного dev-доступа заранее добавьте только выделенный account и VPN IP в
`launcher_dev_account`.
## Проверка
После сборки и применения update:
1. выпустить ticket для тестового account;
2. убедиться, что прямой вход со старым паролем отвергнут;
3. войти с ticket;
4. убедиться, что строка удалена из `launcher_ticket` и появилась в
`launcher_ticket_consumption`;
5. повторить вход с тем же ticket и получить отказ;
6. выпустить два ticket подряд и убедиться, что работает только второй.
-9
View File
@@ -341,14 +341,6 @@ import_encounter_journal_sql() {
fi
}
import_premium_sql() {
log "Started importing Premium SQL files"
local world_premium_sql="$ROOT_DIR/sql/Premium/world.sql"
import_sql_file "acore_world" "$world_premium_sql"
}
if (($# > 0)); then
case "$1" in
-h|--help)
@@ -381,7 +373,6 @@ import_playerbots_ru_sql
import_encounter_journal_sql
import_store_sql
import_transmog_sql
import_premium_sql
configure_server_motd
log "custom SQL bootstrap completed"
+167
View File
@@ -0,0 +1,167 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ENV_FILE="${ENV_FILE:-$ROOT_DIR/.env}"
CLIENT_BUILD=""
TTL_SECONDS=60
if [[ -f "$ENV_FILE" ]]; then
set -a
# shellcheck disable=SC1090
source "$ENV_FILE"
set +a
fi
CLIENT_BUILD="${ACORE_REALMLIST_GAMEBUILD:-12340}"
usage() {
cat <<EOF
Usage: $(basename "$0") <game-account> [--build <client-build>] [--ttl <1-60>]
Issues one single-use MoonWell launcher ticket and revokes the account's
previous active ticket. The JSON response containing the ticket is written
only to stdout; diagnostics go to stderr.
EOF
}
if (($# == 0)) || [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
usage
exit 0
fi
ACCOUNT="$1"
shift
while (($#)); do
case "$1" in
--build)
[[ $# -ge 2 ]] || { printf '%s\n' '--build requires a value' >&2; exit 1; }
CLIENT_BUILD="$2"
shift 2
;;
--ttl)
[[ $# -ge 2 ]] || { printf '%s\n' '--ttl requires a value' >&2; exit 1; }
TTL_SECONDS="$2"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
printf 'Unknown option: %s\n' "$1" >&2
exit 1
;;
esac
done
if ! [[ "$CLIENT_BUILD" =~ ^[0-9]+$ ]] || ((CLIENT_BUILD < 1 || CLIENT_BUILD > 65535)); then
printf '%s\n' 'client build must be an integer from 1 to 65535' >&2
exit 1
fi
if ! [[ "$TTL_SECONDS" =~ ^[0-9]+$ ]] || ((TTL_SECONDS < 1 || TTL_SECONDS > 60)); then
printf '%s\n' 'ttl must be an integer from 1 to 60 seconds' >&2
exit 1
fi
export MOONWELL_ISSUER_ACCOUNT="$ACCOUNT"
mapfile -t ACCOUNT_DATA < <(python3 <<'PY'
import json
import os
account = os.environ["MOONWELL_ISSUER_ACCOUNT"]
account = "".join(chr(ord(ch) - 32) if "a" <= ch <= "z" else ch for ch in account)
encoded = account.encode("ascii", "strict")
if not encoded or len(encoded) > 17:
raise SystemExit("game account must contain 1 to 17 ASCII bytes")
if any(byte < 0x21 or byte > 0x7E for byte in encoded) or b"=" in encoded:
raise SystemExit("game account must be printable ASCII without whitespace or '='")
print(account)
print(encoded.hex().upper())
print(json.dumps(account, ensure_ascii=True))
PY
)
unset MOONWELL_ISSUER_ACCOUNT ACCOUNT
CANONICAL_ACCOUNT="${ACCOUNT_DATA[0]}"
ACCOUNT_HEX="${ACCOUNT_DATA[1]}"
ACCOUNT_JSON="${ACCOUNT_DATA[2]}"
unset ACCOUNT_DATA
cd "$ROOT_DIR"
if ! docker compose ps --status running --services | grep -qx 'ac-database'; then
printf '%s\n' 'ac-database is not running; start it with ./start-server.sh' >&2
exit 1
fi
ACCOUNT_ID="$(docker compose exec -T ac-database bash -lc \
'mysql -N -B -uroot -p"$MYSQL_ROOT_PASSWORD" acore_auth' \
<<<"SELECT id FROM account WHERE username = CONVERT(0x$ACCOUNT_HEX USING ascii) LIMIT 1;")"
if ! [[ "$ACCOUNT_ID" =~ ^[0-9]+$ ]]; then
printf 'game account does not exist: %s\n' "$CANONICAL_ACCOUNT" >&2
exit 1
fi
export MOONWELL_ISSUER_CANONICAL_ACCOUNT="$CANONICAL_ACCOUNT"
mapfile -t TICKET_DATA < <(python3 <<'PY'
import hashlib
import os
import secrets
alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
account = os.environ["MOONWELL_ISSUER_CANONICAL_ACCOUNT"]
ticket = "".join(secrets.choice(alphabet) for _ in range(16))
generation = secrets.token_bytes(16)
salt = secrets.token_bytes(32)
n = int("894B645E89E1535BBDAD5B8B290650530801B18EBFBF5E8FAB3C82872A3E9BB7", 16)
inner = hashlib.sha1(f"{account}:{ticket}".encode("ascii")).digest()
x = hashlib.sha1(salt + inner).digest()
verifier = pow(7, int.from_bytes(x, "little"), n).to_bytes(32, "little")
print(generation.hex().upper())
print(salt.hex().upper())
print(verifier.hex().upper())
print(ticket)
PY
)
unset MOONWELL_ISSUER_CANONICAL_ACCOUNT
GENERATION_HEX="${TICKET_DATA[0]}"
SALT_HEX="${TICKET_DATA[1]}"
VERIFIER_HEX="${TICKET_DATA[2]}"
TICKET="${TICKET_DATA[3]}"
unset TICKET_DATA
SQL="INSERT INTO launcher_ticket
(account_id, generation_id, srp_salt, srp_verifier, client_build, issued_at, expires_at)
VALUES
($ACCOUNT_ID, UNHEX('$GENERATION_HEX'), UNHEX('$SALT_HEX'), UNHEX('$VERIFIER_HEX'),
$CLIENT_BUILD, UTC_TIMESTAMP(6), UTC_TIMESTAMP(6) + INTERVAL $TTL_SECONDS SECOND)
ON DUPLICATE KEY UPDATE
generation_id = VALUES(generation_id),
srp_salt = VALUES(srp_salt),
srp_verifier = VALUES(srp_verifier),
client_build = VALUES(client_build),
launcher_session_hash = NULL,
issued_at = VALUES(issued_at),
expires_at = VALUES(expires_at);
SELECT DATE_FORMAT(expires_at, '%Y-%m-%dT%H:%i:%sZ')
FROM launcher_ticket
WHERE account_id = $ACCOUNT_ID;"
EXPIRES_AT="$(docker compose exec -T ac-database bash -lc \
'mysql -N -B -uroot -p"$MYSQL_ROOT_PASSWORD" acore_auth' <<<"$SQL")"
printf '{"account":%s,"ticket":"%s","expires_at":"%s","client_build":%s}\n' \
"$ACCOUNT_JSON" "$TICKET" "$EXPIRES_AT" "$CLIENT_BUILD"
TICKET="$(printf '%*s' 16 '')"
unset TICKET SQL GENERATION_HEX SALT_HEX VERIFIER_HEX ACCOUNT_HEX CANONICAL_ACCOUNT ACCOUNT_JSON
-1
View File
@@ -1 +0,0 @@
return {}
-172
View File
@@ -1,172 +0,0 @@
local PLAYER_EVENT_ON_LOGIN = 3
local PLAYER_EVENT_ON_SPELL_CAST = 5
local PLAYER_EVENT_ON_LOGOUT = 4
local PLAYER_EVENT_ON_COMMAND = 42
local PREMIUM_FLAG_INDEX = 100
local ACCOUNT_FLAG_RECURRING_BILLING = 8192
local SUMMON_SQUIRE_SPELL_ID = 80000
local SQUIRE_CREATURE_TEMPLATE_ID = 9000001
local NPC_TEXT_ID = 10000000
---todo extract to utils
---@param inputstr string
---@param separator string
---@return table
local function SplitString(inputstr, separator)
if separator == nil then
separator = "%s"
end
local t = {}
for str in string.gmatch(inputstr, "([^" .. separator .. "]+)") do
table.insert(t, str)
end
return t
end
---@return boolean
local function IsPremium(player)
return player:HasFlag(PREMIUM_FLAG_INDEX, 1)
end
local function OnPremiumPlayer(player)
if not IsPremium(player) then
return
end
player:SendAreaTriggerMessage(
'На вашем аккаунте активна подписка Premium! Благодарим за поддержку проекта MoonWell.')
player:LearnSpell(SUMMON_SQUIRE_SPELL_ID)
player:SendBroadcastMessage("Милостью Элуны вам была выдана способность |cff71d5ff|Hspell:" ..
SUMMON_SQUIRE_SPELL_ID .. "|h[" .. "Призыв Оруженосца]h|")
end
local function RefreshPremiumStatus(player)
local query = AuthDBQuery("SELECT flags FROM account WHERE id = " .. player:GetAccountId())
if query then
local function HasFlag(mask, flag)
return bit_and(mask, flag) == flag
end
local flags = query:GetUInt32(0)
if HasFlag(flags, ACCOUNT_FLAG_RECURRING_BILLING) then
player:SetFlag(PREMIUM_FLAG_INDEX, 1)
OnPremiumPlayer(player)
return true
end
end
return false
end
local function ApplyPremium(player)
AuthDBExecute(
string.format(
"UPDATE account SET flags = flags | %d WHERE id = %d",
ACCOUNT_FLAG_RECURRING_BILLING,
player:GetAccountId()
)
)
RefreshPremiumStatus(player)
end
local function OnLogin(event, player)
RefreshPremiumStatus(player)
end
--- @param event any
--- @param player any
--- @param command string
--- @param chatHandler any
local function OnCommand(event, player, command, chatHandler)
---Console is not supported yet
if not player then return end
local parts = SplitString(command:lower(), ' ')
if parts[1] ~= 'premium' then return end
if parts[2] == 'add' then
ApplyPremium(player:GetSelection())
end
return false
end
local function OnSummonSquireCast(player)
local x, y, z, o = player:GetLocation()
local squire = player:SpawnCreature(SQUIRE_CREATURE_TEMPLATE_ID, x + 1, y + 1, z, o, 1, 30000)
if squire then
squire:SetFaction(player:GetFaction())
end
end
local function OnSpellCast(event, player, spell, skipCheck)
local spellId = spell:GetEntry()
if spellId == SUMMON_SQUIRE_SPELL_ID then
OnSummonSquireCast(player)
end
end
local function OnGossipHello(event, player, creature)
player:GossipClearMenu()
player:GossipMenuAddItem(4, "Открыть почтовый ящик", 900001, 1)
player:GossipMenuAddItem(1, "Открыть банк", 900001, 2)
player:GossipMenuAddItem(6, "Открыть аукцион", 900001, 3)
player:GossipMenuAddItem(4, "Отремонтировать снаряжение", 900001, 4)
player:GossipSendMenu(NPC_TEXT_ID, creature)
end
local function OnGossipSelect(event, player, creature, sender, intid, code)
if sender == 900001 then
if intid == 1 then
player:SendShowMailBox(creature:GetGUID())
player:GossipComplete()
end
if intid == 2 then
player:SendShowBank(creature)
player:GossipComplete()
end
if intid == 3 then
player:SendAuctionMenu(creature)
player:GossipComplete()
end
if intid == 4 then
player:DurabilityRepairAll()
player:PlayDirectSound(1116)
player:GossipComplete()
end
end
end
local function OnLogout(event, player)
player:RemoveSpell(SUMMON_SQUIRE_SPELL_ID)
end
RegisterCreatureGossipEvent(SQUIRE_CREATURE_TEMPLATE_ID, 1, OnGossipHello)
RegisterCreatureGossipEvent(SQUIRE_CREATURE_TEMPLATE_ID, 2, OnGossipSelect)
RegisterPlayerEvent(PLAYER_EVENT_ON_LOGIN, OnLogin)
RegisterPlayerEvent(PLAYER_EVENT_ON_LOGOUT, OnLogout)
RegisterPlayerEvent(PLAYER_EVENT_ON_COMMAND, OnCommand)
RegisterPlayerEvent(PLAYER_EVENT_ON_SPELL_CAST, OnSpellCast)
+2 -2
View File
@@ -61,9 +61,9 @@
# false - (disabled)
Eluna.Enabled = true
Eluna.TraceBack = true
Eluna.TraceBack = false
Eluna.ScriptPath = "lua_scripts"
Eluna.PlayerAnnounceReload = true
Eluna.PlayerAnnounceReload = false
Eluna.RequirePaths = ""
Eluna.RequireCPaths = ""
Eluna.AutoReload = false
-380
View File
@@ -1,380 +0,0 @@
-- Create Squire NPC
INSERT
INTO
acore_world.creature_template (entry,
difficulty_entry_1,
difficulty_entry_2,
difficulty_entry_3,
KillCredit1,
KillCredit2,
name,
subname,
IconName,
gossip_menu_id,
minlevel,
maxlevel,
`exp`,
faction,
npcflag,
speed_walk,
speed_run,
speed_swim,
speed_flight,
detection_range,
`rank`,
dmgschool,
DamageModifier,
BaseAttackTime,
RangeAttackTime,
BaseVariance,
RangeVariance,
unit_class,
unit_flags,
unit_flags2,
dynamicflags,
family,
`type`,
type_flags,
lootid,
pickpocketloot,
skinloot,
PetSpellDataId,
VehicleId,
mingold,
maxgold,
AIName,
MovementType,
HoverHeight,
HealthModifier,
ManaModifier,
ArmorModifier,
ExperienceModifier,
RacialLeader,
movementId,
RegenHealth,
mechanic_immune_mask,
spell_school_immune_mask,
flags_extra,
ScriptName,
VerifiedBuild)
VALUES
(9000001,
29831,
0,
0,
0,
0,
'Eluna Squire',
'',
'',
0,
81,
81,
2,
21,
1,
1.0,
1.42857,
1.0,
1.0,
20.0,
1,
0,
7.5,
2000,
2000,
1.0,
1.0,
1,
64,
2048,
8,
0,
6,
72,
100003,
0,
0,
0,
0,
1163,
1524,
'SmartAI',
0,
1.0,
4.0,
1.0,
1.0,
2.5,
0,
144,
1,
1007366674,
0,
0,
'',
12340) ON DUPLICATE KEY UPDATE entry = entry;
-- Add Squire localization
INSERT INTO
acore_world.creature_template_locale (entry,
locale,
Name,
Title,
VerifiedBuild)
VALUES
(9000001,
'ruRU',
'Оруженосец',
'',
12340) ON DUPLICATE KEY UPDATE entry = entry;
-- Add Squire model
INSERT
INTO
acore_world.creature_template_model (CreatureID,
Idx,
CreatureDisplayID,
DisplayScale,
Probability,
VerifiedBuild)
VALUES
(9000001,
0,
16927,
1.0,
1.0,
12340) ON
DUPLICATE KEY
UPDATE
CreatureID = CreatureID;
-- Add Squire dialogue text
INSERT
INTO
acore_world.npc_text (ID,
text0_0,
text0_1,
BroadcastTextID0,
lang0,
Probability0,
em0_0,
em0_1,
em0_2,
em0_3,
em0_4,
em0_5,
text1_0,
text1_1,
BroadcastTextID1,
lang1,
Probability1,
em1_0,
em1_1,
em1_2,
em1_3,
em1_4,
em1_5,
text2_0,
text2_1,
BroadcastTextID2,
lang2,
Probability2,
em2_0,
em2_1,
em2_2,
em2_3,
em2_4,
em2_5,
text3_0,
text3_1,
BroadcastTextID3,
lang3,
Probability3,
em3_0,
em3_1,
em3_2,
em3_3,
em3_4,
em3_5,
text4_0,
text4_1,
BroadcastTextID4,
lang4,
Probability4,
em4_0,
em4_1,
em4_2,
em4_3,
em4_4,
em4_5,
text5_0,
text5_1,
BroadcastTextID5,
lang5,
Probability5,
em5_0,
em5_1,
em5_2,
em5_3,
em5_4,
em5_5,
text6_0,
text6_1,
BroadcastTextID6,
lang6,
Probability6,
em6_0,
em6_1,
em6_2,
em6_3,
em6_4,
em6_5,
text7_0,
text7_1,
BroadcastTextID7,
lang7,
Probability7,
em7_0,
em7_1,
em7_2,
em7_3,
em7_4,
em7_5,
VerifiedBuild)
VALUES
(10000000,
'',
'By the grace of the stars, I have been bound to your side, $n. This ancient pile of bones is ready to serve you.',
0,
0,
0.0,
0,
0,
0,
0,
0,
0,
'',
'',
0,
0,
0.0,
0,
0,
0,
0,
0,
0,
'',
'',
0,
0,
0.0,
0,
0,
0,
0,
0,
0,
'',
'',
0,
0,
0.0,
0,
0,
0,
0,
0,
0,
'',
'',
0,
0,
0.0,
0,
0,
0,
0,
0,
0,
'',
'',
0,
0,
0.0,
0,
0,
0,
0,
0,
0,
'',
'',
0,
0,
0.0,
0,
0,
0,
0,
0,
0,
'',
'',
0,
0,
0.0,
0,
0,
0,
0,
0,
0,
0) ON DUPLICATE KEY UPDATE ID = ID;
-- Add squire dialogue text locale
INSERT
INTO
acore_world.npc_text_locale (ID,
Locale,
Text0_0,
Text0_1,
Text1_0,
Text1_1,
Text2_0,
Text2_1,
Text3_0,
Text3_1,
Text4_0,
Text4_1,
Text5_0,
Text5_1,
Text6_0,
Text6_1,
Text7_0,
Text7_1)
VALUES
(10000000,
'ruRU',
'',
'Милостью высших сил я был приставлен к тебе, $n. Эти древние кости готовы служить. Чего желаешь?',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'') ON DUPLICATE KEY UPDATE ID = ID;
+138 -39
View File
@@ -183,6 +183,7 @@ bool AuthSession::Update()
return false;
_queryProcessor.ProcessReadyCallbacks();
_transactionProcessor.ProcessReadyCallbacks();
return true;
}
@@ -309,7 +310,9 @@ bool AuthSession::HandleLogonChallenge()
// Get the account details from the account table
LoginDatabasePreparedStatement* stmt = LoginDatabase.GetPreparedStatement(LOGIN_SEL_LOGONCHALLENGE);
stmt->SetData(0, GetRemoteIpAddress().to_string());
stmt->SetData(1, login);
stmt->SetData(1, GetRemoteIpAddress().to_string());
stmt->SetData(2, _build);
stmt->SetData(3, login);
_queryProcessor.AddCallback(LoginDatabase.AsyncQuery(stmt).WithPreparedCallback(std::bind(&AuthSession::LogonChallengeCallback, this, std::placeholders::_1)));
return true;
@@ -387,8 +390,21 @@ void AuthSession::LogonChallengeCallback(PreparedQueryResult result)
uint8 securityFlags = 0;
// Check if a TOTP token is needed
if (!fields[12].IsNull())
_isDeveloperLogin = fields[15].Get<bool>();
_launcherTicketGeneration.reset();
if (!_isDeveloperLogin && (fields[16].IsNull() || fields[17].IsNull() || fields[18].IsNull()))
{
pkt << uint8(WOW_FAIL_UNKNOWN_ACCOUNT);
SendPacket(pkt);
LOG_INFO("server.authserver", "'{}:{}' [AuthChallenge] launcher ticket required for account id {}",
ipAddress, port, _accountInfo.Id);
return;
}
// Launcher authentication completes MFA before ticket issuance. Only the
// explicitly allowlisted developer flow may request the legacy TOTP UI.
if (_isDeveloperLogin && !fields[12].IsNull())
{
securityFlags = 4;
_totpSecret = fields[12].Get<Binary>();
@@ -406,9 +422,19 @@ void AuthSession::LogonChallengeCallback(PreparedQueryResult result)
}
}
_srp6.emplace(_accountInfo.Login,
fields[13].Get<Binary, Acore::Crypto::SRP6::SALT_LENGTH>(),
fields[14].Get<Binary, Acore::Crypto::SRP6::VERIFIER_LENGTH>());
if (_isDeveloperLogin)
{
_srp6.emplace(_accountInfo.Login,
fields[13].Get<Binary, Acore::Crypto::SRP6::SALT_LENGTH>(),
fields[14].Get<Binary, Acore::Crypto::SRP6::VERIFIER_LENGTH>());
}
else
{
_launcherTicketGeneration = fields[16].Get<Binary, 16>();
_srp6.emplace(_accountInfo.Login,
fields[17].Get<Binary, Acore::Crypto::SRP6::SALT_LENGTH>(),
fields[18].Get<Binary, Acore::Crypto::SRP6::VERIFIER_LENGTH>());
}
// Fill the response packet with the result
if (AuthHelper::IsAcceptedClientBuild(_build))
@@ -515,45 +541,77 @@ bool AuthSession::HandleLogonProof()
// No SQL injection (escaped user name) and IP address as received by socket
std::string address = sConfigMgr->GetOption<bool>("AllowLoggingIPAddressesInDatabase", true, true) ? GetRemoteIpAddress().to_string() : "0.0.0.0";
LoginDatabasePreparedStatement* stmt = LoginDatabase.GetPreparedStatement(LOGIN_UPD_LOGONPROOF);
stmt->SetData(0, _sessionKey);
stmt->SetData(1, address);
stmt->SetData(2, GetLocaleByName(_localizationName));
stmt->SetData(3, _os);
stmt->SetData(4, _accountInfo.Login);
_queryProcessor.AddCallback(LoginDatabase.AsyncQuery(stmt)
.WithPreparedCallback([this, M2 = Acore::Crypto::SRP6::GetSessionVerifier(logonProof->A, logonProof->clientM, _sessionKey)](PreparedQueryResult const&)
Acore::Crypto::SHA1::Digest serverProof = Acore::Crypto::SRP6::GetSessionVerifier(logonProof->A, logonProof->clientM, _sessionKey);
if (_isDeveloperLogin)
{
// Finish SRP6 and send the final result to the client
ByteBuffer packet;
if (_expversion & POST_BC_EXP_FLAG) // 2.x and 3.x clients
LoginDatabasePreparedStatement* stmt = LoginDatabase.GetPreparedStatement(LOGIN_UPD_LOGONPROOF);
stmt->SetData(0, _sessionKey);
stmt->SetData(1, address);
stmt->SetData(2, GetLocaleByName(_localizationName));
stmt->SetData(3, _os);
stmt->SetData(4, _accountInfo.Login);
_queryProcessor.AddCallback(LoginDatabase.AsyncQuery(stmt)
.WithPreparedCallback([this, serverProof](PreparedQueryResult const&)
{
sAuthLogonProof_S proof;
proof.M2 = M2;
proof.cmd = AUTH_LOGON_PROOF;
proof.error = 0;
proof.AccountFlags = _accountInfo.Flags;
proof.SurveyId = 0;
proof.LoginFlags = 0; // 0x1 = has account message
SendLogonProofSuccess(serverProof);
}));
}
else
{
ASSERT(_launcherTicketGeneration);
std::array<uint8, 16> claimNonce = Acore::Crypto::GetRandomBytes<16>();
packet.resize(sizeof(proof));
std::memcpy(packet.contents(), &proof, sizeof(proof));
}
else
LoginDatabaseTransaction transaction = LoginDatabase.BeginTransaction();
LoginDatabasePreparedStatement* consume = LoginDatabase.GetPreparedStatement(LOGIN_INS_LAUNCHER_TICKET_CONSUMPTION);
consume->SetData(0, claimNonce);
consume->SetData(1, _accountInfo.Id);
consume->SetData(2, *_launcherTicketGeneration);
transaction->Append(consume);
LoginDatabasePreparedStatement* remove = LoginDatabase.GetPreparedStatement(LOGIN_DEL_LAUNCHER_TICKET);
remove->SetData(0, _accountInfo.Id);
remove->SetData(1, *_launcherTicketGeneration);
transaction->Append(remove);
LoginDatabasePreparedStatement* update = LoginDatabase.GetPreparedStatement(LOGIN_UPD_LAUNCHER_LOGONPROOF);
update->SetData(0, _sessionKey);
update->SetData(1, address);
update->SetData(2, GetLocaleByName(_localizationName));
update->SetData(3, _os);
update->SetData(4, _accountInfo.Id);
update->SetData(5, *_launcherTicketGeneration);
update->SetData(6, claimNonce);
transaction->Append(update);
std::array<uint8, 16> generation = *_launcherTicketGeneration;
_transactionProcessor.AddCallback(LoginDatabase.AsyncCommitTransaction(transaction)).AfterComplete(
[this, serverProof, generation, claimNonce](bool success)
{
sAuthLogonProof_S_Old proof;
proof.M2 = M2;
proof.cmd = AUTH_LOGON_PROOF;
proof.error = 0;
proof.unk2 = 0x00;
if (!success)
{
SendLogonProofFailure();
return;
}
packet.resize(sizeof(proof));
std::memcpy(packet.contents(), &proof, sizeof(proof));
}
LoginDatabasePreparedStatement* claim = LoginDatabase.GetPreparedStatement(LOGIN_SEL_LAUNCHER_TICKET_CLAIM);
claim->SetData(0, _accountInfo.Id);
claim->SetData(1, generation);
claim->SetData(2, claimNonce);
_queryProcessor.AddCallback(LoginDatabase.AsyncQuery(claim)
.WithPreparedCallback([this, serverProof](PreparedQueryResult result)
{
if (!result)
{
SendLogonProofFailure();
return;
}
SendPacket(packet);
_status = STATUS_AUTHED;
}));
SendLogonProofSuccess(serverProof);
}));
});
}
}
else
{
@@ -618,6 +676,47 @@ bool AuthSession::HandleLogonProof()
return true;
}
void AuthSession::SendLogonProofFailure()
{
ByteBuffer packet;
packet << uint8(AUTH_LOGON_PROOF);
packet << uint8(WOW_FAIL_UNKNOWN_ACCOUNT);
packet << uint16(0);
SendPacket(packet);
}
void AuthSession::SendLogonProofSuccess(Acore::Crypto::SHA1::Digest const& serverProof)
{
ByteBuffer packet;
if (_expversion & POST_BC_EXP_FLAG)
{
sAuthLogonProof_S proof;
proof.M2 = serverProof;
proof.cmd = AUTH_LOGON_PROOF;
proof.error = 0;
proof.AccountFlags = _accountInfo.Flags;
proof.SurveyId = 0;
proof.LoginFlags = 0;
packet.resize(sizeof(proof));
std::memcpy(packet.contents(), &proof, sizeof(proof));
}
else
{
sAuthLogonProof_S_Old proof;
proof.M2 = serverProof;
proof.cmd = AUTH_LOGON_PROOF;
proof.error = 0;
proof.unk2 = 0x00;
packet.resize(sizeof(proof));
std::memcpy(packet.contents(), &proof, sizeof(proof));
}
SendPacket(packet);
_status = STATUS_AUTHED;
}
bool AuthSession::HandleReconnectChallenge()
{
_status = STATUS_CLOSED;
@@ -27,6 +27,7 @@
#include "QueryResult.h"
#include "SRP6.h"
#include "Socket.h"
#include "Transaction.h"
#include <boost/asio/ip/tcp.hpp>
using boost::asio::ip::tcp;
@@ -89,10 +90,14 @@ private:
void LogonChallengeCallback(PreparedQueryResult result);
void ReconnectChallengeCallback(PreparedQueryResult result);
void RealmListCallback(PreparedQueryResult result);
void SendLogonProofFailure();
void SendLogonProofSuccess(Acore::Crypto::SHA1::Digest const& serverProof);
bool VerifyVersion(uint8 const* a, int32 aLength, Acore::Crypto::SHA1::Digest const& versionProof, bool isReconnect);
Optional<Acore::Crypto::SRP6> _srp6;
Optional<std::array<uint8, 16>> _launcherTicketGeneration;
bool _isDeveloperLogin = false;
SessionKey _sessionKey = {};
std::array<uint8, 16> _reconnectProof = {};
@@ -106,6 +111,7 @@ private:
uint8 _expversion;
QueryCallbackProcessor _queryProcessor;
AsyncCallbackProcessor<TransactionCallback> _transactionProcessor;
};
#pragma pack(push, 1)
@@ -27,11 +27,14 @@ void LoginDatabaseConnection::DoPrepareStatements()
"SELECT a.id, a.username, a.locked, a.lock_country, a.last_ip, a.Flags, a.failed_logins, "
"ab.unbandate > UNIX_TIMESTAMP() OR ab.unbandate = ab.bandate, ab.unbandate = ab.bandate, "
"ipb.unbandate > UNIX_TIMESTAMP() OR ipb.unbandate = ipb.bandate, ipb.unbandate = ipb.bandate, "
"aa.gmlevel, a.totp_secret, a.salt, a.verifier "
"aa.gmlevel, a.totp_secret, a.salt, a.verifier, "
"lda.account_id IS NOT NULL, lt.generation_id, lt.srp_salt, lt.srp_verifier "
"FROM account a "
"LEFT JOIN account_access aa ON a.id = aa.id "
"LEFT JOIN account_banned ab ON ab.id = a.id AND ab.active = 1 "
"LEFT JOIN ip_banned ipb ON ipb.ip = ? "
"LEFT JOIN launcher_dev_account lda ON lda.account_id = a.id AND lda.allowed_ip = ? AND lda.enabled = 1 "
"LEFT JOIN launcher_ticket lt ON lt.account_id = a.id AND lt.expires_at > UTC_TIMESTAMP(6) AND lt.client_build = ? "
"WHERE a.username = ?", CONNECTION_ASYNC);
PrepareStatement(LOGIN_SEL_RECONNECTCHALLENGE,
"SELECT a.id, a.username, a.locked, a.lock_country, a.last_ip, a.Flags, a.failed_logins, "
@@ -42,11 +45,11 @@ void LoginDatabaseConnection::DoPrepareStatements()
"LEFT JOIN account_access aa ON a.id = aa.id "
"LEFT JOIN account_banned ab ON ab.id = a.id AND ab.active = 1 "
"LEFT JOIN ip_banned ipb ON ipb.ip = ? "
"WHERE a.username = ? AND a.session_key IS NOT NULL", CONNECTION_ASYNC);
"WHERE a.username = ? AND a.session_key IS NOT NULL AND a.session_auth IN ('launcher', 'dev')", CONNECTION_ASYNC);
PrepareStatement(LOGIN_SEL_ACCOUNT_INFO_BY_NAME, "SELECT a.id, a.session_key, a.last_ip, a.locked, a.lock_country, a.expansion, a.Flags, a.mutetime, a.locale, a.recruiter, a.os, a.totaltime, "
"aa.gmlevel, ab.unbandate > UNIX_TIMESTAMP() OR ab.unbandate = ab.bandate, r.id FROM account a LEFT JOIN account_access aa ON a.id = aa.id AND aa.RealmID IN (-1, ?) "
"LEFT JOIN account_banned ab ON a.id = ab.id AND ab.active = 1 LEFT JOIN account r ON a.id = r.recruiter WHERE a.username = ? "
"AND a.session_key IS NOT NULL ORDER BY aa.RealmID DESC LIMIT 1", CONNECTION_ASYNC);
"AND a.session_key IS NOT NULL AND a.session_auth IN ('launcher', 'dev') ORDER BY aa.RealmID DESC LIMIT 1", CONNECTION_ASYNC);
PrepareStatement(LOGIN_SEL_IP_INFO, "SELECT unbandate > UNIX_TIMESTAMP() OR unbandate = bandate AS banned, NULL as country FROM ip_banned WHERE ip = ?", CONNECTION_ASYNC);
PrepareStatement(LOGIN_SEL_REALMLIST, "SELECT id, name, address, localAddress, localSubnetMask, port, icon, flag, timezone, allowedSecurityLevel, population, gamebuild FROM realmlist WHERE flag <> 3 ORDER BY name", CONNECTION_SYNCH);
PrepareStatement(LOGIN_DEL_EXPIRED_IP_BANS, "DELETE FROM ip_banned WHERE unbandate<>bandate AND unbandate<=UNIX_TIMESTAMP()", CONNECTION_ASYNC);
@@ -61,7 +64,20 @@ void LoginDatabaseConnection::DoPrepareStatements()
PrepareStatement(LOGIN_INS_ACCOUNT_AUTO_BANNED, "INSERT INTO account_banned VALUES (?, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()+?, 'realmd', 'Failed login autoban', 1)", CONNECTION_ASYNC);
PrepareStatement(LOGIN_DEL_ACCOUNT_BANNED, "DELETE FROM account_banned WHERE id = ?", CONNECTION_ASYNC);
PrepareStatement(LOGIN_UPD_LOGON, "UPDATE account SET salt = ?, verifier = ? WHERE id = ?", CONNECTION_ASYNC);
PrepareStatement(LOGIN_UPD_LOGONPROOF, "UPDATE account SET session_key = ?, last_ip = ?, last_login = NOW(), locale = ?, failed_logins = 0, os = ? WHERE username = ?", CONNECTION_ASYNC);
PrepareStatement(LOGIN_UPD_LOGONPROOF, "UPDATE account SET session_key = ?, session_auth = 'dev', last_ip = ?, last_login = NOW(), locale = ?, failed_logins = 0, os = ? WHERE username = ?", CONNECTION_ASYNC);
PrepareStatement(LOGIN_INS_LAUNCHER_TICKET_CONSUMPTION,
"INSERT INTO launcher_ticket_consumption (generation_id, account_id, claim_nonce, consumed_at) "
"SELECT generation_id, account_id, ?, UTC_TIMESTAMP(6) FROM launcher_ticket "
"WHERE account_id = ? AND generation_id = ? AND expires_at > UTC_TIMESTAMP(6)", CONNECTION_ASYNC);
PrepareStatement(LOGIN_DEL_LAUNCHER_TICKET,
"DELETE FROM launcher_ticket WHERE account_id = ? AND generation_id = ?", CONNECTION_ASYNC);
PrepareStatement(LOGIN_UPD_LAUNCHER_LOGONPROOF,
"UPDATE account a JOIN launcher_ticket_consumption c ON c.account_id = a.id "
"SET a.session_key = ?, a.session_auth = 'launcher', a.last_ip = ?, a.last_login = NOW(), "
"a.locale = ?, a.failed_logins = 0, a.os = ? "
"WHERE a.id = ? AND c.generation_id = ? AND c.claim_nonce = ?", CONNECTION_ASYNC);
PrepareStatement(LOGIN_SEL_LAUNCHER_TICKET_CLAIM,
"SELECT 1 FROM launcher_ticket_consumption WHERE account_id = ? AND generation_id = ? AND claim_nonce = ?", CONNECTION_ASYNC);
PrepareStatement(LOGIN_UPD_FAILEDLOGINS, "UPDATE account SET failed_logins = failed_logins + 1 WHERE username = ?", CONNECTION_ASYNC);
PrepareStatement(LOGIN_SEL_FAILEDLOGINS, "SELECT id, failed_logins FROM account WHERE username = ?", CONNECTION_SYNCH);
PrepareStatement(LOGIN_SEL_ACCOUNT_ID_BY_NAME, "SELECT id FROM account WHERE username = ?", CONNECTION_SYNCH);
@@ -41,6 +41,10 @@ enum LoginDatabaseStatements : uint32
LOGIN_DEL_ACCOUNT_BANNED,
LOGIN_UPD_LOGON,
LOGIN_UPD_LOGONPROOF,
LOGIN_INS_LAUNCHER_TICKET_CONSUMPTION,
LOGIN_DEL_LAUNCHER_TICKET,
LOGIN_UPD_LAUNCHER_LOGONPROOF,
LOGIN_SEL_LAUNCHER_TICKET_CLAIM,
LOGIN_SEL_LOGONCHALLENGE,
LOGIN_SEL_RECONNECTCHALLENGE,
LOGIN_UPD_FAILEDLOGINS,