470 lines
22 KiB
C++
470 lines
22 KiB
C++
#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 µ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);
|
||
});
|
||
}
|