#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); }); }