From 8bcbe07c87ee4b776d9ba743bb598f22ee80dda0 Mon Sep 17 00:00:00 2001 From: TheCodex6824 Date: Thu, 21 Apr 2022 16:01:55 -0400 Subject: [PATCH 1/4] Fix Mojang auth failing due to Mojang rejecting requests to the profile endpoint --- launcher/CMakeLists.txt | 2 + launcher/minecraft/auth/Parsers.cpp | 170 ++++++++++++++++++ launcher/minecraft/auth/Parsers.h | 1 + launcher/minecraft/auth/Yggdrasil.cpp | 22 +++ launcher/minecraft/auth/flows/Mojang.cpp | 6 +- .../auth/steps/MinecraftProfileStepMojang.cpp | 94 ++++++++++ .../auth/steps/MinecraftProfileStepMojang.h | 22 +++ 7 files changed, 314 insertions(+), 3 deletions(-) create mode 100644 launcher/minecraft/auth/steps/MinecraftProfileStepMojang.cpp create mode 100644 launcher/minecraft/auth/steps/MinecraftProfileStepMojang.h diff --git a/launcher/CMakeLists.txt b/launcher/CMakeLists.txt index 6ed86726b..075c183a7 100644 --- a/launcher/CMakeLists.txt +++ b/launcher/CMakeLists.txt @@ -235,6 +235,8 @@ set(MINECRAFT_SOURCES minecraft/auth/steps/MigrationEligibilityStep.h minecraft/auth/steps/MinecraftProfileStep.cpp minecraft/auth/steps/MinecraftProfileStep.h + minecraft/auth/steps/MinecraftProfileStepMojang.cpp + minecraft/auth/steps/MinecraftProfileStepMojang.h minecraft/auth/steps/MSAStep.cpp minecraft/auth/steps/MSAStep.h minecraft/auth/steps/XboxAuthorizationStep.cpp diff --git a/launcher/minecraft/auth/Parsers.cpp b/launcher/minecraft/auth/Parsers.cpp index 2dd36562f..82f23559a 100644 --- a/launcher/minecraft/auth/Parsers.cpp +++ b/launcher/minecraft/auth/Parsers.cpp @@ -212,6 +212,176 @@ bool parseMinecraftProfile(QByteArray & data, MinecraftProfile &output) { return true; } +namespace { + // these skin URLs are for the MHF_Steve and MHF_Alex accounts (made by a Mojang employee) + // they are needed because the session server doesn't return skin urls for default skins + static const QString SKIN_URL_STEVE = "http://textures.minecraft.net/texture/1a4af718455d4aab528e7a61f86fa25e6a369d1768dcb13f7df319a713eb810b"; + static const QString SKIN_URL_ALEX = "http://textures.minecraft.net/texture/83cee5ca6afcdb171285aa00e8049c297b2dbeba0efb8ff970a5677a1b644032"; + + bool isDefaultModelSteve(QString uuid) { + // need to calculate *Java* hashCode of UUID + // if number is even, skin/model is steve, otherwise it is alex + + // just in case dashes are in the id + uuid.remove('-'); + + if (uuid.size() != 32) { + return true; + } + + // qulonglong is guaranteed to be 64 bits + // we need to use unsigned numbers to guarantee truncation below + qulonglong most = uuid.left(16).toULongLong(nullptr, 16); + qulonglong least = uuid.right(16).toULongLong(nullptr, 16); + qulonglong xored = most ^ least; + return ((static_cast(xored >> 32)) ^ static_cast(xored)) % 2 == 0; + } +} + +/** +Uses session server for skin/cape lookup instead of profile, +because locked Mojang accounts cannot access profile endpoint +(https://api.minecraftservices.com/minecraft/profile/) + +ref: https://wiki.vg/Mojang_API#UUID_to_Profile_and_Skin.2FCape + +{ + "id": "", + "name": "", + "properties": [ + { + "name": "textures", + "value": "" + } + ] +} + +decoded base64 "value": +{ + "timestamp": , + "profileId": "", + "profileName": "", + "textures": { + "SKIN": { + "url": "" + }, + "CAPE": { + "url": "" + } + } +} +*/ + +bool parseMinecraftProfileMojang(QByteArray & data, MinecraftProfile &output) { + qDebug() << "Parsing Minecraft profile..."; +#ifndef NDEBUG + qDebug() << data; +#endif + + QJsonParseError jsonError; + QJsonDocument doc = QJsonDocument::fromJson(data, &jsonError); + if(jsonError.error) { + qWarning() << "Failed to parse response as JSON: " << jsonError.errorString(); + return false; + } + + auto obj = doc.object(); + if(!getString(obj.value("id"), output.id)) { + qWarning() << "Minecraft profile id is not a string"; + return false; + } + + if(!getString(obj.value("name"), output.name)) { + qWarning() << "Minecraft profile name is not a string"; + return false; + } + + auto propsArray = obj.value("properties").toArray(); + QByteArray texturePayload; + for( auto p : propsArray) { + auto pObj = p.toObject(); + auto name = pObj.value("name"); + if (!name.isString() || name.toString() != "textures") { + continue; + } + + auto value = pObj.value("value"); + if (value.isString()) { + texturePayload = QByteArray::fromBase64(value.toString().toUtf8(), QByteArray::AbortOnBase64DecodingErrors); + } + + if (!texturePayload.isEmpty()) { + break; + } + } + + if (texturePayload.isNull()) { + qWarning() << "No texture payload data"; + return false; + } + + doc = QJsonDocument::fromJson(texturePayload, &jsonError); + if(jsonError.error) { + qWarning() << "Failed to parse response as JSON: " << jsonError.errorString(); + return false; + } + + obj = doc.object(); + auto textures = obj.value("textures"); + if (!textures.isObject()) { + qWarning() << "No textures array in response"; + return false; + } + + Skin skinOut; + // fill in default skin info ourselves, as this endpoint doesn't provide it + bool steve = isDefaultModelSteve(output.id); + skinOut.variant = steve ? "classic" : "slim"; + skinOut.url = steve ? SKIN_URL_STEVE : SKIN_URL_ALEX; + // sadly we can't figure this out, but I don't think it really matters... + skinOut.id = "00000000-0000-0000-0000-000000000000"; + Cape capeOut; + auto tObj = textures.toObject(); + for (auto idx = tObj.constBegin(); idx != tObj.constEnd(); ++idx) { + if (idx->isObject()) { + if (idx.key() == "SKIN") { + auto skin = idx->toObject(); + if (!getString(skin.value("url"), skinOut.url)) { + qWarning() << "Skin url is not a string"; + return false; + } + + auto maybeMeta = skin.find("metadata"); + if (maybeMeta != skin.end() && maybeMeta->isObject()) { + auto meta = maybeMeta->toObject(); + // might not be present + getString(meta.value("model"), skinOut.variant); + } + } + else if (idx.key() == "CAPE") { + auto cape = idx->toObject(); + if (!getString(cape.value("url"), capeOut.url)) { + qWarning() << "Cape url is not a string"; + return false; + } + + // we don't know the cape ID as it is not returned from the session server + // so just fake it - changing capes is probably locked anyway :( + capeOut.alias = "cape"; + } + } + } + + output.skin = skinOut; + if (capeOut.alias == "cape") { + output.capes = QMap({{capeOut.alias, capeOut}}); + output.currentCape = capeOut.alias; + } + + output.validity = Katabasis::Validity::Certain; + return true; +} + bool parseMinecraftEntitlements(QByteArray & data, MinecraftEntitlement &output) { qDebug() << "Parsing Minecraft entitlements..."; #ifndef NDEBUG diff --git a/launcher/minecraft/auth/Parsers.h b/launcher/minecraft/auth/Parsers.h index dac7f69bf..2666d890c 100644 --- a/launcher/minecraft/auth/Parsers.h +++ b/launcher/minecraft/auth/Parsers.h @@ -14,6 +14,7 @@ namespace Parsers bool parseMojangResponse(QByteArray &data, Katabasis::Token &output); bool parseMinecraftProfile(QByteArray &data, MinecraftProfile &output); + bool parseMinecraftProfileMojang(QByteArray &data, MinecraftProfile &output); bool parseMinecraftEntitlements(QByteArray &data, MinecraftEntitlement &output); bool parseRolloutResponse(QByteArray &data, bool& result); } diff --git a/launcher/minecraft/auth/Yggdrasil.cpp b/launcher/minecraft/auth/Yggdrasil.cpp index 7ac842a67..299784119 100644 --- a/launcher/minecraft/auth/Yggdrasil.cpp +++ b/launcher/minecraft/auth/Yggdrasil.cpp @@ -209,6 +209,28 @@ void Yggdrasil::processResponse(QJsonObject responseData) { m_data->yggdrasilToken.validity = Katabasis::Validity::Certain; m_data->yggdrasilToken.issueInstant = QDateTime::currentDateTimeUtc(); + // Get UUID here since we need it for later + auto profile = responseData.value("selectedProfile"); + if (!profile.isObject()) { + changeState(AccountTaskState::STATE_FAILED_HARD, tr("Authentication server didn't send a selected profile.")); + return; + } + + auto profileObj = profile.toObject(); + for (auto i = profileObj.constBegin(); i != profileObj.constEnd(); ++i) { + if (i.key() == "name" && i.value().isString()) { + m_data->minecraftProfile.name = i->toString(); + } + else if (i.key() == "id" && i.value().isString()) { + m_data->minecraftProfile.id = i->toString(); + } + } + + if (m_data->minecraftProfile.id.isEmpty()) { + changeState(AccountTaskState::STATE_FAILED_HARD, tr("Authentication server didn't send a UUID in selected profile.")); + return; + } + // We've made it through the minefield of possible errors. Return true to indicate that // we've succeeded. qDebug() << "Finished reading authentication response."; diff --git a/launcher/minecraft/auth/flows/Mojang.cpp b/launcher/minecraft/auth/flows/Mojang.cpp index 4661dbe23..b86b0936a 100644 --- a/launcher/minecraft/auth/flows/Mojang.cpp +++ b/launcher/minecraft/auth/flows/Mojang.cpp @@ -1,7 +1,7 @@ #include "Mojang.h" #include "minecraft/auth/steps/YggdrasilStep.h" -#include "minecraft/auth/steps/MinecraftProfileStep.h" +#include "minecraft/auth/steps/MinecraftProfileStepMojang.h" #include "minecraft/auth/steps/MigrationEligibilityStep.h" #include "minecraft/auth/steps/GetSkinStep.h" @@ -10,7 +10,7 @@ MojangRefresh::MojangRefresh( QObject *parent ) : AuthFlow(data, parent) { m_steps.append(new YggdrasilStep(m_data, QString())); - m_steps.append(new MinecraftProfileStep(m_data)); + m_steps.append(new MinecraftProfileStepMojang(m_data)); m_steps.append(new MigrationEligibilityStep(m_data)); m_steps.append(new GetSkinStep(m_data)); } @@ -21,7 +21,7 @@ MojangLogin::MojangLogin( QObject *parent ): AuthFlow(data, parent), m_password(password) { m_steps.append(new YggdrasilStep(m_data, m_password)); - m_steps.append(new MinecraftProfileStep(m_data)); + m_steps.append(new MinecraftProfileStepMojang(m_data)); m_steps.append(new MigrationEligibilityStep(m_data)); m_steps.append(new GetSkinStep(m_data)); } diff --git a/launcher/minecraft/auth/steps/MinecraftProfileStepMojang.cpp b/launcher/minecraft/auth/steps/MinecraftProfileStepMojang.cpp new file mode 100644 index 000000000..d30352725 --- /dev/null +++ b/launcher/minecraft/auth/steps/MinecraftProfileStepMojang.cpp @@ -0,0 +1,94 @@ +#include "MinecraftProfileStepMojang.h" + +#include + +#include "minecraft/auth/AuthRequest.h" +#include "minecraft/auth/Parsers.h" + +MinecraftProfileStepMojang::MinecraftProfileStepMojang(AccountData* data) : AuthStep(data) { + +} + +MinecraftProfileStepMojang::~MinecraftProfileStepMojang() noexcept = default; + +QString MinecraftProfileStepMojang::describe() { + return tr("Fetching the Minecraft profile."); +} + + +void MinecraftProfileStepMojang::perform() { + if (m_data->minecraftProfile.id.isEmpty()) { + emit finished(AccountTaskState::STATE_FAILED_HARD, tr("A UUID is required to get the profile.")); + return; + } + + // use session server instead of profile due to profile endpoint being locked for locked Mojang accounts + QUrl url = QUrl("https://sessionserver.mojang.com/session/minecraft/profile/" + m_data->minecraftProfile.id); + QNetworkRequest req = QNetworkRequest(url); + AuthRequest *request = new AuthRequest(this); + connect(request, &AuthRequest::finished, this, &MinecraftProfileStepMojang::onRequestDone); + request->get(req); +} + +void MinecraftProfileStepMojang::rehydrate() { + // NOOP, for now. We only save bools and there's nothing to check. +} + +void MinecraftProfileStepMojang::onRequestDone( + QNetworkReply::NetworkError error, + QByteArray data, + QList headers +) { + auto requestor = qobject_cast(QObject::sender()); + requestor->deleteLater(); + +#ifndef NDEBUG + qDebug() << data; +#endif + if (error == QNetworkReply::ContentNotFoundError) { + // NOTE: Succeed even if we do not have a profile. This is a valid account state. + if(m_data->type == AccountType::Mojang) { + m_data->minecraftEntitlement.canPlayMinecraft = false; + m_data->minecraftEntitlement.ownsMinecraft = false; + } + m_data->minecraftProfile = MinecraftProfile(); + emit finished( + AccountTaskState::STATE_SUCCEEDED, + tr("Account has no Minecraft profile.") + ); + return; + } + if (error != QNetworkReply::NoError) { + qWarning() << "Error getting profile:"; + qWarning() << " HTTP Status: " << requestor->httpStatus_; + qWarning() << " Internal error no.: " << error; + qWarning() << " Error string: " << requestor->errorString_; + + qWarning() << " Response:"; + qWarning() << QString::fromUtf8(data); + + emit finished( + AccountTaskState::STATE_FAILED_SOFT, + tr("Minecraft Java profile acquisition failed.") + ); + return; + } + if(!Parsers::parseMinecraftProfileMojang(data, m_data->minecraftProfile)) { + m_data->minecraftProfile = MinecraftProfile(); + emit finished( + AccountTaskState::STATE_FAILED_SOFT, + tr("Minecraft Java profile response could not be parsed") + ); + return; + } + + if(m_data->type == AccountType::Mojang) { + auto validProfile = m_data->minecraftProfile.validity == Katabasis::Validity::Certain; + m_data->minecraftEntitlement.canPlayMinecraft = validProfile; + m_data->minecraftEntitlement.ownsMinecraft = validProfile; + } + emit finished( + AccountTaskState::STATE_WORKING, + tr("Minecraft Java profile acquisition succeeded.") + ); +} diff --git a/launcher/minecraft/auth/steps/MinecraftProfileStepMojang.h b/launcher/minecraft/auth/steps/MinecraftProfileStepMojang.h new file mode 100644 index 000000000..e06b30ab0 --- /dev/null +++ b/launcher/minecraft/auth/steps/MinecraftProfileStepMojang.h @@ -0,0 +1,22 @@ +#pragma once +#include + +#include "QObjectPtr.h" +#include "minecraft/auth/AuthStep.h" + + +class MinecraftProfileStepMojang : public AuthStep { + Q_OBJECT + +public: + explicit MinecraftProfileStepMojang(AccountData *data); + virtual ~MinecraftProfileStepMojang() noexcept; + + void perform() override; + void rehydrate() override; + + QString describe() override; + +private slots: + void onRequestDone(QNetworkReply::NetworkError, QByteArray, QList); +}; From e56f0db11bc096909bc17eac1a5cdf5de06817dc Mon Sep 17 00:00:00 2001 From: TheCodex6824 Date: Sat, 23 Apr 2022 10:32:52 -0400 Subject: [PATCH 2/4] Remove base64 decode option that was added in Qt 5.15 --- launcher/minecraft/auth/Parsers.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/launcher/minecraft/auth/Parsers.cpp b/launcher/minecraft/auth/Parsers.cpp index 82f23559a..60458b9b8 100644 --- a/launcher/minecraft/auth/Parsers.cpp +++ b/launcher/minecraft/auth/Parsers.cpp @@ -307,7 +307,7 @@ bool parseMinecraftProfileMojang(QByteArray & data, MinecraftProfile &output) { auto value = pObj.value("value"); if (value.isString()) { - texturePayload = QByteArray::fromBase64(value.toString().toUtf8(), QByteArray::AbortOnBase64DecodingErrors); + texturePayload = QByteArray::fromBase64(value.toString().toUtf8()); } if (!texturePayload.isEmpty()) { From a0bafa49520195512c388ebe8d5e5b307d0a10be Mon Sep 17 00:00:00 2001 From: TheCodex6824 Date: Sat, 23 Apr 2022 11:11:55 -0400 Subject: [PATCH 3/4] Re-add base64 decode option for Qt versions that support it --- launcher/minecraft/auth/Parsers.cpp | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/launcher/minecraft/auth/Parsers.cpp b/launcher/minecraft/auth/Parsers.cpp index 60458b9b8..ea882b564 100644 --- a/launcher/minecraft/auth/Parsers.cpp +++ b/launcher/minecraft/auth/Parsers.cpp @@ -213,8 +213,8 @@ bool parseMinecraftProfile(QByteArray & data, MinecraftProfile &output) { } namespace { - // these skin URLs are for the MHF_Steve and MHF_Alex accounts (made by a Mojang employee) - // they are needed because the session server doesn't return skin urls for default skins + // these skin URLs are for the MHF_Steve and MHF_Alex accounts (made by a Mojang employee) + // they are needed because the session server doesn't return skin urls for default skins static const QString SKIN_URL_STEVE = "http://textures.minecraft.net/texture/1a4af718455d4aab528e7a61f86fa25e6a369d1768dcb13f7df319a713eb810b"; static const QString SKIN_URL_ALEX = "http://textures.minecraft.net/texture/83cee5ca6afcdb171285aa00e8049c297b2dbeba0efb8ff970a5677a1b644032"; @@ -307,7 +307,11 @@ bool parseMinecraftProfileMojang(QByteArray & data, MinecraftProfile &output) { auto value = pObj.value("value"); if (value.isString()) { +#if QT_VERSION >= QT_VERSION_CHECK(5, 15, 0) + texturePayload = QByteArray::fromBase64(value.toString().toUtf8(), QByteArray::AbortOnBase64DecodingErrors); +#else texturePayload = QByteArray::fromBase64(value.toString().toUtf8()); +#endif } if (!texturePayload.isEmpty()) { From 1ff459d995f685aa5a83fe2c1c4b8f0f3b56ed03 Mon Sep 17 00:00:00 2001 From: TheCodex6824 Date: Mon, 25 Apr 2022 14:08:27 -0400 Subject: [PATCH 4/4] Use suggested error handling --- launcher/minecraft/auth/Parsers.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/launcher/minecraft/auth/Parsers.cpp b/launcher/minecraft/auth/Parsers.cpp index ea882b564..47473899b 100644 --- a/launcher/minecraft/auth/Parsers.cpp +++ b/launcher/minecraft/auth/Parsers.cpp @@ -1,4 +1,5 @@ #include "Parsers.h" +#include "Json.h" #include #include @@ -285,7 +286,7 @@ bool parseMinecraftProfileMojang(QByteArray & data, MinecraftProfile &output) { return false; } - auto obj = doc.object(); + auto obj = Json::requireObject(doc, "mojang minecraft profile"); if(!getString(obj.value("id"), output.id)) { qWarning() << "Minecraft profile id is not a string"; return false; @@ -330,7 +331,7 @@ bool parseMinecraftProfileMojang(QByteArray & data, MinecraftProfile &output) { return false; } - obj = doc.object(); + obj = Json::requireObject(doc, "session texture payload"); auto textures = obj.value("textures"); if (!textures.isObject()) { qWarning() << "No textures array in response";