GH-3392 Switch MS account login to use device flow instead

Device flow involves the user manually opening a web page and putting in
a code. We no longer need to interact with the browser.
This commit is contained in:
Petr Mrázek 2021-08-22 20:01:18 +02:00
parent 50b92c1af2
commit eae65da110
8 changed files with 96 additions and 42 deletions

View File

@ -41,6 +41,9 @@ int MSALoginDialog::exec() {
connect(m_loginTask.get(), &Task::succeeded, this, &MSALoginDialog::onTaskSucceeded); connect(m_loginTask.get(), &Task::succeeded, this, &MSALoginDialog::onTaskSucceeded);
connect(m_loginTask.get(), &Task::status, this, &MSALoginDialog::onTaskStatus); connect(m_loginTask.get(), &Task::status, this, &MSALoginDialog::onTaskStatus);
connect(m_loginTask.get(), &Task::progress, this, &MSALoginDialog::onTaskProgress); connect(m_loginTask.get(), &Task::progress, this, &MSALoginDialog::onTaskProgress);
connect(m_loginTask.get(), &AccountTask::showVerificationUriAndCode, this, &MSALoginDialog::showVerificationUriAndCode);
connect(m_loginTask.get(), &AccountTask::hideVerificationUriAndCode, this, &MSALoginDialog::hideVerificationUriAndCode);
connect(&m_externalLoginTimer, &QTimer::timeout, this, &MSALoginDialog::externalLoginTick);
m_loginTask->start(); m_loginTask->start();
return QDialog::exec(); return QDialog::exec();
@ -52,6 +55,37 @@ MSALoginDialog::~MSALoginDialog()
delete ui; delete ui;
} }
void MSALoginDialog::externalLoginTick() {
m_externalLoginElapsed++;
ui->progressBar->setValue(m_externalLoginElapsed);
ui->progressBar->repaint();
if(m_externalLoginElapsed >= m_externalLoginTimeout) {
m_externalLoginTimer.stop();
}
}
void MSALoginDialog::showVerificationUriAndCode(const QUrl& uri, const QString& code, int expiresIn) {
m_externalLoginElapsed = 0;
m_externalLoginTimeout = expiresIn;
m_externalLoginTimer.setInterval(1000);
m_externalLoginTimer.setSingleShot(false);
m_externalLoginTimer.start();
ui->progressBar->setMaximum(expiresIn);
ui->progressBar->setValue(m_externalLoginElapsed);
QString urlString = uri.toString();
QString linkString = QString("<a href=\"%1\">%2</a>").arg(urlString, urlString);
ui->label->setText(tr("<p>Please open up %1 in a browser and put in the code <b>%2</b> to proceed with login.</p>").arg(linkString, code));
}
void MSALoginDialog::hideVerificationUriAndCode() {
m_externalLoginTimer.stop();
}
void MSALoginDialog::setUserInputsEnabled(bool enable) void MSALoginDialog::setUserInputsEnabled(bool enable)
{ {
ui->buttonBox->setEnabled(enable); ui->buttonBox->setEnabled(enable);

View File

@ -17,6 +17,7 @@
#include <QtWidgets/QDialog> #include <QtWidgets/QDialog>
#include <QtCore/QEventLoop> #include <QtCore/QEventLoop>
#include <QTimer>
#include "minecraft/auth/MinecraftAccount.h" #include "minecraft/auth/MinecraftAccount.h"
@ -46,10 +47,17 @@ slots:
void onTaskSucceeded(); void onTaskSucceeded();
void onTaskStatus(const QString &status); void onTaskStatus(const QString &status);
void onTaskProgress(qint64 current, qint64 total); void onTaskProgress(qint64 current, qint64 total);
void showVerificationUriAndCode(const QUrl &uri, const QString &code, int expiresIn);
void hideVerificationUriAndCode();
void externalLoginTick();
private: private:
Ui::MSALoginDialog *ui; Ui::MSALoginDialog *ui;
MinecraftAccountPtr m_account; MinecraftAccountPtr m_account;
std::shared_ptr<Task> m_loginTask; std::shared_ptr<AccountTask> m_loginTask;
QTimer m_externalLoginTimer;
int m_externalLoginElapsed = 0;
int m_externalLoginTimeout = 0;
}; };

View File

