2026-08-22 18:19:13 +03:00
|
|
|
|
#include "authservice.h"
|
|
|
|
|
|
|
|
|
|
|
|
#include <QCryptographicHash>
|
|
|
|
|
|
#include <QDir>
|
|
|
|
|
|
#include <QFile>
|
|
|
|
|
|
#include <QFileInfo>
|
|
|
|
|
|
#include <QJsonDocument>
|
|
|
|
|
|
#include <QNetworkAccessManager>
|
|
|
|
|
|
#include <QNetworkReply>
|
|
|
|
|
|
#include <QNetworkRequest>
|
|
|
|
|
|
#include <QUuid>
|
|
|
|
|
|
|
|
|
|
|
|
namespace {
|
|
|
|
|
|
|
|
|
|
|
|
const QString kAuthServer = QStringLiteral("https://authserver.ely.by");
|
2026-08-22 23:39:46 +00:00
|
|
|
|
const QString kInjectorManifest = QStringLiteral("https://authlib-injector.yushi.moe/artifact/latest.json");
|
2026-08-22 18:19:13 +03:00
|
|
|
|
const QString kInjectorFileName = QStringLiteral("authlib-injector.jar");
|
|
|
|
|
|
|
|
|
|
|
|
// Ely.by отдаёт UUID без дефисов, вручную введённый мог прийти и с ними.
|
|
|
|
|
|
QString normalizeUuid(const QString &uuid)
|
|
|
|
|
|
{
|
|
|
|
|
|
return QString(uuid).remove(u'-');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
QString describeElyError(const QJsonObject &response, int status)
|
|
|
|
|
|
{
|
|
|
|
|
|
const QString message = response.value(QStringLiteral("errorMessage")).toString();
|
|
|
|
|
|
if (!message.isEmpty())
|
|
|
|
|
|
return message;
|
|
|
|
|
|
const QString error = response.value(QStringLiteral("error")).toString();
|
|
|
|
|
|
if (!error.isEmpty())
|
|
|
|
|
|
return error;
|
|
|
|
|
|
return QObject::tr("Сервер авторизации вернул код %1").arg(status);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
} // namespace
|
|
|
|
|
|
|
|
|
|
|
|
AuthService::AuthService(QObject *parent)
|
|
|
|
|
|
: QObject(parent)
|
|
|
|
|
|
, m_network(new QNetworkAccessManager(this))
|
2026-08-22 23:39:46 +00:00
|
|
|
|
{
|
|
|
|
|
|
}
|
2026-08-22 18:19:13 +03:00
|
|
|
|
|
|
|
|
|
|
AuthResult AuthService::offline(const QString &nickname)
|
|
|
|
|
|
{
|
|
|
|
|
|
AuthResult result;
|
|
|
|
|
|
if (nickname.isEmpty()) {
|
|
|
|
|
|
result.error = tr("Для офлайн-запуска нужен никнейм");
|
|
|
|
|
|
return result;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-22 23:39:46 +00:00
|
|
|
|
QByteArray digest = QCryptographicHash::hash(
|
|
|
|
|
|
QStringLiteral("OfflinePlayer:%1").arg(nickname).toUtf8(), QCryptographicHash::Md5);
|
|
|
|
|
|
digest[6] = static_cast<char>((digest[6] & 0x0f) | 0x30); // версия UUID = 3
|
|
|
|
|
|
digest[8] = static_cast<char>((digest[8] & 0x3f) | 0x80); // вариант RFC 4122
|
2026-08-22 18:19:13 +03:00
|
|
|
|
|
|
|
|
|
|
result.ok = true;
|
|
|
|
|
|
result.playerName = nickname;
|
|
|
|
|
|
result.uuid = QString::fromLatin1(digest.toHex());
|
|
|
|
|
|
result.accessToken = QStringLiteral("0");
|
|
|
|
|
|
result.clientToken = QStringLiteral("0");
|
|
|
|
|
|
result.userType = QStringLiteral("legacy");
|
|
|
|
|
|
return result;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
QString AuthService::generateClientToken()
|
|
|
|
|
|
{
|
|
|
|
|
|
return QUuid::createUuid().toString(QUuid::WithoutBraces);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-22 23:39:46 +00:00
|
|
|
|
void AuthService::postJson(
|
|
|
|
|
|
const QString &endpoint,
|
|
|
|
|
|
const QJsonObject &body,
|
|
|
|
|
|
std::function<void(int, const QJsonObject &, const QString &)> handler)
|
2026-08-22 18:19:13 +03:00
|
|
|
|
{
|
|
|
|
|
|
QNetworkRequest request{QUrl(kAuthServer + endpoint)};
|
|
|
|
|
|
request.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/json"));
|
|
|
|
|
|
request.setAttribute(QNetworkRequest::RedirectPolicyAttribute,
|
|
|
|
|
|
QNetworkRequest::NoLessSafeRedirectPolicy);
|
|
|
|
|
|
|
2026-08-22 23:39:46 +00:00
|
|
|
|
QNetworkReply *reply = m_network->post(request, QJsonDocument(body).toJson(QJsonDocument::Compact));
|
2026-08-22 18:19:13 +03:00
|
|
|
|
connect(reply, &QNetworkReply::finished, this, [reply, handler = std::move(handler)]() {
|
|
|
|
|
|
reply->deleteLater();
|
|
|
|
|
|
|
|
|
|
|
|
const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
|
|
|
|
|
|
const QByteArray payload = reply->readAll();
|
|
|
|
|
|
|
|
|
|
|
|
// Сетевую ошибку отличаем от ответа сервера: при 401 статус есть, а error — есть всегда.
|
|
|
|
|
|
QString transportError;
|
|
|
|
|
|
if (status == 0 && reply->error() != QNetworkReply::NoError)
|
|
|
|
|
|
transportError = reply->errorString();
|
|
|
|
|
|
|
|
|
|
|
|
handler(status, QJsonDocument::fromJson(payload).object(), transportError);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void AuthService::loginElyBy(const QString &login,
|
|
|
|
|
|
const QString &password,
|
|
|
|
|
|
const QString &clientToken,
|
|
|
|
|
|
const QString &accessToken,
|
|
|
|
|
|
Callback callback)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (login.isEmpty()) {
|
|
|
|
|
|
AuthResult result;
|
|
|
|
|
|
result.error = tr("Укажите логин Ely.by в настройках профиля");
|
|
|
|
|
|
callback(result);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const QString token = clientToken.isEmpty() ? generateClientToken() : clientToken;
|
|
|
|
|
|
|
|
|
|
|
|
// Токена ещё нет — сразу за паролем.
|
|
|
|
|
|
if (accessToken.isEmpty()) {
|
|
|
|
|
|
authenticate(login, password, token, std::move(callback));
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
emit progress(tr("Проверка сохранённого токена Ely.by…"));
|
|
|
|
|
|
postJson(QStringLiteral("/auth/validate"),
|
|
|
|
|
|
{{QStringLiteral("accessToken"), accessToken}},
|
2026-08-22 23:39:46 +00:00
|
|
|
|
[this, login, password, token, accessToken, callback](
|
|
|
|
|
|
int status, const QJsonObject &, const QString &transportError) {
|
2026-08-22 18:19:13 +03:00
|
|
|
|
if (!transportError.isEmpty()) {
|
|
|
|
|
|
AuthResult result;
|
|
|
|
|
|
result.error = tr("Нет связи с authserver.ely.by: %1").arg(transportError);
|
|
|
|
|
|
callback(result);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (status == 200) {
|
|
|
|
|
|
AuthResult result;
|
|
|
|
|
|
result.ok = true;
|
|
|
|
|
|
result.accessToken = accessToken;
|
|
|
|
|
|
result.clientToken = token;
|
|
|
|
|
|
result.userType = QStringLiteral("ELYBY");
|
|
|
|
|
|
callback(result);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Токен протух — пробуем продлить его, не спрашивая пароль.
|
|
|
|
|
|
emit progress(tr("Обновление токена Ely.by…"));
|
|
|
|
|
|
postJson(QStringLiteral("/auth/refresh"),
|
|
|
|
|
|
{{QStringLiteral("accessToken"), accessToken},
|
|
|
|
|
|
{QStringLiteral("clientToken"), token},
|
|
|
|
|
|
{QStringLiteral("requestUser"), true}},
|
2026-08-22 23:39:46 +00:00
|
|
|
|
[this, login, password, token, callback](
|
|
|
|
|
|
int refreshStatus, const QJsonObject &response, const QString &error) {
|
2026-08-22 18:19:13 +03:00
|
|
|
|
if (error.isEmpty() && refreshStatus == 200) {
|
|
|
|
|
|
AuthResult result;
|
|
|
|
|
|
result.ok = true;
|
2026-08-22 23:39:46 +00:00
|
|
|
|
result.accessToken = response.value(QStringLiteral("accessToken")).toString();
|
2026-08-22 18:19:13 +03:00
|
|
|
|
result.clientToken = token;
|
2026-08-22 23:39:46 +00:00
|
|
|
|
const QJsonObject profile =
|
|
|
|
|
|
response.value(QStringLiteral("selectedProfile")).toObject();
|
|
|
|
|
|
result.playerName = profile.value(QStringLiteral("name")).toString();
|
|
|
|
|
|
result.uuid = normalizeUuid(profile.value(QStringLiteral("id")).toString());
|
2026-08-22 18:19:13 +03:00
|
|
|
|
result.userType = QStringLiteral("ELYBY");
|
|
|
|
|
|
callback(result);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
authenticate(login, password, token, callback);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void AuthService::loginElyByWithTotp(const QString &login,
|
|
|
|
|
|
const QString &password,
|
|
|
|
|
|
const QString &totp,
|
|
|
|
|
|
const QString &clientToken,
|
|
|
|
|
|
Callback callback)
|
|
|
|
|
|
{
|
|
|
|
|
|
// Ely.by ждёт двухфакторный код прямо в пароле, через двоеточие.
|
|
|
|
|
|
authenticate(login,
|
|
|
|
|
|
password + u':' + totp,
|
|
|
|
|
|
clientToken.isEmpty() ? generateClientToken() : clientToken,
|
|
|
|
|
|
std::move(callback));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void AuthService::authenticate(const QString &login,
|
|
|
|
|
|
const QString &password,
|
|
|
|
|
|
const QString &clientToken,
|
|
|
|
|
|
Callback callback)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (password.isEmpty()) {
|
|
|
|
|
|
AuthResult result;
|
|
|
|
|
|
result.error = tr("Укажите пароль Ely.by в настройках профиля");
|
|
|
|
|
|
callback(result);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
emit progress(tr("Авторизация на Ely.by…"));
|
|
|
|
|
|
postJson(QStringLiteral("/auth/authenticate"),
|
|
|
|
|
|
{{QStringLiteral("username"), login},
|
|
|
|
|
|
{QStringLiteral("password"), password},
|
|
|
|
|
|
{QStringLiteral("clientToken"), clientToken},
|
|
|
|
|
|
{QStringLiteral("requestUser"), true}},
|
2026-08-22 23:39:46 +00:00
|
|
|
|
[clientToken, callback](int status, const QJsonObject &response, const QString &transportError) {
|
2026-08-22 18:19:13 +03:00
|
|
|
|
AuthResult result;
|
|
|
|
|
|
result.clientToken = clientToken;
|
|
|
|
|
|
|
|
|
|
|
|
if (!transportError.isEmpty()) {
|
2026-08-22 23:39:46 +00:00
|
|
|
|
result.error = AuthService::tr("Нет связи с authserver.ely.by: %1").arg(transportError);
|
2026-08-22 18:19:13 +03:00
|
|
|
|
callback(result);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (status != 200) {
|
|
|
|
|
|
const QString message = describeElyError(response, status);
|
|
|
|
|
|
// Единственный случай, когда ошибка лечится вводом кода, а не пароля.
|
|
|
|
|
|
result.twoFactorRequired = message.contains(QStringLiteral("two factor"),
|
|
|
|
|
|
Qt::CaseInsensitive);
|
|
|
|
|
|
result.error = result.twoFactorRequired
|
2026-08-22 23:39:46 +00:00
|
|
|
|
? AuthService::tr("Аккаунт защищён двухфакторной аутентификацией")
|
2026-08-22 18:19:13 +03:00
|
|
|
|
: message;
|
|
|
|
|
|
callback(result);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-22 23:39:46 +00:00
|
|
|
|
const QJsonObject profile = response.value(QStringLiteral("selectedProfile")).toObject();
|
2026-08-22 18:19:13 +03:00
|
|
|
|
result.playerName = profile.value(QStringLiteral("name")).toString();
|
|
|
|
|
|
result.uuid = normalizeUuid(profile.value(QStringLiteral("id")).toString());
|
|
|
|
|
|
result.accessToken = response.value(QStringLiteral("accessToken")).toString();
|
|
|
|
|
|
result.userType = QStringLiteral("ELYBY");
|
|
|
|
|
|
result.ok = !result.accessToken.isEmpty() && !result.playerName.isEmpty();
|
|
|
|
|
|
if (!result.ok)
|
2026-08-22 23:39:46 +00:00
|
|
|
|
result.error = AuthService::tr("Ely.by не вернул игровой профиль для этого аккаунта");
|
2026-08-22 18:19:13 +03:00
|
|
|
|
callback(result);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void AuthService::ensureAuthlibInjector(
|
2026-08-22 23:39:46 +00:00
|
|
|
|
const QString &targetDir,
|
|
|
|
|
|
std::function<void(const QString &, const QString &)> callback)
|
2026-08-22 18:19:13 +03:00
|
|
|
|
{
|
|
|
|
|
|
QDir().mkpath(targetDir);
|
|
|
|
|
|
const QString jarPath = targetDir + u'/' + kInjectorFileName;
|
|
|
|
|
|
if (QFileInfo::exists(jarPath)) {
|
|
|
|
|
|
callback(jarPath, {});
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
emit progress(tr("Загрузка authlib-injector…"));
|
|
|
|
|
|
QNetworkRequest manifestRequest{QUrl(kInjectorManifest)};
|
|
|
|
|
|
manifestRequest.setAttribute(QNetworkRequest::RedirectPolicyAttribute,
|
|
|
|
|
|
QNetworkRequest::NoLessSafeRedirectPolicy);
|
|
|
|
|
|
|
|
|
|
|
|
QNetworkReply *manifestReply = m_network->get(manifestRequest);
|
|
|
|
|
|
connect(manifestReply, &QNetworkReply::finished, this, [this, manifestReply, jarPath, callback]() {
|
|
|
|
|
|
manifestReply->deleteLater();
|
|
|
|
|
|
if (manifestReply->error() != QNetworkReply::NoError) {
|
2026-08-22 23:39:46 +00:00
|
|
|
|
callback({}, tr("Не удалось получить сведения об authlib-injector: %1")
|
|
|
|
|
|
.arg(manifestReply->errorString()));
|
2026-08-22 18:19:13 +03:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const QJsonObject manifest = QJsonDocument::fromJson(manifestReply->readAll()).object();
|
|
|
|
|
|
const QString url = manifest.value(QStringLiteral("download_url")).toString();
|
|
|
|
|
|
const QString expectedSha256 = manifest.value(QStringLiteral("checksums"))
|
|
|
|
|
|
.toObject()
|
|
|
|
|
|
.value(QStringLiteral("sha256"))
|
|
|
|
|
|
.toString();
|
|
|
|
|
|
if (url.isEmpty()) {
|
|
|
|
|
|
callback({}, tr("В манифесте authlib-injector нет ссылки на файл"));
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
QNetworkRequest jarRequest{QUrl(url)};
|
|
|
|
|
|
jarRequest.setAttribute(QNetworkRequest::RedirectPolicyAttribute,
|
|
|
|
|
|
QNetworkRequest::NoLessSafeRedirectPolicy);
|
|
|
|
|
|
QNetworkReply *jarReply = m_network->get(jarRequest);
|
2026-08-22 23:39:46 +00:00
|
|
|
|
connect(jarReply, &QNetworkReply::finished, this, [jarReply, jarPath, expectedSha256, callback]() {
|
|
|
|
|
|
jarReply->deleteLater();
|
|
|
|
|
|
if (jarReply->error() != QNetworkReply::NoError) {
|
|
|
|
|
|
callback({}, AuthService::tr("Не удалось скачать authlib-injector: %1").arg(jarReply->errorString()));
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const QByteArray payload = jarReply->readAll();
|
|
|
|
|
|
if (!expectedSha256.isEmpty()) {
|
|
|
|
|
|
const QString actual = QString::fromLatin1(
|
|
|
|
|
|
QCryptographicHash::hash(payload, QCryptographicHash::Sha256).toHex());
|
|
|
|
|
|
if (actual != expectedSha256) {
|
|
|
|
|
|
callback({}, AuthService::tr("Контрольная сумма authlib-injector не совпала"));
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
QFile file(jarPath);
|
|
|
|
|
|
if (!file.open(QIODevice::WriteOnly) || file.write(payload) != payload.size()) {
|
|
|
|
|
|
callback({}, AuthService::tr("Не удалось сохранить %1").arg(QDir::toNativeSeparators(jarPath)));
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
file.close();
|
|
|
|
|
|
callback(jarPath, {});
|
|
|
|
|
|
});
|
2026-08-22 18:19:13 +03:00
|
|
|
|
});
|
|
|
|
|
|
}
|