From 475d949a1e384d5de16bea5c90fee115460b4b8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Mr=C3=A1zek?= Date: Wed, 10 Nov 2021 03:02:51 +0100 Subject: [PATCH] GH-4217 Add support for GamePass accounts and MC profile setup - We now use the new endpoint for loggiong in via XBox tokens (/launcher/login) - We now check game entitlements instead of only relying on MC profile presence - Accounts can now be added even when they do not have a profile - The launcher will guide you through selecting a Minecraft name if you don't have one yet --- launcher/CMakeLists.txt | 6 + launcher/LaunchController.cpp | 37 +- launcher/MainWindow.cpp | 42 ++- launcher/dialogs/ProfileSetupDialog.cpp | 248 +++++++++++++ launcher/dialogs/ProfileSetupDialog.h | 88 +++++ launcher/dialogs/ProfileSetupDialog.ui | 74 ++++ launcher/minecraft/auth/AccountData.cpp | 45 ++- launcher/minecraft/auth/AccountData.h | 7 + launcher/minecraft/auth/AccountList.cpp | 38 +- launcher/minecraft/auth/AccountList.h | 2 +- launcher/minecraft/auth/AuthSession.h | 1 + launcher/minecraft/auth/MinecraftAccount.cpp | 17 +- launcher/minecraft/auth/flows/AuthContext.cpp | 343 +++--------------- launcher/minecraft/auth/flows/AuthContext.h | 6 + launcher/minecraft/auth/flows/Parsers.cpp | 315 ++++++++++++++++ launcher/minecraft/auth/flows/Parsers.h | 19 + launcher/pages/global/AccountListPage.cpp | 8 +- 17 files changed, 956 insertions(+), 340 deletions(-) create mode 100644 launcher/dialogs/ProfileSetupDialog.cpp create mode 100644 launcher/dialogs/ProfileSetupDialog.h create mode 100644 launcher/dialogs/ProfileSetupDialog.ui create mode 100644 launcher/minecraft/auth/flows/Parsers.cpp create mode 100644 launcher/minecraft/auth/flows/Parsers.h diff --git a/launcher/CMakeLists.txt b/launcher/CMakeLists.txt index eba8f8a1..0aeadd51 100644 --- a/launcher/CMakeLists.txt +++ b/launcher/CMakeLists.txt @@ -228,6 +228,9 @@ set(MINECRAFT_SOURCES minecraft/auth/flows/Yggdrasil.h minecraft/auth/flows/Yggdrasil.cpp + minecraft/auth/flows/Parsers.h + minecraft/auth/flows/Parsers.cpp + minecraft/gameoptions/GameOptions.h minecraft/gameoptions/GameOptions.cpp @@ -732,6 +735,8 @@ SET(LAUNCHER_SOURCES dialogs/AboutDialog.h dialogs/ProfileSelectDialog.cpp dialogs/ProfileSelectDialog.h + dialogs/ProfileSetupDialog.cpp + dialogs/ProfileSetupDialog.h dialogs/CopyInstanceDialog.cpp dialogs/CopyInstanceDialog.h dialogs/CustomMessageBox.cpp @@ -859,6 +864,7 @@ SET(LAUNCHER_UIS dialogs/ProgressDialog.ui dialogs/IconPickerDialog.ui dialogs/ProfileSelectDialog.ui + dialogs/ProfileSetupDialog.ui dialogs/EditAccountDialog.ui dialogs/ExportInstanceDialog.ui dialogs/LoginDialog.ui diff --git a/launcher/LaunchController.cpp b/launcher/LaunchController.cpp index 1c1e41e6..9edccaf2 100644 --- a/launcher/LaunchController.cpp +++ b/launcher/LaunchController.cpp @@ -18,6 +18,7 @@ #include #include #include +#include "dialogs/ProfileSetupDialog.h" LaunchController::LaunchController(QObject *parent) : Task(parent) { @@ -79,7 +80,7 @@ void LaunchController::decideAccount() // If the user said to use the account as default, do that. if (selectDialog.useAsGlobalDefault() && m_accountToUse) { - accounts->setActiveAccount(m_accountToUse->profileId()); + accounts->setActiveAccount(m_accountToUse); } } } @@ -179,10 +180,40 @@ void LaunchController::login() { } break; } + case AuthSession::RequiresProfileSetup: { + auto entitlement = m_accountToUse->accountData()->minecraftEntitlement; + QString errorString; + if(!entitlement.canPlayMinecraft) { + errorString = tr("The account does not own Minecraft. You need to purchase the game first to play it."); + QMessageBox::warning( + nullptr, + tr("Missing Minecraft profile"), + errorString, + QMessageBox::StandardButton::Ok, + QMessageBox::StandardButton::Ok + ); + tryagain = false; + emitFailed(errorString); + return; + } + // Now handle setting up a profile name here... + ProfileSetupDialog dialog(m_accountToUse, m_parentWidget); + if (dialog.exec() == QDialog::Accepted) + { + tryagain = true; + continue; + } + else + { + tryagain = false; + emitFailed(tr("Received undetermined session status during login.")); + return; + } + } case AuthSession::RequiresOAuth: { auto errorString = tr("Microsoft account has expired and needs to be logged into manually again."); QMessageBox::warning( - nullptr, + m_parentWidget, tr("Microsoft Account refresh failed"), errorString, QMessageBox::StandardButton::Ok, @@ -195,7 +226,7 @@ void LaunchController::login() { case AuthSession::GoneOrMigrated: { auto errorString = tr("The account no longer exists on the servers. It may have been migrated, in which case please add the new account you migrated this one to."); QMessageBox::warning( - nullptr, + m_parentWidget, tr("Account gone"), errorString, QMessageBox::StandardButton::Ok, diff --git a/launcher/MainWindow.cpp b/launcher/MainWindow.cpp index 21502894..7df3e717 100644 --- a/launcher/MainWindow.cpp +++ b/launcher/MainWindow.cpp @@ -1030,7 +1030,6 @@ void MainWindow::repopulateAccountsMenu() QString active_profileId = ""; if (active_account != nullptr) { - active_profileId = active_account->profileId(); // this can be called before accountMenuButton exists if (accountMenuButton) { @@ -1053,14 +1052,20 @@ void MainWindow::repopulateAccountsMenu() MinecraftAccountPtr account = accounts->at(i); auto profileLabel = profileInUseFilter(account->profileName(), account->isInUse()); QAction *action = new QAction(profileLabel, this); - action->setData(account->profileId()); + action->setData(i); action->setCheckable(true); - if (active_profileId == account->profileId()) + if (active_account == account) { action->setChecked(true); } - action->setIcon(account->getFace()); + auto face = account->getFace(); + if(!face.isNull()) { + action->setIcon(face); + } + else { + action->setIcon(LAUNCHER->getThemedIcon("noaccount")); + } accountMenu->addAction(action); connect(action, SIGNAL(triggered(bool)), SLOT(changeActiveAccount())); } @@ -1071,8 +1076,8 @@ void MainWindow::repopulateAccountsMenu() QAction *action = new QAction(tr("No Default Account"), this); action->setCheckable(true); action->setIcon(LAUNCHER->getThemedIcon("noaccount")); - action->setData(""); - if (active_profileId.isEmpty()) { + action->setData(-1); + if (active_account == nullptr) { action->setChecked(true); } @@ -1098,20 +1103,19 @@ void MainWindow::updatesAllowedChanged(bool allowed) void MainWindow::changeActiveAccount() { QAction *sAction = (QAction *)sender(); + // Profile's associated Mojang username - // Will need to change when profiles are properly implemented - if (sAction->data().type() != QVariant::Type::String) + if (sAction->data().type() != QVariant::Type::Int) return; QVariant data = sAction->data(); - QString id = ""; - if (!data.isNull()) - { - id = data.toString(); + bool valid = false; + int index = data.toInt(&valid); + if(!valid) { + index = -1; } - - LAUNCHER->accounts()->setActiveAccount(id); - + std::shared_ptr accounts = LAUNCHER->accounts(); + accounts->setActiveAccount(index == -1 ? nullptr : accounts->at(index)); activeAccountChanged(); } @@ -1126,7 +1130,13 @@ void MainWindow::activeAccountChanged() { auto profileLabel = profileInUseFilter(account->profileName(), account->isInUse()); accountMenuButton->setText(profileLabel); - accountMenuButton->setIcon(account->getFace()); + auto face = account->getFace(); + if(face.isNull()) { + accountMenuButton->setIcon(LAUNCHER->getThemedIcon("noaccount")); + } + else { + accountMenuButton->setIcon(face); + } return; } diff --git a/launcher/dialogs/ProfileSetupDialog.cpp b/launcher/dialogs/ProfileSetupDialog.cpp new file mode 100644 index 00000000..e3d7aec2 --- /dev/null +++ b/launcher/dialogs/ProfileSetupDialog.cpp @@ -0,0 +1,248 @@ +/* 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 "ProfileSetupDialog.h" +#include "ui_ProfileSetupDialog.h" + +#include +#include +#include +#include + +#include + +#include +#include +#include + +ProfileSetupDialog::ProfileSetupDialog(MinecraftAccountPtr accountToSetup, QWidget *parent) + : QDialog(parent), m_accountToSetup(accountToSetup), ui(new Ui::ProfileSetupDialog) +{ + ui->setupUi(this); + ui->errorLabel->setVisible(false); + + goodIcon = LAUNCHER->getThemedIcon("status-good"); + yellowIcon = LAUNCHER->getThemedIcon("status-yellow"); + badIcon = LAUNCHER->getThemedIcon("status-bad"); + + QRegExp permittedNames("[a-zA-Z0-9_]{3,16}"); + auto nameEdit = ui->nameEdit; + nameEdit->setValidator(new QRegExpValidator(permittedNames)); + nameEdit->setClearButtonEnabled(true); + validityAction = nameEdit->addAction(yellowIcon, QLineEdit::LeadingPosition); + connect(nameEdit, &QLineEdit::textEdited, this, &ProfileSetupDialog::nameEdited); + + checkStartTimer.setSingleShot(true); + connect(&checkStartTimer, &QTimer::timeout, this, &ProfileSetupDialog::startCheck); + + setNameStatus(NameStatus::NotSet, QString()); +} + +ProfileSetupDialog::~ProfileSetupDialog() +{ + delete ui; +} + +void ProfileSetupDialog::on_buttonBox_accepted() +{ + setupProfile(currentCheck); +} + +void ProfileSetupDialog::on_buttonBox_rejected() +{ + reject(); +} + +void ProfileSetupDialog::setNameStatus(ProfileSetupDialog::NameStatus status, QString errorString = QString()) +{ + nameStatus = status; + auto okButton = ui->buttonBox->button(QDialogButtonBox::Ok); + switch(nameStatus) + { + case NameStatus::Available: { + validityAction->setIcon(goodIcon); + okButton->setEnabled(true); + } + break; + case NameStatus::NotSet: + case NameStatus::Pending: + validityAction->setIcon(yellowIcon); + okButton->setEnabled(false); + break; + case NameStatus::Exists: + case NameStatus::Error: + validityAction->setIcon(badIcon); + okButton->setEnabled(false); + break; + } + if(!errorString.isEmpty()) { + ui->errorLabel->setText(errorString); + ui->errorLabel->setVisible(true); + } + else { + ui->errorLabel->setVisible(false); + } +} + +void ProfileSetupDialog::nameEdited(const QString& name) +{ + if(!ui->nameEdit->hasAcceptableInput()) { + setNameStatus(NameStatus::NotSet, tr("Name is too short - must be between 3 and 16 characters long.")); + return; + } + scheduleCheck(name); +} + +void ProfileSetupDialog::scheduleCheck(const QString& name) { + queuedCheck = name; + setNameStatus(NameStatus::Pending); + checkStartTimer.start(1000); +} + +void ProfileSetupDialog::startCheck() { + if(isChecking) { + return; + } + if(queuedCheck.isNull()) { + return; + } + checkName(queuedCheck); +} + + +void ProfileSetupDialog::checkName(const QString &name) { + if(isChecking) { + return; + } + + currentCheck = name; + isChecking = true; + + auto token = m_accountToSetup->accessToken(); + + auto url = QString("https://api.minecraftservices.com/minecraft/profile/name/%1/available").arg(name); + QNetworkRequest request = QNetworkRequest(url); + request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + request.setRawHeader("Accept", "application/json"); + request.setRawHeader("Authorization", QString("Bearer %1").arg(token).toUtf8()); + + AuthRequest *requestor = new AuthRequest(this); + connect(requestor, &AuthRequest::finished, this, &ProfileSetupDialog::checkFinished); + requestor->get(request); +} + +void ProfileSetupDialog::checkFinished( + QNetworkReply::NetworkError error, + QByteArray data, + QList headers +) { + if(error == QNetworkReply::NoError) { + auto doc = QJsonDocument::fromJson(data); + auto root = doc.object(); + auto statusValue = root.value("status").toString("INVALID"); + if(statusValue == "AVAILABLE") { + setNameStatus(NameStatus::Available); + } + else if (statusValue == "DUPLICATE") { + setNameStatus(NameStatus::Exists, tr("Minecraft profile with name %1 already exists.").arg(currentCheck)); + } + else if (statusValue == "NOT_ALLOWED") { + setNameStatus(NameStatus::Exists, tr("The name %1 is not allowed.").arg(currentCheck)); + } + else { + setNameStatus(NameStatus::Error, tr("Unhandled profile name status: %1").arg(statusValue)); + } + } + else { + setNameStatus(NameStatus::Error, tr("Failed to check name availability.")); + } + isChecking = false; +} + +void ProfileSetupDialog::setupProfile(const QString &profileName) { + if(isWorking) { + return; + } + + auto token = m_accountToSetup->accessToken(); + + auto url = QString("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(token).toUtf8()); + + QString payloadTemplate("{\"profileName\":\"%1\"}"); + auto data = payloadTemplate.arg(profileName).toUtf8(); + + AuthRequest *requestor = new AuthRequest(this); + connect(requestor, &AuthRequest::finished, this, &ProfileSetupDialog::setupProfileFinished); + requestor->post(request, data); + isWorking = true; + + auto button = ui->buttonBox->button(QDialogButtonBox::Cancel); + button->setEnabled(false); +} + +namespace { + +struct MojangError{ + static MojangError fromJSON(QByteArray data) { + MojangError out; + out.error = QString::fromUtf8(data); + auto doc = QJsonDocument::fromJson(data, &out.parseError); + auto object = doc.object(); + + out.fullyParsed = true; + out.fullyParsed &= Parsers::getString(object.value("path"), out.path); + out.fullyParsed &= Parsers::getString(object.value("error"), out.error); + out.fullyParsed &= Parsers::getString(object.value("errorMessage"), out.errorMessage); + + return out; + } + + QString rawError; + QJsonParseError parseError; + bool fullyParsed; + + QString path; + QString error; + QString errorMessage; +}; + +} + +void ProfileSetupDialog::setupProfileFinished( + QNetworkReply::NetworkError error, + QByteArray data, + QList headers +) { + isWorking = false; + if(error == QNetworkReply::NoError) { + /* + * data contains the profile in the response + * ... we could parse it and update the account, but let's just return back to the normal login flow instead... + */ + accept(); + } + else { + auto parsedError = MojangError::fromJSON(data); + ui->errorLabel->setVisible(true); + ui->errorLabel->setText(tr("The server returned the following error:") + "\n\n" + parsedError.errorMessage); + qDebug() << parsedError.rawError; + auto button = ui->buttonBox->button(QDialogButtonBox::Cancel); + button->setEnabled(true); + } +} diff --git a/launcher/dialogs/ProfileSetupDialog.h b/launcher/dialogs/ProfileSetupDialog.h new file mode 100644 index 00000000..6f413ebd --- /dev/null +++ b/launcher/dialogs/ProfileSetupDialog.h @@ -0,0 +1,88 @@ +/* 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 +#include +#include +#include + +#include +#include + +namespace Ui +{ +class ProfileSetupDialog; +} + +class ProfileSetupDialog : public QDialog +{ + Q_OBJECT +public: + + explicit ProfileSetupDialog(MinecraftAccountPtr accountToSetup, QWidget *parent = 0); + ~ProfileSetupDialog(); + + enum class NameStatus + { + NotSet, + Pending, + Available, + Exists, + Error + } nameStatus = NameStatus::NotSet; + +private slots: + void on_buttonBox_accepted(); + void on_buttonBox_rejected(); + + void nameEdited(const QString &name); + void checkFinished( + QNetworkReply::NetworkError error, + QByteArray data, + QList headers + ); + void startCheck(); + + void setupProfileFinished( + QNetworkReply::NetworkError error, + QByteArray data, + QList headers + ); +protected: + void scheduleCheck(const QString &name); + void checkName(const QString &name); + void setNameStatus(NameStatus status, QString errorString); + + void setupProfile(const QString & profileName); + +private: + MinecraftAccountPtr m_accountToSetup; + Ui::ProfileSetupDialog *ui; + QIcon goodIcon; + QIcon yellowIcon; + QIcon badIcon; + QAction * validityAction = nullptr; + + QString queuedCheck; + + bool isChecking = false; + bool isWorking = false; + QString currentCheck; + + QTimer checkStartTimer; +}; + diff --git a/launcher/dialogs/ProfileSetupDialog.ui b/launcher/dialogs/ProfileSetupDialog.ui new file mode 100644 index 00000000..9dbabb4b --- /dev/null +++ b/launcher/dialogs/ProfileSetupDialog.ui @@ -0,0 +1,74 @@ + + + ProfileSetupDialog + + + + 0 + 0 + 615 + 208 + + + + Choose Minecraft name + + + + + + + 0 + 0 + + + + You just need to take one more step to be able to play Minecraft on this account. + +Choose your name carefully: + + + true + + + nameEdit + + + + + + + + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + true + + + Errors go here + + + true + + + true + + + Qt::LinksAccessibleByKeyboard|Qt::LinksAccessibleByMouse|Qt::TextBrowserInteraction|Qt::TextSelectableByKeyboard|Qt::TextSelectableByMouse + + + + + + + nameEdit + + + + diff --git a/launcher/minecraft/auth/AccountData.cpp b/launcher/minecraft/auth/AccountData.cpp index 5c6de9df..8aa4e37f 100644 --- a/launcher/minecraft/auth/AccountData.cpp +++ b/launcher/minecraft/auth/AccountData.cpp @@ -207,6 +207,35 @@ MinecraftProfile profileFromJSONV3(const QJsonObject &parent, const char * token return out; } +void entitlementToJSONV3(QJsonObject &parent, MinecraftEntitlement p) { + if(p.validity == Katabasis::Validity::None) { + return; + } + QJsonObject out; + out["ownsMinecraft"] = QJsonValue(p.ownsMinecraft); + out["canPlayMinecraft"] = QJsonValue(p.canPlayMinecraft); + parent["entitlement"] = out; +} + +bool entitlementFromJSONV3(const QJsonObject &parent, MinecraftEntitlement & out) { + auto entitlementObject = parent.value("entitlement").toObject(); + if(entitlementObject.isEmpty()) { + return false; + } + { + auto ownsMinecraftV = entitlementObject.value("ownsMinecraft"); + auto canPlayMinecraftV = entitlementObject.value("canPlayMinecraft"); + if(!ownsMinecraftV.isBool() || !canPlayMinecraftV.isBool()) { + qWarning() << "mandatory attributes are missing or of unexpected type"; + return false; + } + out.canPlayMinecraft = canPlayMinecraftV.toBool(false); + out.ownsMinecraft = ownsMinecraftV.toBool(false); + out.validity = Katabasis::Validity::Assumed; + } + return true; +} + } bool AccountData::resumeStateFromV2(QJsonObject data) { @@ -304,9 +333,15 @@ bool AccountData::resumeStateFromV3(QJsonObject data) { yggdrasilToken = tokenFromJSONV3(data, "ygg"); minecraftProfile = profileFromJSONV3(data, "profile"); + if(!entitlementFromJSONV3(data, minecraftEntitlement)) { + if(minecraftProfile.validity != Katabasis::Validity::None) { + minecraftEntitlement.canPlayMinecraft = true; + minecraftEntitlement.ownsMinecraft = true; + minecraftEntitlement.validity = Katabasis::Validity::Assumed; + } + } validity_ = minecraftProfile.validity; - return true; } @@ -331,6 +366,7 @@ QJsonObject AccountData::saveState() const { tokenToJSONV3(output, yggdrasilToken, "ygg"); profileToJSONV3(output, minecraftProfile, "profile"); + entitlementToJSONV3(output, minecraftEntitlement); return output; } @@ -378,7 +414,12 @@ QString AccountData::profileId() const { } QString AccountData::profileName() const { - return minecraftProfile.name; + if(minecraftProfile.name.size() == 0) { + return QObject::tr("No profile (%1)").arg(accountDisplayString()); + } + else { + return minecraftProfile.name; + } } QString AccountData::accountDisplayString() const { diff --git a/launcher/minecraft/auth/AccountData.h b/launcher/minecraft/auth/AccountData.h index cf58fb76..09cd2c73 100644 --- a/launcher/minecraft/auth/AccountData.h +++ b/launcher/minecraft/auth/AccountData.h @@ -21,6 +21,12 @@ struct Cape { QByteArray data; }; +struct MinecraftEntitlement { + bool ownsMinecraft = false; + bool canPlayMinecraft = false; + Katabasis::Validity validity = Katabasis::Validity::None; +}; + struct MinecraftProfile { QString id; QString name; @@ -69,5 +75,6 @@ struct AccountData { Katabasis::Token yggdrasilToken; MinecraftProfile minecraftProfile; + MinecraftEntitlement minecraftEntitlement; Katabasis::Validity validity_ = Katabasis::Validity::None; }; diff --git a/launcher/minecraft/auth/AccountList.cpp b/launcher/minecraft/auth/AccountList.cpp index a76cac55..4895c39c 100644 --- a/launcher/minecraft/auth/AccountList.cpp +++ b/launcher/minecraft/auth/AccountList.cpp @@ -64,21 +64,18 @@ const MinecraftAccountPtr AccountList::at(int i) const void AccountList::addAccount(const MinecraftAccountPtr account) { - // We only ever want accounts with valid profiles. - // Keeping profile-less accounts is pointless and serves no purpose. auto profileId = account->profileId(); - if(!profileId.size()) { - return; + if(profileId.size()) { + // override/replace existing account with the same profileId + auto existingAccount = findAccountByProfileId(profileId); + if(existingAccount != -1) { + m_accounts[existingAccount] = account; + emit dataChanged(index(existingAccount), index(existingAccount, columnCount(QModelIndex()) - 1)); + onListChanged(); + return; + } } - // override/replace existing account with the same profileId - auto existingAccount = findAccountByProfileId(profileId); - if(existingAccount != -1) { - m_accounts[existingAccount] = account; - emit dataChanged(index(existingAccount), index(existingAccount, columnCount(QModelIndex()) - 1)); - onListChanged(); - return; - } // if we don't have this porfileId yet, add the account to the end int row = m_accounts.count(); @@ -112,9 +109,9 @@ MinecraftAccountPtr AccountList::activeAccount() const return m_activeAccount; } -void AccountList::setActiveAccount(const QString &profileId) +void AccountList::setActiveAccount(MinecraftAccountPtr newAccount) { - if (profileId.isEmpty() && m_activeAccount) + if (!newAccount && m_activeAccount) { int idx = 0; auto prevActiveAcc = m_activeAccount; @@ -138,7 +135,7 @@ void AccountList::setActiveAccount(const QString &profileId) int idx = 0; for (MinecraftAccountPtr account : m_accounts) { - if (account->profileId() == profileId) + if (account == newAccount) { newActiveAccount = account; newActiveAccountIdx = idx; @@ -321,7 +318,7 @@ bool AccountList::setData(const QModelIndex &index, const QVariant &value, int r if(value == Qt::Checked) { MinecraftAccountPtr account = at(index.row()); - setActiveAccount(account->profileId()); + setActiveAccount(account); } } @@ -435,11 +432,10 @@ bool AccountList::loadV3(QJsonObject& root) { if (account.get() != nullptr) { auto profileId = account->profileId(); - if(!profileId.size()) { - continue; - } - if(findAccountByProfileId(profileId) != -1) { - continue; + if(profileId.size()) { + if(findAccountByProfileId(profileId) != -1) { + continue; + } } connect(account.get(), &MinecraftAccount::changed, this, &AccountList::accountChanged); m_accounts.append(account); diff --git a/launcher/minecraft/auth/AccountList.h b/launcher/minecraft/auth/AccountList.h index e275eb17..d6d72cd3 100644 --- a/launcher/minecraft/auth/AccountList.h +++ b/launcher/minecraft/auth/AccountList.h @@ -79,7 +79,7 @@ public: bool saveList(); MinecraftAccountPtr activeAccount() const; - void setActiveAccount(const QString &profileId); + void setActiveAccount(MinecraftAccountPtr profileId); bool anyAccountIsValid(); signals: diff --git a/launcher/minecraft/auth/AuthSession.h b/launcher/minecraft/auth/AuthSession.h index f609d5d3..f631a97d 100644 --- a/launcher/minecraft/auth/AuthSession.h +++ b/launcher/minecraft/auth/AuthSession.h @@ -17,6 +17,7 @@ struct AuthSession Undetermined, RequiresOAuth, RequiresPassword, + RequiresProfileSetup, PlayableOffline, PlayableOnline, GoneOrMigrated diff --git a/launcher/minecraft/auth/MinecraftAccount.cpp b/launcher/minecraft/auth/MinecraftAccount.cpp index 2d76f9ac..8e294443 100644 --- a/launcher/minecraft/auth/MinecraftAccount.cpp +++ b/launcher/minecraft/auth/MinecraftAccount.cpp @@ -213,8 +213,21 @@ void MinecraftAccount::authSucceeded() auto session = m_currentTask->getAssignedSession(); if (session) { - session->status = - session->wants_online ? AuthSession::PlayableOnline : AuthSession::PlayableOffline; + /* + session->status = AuthSession::RequiresProfileSetup; + session->auth_server_online = true; + */ + if(data.profileId().size() == 0) { + session->status = AuthSession::RequiresProfileSetup; + } + else { + if(session->wants_online) { + session->status = AuthSession::PlayableOnline; + } + else { + session->status = AuthSession::PlayableOffline; + } + } fillSession(session); session->auth_server_online = true; } diff --git a/launcher/minecraft/auth/flows/AuthContext.cpp b/launcher/minecraft/auth/flows/AuthContext.cpp index 9fb3ec48..17fcc9dc 100644 --- a/launcher/minecraft/auth/flows/AuthContext.cpp +++ b/launcher/minecraft/auth/flows/AuthContext.cpp @@ -22,6 +22,8 @@ #include "Env.h" +#include "Parsers.h" + using OAuth2 = Katabasis::OAuth2; using Activity = Katabasis::Activity; @@ -86,7 +88,7 @@ void AuthContext::initMojang() { } void AuthContext::onMojangSucceeded() { - doMinecraftProfile(); + doEntitlements(); } @@ -169,137 +171,6 @@ void AuthContext::doUserAuth() { 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; -} - -bool getNumber(QJsonValue value, int64_t & out) { - if(!value.isDouble()) { - return false; - } - out = (int64_t) value.toDouble(); - return true; -} - -bool getBool(QJsonValue value, bool & out) { - if(!value.isBool()) { - return false; - } - out = value.toBool(); - 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, const char * name) { - qDebug() << "Parsing" << name <<":"; -#ifndef NDEBUG - qDebug() << data; -#endif - 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(); - return false; - } - - auto obj = doc.object(); - if(!getDateTime(obj.value("IssueInstant"), output.issueInstant)) { - qWarning() << "User IssueInstant is not a timestamp"; - return false; - } - if(!getDateTime(obj.value("NotAfter"), output.notAfter)) { - qWarning() << "User NotAfter is not a timestamp"; - return false; - } - if(!getString(obj.value("Token"), output.token)) { - qWarning() << "User Token is not a timestamp"; - return false; - } - auto arrayVal = obj.value("DisplayClaims").toObject().value("xui"); - if(!arrayVal.isArray()) { - qWarning() << "Missing xui claims array"; - 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..."; - return false; - } - output.extra[iter.key()] = claim; - } - - break; - } - if(!foundUHS) { - qWarning() << "Missing uhs"; - return false; - } - output.validity = Katabasis::Validity::Certain; - qDebug() << name << "is valid."; - return true; -} - -} - void AuthContext::onUserAuthDone( QNetworkReply::NetworkError error, QByteArray replyData, @@ -313,7 +184,7 @@ void AuthContext::onUserAuthDone( } Katabasis::Token temp; - if(!parseXTokenResponse(replyData, temp, "UToken")) { + if(!Parsers::parseXTokenResponse(replyData, temp, "UToken")) { qWarning() << "Could not parse user authentication response..."; finishActivity(); changeState(STATE_FAILED_HARD, tr("XBox user authentication response could not be understood.")); @@ -374,7 +245,7 @@ void AuthContext::processSTSError(QNetworkReply::NetworkError error, QByteArray int64_t errorCode = -1; auto obj = doc.object(); - if(!getNumber(obj.value("XErr"), errorCode)) { + if(!Parsers::getNumber(obj.value("XErr"), errorCode)) { qWarning() << "XErr is not a number"; return; } @@ -400,7 +271,7 @@ void AuthContext::onSTSAuthMinecraftDone( } Katabasis::Token temp; - if(!parseXTokenResponse(replyData, temp, "STSAuthMinecraft")) { + if(!Parsers::parseXTokenResponse(replyData, temp, "STSAuthMinecraft")) { qWarning() << "Could not parse authorization response for access to mojang services..."; failResult(m_mcAuthSucceeded); return; @@ -417,67 +288,33 @@ void AuthContext::onSTSAuthMinecraftDone( } void AuthContext::doMinecraftAuth() { + auto requestURL = "https://api.minecraftservices.com/launcher/login"; + auto uhs = m_data->mojangservicesToken.extra["uhs"].toString(); + auto xToken = m_data->mojangservicesToken.token; + QString mc_auth_template = R"XXX( { - "identityToken": "XBL3.0 x=%1;%2" + "xtoken": "XBL3.0 x=%1;%2", + "platform": "PC_LAUNCHER" } )XXX"; - auto data = mc_auth_template.arg(m_data->mojangservicesToken.extra["uhs"].toString(), m_data->mojangservicesToken.token); + auto requestBody = mc_auth_template.arg(uhs, xToken); - QNetworkRequest request = QNetworkRequest(QUrl("https://api.minecraftservices.com/authentication/login_with_xbox")); + QNetworkRequest request = QNetworkRequest(QUrl(requestURL)); request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); request.setRawHeader("Accept", "application/json"); AuthRequest *requestor = new AuthRequest(this); connect(requestor, &AuthRequest::finished, this, &AuthContext::onMinecraftAuthDone); - requestor->post(request, data.toUtf8()); + requestor->post(request, requestBody.toUtf8()); qDebug() << "Getting Minecraft access token..."; } -namespace { -bool parseMojangResponse(QByteArray & data, Katabasis::Token &output) { - QJsonParseError jsonError; - qDebug() << "Parsing Mojang response..."; -#ifndef NDEBUG - qDebug() << data; -#endif - QJsonDocument doc = QJsonDocument::fromJson(data, &jsonError); - if(jsonError.error) { - qWarning() << "Failed to parse response from api.minecraftservices.com/authentication/login_with_xbox as JSON: " << jsonError.errorString(); - 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"; - 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"; - return false; - } - - // TODO: it's a JWT... validate it? - if(!getString(obj.value("access_token"), output.token)) { - qWarning() << "access_token is not valid"; - return false; - } - output.validity = Katabasis::Validity::Certain; - qDebug() << "Mojang response is valid."; - return true; -} -} - void AuthContext::onMinecraftAuthDone( QNetworkReply::NetworkError error, QByteArray replyData, QList headers ) { + qDebug() << replyData; if (error != QNetworkReply::NoError) { qWarning() << "Reply error:" << error; #ifndef NDEBUG @@ -487,7 +324,7 @@ void AuthContext::onMinecraftAuthDone( return; } - if(!parseMojangResponse(replyData, m_data->yggdrasilToken)) { + if(!Parsers::parseMojangResponse(replyData, m_data->yggdrasilToken)) { qWarning() << "Could not parse login_with_xbox response..."; #ifndef NDEBUG qDebug() << replyData; @@ -539,7 +376,7 @@ void AuthContext::onSTSAuthGenericDone( } Katabasis::Token temp; - if(!parseXTokenResponse(replyData, temp, "STSAuthGeneric")) { + if(!Parsers::parseXTokenResponse(replyData, temp, "STSAuthGeneric")) { qWarning() << "Could not parse authorization response for access to xbox API..."; failResult(m_xboxProfileSucceeded); return; @@ -619,7 +456,7 @@ void AuthContext::checkResult() { return; } if(m_mcAuthSucceeded && m_xboxProfileSucceeded) { - doMinecraftProfile(); + doEntitlements(); } else { finishActivity(); @@ -662,84 +499,33 @@ void AuthContext::checkResult() { } } -namespace { -bool parseMinecraftProfile(QByteArray & data, MinecraftProfile &output) { - qDebug() << "Parsing Minecraft profile..."; +void AuthContext::doEntitlements() { + auto uuid = QUuid::createUuid(); + entitlementsRequestId = uuid.toString().remove('{').remove('}'); + auto url = "https://api.minecraftservices.com/entitlements/license?requestId=" + entitlementsRequestId; + 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()); + AuthRequest *requestor = new AuthRequest(this); + connect(requestor, &AuthRequest::finished, this, &AuthContext::onEntitlementsDone); + requestor->get(request); + qDebug() << "Getting Xbox profile..."; +} + + +void AuthContext::onEntitlementsDone( + QNetworkReply::NetworkError error, + QByteArray data, + QList headers +) { #ifndef NDEBUG qDebug() << data; #endif - - 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(); - return false; - } - - auto obj = doc.object(); - if(!getString(obj.value("id"), output.id)) { - qWarning() << "Minecraft profile id is not a string"; - return false; - } - - if(!getString(obj.value("name"), output.name)) { - qWarning() << "Minecraft profile name is not a string"; - return false; - } - - auto 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(); - - QString currentCape; - for(auto cape: capesArray) { - 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 = capeOut.id; - } - if(!getString(capeObj.value("url"), capeOut.url)) { - continue; - } - if(!getString(capeObj.value("alias"), capeOut.alias)) { - continue; - } - - output.capes[capeOut.id] = capeOut; - } - output.currentCape = currentCape; - output.validity = Katabasis::Validity::Certain; - return true; -} + // TODO: check presence of same entitlementsRequestId? + // TODO: validate JWTs? + Parsers::parseMinecraftEntitlements(data, m_data->minecraftEntitlement); + doMinecraftProfile(); } void AuthContext::doMinecraftProfile() { @@ -766,9 +552,9 @@ void AuthContext::onMinecraftProfileDone( qDebug() << data; #endif if (error == QNetworkReply::ContentNotFoundError) { + // NOTE: Succeed even if we do not have a profile. This is a valid account state. m_data->minecraftProfile = MinecraftProfile(); - finishActivity(); - changeState(STATE_FAILED_HARD, tr("Account is missing a Minecraft Java profile.\n\nWhile the Microsoft account is valid, it does not own the game.\n\nYou might own Bedrock on this account, but that does not give you access to Java currently.")); + succeed(); return; } if (error != QNetworkReply::NoError) { @@ -776,7 +562,7 @@ void AuthContext::onMinecraftProfileDone( changeState(STATE_FAILED_HARD, tr("Minecraft Java profile acquisition failed.")); return; } - if(!parseMinecraftProfile(data, m_data->minecraftProfile)) { + if(!Parsers::parseMinecraftProfile(data, m_data->minecraftProfile)) { m_data->minecraftProfile = MinecraftProfile(); finishActivity(); changeState(STATE_FAILED_HARD, tr("Minecraft Java profile response could not be parsed")); @@ -805,43 +591,13 @@ void AuthContext::doMigrationEligibilityCheck() { requestor->get(request); } -bool parseRolloutResponse(QByteArray & data, bool& result) { - qDebug() << "Parsing Rollout response..."; -#ifndef NDEBUG - qDebug() << data; -#endif - - QJsonParseError jsonError; - QJsonDocument doc = QJsonDocument::fromJson(data, &jsonError); - if(jsonError.error) { - qWarning() << "Failed to parse response from https://api.minecraftservices.com/rollout/v1/msamigration as JSON: " << jsonError.errorString(); - return false; - } - - auto obj = doc.object(); - QString feature; - if(!getString(obj.value("feature"), feature)) { - qWarning() << "Rollout feature is not a string"; - return false; - } - if(feature != "msamigration") { - qWarning() << "Rollout feature is not what we expected (msamigration), but is instead \"" << feature << "\""; - return false; - } - if(!getBool(obj.value("rollout"), result)) { - qWarning() << "Rollout feature is not a string"; - return false; - } - return true; -} - void AuthContext::onMigrationEligibilityCheckDone( QNetworkReply::NetworkError error, QByteArray data, QList headers ) { if (error == QNetworkReply::NoError) { - parseRolloutResponse(data, m_data->canMigrateToMSA); + Parsers::parseRolloutResponse(data, m_data->canMigrateToMSA); } doGetSkin(); } @@ -865,6 +621,11 @@ void AuthContext::onSkinDone( if (error == QNetworkReply::NoError) { m_data->minecraftProfile.skin.data = data; } + succeed(); + +} + +void AuthContext::succeed() { m_data->validity_ = Katabasis::Validity::Certain; finishActivity(); changeState(STATE_SUCCEEDED, tr("Finished all authentication steps")); diff --git a/launcher/minecraft/auth/flows/AuthContext.h b/launcher/minecraft/auth/flows/AuthContext.h index dc7552ac..dcb91613 100644 --- a/launcher/minecraft/auth/flows/AuthContext.h +++ b/launcher/minecraft/auth/flows/AuthContext.h @@ -63,6 +63,9 @@ protected: void doXBoxProfile(); Q_SLOT void onXBoxProfileDone(QNetworkReply::NetworkError, QByteArray, QList); + void doEntitlements(); + Q_SLOT void onEntitlementsDone(QNetworkReply::NetworkError, QByteArray, QList); + void doMinecraftProfile(); Q_SLOT void onMinecraftProfileDone(QNetworkReply::NetworkError, QByteArray, QList); @@ -72,6 +75,8 @@ protected: void doGetSkin(); Q_SLOT void onSkinDone(QNetworkReply::NetworkError, QByteArray, QList); + void succeed(); + void failResult(bool & flag); void succeedResult(bool & flag); void checkResult(); @@ -88,6 +93,7 @@ protected: int m_requestsDone = 0; bool m_xboxProfileSucceeded = false; bool m_mcAuthSucceeded = false; + QString entitlementsRequestId; QSet stsErrors; bool stsFailed = false; diff --git a/launcher/minecraft/auth/flows/Parsers.cpp b/launcher/minecraft/auth/flows/Parsers.cpp new file mode 100644 index 00000000..e79d67f2 --- /dev/null +++ b/launcher/minecraft/auth/flows/Parsers.cpp @@ -0,0 +1,315 @@ +#include "Parsers.h" + +#include +#include + +namespace Parsers { + +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; +} + +bool getNumber(QJsonValue value, int64_t & out) { + if(!value.isDouble()) { + return false; + } + out = (int64_t) value.toDouble(); + return true; +} + +bool getBool(QJsonValue value, bool & out) { + if(!value.isBool()) { + return false; + } + out = value.toBool(); + 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, const char * name) { + qDebug() << "Parsing" << name <<":"; +#ifndef NDEBUG + qDebug() << data; +#endif + 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(); + return false; + } + + auto obj = doc.object(); + if(!getDateTime(obj.value("IssueInstant"), output.issueInstant)) { + qWarning() << "User IssueInstant is not a timestamp"; + return false; + } + if(!getDateTime(obj.value("NotAfter"), output.notAfter)) { + qWarning() << "User NotAfter is not a timestamp"; + return false; + } + if(!getString(obj.value("Token"), output.token)) { + qWarning() << "User Token is not a timestamp"; + return false; + } + auto arrayVal = obj.value("DisplayClaims").toObject().value("xui"); + if(!arrayVal.isArray()) { + qWarning() << "Missing xui claims array"; + 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..."; + return false; + } + output.extra[iter.key()] = claim; + } + + break; + } + if(!foundUHS) { + qWarning() << "Missing uhs"; + return false; + } + output.validity = Katabasis::Validity::Certain; + qDebug() << name << "is valid."; + return true; +} + +bool parseMinecraftProfile(QByteArray & data, MinecraftProfile &output) { + qDebug() << "Parsing Minecraft profile..."; +#ifndef NDEBUG + qDebug() << data; +#endif + + QJsonParseError jsonError; + QJsonDocument doc = QJsonDocument::fromJson(data, &jsonError); + if(jsonError.error) { + qWarning() << "Failed to parse response from user.auth.xboxlive.com as JSON: " << jsonError.errorString(); + return false; + } + + auto obj = doc.object(); + if(!getString(obj.value("id"), output.id)) { + qWarning() << "Minecraft profile id is not a string"; + return false; + } + + if(!getString(obj.value("name"), output.name)) { + qWarning() << "Minecraft profile name is not a string"; + return false; + } + + auto 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(); + + QString currentCape; + for(auto cape: capesArray) { + 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 = capeOut.id; + } + if(!getString(capeObj.value("url"), capeOut.url)) { + continue; + } + if(!getString(capeObj.value("alias"), capeOut.alias)) { + continue; + } + + output.capes[capeOut.id] = capeOut; + } + output.currentCape = currentCape; + output.validity = Katabasis::Validity::Certain; + return true; +} + +bool parseMinecraftEntitlements(QByteArray & data, MinecraftEntitlement &output) { + qDebug() << "Parsing Minecraft entitlements..."; +#ifndef NDEBUG + qDebug() << data; +#endif + + 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(); + return false; + } + + auto obj = doc.object(); + + auto itemsArray = obj.value("items").toArray(); + for(auto item: itemsArray) { + auto itemObj = item.toObject(); + QString name; + if(!getString(itemObj.value("name"), name)) { + continue; + } + if(name == "game_minecraft") { + output.canPlayMinecraft = true; + } + if(name == "product_minecraft") { + output.ownsMinecraft = true; + } + } + output.validity = Katabasis::Validity::Certain; + return true; +} + +bool parseRolloutResponse(QByteArray & data, bool& result) { + qDebug() << "Parsing Rollout response..."; +#ifndef NDEBUG + qDebug() << data; +#endif + + QJsonParseError jsonError; + QJsonDocument doc = QJsonDocument::fromJson(data, &jsonError); + if(jsonError.error) { + qWarning() << "Failed to parse response from https://api.minecraftservices.com/rollout/v1/msamigration as JSON: " << jsonError.errorString(); + return false; + } + + auto obj = doc.object(); + QString feature; + if(!getString(obj.value("feature"), feature)) { + qWarning() << "Rollout feature is not a string"; + return false; + } + if(feature != "msamigration") { + qWarning() << "Rollout feature is not what we expected (msamigration), but is instead \"" << feature << "\""; + return false; + } + if(!getBool(obj.value("rollout"), result)) { + qWarning() << "Rollout feature is not a string"; + return false; + } + return true; +} + +bool parseMojangResponse(QByteArray & data, Katabasis::Token &output) { + QJsonParseError jsonError; + qDebug() << "Parsing Mojang response..."; +#ifndef NDEBUG + qDebug() << data; +#endif + QJsonDocument doc = QJsonDocument::fromJson(data, &jsonError); + if(jsonError.error) { + qWarning() << "Failed to parse response from api.minecraftservices.com/launcher/login as JSON: " << jsonError.errorString(); + 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"; + 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"; + return false; + } + + // TODO: it's a JWT... validate it? + if(!getString(obj.value("access_token"), output.token)) { + qWarning() << "access_token is not valid"; + return false; + } + output.validity = Katabasis::Validity::Certain; + qDebug() << "Mojang response is valid."; + return true; +} + +} diff --git a/launcher/minecraft/auth/flows/Parsers.h b/launcher/minecraft/auth/flows/Parsers.h new file mode 100644 index 00000000..6c6b64ee --- /dev/null +++ b/launcher/minecraft/auth/flows/Parsers.h @@ -0,0 +1,19 @@ +#pragma once + +#include "minecraft/auth/AccountData.h" + +namespace Parsers +{ + bool getDateTime(QJsonValue value, QDateTime & out); + bool getString(QJsonValue value, QString & out); + bool getNumber(QJsonValue value, double & out); + bool getNumber(QJsonValue value, int64_t & out); + bool getBool(QJsonValue value, bool & out); + + bool parseXTokenResponse(QByteArray &data, Katabasis::Token &output, const char * name); + bool parseMojangResponse(QByteArray &data, Katabasis::Token &output); + + bool parseMinecraftProfile(QByteArray &data, MinecraftProfile &output); + bool parseMinecraftEntitlements(QByteArray &data, MinecraftEntitlement &output); + bool parseRolloutResponse(QByteArray &data, bool& result); +} diff --git a/launcher/pages/global/AccountListPage.cpp b/launcher/pages/global/AccountListPage.cpp index 4f8b8e9c..bf5ab633 100644 --- a/launcher/pages/global/AccountListPage.cpp +++ b/launcher/pages/global/AccountListPage.cpp @@ -121,7 +121,7 @@ void AccountListPage::on_actionAddMojang_triggered() { m_accounts->addAccount(account); if (m_accounts->count() == 1) { - m_accounts->setActiveAccount(account->profileId()); + m_accounts->setActiveAccount(account); } } } @@ -149,7 +149,7 @@ void AccountListPage::on_actionAddMicrosoft_triggered() { m_accounts->addAccount(account); if (m_accounts->count() == 1) { - m_accounts->setActiveAccount(account->profileId()); + m_accounts->setActiveAccount(account); } } } @@ -187,13 +187,13 @@ void AccountListPage::on_actionSetDefault_triggered() { QModelIndex selected = selection.first(); MinecraftAccountPtr account = selected.data(AccountList::PointerRole).value(); - m_accounts->setActiveAccount(account->profileId()); + m_accounts->setActiveAccount(account); } } void AccountListPage::on_actionNoDefault_triggered() { - m_accounts->setActiveAccount(""); + m_accounts->setActiveAccount(nullptr); } void AccountListPage::updateButtonStates()