Files
minecraft-launcher/msaauthservice.cpp
T

466 lines
21 KiB
C++

#include "msaauthservice.h"
#include "localization.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 Loc::text("auth.msa.error.noXboxProfile");
case 2148916235:
return Loc::text("auth.msa.error.countryBlocked");
case 2148916236:
case 2148916237:
return Loc::text("auth.msa.error.ageVerification");
case 2148916238:
return Loc::text("auth.msa.error.childAccount");
default:
break;
}
const QString message = response.value(QStringLiteral("Message")).toString();
return message.isEmpty() ? Loc::text("auth.msa.error.xboxRejected").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 Loc::text("auth.msa.error.microsoftCode").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 = Loc::text("auth.msa.error.noAuthCode");
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(Loc::text("auth.msa.progress.exchangingCode"));
exchangeToken(form.toString(QUrl::FullyEncoded).toUtf8(), std::move(callback));
}
void MsaAuthService::loginWithRefreshToken(const QString &refreshToken, Callback callback)
{
if (refreshToken.isEmpty()) {
AuthResult result;
result.error = Loc::text("auth.msa.error.profileNotLinked");
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(Loc::text("auth.msa.progress.refreshingSession"));
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 = Loc::text("auth.msa.error.microsoftUnreachable").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 = Loc::text("auth.msa.error.noAccessToken");
callback(result);
return;
}
authenticateXbox(accessToken, refreshToken, callback);
});
}
void MsaAuthService::authenticateXbox(const QString &microsoftToken,
const QString &refreshToken,
Callback callback)
{
emit progress(Loc::text("auth.msa.progress.xboxSignIn"));
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 = Loc::text("auth.msa.error.xboxUnreachable").arg(transportError);
callback(result);
return;
}
if (status != 200) {
result.error = Loc::text("auth.msa.error.xboxTokenRejected").arg(status);
callback(result);
return;
}
const QString token = response.value(QStringLiteral("Token")).toString();
if (token.isEmpty()) {
result.error = Loc::text("auth.msa.error.xboxNoToken");
callback(result);
return;
}
authorizeXsts(token, refreshToken, callback);
});
}
void MsaAuthService::authorizeXsts(const QString &xblToken,
const QString &refreshToken,
Callback callback)
{
emit progress(Loc::text("auth.msa.progress.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 = Loc::text("auth.msa.error.xstsUnreachable").arg(transportError);
callback(result);
return;
}
if (status == 401) {
result.error = describeXstsError(response);
callback(result);
return;
}
if (status != 200) {
result.error = Loc::text("auth.msa.error.xstsRejected").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 = Loc::text("auth.msa.error.xstsNoUser");
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(Loc::text("auth.msa.progress.minecraftServices"));
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 = Loc::text("auth.msa.error.servicesUnreachable").arg(transportError);
callback(result);
return;
}
if (status != 200) {
result.error = Loc::text("auth.msa.error.servicesRejected").arg(status);
callback(result);
return;
}
result.accessToken = response.value(QStringLiteral("access_token")).toString();
if (result.accessToken.isEmpty()) {
result.error = Loc::text("auth.msa.error.servicesNoToken");
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(Loc::text("auth.msa.progress.checkingLicence"));
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 = Loc::text("auth.msa.error.servicesUnreachable")
.arg(transportError);
callback(result);
return;
}
// 404 здесь — штатный ответ для аккаунта, на котором нет копии
// игры: профиль игрока создаётся только вместе с покупкой.
if (status == 404 || status == 401) {
result.licenseMissing = true;
result.error =
storeEntitlement
? Loc::text("auth.msa.error.noProfileYet")
: Loc::text("auth.msa.error.noLicence");
callback(result);
return;
}
if (status != 200) {
result.error =
Loc::text("auth.msa.error.profileFetchFailed").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 = Loc::text("auth.msa.error.servicesNoProfile");
}
callback(result);
});
}