diff options
Diffstat (limited to 'api/logic')
-rw-r--r-- | api/logic/minecraft/auth-msa/BuildConfig.cpp.in | 9 | ||||
-rw-r--r-- | api/logic/minecraft/auth-msa/BuildConfig.h | 11 | ||||
-rw-r--r-- | api/logic/minecraft/auth-msa/CMakeLists.txt | 28 | ||||
-rw-r--r-- | api/logic/minecraft/auth-msa/context.cpp | 938 | ||||
-rw-r--r-- | api/logic/minecraft/auth-msa/context.h | 128 | ||||
-rw-r--r-- | api/logic/minecraft/auth-msa/main.cpp | 100 | ||||
-rw-r--r-- | api/logic/minecraft/auth-msa/mainwindow.cpp | 97 | ||||
-rw-r--r-- | api/logic/minecraft/auth-msa/mainwindow.h | 34 | ||||
-rw-r--r-- | api/logic/minecraft/auth-msa/mainwindow.ui | 72 |
9 files changed, 1417 insertions, 0 deletions
diff --git a/api/logic/minecraft/auth-msa/BuildConfig.cpp.in b/api/logic/minecraft/auth-msa/BuildConfig.cpp.in new file mode 100644 index 00000000..8f470e25 --- /dev/null +++ b/api/logic/minecraft/auth-msa/BuildConfig.cpp.in @@ -0,0 +1,9 @@ +#include "BuildConfig.h" +#include <QObject> + +const Config BuildConfig; + +Config::Config() +{ + CLIENT_ID = "@MOJANGDEMO_CLIENT_ID@"; +} diff --git a/api/logic/minecraft/auth-msa/BuildConfig.h b/api/logic/minecraft/auth-msa/BuildConfig.h new file mode 100644 index 00000000..7a01d704 --- /dev/null +++ b/api/logic/minecraft/auth-msa/BuildConfig.h @@ -0,0 +1,11 @@ +#pragma once +#include <QString> + +class Config +{ +public: + Config(); + QString CLIENT_ID; +}; + +extern const Config BuildConfig; diff --git a/api/logic/minecraft/auth-msa/CMakeLists.txt b/api/logic/minecraft/auth-msa/CMakeLists.txt new file mode 100644 index 00000000..22777d1b --- /dev/null +++ b/api/logic/minecraft/auth-msa/CMakeLists.txt @@ -0,0 +1,28 @@ +find_package(Qt5 COMPONENTS Core Gui Network Widgets REQUIRED) + +set(CMAKE_AUTOMOC ON) +set(CMAKE_AUTOUIC ON) +set(CMAKE_INCLUDE_CURRENT_DIR ON) +set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Wall") + + +set(MOJANGDEMO_CLIENT_ID "" CACHE STRING "Client ID used for OAuth2 in mojangdemo") + +configure_file("${CMAKE_CURRENT_SOURCE_DIR}/BuildConfig.cpp.in" "${CMAKE_CURRENT_BINARY_DIR}/BuildConfig.cpp") + +set(mojang_SRCS + main.cpp + context.cpp + context.h + + mainwindow.cpp + mainwindow.h + mainwindow.ui + + ${CMAKE_CURRENT_BINARY_DIR}/BuildConfig.cpp + BuildConfig.h +) + +add_executable( mojangdemo ${mojang_SRCS} ) +target_link_libraries( mojangdemo Katabasis Qt5::Gui Qt5::Widgets ) +target_include_directories(mojangdemo PRIVATE logic) diff --git a/api/logic/minecraft/auth-msa/context.cpp b/api/logic/minecraft/auth-msa/context.cpp new file mode 100644 index 00000000..d7ecda30 --- /dev/null +++ b/api/logic/minecraft/auth-msa/context.cpp @@ -0,0 +1,938 @@ +#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 "context.h" +#include "katabasis/Globals.h" +#include "katabasis/StoreQSettings.h" +#include "katabasis/Requestor.h" +#include "BuildConfig.h" + +using OAuth2 = Katabasis::OAuth2; +using Requestor = Katabasis::Requestor; +using Activity = Katabasis::Activity; + +Context::Context(QObject *parent) : + QObject(parent) +{ + mgr = new QNetworkAccessManager(this); + + Katabasis::OAuth2::Options opts; + opts.scope = "XboxLive.signin offline_access"; + opts.clientIdentifier = BuildConfig.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}; + + oauth2 = new OAuth2(opts, m_account.msaToken, this, mgr); + + connect(oauth2, &OAuth2::linkingFailed, this, &Context::onLinkingFailed); + connect(oauth2, &OAuth2::linkingSucceeded, this, &Context::onLinkingSucceeded); + connect(oauth2, &OAuth2::openBrowser, this, &Context::onOpenBrowser); + connect(oauth2, &OAuth2::closeBrowser, this, &Context::onCloseBrowser); + connect(oauth2, &OAuth2::activityChanged, this, &Context::onOAuthActivityChanged); +} + +void Context::beginActivity(Activity activity) { + if(isBusy()) { + throw 0; + } + activity_ = activity; + emit activityChanged(activity_); +} + +void Context::finishActivity() { + if(!isBusy()) { + throw 0; + } + activity_ = Katabasis::Activity::Idle; + m_account.validity_ = m_account.minecraftProfile.validity; + emit activityChanged(activity_); +} + +QString Context::gameToken() { + return m_account.minecraftToken.token; +} + +QString Context::userId() { + return m_account.minecraftProfile.id; +} + +QString Context::userName() { + return m_account.minecraftProfile.name; +} + +bool Context::silentSignIn() { + if(isBusy()) { + return false; + } + beginActivity(Activity::Refreshing); + if(!oauth2->refresh()) { + finishActivity(); + return false; + } + + requestsDone = 0; + xboxProfileSucceeded = false; + mcAuthSucceeded = false; + + return true; +} + +bool Context::signIn() { + if(isBusy()) { + return false; + } + + requestsDone = 0; + xboxProfileSucceeded = false; + mcAuthSucceeded = false; + + beginActivity(Activity::LoggingIn); + oauth2->unlink(); + m_account = AccountData(); + oauth2->link(); + return true; +} + +bool Context::signOut() { + if(isBusy()) { + return false; + } + beginActivity(Activity::LoggingOut); + oauth2->unlink(); + m_account = AccountData(); + finishActivity(); + return true; +} + + +void Context::onOpenBrowser(const QUrl &url) { + QDesktopServices::openUrl(url); +} + +void Context::onCloseBrowser() { + +} + +void Context::onLinkingFailed() { + finishActivity(); +} + +void Context::onLinkingSucceeded() { + auto *o2t = qobject_cast<OAuth2 *>(sender()); + if (!o2t->linked()) { + finishActivity(); + 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 Context::onOAuthActivityChanged(Katabasis::Activity activity) { + // respond to activity change here +} + +void Context::doUserAuth() { + 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_account.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, oauth2, this); + requestor->setAddAccessTokenInQuery(false); + + connect(requestor, &Requestor::finished, this, &Context::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::ISODateWithMs); + 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 Context::onUserAuthDone( + int requestId, + QNetworkReply::NetworkError error, + QByteArray replyData, + QList<QNetworkReply::RawHeaderPair> headers +) { + if (error != QNetworkReply::NoError) { + qWarning() << "Reply error:" << error; + finishActivity(); + return; + } + + Katabasis::Token temp; + if(!parseXTokenResponse(replyData, temp)) { + qWarning() << "Could not parse user authentication response..."; + finishActivity(); + return; + } + m_account.userToken = temp; + + 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 Context::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_account.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, oauth2, this); + requestor->setAddAccessTokenInQuery(false); + + connect(requestor, &Requestor::finished, this, &Context::onSTSAuthMinecraftDone); + requestor->post(request, xbox_auth_data.toUtf8()); + qDebug() << "Second layer of XBox auth ... commencing."; +} + +void Context::onSTSAuthMinecraftDone( + int requestId, + QNetworkReply::NetworkError error, + QByteArray replyData, + QList<QNetworkReply::RawHeaderPair> headers +) { + if (error != QNetworkReply::NoError) { + qWarning() << "Reply error:" << error; + finishActivity(); + return; + } + + Katabasis::Token temp; + if(!parseXTokenResponse(replyData, temp)) { + qWarning() << "Could not parse authorization response for access to mojang services..."; + finishActivity(); + return; + } + + if(temp.extra["uhs"] != m_account.userToken.extra["uhs"]) { + qWarning() << "Server has changed user hash in the reply... something is wrong. ABORTING"; + qDebug() << replyData; + finishActivity(); + return; + } + m_account.mojangservicesToken = temp; + + doMinecraftAuth(); +} + +void Context::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_account.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, oauth2, this); + requestor->setAddAccessTokenInQuery(false); + + connect(requestor, &Requestor::finished, this, &Context::onSTSAuthGenericDone); + requestor->post(request, xbox_auth_data.toUtf8()); + qDebug() << "Second layer of XBox auth ... commencing."; +} + +void Context::onSTSAuthGenericDone( + int requestId, + QNetworkReply::NetworkError error, + QByteArray replyData, + QList<QNetworkReply::RawHeaderPair> headers +) { + if (error != QNetworkReply::NoError) { + qWarning() << "Reply error:" << error; + finishActivity(); + return; + } + + Katabasis::Token temp; + if(!parseXTokenResponse(replyData, temp)) { + qWarning() << "Could not parse authorization response for access to xbox API..."; + finishActivity(); + return; + } + + if(temp.extra["uhs"] != m_account.userToken.extra["uhs"]) { + qWarning() << "Server has changed user hash in the reply... something is wrong. ABORTING"; + qDebug() << replyData; + finishActivity(); + return; + } + m_account.xboxApiToken = temp; + + doXBoxProfile(); +} + + +void Context::doMinecraftAuth() { + QString mc_auth_template = R"XXX( +{ + "identityToken": "XBL3.0 x=%1;%2" +} +)XXX"; + auto data = mc_auth_template.arg(m_account.mojangservicesToken.extra["uhs"].toString(), m_account.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, oauth2, this); + requestor->setAddAccessTokenInQuery(false); + + connect(requestor, &Requestor::finished, this, &Context::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 Context::onMinecraftAuthDone( + int requestId, + QNetworkReply::NetworkError error, + QByteArray replyData, + QList<QNetworkReply::RawHeaderPair> headers +) { + requestsDone++; + + if (error != QNetworkReply::NoError) { + qWarning() << "Reply error:" << error; + qDebug() << replyData; + finishActivity(); + return; + } + + if(!parseMojangResponse(replyData, m_account.minecraftToken)) { + qWarning() << "Could not parse login_with_xbox response..."; + qDebug() << replyData; + finishActivity(); + return; + } + mcAuthSucceeded = true; + + checkResult(); +} + +void Context::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_account.userToken.extra["uhs"].toString(), m_account.xboxApiToken.token).toUtf8()); + Requestor *requestor = new Requestor(mgr, oauth2, this); + requestor->setAddAccessTokenInQuery(false); + + connect(requestor, &Requestor::finished, this, &Context::onXBoxProfileDone); + requestor->get(request); + qDebug() << "Getting Xbox profile..."; +} + +void Context::onXBoxProfileDone( + int requestId, + QNetworkReply::NetworkError error, + QByteArray replyData, + QList<QNetworkReply::RawHeaderPair> headers +) { + requestsDone ++; + + if (error != QNetworkReply::NoError) { + qWarning() << "Reply error:" << error; + qDebug() << replyData; + finishActivity(); + return; + } + + qDebug() << "XBox profile: " << replyData; + + xboxProfileSucceeded = true; + checkResult(); +} + +void Context::checkResult() { + if(requestsDone != 2) { + return; + } + if(mcAuthSucceeded && xboxProfileSucceeded) { + doMinecraftProfile(); + } + else { + finishActivity(); + } +} + +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 Context::doMinecraftProfile() { + 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_account.minecraftToken.token).toUtf8()); + + Requestor *requestor = new Requestor(mgr, oauth2, this); + requestor->setAddAccessTokenInQuery(false); + + connect(requestor, &Requestor::finished, this, &Context::onMinecraftProfileDone); + requestor->get(request); +} + +void Context::onMinecraftProfileDone(int, QNetworkReply::NetworkError error, QByteArray data, QList<QNetworkReply::RawHeaderPair> headers) { + qDebug() << data; + if (error == QNetworkReply::ContentNotFoundError) { + m_account.minecraftProfile = MinecraftProfile(); + finishActivity(); + return; + } + if (error != QNetworkReply::NoError) { + finishActivity(); + return; + } + if(!parseMinecraftProfile(data, m_account.minecraftProfile)) { + m_account.minecraftProfile = MinecraftProfile(); + finishActivity(); + return; + } + doGetSkin(); +} + +void Context::doGetSkin() { + auto url = QUrl(m_account.minecraftProfile.skin.url); + QNetworkRequest request = QNetworkRequest(url); + Requestor *requestor = new Requestor(mgr, oauth2, this); + requestor->setAddAccessTokenInQuery(false); + connect(requestor, &Requestor::finished, this, &Context::onSkinDone); + requestor->get(request); +} + +void Context::onSkinDone(int, QNetworkReply::NetworkError error, QByteArray data, QList<QNetworkReply::RawHeaderPair>) { + if (error == QNetworkReply::NoError) { + m_account.minecraftProfile.skin.data = data; + } + finishActivity(); +} + +namespace { +void tokenToJSON(QJsonObject &parent, Katabasis::Token t, const char * tokenName) { + if(t.validity == Katabasis::Validity::None || !t.persistent) { + return; + } + QJsonObject out; + if(t.issueInstant.isValid()) { + out["iat"] = QJsonValue(t.issueInstant.toSecsSinceEpoch()); + } + + if(t.notAfter.isValid()) { + out["exp"] = QJsonValue(t.notAfter.toSecsSinceEpoch()); + } + + if(!t.token.isEmpty()) { + out["token"] = QJsonValue(t.token); + } + if(!t.refresh_token.isEmpty()) { + out["refresh_token"] = QJsonValue(t.refresh_token); + } + if(t.extra.size()) { + out["extra"] = QJsonObject::fromVariantMap(t.extra); + } + if(out.size()) { + parent[tokenName] = out; + } +} + +Katabasis::Token tokenFromJSON(const QJsonObject &parent, const char * tokenName) { + Katabasis::Token out; + auto tokenObject = parent.value(tokenName).toObject(); + if(tokenObject.isEmpty()) { + return out; + } + auto issueInstant = tokenObject.value("iat"); + if(issueInstant.isDouble()) { + out.issueInstant = QDateTime::fromSecsSinceEpoch((int64_t) issueInstant.toDouble()); + } + + auto notAfter = tokenObject.value("exp"); + if(notAfter.isDouble()) { + out.notAfter = QDateTime::fromSecsSinceEpoch((int64_t) notAfter.toDouble()); + } + + auto token = tokenObject.value("token"); + if(token.isString()) { + out.token = token.toString(); + out.validity = Katabasis::Validity::Assumed; + } + + auto refresh_token = tokenObject.value("refresh_token"); + if(refresh_token.isString()) { + out.refresh_token = refresh_token.toString(); + } + + auto extra = tokenObject.value("extra"); + if(extra.isObject()) { + out.extra = extra.toObject().toVariantMap(); + } + return out; +} + +void profileToJSON(QJsonObject &parent, MinecraftProfile p, const char * tokenName) { + if(p.id.isEmpty()) { + return; + } + QJsonObject out; + out["id"] = QJsonValue(p.id); + out["name"] = QJsonValue(p.name); + if(p.currentCape != -1) { + out["cape"] = p.capes[p.currentCape].id; + } + + { + QJsonObject skinObj; + skinObj["id"] = p.skin.id; + skinObj["url"] = p.skin.url; + skinObj["variant"] = p.skin.variant; + if(p.skin.data.size()) { + skinObj["data"] = QString::fromLatin1(p.skin.data.toBase64()); + } + out["skin"] = skinObj; + } + + QJsonArray capesArray; + for(auto & cape: p.capes) { + QJsonObject capeObj; + capeObj["id"] = cape.id; + capeObj["url"] = cape.url; + capeObj["alias"] = cape.alias; + if(cape.data.size()) { + capeObj["data"] = QString::fromLatin1(cape.data.toBase64()); + } + capesArray.push_back(capeObj); + } + out["capes"] = capesArray; + parent[tokenName] = out; +} + +MinecraftProfile profileFromJSON(const QJsonObject &parent, const char * tokenName) { + MinecraftProfile out; + auto tokenObject = parent.value(tokenName).toObject(); + if(tokenObject.isEmpty()) { + return out; + } + { + auto idV = tokenObject.value("id"); + auto nameV = tokenObject.value("name"); + if(!idV.isString() || !nameV.isString()) { + qWarning() << "mandatory profile attributes are missing or of unexpected type"; + return MinecraftProfile(); + } + out.name = nameV.toString(); + out.id = idV.toString(); + } + + { + auto skinV = tokenObject.value("skin"); + if(!skinV.isObject()) { + qWarning() << "skin is missing"; + return MinecraftProfile(); + } + auto skinObj = skinV.toObject(); + auto idV = skinObj.value("id"); + auto urlV = skinObj.value("url"); + auto variantV = skinObj.value("variant"); + if(!idV.isString() || !urlV.isString() || !variantV.isString()) { + qWarning() << "mandatory skin attributes are missing or of unexpected type"; + return MinecraftProfile(); + } + out.skin.id = idV.toString(); + out.skin.url = urlV.toString(); + out.skin.variant = variantV.toString(); + + // data for skin is optional + auto dataV = skinObj.value("data"); + if(dataV.isString()) { + // TODO: validate base64 + out.skin.data = QByteArray::fromBase64(dataV.toString().toLatin1()); + } + else if (!dataV.isUndefined()) { + qWarning() << "skin data is something unexpected"; + return MinecraftProfile(); + } + } + + auto capesV = tokenObject.value("capes"); + if(!capesV.isArray()) { + qWarning() << "capes is not an array!"; + return MinecraftProfile(); + } + auto capesArray = capesV.toArray(); + for(auto capeV: capesArray) { + if(!capeV.isObject()) { + qWarning() << "cape is not an object!"; + return MinecraftProfile(); + } + auto capeObj = capeV.toObject(); + auto idV = capeObj.value("id"); + auto urlV = capeObj.value("url"); + auto aliasV = capeObj.value("alias"); + if(!idV.isString() || !urlV.isString() || !aliasV.isString()) { + qWarning() << "mandatory skin attributes are missing or of unexpected type"; + return MinecraftProfile(); + } + Cape cape; + cape.id = idV.toString(); + cape.url = urlV.toString(); + cape.alias = aliasV.toString(); + + // data for cape is optional. + auto dataV = capeObj.value("data"); + if(dataV.isString()) { + // TODO: validate base64 + cape.data = QByteArray::fromBase64(dataV.toString().toLatin1()); + } + else if (!dataV.isUndefined()) { + qWarning() << "cape data is something unexpected"; + return MinecraftProfile(); + } + out.capes.push_back(cape); + } + out.validity = Katabasis::Validity::Assumed; + return out; +} + +} + +bool Context::resumeFromState(QByteArray data) { + QJsonParseError error; + auto doc = QJsonDocument::fromJson(data, &error); + if(error.error != QJsonParseError::NoError) { + qWarning() << "Failed to parse account data as JSON."; + return false; + } + auto docObject = doc.object(); + m_account.msaToken = tokenFromJSON(docObject, "msa"); + m_account.userToken = tokenFromJSON(docObject, "utoken"); + m_account.xboxApiToken = tokenFromJSON(docObject, "xrp-main"); + m_account.mojangservicesToken = tokenFromJSON(docObject, "xrp-mc"); + m_account.minecraftToken = tokenFromJSON(docObject, "ygg"); + + m_account.minecraftProfile = profileFromJSON(docObject, "profile"); + + m_account.validity_ = m_account.minecraftProfile.validity; + + return true; +} + +QByteArray Context::saveState() { + QJsonDocument doc; + QJsonObject output; + tokenToJSON(output, m_account.msaToken, "msa"); + tokenToJSON(output, m_account.userToken, "utoken"); + tokenToJSON(output, m_account.xboxApiToken, "xrp-main"); + tokenToJSON(output, m_account.mojangservicesToken, "xrp-mc"); + tokenToJSON(output, m_account.minecraftToken, "ygg"); + profileToJSON(output, m_account.minecraftProfile, "profile"); + doc.setObject(output); + return doc.toJson(QJsonDocument::Indented); +} diff --git a/api/logic/minecraft/auth-msa/context.h b/api/logic/minecraft/auth-msa/context.h new file mode 100644 index 00000000..f1ac99b8 --- /dev/null +++ b/api/logic/minecraft/auth-msa/context.h @@ -0,0 +1,128 @@ +#pragma once + +#include <QObject> +#include <QList> +#include <QVector> +#include <QNetworkReply> +#include <QImage> + +#include <katabasis/OAuth2.h> + +struct Skin { + QString id; + QString url; + QString variant; + + QByteArray data; +}; + +struct Cape { + QString id; + QString url; + QString alias; + + QByteArray data; +}; + +struct MinecraftProfile { + QString id; + QString name; + Skin skin; + int currentCape = -1; + QVector<Cape> capes; + Katabasis::Validity validity = Katabasis::Validity::None; +}; + +enum class AccountType { + MSA, + Mojang +}; + +struct AccountData { + AccountType type = AccountType::MSA; + + Katabasis::Token msaToken; + Katabasis::Token userToken; + Katabasis::Token xboxApiToken; + Katabasis::Token mojangservicesToken; + Katabasis::Token minecraftToken; + + MinecraftProfile minecraftProfile; + Katabasis::Validity validity_ = Katabasis::Validity::None; +}; + +class Context : public QObject +{ + Q_OBJECT + +public: + explicit Context(QObject *parent = 0); + + QByteArray saveState(); + bool resumeFromState(QByteArray data); + + bool isBusy() { + return activity_ != Katabasis::Activity::Idle; + }; + Katabasis::Validity validity() { + return m_account.validity_; + }; + + bool signIn(); + bool silentSignIn(); + bool signOut(); + + QString userName(); + QString userId(); + QString gameToken(); +signals: + void succeeded(); + void failed(); + void activityChanged(Katabasis::Activity activity); + +private slots: + void onLinkingSucceeded(); + void onLinkingFailed(); + void onOpenBrowser(const QUrl &url); + void onCloseBrowser(); + void onOAuthActivityChanged(Katabasis::Activity activity); + +private: + 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(); + +private: + void beginActivity(Katabasis::Activity activity); + void finishActivity(); + void clearTokens(); + +private: + Katabasis::OAuth2 *oauth2 = nullptr; + + int requestsDone = 0; + bool xboxProfileSucceeded = false; + bool mcAuthSucceeded = false; + Katabasis::Activity activity_ = Katabasis::Activity::Idle; + + AccountData m_account; + + QNetworkAccessManager *mgr = nullptr; +}; diff --git a/api/logic/minecraft/auth-msa/main.cpp b/api/logic/minecraft/auth-msa/main.cpp new file mode 100644 index 00000000..481e0126 --- /dev/null +++ b/api/logic/minecraft/auth-msa/main.cpp @@ -0,0 +1,100 @@ +#include <QApplication> +#include <QStringList> +#include <QTimer> +#include <QDebug> +#include <QFile> +#include <QSaveFile> + +#include "context.h" +#include "mainwindow.h" + +void myMessageOutput(QtMsgType type, const QMessageLogContext &context, const QString &msg) +{ + QByteArray localMsg = msg.toLocal8Bit(); + const char *file = context.file ? context.file : ""; + const char *function = context.function ? context.function : ""; + switch (type) { + case QtDebugMsg: + fprintf(stderr, "Debug: %s (%s:%u, %s)\n", localMsg.constData(), file, context.line, function); + break; + case QtInfoMsg: + fprintf(stderr, "Info: %s (%s:%u, %s)\n", localMsg.constData(), file, context.line, function); + break; + case QtWarningMsg: + fprintf(stderr, "Warning: %s (%s:%u, %s)\n", localMsg.constData(), file, context.line, function); + break; + case QtCriticalMsg: + fprintf(stderr, "Critical: %s (%s:%u, %s)\n", localMsg.constData(), file, context.line, function); + break; + case QtFatalMsg: + fprintf(stderr, "Fatal: %s (%s:%u, %s)\n", localMsg.constData(), file, context.line, function); + break; + } +} + +class Helper : public QObject { + Q_OBJECT + +public: + Helper(Context * 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_, &Context::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: + Context *context_; + QString msg_; +}; + +int main(int argc, char *argv[]) { + qInstallMessageHandler(myMessageOutput); + QApplication a(argc, argv); + QCoreApplication::setOrganizationName("MultiMC"); + QCoreApplication::setApplicationName("MultiMC"); + Context c; + Helper helper(&c); + MainWindow window(&c); + window.show(); + QTimer::singleShot(0, &helper, &Helper::run); + return a.exec(); +} + +#include "main.moc" diff --git a/api/logic/minecraft/auth-msa/mainwindow.cpp b/api/logic/minecraft/auth-msa/mainwindow.cpp new file mode 100644 index 00000000..d4e18dc0 --- /dev/null +++ b/api/logic/minecraft/auth-msa/mainwindow.cpp @@ -0,0 +1,97 @@ +#include "mainwindow.h" +#include "ui_mainwindow.h" +#include <QDebug> + +#include <QDesktopServices> + +#include "BuildConfig.h" + +MainWindow::MainWindow(Context * context, QWidget *parent) : + QMainWindow(parent), + m_context(context), + m_ui(new Ui::MainWindow) +{ + m_ui->setupUi(this); + connect(m_ui->signInButton_MSA, &QPushButton::clicked, this, &MainWindow::SignInMSAClicked); + connect(m_ui->signInButton_Mojang, &QPushButton::clicked, this, &MainWindow::SignInMojangClicked); + connect(m_ui->signOutButton, &QPushButton::clicked, this, &MainWindow::SignOutClicked); + connect(m_ui->refreshButton, &QPushButton::clicked, this, &MainWindow::RefreshClicked); + + // connect(m_context, &Context::linkingSucceeded, this, &MainWindow::SignInSucceeded); + // connect(m_context, &Context::linkingFailed, this, &MainWindow::SignInFailed); + connect(m_context, &Context::activityChanged, this, &MainWindow::ActivityChanged); + ActivityChanged(Katabasis::Activity::Idle); +} + +MainWindow::~MainWindow() = default; + +void MainWindow::ActivityChanged(Katabasis::Activity activity) { + switch(activity) { + case Katabasis::Activity::Idle: { + if(m_context->validity() != Katabasis::Validity::None) { + m_ui->signInButton_Mojang->setEnabled(false); + m_ui->signInButton_MSA->setEnabled(false); + m_ui->signOutButton->setEnabled(true); + m_ui->refreshButton->setEnabled(true); + m_ui->statusBar->showMessage(QString("Hello %1!").arg(m_context->userName())); + } + else { + m_ui->signInButton_Mojang->setEnabled(true); + m_ui->signInButton_MSA->setEnabled(true); + m_ui->signOutButton->setEnabled(false); + m_ui->refreshButton->setEnabled(false); + m_ui->statusBar->showMessage("Press the login button to start."); + } + } + break; + case Katabasis::Activity::LoggingIn: { + m_ui->signInButton_Mojang->setEnabled(false); + m_ui->signInButton_MSA->setEnabled(false); + m_ui->signOutButton->setEnabled(false); + m_ui->refreshButton->setEnabled(false); + m_ui->statusBar->showMessage("Logging in..."); + } + break; + case Katabasis::Activity::LoggingOut: { + m_ui->signInButton_Mojang->setEnabled(false); + m_ui->signInButton_MSA->setEnabled(false); + m_ui->signOutButton->setEnabled(false); + m_ui->refreshButton->setEnabled(false); + m_ui->statusBar->showMessage("Logging out..."); + } + break; + case Katabasis::Activity::Refreshing: { + m_ui->signInButton_Mojang->setEnabled(false); + m_ui->signInButton_MSA->setEnabled(false); + m_ui->signOutButton->setEnabled(false); + m_ui->refreshButton->setEnabled(false); + m_ui->statusBar->showMessage("Refreshing login..."); + } + break; + } +} + +void MainWindow::SignInMSAClicked() { + qDebug() << "Sign In MSA"; + // signIn({{"prompt", "select_account"}}) + // FIXME: wrong. very wrong. this should not be operating on the current context + m_context->signIn(); +} + +void MainWindow::SignInMojangClicked() { + qDebug() << "Sign In Mojang"; + // signIn({{"prompt", "select_account"}}) + // FIXME: wrong. very wrong. this should not be operating on the current context + m_context->signIn(); +} + + +void MainWindow::SignOutClicked() { + qDebug() << "Sign Out"; + m_context->signOut(); +} + +void MainWindow::RefreshClicked() { + qDebug() << "Refresh"; + m_context->silentSignIn(); +} diff --git a/api/logic/minecraft/auth-msa/mainwindow.h b/api/logic/minecraft/auth-msa/mainwindow.h new file mode 100644 index 00000000..abde52d8 --- /dev/null +++ b/api/logic/minecraft/auth-msa/mainwindow.h @@ -0,0 +1,34 @@ +#pragma once + +#include <QMainWindow> +#include <QScopedPointer> +#include <QtNetwork> +#include <katabasis/Bits.h> + +#include "context.h" + +namespace Ui { +class MainWindow; +} + +class MainWindow : public QMainWindow { + Q_OBJECT + +public: + explicit MainWindow(Context * context, QWidget *parent = nullptr); + ~MainWindow() override; + +private slots: + void SignInMojangClicked(); + void SignInMSAClicked(); + + void SignOutClicked(); + void RefreshClicked(); + + void ActivityChanged(Katabasis::Activity activity); + +private: + Context* m_context; + QScopedPointer<Ui::MainWindow> m_ui; +}; + diff --git a/api/logic/minecraft/auth-msa/mainwindow.ui b/api/logic/minecraft/auth-msa/mainwindow.ui new file mode 100644 index 00000000..32b34128 --- /dev/null +++ b/api/logic/minecraft/auth-msa/mainwindow.ui @@ -0,0 +1,72 @@ +<?xml version="1.0" encoding="UTF-8"?> +<ui version="4.0"> + <class>MainWindow</class> + <widget class="QMainWindow" name="MainWindow"> + <property name="geometry"> + <rect> + <x>0</x> + <y>0</y> + <width>1037</width> + <height>511</height> + </rect> + </property> + <property name="windowTitle"> + <string>SmartMapsClient</string> + </property> + <property name="dockNestingEnabled"> + <bool>true</bool> + </property> + <widget class="QWidget" name="centralWidget"> + <layout class="QGridLayout" name="gridLayout"> + <item row="1" column="3"> + <widget class="QPushButton" name="signInButton_Mojang"> + <property name="text"> + <string>SignIn Mojang</string> + </property> + </widget> + </item> + <item row="3" column="3"> + <widget class="Line" name="line"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + </widget> + </item> + <item row="1" column="0" rowspan="7" colspan="3"> + <widget class="QTreeView" name="accountView"/> + </item> + <item row="5" column="3"> + <widget class="QPushButton" name="refreshButton"> + <property name="text"> + <string>Refresh</string> + </property> + </widget> + </item> + <item row="2" column="3"> + <widget class="QPushButton" name="signInButton_MSA"> + <property name="text"> + <string>SignIn MSA</string> + </property> + </widget> + </item> + <item row="6" column="3"> + <widget class="QPushButton" name="signOutButton"> + <property name="text"> + <string>SignOut</string> + </property> + </widget> + </item> + <item row="4" column="3"> + <widget class="QPushButton" name="makeActiveButton"> + <property name="text"> + <string>Make Active</string> + </property> + </widget> + </item> + </layout> + </widget> + <widget class="QStatusBar" name="statusBar"/> + </widget> + <resources/> + <connections/> +</ui> |