@ -30,6 +30,9 @@ aaaaa</string>
<property name="textFormat"> <property name="textFormat">
<enum>Qt::RichText</enum> <enum>Qt::RichText</enum>
</property> </property>
<property name="openExternalLinks">
<bool>true</bool>
</property>
<property name="textInteractionFlags"> <property name="textInteractionFlags">
<set>Qt::LinksAccessibleByKeyboard|Qt::LinksAccessibleByMouse|Qt::TextBrowserInteraction|Qt::TextSelectableByKeyboard|Qt::TextSelectableByMouse</set> <set>Qt::LinksAccessibleByKeyboard|Qt::LinksAccessibleByMouse|Qt::TextBrowserInteraction|Qt::TextSelectableByKeyboard|Qt::TextSelectableByMouse</set>
</property> </property>

View File

@ -83,6 +83,10 @@ public:
return m_accountState; return m_accountState;
} }
signals:
void showVerificationUriAndCode(const QUrl &uri, const QString &code, int expiresIn);
void hideVerificationUriAndCode();
protected: protected:
/** /**

View File

@ -43,7 +43,7 @@ void AuthContext::finishActivity() {
throw 0; throw 0;
} }
m_activity = Katabasis::Activity::Idle; m_activity = Katabasis::Activity::Idle;
m_stage = MSAStage::Idle; setStage(AuthStage::Complete);
m_data->validity_ = m_data->minecraftProfile.validity; m_data->validity_ = m_data->minecraftProfile.validity;
emit activityChanged(m_activity); emit activityChanged(m_activity);
} }
@ -55,16 +55,16 @@ void AuthContext::initMSA() {
Katabasis::OAuth2::Options opts; Katabasis::OAuth2::Options opts;
opts.scope = "XboxLive.signin offline_access"; opts.scope = "XboxLive.signin offline_access";
opts.clientIdentifier = BuildConfig.MSA_CLIENT_ID; opts.clientIdentifier = BuildConfig.MSA_CLIENT_ID;
opts.authorizationUrl = "https://login.live.com/oauth20_authorize.srf"; opts.authorizationUrl = "https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode";
opts.accessTokenUrl = "https://login.live.com/oauth20_token.srf"; opts.accessTokenUrl = "https://login.microsoftonline.com/consumers/oauth2/v2.0/token";
opts.listenerPorts = {28562, 28563, 28564, 28565, 28566}; opts.listenerPorts = {28562, 28563, 28564, 28565, 28566};
m_oauth2 = new OAuth2(opts, m_data->msaToken, this, mgr); m_oauth2 = new OAuth2(opts, m_data->msaToken, this, mgr);
m_oauth2->setGrantFlow(Katabasis::OAuth2::GrantFlowDevice);
connect(m_oauth2, &OAuth2::linkingFailed, this, &AuthContext::onOAuthLinkingFailed); connect(m_oauth2, &OAuth2::linkingFailed, this, &AuthContext::onOAuthLinkingFailed);
connect(m_oauth2, &OAuth2::linkingSucceeded, this, &AuthContext::onOAuthLinkingSucceeded); connect(m_oauth2, &OAuth2::linkingSucceeded, this, &AuthContext::onOAuthLinkingSucceeded);
connect(m_oauth2, &OAuth2::openBrowser, this, &AuthContext::onOpenBrowser); connect(m_oauth2, &OAuth2::showVerificationUriAndCode, this, &AuthContext::showVerificationUriAndCode);
connect(m_oauth2, &OAuth2::closeBrowser, this, &AuthContext::onCloseBrowser);
connect(m_oauth2, &OAuth2::activityChanged, this, &AuthContext::onOAuthActivityChanged); connect(m_oauth2, &OAuth2::activityChanged, this, &AuthContext::onOAuthActivityChanged);
} }
@ -106,20 +106,14 @@ bool AuthContext::signOut() {
} }
*/ */
void AuthContext::onOpenBrowser(const QUrl &url) {
QDesktopServices::openUrl(url);
}
void AuthContext::onCloseBrowser() {
}
void AuthContext::onOAuthLinkingFailed() { void AuthContext::onOAuthLinkingFailed() {
emit hideVerificationUriAndCode();
finishActivity(); finishActivity();
changeState(STATE_FAILED_HARD, tr("Microsoft user authentication failed.")); changeState(STATE_FAILED_HARD, tr("Microsoft user authentication failed."));
} }
void AuthContext::onOAuthLinkingSucceeded() { void AuthContext::onOAuthLinkingSucceeded() {
emit hideVerificationUriAndCode();
auto *o2t = qobject_cast<OAuth2 *>(sender()); auto *o2t = qobject_cast<OAuth2 *>(sender());
if (!o2t->linked()) { if (!o2t->linked()) {
finishActivity(); finishActivity();
@ -143,7 +137,7 @@ void AuthContext::onOAuthActivityChanged(Katabasis::Activity activity) {
} }
void AuthContext::doUserAuth() { void AuthContext::doUserAuth() {
m_stage = MSAStage::UserAuth; setStage(AuthStage::UserAuth);
changeState(STATE_WORKING, tr("Starting user authentication")); changeState(STATE_WORKING, tr("Starting user authentication"));
QString xbox_auth_template = R"XXX( QString xbox_auth_template = R"XXX(
@ -307,7 +301,7 @@ void AuthContext::onUserAuthDone(
} }
m_data->userToken = temp; m_data->userToken = temp;
m_stage = MSAStage::XboxAuth; setStage(AuthStage::XboxAuth);
changeState(STATE_WORKING, tr("Starting XBox authentication")); changeState(STATE_WORKING, tr("Starting XBox authentication"));
doSTSAuthMinecraft(); doSTSAuthMinecraft();
@ -671,7 +665,7 @@ bool parseMinecraftProfile(QByteArray & data, MinecraftProfile &output) {
} }
void AuthContext::doMinecraftProfile() { void AuthContext::doMinecraftProfile() {
m_stage = MSAStage::MinecraftProfile; setStage(AuthStage::MinecraftProfile);
changeState(STATE_WORKING, tr("Starting minecraft profile acquisition")); changeState(STATE_WORKING, tr("Starting minecraft profile acquisition"));
auto url = QUrl("https://api.minecraftservices.com/minecraft/profile"); auto url = QUrl("https://api.minecraftservices.com/minecraft/profile");
@ -710,7 +704,7 @@ void AuthContext::onMinecraftProfileDone(int, QNetworkReply::NetworkError error,
} }
void AuthContext::doGetSkin() { void AuthContext::doGetSkin() {
m_stage = MSAStage::Skin; setStage(AuthStage::Skin);
changeState(STATE_WORKING, tr("Fetching player skin")); changeState(STATE_WORKING, tr("Fetching player skin"));
auto url = QUrl(m_data->minecraftProfile.skin.url); auto url = QUrl(m_data->minecraftProfile.skin.url);
@ -730,12 +724,18 @@ void AuthContext::onSkinDone(int, QNetworkReply::NetworkError error, QByteArray
changeState(STATE_SUCCEEDED, tr("Finished all authentication steps")); changeState(STATE_SUCCEEDED, tr("Finished all authentication steps"));
} }
void AuthContext::setStage(AuthContext::AuthStage stage) {
m_stage = stage;
emit progress((int)m_stage, (int)AuthStage::Complete);
}
QString AuthContext::getStateMessage() const { QString AuthContext::getStateMessage() const {
switch (m_accountState) switch (m_accountState)
{ {
case STATE_WORKING: case STATE_WORKING:
switch(m_stage) { switch(m_stage) {
case MSAStage::Idle: { case AuthStage::Initial: {
QString loginMessage = tr("Logging in as %1 user"); QString loginMessage = tr("Logging in as %1 user");
if(m_data->type == AccountType::MSA) { if(m_data->type == AccountType::MSA) {
return loginMessage.arg("Microsoft"); return loginMessage.arg("Microsoft");
@ -744,14 +744,16 @@ QString AuthContext::getStateMessage() const {
return loginMessage.arg("Mojang"); return loginMessage.arg("Mojang");
} }
} }
case MSAStage::UserAuth: case AuthStage::UserAuth:
return tr("Logging in as XBox user"); return tr("Logging in as XBox user");
case MSAStage::XboxAuth: case AuthStage::XboxAuth:
return tr("Logging in with XBox and Mojang services"); return tr("Logging in with XBox and Mojang services");
case MSAStage::MinecraftProfile: case AuthStage::MinecraftProfile:
return tr("Getting Minecraft profile"); return tr("Getting Minecraft profile");
case MSAStage::Skin: case AuthStage::Skin:
return tr("Getting Minecraft skin"); return tr("Getting Minecraft skin");
case AuthStage::Complete:
return tr("Finished");
default: default:
break; break;
} }

View File

@ -36,8 +36,7 @@ private slots:
// OAuth-specific callbacks // OAuth-specific callbacks
void onOAuthLinkingSucceeded(); void onOAuthLinkingSucceeded();
void onOAuthLinkingFailed(); void onOAuthLinkingFailed();
void onOpenBrowser(const QUrl &url);
void onCloseBrowser();
void onOAuthActivityChanged(Katabasis::Activity activity); void onOAuthActivityChanged(Katabasis::Activity activity);
// Yggdrasil specific callbacks // Yggdrasil specific callbacks
@ -82,13 +81,16 @@ protected:
bool m_xboxProfileSucceeded = false; bool m_xboxProfileSucceeded = false;
bool m_mcAuthSucceeded = false; bool m_mcAuthSucceeded = false;
Katabasis::Activity m_activity = Katabasis::Activity::Idle; Katabasis::Activity m_activity = Katabasis::Activity::Idle;
enum class MSAStage { enum class AuthStage {
Idle, Initial,
UserAuth, UserAuth,
XboxAuth, XboxAuth,
MinecraftProfile, MinecraftProfile,
Skin Skin,
} m_stage = MSAStage::Idle; Complete
} m_stage = AuthStage::Initial;
void setStage(AuthStage stage);
QNetworkAccessManager *mgr = nullptr; QNetworkAccessManager *mgr = nullptr;
}; };

View File

@ -140,7 +140,7 @@ signals:
void closeBrowser(); void closeBrowser();
/// Emitted when client needs to show a verification uri and user code /// Emitted when client needs to show a verification uri and user code
void showVerificationUriAndCode(const QUrl &uri, const QString &code); void showVerificationUriAndCode(const QUrl &uri, const QString &code, int expiresIn);
/// Emitted when authentication/deauthentication succeeded. /// Emitted when authentication/deauthentication succeeded.
void linkingSucceeded(); void linkingSucceeded();
@ -181,7 +181,7 @@ protected:
void setExpires(QDateTime v); void setExpires(QDateTime v);
/// Start polling authorization server /// Start polling authorization server
void startPollServer(const QVariantMap &params); void startPollServer(const QVariantMap &params, int expiresIn);
/// Set authentication token. /// Set authentication token.
void setToken(const QString &v); void setToken(const QString &v);

View File

@ -472,16 +472,8 @@ void OAuth2::setExpires(QDateTime v) {
token_.notAfter = v; token_.notAfter = v;
} }
void OAuth2::startPollServer(const QVariantMap &params) void OAuth2::startPollServer(const QVariantMap &params, int expiresIn)
{ {
bool ok = false;
int expiresIn = params[OAUTH2_EXPIRES_IN].toInt(&ok);
if (!ok) {
qWarning() << "OAuth2::startPollServer: No expired_in parameter";
emit linkingFailed();
return;
}
qDebug() << "OAuth2::startPollServer: device_ and user_code expires in" << expiresIn << "seconds"; qDebug() << "OAuth2::startPollServer: device_ and user_code expires in" << expiresIn << "seconds";
QUrl url(options_.accessTokenUrl); QUrl url(options_.accessTokenUrl);
@ -502,6 +494,7 @@ void OAuth2::startPollServer(const QVariantMap &params)
PollServer * pollServer = new PollServer(manager_, authRequest, payload, expiresIn, this); PollServer * pollServer = new PollServer(manager_, authRequest, payload, expiresIn, this);
if (params.contains(OAUTH2_INTERVAL)) { if (params.contains(OAUTH2_INTERVAL)) {
bool ok = false;
int interval = params[OAUTH2_INTERVAL].toInt(&ok); int interval = params[OAUTH2_INTERVAL].toInt(&ok);
if (ok) if (ok)
pollServer->setInterval(interval); pollServer->setInterval(interval);
@ -629,9 +622,17 @@ void OAuth2::onDeviceAuthReplyFinished()
if (params.contains(OAUTH2_VERIFICATION_URI_COMPLETE)) if (params.contains(OAUTH2_VERIFICATION_URI_COMPLETE))
emit openBrowser(params.take(OAUTH2_VERIFICATION_URI_COMPLETE).toUrl()); emit openBrowser(params.take(OAUTH2_VERIFICATION_URI_COMPLETE).toUrl());
emit showVerificationUriAndCode(uri, userCode); bool ok = false;
int expiresIn = params[OAUTH2_EXPIRES_IN].toInt(&ok);
if (!ok) {
qWarning() << "OAuth2::startPollServer: No expired_in parameter";
emit linkingFailed();
return;
}
startPollServer(params); emit showVerificationUriAndCode(uri, userCode, expiresIn);
startPollServer(params, expiresIn);
} else { } else {
qWarning() << "OAuth2::onDeviceAuthReplyFinished: Mandatory parameters missing from response"; qWarning() << "OAuth2::onDeviceAuthReplyFinished: Mandatory parameters missing from response";
emit linkingFailed(); emit linkingFailed();