From 3c46d8a412956a759f61ae802c540ef72d00b35d Mon Sep 17 00:00:00 2001 From: Petr Mrázek Date: Sat, 4 Dec 2021 01:18:05 +0100 Subject: GH-4071 Heavily refactor and rearchitect account system This makes the account system much more modular and makes it treat errors as something recoverable, unless they come directly from the MSA refresh token becoming invalid. --- launcher/minecraft/auth/AccountData.h | 15 + launcher/minecraft/auth/AccountList.cpp | 138 ++++- launcher/minecraft/auth/AccountList.h | 28 + launcher/minecraft/auth/AccountTask.cpp | 75 ++- launcher/minecraft/auth/AccountTask.h | 72 +-- launcher/minecraft/auth/AuthRequest.cpp | 125 ++++ launcher/minecraft/auth/AuthRequest.h | 70 +++ launcher/minecraft/auth/AuthStep.cpp | 7 + launcher/minecraft/auth/AuthStep.h | 33 + launcher/minecraft/auth/MinecraftAccount.cpp | 259 ++------ launcher/minecraft/auth/MinecraftAccount.h | 37 +- launcher/minecraft/auth/Parsers.cpp | 316 ++++++++++ launcher/minecraft/auth/Parsers.h | 19 + launcher/minecraft/auth/Yggdrasil.cpp | 331 ++++++++++ launcher/minecraft/auth/Yggdrasil.h | 102 ++++ launcher/minecraft/auth/flows/AuthContext.cpp | 671 --------------------- launcher/minecraft/auth/flows/AuthContext.h | 110 ---- launcher/minecraft/auth/flows/AuthFlow.cpp | 71 +++ launcher/minecraft/auth/flows/AuthFlow.h | 45 ++ launcher/minecraft/auth/flows/AuthRequest.cpp | 121 ---- launcher/minecraft/auth/flows/AuthRequest.h | 64 -- launcher/minecraft/auth/flows/MSA.cpp | 37 ++ launcher/minecraft/auth/flows/MSA.h | 22 + launcher/minecraft/auth/flows/MSAInteractive.cpp | 22 - launcher/minecraft/auth/flows/MSAInteractive.h | 13 - launcher/minecraft/auth/flows/MSASilent.cpp | 16 - launcher/minecraft/auth/flows/MSASilent.h | 13 - launcher/minecraft/auth/flows/Mojang.cpp | 27 + launcher/minecraft/auth/flows/Mojang.h | 26 + launcher/minecraft/auth/flows/MojangLogin.cpp | 18 - launcher/minecraft/auth/flows/MojangLogin.h | 17 - launcher/minecraft/auth/flows/MojangRefresh.cpp | 17 - launcher/minecraft/auth/flows/MojangRefresh.h | 10 - launcher/minecraft/auth/flows/Parsers.cpp | 316 ---------- launcher/minecraft/auth/flows/Parsers.h | 19 - launcher/minecraft/auth/flows/Yggdrasil.cpp | 331 ---------- launcher/minecraft/auth/flows/Yggdrasil.h | 86 --- launcher/minecraft/auth/steps/EntitlementsStep.cpp | 53 ++ launcher/minecraft/auth/steps/EntitlementsStep.h | 25 + launcher/minecraft/auth/steps/GetSkinStep.cpp | 43 ++ launcher/minecraft/auth/steps/GetSkinStep.h | 22 + .../minecraft/auth/steps/LauncherLoginStep.cpp | 78 +++ launcher/minecraft/auth/steps/LauncherLoginStep.h | 22 + launcher/minecraft/auth/steps/MSAStep.cpp | 111 ++++ launcher/minecraft/auth/steps/MSAStep.h | 32 + .../auth/steps/MigrationEligibilityStep.cpp | 45 ++ .../auth/steps/MigrationEligibilityStep.h | 22 + .../minecraft/auth/steps/MinecraftProfileStep.cpp | 83 +++ .../minecraft/auth/steps/MinecraftProfileStep.h | 22 + .../minecraft/auth/steps/XboxAuthorizationStep.cpp | 158 +++++ .../minecraft/auth/steps/XboxAuthorizationStep.h | 34 ++ launcher/minecraft/auth/steps/XboxProfileStep.cpp | 73 +++ launcher/minecraft/auth/steps/XboxProfileStep.h | 22 + launcher/minecraft/auth/steps/XboxUserStep.cpp | 68 +++ launcher/minecraft/auth/steps/XboxUserStep.h | 22 + launcher/minecraft/auth/steps/YggdrasilStep.cpp | 51 ++ launcher/minecraft/auth/steps/YggdrasilStep.h | 28 + launcher/minecraft/services/CapeChange.cpp | 8 +- launcher/minecraft/services/CapeChange.h | 5 +- launcher/minecraft/services/SkinDelete.cpp | 6 +- launcher/minecraft/services/SkinDelete.h | 6 +- launcher/minecraft/services/SkinUpload.cpp | 6 +- launcher/minecraft/services/SkinUpload.h | 5 +- 63 files changed, 2591 insertions(+), 2158 deletions(-) create mode 100644 launcher/minecraft/auth/AuthRequest.cpp create mode 100644 launcher/minecraft/auth/AuthRequest.h create mode 100644 launcher/minecraft/auth/AuthStep.cpp create mode 100644 launcher/minecraft/auth/AuthStep.h create mode 100644 launcher/minecraft/auth/Parsers.cpp create mode 100644 launcher/minecraft/auth/Parsers.h create mode 100644 launcher/minecraft/auth/Yggdrasil.cpp create mode 100644 launcher/minecraft/auth/Yggdrasil.h delete mode 100644 launcher/minecraft/auth/flows/AuthContext.cpp delete mode 100644 launcher/minecraft/auth/flows/AuthContext.h create mode 100644 launcher/minecraft/auth/flows/AuthFlow.cpp create mode 100644 launcher/minecraft/auth/flows/AuthFlow.h delete mode 100644 launcher/minecraft/auth/flows/AuthRequest.cpp delete mode 100644 launcher/minecraft/auth/flows/AuthRequest.h create mode 100644 launcher/minecraft/auth/flows/MSA.cpp create mode 100644 launcher/minecraft/auth/flows/MSA.h delete mode 100644 launcher/minecraft/auth/flows/MSAInteractive.cpp delete mode 100644 launcher/minecraft/auth/flows/MSAInteractive.h delete mode 100644 launcher/minecraft/auth/flows/MSASilent.cpp delete mode 100644 launcher/minecraft/auth/flows/MSASilent.h create mode 100644 launcher/minecraft/auth/flows/Mojang.cpp create mode 100644 launcher/minecraft/auth/flows/Mojang.h delete mode 100644 launcher/minecraft/auth/flows/MojangLogin.cpp delete mode 100644 launcher/minecraft/auth/flows/MojangLogin.h delete mode 100644 launcher/minecraft/auth/flows/MojangRefresh.cpp delete mode 100644 launcher/minecraft/auth/flows/MojangRefresh.h delete mode 100644 launcher/minecraft/auth/flows/Parsers.cpp delete mode 100644 launcher/minecraft/auth/flows/Parsers.h delete mode 100644 launcher/minecraft/auth/flows/Yggdrasil.cpp delete mode 100644 launcher/minecraft/auth/flows/Yggdrasil.h create mode 100644 launcher/minecraft/auth/steps/EntitlementsStep.cpp create mode 100644 launcher/minecraft/auth/steps/EntitlementsStep.h create mode 100644 launcher/minecraft/auth/steps/GetSkinStep.cpp create mode 100644 launcher/minecraft/auth/steps/GetSkinStep.h create mode 100644 launcher/minecraft/auth/steps/LauncherLoginStep.cpp create mode 100644 launcher/minecraft/auth/steps/LauncherLoginStep.h create mode 100644 launcher/minecraft/auth/steps/MSAStep.cpp create mode 100644 launcher/minecraft/auth/steps/MSAStep.h create mode 100644 launcher/minecraft/auth/steps/MigrationEligibilityStep.cpp create mode 100644 launcher/minecraft/auth/steps/MigrationEligibilityStep.h create mode 100644 launcher/minecraft/auth/steps/MinecraftProfileStep.cpp create mode 100644 launcher/minecraft/auth/steps/MinecraftProfileStep.h create mode 100644 launcher/minecraft/auth/steps/XboxAuthorizationStep.cpp create mode 100644 launcher/minecraft/auth/steps/XboxAuthorizationStep.h create mode 100644 launcher/minecraft/auth/steps/XboxProfileStep.cpp create mode 100644 launcher/minecraft/auth/steps/XboxProfileStep.h create mode 100644 launcher/minecraft/auth/steps/XboxUserStep.cpp create mode 100644 launcher/minecraft/auth/steps/XboxUserStep.h create mode 100644 launcher/minecraft/auth/steps/YggdrasilStep.cpp create mode 100644 launcher/minecraft/auth/steps/YggdrasilStep.h (limited to 'launcher/minecraft') diff --git a/launcher/minecraft/auth/AccountData.h b/launcher/minecraft/auth/AccountData.h index 09cd2c73..fa42747e 100644 --- a/launcher/minecraft/auth/AccountData.h +++ b/launcher/minecraft/auth/AccountData.h @@ -41,6 +41,16 @@ enum class AccountType { Mojang }; +enum class AccountState { + Unchecked, + Offline, + Working, + Online, + Errored, + Expired, + Gone +}; + struct AccountData { QJsonObject saveState() const; bool resumeStateFromV2(QJsonObject data); @@ -77,4 +87,9 @@ struct AccountData { MinecraftProfile minecraftProfile; MinecraftEntitlement minecraftEntitlement; Katabasis::Validity validity_ = Katabasis::Validity::None; + + // runtime only information (not saved with the account) + QString internalId; + QString errorString; + AccountState accountState = AccountState::Unchecked; }; diff --git a/launcher/minecraft/auth/AccountList.cpp b/launcher/minecraft/auth/AccountList.cpp index d7537345..c44e3e89 100644 --- a/launcher/minecraft/auth/AccountList.cpp +++ b/launcher/minecraft/auth/AccountList.cpp @@ -15,6 +15,7 @@ #include "AccountList.h" #include "AccountData.h" +#include "AccountTask.h" #include #include @@ -24,6 +25,7 @@ #include #include #include +#include #include @@ -35,7 +37,14 @@ enum AccountListVersion { MojangMSA = 3 }; -AccountList::AccountList(QObject *parent) : QAbstractListModel(parent) { } +AccountList::AccountList(QObject *parent) : QAbstractListModel(parent) { + m_refreshTimer = new QTimer(this); + m_refreshTimer->setSingleShot(true); + connect(m_refreshTimer, &QTimer::timeout, this, &AccountList::fillQueue); + m_nextTimer = new QTimer(this); + m_nextTimer->setSingleShot(true); + connect(m_nextTimer, &QTimer::timeout, this, &AccountList::tryNext); +} AccountList::~AccountList() noexcept {} @@ -244,13 +253,29 @@ QVariant AccountList::data(const QModelIndex &index, int role) const } case StatusColumn: { - if(account->isActive()) { - return tr("Working", "Account status"); - } - if(account->isExpired()) { - return tr("Expired", "Account status"); + switch(account->accountState()) { + case AccountState::Unchecked: { + return tr("Unchecked", "Account status"); + } + case AccountState::Offline: { + return tr("Offline", "Account status"); + } + case AccountState::Online: { + return tr("Online", "Account status"); + } + case AccountState::Working: { + return tr("Working", "Account status"); + } + case AccountState::Errored: { + return tr("Errored", "Account status"); + } + case AccountState::Expired: { + return tr("Expired", "Account status"); + } + case AccountState::Gone: { + return tr("Gone", "Account status"); + } } - return tr("Ready", "Account status"); } case ProfileNameColumn: { @@ -583,10 +608,105 @@ void AccountList::setListFilePath(QString path, bool autosave) bool AccountList::anyAccountIsValid() { - for(auto account:m_accounts) + for(auto account: m_accounts) { - if(account->accountStatus() != NotVerified) + if(account->ownsMinecraft()) { return true; + } } return false; } + +void AccountList::fillQueue() { + + if(m_defaultAccount && m_defaultAccount->shouldRefresh()) { + auto idToRefresh = m_defaultAccount->internalId(); + m_refreshQueue.push_back(idToRefresh); + qDebug() << "AccountList: Queued default account with internal ID " << idToRefresh << " to refresh first"; + } + + for(int i = 0; i < count(); i++) { + auto account = at(i); + if(account == m_defaultAccount) { + continue; + } + + if(account->shouldRefresh()) { + auto idToRefresh = account->internalId(); + m_refreshQueue.push_back(idToRefresh); + qDebug() << "AccountList: Queued account with internal ID " << idToRefresh << " to refresh"; + } + } + m_refreshQueue.removeDuplicates(); + tryNext(); +} + +void AccountList::requestRefresh(QString accountId) { + m_refreshQueue.push_back(accountId); + if(!isActive()) { + tryNext(); + } +} + +void AccountList::tryNext() { + beginActivity(); + while (m_refreshQueue.length()) { + auto accountId = m_refreshQueue.front(); + m_refreshQueue.pop_front(); + for(int i = 0; i < count(); i++) { + auto account = at(i); + if(account->internalId() == accountId) { + m_currentTask = account->refresh(); + if(m_currentTask) { + connect(m_currentTask.get(), &AccountTask::succeeded, this, &AccountList::authSucceeded); + connect(m_currentTask.get(), &AccountTask::failed, this, &AccountList::authFailed); + m_currentTask->start(); + qDebug() << "RefreshSchedule: Processing account " << account->accountDisplayString() << " with internal ID " << accountId; + return; + } + } + } + qDebug() << "RefreshSchedule: Account with with internal ID " << accountId << " not found."; + } + endActivity(); + // if we get here, no account needed refreshing. Schedule refresh in an hour. + m_refreshTimer->start(std::chrono::hours(1)); +} + +void AccountList::authSucceeded() { + qDebug() << "RefreshSchedule: Background account refresh succeeded"; + m_currentTask.reset(); + endActivity(); + m_nextTimer->start(std::chrono::seconds(20)); +} + +void AccountList::authFailed(QString reason) { + qDebug() << "RefreshSchedule: Background account refresh failed: " << reason; + m_currentTask.reset(); + endActivity(); + m_nextTimer->start(std::chrono::seconds(20)); +} + +bool AccountList::isActive() const { + return m_activityCount != 0; +} + +void AccountList::beginActivity() { + bool activating = m_activityCount == 0; + m_activityCount++; + if(activating) { + emit activityChanged(true); + } +} + +void AccountList::endActivity() { + if(m_activityCount == 0) { + qWarning() << m_name << " - Activity count would become below zero"; + return; + } + bool deactivating = m_activityCount == 1; + m_activityCount--; + if(deactivating) { + emit activityChanged(false); + } +} diff --git a/launcher/minecraft/auth/AccountList.h b/launcher/minecraft/auth/AccountList.h index 08004628..75686c21 100644 --- a/launcher/minecraft/auth/AccountList.h +++ b/launcher/minecraft/auth/AccountList.h @@ -67,6 +67,8 @@ public: MinecraftAccountPtr getAccountByProfileName(const QString &profileName) const; QStringList profileNames() const; + void requestRefresh(QString accountId); + /*! * Sets the path to load/save the list file from/to. * If autosave is true, this list will automatically save to the given path whenever it changes. @@ -85,10 +87,20 @@ public: void setDefaultAccount(MinecraftAccountPtr profileId); bool anyAccountIsValid(); + bool isActive() const; + +protected: + void beginActivity(); + void endActivity(); + +private: + const char* m_name; + uint32_t m_activityCount = 0; signals: void listChanged(); void listActivityChanged(); void defaultAccountChanged(); + void activityChanged(bool active); public slots: /** @@ -101,7 +113,23 @@ public slots: */ void accountActivityChanged(bool active); + /** + * This is initially to run background account refresh tasks, or on a hourly timer + */ + void fillQueue(); + +private slots: + void tryNext(); + + void authSucceeded(); + void authFailed(QString reason); + protected: + QList m_refreshQueue; + QTimer *m_refreshTimer; + QTimer *m_nextTimer; + shared_qobject_ptr m_currentTask; + /*! * Called whenever the list changes. * This emits the listChanged() signal and autosaves the list (if autosave is enabled). diff --git a/launcher/minecraft/auth/AccountTask.cpp b/launcher/minecraft/auth/AccountTask.cpp index 25d753de..98d8d94d 100644 --- a/launcher/minecraft/auth/AccountTask.cpp +++ b/launcher/minecraft/auth/AccountTask.cpp @@ -28,40 +28,79 @@ AccountTask::AccountTask(AccountData *data, QObject *parent) : Task(parent), m_data(data) { - changeState(STATE_CREATED); + changeState(AccountTaskState::STATE_CREATED); } QString AccountTask::getStateMessage() const { - switch (m_accountState) + switch (m_taskState) { - case STATE_CREATED: + case AccountTaskState::STATE_CREATED: return "Waiting..."; - case STATE_WORKING: + case AccountTaskState::STATE_WORKING: return tr("Sending request to auth servers..."); - case STATE_SUCCEEDED: + case AccountTaskState::STATE_SUCCEEDED: return tr("Authentication task succeeded."); - case STATE_FAILED_SOFT: + case AccountTaskState::STATE_OFFLINE: return tr("Failed to contact the authentication server."); - case STATE_FAILED_HARD: - return tr("Failed to authenticate."); - case STATE_FAILED_GONE: + case AccountTaskState::STATE_FAILED_SOFT: + return tr("Encountered an error during authentication."); + case AccountTaskState::STATE_FAILED_HARD: + return tr("Failed to authenticate. The session has expired."); + case AccountTaskState::STATE_FAILED_GONE: return tr("Failed to authenticate. The account no longer exists."); default: return tr("..."); } } -void AccountTask::changeState(AccountTask::State newState, QString reason) +bool AccountTask::changeState(AccountTaskState newState, QString reason) { - m_accountState = newState; + m_taskState = newState; setStatus(getStateMessage()); - if (newState == STATE_SUCCEEDED) - { - emitSucceeded(); - } - else if (newState == STATE_FAILED_HARD || newState == STATE_FAILED_SOFT || newState == STATE_FAILED_GONE) - { - emitFailed(reason); + switch(newState) { + case AccountTaskState::STATE_CREATED: { + m_data->errorString.clear(); + return true; + } + case AccountTaskState::STATE_WORKING: { + m_data->accountState = AccountState::Working; + return true; + } + case AccountTaskState::STATE_SUCCEEDED: { + m_data->accountState = AccountState::Online; + emitSucceeded(); + return false; + } + case AccountTaskState::STATE_OFFLINE: { + m_data->errorString = reason; + m_data->accountState = AccountState::Offline; + emitFailed(reason); + return false; + } + case AccountTaskState::STATE_FAILED_SOFT: { + m_data->errorString = reason; + m_data->accountState = AccountState::Errored; + emitFailed(reason); + return false; + } + case AccountTaskState::STATE_FAILED_HARD: { + m_data->errorString = reason; + m_data->accountState = AccountState::Expired; + emitFailed(reason); + return false; + } + case AccountTaskState::STATE_FAILED_GONE: { + m_data->errorString = reason; + m_data->accountState = AccountState::Gone; + emitFailed(reason); + return false; + } + default: { + QString error = tr("Unknown account task state: %1").arg(int(newState)); + m_data->accountState = AccountState::Errored; + emitFailed(error); + return false; + } } } diff --git a/launcher/minecraft/auth/AccountTask.h b/launcher/minecraft/auth/AccountTask.h index 4f3bd52a..dac3f1b5 100644 --- a/launcher/minecraft/auth/AccountTask.h +++ b/launcher/minecraft/auth/AccountTask.h @@ -26,62 +26,32 @@ class QNetworkReply; +/** + * Enum for describing the state of the current task. + * Used by the getStateMessage function to determine what the status message should be. + */ +enum class AccountTaskState +{ + STATE_CREATED, + STATE_WORKING, + STATE_SUCCEEDED, + STATE_FAILED_SOFT, //!< soft failure. authentication went through partially + STATE_FAILED_HARD, //!< hard failure. main tokens are invalid + STATE_FAILED_GONE, //!< hard failure. main tokens are invalid, and the account no longer exists + STATE_OFFLINE //!< soft failure. authentication failed in the first step in a 'soft' way +}; + class AccountTask : public Task { - friend class AuthContext; Q_OBJECT public: explicit AccountTask(AccountData * data, QObject *parent = 0); virtual ~AccountTask() {}; - /** - * assign a session to this task. the session will be filled with required infomration - * upon completion - */ - void assignSession(AuthSessionPtr session) - { - m_session = session; - } - - /// get the assigned session for filling with information. - AuthSessionPtr getAssignedSession() - { - return m_session; - } - - /** - * Class describing a Account error response. - */ - struct Error - { - QString m_errorMessageShort; - QString m_errorMessageVerbose; - QString m_cause; - }; - - enum AbortedBy - { - BY_NOTHING, - BY_USER, - BY_TIMEOUT - } m_aborted = BY_NOTHING; - - /** - * Enum for describing the state of the current task. - * Used by the getStateMessage function to determine what the status message should be. - */ - enum State - { - STATE_CREATED, - STATE_WORKING, - STATE_FAILED_SOFT, //!< soft failure. this generally means the user auth details haven't been invalidated - STATE_FAILED_HARD, //!< hard failure. auth is invalid - STATE_FAILED_GONE, //!< hard failure. auth is invalid, and the account no longer exists - STATE_SUCCEEDED - } m_accountState = STATE_CREATED; + AccountTaskState m_taskState = AccountTaskState::STATE_CREATED; - State accountState() { - return m_accountState; + AccountTaskState taskState() { + return m_taskState; } signals: @@ -98,11 +68,9 @@ protected: virtual QString getStateMessage() const; protected slots: - void changeState(State newState, QString reason=QString()); + // NOTE: true -> non-terminal state, false -> terminal state + bool changeState(AccountTaskState newState, QString reason = QString()); protected: - // FIXME: segfault disaster waiting to happen AccountData *m_data = nullptr; - std::shared_ptr m_error; - AuthSessionPtr m_session; }; diff --git a/launcher/minecraft/auth/AuthRequest.cpp b/launcher/minecraft/auth/AuthRequest.cpp new file mode 100644 index 00000000..459d2354 --- /dev/null +++ b/launcher/minecraft/auth/AuthRequest.cpp @@ -0,0 +1,125 @@ +#include + +#include +#include +#include +#include + +#include "Application.h" +#include "AuthRequest.h" +#include "katabasis/Globals.h" + +AuthRequest::AuthRequest(QObject *parent): QObject(parent) { +} + +AuthRequest::~AuthRequest() { +} + +void AuthRequest::get(const QNetworkRequest &req, int timeout/* = 60*1000*/) { + setup(req, QNetworkAccessManager::GetOperation); + reply_ = APPLICATION->network()->get(request_); + status_ = Requesting; + timedReplies_.add(new Katabasis::Reply(reply_, timeout)); + connect(reply_, SIGNAL(error(QNetworkReply::NetworkError)), this, SLOT(onRequestError(QNetworkReply::NetworkError))); + connect(reply_, SIGNAL(finished()), this, SLOT(onRequestFinished())); + connect(reply_, &QNetworkReply::sslErrors, this, &AuthRequest::onSslErrors); +} + +void AuthRequest::post(const QNetworkRequest &req, const QByteArray &data, int timeout/* = 60*1000*/) { + setup(req, QNetworkAccessManager::PostOperation); + data_ = data; + status_ = Requesting; + reply_ = APPLICATION->network()->post(request_, data_); + timedReplies_.add(new Katabasis::Reply(reply_, timeout)); + connect(reply_, SIGNAL(error(QNetworkReply::NetworkError)), this, SLOT(onRequestError(QNetworkReply::NetworkError))); + connect(reply_, SIGNAL(finished()), this, SLOT(onRequestFinished())); + connect(reply_, &QNetworkReply::sslErrors, this, &AuthRequest::onSslErrors); + connect(reply_, SIGNAL(uploadProgress(qint64,qint64)), this, SLOT(onUploadProgress(qint64,qint64))); +} + +void AuthRequest::onRequestFinished() { + if (status_ == Idle) { + return; + } + if (reply_ != qobject_cast(sender())) { + return; + } + httpStatus_ = 200; + finish(); +} + +void AuthRequest::onRequestError(QNetworkReply::NetworkError error) { + qWarning() << "AuthRequest::onRequestError: Error" << (int)error; + if (status_ == Idle) { + return; + } + if (reply_ != qobject_cast(sender())) { + return; + } + errorString_ = reply_->errorString(); + httpStatus_ = reply_->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + error_ = error; + qWarning() << "AuthRequest::onRequestError: Error string: " << errorString_; + qWarning() << "AuthRequest::onRequestError: HTTP status" << httpStatus_ << reply_->attribute(QNetworkRequest::HttpReasonPhraseAttribute).toString(); + + // QTimer::singleShot(10, this, SLOT(finish())); +} + +void AuthRequest::onSslErrors(QList 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 AuthRequest::onUploadProgress(qint64 uploaded, qint64 total) { + if (status_ == Idle) { + qWarning() << "AuthRequest::onUploadProgress: No pending request"; + return; + } + if (reply_ != qobject_cast(sender())) { + return; + } + // Restart timeout because request in progress + Katabasis::Reply *o2Reply = timedReplies_.find(reply_); + if(o2Reply) { + o2Reply->start(); + } + emit uploadProgress(uploaded, total); +} + +void AuthRequest::setup(const QNetworkRequest &req, QNetworkAccessManager::Operation operation, const QByteArray &verb) { + request_ = req; + operation_ = operation; + url_ = req.url(); + + QUrl url = url_; + request_.setUrl(url); + + if (!verb.isEmpty()) { + request_.setRawHeader(Katabasis::HTTP_HTTP_HEADER, verb); + } + + status_ = Requesting; + error_ = QNetworkReply::NoError; + errorString_.clear(); + httpStatus_ = 0; +} + +void AuthRequest::finish() { + QByteArray data; + if (status_ == Idle) { + qWarning() << "AuthRequest::finish: No pending request"; + return; + } + data = reply_->readAll(); + status_ = Idle; + timedReplies_.remove(reply_); + reply_->disconnect(this); + reply_->deleteLater(); + QList headers = reply_->rawHeaderPairs(); + emit finished(error_, data, headers); +} diff --git a/launcher/minecraft/auth/AuthRequest.h b/launcher/minecraft/auth/AuthRequest.h new file mode 100644 index 00000000..89f7a123 --- /dev/null +++ b/launcher/minecraft/auth/AuthRequest.h @@ -0,0 +1,70 @@ +#pragma once +#include +#include +#include +#include +#include +#include + +#include "katabasis/Reply.h" + +/// Makes authentication requests. +class AuthRequest: public QObject { + Q_OBJECT + +public: + explicit AuthRequest(QObject *parent = 0); + ~AuthRequest(); + +public slots: + void get(const QNetworkRequest &req, int timeout = 60*1000); + void post(const QNetworkRequest &req, const QByteArray &data, int timeout = 60*1000); + + +signals: + + /// Emitted when a request has been completed or failed. + void finished(QNetworkReply::NetworkError error, QByteArray data, QList headers); + + /// Emitted when an upload has progressed. + void uploadProgress(qint64 bytesSent, qint64 bytesTotal); + +protected slots: + + /// Handle request finished. + void onRequestFinished(); + + /// Handle request error. + void onRequestError(QNetworkReply::NetworkError error); + + /// Handle ssl errors. + void onSslErrors(QList errors); + + /// Finish the request, emit finished() signal. + void finish(); + + /// Handle upload progress. + void onUploadProgress(qint64 uploaded, qint64 total); + +public: + QNetworkReply::NetworkError error_; + int httpStatus_ = 0; + QString errorString_; + +protected: + void setup(const QNetworkRequest &request, QNetworkAccessManager::Operation operation, const QByteArray &verb = QByteArray()); + + enum Status { + Idle, Requesting, ReRequesting + }; + + QNetworkRequest request_; + QByteArray data_; + QNetworkReply *reply_; + Status status_; + QNetworkAccessManager::Operation operation_; + QUrl url_; + Katabasis::ReplyList timedReplies_; + + QTimer *timer_; +}; diff --git a/launcher/minecraft/auth/AuthStep.cpp b/launcher/minecraft/auth/AuthStep.cpp new file mode 100644 index 00000000..ffa2581b --- /dev/null +++ b/launcher/minecraft/auth/AuthStep.cpp @@ -0,0 +1,7 @@ +#include "AuthStep.h" + +AuthStep::AuthStep(AccountData *data) : QObject(nullptr), m_data(data) { +} + +AuthStep::~AuthStep() noexcept = default; + diff --git a/launcher/minecraft/auth/AuthStep.h b/launcher/minecraft/auth/AuthStep.h new file mode 100644 index 00000000..2a8dc2ca --- /dev/null +++ b/launcher/minecraft/auth/AuthStep.h @@ -0,0 +1,33 @@ +#pragma once +#include +#include +#include + +#include "QObjectPtr.h" +#include "minecraft/auth/AccountData.h" +#include "AccountTask.h" + +class AuthStep : public QObject { + Q_OBJECT + +public: + using Ptr = shared_qobject_ptr; + +public: + explicit AuthStep(AccountData *data); + virtual ~AuthStep() noexcept; + + virtual QString describe() = 0; + +public slots: + virtual void perform() = 0; + virtual void rehydrate() = 0; + +signals: + void finished(AccountTaskState resultingState, QString message); + void showVerificationUriAndCode(const QUrl &uri, const QString &code, int expiresIn); + void hideVerificationUriAndCode(); + +protected: + AccountData *m_data; +}; diff --git a/launcher/minecraft/auth/MinecraftAccount.cpp b/launcher/minecraft/auth/MinecraftAccount.cpp index 30ed6afe..7ce87a3d 100644 --- a/launcher/minecraft/auth/MinecraftAccount.cpp +++ b/launcher/minecraft/auth/MinecraftAccount.cpp @@ -16,7 +16,6 @@ */ #include "MinecraftAccount.h" -#include "flows/AuthContext.h" #include #include @@ -28,14 +27,12 @@ #include #include -#include "flows/MSASilent.h" -#include "flows/MSAInteractive.h" -#include "flows/MojangRefresh.h" -#include "flows/MojangLogin.h" +#include "flows/MSA.h" +#include "flows/Mojang.h" MinecraftAccount::MinecraftAccount(QObject* parent) : QObject(parent) { - m_internalId = QUuid::createUuid().toString().remove(QRegExp("[{}-]")); + data.internalId = QUuid::createUuid().toString().remove(QRegExp("[{}-]")); } @@ -77,42 +74,10 @@ QJsonObject MinecraftAccount::saveToJson() const return data.saveState(); } -AccountStatus MinecraftAccount::accountStatus() const { - if(data.type == AccountType::Mojang) { - if (data.accessToken().isEmpty()) { - return NotVerified; - } - else { - return Verified; - } - } - // MSA - // FIXME: this is extremely crude and probably wrong - if(data.msaToken.token.isEmpty()) { - return NotVerified; - } - else { - return Verified; - } -} - -bool MinecraftAccount::isExpired() const { - switch(data.type) { - case AccountType::Mojang: { - return data.accessToken().isEmpty(); - } - break; - case AccountType::MSA: { - return data.msaToken.validity == Katabasis::Validity::None; - } - break; - default: { - return true; - } - } +AccountState MinecraftAccount::accountState() const { + return data.accountState; } - QPixmap MinecraftAccount::getFace() const { QPixmap skinTexture; if(!skinTexture.loadFromData(data.minecraftProfile.skin.data, "PNG")) { @@ -126,136 +91,51 @@ QPixmap MinecraftAccount::getFace() const { } -shared_qobject_ptr MinecraftAccount::login(AuthSessionPtr session, QString password) -{ +shared_qobject_ptr MinecraftAccount::login(QString password) { Q_ASSERT(m_currentTask.get() == nullptr); - // take care of the true offline status - if (accountStatus() == NotVerified && password.isEmpty()) - { - if (session) - { - session->status = AuthSession::RequiresPassword; - fillSession(session); - } - return nullptr; - } - - if(accountStatus() == Verified && !session->wants_online) - { - session->status = AuthSession::PlayableOffline; - session->auth_server_online = false; - fillSession(session); - return nullptr; - } - else - { - if (password.isEmpty()) - { - m_currentTask.reset(new MojangRefresh(&data)); - } - else - { - m_currentTask.reset(new MojangLogin(&data, password)); - } - m_currentTask->assignSession(session); - - connect(m_currentTask.get(), SIGNAL(succeeded()), SLOT(authSucceeded())); - connect(m_currentTask.get(), SIGNAL(failed(QString)), SLOT(authFailed(QString))); - emit activityChanged(true); - } + m_currentTask.reset(new MojangLogin(&data, password)); + connect(m_currentTask.get(), SIGNAL(succeeded()), SLOT(authSucceeded())); + connect(m_currentTask.get(), SIGNAL(failed(QString)), SLOT(authFailed(QString))); + emit activityChanged(true); return m_currentTask; } -shared_qobject_ptr MinecraftAccount::loginMSA(AuthSessionPtr session) { +shared_qobject_ptr MinecraftAccount::loginMSA() { Q_ASSERT(m_currentTask.get() == nullptr); - if(accountStatus() == Verified && !session->wants_online) - { - session->status = AuthSession::PlayableOffline; - session->auth_server_online = false; - fillSession(session); - return nullptr; - } - else - { - m_currentTask.reset(new MSAInteractive(&data)); - m_currentTask->assignSession(session); - - connect(m_currentTask.get(), SIGNAL(succeeded()), SLOT(authSucceeded())); - connect(m_currentTask.get(), SIGNAL(failed(QString)), SLOT(authFailed(QString))); - emit activityChanged(true); - } + m_currentTask.reset(new MSAInteractive(&data)); + connect(m_currentTask.get(), SIGNAL(succeeded()), SLOT(authSucceeded())); + connect(m_currentTask.get(), SIGNAL(failed(QString)), SLOT(authFailed(QString))); + emit activityChanged(true); return m_currentTask; } -shared_qobject_ptr MinecraftAccount::refresh(AuthSessionPtr session) { - Q_ASSERT(m_currentTask.get() == nullptr); - - // take care of the true offline status - if (accountStatus() == NotVerified) - { - if (session) - { - if(data.type == AccountType::MSA) { - session->status = AuthSession::RequiresOAuth; - } - else { - session->status = AuthSession::RequiresPassword; - } - fillSession(session); - } - return nullptr; +shared_qobject_ptr MinecraftAccount::refresh() { + if(m_currentTask) { + return m_currentTask; } - if(accountStatus() == Verified && !session->wants_online) - { - session->status = AuthSession::PlayableOffline; - session->auth_server_online = false; - fillSession(session); - return nullptr; + if(data.type == AccountType::MSA) { + m_currentTask.reset(new MSASilent(&data)); } - else - { - if(data.type == AccountType::MSA) { - m_currentTask.reset(new MSASilent(&data)); - } - else { - m_currentTask.reset(new MojangRefresh(&data)); - } - m_currentTask->assignSession(session); - - connect(m_currentTask.get(), SIGNAL(succeeded()), SLOT(authSucceeded())); - connect(m_currentTask.get(), SIGNAL(failed(QString)), SLOT(authFailed(QString))); - emit activityChanged(true); + else { + m_currentTask.reset(new MojangRefresh(&data)); } + + connect(m_currentTask.get(), SIGNAL(succeeded()), SLOT(authSucceeded())); + connect(m_currentTask.get(), SIGNAL(failed(QString)), SLOT(authFailed(QString))); + emit activityChanged(true); + return m_currentTask; +} + +shared_qobject_ptr MinecraftAccount::currentTask() { return m_currentTask; } void MinecraftAccount::authSucceeded() { - auto session = m_currentTask->getAssignedSession(); - if (session) - { - /* - 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; - } m_currentTask.reset(); emit changed(); emit activityChanged(false); @@ -263,62 +143,35 @@ void MinecraftAccount::authSucceeded() void MinecraftAccount::authFailed(QString reason) { - auto session = m_currentTask->getAssignedSession(); - // This is emitted when the yggdrasil tasks time out or are cancelled. - // -> we treat the error as no-op - switch (m_currentTask->accountState()) { - case AccountTask::STATE_FAILED_SOFT: { - if (session) - { - if(accountStatus() == Verified) { - session->status = AuthSession::PlayableOffline; - } - else { - if(data.type == AccountType::MSA) { - session->status = AuthSession::RequiresOAuth; - } - else { - session->status = AuthSession::RequiresPassword; - } - } - session->auth_server_online = false; - fillSession(session); - } + switch (m_currentTask->taskState()) { + case AccountTaskState::STATE_OFFLINE: + case AccountTaskState::STATE_FAILED_SOFT: { + // NOTE: this doesn't do much. There was an error of some sort. } break; - case AccountTask::STATE_FAILED_HARD: { - // FIXME: MSA data clearing - data.yggdrasilToken.token = QString(); - data.yggdrasilToken.validity = Katabasis::Validity::None; - data.validity_ = Katabasis::Validity::None; - emit changed(); - if (session) - { - if(data.type == AccountType::MSA) { - session->status = AuthSession::RequiresOAuth; - } - else { - session->status = AuthSession::RequiresPassword; - } - session->auth_server_online = true; - fillSession(session); + case AccountTaskState::STATE_FAILED_HARD: { + if(isMSA()) { + data.msaToken.token = QString(); + data.msaToken.refresh_token = QString(); + data.msaToken.validity = Katabasis::Validity::None; + data.validity_ = Katabasis::Validity::None; + } + else { + data.yggdrasilToken.token = QString(); + data.yggdrasilToken.validity = Katabasis::Validity::None; + data.validity_ = Katabasis::Validity::None; } + emit changed(); } break; - case AccountTask::STATE_FAILED_GONE: { + case AccountTaskState::STATE_FAILED_GONE: { data.validity_ = Katabasis::Validity::None; emit changed(); - if (session) - { - session->status = AuthSession::GoneOrMigrated; - session->auth_server_online = true; - fillSession(session); - } } break; - case AccountTask::STATE_CREATED: - case AccountTask::STATE_WORKING: - case AccountTask::STATE_SUCCEEDED: { + case AccountTaskState::STATE_CREATED: + case AccountTaskState::STATE_WORKING: + case AccountTaskState::STATE_SUCCEEDED: { // Not reachable here, as they are not failures. } } @@ -366,6 +219,18 @@ bool MinecraftAccount::shouldRefresh() const { void MinecraftAccount::fillSession(AuthSessionPtr session) { + if(ownsMinecraft() && !hasProfile()) { + session->status = AuthSession::RequiresProfileSetup; + } + else { + if(session->wants_online) { + session->status = AuthSession::PlayableOnline; + } + else { + session->status = AuthSession::PlayableOffline; + } + } + // the user name. you have to have an user name // FIXME: not with MSA session->username = data.userName(); diff --git a/launcher/minecraft/auth/MinecraftAccount.h b/launcher/minecraft/auth/MinecraftAccount.h index 459ef903..18f142c4 100644 --- a/launcher/minecraft/auth/MinecraftAccount.h +++ b/launcher/minecraft/auth/MinecraftAccount.h @@ -24,6 +24,7 @@ #include #include + #include "AuthSession.h" #include "Usable.h" #include "AccountData.h" @@ -50,12 +51,6 @@ struct AccountProfile bool legacy; }; -enum AccountStatus -{ - NotVerified, - Verified -}; - /** * Object that stores information about a certain Mojang account. * @@ -90,15 +85,17 @@ public: /* manipulation */ * Attempt to login. Empty password means we use the token. * If the attempt fails because we already are performing some task, it returns false. */ - shared_qobject_ptr login(AuthSessionPtr session, QString password); + shared_qobject_ptr login(QString password); - shared_qobject_ptr loginMSA(AuthSessionPtr session); + shared_qobject_ptr loginMSA(); - shared_qobject_ptr refresh(AuthSessionPtr session); + shared_qobject_ptr refresh(); + + shared_qobject_ptr currentTask(); public: /* queries */ QString internalId() const { - return m_internalId; + return data.internalId; } QString accountDisplayString() const { @@ -123,8 +120,6 @@ public: /* queries */ bool isActive() const; - bool isExpired() const; - bool canMigrate() const { return data.canMigrateToMSA; } @@ -133,6 +128,14 @@ public: /* queries */ return data.type == AccountType::MSA; } + bool ownsMinecraft() const { + return data.minecraftEntitlement.ownsMinecraft; + } + + bool hasProfile() const { + return data.profileId().size() != 0; + } + QString typeString() const { switch(data.type) { case AccountType::Mojang: { @@ -154,8 +157,8 @@ public: /* queries */ QPixmap getFace() const; - //! Returns whether the account is NotVerified, Verified or Online - AccountStatus accountStatus() const; + //! Returns the current state of the account + AccountState accountState() const; AccountData * accountData() { return &data; @@ -163,6 +166,8 @@ public: /* queries */ bool shouldRefresh() const; + void fillSession(AuthSessionPtr session); + signals: /** * This signal is emitted when the account changes @@ -174,7 +179,6 @@ signals: // TODO: better signalling for the various possible state changes - especially errors protected: /* variables */ - QString m_internalId; AccountData data; // current task we are executing here @@ -189,7 +193,4 @@ private slots: void authSucceeded(); void authFailed(QString reason); - -private: - void fillSession(AuthSessionPtr session); }; diff --git a/launcher/minecraft/auth/Parsers.cpp b/launcher/minecraft/auth/Parsers.cpp new file mode 100644 index 00000000..4cab78ef --- /dev/null +++ b/launcher/minecraft/auth/Parsers.cpp @@ -0,0 +1,316 @@ +#include "Parsers.h" + +#include +#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, QString 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/Parsers.h b/launcher/minecraft/auth/Parsers.h new file mode 100644 index 00000000..dac7f69b --- /dev/null +++ b/launcher/minecraft/auth/Parsers.h @@ -0,0 +1,19 @@ +#pragma once + +#include "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, QString 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/minecraft/auth/Yggdrasil.cpp b/launcher/minecraft/auth/Yggdrasil.cpp new file mode 100644 index 00000000..7ac842a6 --- /dev/null +++ b/launcher/minecraft/auth/Yggdrasil.cpp @@ -0,0 +1,331 @@ +/* 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 +#include +#include +#include +#include +#include + +#include + +#include "Application.h" + +Yggdrasil::Yggdrasil(AccountData *data, QObject *parent) + : AccountTask(data, parent) +{ + changeState(AccountTaskState::STATE_CREATED); +} + +void Yggdrasil::sendRequest(QUrl endpoint, QByteArray content) { + changeState(AccountTaskState::STATE_WORKING); + + QNetworkRequest netRequest(endpoint); + netRequest.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + m_netReply = APPLICATION->network()->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("https://authserver.mojang.com/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("https://authserver.mojang.com/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 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(AccountTaskState::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(AccountTaskState::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(AccountTaskState::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; + m_data->yggdrasilToken.issueInstant = QDateTime::currentDateTimeUtc(); + + // We've made it through the minefield of possible errors. Return true to indicate that + // we've succeeded. + qDebug() << "Finished reading authentication response."; + changeState(AccountTaskState::STATE_SUCCEEDED); +} + +void Yggdrasil::processReply() { + changeState(AccountTaskState::STATE_WORKING); + + switch (m_netReply->error()) + { + case QNetworkReply::NoError: + break; + case QNetworkReply::TimeoutError: + changeState(AccountTaskState::STATE_FAILED_SOFT, tr("Authentication operation timed out.")); + return; + case QNetworkReply::OperationCanceledError: + changeState(AccountTaskState::STATE_FAILED_SOFT, tr("Authentication operation cancelled.")); + return; + case QNetworkReply::SslHandshakeFailedError: + changeState( + AccountTaskState::STATE_FAILED_SOFT, + tr( + "SSL Handshake failed.
There might be a few causes for it:
" + "
    " + "
  • You use Windows and need to update your root certificates, please install any outstanding updates.
  • " + "
  • Some device on your network is interfering with SSL traffic. In that case, " + "you have bigger worries than Minecraft not starting.
  • " + "
  • Possibly something else. Check the log file for details
  • " + "
" + ) + ); + return; + // used for invalid credentials and similar errors. Fall through. + case QNetworkReply::ContentAccessDenied: + case QNetworkReply::ContentOperationNotPermittedError: + break; + case QNetworkReply::ContentGoneError: { + changeState( + AccountTaskState::STATE_FAILED_GONE, + tr("The Mojang account no longer exists. It may have been migrated to a Microsoft account.") + ); + } + default: + changeState( + AccountTaskState::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 aut