From d6986057d12cafee9cb9789376c915c4ec64946b Mon Sep 17 00:00:00 2001 From: galeon Date: Tue, 1 Sep 2026 22:25:23 +0300 Subject: [PATCH] license login feature --- CMakeLists.txt | 31 +++ Main.qml | 111 ++++++++- MicrosoftLoginDialog.qml | 132 +++++++++++ authservice.h | 9 + gamelauncher.cpp | 3 +- gamelauncher.h | 1 + launcherbackend.cpp | 214 +++++++++++++++++- launcherbackend.h | 33 ++- main.cpp | 11 + msaauthservice.cpp | 469 +++++++++++++++++++++++++++++++++++++++ msaauthservice.h | 67 ++++++ 11 files changed, 1062 insertions(+), 19 deletions(-) create mode 100644 MicrosoftLoginDialog.qml create mode 100644 msaauthservice.cpp create mode 100644 msaauthservice.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 879947e..e4670ed 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -6,8 +6,31 @@ project(Minecraft_launcher VERSION 0.1 LANGUAGES CXX C) set(CMAKE_CXX_STANDARD_REQUIRED ON) # CorePrivate нужен ради QZipReader — им распаковываются нативные библиотеки LWJGL. +# Про привязку к версии Qt из-за него предупреждать не нужно: это осознанный выбор. +set(QT_NO_PRIVATE_MODULE_WARNING ON) find_package(Qt6 REQUIRED COMPONENTS Quick QuickControls2 Core CorePrivate Gui Network) +# ── Qt WebEngine: окно входа в аккаунт Microsoft ──────────────────────────── +# +# Модуль необязательный, и это принципиально: он ставится отдельной галочкой в +# установщике Qt и тянет за собой WebChannel с Positioning, которых в типовой +# установке нет. Если сделать его обязательным, у любого, кто их не поставил, +# проект перестаёт конфигурироваться целиком — вместе с офлайном и Ely.by. +# +# Без модуля лаунчер собирается и работает как раньше, только вход через +# Microsoft сообщает, что эта сборка его не умеет. +find_package(Qt6 QUIET COMPONENTS WebEngineQuick) +if(Qt6WebEngineQuick_FOUND) + message(STATUS "Qt WebEngine найден: вход через аккаунт Microsoft будет доступен") + set(LAUNCHER_MSA_QML_FILES MicrosoftLoginDialog.qml) +else() + message(WARNING + "Qt WebEngine не найден — лаунчер соберётся без входа через аккаунт Microsoft. " + "Чтобы его включить, доустановите в MaintenanceTool для своей версии Qt сразу три " + "компонента: Qt WebEngine, Qt WebChannel и Qt Positioning.") + set(LAUNCHER_MSA_QML_FILES "") +endif() + qt_standard_project_setup(REQUIRES 6.8) qt_add_executable(appMinecraft_launcher @@ -35,6 +58,8 @@ qt_add_qml_module(appMinecraft_launcher javainstaller.cpp authservice.h authservice.cpp + msaauthservice.h + msaauthservice.cpp gamelauncher.h gamelauncher.cpp versionmanifestservice.h @@ -64,6 +89,7 @@ qt_add_qml_module(appMinecraft_launcher SeasonalBuildsDialog.qml LoaderRow.qml ProgressPanel.qml + ${LAUNCHER_MSA_QML_FILES} RESOURCES images/Folder/Folder_Active.svg images/Folder/Folder_Idle.svg images/Folder/Folder_Pressed.svg images/Options/Options_active.svg images/Options/Options_Idle.svg images/Options/Options_Pressed.svg images/Play_Button/Play_Active.svg images/Play_Button/Play_Idle.svg images/Play_Button/Play_pressed.svg images/Profile_Box/Asset_23.jpg images/Profile_Box/Asset_23.svg images/Profile_Box/Asset_24.jpg images/Profile_Box/Asset_24.svg images/Profile_Box/Kishka_Profile_Active.svg images/Profile_Box/Kishka_Profile_Idle.svg images/Profile_Box/Kishka_Profile_open.svg images/Scroll/Scroll_Lever.svg images/Scroll/Scroll_palka.svg images/Version_box/Kishka_Profile_Open.svg images/Version_box/Kishka_Ver_Active.svg images/Version_box/Kishka_Ver_idle.svg images/Trash.svg images/Pencil.svg images/GovuztTW8AAHqBf.jpeg images/photo_2025-12-16_15-04-17.jpg images/photo_2025-12-21_02-30-09.jpg ) @@ -82,6 +108,11 @@ target_link_libraries(appMinecraft_launcher PRIVATE Qt6::Quick Qt6::QuickControls2 Qt6::Core Qt6::CorePrivate Qt6::Gui Qt6::Network ) +if(Qt6WebEngineQuick_FOUND) + target_link_libraries(appMinecraft_launcher PRIVATE Qt6::WebEngineQuick) + target_compile_definitions(appMinecraft_launcher PRIVATE LAUNCHER_HAS_WEBENGINE) +endif() + include(GNUInstallDirs) # ── Эталонный zlib для установщиков Forge и NeoForge ──────────────────────── diff --git a/Main.qml b/Main.qml index 2bc084e..f90791c 100644 --- a/Main.qml +++ b/Main.qml @@ -31,6 +31,13 @@ Window { : qsTr("Игра закрыта"), crashed || exitCode !== 0 ? "#cc3333" : "#555555") } + onMicrosoftLoginUrlReady: (url) => window.openMicrosoftLogin(url) + // Выбор в списке здесь не трогаем: новый профиль уже выбран тем, кто + // его создал, а повторный вход мог быть и не в последний профиль. + onMicrosoftLoginSucceeded: (playerName) => + window.showToast(qsTr("Вход выполнен: %1").arg(playerName), "#4b7a1f") + onMicrosoftLoginFailed: (message) => window.showToast(message, "#cc3333", 8000) + onMicrosoftReloginRequired: (profileIndex) => backend.startMicrosoftLogin(profileIndex) onGameOutput: (line) => console.log(line) onSeasonalInstallFinished: (seasonalId, buildName) => { window.showToast(qsTr("Сборка «%1» установлена — можно запускать").arg(buildName), @@ -52,6 +59,27 @@ Window { } } + // Окно входа Microsoft создаётся по требованию, а не вместе с главным + // окном: MicrosoftLoginDialog.qml попадает в модуль только в сборках с Qt + // WebEngine, и обычная декларация сломала бы всё окно в остальных. + property var microsoftLoginDialog: null + + function openMicrosoftLogin(url) { + if (microsoftLoginDialog === null) { + const component = Qt.createComponent("MicrosoftLoginDialog.qml") + if (component.status !== Component.Ready) { + backend.cancelMicrosoftLogin() + window.showToast(qsTr("Окно входа Microsoft недоступно: сборка без Qt WebEngine"), + "#cc3333", 8000) + return + } + microsoftLoginDialog = component.createObject(window, { backend: backend }) + microsoftLoginDialog.failed.connect( + (message) => window.showToast(message, "#cc3333", 6000)) + } + microsoftLoginDialog.openAt(url) + } + function formatMb(bytes) { return (bytes / 1048576).toFixed(1) } @@ -557,7 +585,8 @@ Window { id: pfLogin x: 20 width: parent.width - 40 - placeholderText: "Логин" + enabled: pfAuth.currentIndex !== 2 + placeholderText: pfAuth.currentIndex === 2 ? "Ник придёт из аккаунта" : "Логин" color: "#ffffff" placeholderTextColor: "#666666" background: Rectangle { @@ -573,7 +602,7 @@ Window { x: 20 width: parent.width - 40 enabled: pfAuth.currentIndex === 1 - placeholderText: pfAuth.currentIndex === 1 ? "Пароль Ely.by" : "Не нужен в офлайне" + placeholderText: pfAuth.currentIndex === 1 ? "Пароль Ely.by" : "Не нужен для этого типа" echoMode: TextInput.Password color: "#ffffff" placeholderTextColor: "#666666" @@ -589,7 +618,9 @@ Window { id: pfAuth x: 20 width: parent.width - 40 - model: ["Офлайн (без пароля)", "Ely.by"] + model: backend.microsoftAvailable + ? ["Офлайн (без пароля)", "Ely.by", "Microsoft (лицензия)"] + : ["Офлайн (без пароля)", "Ely.by"] } } @@ -641,10 +672,14 @@ Window { onAccepted: { const name = pfName.text.trim() + const type = pfAuth.currentIndex === 2 ? "microsoft" + : pfAuth.currentIndex === 1 ? "elyby" : "offline" if (name !== "") { - backend.addProfile(name, pfLogin.text.trim(), pfPassword.text, - pfAuth.currentIndex === 1 ? "elyby" : "offline") + backend.addProfile(name, pfLogin.text.trim(), pfPassword.text, type) profileBox.currentIndex = backend.profileNames.length - 1 + // Профиль Microsoft без входа бесполезен: сразу показываем окно. + if (type === "microsoft") + backend.startMicrosoftLogin(backend.profileNames.length - 1) } pfName.text = ""; pfLogin.text = ""; pfPassword.text = ""; pfAuth.currentIndex = 0 } @@ -665,6 +700,10 @@ Window { padding: 0 property int editIndex: -1 + // Состояние входа в Microsoft у открытого профиля — для строки статуса. + property bool msProfile: false + property bool msLinked: false + property string msName: "" function openFor(index) { const data = backend.profileAt(index) @@ -672,7 +711,10 @@ Window { epName.text = data.name || "" epLogin.text = data.login || "" epPassword.text = data.password || "" - epAuth.currentIndex = data.authType === "elyby" ? 1 : 0 + msProfile = data.authType === "microsoft" + epAuth.currentIndex = msProfile ? 2 : data.authType === "elyby" ? 1 : 0 + msLinked = data.hasMicrosoftSession === true + msName = data.resolvedName || "" profileBox.popup.close() open() } @@ -725,7 +767,8 @@ Window { id: epLogin x: 20 width: parent.width - 40 - placeholderText: "Логин" + enabled: epAuth.currentIndex !== 2 + placeholderText: epAuth.currentIndex === 2 ? "Ник придёт из аккаунта" : "Логин" color: "#ffffff" placeholderTextColor: "#666666" background: Rectangle { @@ -741,7 +784,7 @@ Window { x: 20 width: parent.width - 40 enabled: epAuth.currentIndex === 1 - placeholderText: epAuth.currentIndex === 1 ? "Пароль Ely.by" : "Не нужен в офлайне" + placeholderText: epAuth.currentIndex === 1 ? "Пароль Ely.by" : "Не нужен для этого типа" echoMode: TextInput.Password color: "#ffffff" placeholderTextColor: "#666666" @@ -757,7 +800,54 @@ Window { id: epAuth x: 20 width: parent.width - 40 - model: ["Офлайн (без пароля)", "Ely.by"] + // Тип уже сохранённого профиля показываем всегда: иначе в + // сборке без WebEngine он молча стал бы офлайновым. + model: (backend.microsoftAvailable || editProfileDialog.msProfile) + ? ["Офлайн (без пароля)", "Ely.by", "Microsoft (лицензия)"] + : ["Офлайн (без пароля)", "Ely.by"] + } + + // Состояние аккаунта Microsoft и кнопка повторного входа: токен + // может протухнуть, а сменить аккаунт иначе нечем. + Column { + x: 20 + width: parent.width - 40 + spacing: 8 + visible: epAuth.currentIndex === 2 + + Text { + text: editProfileDialog.msLinked + ? qsTr("Вход выполнен: %1").arg(editProfileDialog.msName) + : qsTr("Вход не выполнен") + color: editProfileDialog.msLinked ? "#91B315" : "#aaaaaa" + font.pixelSize: 13 + } + + Button { + width: parent.width; height: 34 + enabled: backend.microsoftAvailable + text: editProfileDialog.msLinked ? "Войти заново" : "Войти в Microsoft" + contentItem: Text { + text: parent.text + color: "#ffffff" + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + background: Rectangle { + color: parent.pressed ? "#6a8510" : "#91B315" + radius: 6 + } + onClicked: { + // Тип профиля мог быть только что переключён на + // Microsoft — сохраняем его до входа, иначе backend + // припишет токены профилю другого типа. + backend.updateProfile(editProfileDialog.editIndex, + epName.text.trim(), epLogin.text.trim(), + epPassword.text, "microsoft") + editProfileDialog.close() + backend.startMicrosoftLogin(editProfileDialog.editIndex) + } + } } } @@ -811,7 +901,8 @@ Window { const name = epName.text.trim() if (editIndex >= 0 && name !== "") backend.updateProfile(editIndex, name, epLogin.text.trim(), epPassword.text, - epAuth.currentIndex === 1 ? "elyby" : "offline") + epAuth.currentIndex === 2 ? "microsoft" + : epAuth.currentIndex === 1 ? "elyby" : "offline") editIndex = -1 epName.text = ""; epLogin.text = ""; epPassword.text = "" } diff --git a/MicrosoftLoginDialog.qml b/MicrosoftLoginDialog.qml new file mode 100644 index 0000000..f5163bd --- /dev/null +++ b/MicrosoftLoginDialog.qml @@ -0,0 +1,132 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls 2.15 +import QtWebEngine + +// Вход в аккаунт Microsoft — то же самое, что делает официальный лаунчер: +// страница входа показывается внутри приложения, а лаунчер ждёт, когда браузер +// уйдёт на redirect_uri с кодом авторизации в адресе. +Dialog { + id: msLogin + + required property var backend + + // Окно ничего не знает про тосты главного окна: о неудаче сообщает сигналом. + signal failed(string message) + + // Код уже отдан backend — второй раз по тому же адресу не реагируем: + // WebEngineView успевает сообщить об urlChanged несколько раз. + property bool codeTaken: false + + anchors.centerIn: parent + modal: true + width: 560 + height: 680 + padding: 0 + closePolicy: Popup.NoAutoClose + + function openAt(url) { + codeTaken = false + web.url = url + open() + } + + // Разбор адреса живёт в C++: правила совпадения должны совпадать с теми, + // по которым сервис строит сам redirect_uri. + function handleUrl(url) { + if (codeTaken) + return + const info = backend.inspectMicrosoftRedirect(String(url)) + if (!info.matched) + return + + codeTaken = true + close() + if (info.code !== "") { + backend.finishMicrosoftLogin(info.code) + } else { + backend.cancelMicrosoftLogin() + msLogin.failed(info.error !== "" ? info.error : "Вход в Microsoft не завершён") + } + } + + background: Rectangle { + color: "#1e1e1e" + radius: 10 + border.color: "#91B315" + border.width: 1 + } + + header: Item { + implicitHeight: 52 + Text { + anchors.centerIn: parent + text: "Вход в аккаунт Microsoft" + color: "#ffffff" + font.pixelSize: 17 + font.bold: true + } + Rectangle { + anchors.bottom: parent.bottom + width: parent.width + height: 1 + color: "#333333" + } + } + + contentItem: Item { + // Профиль без storageName — значит без диска: куки живут только пока + // работает лаунчер и в общий браузер не попадают. За выбор аккаунта в + // пределах сессии отвечает prompt=select_account в адресе входа. + WebEngineProfilePrototype { + id: msProfile + } + + WebEngineView { + id: web + anchors.fill: parent + anchors.margins: 12 + + profile: msProfile.instance() + + onUrlChanged: msLogin.handleUrl(url) + } + + BusyIndicator { + anchors.centerIn: parent + running: web.loading + visible: running + } + } + + footer: Item { + implicitHeight: 60 + Rectangle { + anchors.top: parent.top + width: parent.width + height: 1 + color: "#333333" + } + Button { + anchors.centerIn: parent + text: "Отмена" + width: 110; height: 36 + contentItem: Text { + text: parent.text + color: "#ffffff" + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + background: Rectangle { + color: parent.pressed ? "#444444" : "#333333" + radius: 6 + } + onClicked: { + msLogin.codeTaken = true + msLogin.close() + msLogin.backend.cancelMicrosoftLogin() + } + } + } +} diff --git a/authservice.h b/authservice.h index e957419..26aaa71 100644 --- a/authservice.h +++ b/authservice.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -12,6 +13,9 @@ struct AuthResult { bool ok = false; bool twoFactorRequired = false; + // Вход в Microsoft прошёл, но копии игры на аккаунте нет: чинится только + // покупкой, поэтому обрабатывается отдельно от прочих ошибок. + bool licenseMissing = false; QString error; QString playerName; // ${auth_player_name} @@ -19,6 +23,11 @@ struct AuthResult QString accessToken; // ${auth_access_token} QString clientToken; // ${clientid} QString userType; // ${user_type}: legacy / msa / ELYBY + + // Только для аккаунтов Microsoft. + QString refreshToken; // продлевает сессию без ввода пароля + QString xuid; // ${auth_xuid} + QDateTime expiresAt; // UTC-время, когда протухает accessToken }; // Yggdrasil-клиент Ely.by плюс офлайн-режим. Все сетевые методы асинхронные: diff --git a/gamelauncher.cpp b/gamelauncher.cpp index be66d50..649e4a9 100644 --- a/gamelauncher.cpp +++ b/gamelauncher.cpp @@ -133,7 +133,8 @@ QStringList GameLauncher::buildArguments(const LaunchOptions &options, variables[QStringLiteral("auth_access_token")] = options.accessToken; variables[QStringLiteral("auth_session")] = QStringLiteral("token:%1:%2").arg(options.accessToken, options.uuid); - variables[QStringLiteral("auth_xuid")] = QStringLiteral("0"); + variables[QStringLiteral("auth_xuid")] = + options.xuid.isEmpty() ? QStringLiteral("0") : options.xuid; variables[QStringLiteral("clientid")] = options.clientToken.isEmpty() ? QStringLiteral("0") : options.clientToken; variables[QStringLiteral("user_type")] = options.userType; diff --git a/gamelauncher.h b/gamelauncher.h index 09469e1..ef6aac1 100644 --- a/gamelauncher.h +++ b/gamelauncher.h @@ -18,6 +18,7 @@ struct LaunchOptions QString accessToken; QString userType; QString clientToken; + QString xuid; // Пусто — подставится "0", как в офлайне QString javaPath; // Пусто — ищем сами int minMemoryMb = 512; diff --git a/launcherbackend.cpp b/launcherbackend.cpp index 2e134bb..7a10439 100644 --- a/launcherbackend.cpp +++ b/launcherbackend.cpp @@ -10,6 +10,7 @@ #include "versioninstaller.h" #include "versionmanifestservice.h" #include "modloaderinstaller.h" +#include "msaauthservice.h" #include "modloaderversionservice.h" #include "buildswitcher.h" #include "seasonalbuildservice.h" @@ -35,12 +36,27 @@ namespace { const QString kOffline = QStringLiteral("offline"); const QString kElyBy = QStringLiteral("elyby"); +const QString kMicrosoft = QStringLiteral("microsoft"); + +// Токен Minecraft живёт около суток, но обновляем его чуть раньше срока: игра +// проверяет сессию не в первую секунду после старта. +const int kTokenRenewMarginSecs = 120; // Сервер готовых сборок по умолчанию. Переопределяется ключом seasonalBaseUrl // в settings.json — это нужно, чтобы прогонять установку против локального // http-сервера, не трогая боевой. const QString kSeasonalBaseUrl = QStringLiteral("https://launcher-builds.galeonworx.com"); +// Тип авторизации профиля: всё, что лаунчер не знает, считается офлайном. +QString normalizedAuthType(const QString &value) +{ + if (value == kElyBy) + return kElyBy; + if (value == kMicrosoft) + return kMicrosoft; + return kOffline; +} + // Дата сезона в таблице: год нужен, время — нет. QString formatSeasonDate(const QDate &date) { @@ -60,6 +76,7 @@ int settingInt(const QVariantMap &settings, const QString &key, int fallback) LauncherBackend::LauncherBackend(QObject *parent) : QObject(parent) , m_auth(new AuthService(this)) + , m_msa(new MsaAuthService(this)) , m_launcher(new GameLauncher(this)) , m_manifest(new VersionManifestService(this)) , m_installer(new VersionInstaller(m_manifest, this)) @@ -95,6 +112,7 @@ LauncherBackend::LauncherBackend(QObject *parent) } connect(m_auth, &AuthService::progress, this, &LauncherBackend::launchProgress); + connect(m_msa, &MsaAuthService::progress, this, &LauncherBackend::launchProgress); connect(m_launcher, &GameLauncher::progress, this, &LauncherBackend::launchProgress); connect(m_launcher, &GameLauncher::output, this, &LauncherBackend::gameOutput); connect(m_launcher, &GameLauncher::gameStarted, this, [this](const QString &commandLine) { @@ -396,7 +414,7 @@ void LauncherBackend::addProfile(const QString &name, profile.name = name; profile.login = login; profile.password = password; - profile.authType = (authType == kElyBy) ? kElyBy : kOffline; + profile.authType = normalizedAuthType(authType); profile.clientToken = AuthService::generateClientToken(); m_profiles.append(profile); saveProfiles(); @@ -428,16 +446,30 @@ void LauncherBackend::updateProfile(int index, return; Profile &profile = m_profiles[index]; - // Смена логина или пароля обесценивает сохранённый токен Ely.by. - if (profile.login != login || profile.password != password) { + const QString type = normalizedAuthType(authType); + + // Смена логина или пароля обесценивает сохранённый токен Ely.by. У аккаунта + // Microsoft ни того ни другого нет, и переименование профиля не должно + // выкидывать из аккаунта. + if (profile.authType == kElyBy && (profile.login != login || profile.password != password)) { profile.accessToken.clear(); profile.uuid.clear(); profile.resolvedName.clear(); } + // Смена типа авторизации: чужие учётные данные к новому типу не подходят. + if (profile.authType != type) { + profile.accessToken.clear(); + profile.refreshToken.clear(); + profile.uuid.clear(); + profile.resolvedName.clear(); + profile.xuid.clear(); + profile.tokenExpiry.clear(); + profile.userType.clear(); + } profile.name = name; profile.login = login; profile.password = password; - profile.authType = (authType == kElyBy) ? kElyBy : kOffline; + profile.authType = type; if (profile.clientToken.isEmpty()) profile.clientToken = AuthService::generateClientToken(); @@ -495,7 +527,9 @@ QVariantMap LauncherBackend::profileAt(int index) const return {{"name", p.name}, {"login", p.login}, {"password", p.password}, - {"authType", p.authType}}; + {"authType", p.authType}, + {"resolvedName", p.resolvedName}, + {"hasMicrosoftSession", !p.refreshToken.isEmpty()}}; } QVariantMap LauncherBackend::customBuildAt(int index) const @@ -1149,10 +1183,157 @@ void LauncherBackend::launchGame(int profileIndex, int buildIndex) beginAuthentication(); } +void LauncherBackend::startMicrosoftLogin(int profileIndex) +{ + if (!microsoftAvailable()) { + emit microsoftLoginFailed(tr("Эта сборка лаунчера собрана без Qt WebEngine — " + "показать окно входа Microsoft нечем")); + return; + } + // -1 из QML означает «профиля ещё нет»; внутри держим -2, чтобы отличать + // это состояние от закрытого окна. + if (profileIndex >= m_profiles.size()) { + emit microsoftLoginFailed(tr("Профиль не найден")); + return; + } + m_msaLoginProfile = profileIndex < 0 ? -2 : profileIndex; + emit microsoftLoginUrlReady(MsaAuthService::authorizationUrl().toString()); +} + +void LauncherBackend::cancelMicrosoftLogin() +{ + m_msaLoginProfile = -1; +} + +QVariantMap LauncherBackend::inspectMicrosoftRedirect(const QString &url) const +{ + QString code; + QString error; + const bool matched = MsaAuthService::matchRedirect(QUrl(url), &code, &error); + return {{QStringLiteral("matched"), matched}, + {QStringLiteral("code"), code}, + {QStringLiteral("error"), error}}; +} + +void LauncherBackend::finishMicrosoftLogin(const QString &code) +{ + if (m_msaLoginProfile == -1) { + emit microsoftLoginFailed(tr("Вход в Microsoft уже отменён")); + return; + } + + setBusy(true); + m_msa->loginWithCode(code, [this](const AuthResult &result) { + setBusy(false); + const int target = m_msaLoginProfile; + m_msaLoginProfile = -1; + if (target == -1) + return; // окно закрыли, пока шла авторизация + + if (!result.ok) { + emit microsoftLoginFailed(result.error); + return; + } + + if (target == -2) { + // Профиля ещё нет — заводим его на ник из аккаунта. + Profile profile; + profile.id = m_nextProfileId++; + profile.name = result.playerName; + profile.authType = kMicrosoft; + profile.clientToken = AuthService::generateClientToken(); + applyMicrosoftResult(profile, result); + m_profiles.append(profile); + } else if (target < m_profiles.size()) { + Profile &profile = m_profiles[target]; + profile.authType = kMicrosoft; + applyMicrosoftResult(profile, result); + } else { + emit microsoftLoginFailed(tr("Профиль был удалён, пока шёл вход")); + return; + } + + saveProfiles(); + emit profilesChanged(); + emit microsoftLoginSucceeded(result.playerName); + }); +} + +void LauncherBackend::applyMicrosoftResult(Profile &profile, const AuthResult &auth) +{ + profile.accessToken = auth.accessToken; + // Microsoft выдаёт новый refresh-токен и при продлении, но если в ответе + // его не было — прежний остаётся рабочим. + if (!auth.refreshToken.isEmpty()) + profile.refreshToken = auth.refreshToken; + if (!auth.uuid.isEmpty()) + profile.uuid = auth.uuid; + if (!auth.playerName.isEmpty()) + profile.resolvedName = auth.playerName; + profile.xuid = auth.xuid; + profile.userType = auth.userType; + profile.tokenExpiry = auth.expiresAt.isValid() + ? auth.expiresAt.toUTC().toString(Qt::ISODate) + : QString(); +} + +void LauncherBackend::beginMicrosoftAuthentication() +{ + const Profile &profile = m_profiles[m_pendingProfile]; + + if (profile.refreshToken.isEmpty()) { + const int index = m_pendingProfile; + failLaunch(tr("Профиль «%1» не подключён к аккаунту Microsoft — войдите в него") + .arg(profile.name)); + emit microsoftReloginRequired(index); + return; + } + + // Сохранённый токен ещё жив — идём в игру, не дёргая Microsoft. + const QDateTime expiry = QDateTime::fromString(profile.tokenExpiry, Qt::ISODate); + if (!profile.accessToken.isEmpty() && expiry.isValid() + && QDateTime::currentDateTimeUtc().addSecs(kTokenRenewMarginSecs) < expiry) { + AuthResult stored; + stored.ok = true; + stored.playerName = profile.resolvedName; + stored.uuid = profile.uuid; + stored.accessToken = profile.accessToken; + stored.refreshToken = profile.refreshToken; + stored.clientToken = MsaAuthService::clientId(); + stored.xuid = profile.xuid; + stored.userType = profile.userType.isEmpty() ? QStringLiteral("msa") : profile.userType; + stored.expiresAt = expiry; + continueLaunch(stored); + return; + } + + m_msa->loginWithRefreshToken(profile.refreshToken, [this](const AuthResult &result) { + if (m_pendingProfile < 0) + return; // запуск отменили, пока шло продление + if (result.licenseMissing) { + failLaunch(result.error); + return; + } + if (!result.ok) { + const int index = m_pendingProfile; + failLaunch(tr("Сессия Microsoft истекла — войдите в аккаунт заново (%1)") + .arg(result.error)); + emit microsoftReloginRequired(index); + return; + } + continueLaunch(result); + }); +} + void LauncherBackend::beginAuthentication() { const Profile &profile = m_profiles[m_pendingProfile]; + if (profile.authType == kMicrosoft) { + beginMicrosoftAuthentication(); + return; + } + if (profile.authType != kElyBy) { const QString nickname = profile.login.isEmpty() ? profile.name : profile.login; const AuthResult result = AuthService::offline(nickname); @@ -1252,7 +1433,10 @@ void LauncherBackend::continueLaunch(const AuthResult &auth) // Ely.by возвращает игровой ник и UUID аккаунта — их и запоминаем, // чтобы в следующий раз обойтись без пароля. - if (!auth.accessToken.isEmpty() && profile.authType == kElyBy) { + if (profile.authType == kMicrosoft && auth.ok) { + applyMicrosoftResult(profile, auth); + saveProfiles(); + } else if (!auth.accessToken.isEmpty() && profile.authType == kElyBy) { profile.accessToken = auth.accessToken; profile.clientToken = auth.clientToken; if (!auth.uuid.isEmpty()) @@ -1273,6 +1457,7 @@ void LauncherBackend::continueLaunch(const AuthResult &auth) options.accessToken = auth.accessToken; options.clientToken = auth.clientToken; options.userType = auth.userType; + options.xuid = auth.xuid; // Скачанная лаунчером сборка важнее пути из настроек: её выбрали явно в // окне выбора версии Java. @@ -1354,6 +1539,15 @@ bool LauncherBackend::busy() const || m_packDownloader->isRunning(); } +bool LauncherBackend::microsoftAvailable() const +{ +#ifdef LAUNCHER_HAS_WEBENGINE + return true; +#else + return false; +#endif +} + void LauncherBackend::setBusy(bool busy) { if (m_busy == busy) @@ -2195,6 +2389,9 @@ void LauncherBackend::loadData() profile.uuid = o.value("uuid").toString(); profile.resolvedName = o.value("resolvedName").toString(); profile.userType = o.value("userType").toString(); + profile.refreshToken = o.value("refreshToken").toString(); + profile.xuid = o.value("xuid").toString(); + profile.tokenExpiry = o.value("tokenExpiry").toString(); if (profile.clientToken.isEmpty()) profile.clientToken = AuthService::generateClientToken(); m_profiles.append(profile); @@ -2308,7 +2505,10 @@ void LauncherBackend::saveProfiles() {"accessToken", p.accessToken}, {"uuid", p.uuid}, {"resolvedName", p.resolvedName}, - {"userType", p.userType}}); + {"userType", p.userType}, + {"refreshToken", p.refreshToken}, + {"xuid", p.xuid}, + {"tokenExpiry", p.tokenExpiry}}); writeJsonFile(LauncherPaths::profilesFile(), QJsonDocument(arr)); } diff --git a/launcherbackend.h b/launcherbackend.h index deb723d..5c84a0b 100644 --- a/launcherbackend.h +++ b/launcherbackend.h @@ -13,6 +13,7 @@ struct AuthResult; class AuthService; +class MsaAuthService; class GameLauncher; class VersionManifestService; class VersionInstaller; @@ -48,6 +49,10 @@ class LauncherBackend : public QObject Q_PROPERTY(QString switchStatus READ switchStatus NOTIFY switchChanged) Q_PROPERTY(bool busy READ busy NOTIFY busyChanged) + // Собран ли лаунчер с Qt WebEngine. Без него окно входа Microsoft + // показать нечем, и интерфейс не должен предлагать этот путь. + Q_PROPERTY(bool microsoftAvailable READ microsoftAvailable CONSTANT) + // Объединённый список для выпадающего меню версий: установленные, разделитель, // затем весь каталог Mojang. Q_PROPERTY(QVariantList versionCatalog READ versionCatalog NOTIFY versionCatalogChanged) @@ -93,6 +98,7 @@ public: QString switchStatus() const; // Пока идёт загрузка версии, лаунчер тоже занят: кнопка запуска гаснет. bool busy() const; + bool microsoftAvailable() const; QVariantList versionCatalog() const; bool catalogLoading() const; @@ -128,6 +134,15 @@ public: // loader, loaderVersion, resolvedVersionId. Q_INVOKABLE void updateCustomBuild(int index, const QVariantMap &fields); Q_INVOKABLE QVariantMap profileAt(int index) const; + + // Вход в аккаунт Microsoft. profileIndex = -1 — вход ещё не привязан к + // профилю: он создастся по нику, который вернут Minecraft Services. + Q_INVOKABLE void startMicrosoftLogin(int profileIndex); + Q_INVOKABLE void finishMicrosoftLogin(const QString &code); + Q_INVOKABLE void cancelMicrosoftLogin(); + // Разбор адреса, на который встроенное окно возвращается после входа. + // Живёт в C++, чтобы правила совпадения не разъезжались с сервисом. + Q_INVOKABLE QVariantMap inspectMicrosoftRedirect(const QString &url) const; Q_INVOKABLE QVariantMap customBuildAt(int index) const; Q_INVOKABLE void removeProfile(int index); // Что именно потеряется при удалении — для текста предупреждения. @@ -224,6 +239,12 @@ signals: void launched(const QString &profileName, const QString &buildName, const QString &serverUrl); void launchError(const QString &message); void twoFactorRequired(const QString &profileName); + // Окну входа Microsoft: открыться на этом адресе. + void microsoftLoginUrlReady(const QString &url); + void microsoftLoginSucceeded(const QString &playerName); + void microsoftLoginFailed(const QString &message); + // Сессия профиля протухла настолько, что нужен повторный вход руками. + void microsoftReloginRequired(int profileIndex); void gameOutput(const QString &line); void gameFinished(int exitCode, bool crashed); @@ -232,8 +253,11 @@ private: { int id = 0; QString name, login, password; - QString authType = QStringLiteral("offline"); // offline | elyby + QString authType = QStringLiteral("offline"); // offline | elyby | microsoft QString clientToken, accessToken, uuid, resolvedName, userType; + // Только для microsoft: продление сессии, ${auth_xuid} и срок жизни + // accessToken в ISO-8601 (чтобы класть в JSON без конвертеров). + QString refreshToken, xuid, tokenExpiry; }; // Пользовательская сборка: имя, сервер, версия Minecraft и, начиная со // второго этапа, выбранный модлоадер. @@ -329,6 +353,8 @@ private: void setBusy(bool busy); void failLaunch(const QString &message); void beginAuthentication(); + void beginMicrosoftAuthentication(); + void applyMicrosoftResult(Profile &profile, const AuthResult &auth); void continueLaunch(const AuthResult &auth); QList m_profiles; @@ -341,6 +367,7 @@ private: int m_nextBuildId = 1; AuthService *m_auth; + MsaAuthService *m_msa; GameLauncher *m_launcher; VersionManifestService *m_manifest; VersionInstaller *m_installer; @@ -356,6 +383,10 @@ private: // когда установщик отчитается. int m_loaderInstallBuildId = -1; + // Профиль, для которого открыто окно входа Microsoft. -1 — окно закрыто, + // -2 — вход для профиля, которого ещё нет. + int m_msaLoginProfile = -1; + // Каталог версий пересобирается только по изменениям: QML читает свойство // помногу раз за кадр, пока открыт список. mutable QVariantList m_catalog; diff --git a/main.cpp b/main.cpp index 1575341..c2e0f48 100644 --- a/main.cpp +++ b/main.cpp @@ -2,8 +2,19 @@ #include #include +#ifdef LAUNCHER_HAS_WEBENGINE +#include +#endif + int main(int argc, char *argv[]) { +#ifdef LAUNCHER_HAS_WEBENGINE + // Вход в аккаунт Microsoft показывается встроенным окном браузера. + // Инициализация обязана идти до QGuiApplication: она выставляет общий + // контекст OpenGL, а после создания приложения это уже не действует. + QtWebEngineQuick::initialize(); +#endif + QGuiApplication app(argc, argv); // Нативный стиль Windows игнорирует пользовательские contentItem/background diff --git a/msaauthservice.cpp b/msaauthservice.cpp new file mode 100644 index 0000000..fd62cbb --- /dev/null +++ b/msaauthservice.cpp @@ -0,0 +1,469 @@ +#include "msaauthservice.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace { + +// ── Azure-приложение лаунчера ─────────────────────────────────────────────── +// Временно используется client_id официального лаунчера Mojang: это старое +// приложение Live Connect, поэтому и конечные точки здесь от login.live.com. +// При переходе на собственное приложение (портал Azure -> регистрация +// приложения -> «Allow public client flows») меняются шесть констант ниже: +// kClientId — id своего приложения; +// kAuthorizeUrl — https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize +// kTokenUrl — https://login.microsoftonline.com/consumers/oauth2/v2.0/token +// kRedirectUri — тот, что зарегистрирован в Azure; для встроенного окна +// удобен https://login.microsoftonline.com/common/oauth2/nativeclient +// kScope — XboxLive.signin offline_access +// kRpsTicketPrefix — "d="; у старого приложения в RpsTicket идёт сырой токен, +// у нового — с этой приставкой. +const QString kClientId = QStringLiteral("00000000402b5328"); +const QString kAuthorizeUrl = QStringLiteral("https://login.live.com/oauth20_authorize.srf"); +const QString kTokenUrl = QStringLiteral("https://login.live.com/oauth20_token.srf"); +const QString kRedirectUri = QStringLiteral("https://login.live.com/oauth20_desktop.srf"); +const QString kScope = QStringLiteral("service::user.auth.xboxlive.com::MBI_SSL"); +const QString kRpsTicketPrefix = QString(); + +const QString kXboxAuthUrl = QStringLiteral("https://user.auth.xboxlive.com/user/authenticate"); +const QString kXstsUrl = QStringLiteral("https://xsts.auth.xboxlive.com/xsts/authorize"); +const QString kMinecraftLoginUrl = + QStringLiteral("https://api.minecraftservices.com/authentication/login_with_xbox"); +const QString kEntitlementsUrl = + QStringLiteral("https://api.minecraftservices.com/entitlements/mcstore"); +const QString kProfileUrl = QStringLiteral("https://api.minecraftservices.com/minecraft/profile"); + +const int kTransferTimeoutMs = 20000; + +QString normalizeUuid(const QString &uuid) +{ + return QString(uuid).remove(u'-'); +} + +// Первый элемент DisplayClaims.xui — единственный, который отдают и Xbox Live, +// и XSTS: в нём лежат uhs (хеш пользователя) и xid (он же XUID). +QJsonObject displayClaim(const QJsonObject &response) +{ + return response.value(QStringLiteral("DisplayClaims")) + .toObject() + .value(QStringLiteral("xui")) + .toArray() + .first() + .toObject(); +} + +// XSTS отказывает не текстом, а числовым кодом — без расшифровки пользователь +// видит просто «401». +QString describeXstsError(const QJsonObject &response) +{ + const qint64 code = static_cast(response.value(QStringLiteral("XErr")).toDouble()); + switch (code) { + case 2148916233: + return MsaAuthService::tr("К аккаунту Microsoft не привязан профиль Xbox. " + "Создайте его на xbox.com и повторите вход."); + case 2148916235: + return MsaAuthService::tr("Xbox Live недоступен в стране, указанной в аккаунте"); + case 2148916236: + case 2148916237: + return MsaAuthService::tr("Аккаунту нужна проверка возраста на xbox.com"); + case 2148916238: + return MsaAuthService::tr("Детский аккаунт: добавьте его в семейную группу " + "Microsoft, иначе вход в Xbox Live невозможен"); + default: + break; + } + const QString message = response.value(QStringLiteral("Message")).toString(); + return message.isEmpty() ? MsaAuthService::tr("Xbox Live отклонил вход (XErr %1)").arg(code) + : message; +} + +QString describeOauthError(const QJsonObject &response, int status) +{ + const QString description = response.value(QStringLiteral("error_description")).toString(); + if (!description.isEmpty()) + return description; + const QString error = response.value(QStringLiteral("error")).toString(); + if (!error.isEmpty()) + return error; + return MsaAuthService::tr("Microsoft вернул код %1").arg(status); +} + +} // namespace + +MsaAuthService::MsaAuthService(QObject *parent) + : QObject(parent) + , m_network(new QNetworkAccessManager(this)) +{ +} + +QString MsaAuthService::clientId() +{ + return kClientId; +} + +QUrl MsaAuthService::authorizationUrl() +{ + QUrl url(kAuthorizeUrl); + QUrlQuery query; + query.addQueryItem(QStringLiteral("client_id"), kClientId); + query.addQueryItem(QStringLiteral("response_type"), QStringLiteral("code")); + query.addQueryItem(QStringLiteral("redirect_uri"), kRedirectUri); + query.addQueryItem(QStringLiteral("scope"), kScope); + // Без этого Microsoft молча пускает по прошлой сессии браузера, и добавить + // второй аккаунт становится нечем. + query.addQueryItem(QStringLiteral("prompt"), QStringLiteral("select_account")); + url.setQuery(query); + return url; +} + +bool MsaAuthService::matchRedirect(const QUrl &url, QString *code, QString *error) +{ + const QUrl redirect(kRedirectUri); + if (url.host().compare(redirect.host(), Qt::CaseInsensitive) != 0 + || url.path() != redirect.path()) + return false; + + // Код приходит в query, но часть приложений Azure настроена на response_mode + // с фрагментом — разбираем оба места. + QUrlQuery query(url.query()); + if (!query.hasQueryItem(QStringLiteral("code")) && !query.hasQueryItem(QStringLiteral("error"))) + query = QUrlQuery(url.fragment()); + + if (code) + *code = query.queryItemValue(QStringLiteral("code"), QUrl::FullyDecoded); + if (error) { + const QString description = + query.queryItemValue(QStringLiteral("error_description"), QUrl::FullyDecoded); + *error = description.isEmpty() + ? query.queryItemValue(QStringLiteral("error"), QUrl::FullyDecoded) + : description; + } + return true; +} + +void MsaAuthService::postForm(const QUrl &url, const QByteArray &body, JsonHandler handler) +{ + QNetworkRequest request{url}; + request.setHeader(QNetworkRequest::ContentTypeHeader, + QStringLiteral("application/x-www-form-urlencoded")); + request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, + QNetworkRequest::NoLessSafeRedirectPolicy); + request.setTransferTimeout(kTransferTimeoutMs); + + QNetworkReply *reply = m_network->post(request, body); + connect(reply, &QNetworkReply::finished, this, [reply, handler = std::move(handler)]() { + reply->deleteLater(); + const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + QString transportError; + if (status == 0 && reply->error() != QNetworkReply::NoError) + transportError = reply->errorString(); + handler(status, QJsonDocument::fromJson(reply->readAll()).object(), transportError); + }); +} + +void MsaAuthService::postJson(const QUrl &url, + const QJsonObject &body, + const QString &bearer, + JsonHandler handler) +{ + QNetworkRequest request{url}; + request.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/json")); + request.setRawHeader("Accept", "application/json"); + if (!bearer.isEmpty()) + request.setRawHeader("Authorization", QStringLiteral("Bearer %1").arg(bearer).toUtf8()); + request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, + QNetworkRequest::NoLessSafeRedirectPolicy); + request.setTransferTimeout(kTransferTimeoutMs); + + QNetworkReply *reply = m_network->post(request, QJsonDocument(body).toJson(QJsonDocument::Compact)); + connect(reply, &QNetworkReply::finished, this, [reply, handler = std::move(handler)]() { + reply->deleteLater(); + const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + QString transportError; + if (status == 0 && reply->error() != QNetworkReply::NoError) + transportError = reply->errorString(); + handler(status, QJsonDocument::fromJson(reply->readAll()).object(), transportError); + }); +} + +void MsaAuthService::getJson(const QUrl &url, const QString &bearer, JsonHandler handler) +{ + QNetworkRequest request{url}; + request.setRawHeader("Accept", "application/json"); + if (!bearer.isEmpty()) + request.setRawHeader("Authorization", QStringLiteral("Bearer %1").arg(bearer).toUtf8()); + request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, + QNetworkRequest::NoLessSafeRedirectPolicy); + request.setTransferTimeout(kTransferTimeoutMs); + + QNetworkReply *reply = m_network->get(request); + connect(reply, &QNetworkReply::finished, this, [reply, handler = std::move(handler)]() { + reply->deleteLater(); + const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + QString transportError; + if (status == 0 && reply->error() != QNetworkReply::NoError) + transportError = reply->errorString(); + handler(status, QJsonDocument::fromJson(reply->readAll()).object(), transportError); + }); +} + +void MsaAuthService::loginWithCode(const QString &code, Callback callback) +{ + if (code.isEmpty()) { + AuthResult result; + result.error = tr("Microsoft не вернул код авторизации"); + callback(result); + return; + } + + QUrlQuery form; + form.addQueryItem(QStringLiteral("client_id"), kClientId); + form.addQueryItem(QStringLiteral("code"), code); + form.addQueryItem(QStringLiteral("grant_type"), QStringLiteral("authorization_code")); + form.addQueryItem(QStringLiteral("redirect_uri"), kRedirectUri); + + emit progress(tr("Обмен кода Microsoft на токен…")); + exchangeToken(form.toString(QUrl::FullyEncoded).toUtf8(), std::move(callback)); +} + +void MsaAuthService::loginWithRefreshToken(const QString &refreshToken, Callback callback) +{ + if (refreshToken.isEmpty()) { + AuthResult result; + result.error = tr("Профиль не подключён к аккаунту Microsoft"); + callback(result); + return; + } + + QUrlQuery form; + form.addQueryItem(QStringLiteral("client_id"), kClientId); + form.addQueryItem(QStringLiteral("refresh_token"), refreshToken); + form.addQueryItem(QStringLiteral("grant_type"), QStringLiteral("refresh_token")); + form.addQueryItem(QStringLiteral("redirect_uri"), kRedirectUri); + form.addQueryItem(QStringLiteral("scope"), kScope); + + emit progress(tr("Обновление сессии Microsoft…")); + exchangeToken(form.toString(QUrl::FullyEncoded).toUtf8(), std::move(callback)); +} + +void MsaAuthService::exchangeToken(const QByteArray &form, Callback callback) +{ + postForm(QUrl(kTokenUrl), form, + [this, callback](int status, const QJsonObject &response, const QString &transportError) { + if (!transportError.isEmpty()) { + AuthResult result; + result.error = tr("Нет связи с сервером Microsoft: %1").arg(transportError); + callback(result); + return; + } + if (status != 200) { + AuthResult result; + result.error = describeOauthError(response, status); + callback(result); + return; + } + + const QString accessToken = response.value(QStringLiteral("access_token")).toString(); + // Refresh-токен приходит и при продлении: Microsoft выдаёт новый + // и обесценивает прежний, поэтому сохранять надо свежий. + const QString refreshToken = response.value(QStringLiteral("refresh_token")).toString(); + if (accessToken.isEmpty()) { + AuthResult result; + result.error = tr("Microsoft не вернул токен доступа"); + callback(result); + return; + } + authenticateXbox(accessToken, refreshToken, callback); + }); +} + +void MsaAuthService::authenticateXbox(const QString µsoftToken, + const QString &refreshToken, + Callback callback) +{ + emit progress(tr("Вход в Xbox Live…")); + + QJsonObject properties{{QStringLiteral("AuthMethod"), QStringLiteral("RPS")}, + {QStringLiteral("SiteName"), QStringLiteral("user.auth.xboxlive.com")}, + {QStringLiteral("RpsTicket"), kRpsTicketPrefix + microsoftToken}}; + const QJsonObject body{{QStringLiteral("Properties"), properties}, + {QStringLiteral("RelyingParty"), QStringLiteral("http://auth.xboxlive.com")}, + {QStringLiteral("TokenType"), QStringLiteral("JWT")}}; + + postJson(QUrl(kXboxAuthUrl), body, {}, + [this, refreshToken, callback](int status, const QJsonObject &response, + const QString &transportError) { + AuthResult result; + if (!transportError.isEmpty()) { + result.error = tr("Нет связи с Xbox Live: %1").arg(transportError); + callback(result); + return; + } + if (status != 200) { + result.error = tr("Xbox Live отклонил токен Microsoft (код %1)").arg(status); + callback(result); + return; + } + + const QString token = response.value(QStringLiteral("Token")).toString(); + if (token.isEmpty()) { + result.error = tr("Xbox Live не вернул токен"); + callback(result); + return; + } + authorizeXsts(token, refreshToken, callback); + }); +} + +void MsaAuthService::authorizeXsts(const QString &xblToken, + const QString &refreshToken, + Callback callback) +{ + emit progress(tr("Проверка прав XSTS…")); + + const QJsonObject properties{{QStringLiteral("SandboxId"), QStringLiteral("RETAIL")}, + {QStringLiteral("UserTokens"), QJsonArray{xblToken}}}; + const QJsonObject body{{QStringLiteral("Properties"), properties}, + {QStringLiteral("RelyingParty"), + QStringLiteral("rp://api.minecraftservices.com/")}, + {QStringLiteral("TokenType"), QStringLiteral("JWT")}}; + + postJson(QUrl(kXstsUrl), body, {}, + [this, refreshToken, callback](int status, const QJsonObject &response, + const QString &transportError) { + AuthResult result; + if (!transportError.isEmpty()) { + result.error = tr("Нет связи с сервером XSTS: %1").arg(transportError); + callback(result); + return; + } + if (status == 401) { + result.error = describeXstsError(response); + callback(result); + return; + } + if (status != 200) { + result.error = tr("XSTS отклонил запрос (код %1)").arg(status); + callback(result); + return; + } + + const QString token = response.value(QStringLiteral("Token")).toString(); + const QJsonObject claim = displayClaim(response); + const QString userHash = claim.value(QStringLiteral("uhs")).toString(); + if (token.isEmpty() || userHash.isEmpty()) { + result.error = tr("XSTS не вернул данные пользователя"); + callback(result); + return; + } + loginWithXbox(userHash, token, claim.value(QStringLiteral("xid")).toString(), + refreshToken, callback); + }); +} + +void MsaAuthService::loginWithXbox(const QString &userHash, + const QString &xstsToken, + const QString &xuid, + const QString &refreshToken, + Callback callback) +{ + emit progress(tr("Авторизация в Minecraft Services…")); + + const QJsonObject body{{QStringLiteral("identityToken"), + QStringLiteral("XBL3.0 x=%1;%2").arg(userHash, xstsToken)}}; + + postJson(QUrl(kMinecraftLoginUrl), body, {}, + [this, xuid, refreshToken, callback](int status, const QJsonObject &response, + const QString &transportError) { + AuthResult result; + if (!transportError.isEmpty()) { + result.error = tr("Нет связи с api.minecraftservices.com: %1").arg(transportError); + callback(result); + return; + } + if (status != 200) { + result.error = tr("Minecraft Services отклонили вход (код %1)").arg(status); + callback(result); + return; + } + + result.accessToken = response.value(QStringLiteral("access_token")).toString(); + if (result.accessToken.isEmpty()) { + result.error = tr("Minecraft Services не вернули токен доступа"); + callback(result); + return; + } + + const int lifetime = response.value(QStringLiteral("expires_in")).toInt(86400); + result.expiresAt = QDateTime::currentDateTimeUtc().addSecs(lifetime); + result.refreshToken = refreshToken; + result.xuid = xuid; + result.clientToken = kClientId; + result.userType = QStringLiteral("msa"); + checkLicense(result, callback); + }); +} + +void MsaAuthService::checkLicense(AuthResult result, Callback callback) +{ + emit progress(tr("Проверка лицензии Minecraft…")); + + getJson(QUrl(kEntitlementsUrl), result.accessToken, + [this, result, callback](int status, const QJsonObject &response, const QString &) { + // Сетевую ошибку здесь не считаем отказом: окончательный ответ + // про лицензию даёт профиль, а список покупок нужен только + // затем, чтобы точнее объяснить отказ. + const bool owns = status == 200 + && !response.value(QStringLiteral("items")).toArray().isEmpty(); + fetchProfile(result, owns, callback); + }); +} + +void MsaAuthService::fetchProfile(AuthResult result, bool storeEntitlement, Callback callback) +{ + getJson(QUrl(kProfileUrl), result.accessToken, + [result, storeEntitlement, callback](int status, const QJsonObject &response, + const QString &transportError) mutable { + if (!transportError.isEmpty()) { + result.error = MsaAuthService::tr("Нет связи с api.minecraftservices.com: %1") + .arg(transportError); + callback(result); + return; + } + + // 404 здесь — штатный ответ для аккаунта, на котором нет копии + // игры: профиль игрока создаётся только вместе с покупкой. + if (status == 404 || status == 401) { + result.licenseMissing = true; + result.error = + storeEntitlement + ? MsaAuthService::tr("На аккаунте Microsoft есть покупка, но игровой " + "профиль не создан. Зайдите один раз в " + "официальный лаунчер и выберите ник.") + : MsaAuthService::tr("На этом аккаунте Microsoft нет копии " + "Minecraft: Java Edition"); + callback(result); + return; + } + if (status != 200) { + result.error = + MsaAuthService::tr("Не удалось получить профиль Minecraft (код %1)").arg(status); + callback(result); + return; + } + + result.playerName = response.value(QStringLiteral("name")).toString(); + result.uuid = normalizeUuid(response.value(QStringLiteral("id")).toString()); + result.ok = !result.playerName.isEmpty() && !result.uuid.isEmpty(); + if (!result.ok) { + result.licenseMissing = true; + result.error = MsaAuthService::tr("Minecraft Services не вернули игровой профиль"); + } + callback(result); + }); +} diff --git a/msaauthservice.h b/msaauthservice.h new file mode 100644 index 0000000..834281e --- /dev/null +++ b/msaauthservice.h @@ -0,0 +1,67 @@ +#pragma once + +#include "authservice.h" + +#include +#include +#include +#include +#include + +class QNetworkAccessManager; + +// Авторизация через аккаунт Microsoft — та же цепочка, что и в официальном +// лаунчере: OAuth2 -> Xbox Live -> XSTS -> Minecraft Services -> проверка +// лицензии. Результат отдаётся тем же AuthResult, что и Ely.by с офлайном, +// поэтому запуск игры дальше идёт по общему пути. +class MsaAuthService : public QObject +{ + Q_OBJECT + +public: + using Callback = std::function; + + explicit MsaAuthService(QObject *parent = nullptr); + + // Id приложения лаунчера. Игра ждёт его в ${clientid}: официальный лаунчер + // подставляет туда именно id приложения, а не случайный токен сессии. + static QString clientId(); + + // Адрес страницы входа для встроенного окна. + static QUrl authorizationUrl(); + + // Отличает адрес, на который Microsoft возвращает управление после входа, + // от остальной навигации внутри окна. Заполняет code либо error. + static bool matchRedirect(const QUrl &url, QString *code, QString *error); + + // Полный вход по коду из окна браузера. + void loginWithCode(const QString &code, Callback callback); + + // Продление сессии без участия пользователя. Refresh-токен Microsoft живёт + // куда дольше суточного токена Minecraft, так что обычно хватает его. + void loginWithRefreshToken(const QString &refreshToken, Callback callback); + +signals: + void progress(const QString &message); + +private: + using JsonHandler = std::function; + + void postForm(const QUrl &url, const QByteArray &body, JsonHandler handler); + void postJson(const QUrl &url, const QJsonObject &body, const QString &bearer, + JsonHandler handler); + void getJson(const QUrl &url, const QString &bearer, JsonHandler handler); + + // Шаги цепочки. Каждый следующий вызывается из колбэка предыдущего. + void exchangeToken(const QByteArray &form, Callback callback); + void authenticateXbox(const QString µsoftToken, const QString &refreshToken, + Callback callback); + void authorizeXsts(const QString &xblToken, const QString &refreshToken, Callback callback); + void loginWithXbox(const QString &userHash, const QString &xstsToken, const QString &xuid, + const QString &refreshToken, Callback callback); + void checkLicense(AuthResult result, Callback callback); + void fetchProfile(AuthResult result, bool storeEntitlement, Callback callback); + + QNetworkAccessManager *m_network; +};