diff options
Diffstat (limited to 'launcher/minecraft/auth/flows')
19 files changed, 1423 insertions, 544 deletions
diff --git a/launcher/minecraft/auth/flows/AuthContext.cpp b/launcher/minecraft/auth/flows/AuthContext.cpp new file mode 100644 index 00000000..9aa58ac3 --- /dev/null +++ b/launcher/minecraft/auth/flows/AuthContext.cpp @@ -0,0 +1,752 @@ +#include <QNetworkAccessManager> +#include <QNetworkRequest> +#include <QNetworkReply> +#include <QDesktopServices> +#include <QMetaEnum> +#include <QDebug> + +#include <QJsonDocument> +#include <QJsonObject> +#include <QJsonArray> + +#include <QUrlQuery> + +#include <QPixmap> +#include <QPainter> + +#include "AuthContext.h" +#include "katabasis/Globals.h" +#include "katabasis/Requestor.h" +#include "BuildConfig.h" + +using OAuth2 = Katabasis::OAuth2; +using Requestor = Katabasis::Requestor; +using Activity = Katabasis::Activity; + +AuthContext::AuthContext(AccountData * data, QObject *parent) : + AccountTask(data, parent) +{ + mgr = new QNetworkAccessManager(this); +} + +void AuthContext::beginActivity(Activity activity) { + if(isBusy()) { + throw 0; + } + m_activity = activity; + changeState(STATE_WORKING, "Initializing"); + emit activityChanged(m_activity); +} + +void AuthContext::finishActivity() { + if(!isBusy()) { + throw 0; + } + m_activity = Katabasis::Activity::Idle; + m_stage = MSAStage::Idle; + m_data->validity_ = m_data->minecraftProfile.validity; + emit activityChanged(m_activity); +} + +void AuthContext::initMSA() { + if(m_oauth2) { + return; + } + Katabasis::OAuth2::Options opts; + opts.scope = "XboxLive.signin offline_access"; + opts.clientIdentifier = BuildConfig.MSA_CLIENT_ID; + opts.authorizationUrl = "https://login.live.com/oauth20_authorize.srf"; + opts.accessTokenUrl = "https://login.live.com/oauth20_token.srf"; + opts.listenerPorts = {28562, 28563, 28564, 28565, 28566}; + + m_oauth2 = new OAuth2(opts, m_data->msaToken, this, mgr); + + connect(m_oauth2, &OAuth2::linkingFailed, this, &AuthContext::onOAuthLinkingFailed); + connect(m_oauth2, &OAuth2::linkingSucceeded, this, &AuthContext::onOAuthLinkingSucceeded); + connect(m_oauth2, &OAuth2::openBrowser, this, &AuthContext::onOpenBrowser); + connect(m_oauth2, &OAuth2::closeBrowser, this, &AuthContext::onCloseBrowser); + connect(m_oauth2, &OAuth2::activityChanged, this, &AuthContext::onOAuthActivityChanged); +} + +void AuthContext::initMojang() { + if(m_yggdrasil) { + return; + } + m_yggdrasil = new Yggdrasil(m_data, this); + + connect(m_yggdrasil, &Task::failed, this, &AuthContext::onMojangFailed); + connect(m_yggdrasil, &Task::succeeded, this, &AuthContext::onMojangSucceeded); +} + +void AuthContext::onMojangSucceeded() { + doMinecraftProfile(); +} + + +void AuthContext::onMojangFailed() { + finishActivity(); + m_error = m_yggdrasil->m_error; + m_aborted = m_yggdrasil->m_aborted; + changeState(m_yggdrasil->accountState(), "Microsoft user authentication failed."); +} + +/* +bool AuthContext::signOut() { + if(isBusy()) { + return false; + } + + start(); + + beginActivity(Activity::LoggingOut); + m_oauth2->unlink(); + m_account = AccountData(); + finishActivity(); + return true; +} +*/ + +void AuthContext::onOpenBrowser(const QUrl &url) { + QDesktopServices::openUrl(url); +} + +void AuthContext::onCloseBrowser() { + +} + +void AuthContext::onOAuthLinkingFailed() { + finishActivity(); + changeState(STATE_FAILED_HARD, "Microsoft user authentication failed."); +} + +void AuthContext::onOAuthLinkingSucceeded() { + auto *o2t = qobject_cast<OAuth2 *>(sender()); + if (!o2t->linked()) { + finishActivity(); + changeState(STATE_FAILED_HARD, "Microsoft user authentication ended with an impossible state (succeeded, but not succeeded at the same time)."); + return; + } + QVariantMap extraTokens = o2t->extraTokens(); + if (!extraTokens.isEmpty()) { + qDebug() << "Extra tokens in response:"; + foreach (QString key, extraTokens.keys()) { + qDebug() << "\t" << key << ":" << extraTokens.value(key); + } + } + doUserAuth(); +} + +void AuthContext::onOAuthActivityChanged(Katabasis::Activity activity) { + // respond to activity change here +} + +void AuthContext::doUserAuth() { + m_stage = MSAStage::UserAuth; + changeState(STATE_WORKING, "Starting user authentication"); + + QString xbox_auth_template = R"XXX( +{ + "Properties": { + "AuthMethod": "RPS", + "SiteName": "user.auth.xboxlive.com", + "RpsTicket": "d=%1" + }, + "RelyingParty": "http://auth.xboxlive.com", + "TokenType": "JWT" +} +)XXX"; + auto xbox_auth_data = xbox_auth_template.arg(m_data->msaToken.token); + + QNetworkRequest request = QNetworkRequest(QUrl("https://user.auth.xboxlive.com/user/authenticate")); + request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + request.setRawHeader("Accept", "application/json"); + auto *requestor = new Katabasis::Requestor(mgr, m_oauth2, this); + requestor->setAddAccessTokenInQuery(false); + + connect(requestor, &Requestor::finished, this, &AuthContext::onUserAuthDone); + requestor->post(request, xbox_auth_data.toUtf8()); + qDebug() << "First layer of XBox auth ... commencing."; +} + +namespace { +bool getDateTime(QJsonValue value, QDateTime & out) { + if(!value.isString()) { + return false; + } + out = QDateTime::fromString(value.toString(), Qt::ISODate); + return out.isValid(); +} + +bool getString(QJsonValue value, QString & out) { + if(!value.isString()) { + return false; + } + out = value.toString(); + return true; +} + +bool getNumber(QJsonValue value, double & out) { + if(!value.isDouble()) { + return false; + } + out = value.toDouble(); + return true; +} + +/* +{ + "IssueInstant":"2020-12-07T19:52:08.4463796Z", + "NotAfter":"2020-12-21T19:52:08.4463796Z", + "Token":"token", + "DisplayClaims":{ + "xui":[ + { + "uhs":"userhash" + } + ] + } + } +*/ +// TODO: handle error responses ... +/* +{ + "Identity":"0", + "XErr":2148916238, + "Message":"", + "Redirect":"https://start.ui.xboxlive.com/AddChildToFamily" +} +// 2148916233 = missing XBox account +// 2148916238 = child account not linked to a family +*/ + +bool parseXTokenResponse(QByteArray & data, Katabasis::Token &output) { + QJsonParseError jsonError; + QJsonDocument doc = QJsonDocument::fromJson(data, &jsonError); + if(jsonError.error) { + qWarning() << "Failed to parse response from user.auth.xboxlive.com as JSON: " << jsonError.errorString(); + qDebug() << data; + return false; + } + + auto obj = doc.object(); + if(!getDateTime(obj.value("IssueInstant"), output.issueInstant)) { + qWarning() << "User IssueInstant is not a timestamp"; + qDebug() << data; + return false; + } + if(!getDateTime(obj.value("NotAfter"), output.notAfter)) { + qWarning() << "User NotAfter is not a timestamp"; + qDebug() << data; + return false; + } + if(!getString(obj.value("Token"), output.token)) { + qWarning() << "User Token is not a timestamp"; + qDebug() << data; + return false; + } + auto arrayVal = obj.value("DisplayClaims").toObject().value("xui"); + if(!arrayVal.isArray()) { + qWarning() << "Missing xui claims array"; + qDebug() << data; + return false; + } + bool foundUHS = false; + for(auto item: arrayVal.toArray()) { + if(!item.isObject()) { + continue; + } + auto obj = item.toObject(); + if(obj.contains("uhs")) { + foundUHS = true; + } else { + continue; + } + // consume all 'display claims' ... whatever that means + for(auto iter = obj.begin(); iter != obj.end(); iter++) { + QString claim; + if(!getString(obj.value(iter.key()), claim)) { + qWarning() << "display claim " << iter.key() << " is not a string..."; + qDebug() << data; + return false; + } + output.extra[iter.key()] = claim; + } + + break; + } + if(!foundUHS) { + qWarning() << "Missing uhs"; + qDebug() << data; + return false; + } + output.validity = Katabasis::Validity::Certain; + qDebug() << data; + return true; +} + +} + +void AuthContext::onUserAuthDone( + int requestId, + QNetworkReply::NetworkError error, + QByteArray replyData, + QList<QNetworkReply::RawHeaderPair> headers +) { + if (error != QNetworkReply::NoError) { + qWarning() << "Reply error:" << error; + finishActivity(); + changeState(STATE_FAILED_HARD, "XBox user authentication failed."); + return; + } + + Katabasis::Token temp; + if(!parseXTokenResponse(replyData, temp)) { + qWarning() << "Could not parse user authentication response..."; + finishActivity(); + changeState(STATE_FAILED_HARD, "XBox user authentication response could not be understood."); + return; + } + m_data->userToken = temp; + + m_stage = MSAStage::XboxAuth; + changeState(STATE_WORKING, "Starting XBox authentication"); + + doSTSAuthMinecraft(); + doSTSAuthGeneric(); +} +/* + url = "https://xsts.auth.xboxlive.com/xsts/authorize" + headers = {"x-xbl-contract-version": "1"} + data = { + "RelyingParty": relying_party, + "TokenType": "JWT", + "Properties": { + "UserTokens": [self.user_token.token], + "SandboxId": "RETAIL", + }, + } +*/ +void AuthContext::doSTSAuthMinecraft() { + QString xbox_auth_template = R"XXX( +{ + "Properties": { + "SandboxId": "RETAIL", + "UserTokens": [ + "%1" + ] + }, + "RelyingParty": "rp://api.minecraftservices.com/", + "TokenType": "JWT" +} +)XXX"; + auto xbox_auth_data = xbox_auth_template.arg(m_data->userToken.token); + + QNetworkRequest request = QNetworkRequest(QUrl("https://xsts.auth.xboxlive.com/xsts/authorize")); + request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + request.setRawHeader("Accept", "application/json"); + Requestor *requestor = new Requestor(mgr, m_oauth2, this); + requestor->setAddAccessTokenInQuery(false); + + connect(requestor, &Requestor::finished, this, &AuthContext::onSTSAuthMinecraftDone); + requestor->post(request, xbox_auth_data.toUtf8()); + qDebug() << "Second layer of XBox auth ... commencing."; +} + +void AuthContext::onSTSAuthMinecraftDone( + int requestId, + QNetworkReply::NetworkError error, + QByteArray replyData, + QList<QNetworkReply::RawHeaderPair> headers +) { + if (error != QNetworkReply::NoError) { + qWarning() << "Reply error:" << error; + m_requestsDone ++; + return; + } + + Katabasis::Token temp; + if(!parseXTokenResponse(replyData, temp)) { + qWarning() << "Could not parse authorization response for access to mojang services..."; + m_requestsDone ++; + return; + } + + if(temp.extra["uhs"] != m_data->userToken.extra["uhs"]) { + qWarning() << "Server has changed user hash in the reply... something is wrong. ABORTING"; + qDebug() << replyData; + m_requestsDone ++; + return; + } + m_data->mojangservicesToken = temp; + + doMinecraftAuth(); +} + +void AuthContext::doSTSAuthGeneric() { + QString xbox_auth_template = R"XXX( +{ + "Properties": { + "SandboxId": "RETAIL", + "UserTokens": [ + "%1" + ] + }, + "RelyingParty": "http://xboxlive.com", + "TokenType": "JWT" +} +)XXX"; + auto xbox_auth_data = xbox_auth_template.arg(m_data->userToken.token); + + QNetworkRequest request = QNetworkRequest(QUrl("https://xsts.auth.xboxlive.com/xsts/authorize")); + request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + request.setRawHeader("Accept", "application/json"); + Requestor *requestor = new Requestor(mgr, m_oauth2, this); + requestor->setAddAccessTokenInQuery(false); + + connect(requestor, &Requestor::finished, this, &AuthContext::onSTSAuthGenericDone); + requestor->post(request, xbox_auth_data.toUtf8()); + qDebug() << "Second layer of XBox auth ... commencing."; +} + +void AuthContext::onSTSAuthGenericDone( + int requestId, + QNetworkReply::NetworkError error, + QByteArray replyData, + QList<QNetworkReply::RawHeaderPair> headers +) { + if (error != QNetworkReply::NoError) { + qWarning() << "Reply error:" << error; + m_requestsDone ++; + return; + } + + Katabasis::Token temp; + if(!parseXTokenResponse(replyData, temp)) { + qWarning() << "Could not parse authorization response for access to xbox API..."; + m_requestsDone ++; + return; + } + + if(temp.extra["uhs"] != m_data->userToken.extra["uhs"]) { + qWarning() << "Server has changed user hash in the reply... something is wrong. ABORTING"; + qDebug() << replyData; + m_requestsDone ++; + return; + } + m_data->xboxApiToken = temp; + + doXBoxProfile(); +} + + +void AuthContext::doMinecraftAuth() { + QString mc_auth_template = R"XXX( +{ + "identityToken": "XBL3.0 x=%1;%2" +} +)XXX"; + auto data = mc_auth_template.arg(m_data->mojangservicesToken.extra["uhs"].toString(), m_data->mojangservicesToken.token); + + QNetworkRequest request = QNetworkRequest(QUrl("https://api.minecraftservices.com/authentication/login_with_xbox")); + request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + request.setRawHeader("Accept", "application/json"); + Requestor *requestor = new Requestor(mgr, m_oauth2, this); + requestor->setAddAccessTokenInQuery(false); + + connect(requestor, &Requestor::finished, this, &AuthContext::onMinecraftAuthDone); + requestor->post(request, data.toUtf8()); + qDebug() << "Getting Minecraft access token..."; +} + +namespace { +bool parseMojangResponse(QByteArray & data, Katabasis::Token &output) { + QJsonParseError jsonError; + QJsonDocument doc = QJsonDocument::fromJson(data, &jsonError); + if(jsonError.error) { + qWarning() << "Failed to parse response from user.auth.xboxlive.com as JSON: " << jsonError.errorString(); + qDebug() << data; + return false; + } + + auto obj = doc.object(); + double expires_in = 0; + if(!getNumber(obj.value("expires_in"), expires_in)) { + qWarning() << "expires_in is not a valid number"; + qDebug() << data; + return false; + } + auto currentTime = QDateTime::currentDateTimeUtc(); + output.issueInstant = currentTime; + output.notAfter = currentTime.addSecs(expires_in); + + QString username; + if(!getString(obj.value("username"), username)) { + qWarning() << "username is not valid"; + qDebug() << data; + return false; + } + + // TODO: it's a JWT... validate it? + if(!getString(obj.value("access_token"), output.token)) { + qWarning() << "access_token is not valid"; + qDebug() << data; + return false; + } + output.validity = Katabasis::Validity::Certain; + qDebug() << data; + return true; +} +} + +void AuthContext::onMinecraftAuthDone( + int requestId, + QNetworkReply::NetworkError error, + QByteArray replyData, + QList<QNetworkReply::RawHeaderPair> headers +) { + m_requestsDone ++; + + if (error != QNetworkReply::NoError) { + qWarning() << "Reply error:" << error; + qDebug() << replyData; + return; + } + + if(!parseMojangResponse(replyData, m_data->yggdrasilToken)) { + qWarning() << "Could not parse login_with_xbox response..."; + qDebug() << replyData; + return; + } + m_mcAuthSucceeded = true; + + checkResult(); +} + +void AuthContext::doXBoxProfile() { + auto url = QUrl("https://profile.xboxlive.com/users/me/profile/settings"); + QUrlQuery q; + q.addQueryItem( + "settings", + "GameDisplayName,AppDisplayName,AppDisplayPicRaw,GameDisplayPicRaw," + "PublicGamerpic,ShowUserAsAvatar,Gamerscore,Gamertag,ModernGamertag,ModernGamertagSuffix," + "UniqueModernGamertag,AccountTier,TenureLevel,XboxOneRep," + "PreferredColor,Location,Bio,Watermarks," + "RealName,RealNameOverride,IsQuarantined" + ); + url.setQuery(q); + + QNetworkRequest request = QNetworkRequest(url); + request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + request.setRawHeader("Accept", "application/json"); + request.setRawHeader("x-xbl-contract-version", "3"); + request.setRawHeader("Authorization", QString("XBL3.0 x=%1;%2").arg(m_data->userToken.extra["uhs"].toString(), m_data->xboxApiToken.token).toUtf8()); + Requestor *requestor = new Requestor(mgr, m_oauth2, this); + requestor->setAddAccessTokenInQuery(false); + + connect(requestor, &Requestor::finished, this, &AuthContext::onXBoxProfileDone); + requestor->get(request); + qDebug() << "Getting Xbox profile..."; +} + +void AuthContext::onXBoxProfileDone( + int requestId, + QNetworkReply::NetworkError error, + QByteArray replyData, + QList<QNetworkReply::RawHeaderPair> headers +) { + m_requestsDone ++; + + if (error != QNetworkReply::NoError) { + qWarning() << "Reply error:" << error; + qDebug() << replyData; + return; + } + + qDebug() << "XBox profile: " << replyData; + + m_xboxProfileSucceeded = true; + checkResult(); +} + +void AuthContext::checkResult() { + if(m_requestsDone != 2) { + return; + } + if(m_mcAuthSucceeded && m_xboxProfileSucceeded) { + doMinecraftProfile(); + } + else { + finishActivity(); + changeState(STATE_FAILED_HARD, "XBox and/or Mojang authentication steps did not succeed"); + } +} + +namespace { +bool parseMinecraftProfile(QByteArray & data, MinecraftProfile &output) { + QJsonParseError jsonError; + QJsonDocument doc = QJsonDocument::fromJson(data, &jsonError); + if(jsonError.error) { + qWarning() << "Failed to parse response from user.auth.xboxlive.com as JSON: " << jsonError.errorString(); + qDebug() << data; + return false; + } + + auto obj = doc.object(); + if(!getString(obj.value("id"), output.id)) { + qWarning() << "minecraft profile id is not a string"; + qDebug() << data; + return false; + } + + if(!getString(obj.value("name"), output.name)) { + qWarning() << "minecraft profile name is not a string"; + qDebug() << data; + return false; + } + + auto skinsArray = obj.value("skins").toArray(); + for(auto skin: skinsArray) { + auto skinObj = skin.toObject(); + Skin skinOut; + if(!getString(skinObj.value("id"), skinOut.id)) { + continue; + } + QString state; + if(!getString(skinObj.value("state"), state)) { + continue; + } + if(state != "ACTIVE") { + continue; + } + if(!getString(skinObj.value("url"), skinOut.url)) { + continue; + } + if(!getString(skinObj.value("variant"), skinOut.variant)) { + continue; + } + // we deal with only the active skin + output.skin = skinOut; + break; + } + auto capesArray = obj.value("capes").toArray(); + int i = -1; + int currentCape = -1; + for(auto cape: capesArray) { + i++; + auto capeObj = cape.toObject(); + Cape capeOut; + if(!getString(capeObj.value("id"), capeOut.id)) { + continue; + } + QString state; + if(!getString(capeObj.value("state"), state)) { + continue; + } + if(state == "ACTIVE") { + currentCape = i; + } + if(!getString(capeObj.value("url"), capeOut.url)) { + continue; + } + if(!getString(capeObj.value("alias"), capeOut.alias)) { + continue; + } + + // we deal with only the active skin + output.capes.push_back(capeOut); + } + output.currentCape = currentCape; + output.validity = Katabasis::Validity::Certain; + return true; +} +} + +void AuthContext::doMinecraftProfile() { + m_stage = MSAStage::MinecraftProfile; + changeState(STATE_WORKING, "Starting minecraft profile acquisition"); + + auto url = QUrl("https://api.minecraftservices.com/minecraft/profile"); + QNetworkRequest request = QNetworkRequest(url); + request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + // request.setRawHeader("Accept", "application/json"); + request.setRawHeader("Authorization", QString("Bearer %1").arg(m_data->yggdrasilToken.token).toUtf8()); + + Requestor *requestor = new Requestor(mgr, m_oauth2, this); + requestor->setAddAccessTokenInQuery(false); + + connect(requestor, &Requestor::finished, this, &AuthContext::onMinecraftProfileDone); + requestor->get(request); +} + +void AuthContext::onMinecraftProfileDone(int, QNetworkReply::NetworkError error, QByteArray data, QList<QNetworkReply::RawHeaderPair> headers) { + qDebug() << data; + if (error == QNetworkReply::ContentNotFoundError) { + m_data->minecraftProfile = MinecraftProfile(); + finishActivity(); + changeState(STATE_FAILED_HARD, "Account is missing a profile"); + return; + } + if (error != QNetworkReply::NoError) { + finishActivity(); + changeState(STATE_FAILED_HARD, "Profile acquisition failed"); + return; + } + if(!parseMinecraftProfile(data, m_data->minecraftProfile)) { + m_data->minecraftProfile = MinecraftProfile(); + finishActivity(); + changeState(STATE_FAILED_HARD, "Profile response could not be parsed"); + return; + } + doGetSkin(); +} + +void AuthContext::doGetSkin() { + m_stage = MSAStage::Skin; + changeState(STATE_WORKING, "Starting skin acquisition"); + + auto url = QUrl(m_data->minecraftProfile.skin.url); + QNetworkRequest request = QNetworkRequest(url); + Requestor *requestor = new Requestor(mgr, m_oauth2, this); + requestor->setAddAccessTokenInQuery(false); + connect(requestor, &Requestor::finished, this, &AuthContext::onSkinDone); + requestor->get(request); +} + +void AuthContext::onSkinDone(int, QNetworkReply::NetworkError error, QByteArray data, QList<QNetworkReply::RawHeaderPair>) { + if (error == QNetworkReply::NoError) { + m_data->minecraftProfile.skin.data = data; + } + m_data->validity_ = Katabasis::Validity::Certain; + finishActivity(); + changeState(STATE_SUCCEEDED, "Finished whole chain"); +} + +QString AuthContext::getStateMessage() const { + switch (m_accountState) + { + case STATE_WORKING: + switch(m_stage) { + case MSAStage::Idle: { + QString loginMessage = tr("Logging in as %1 user"); + if(m_data->type == AccountType::MSA) { + return loginMessage.arg("Microsoft"); + } + else { + return loginMessage.arg("Mojang"); + } + } + case MSAStage::UserAuth: + return tr("Logging in as XBox user"); + case MSAStage::XboxAuth: + return tr("Logging in with XBox and Mojang services"); + case MSAStage::MinecraftProfile: + return tr("Getting Minecraft profile"); + case MSAStage::Skin: + return tr("Getting Minecraft skin"); + default: + break; + } + default: + return AccountTask::getStateMessage(); + } +} diff --git a/launcher/minecraft/auth/flows/AuthContext.h b/launcher/minecraft/auth/flows/AuthContext.h new file mode 100644 index 00000000..5f99dba3 --- /dev/null +++ b/launcher/minecraft/auth/flows/AuthContext.h @@ -0,0 +1,94 @@ +#pragma once + +#include <QObject> +#include <QList> +#include <QVector> +#include <QNetworkReply> +#include <QImage> + +#include <katabasis/OAuth2.h> +#include "Yggdrasil.h" +#include "../AccountData.h" +#include "../AccountTask.h" + +class AuthContext : public AccountTask +{ + Q_OBJECT + +public: + explicit AuthContext(AccountData * data, QObject *parent = 0); + + bool isBusy() { + return m_activity != Katabasis::Activity::Idle; + }; + Katabasis::Validity validity() { + return m_data->validity_; + }; + + //bool signOut(); + + QString getStateMessage() const override; + +signals: + void activityChanged(Katabasis::Activity activity); + +private slots: +// OAuth-specific callbacks + void onOAuthLinkingSucceeded(); + void onOAuthLinkingFailed(); + void onOpenBrowser(const QUrl &url); + void onCloseBrowser(); + void onOAuthActivityChanged(Katabasis::Activity activity); + +// Yggdrasil specific callbacks + void onMojangSucceeded(); + void onMojangFailed(); + +protected: + void initMSA(); + void initMojang(); + + void doUserAuth(); + Q_SLOT void onUserAuthDone(int, QNetworkReply::NetworkError, QByteArray, QList<QNetworkReply::RawHeaderPair>); + + void doSTSAuthMinecraft(); + Q_SLOT void onSTSAuthMinecraftDone(int, QNetworkReply::NetworkError, QByteArray, QList<QNetworkReply::RawHeaderPair>); + void doMinecraftAuth(); + Q_SLOT void onMinecraftAuthDone(int, QNetworkReply::NetworkError, QByteArray, QList<QNetworkReply::RawHeaderPair>); + + void doSTSAuthGeneric(); + Q_SLOT void onSTSAuthGenericDone(int, QNetworkReply::NetworkError, QByteArray, QList<QNetworkReply::RawHeaderPair>); + void doXBoxProfile(); + Q_SLOT void onXBoxProfileDone(int, QNetworkReply::NetworkError, QByteArray, QList<QNetworkReply::RawHeaderPair>); + + void doMinecraftProfile(); + Q_SLOT void onMinecraftProfileDone(int, QNetworkReply::NetworkError, QByteArray, QList<QNetworkReply::RawHeaderPair>); + + void doGetSkin(); + Q_SLOT void onSkinDone(int, QNetworkReply::NetworkError, QByteArray, QList<QNetworkReply::RawHeaderPair>); + + void checkResult(); + +protected: + void beginActivity(Katabasis::Activity activity); + void finishActivity(); + void clearTokens(); + +protected: + Katabasis::OAuth2 *m_oauth2 = nullptr; + Yggdrasil *m_yggdrasil = nullptr; + + int m_requestsDone = 0; + bool m_xboxProfileSucceeded = false; + bool m_mcAuthSucceeded = false; + Katabasis::Activity m_activity = Katabasis::Activity::Idle; + enum class MSAStage { + Idle, + UserAuth, + XboxAuth, + MinecraftProfile, + Skin + } m_stage = MSAStage::Idle; + + QNetworkAccessManager *mgr = nullptr; +}; diff --git a/launcher/minecraft/auth/flows/AuthenticateTask.cpp b/launcher/minecraft/auth/flows/AuthenticateTask.cpp deleted file mode 100644 index 2e8dc859..00000000 --- a/launcher/minecraft/auth/flows/AuthenticateTask.cpp +++ /dev/null @@ -1,202 +0,0 @@ - -/* Copyright 2013-2021 MultiMC Contributors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#include "AuthenticateTask.h" -#include "../MojangAccount.h" - -#include <QJsonDocument> -#include <QJsonObject> -#include <QJsonArray> -#include <QVariant> - -#include <QDebug> -#include <QUuid> - -AuthenticateTask::AuthenticateTask(MojangAccount * account, const QString &password, - QObject *parent) - : YggdrasilTask(account, parent), m_password(password) -{ -} - -QJsonObject AuthenticateTask::getRequestContent() const -{ - /* - * { - * "agent": { // optional - * "name": "Minecraft", // So far this is the only encountered value - * "version": 1 // This number might be increased - * // by the vanilla client in the future - * }, - * "username": "mojang account name", // Can be an email address or player name for - // unmigrated accounts - * "password": "mojang account password", - * "clientToken": "client identifier" // optional - * "requestUser": true/false // request the user structure - * } - */ - QJsonObject req; - - { - QJsonObject agent; - // C++ makes string literals void* for some stupid reason, so we have to tell it - // QString... Thanks Obama. - agent.insert("name", QString("Minecraft")); - agent.insert("version", 1); - req.insert("agent", agent); - } - - req.insert("username", m_account->username()); - req.insert("password", m_password); - req.insert("requestUser", true); - - // If we already have a client token, give it to the server. - // Otherwise, let the server give us one. - - if(m_account->m_clientToken.isEmpty()) - { - auto uuid = QUuid::createUuid(); - auto uuidString = uuid.toString().remove('{').remove('-').remove('}'); - m_account->m_clientToken = uuidString; - } - req.insert("clientToken", m_account->m_clientToken); - - return req; -} - -void AuthenticateTask::processResponse(QJsonObject responseData) -{ - // Read the response data. We need to get the client token, access token, and the selected - // profile. - qDebug() << "Processing authentication response."; - // qDebug() << responseData; - // If we already have a client token, make sure the one the server gave us matches our - // existing one. - qDebug() << "Getting client token."; - QString clientToken = responseData.value("clientToken").toString(""); - if (clientToken.isEmpty()) - { - // Fail if the server gave us an empty client token - changeState(STATE_FAILED_HARD, tr("Authentication server didn't send a client token.")); - return; - } - if (!m_account->m_clientToken.isEmpty() && clientToken != m_account->m_clientToken) - { - changeState(STATE_FAILED_HARD, tr("Authentication server attempted to change the client token. This isn't supported.")); - return; - } - // Set the client token. - m_account->m_clientToken = clientToken; - - // Now, we set the access token. - qDebug() << "Getting access token."; - QString accessToken = responseData.value("accessToken").toString(""); - if (accessToken.isEmpty()) - { - // Fail if the server didn't give us an access token. - changeState(STATE_FAILED_HARD, tr("Authentication server didn't send an access token.")); - return; - } - // Set the access token. - m_account->m_accessToken = accessToken; - - // Now we load the list of available profiles. - // Mojang hasn't yet implemented the profile system, - // but we might as well support what's there so we - // don't have trouble implementing it later. - qDebug() << "Loading profile list."; - QJsonArray availableProfiles = responseData.value("availableProfiles").toArray(); - QList<AccountProfile> loadedProfiles; - for (auto iter : availableProfiles) - { - QJsonObject profile = iter.toObject(); - // Profiles are easy, we just need their ID and name. - QString id = profile.value("id").toString(""); - QString name = profile.value("name").toString(""); - bool legacy = profile.value("legacy").toBool(false); - - if (id.isEmpty() || name.isEmpty()) - { - // This should never happen, but we might as well - // warn about it if it does so we can debug it easily. - // You never know when Mojang might do something truly derpy. - qWarning() << "Found entry in available profiles list with missing ID or name " - "field. Ignoring it."; - } - - // Now, add a new AccountProfile entry to the list. - loadedProfiles.append({id, name, legacy}); - } - // Put the list of profiles we loaded into the MojangAccount object. - m_account->m_profiles = loadedProfiles; - - // Finally, we set the current profile to the correct value. This is pretty simple. - // We do need to make sure that the current profile that the server gave us - // is actually in the available profiles list. - // If it isn't, we'll just fail horribly (*shouldn't* ever happen, but you never know). - qDebug() << "Setting current profile."; - QJsonObject currentProfile = responseData.value("selectedProfile").toObject(); - QString currentProfileId = currentProfile.value("id").toString(""); - if (currentProfileId.isEmpty()) - { - changeState(STATE_FAILED_HARD, tr("Authentication server didn't specify a currently selected profile. The account exists, but likely isn't premium.")); - return; - } - if (!m_account->setCurrentProfile(currentProfileId)) - { - changeState(STATE_FAILED_HARD, tr("Authentication server specified a selected profile that wasn't in the available profiles list.")); - return; - } - - // this is what the vanilla launcher passes to the userProperties launch param - if (responseData.contains("user")) - { - User u; - auto obj = responseData.value("user").toObject(); - u.id = obj.value("id").toString(); - auto propArray = obj.value("properties").toArray(); - for (auto prop : propArray) - { - auto propTuple = prop.toObject(); - auto name = propTuple.value("name").toString(); - auto value = propTuple.value("value").toString(); - u.properties.insert(name, value); - } - m_account->m_user = u; - } - - // We've made it through the minefield of possible errors. Return true to indicate that - // we've succeeded. - qDebug() << "Finished reading authentication response."; - changeState(STATE_SUCCEEDED); -} - -QString AuthenticateTask::getEndpoint() const -{ - return "authenticate"; -} - -QString AuthenticateTask::getStateMessage() const -{ - switch (m_state) - { - case STATE_SENDING_REQUEST: - return tr("Authenticating: Sending request..."); - case STATE_PROCESSING_RESPONSE: - return tr("Authenticating: Processing response..."); - default: - return YggdrasilTask::getStateMessage(); - } -} diff --git a/launcher/minecraft/auth/flows/AuthenticateTask.h b/launcher/minecraft/auth/flows/AuthenticateTask.h deleted file mode 100644 index 4c14eec7..00000000 --- a/launcher/minecraft/auth/flows/AuthenticateTask.h +++ /dev/null @@ -1,46 +0,0 @@ -/* Copyright 2013-2021 MultiMC Contributors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#pragma once - -#include "../YggdrasilTask.h" - -#include <QObject> -#include <QString> -#include <QJsonObject> - -/** - * The authenticate task takes a MojangAccount with no access token and password and attempts to - * authenticate with Mojang's servers. - * If successful, it will set the MojangAccount's access token. - */ -class AuthenticateTask : public YggdrasilTask -{ - Q_OBJECT -public: - AuthenticateTask(MojangAccount *account, const QString &password, QObject *parent = 0); - -protected: - virtual QJsonObject getRequestContent() const override; - - virtual QString getEndpoint() const override; - - virtual void processResponse(QJsonObject responseData) override; - - virtual QString getStateMessage() const override; - -private: - QString m_password; -}; diff --git a/launcher/minecraft/auth/flows/MSAHelper.txt b/launcher/minecraft/auth/flows/MSAHelper.txt new file mode 100644 index 00000000..dfaec374 --- /dev/null +++ b/launcher/minecraft/auth/flows/MSAHelper.txt @@ -0,0 +1,51 @@ +class Helper : public QObject { + Q_OBJECT + +public: + Helper(MSAFlows * context) : QObject(), context_(context), msg_(QString()) { + QFile tokenCache("usercache.dat"); + if(tokenCache.open(QIODevice::ReadOnly)) { + context_->resumeFromState(tokenCache.readAll()); + } + } + +public slots: + void run() { + connect(context_, &MSAFlows::activityChanged, this, &Helper::onActivityChanged); + context_->silentSignIn(); + } + + void onFailed() { + qDebug() << "Login failed"; + } + + void onActivityChanged(Katabasis::Activity activity) { + if(activity == Katabasis::Activity::Idle) { + switch(context_->validity()) { + case Katabasis::Validity::None: { + // account is gone, remove it. + QFile::remove("usercache.dat"); + } + break; + case Katabasis::Validity::Assumed: { + // this is basically a soft-failed refresh. do nothing. + } + break; + case Katabasis::Validity::Certain: { + // stuff got refreshed / signed in. Save. + auto data = context_->saveState(); + QSaveFile tokenCache("usercache.dat"); + if(tokenCache.open(QIODevice::WriteOnly)) { + tokenCache.write(context_->saveState()); + tokenCache.commit(); + } + } + break; + } + } + } + +private: + MSAFlows *context_; + QString msg_; +}; diff --git a/launcher/minecraft/auth/flows/MSAInteractive.cpp b/launcher/minecraft/auth/flows/MSAInteractive.cpp new file mode 100644 index 00000000..03beb279 --- /dev/null +++ b/launcher/minecraft/auth/flows/MSAInteractive.cpp @@ -0,0 +1,20 @@ +#include "MSAInteractive.h" + +MSAInteractive::MSAInteractive(AccountData* data, QObject* parent) : AuthContext(data, parent) {} + +void MSAInteractive::executeTask() { + m_requestsDone = 0; + m_xboxProfileSucceeded = false; + m_mcAuthSucceeded = false; + + initMSA(); + + QVariantMap extraOpts; + extraOpts["prompt"] = "select_account"; + m_oauth2->setExtraRequestParams(extraOpts); + + beginActivity(Katabasis::Activity::LoggingIn); + m_oauth2->unlink(); + *m_data = AccountData(); + m_oauth2->link(); +} diff --git a/launcher/minecraft/auth/flows/MSAInteractive.h b/launcher/minecraft/auth/flows/MSAInteractive.h new file mode 100644 index 00000000..9556f254 --- /dev/null +++ b/launcher/minecraft/auth/flows/MSAInteractive.h @@ -0,0 +1,10 @@ +#pragma once +#include "AuthContext.h" + +class MSAInteractive : public AuthContext +{ + Q_OBJECT +public: + explicit MSAInteractive(AccountData * data, QObject *parent = 0); + void executeTask() override; +}; diff --git a/launcher/minecraft/auth/flows/MSASilent.cpp b/launcher/minecraft/auth/flows/MSASilent.cpp new file mode 100644 index 00000000..8ce43c1f --- /dev/null +++ b/launcher/minecraft/auth/flows/MSASilent.cpp @@ -0,0 +1,16 @@ +#include "MSASilent.h" + +MSASilent::MSASilent(AccountData* data, QObject* parent) : AuthContext(data, parent) {} + +void MSASilent::executeTask() { + m_requestsDone = 0; + m_xboxProfileSucceeded = false; + m_mcAuthSucceeded = false; + + initMSA(); + + beginActivity(Katabasis::Activity::Refreshing); + if(!m_oauth2->refresh()) { + finishActivity(); + } +} diff --git a/launcher/minecraft/auth/flows/MSASilent.h b/launcher/minecraft/auth/flows/MSASilent.h new file mode 100644 index 00000000..e1b3d43d --- /dev/null +++ b/launcher/minecraft/auth/flows/MSASilent.h @@ -0,0 +1,10 @@ +#pragma once +#include "AuthContext.h" + +class MSASilent : public AuthContext +{ + Q_OBJECT +public: + explicit MSASilent(AccountData * data, QObject *parent = 0); + void executeTask() override; +}; diff --git a/launcher/minecraft/auth/flows/MojangLogin.cpp b/launcher/minecraft/auth/flows/MojangLogin.cpp new file mode 100644 index 00000000..cca911b5 --- /dev/null +++ b/launcher/minecraft/auth/flows/MojangLogin.cpp @@ -0,0 +1,14 @@ +#include "MojangLogin.h" + +MojangLogin::MojangLogin(AccountData* data, QString password, QObject* parent) : AuthContext(data, parent), m_password(password) {} + +void MojangLogin::executeTask() { + m_requestsDone = 0; + m_xboxProfileSucceeded = false; + m_mcAuthSucceeded = false; + + initMojang(); + + beginActivity(Katabasis::Activity::LoggingIn); + m_yggdrasil->login(m_password); +} diff --git a/launcher/minecraft/auth/flows/MojangLogin.h b/launcher/minecraft/auth/flows/MojangLogin.h new file mode 100644 index 00000000..2e765ae8 --- /dev/null +++ b/launcher/minecraft/auth/flows/MojangLogin.h @@ -0,0 +1,13 @@ +#pragma once +#include "AuthContext.h" + +class MojangLogin : public AuthContext +{ + Q_OBJECT +public: + explicit MojangLogin(AccountData * data, QString password, QObject *parent = 0); + void executeTask() override; + +private: + QString m_password; +}; diff --git a/launcher/minecraft/auth/flows/MojangRefresh.cpp b/launcher/minecraft/auth/flows/MojangRefresh.cpp new file mode 100644 index 00000000..af99175c --- /dev/null +++ b/launcher/minecraft/auth/flows/MojangRefresh.cpp @@ -0,0 +1,14 @@ +#include "MojangRefresh.h" + +MojangRefresh::MojangRefresh(AccountData* data, QObject* parent) : AuthContext(data, parent) {} + +void MojangRefresh::executeTask() { + m_requestsDone = 0; + m_xboxProfileSucceeded = false; + m_mcAuthSucceeded = false; + + initMojang(); + + beginActivity(Katabasis::Activity::Refreshing); + m_yggdrasil->refresh(); +} diff --git a/launcher/minecraft/auth/flows/MojangRefresh.h b/launcher/minecraft/auth/flows/MojangRefresh.h new file mode 100644 index 00000000..fb4facd5 --- /dev/null +++ b/launcher/minecraft/auth/flows/MojangRefresh.h @@ -0,0 +1,10 @@ +#pragma once +#include "AuthContext.h" + +class MojangRefresh : public AuthContext +{ + Q_OBJECT +public: + explicit MojangRefresh(AccountData * data, QObject *parent = 0); + void executeTask() override; +}; diff --git a/launcher/minecraft/auth/flows/RefreshTask.cpp b/launcher/minecraft/auth/flows/RefreshTask.cpp deleted file mode 100644 index ecba178d..00000000 --- a/launcher/minecraft/auth/flows/RefreshTask.cpp +++ /dev/null @@ -1,144 +0,0 @@ -/* Copyright 2013-2021 MultiMC Contributors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#include "RefreshTask.h" -#include "../MojangAccount.h" - -#include <QJsonDocument> -#include <QJsonObject> -#include <QJsonArray> -#include <QVariant> - -#include <QDebug> - -RefreshTask::RefreshTask(MojangAccount *account) : YggdrasilTask(account) -{ -} - -QJsonObject RefreshTask::getRequestContent() const -{ - /* - * { - * "clientToken": "client identifier" - * "accessToken": "current access token to be refreshed" - * "selectedProfile": // specifying this causes errors - * { - * "id": "profile ID" - * "name": "profile name" - * } - * "requestUser": true/false // request the user structure - * } - */ - QJsonObject req; - req.insert("clientToken", m_account->m_clientToken); - req.insert("accessToken", m_account->m_accessToken); - /* - { - auto currentProfile = m_account->currentProfile(); - QJsonObject profile; - profile.insert("id", currentProfile->id()); - profile.insert("name", currentProfile->name()); - req.insert("selectedProfile", profile); - } - */ - req.insert("requestUser", true); - - return req; -} - -void RefreshTask::processResponse(QJsonObject responseData) -{ - // Read the response data. We need to get the client token, access token, and the selected - // profile. - qDebug() << "Processing authentication response."; - - // qDebug() << responseData; - // If we already have a client token, make sure the one the server gave us matches our - // existing one. - QString clientToken = responseData.value("clientToken").toString(""); - if (clientToken.isEmpty()) - { - // Fail if the server gave us an empty client token - changeState(STATE_FAILED_HARD, tr("Authentication server didn't send a client token.")); - return; - } - if (!m_account->m_clientToken.isEmpty() && clientToken != m_account->m_clientToken) - { - changeState(STATE_FAILED_HARD, tr("Authentication server attempted to change the client token. This isn't supported.")); - return; - } - - // Now, we set the access token. - qDebug() << "Getting new access token."; - QString accessToken = responseData.value("accessToken").toString(""); - if (accessToken.isEmpty()) - { - // Fail if the server didn't give us an access token. - changeState(STATE_FAILED_HARD, tr("Authentication server didn't send an access token.")); - return; - } - - // we validate that the server responded right. (our current profile = returned current - // profile) - QJsonObject currentProfile = responseData.value("selectedProfile").toObject(); - QString currentProfileId = currentProfile.value("id").toString(""); - if (m_account->currentProfile()->id != currentProfileId) - { - changeState(STATE_FAILED_HARD, tr("Authentication server didn't specify the same prefile as expected.")); - return; - } - - // this is what the vanilla launcher passes to the userProperties launch param - if (responseData.contains("user")) - { - User u; - auto obj = responseData.value("user").toObject(); - u.id = obj.value("id").toString(); - auto propArray = obj.value("properties").toArray(); - for (auto prop : propArray) - { - auto propTuple = prop.toObject(); - auto name = propTuple.value("name").toString(); - auto value = propTuple.value("value").toString(); - u.properties.insert(name, value); - } - m_account->m_user = u; - } - - // We've made it through the minefield of possible errors. Return true to indicate that - // we've succeeded. - qDebug() << "Finished reading refresh response."; - // Reset the access token. - m_account->m_accessToken = accessToken; - changeState(STATE_SUCCEEDED); -} - -QString RefreshTask::getEndpoint() const -{ - return "refresh"; -} - -QString RefreshTask::getStateMessage() const -{ - switch (m_state) - { - case STATE_SENDING_REQUEST: - return tr("Refreshing login token..."); - case STATE_PROCESSING_RESPONSE: - return tr("Refreshing login token: Processing response..."); - default: - return YggdrasilTask::getStateMessage(); - } -} diff --git a/launcher/minecraft/auth/flows/RefreshTask.h b/launcher/minecraft/auth/flows/RefreshTask.h deleted file mode 100644 index f0840dda..00000000 --- a/launcher/minecraft/auth/flows/RefreshTask.h +++ /dev/null @@ -1,44 +0,0 @@ -/* Copyright 2013-2021 MultiMC Contributors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#pragma once - -#include "../YggdrasilTask.h" - -#include <QObject> -#include <QString> -#include <QJsonObject> - -/** - * The authenticate task takes a MojangAccount with a possibly timed-out access token - * and attempts to authenticate with Mojang's servers. - * If successful, it will set the new access token. The token is considered validated. - */ -class RefreshTask : public YggdrasilTask -{ - Q_OBJECT -public: - RefreshTask(MojangAccount * account); - -protected: - virtual QJsonObject getRequestContent() const override; - - virtual QString getEndpoint() const override; - - virtual void processResponse(QJsonObject responseData) override; - - virtual QString getStateMessage() const override; -}; - diff --git a/launcher/minecraft/auth/flows/ValidateTask.cpp b/launcher/minecraft/auth/flows/ValidateTask.cpp deleted file mode 100644 index 6b3f0a65..00000000 --- a/launcher/minecraft/auth/flows/ValidateTask.cpp +++ /dev/null @@ -1,61 +0,0 @@ - -/* Copyright 2013-2021 MultiMC Contributors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#include "ValidateTask.h" -#include "../MojangAccount.h" - -#include <QJsonDocument> -#include <QJsonObject> -#include <QJsonArray> -#include <QVariant> - -#include <QDebug> - -ValidateTask::ValidateTask(MojangAccount * account, QObject *parent) - : YggdrasilTask(account, parent) -{ -} - -QJsonObject ValidateTask::getRequestContent() const -{ - QJsonObject req; - req.insert("accessToken", m_account->m_accessToken); - return req; -} - -void ValidateTask::processResponse(QJsonObject responseData) -{ - // Assume that if processError wasn't called, then the request was successful. - changeState(YggdrasilTask::STATE_SUCCEEDED); -} - -QString ValidateTask::getEndpoint() const -{ - return "validate"; -} - -QString ValidateTask::getStateMessage() const -{ - switch (m_state) - { - case YggdrasilTask::STATE_SENDING_REQUEST: - return tr("Validating access token: Sending request..."); - case YggdrasilTask::STATE_PROCESSING_RESPONSE: - return tr("Validating access token: Processing response..."); - default: - return YggdrasilTask::getStateMessage(); - } -} diff --git a/launcher/minecraft/auth/flows/ValidateTask.h b/launcher/minecraft/auth/flows/ValidateTask.h deleted file mode 100644 index 986c2e9f..00000000 --- a/launcher/minecraft/auth/flows/ValidateTask.h +++ /dev/null @@ -1,47 +0,0 @@ -/* Copyright 2013-2021 MultiMC Contributors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/* - * :FIXME: DEAD CODE, DEAD CODE, DEAD CODE! :FIXME: - */ - -#pragma once - -#include "../YggdrasilTask.h" - -#include <QObject> -#include <QString> -#include <QJsonObject> - -/** - * The validate task takes a MojangAccount and checks to make sure its access token is valid. - */ -class ValidateTask : public YggdrasilTask -{ - Q_OBJECT -public: - ValidateTask(MojangAccount *account, QObject *parent = 0); - -protected: - virtual QJsonObject getRequestContent() const override; - - virtual QString getEndpoint() const override; - - virtual void processResponse(QJsonObject responseData) override; - - virtual QString getStateMessage() const override; - -private: -}; diff --git a/launcher/minecraft/auth/flows/Yggdrasil.cpp b/launcher/minecraft/auth/flows/Yggdrasil.cpp new file mode 100644 index 00000000..7cea059c --- /dev/null +++ b/launcher/minecraft/auth/flows/Yggdrasil.cpp @@ -0,0 +1,337 @@ +/* Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "Yggdrasil.h" +#include "../AccountData.h" + +#include <QObject> +#include <QString> +#include <QJsonObject> +#include <QJsonDocument> +#include <QNetworkReply> +#include <QByteArray> + +#include <Env.h> + +#include <BuildConfig.h> + +#include <QDebug> + +Yggdrasil::Yggdrasil(AccountData *data, QObject *parent) + : AccountTask(data, parent) +{ + changeState(STATE_CREATED); +} + +void Yggdrasil::sendRequest(QUrl endpoint, QByteArray content) { + changeState(STATE_WORKING); + + QNetworkRequest netRequest(endpoint); + netRequest.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + m_netReply = ENV.qnam().post(netRequest, content); + connect(m_netReply, &QNetworkReply::finished, this, &Yggdrasil::processReply); + connect(m_netReply, &QNetworkReply::uploadProgress, this, &Yggdrasil::refreshTimers); + connect(m_netReply, &QNetworkReply::downloadProgress, this, &Yggdrasil::refreshTimers); + connect(m_netReply, &QNetworkReply::sslErrors, this, &Yggdrasil::sslErrors); + timeout_keeper.setSingleShot(true); + timeout_keeper.start(timeout_max); + counter.setSingleShot(false); + counter.start(time_step); + progress(0, timeout_max); + connect(&timeout_keeper, &QTimer::timeout, this, &Yggdrasil::abortByTimeout); + connect(&counter, &QTimer::timeout, this, &Yggdrasil::heartbeat); +} + +void Yggdrasil::executeTask() { +} + +void Yggdrasil::refresh() { + start(); + /* + * { + * "clientToken": "client identifier" + * "accessToken": "current access token to be refreshed" + * "selectedProfile": // specifying this causes errors + * { + * "id": "profile ID" + * "name": "profile name" + * } + * "requestUser": true/false // request the user structure + * } + */ + QJsonObject req; + req.insert("clientToken", m_data->clientToken()); + req.insert("accessToken", m_data->accessToken()); + /* + { + auto currentProfile = m_account->currentProfile(); + QJsonObject profile; + profile.insert("id", currentProfile->id()); + profile.insert("name", currentProfile->name()); + req.insert("selectedProfile", profile); + } + */ + req.insert("requestUser", false); + QJsonDocument doc(req); + + QUrl reqUrl(BuildConfig.AUTH_BASE + "refresh"); + QByteArray requestData = doc.toJson(); + + sendRequest(reqUrl, requestData); +} + +void Yggdrasil::login(QString password) { + start(); + /* + * { + * "agent": { // optional + * "name": "Minecraft", // So far this is the only encountered value + * "version": 1 // This number might be increased + * // by the vanilla client in the future + * }, + * "username": "mojang account name", // Can be an email address or player name for + * // unmigrated accounts + * "password": "mojang account password", + * "clientToken": "client identifier", // optional + * "requestUser": true/false // request the user structure + * } + */ + QJsonObject req; + + { + QJsonObject agent; + // C++ makes string literals void* for some stupid reason, so we have to tell it + // QString... Thanks Obama. + agent.insert("name", QString("Minecraft")); + agent.insert("version", 1); + req.insert("agent", agent); + } + + req.insert("username", m_data->userName()); + req.insert("password", password); + req.insert("requestUser", false); + + // If we already have a client token, give it to the server. + // Otherwise, let the server give us one. + + m_data->generateClientTokenIfMissing(); + req.insert("clientToken", m_data->clientToken()); + + QJsonDocument doc(req); + + QUrl reqUrl(BuildConfig.AUTH_BASE + "authenticate"); + QNetworkRequest netRequest(reqUrl); + QByteArray requestData = doc.toJson(); + + sendRequest(reqUrl, requestData); +} + + + +void Yggdrasil::refreshTimers(qint64, qint64) +{ + timeout_keeper.stop(); + timeout_keeper.start(timeout_max); + progress(count = 0, timeout_max); +} +void Yggdrasil::heartbeat() +{ + count += time_step; + progress(count, timeout_max); +} + +bool Yggdrasil::abort() +{ + progress(timeout_max, timeout_max); + // TODO: actually use this in a meaningful way + m_aborted = Yggdrasil::BY_USER; + m_netReply->abort(); + return true; +} + +void Yggdrasil::abortByTimeout() +{ + progress(timeout_max, timeout_max); + // TODO: actually use this in a meaningful way + m_aborted = Yggdrasil::BY_TIMEOUT; + m_netReply->abort(); +} + +void Yggdrasil::sslErrors(QList<QSslError> errors) +{ + int i = 1; + for (auto error : errors) + { + qCritical() << "LOGIN SSL Error #" << i << " : " << error.errorString(); + auto cert = error.certificate(); + qCritical() << "Certificate in question:\n" << cert.toText(); + i++; + } +} + +void Yggdrasil::processResponse(QJsonObject responseData) +{ + // Read the response data. We need to get the client token, access token, and the selected + // profile. + qDebug() << "Processing authentication response."; + + // qDebug() << responseData; + // If we already have a client token, make sure the one the server gave us matches our + // existing one. + QString clientToken = responseData.value("clientToken").toString(""); + if (clientToken.isEmpty()) + { + // Fail if the server gave us an empty client token + changeState(STATE_FAILED_HARD, tr("Authentication server didn't send a client token.")); + return; + } + if(m_data->clientToken().isEmpty()) { + m_data->setClientToken(clientToken); + } + else if(clientToken != m_data->clientToken()) { + changeState(STATE_FAILED_HARD, tr("Authentication server attempted to change the client token. This isn't supported.")); + return; + } + + // Now, we set the access token. + qDebug() << "Getting access token."; + QString accessToken = responseData.value("accessToken").toString(""); + if (accessToken.isEmpty()) + { + // Fail if the server didn't give us an access token. + changeState(STATE_FAILED_HARD, tr("Authentication server didn't send an access token.")); + return; + } + // Set the access token. + m_data->yggdrasilToken.token = accessToken; + m_data->yggdrasilToken.validity = Katabasis::Validity::Certain; + + // We've made it through the minefield of possible errors. Return true to indicate that + // we've succeeded. + qDebug() << "Finished reading authentication response."; + changeState(STATE_SUCCEEDED); +} + +void Yggdrasil::processReply() +{ + changeState(STATE_WORKING); + + switch (m_netReply->error()) + { + case QNetworkReply::NoError: + break; + case QNetworkReply::TimeoutError: + changeState(STATE_FAILED_SOFT, tr("Authentication operation timed out.")); + return; + case QNetworkReply::OperationCanceledError: + changeState(STATE_FAILED_SOFT, tr("Authentication operation cancelled.")); + return; + case QNetworkReply::SslHandshakeFailedError: + changeState( + STATE_FAILED_SOFT, + tr("<b>SSL Handshake failed.</b><br/>There might be a few causes for it:<br/>" + "<ul>" + "<li>You use Windows XP and need to <a " + "href=\"https://www.microsoft.com/en-us/download/details.aspx?id=38918\">update " + "your root certificates</a></li>" + "<li>Some device on your network is interfering with SSL traffic. In that case, " + "you have bigger worries than Minecraft not starting.</li>" + "<li>Possibly something else. Check the MultiMC log file for details</li>" + "</ul>")); + return; + // used for invalid credentials and similar errors. Fall through. + case QNetworkReply::ContentAccessDenied: + case QNetworkReply::ContentOperationNotPermittedError: + break; + default: + changeState(STATE_FAILED_SOFT, + tr("Authentication operation failed due to a network error: %1 (%2)") + .arg(m_netReply->errorString()).arg(m_netReply->error())); + return; + } + + // Try to parse the response regardless of the response code. + // Sometimes the auth server will give more information and an error code. + QJsonParseError jsonError; + QByteArray replyData = m_netReply->readAll(); + QJsonDocument doc = QJsonDocument::fromJson(replyData, &jsonError); + // Check the response code. + int responseCode = m_netReply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + + if (responseCode == 200) + { + // If the response code was 200, then there shouldn't be an error. Make sure + // anyways. + // Also, sometimes an empty reply indicates success. If there was no data received, + // pass an empty json object to the processResponse function. + if (jsonError.error == QJsonParseError::NoError || replyData.size() == 0) + { + processResponse(replyData.size() > 0 ? doc.object() : QJsonObject()); + return; + } + else + { + changeState(STATE_FAILED_SOFT, tr("Failed to parse authentication server response " + "JSON response: %1 at offset %2.") + .arg(jsonError.errorString()) + .arg(jsonError.offset)); + qCritical() << replyData; + } + return; + } + + // If the response code was not 200, then Yggdrasil may have given us information + // about the error. + // If we can parse the response, then get information from it. Otherwise just say + // there was an unknown error. + if (jsonError.error == QJsonParseError::NoError) + { + // We were able to parse the server's response. Woo! + // Call processError. If a subclass has overridden it then they'll handle their + // stuff there. + qDebug() << "The request failed, but the server gave us an error message. " + "Processing error."; + processError(doc.object()); + } + else + { + // The server didn't say anything regarding the error. Give the user an unknown + // error. + qDebug() + << "The request failed and the server gave no error message. Unknown error."; + changeState(STATE_FAILED_SOFT, + tr("An unknown error occurred when trying to communicate with the " + "authentication server: %1").arg(m_netReply->errorString())); + } +} + +void Yggdrasil::processError(QJsonObject responseData) +{ + QJsonValue errorVal = responseData.value("error"); + QJsonValue errorMessageValue = responseData.value("errorMessage"); + QJsonValue causeVal = responseData.value("cause"); + + if (errorVal.isString() && errorMessageValue.isString()) + { + m_error = std::shared_ptr<Error>(new Error{ + errorVal.toString(""), errorMessageValue.toString(""), causeVal.toString("")}); + changeState(STATE_FAILED_HARD, m_error->m_errorMessageVerbose); + } + else + { + // Error is not in standard format. Don't set m_error and return unknown error. + changeState(STATE_FAILED_HARD, tr("An unknown Yggdrasil error occurred.")); + } +} diff --git a/launcher/minecraft/auth/flows/Yggdrasil.h b/launcher/minecraft/auth/flows/Yggdrasil.h new file mode 100644 index 00000000..e709cb9f --- /dev/null +++ b/launcher/minecraft/auth/flows/Yggdrasil.h @@ -0,0 +1,82 @@ +/* Copyright 2013-2021 MultiMC Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include "../AccountTask.h" + +#include <QString> +#include <QJsonObject> +#include <QTimer> +#include <qsslerror.h> + +#include "../MinecraftAccount.h" + +class QNetworkReply; + +/** + * A Yggdrasil task is a task that performs an operation on a given mojang account. + */ +class Yggdrasil : public AccountTask +{ + Q_OBJECT +public: + explicit Yggdrasil(AccountData * data, QObject *parent = 0); + virtual ~Yggdrasil() {}; + + void refresh(); + void login(QString password); +protected: + void executeTask() override; + + /** + * Processes the response received from the server. + * If an error occurred, this should emit a failed signal. + * If Yggdrasil gave an error response, it should call setError() first, and then return false. + * Otherwise, it should return true. + * Note: If the response from the server was blank, and the HTTP code was 200, this function is called with + * an empty QJsonObject. + */ + void processResponse(QJsonObject responseData); + + /** + * Processes an error response received from the server. + * The default implementation will read data from Yggdrasil's standard error response format and set it as this task's Error. + * \returns a QString error message that will be passed to emitFailed. + */ + virtual void processError(QJsonObject responseData); + +protected slots: + void processReply(); + void refreshTimers(qint64, qint64); + void heartbeat(); + void sslErrors(QList<QSslError>); + void abortByTimeout(); + +public slots: + virtual bool abort() override; + +private: + void sendRequest(QUrl endpoint, QByteArray content); + +protected: + QNetworkReply *m_netReply = nullptr; + QTimer timeout_keeper; + QTimer counter; + int count = 0; // num msec since time reset + + const int timeout_max = 30000; + const int time_step = 50; +}; |