Files
minecraft-launcher/msaauthservice.cpp
T
2026-09-01 22:25:23 +03:00

470 lines
22 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#include "msaauthservice.h"
#include <QDateTime>
#include <QJsonArray>
#include <QJsonDocument>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QUrlQuery>
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<qint64>(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 &microsoftToken,
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);
});
}