diff --git a/CMakeLists.txt b/CMakeLists.txt index 5e3d6cea0..be6f7dfef 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -278,6 +278,7 @@ add_subdirectory(libraries/LocalPeer) # fork of a library from Qt solutions add_subdirectory(libraries/classparser) # google analytics library add_subdirectory(libraries/optional-bare) add_subdirectory(libraries/tomlc99) # toml parser +add_subdirectory(libraries/katabasis) # An OAuth2 library that tried to do too much ############################### Built Artifacts ############################### diff --git a/COPYING.md b/COPYING.md index caa4bed55..4c19bbc22 100644 --- a/COPYING.md +++ b/COPYING.md @@ -254,25 +254,51 @@ # tomlc99 - MIT License + MIT License - Copyright (c) 2017 CK Tan - https://github.com/cktan/tomlc99 + Copyright (c) 2017 CK Tan + https://github.com/cktan/tomlc99 - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to deal - in the Software without restriction, including without limitation the rights - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: - The above copyright notice and this permission notice shall be included in all - copies or substantial portions of the Software. + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - SOFTWARE. + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. + +# O2 (Katabasis fork) + + Copyright (c) 2012, Akos Polster + All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/api/logic/minecraft/auth-msa/BuildConfig.cpp.in b/api/logic/minecraft/auth-msa/BuildConfig.cpp.in new file mode 100644 index 000000000..8f470e25e --- /dev/null +++ b/api/logic/minecraft/auth-msa/BuildConfig.cpp.in @@ -0,0 +1,9 @@ +#include "BuildConfig.h" +#include + +const Config BuildConfig; + +Config::Config() +{ + CLIENT_ID = "@MOJANGDEMO_CLIENT_ID@"; +} diff --git a/api/logic/minecraft/auth-msa/BuildConfig.h b/api/logic/minecraft/auth-msa/BuildConfig.h new file mode 100644 index 000000000..7a01d704e --- /dev/null +++ b/api/logic/minecraft/auth-msa/BuildConfig.h @@ -0,0 +1,11 @@ +#pragma once +#include + +class Config +{ +public: + Config(); + QString CLIENT_ID; +}; + +extern const Config BuildConfig; diff --git a/api/logic/minecraft/auth-msa/CMakeLists.txt b/api/logic/minecraft/auth-msa/CMakeLists.txt new file mode 100644 index 000000000..22777d1b3 --- /dev/null +++ b/api/logic/minecraft/auth-msa/CMakeLists.txt @@ -0,0 +1,28 @@ +find_package(Qt5 COMPONENTS Core Gui Network Widgets REQUIRED) + +set(CMAKE_AUTOMOC ON) +set(CMAKE_AUTOUIC ON) +set(CMAKE_INCLUDE_CURRENT_DIR ON) +set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Wall") + + +set(MOJANGDEMO_CLIENT_ID "" CACHE STRING "Client ID used for OAuth2 in mojangdemo") + +configure_file("${CMAKE_CURRENT_SOURCE_DIR}/BuildConfig.cpp.in" "${CMAKE_CURRENT_BINARY_DIR}/BuildConfig.cpp") + +set(mojang_SRCS + main.cpp + context.cpp + context.h + + mainwindow.cpp + mainwindow.h + mainwindow.ui + + ${CMAKE_CURRENT_BINARY_DIR}/BuildConfig.cpp + BuildConfig.h +) + +add_executable( mojangdemo ${mojang_SRCS} ) +target_link_libraries( mojangdemo Katabasis Qt5::Gui Qt5::Widgets ) +target_include_directories(mojangdemo PRIVATE logic) diff --git a/api/logic/minecraft/auth-msa/context.cpp b/api/logic/minecraft/auth-msa/context.cpp new file mode 100644 index 000000000..d7ecda30c --- /dev/null +++ b/api/logic/minecraft/auth-msa/context.cpp @@ -0,0 +1,938 @@ +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include + +#include +#include + +#include "context.h" +#include "katabasis/Globals.h" +#include "katabasis/StoreQSettings.h" +#include "katabasis/Requestor.h" +#include "BuildConfig.h" + +using OAuth2 = Katabasis::OAuth2; +using Requestor = Katabasis::Requestor; +using Activity = Katabasis::Activity; + +Context::Context(QObject *parent) : + QObject(parent) +{ + mgr = new QNetworkAccessManager(this); + + Katabasis::OAuth2::Options opts; + opts.scope = "XboxLive.signin offline_access"; + opts.clientIdentifier = BuildConfig.CLIENT_ID; + opts.authorizationUrl = "https://login.live.com/oauth20_authorize.srf"; + opts.accessTokenUrl = "https://login.live.com/oauth20_token.srf"; + opts.listenerPorts = {28562, 28563, 28564, 28565, 28566}; + + oauth2 = new OAuth2(opts, m_account.msaToken, this, mgr); + + connect(oauth2, &OAuth2::linkingFailed, this, &Context::onLinkingFailed); + connect(oauth2, &OAuth2::linkingSucceeded, this, &Context::onLinkingSucceeded); + connect(oauth2, &OAuth2::openBrowser, this, &Context::onOpenBrowser); + connect(oauth2, &OAuth2::closeBrowser, this, &Context::onCloseBrowser); + connect(oauth2, &OAuth2::activityChanged, this, &Context::onOAuthActivityChanged); +} + +void Context::beginActivity(Activity activity) { + if(isBusy()) { + throw 0; + } + activity_ = activity; + emit activityChanged(activity_); +} + +void Context::finishActivity() { + if(!isBusy()) { + throw 0; + } + activity_ = Katabasis::Activity::Idle; + m_account.validity_ = m_account.minecraftProfile.validity; + emit activityChanged(activity_); +} + +QString Context::gameToken() { + return m_account.minecraftToken.token; +} + +QString Context::userId() { + return m_account.minecraftProfile.id; +} + +QString Context::userName() { + return m_account.minecraftProfile.name; +} + +bool Context::silentSignIn() { + if(isBusy()) { + return false; + } + beginActivity(Activity::Refreshing); + if(!oauth2->refresh()) { + finishActivity(); + return false; + } + + requestsDone = 0; + xboxProfileSucceeded = false; + mcAuthSucceeded = false; + + return true; +} + +bool Context::signIn() { + if(isBusy()) { + return false; + } + + requestsDone = 0; + xboxProfileSucceeded = false; + mcAuthSucceeded = false; + + beginActivity(Activity::LoggingIn); + oauth2->unlink(); + m_account = AccountData(); + oauth2->link(); + return true; +} + +bool Context::signOut() { + if(isBusy()) { + return false; + } + beginActivity(Activity::LoggingOut); + oauth2->unlink(); + m_account = AccountData(); + finishActivity(); + return true; +} + + +void Context::onOpenBrowser(const QUrl &url) { + QDesktopServices::openUrl(url); +} + +void Context::onCloseBrowser() { + +} + +void Context::onLinkingFailed() { + finishActivity(); +} + +void Context::onLinkingSucceeded() { + auto *o2t = qobject_cast(sender()); + if (!o2t->linked()) { + finishActivity(); + return; + } + QVariantMap extraTokens = o2t->extraTokens(); + if (!extraTokens.isEmpty()) { + qDebug() << "Extra tokens in response:"; + foreach (QString key, extraTokens.keys()) { + qDebug() << "\t" << key << ":" << extraTokens.value(key); + } + } + doUserAuth(); +} + +void Context::onOAuthActivityChanged(Katabasis::Activity activity) { + // respond to activity change here +} + +void Context::doUserAuth() { + QString xbox_auth_template = R"XXX( +{ + "Properties": { + "AuthMethod": "RPS", + "SiteName": "user.auth.xboxlive.com", + "RpsTicket": "d=%1" + }, + "RelyingParty": "http://auth.xboxlive.com", + "TokenType": "JWT" +} +)XXX"; + auto xbox_auth_data = xbox_auth_template.arg(m_account.msaToken.token); + + QNetworkRequest request = QNetworkRequest(QUrl("https://user.auth.xboxlive.com/user/authenticate")); + request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + request.setRawHeader("Accept", "application/json"); + auto *requestor = new Katabasis::Requestor(mgr, oauth2, this); + requestor->setAddAccessTokenInQuery(false); + + connect(requestor, &Requestor::finished, this, &Context::onUserAuthDone); + requestor->post(request, xbox_auth_data.toUtf8()); + qDebug() << "First layer of XBox auth ... commencing."; +} + +namespace { +bool getDateTime(QJsonValue value, QDateTime & out) { + if(!value.isString()) { + return false; + } + out = QDateTime::fromString(value.toString(), Qt::ISODateWithMs); + return out.isValid(); +} + +bool getString(QJsonValue value, QString & out) { + if(!value.isString()) { + return false; + } + out = value.toString(); + return true; +} + +bool getNumber(QJsonValue value, double & out) { + if(!value.isDouble()) { + return false; + } + out = value.toDouble(); + return true; +} + +/* +{ + "IssueInstant":"2020-12-07T19:52:08.4463796Z", + "NotAfter":"2020-12-21T19:52:08.4463796Z", + "Token":"token", + "DisplayClaims":{ + "xui":[ + { + "uhs":"userhash" + } + ] + } + } +*/ +// TODO: handle error responses ... +/* +{ + "Identity":"0", + "XErr":2148916238, + "Message":"", + "Redirect":"https://start.ui.xboxlive.com/AddChildToFamily" +} +// 2148916233 = missing XBox account +// 2148916238 = child account not linked to a family +*/ + +bool parseXTokenResponse(QByteArray & data, Katabasis::Token &output) { + QJsonParseError jsonError; + QJsonDocument doc = QJsonDocument::fromJson(data, &jsonError); + if(jsonError.error) { + qWarning() << "Failed to parse response from user.auth.xboxlive.com as JSON: " << jsonError.errorString(); + qDebug() << data; + return false; + } + + auto obj = doc.object(); + if(!getDateTime(obj.value("IssueInstant"), output.issueInstant)) { + qWarning() << "User IssueInstant is not a timestamp"; + qDebug() << data; + return false; + } + if(!getDateTime(obj.value("NotAfter"), output.notAfter)) { + qWarning() << "User NotAfter is not a timestamp"; + qDebug() << data; + return false; + } + if(!getString(obj.value("Token"), output.token)) { + qWarning() << "User Token is not a timestamp"; + qDebug() << data; + return false; + } + auto arrayVal = obj.value("DisplayClaims").toObject().value("xui"); + if(!arrayVal.isArray()) { + qWarning() << "Missing xui claims array"; + qDebug() << data; + return false; + } + bool foundUHS = false; + for(auto item: arrayVal.toArray()) { + if(!item.isObject()) { + continue; + } + auto obj = item.toObject(); + if(obj.contains("uhs")) { + foundUHS = true; + } else { + continue; + } + // consume all 'display claims' ... whatever that means + for(auto iter = obj.begin(); iter != obj.end(); iter++) { + QString claim; + if(!getString(obj.value(iter.key()), claim)) { + qWarning() << "display claim " << iter.key() << " is not a string..."; + qDebug() << data; + return false; + } + output.extra[iter.key()] = claim; + } + + break; + } + if(!foundUHS) { + qWarning() << "Missing uhs"; + qDebug() << data; + return false; + } + output.validity = Katabasis::Validity::Certain; + qDebug() << data; + return true; +} + +} + +void Context::onUserAuthDone( + int requestId, + QNetworkReply::NetworkError error, + QByteArray replyData, + QList headers +) { + if (error != QNetworkReply::NoError) { + qWarning() << "Reply error:" << error; + finishActivity(); + return; + } + + Katabasis::Token temp; + if(!parseXTokenResponse(replyData, temp)) { + qWarning() << "Could not parse user authentication response..."; + finishActivity(); + return; + } + m_account.userToken = temp; + + doSTSAuthMinecraft(); + doSTSAuthGeneric(); +} +/* + url = "https://xsts.auth.xboxlive.com/xsts/authorize" + headers = {"x-xbl-contract-version": "1"} + data = { + "RelyingParty": relying_party, + "TokenType": "JWT", + "Properties": { + "UserTokens": [self.user_token.token], + "SandboxId": "RETAIL", + }, + } +*/ +void Context::doSTSAuthMinecraft() { + QString xbox_auth_template = R"XXX( +{ + "Properties": { + "SandboxId": "RETAIL", + "UserTokens": [ + "%1" + ] + }, + "RelyingParty": "rp://api.minecraftservices.com/", + "TokenType": "JWT" +} +)XXX"; + auto xbox_auth_data = xbox_auth_template.arg(m_account.userToken.token); + + QNetworkRequest request = QNetworkRequest(QUrl("https://xsts.auth.xboxlive.com/xsts/authorize")); + request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + request.setRawHeader("Accept", "application/json"); + Requestor *requestor = new Requestor(mgr, oauth2, this); + requestor->setAddAccessTokenInQuery(false); + + connect(requestor, &Requestor::finished, this, &Context::onSTSAuthMinecraftDone); + requestor->post(request, xbox_auth_data.toUtf8()); + qDebug() << "Second layer of XBox auth ... commencing."; +} + +void Context::onSTSAuthMinecraftDone( + int requestId, + QNetworkReply::NetworkError error, + QByteArray replyData, + QList headers +) { + if (error != QNetworkReply::NoError) { + qWarning() << "Reply error:" << error; + finishActivity(); + return; + } + + Katabasis::Token temp; + if(!parseXTokenResponse(replyData, temp)) { + qWarning() << "Could not parse authorization response for access to mojang services..."; + finishActivity(); + return; + } + + if(temp.extra["uhs"] != m_account.userToken.extra["uhs"]) { + qWarning() << "Server has changed user hash in the reply... something is wrong. ABORTING"; + qDebug() << replyData; + finishActivity(); + return; + } + m_account.mojangservicesToken = temp; + + doMinecraftAuth(); +} + +void Context::doSTSAuthGeneric() { + QString xbox_auth_template = R"XXX( +{ + "Properties": { + "SandboxId": "RETAIL", + "UserTokens": [ + "%1" + ] + }, + "RelyingParty": "http://xboxlive.com", + "TokenType": "JWT" +} +)XXX"; + auto xbox_auth_data = xbox_auth_template.arg(m_account.userToken.token); + + QNetworkRequest request = QNetworkRequest(QUrl("https://xsts.auth.xboxlive.com/xsts/authorize")); + request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + request.setRawHeader("Accept", "application/json"); + Requestor *requestor = new Requestor(mgr, oauth2, this); + requestor->setAddAccessTokenInQuery(false); + + connect(requestor, &Requestor::finished, this, &Context::onSTSAuthGenericDone); + requestor->post(request, xbox_auth_data.toUtf8()); + qDebug() << "Second layer of XBox auth ... commencing."; +} + +void Context::onSTSAuthGenericDone( + int requestId, + QNetworkReply::NetworkError error, + QByteArray replyData, + QList headers +) { + if (error != QNetworkReply::NoError) { + qWarning() << "Reply error:" << error; + finishActivity(); + return; + } + + Katabasis::Token temp; + if(!parseXTokenResponse(replyData, temp)) { + qWarning() << "Could not parse authorization response for access to xbox API..."; + finishActivity(); + return; + } + + if(temp.extra["uhs"] != m_account.userToken.extra["uhs"]) { + qWarning() << "Server has changed user hash in the reply... something is wrong. ABORTING"; + qDebug() << replyData; + finishActivity(); + return; + } + m_account.xboxApiToken = temp; + + doXBoxProfile(); +} + + +void Context::doMinecraftAuth() { + QString mc_auth_template = R"XXX( +{ + "identityToken": "XBL3.0 x=%1;%2" +} +)XXX"; + auto data = mc_auth_template.arg(m_account.mojangservicesToken.extra["uhs"].toString(), m_account.mojangservicesToken.token); + + QNetworkRequest request = QNetworkRequest(QUrl("https://api.minecraftservices.com/authentication/login_with_xbox")); + request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + request.setRawHeader("Accept", "application/json"); + Requestor *requestor = new Requestor(mgr, oauth2, this); + requestor->setAddAccessTokenInQuery(false); + + connect(requestor, &Requestor::finished, this, &Context::onMinecraftAuthDone); + requestor->post(request, data.toUtf8()); + qDebug() << "Getting Minecraft access token..."; +} + +namespace { +bool parseMojangResponse(QByteArray & data, Katabasis::Token &output) { + QJsonParseError jsonError; + QJsonDocument doc = QJsonDocument::fromJson(data, &jsonError); + if(jsonError.error) { + qWarning() << "Failed to parse response from user.auth.xboxlive.com as JSON: " << jsonError.errorString(); + qDebug() << data; + return false; + } + + auto obj = doc.object(); + double expires_in = 0; + if(!getNumber(obj.value("expires_in"), expires_in)) { + qWarning() << "expires_in is not a valid number"; + qDebug() << data; + return false; + } + auto currentTime = QDateTime::currentDateTimeUtc(); + output.issueInstant = currentTime; + output.notAfter = currentTime.addSecs(expires_in); + + QString username; + if(!getString(obj.value("username"), username)) { + qWarning() << "username is not valid"; + qDebug() << data; + return false; + } + + // TODO: it's a JWT... validate it? + if(!getString(obj.value("access_token"), output.token)) { + qWarning() << "access_token is not valid"; + qDebug() << data; + return false; + } + output.validity = Katabasis::Validity::Certain; + qDebug() << data; + return true; +} +} + +void Context::onMinecraftAuthDone( + int requestId, + QNetworkReply::NetworkError error, + QByteArray replyData, + QList headers +) { + requestsDone++; + + if (error != QNetworkReply::NoError) { + qWarning() << "Reply error:" << error; + qDebug() << replyData; + finishActivity(); + return; + } + + if(!parseMojangResponse(replyData, m_account.minecraftToken)) { + qWarning() << "Could not parse login_with_xbox response..."; + qDebug() << replyData; + finishActivity(); + return; + } + mcAuthSucceeded = true; + + checkResult(); +} + +void Context::doXBoxProfile() { + auto url = QUrl("https://profile.xboxlive.com/users/me/profile/settings"); + QUrlQuery q; + q.addQueryItem( + "settings", + "GameDisplayName,AppDisplayName,AppDisplayPicRaw,GameDisplayPicRaw," + "PublicGamerpic,ShowUserAsAvatar,Gamerscore,Gamertag,ModernGamertag,ModernGamertagSuffix," + "UniqueModernGamertag,AccountTier,TenureLevel,XboxOneRep," + "PreferredColor,Location,Bio,Watermarks," + "RealName,RealNameOverride,IsQuarantined" + ); + url.setQuery(q); + + QNetworkRequest request = QNetworkRequest(url); + request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + request.setRawHeader("Accept", "application/json"); + request.setRawHeader("x-xbl-contract-version", "3"); + request.setRawHeader("Authorization", QString("XBL3.0 x=%1;%2").arg(m_account.userToken.extra["uhs"].toString(), m_account.xboxApiToken.token).toUtf8()); + Requestor *requestor = new Requestor(mgr, oauth2, this); + requestor->setAddAccessTokenInQuery(false); + + connect(requestor, &Requestor::finished, this, &Context::onXBoxProfileDone); + requestor->get(request); + qDebug() << "Getting Xbox profile..."; +} + +void Context::onXBoxProfileDone( + int requestId, + QNetworkReply::NetworkError error, + QByteArray replyData, + QList headers +) { + requestsDone ++; + + if (error != QNetworkReply::NoError) { + qWarning() << "Reply error:" << error; + qDebug() << replyData; + finishActivity(); + return; + } + + qDebug() << "XBox profile: " << replyData; + + xboxProfileSucceeded = true; + checkResult(); +} + +void Context::checkResult() { + if(requestsDone != 2) { + return; + } + if(mcAuthSucceeded && xboxProfileSucceeded) { + doMinecraftProfile(); + } + else { + finishActivity(); + } +} + +namespace { +bool parseMinecraftProfile(QByteArray & data, MinecraftProfile &output) { + QJsonParseError jsonError; + QJsonDocument doc = QJsonDocument::fromJson(data, &jsonError); + if(jsonError.error) { + qWarning() << "Failed to parse response from user.auth.xboxlive.com as JSON: " << jsonError.errorString(); + qDebug() << data; + return false; + } + + auto obj = doc.object(); + if(!getString(obj.value("id"), output.id)) { + qWarning() << "minecraft profile id is not a string"; + qDebug() << data; + return false; + } + + if(!getString(obj.value("name"), output.name)) { + qWarning() << "minecraft profile name is not a string"; + qDebug() << data; + return false; + } + + auto skinsArray = obj.value("skins").toArray(); + for(auto skin: skinsArray) { + auto skinObj = skin.toObject(); + Skin skinOut; + if(!getString(skinObj.value("id"), skinOut.id)) { + continue; + } + QString state; + if(!getString(skinObj.value("state"), state)) { + continue; + } + if(state != "ACTIVE") { + continue; + } + if(!getString(skinObj.value("url"), skinOut.url)) { + continue; + } + if(!getString(skinObj.value("variant"), skinOut.variant)) { + continue; + } + // we deal with only the active skin + output.skin = skinOut; + break; + } + auto capesArray = obj.value("capes").toArray(); + int i = -1; + int currentCape = -1; + for(auto cape: capesArray) { + i++; + auto capeObj = cape.toObject(); + Cape capeOut; + if(!getString(capeObj.value("id"), capeOut.id)) { + continue; + } + QString state; + if(!getString(capeObj.value("state"), state)) { + continue; + } + if(state == "ACTIVE") { + currentCape = i; + } + if(!getString(capeObj.value("url"), capeOut.url)) { + continue; + } + if(!getString(capeObj.value("alias"), capeOut.alias)) { + continue; + } + + // we deal with only the active skin + output.capes.push_back(capeOut); + } + output.currentCape = currentCape; + output.validity = Katabasis::Validity::Certain; + return true; +} +} + +void Context::doMinecraftProfile() { + auto url = QUrl("https://api.minecraftservices.com/minecraft/profile"); + QNetworkRequest request = QNetworkRequest(url); + request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + // request.setRawHeader("Accept", "application/json"); + request.setRawHeader("Authorization", QString("Bearer %1").arg(m_account.minecraftToken.token).toUtf8()); + + Requestor *requestor = new Requestor(mgr, oauth2, this); + requestor->setAddAccessTokenInQuery(false); + + connect(requestor, &Requestor::finished, this, &Context::onMinecraftProfileDone); + requestor->get(request); +} + +void Context::onMinecraftProfileDone(int, QNetworkReply::NetworkError error, QByteArray data, QList headers) { + qDebug() << data; + if (error == QNetworkReply::ContentNotFoundError) { + m_account.minecraftProfile = MinecraftProfile(); + finishActivity(); + return; + } + if (error != QNetworkReply::NoError) { + finishActivity(); + return; + } + if(!parseMinecraftProfile(data, m_account.minecraftProfile)) { + m_account.minecraftProfile = MinecraftProfile(); + finishActivity(); + return; + } + doGetSkin(); +} + +void Context::doGetSkin() { + auto url = QUrl(m_account.minecraftProfile.skin.url); + QNetworkRequest request = QNetworkRequest(url); + Requestor *requestor = new Requestor(mgr, oauth2, this); + requestor->setAddAccessTokenInQuery(false); + connect(requestor, &Requestor::finished, this, &Context::onSkinDone); + requestor->get(request); +} + +void Context::onSkinDone(int, QNetworkReply::NetworkError error, QByteArray data, QList) { + if (error == QNetworkReply::NoError) { + m_account.minecraftProfile.skin.data = data; + } + finishActivity(); +} + +namespace { +void tokenToJSON(QJsonObject &parent, Katabasis::Token t, const char * tokenName) { + if(t.validity == Katabasis::Validity::None || !t.persistent) { + return; + } + QJsonObject out; + if(t.issueInstant.isValid()) { + out["iat"] = QJsonValue(t.issueInstant.toSecsSinceEpoch()); + } + + if(t.notAfter.isValid()) { + out["exp"] = QJsonValue(t.notAfter.toSecsSinceEpoch()); + } + + if(!t.token.isEmpty()) { + out["token"] = QJsonValue(t.token); + } + if(!t.refresh_token.isEmpty()) { + out["refresh_token"] = QJsonValue(t.refresh_token); + } + if(t.extra.size()) { + out["extra"] = QJsonObject::fromVariantMap(t.extra); + } + if(out.size()) { + parent[tokenName] = out; + } +} + +Katabasis::Token tokenFromJSON(const QJsonObject &parent, const char * tokenName) { + Katabasis::Token out; + auto tokenObject = parent.value(tokenName).toObject(); + if(tokenObject.isEmpty()) { + return out; + } + auto issueInstant = tokenObject.value("iat"); + if(issueInstant.isDouble()) { + out.issueInstant = QDateTime::fromSecsSinceEpoch((int64_t) issueInstant.toDouble()); + } + + auto notAfter = tokenObject.value("exp"); + if(notAfter.isDouble()) { + out.notAfter = QDateTime::fromSecsSinceEpoch((int64_t) notAfter.toDouble()); + } + + auto token = tokenObject.value("token"); + if(token.isString()) { + out.token = token.toString(); + out.validity = Katabasis::Validity::Assumed; + } + + auto refresh_token = tokenObject.value("refresh_token"); + if(refresh_token.isString()) { + out.refresh_token = refresh_token.toString(); + } + + auto extra = tokenObject.value("extra"); + if(extra.isObject()) { + out.extra = extra.toObject().toVariantMap(); + } + return out; +} + +void profileToJSON(QJsonObject &parent, MinecraftProfile p, const char * tokenName) { + if(p.id.isEmpty()) { + return; + } + QJsonObject out; + out["id"] = QJsonValue(p.id); + out["name"] = QJsonValue(p.name); + if(p.currentCape != -1) { + out["cape"] = p.capes[p.currentCape].id; + } + + { + QJsonObject skinObj; + skinObj["id"] = p.skin.id; + skinObj["url"] = p.skin.url; + skinObj["variant"] = p.skin.variant; + if(p.skin.data.size()) { + skinObj["data"] = QString::fromLatin1(p.skin.data.toBase64()); + } + out["skin"] = skinObj; + } + + QJsonArray capesArray; + for(auto & cape: p.capes) { + QJsonObject capeObj; + capeObj["id"] = cape.id; + capeObj["url"] = cape.url; + capeObj["alias"] = cape.alias; + if(cape.data.size()) { + capeObj["data"] = QString::fromLatin1(cape.data.toBase64()); + } + capesArray.push_back(capeObj); + } + out["capes"] = capesArray; + parent[tokenName] = out; +} + +MinecraftProfile profileFromJSON(const QJsonObject &parent, const char * tokenName) { + MinecraftProfile out; + auto tokenObject = parent.value(tokenName).toObject(); + if(tokenObject.isEmpty()) { + return out; + } + { + auto idV = tokenObject.value("id"); + auto nameV = tokenObject.value("name"); + if(!idV.isString() || !nameV.isString()) { + qWarning() << "mandatory profile attributes are missing or of unexpected type"; + return MinecraftProfile(); + } + out.name = nameV.toString(); + out.id = idV.toString(); + } + + { + auto skinV = tokenObject.value("skin"); + if(!skinV.isObject()) { + qWarning() << "skin is missing"; + return MinecraftProfile(); + } + auto skinObj = skinV.toObject(); + auto idV = skinObj.value("id"); + auto urlV = skinObj.value("url"); + auto variantV = skinObj.value("variant"); + if(!idV.isString() || !urlV.isString() || !variantV.isString()) { + qWarning() << "mandatory skin attributes are missing or of unexpected type"; + return MinecraftProfile(); + } + out.skin.id = idV.toString(); + out.skin.url = urlV.toString(); + out.skin.variant = variantV.toString(); + + // data for skin is optional + auto dataV = skinObj.value("data"); + if(dataV.isString()) { + // TODO: validate base64 + out.skin.data = QByteArray::fromBase64(dataV.toString().toLatin1()); + } + else if (!dataV.isUndefined()) { + qWarning() << "skin data is something unexpected"; + return MinecraftProfile(); + } + } + + auto capesV = tokenObject.value("capes"); + if(!capesV.isArray()) { + qWarning() << "capes is not an array!"; + return MinecraftProfile(); + } + auto capesArray = capesV.toArray(); + for(auto capeV: capesArray) { + if(!capeV.isObject()) { + qWarning() << "cape is not an object!"; + return MinecraftProfile(); + } + auto capeObj = capeV.toObject(); + auto idV = capeObj.value("id"); + auto urlV = capeObj.value("url"); + auto aliasV = capeObj.value("alias"); + if(!idV.isString() || !urlV.isString() || !aliasV.isString()) { + qWarning() << "mandatory skin attributes are missing or of unexpected type"; + return MinecraftProfile(); + } + Cape cape; + cape.id = idV.toString(); + cape.url = urlV.toString(); + cape.alias = aliasV.toString(); + + // data for cape is optional. + auto dataV = capeObj.value("data"); + if(dataV.isString()) { + // TODO: validate base64 + cape.data = QByteArray::fromBase64(dataV.toString().toLatin1()); + } + else if (!dataV.isUndefined()) { + qWarning() << "cape data is something unexpected"; + return MinecraftProfile(); + } + out.capes.push_back(cape); + } + out.validity = Katabasis::Validity::Assumed; + return out; +} + +} + +bool Context::resumeFromState(QByteArray data) { + QJsonParseError error; + auto doc = QJsonDocument::fromJson(data, &error); + if(error.error != QJsonParseError::NoError) { + qWarning() << "Failed to parse account data as JSON."; + return false; + } + auto docObject = doc.object(); + m_account.msaToken = tokenFromJSON(docObject, "msa"); + m_account.userToken = tokenFromJSON(docObject, "utoken"); + m_account.xboxApiToken = tokenFromJSON(docObject, "xrp-main"); + m_account.mojangservicesToken = tokenFromJSON(docObject, "xrp-mc"); + m_account.minecraftToken = tokenFromJSON(docObject, "ygg"); + + m_account.minecraftProfile = profileFromJSON(docObject, "profile"); + + m_account.validity_ = m_account.minecraftProfile.validity; + + return true; +} + +QByteArray Context::saveState() { + QJsonDocument doc; + QJsonObject output; + tokenToJSON(output, m_account.msaToken, "msa"); + tokenToJSON(output, m_account.userToken, "utoken"); + tokenToJSON(output, m_account.xboxApiToken, "xrp-main"); + tokenToJSON(output, m_account.mojangservicesToken, "xrp-mc"); + tokenToJSON(output, m_account.minecraftToken, "ygg"); + profileToJSON(output, m_account.minecraftProfile, "profile"); + doc.setObject(output); + return doc.toJson(QJsonDocument::Indented); +} diff --git a/api/logic/minecraft/auth-msa/context.h b/api/logic/minecraft/auth-msa/context.h new file mode 100644 index 000000000..f1ac99b87 --- /dev/null +++ b/api/logic/minecraft/auth-msa/context.h @@ -0,0 +1,128 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include + +struct Skin { + QString id; + QString url; + QString variant; + + QByteArray data; +}; + +struct Cape { + QString id; + QString url; + QString alias; + + QByteArray data; +}; + +struct MinecraftProfile { + QString id; + QString name; + Skin skin; + int currentCape = -1; + QVector capes; + Katabasis::Validity validity = Katabasis::Validity::None; +}; + +enum class AccountType { + MSA, + Mojang +}; + +struct AccountData { + AccountType type = AccountType::MSA; + + Katabasis::Token msaToken; + Katabasis::Token userToken; + Katabasis::Token xboxApiToken; + Katabasis::Token mojangservicesToken; + Katabasis::Token minecraftToken; + + MinecraftProfile minecraftProfile; + Katabasis::Validity validity_ = Katabasis::Validity::None; +}; + +class Context : public QObject +{ + Q_OBJECT + +public: + explicit Context(QObject *parent = 0); + + QByteArray saveState(); + bool resumeFromState(QByteArray data); + + bool isBusy() { + return activity_ != Katabasis::Activity::Idle; + }; + Katabasis::Validity validity() { + return m_account.validity_; + }; + + bool signIn(); + bool silentSignIn(); + bool signOut(); + + QString userName(); + QString userId(); + QString gameToken(); +signals: + void succeeded(); + void failed(); + void activityChanged(Katabasis::Activity activity); + +private slots: + void onLinkingSucceeded(); + void onLinkingFailed(); + void onOpenBrowser(const QUrl &url); + void onCloseBrowser(); + void onOAuthActivityChanged(Katabasis::Activity activity); + +private: + void doUserAuth(); + Q_SLOT void onUserAuthDone(int, QNetworkReply::NetworkError, QByteArray, QList); + + void doSTSAuthMinecraft(); + Q_SLOT void onSTSAuthMinecraftDone(int, QNetworkReply::NetworkError, QByteArray, QList); + void doMinecraftAuth(); + Q_SLOT void onMinecraftAuthDone(int, QNetworkReply::NetworkError, QByteArray, QList); + + void doSTSAuthGeneric(); + Q_SLOT void onSTSAuthGenericDone(int, QNetworkReply::NetworkError, QByteArray, QList); + void doXBoxProfile(); + Q_SLOT void onXBoxProfileDone(int, QNetworkReply::NetworkError, QByteArray, QList); + + void doMinecraftProfile(); + Q_SLOT void onMinecraftProfileDone(int, QNetworkReply::NetworkError, QByteArray, QList); + + void doGetSkin(); + Q_SLOT void onSkinDone(int, QNetworkReply::NetworkError, QByteArray, QList); + + void checkResult(); + +private: + void beginActivity(Katabasis::Activity activity); + void finishActivity(); + void clearTokens(); + +private: + Katabasis::OAuth2 *oauth2 = nullptr; + + int requestsDone = 0; + bool xboxProfileSucceeded = false; + bool mcAuthSucceeded = false; + Katabasis::Activity activity_ = Katabasis::Activity::Idle; + + AccountData m_account; + + QNetworkAccessManager *mgr = nullptr; +}; diff --git a/api/logic/minecraft/auth-msa/main.cpp b/api/logic/minecraft/auth-msa/main.cpp new file mode 100644 index 000000000..481e01269 --- /dev/null +++ b/api/logic/minecraft/auth-msa/main.cpp @@ -0,0 +1,100 @@ +#include +#include +#include +#include +#include +#include + +#include "context.h" +#include "mainwindow.h" + +void myMessageOutput(QtMsgType type, const QMessageLogContext &context, const QString &msg) +{ + QByteArray localMsg = msg.toLocal8Bit(); + const char *file = context.file ? context.file : ""; + const char *function = context.function ? context.function : ""; + switch (type) { + case QtDebugMsg: + fprintf(stderr, "Debug: %s (%s:%u, %s)\n", localMsg.constData(), file, context.line, function); + break; + case QtInfoMsg: + fprintf(stderr, "Info: %s (%s:%u, %s)\n", localMsg.constData(), file, context.line, function); + break; + case QtWarningMsg: + fprintf(stderr, "Warning: %s (%s:%u, %s)\n", localMsg.constData(), file, context.line, function); + break; + case QtCriticalMsg: + fprintf(stderr, "Critical: %s (%s:%u, %s)\n", localMsg.constData(), file, context.line, function); + break; + case QtFatalMsg: + fprintf(stderr, "Fatal: %s (%s:%u, %s)\n", localMsg.constData(), file, context.line, function); + break; + } +} + +class Helper : public QObject { + Q_OBJECT + +public: + Helper(Context * context) : QObject(), context_(context), msg_(QString()) { + QFile tokenCache("usercache.dat"); + if(tokenCache.open(QIODevice::ReadOnly)) { + context_->resumeFromState(tokenCache.readAll()); + } + } + +public slots: + void run() { + connect(context_, &Context::activityChanged, this, &Helper::onActivityChanged); + context_->silentSignIn(); + } + + void onFailed() { + qDebug() << "Login failed"; + } + + void onActivityChanged(Katabasis::Activity activity) { + if(activity == Katabasis::Activity::Idle) { + switch(context_->validity()) { + case Katabasis::Validity::None: { + // account is gone, remove it. + QFile::remove("usercache.dat"); + } + break; + case Katabasis::Validity::Assumed: { + // this is basically a soft-failed refresh. do nothing. + } + break; + case Katabasis::Validity::Certain: { + // stuff got refreshed / signed in. Save. + auto data = context_->saveState(); + QSaveFile tokenCache("usercache.dat"); + if(tokenCache.open(QIODevice::WriteOnly)) { + tokenCache.write(context_->saveState()); + tokenCache.commit(); + } + } + break; + } + } + } + +private: + Context *context_; + QString msg_; +}; + +int main(int argc, char *argv[]) { + qInstallMessageHandler(myMessageOutput); + QApplication a(argc, argv); + QCoreApplication::setOrganizationName("MultiMC"); + QCoreApplication::setApplicationName("MultiMC"); + Context c; + Helper helper(&c); + MainWindow window(&c); + window.show(); + QTimer::singleShot(0, &helper, &Helper::run); + return a.exec(); +} + +#include "main.moc" diff --git a/api/logic/minecraft/auth-msa/mainwindow.cpp b/api/logic/minecraft/auth-msa/mainwindow.cpp new file mode 100644 index 000000000..d4e18dc04 --- /dev/null +++ b/api/logic/minecraft/auth-msa/mainwindow.cpp @@ -0,0 +1,97 @@ +#include "mainwindow.h" +#include "ui_mainwindow.h" +#include + +#include + +#include "BuildConfig.h" + +MainWindow::MainWindow(Context * context, QWidget *parent) : + QMainWindow(parent), + m_context(context), + m_ui(new Ui::MainWindow) +{ + m_ui->setupUi(this); + connect(m_ui->signInButton_MSA, &QPushButton::clicked, this, &MainWindow::SignInMSAClicked); + connect(m_ui->signInButton_Mojang, &QPushButton::clicked, this, &MainWindow::SignInMojangClicked); + connect(m_ui->signOutButton, &QPushButton::clicked, this, &MainWindow::SignOutClicked); + connect(m_ui->refreshButton, &QPushButton::clicked, this, &MainWindow::RefreshClicked); + + // connect(m_context, &Context::linkingSucceeded, this, &MainWindow::SignInSucceeded); + // connect(m_context, &Context::linkingFailed, this, &MainWindow::SignInFailed); + connect(m_context, &Context::activityChanged, this, &MainWindow::ActivityChanged); + ActivityChanged(Katabasis::Activity::Idle); +} + +MainWindow::~MainWindow() = default; + +void MainWindow::ActivityChanged(Katabasis::Activity activity) { + switch(activity) { + case Katabasis::Activity::Idle: { + if(m_context->validity() != Katabasis::Validity::None) { + m_ui->signInButton_Mojang->setEnabled(false); + m_ui->signInButton_MSA->setEnabled(false); + m_ui->signOutButton->setEnabled(true); + m_ui->refreshButton->setEnabled(true); + m_ui->statusBar->showMessage(QString("Hello %1!").arg(m_context->userName())); + } + else { + m_ui->signInButton_Mojang->setEnabled(true); + m_ui->signInButton_MSA->setEnabled(true); + m_ui->signOutButton->setEnabled(false); + m_ui->refreshButton->setEnabled(false); + m_ui->statusBar->showMessage("Press the login button to start."); + } + } + break; + case Katabasis::Activity::LoggingIn: { + m_ui->signInButton_Mojang->setEnabled(false); + m_ui->signInButton_MSA->setEnabled(false); + m_ui->signOutButton->setEnabled(false); + m_ui->refreshButton->setEnabled(false); + m_ui->statusBar->showMessage("Logging in..."); + } + break; + case Katabasis::Activity::LoggingOut: { + m_ui->signInButton_Mojang->setEnabled(false); + m_ui->signInButton_MSA->setEnabled(false); + m_ui->signOutButton->setEnabled(false); + m_ui->refreshButton->setEnabled(false); + m_ui->statusBar->showMessage("Logging out..."); + } + break; + case Katabasis::Activity::Refreshing: { + m_ui->signInButton_Mojang->setEnabled(false); + m_ui->signInButton_MSA->setEnabled(false); + m_ui->signOutButton->setEnabled(false); + m_ui->refreshButton->setEnabled(false); + m_ui->statusBar->showMessage("Refreshing login..."); + } + break; + } +} + +void MainWindow::SignInMSAClicked() { + qDebug() << "Sign In MSA"; + // signIn({{"prompt", "select_account"}}) + // FIXME: wrong. very wrong. this should not be operating on the current context + m_context->signIn(); +} + +void MainWindow::SignInMojangClicked() { + qDebug() << "Sign In Mojang"; + // signIn({{"prompt", "select_account"}}) + // FIXME: wrong. very wrong. this should not be operating on the current context + m_context->signIn(); +} + + +void MainWindow::SignOutClicked() { + qDebug() << "Sign Out"; + m_context->signOut(); +} + +void MainWindow::RefreshClicked() { + qDebug() << "Refresh"; + m_context->silentSignIn(); +} diff --git a/api/logic/minecraft/auth-msa/mainwindow.h b/api/logic/minecraft/auth-msa/mainwindow.h new file mode 100644 index 000000000..abde52d84 --- /dev/null +++ b/api/logic/minecraft/auth-msa/mainwindow.h @@ -0,0 +1,34 @@ +#pragma once + +#include +#include +#include +#include + +#include "context.h" + +namespace Ui { +class MainWindow; +} + +class MainWindow : public QMainWindow { + Q_OBJECT + +public: + explicit MainWindow(Context * context, QWidget *parent = nullptr); + ~MainWindow() override; + +private slots: + void SignInMojangClicked(); + void SignInMSAClicked(); + + void SignOutClicked(); + void RefreshClicked(); + + void ActivityChanged(Katabasis::Activity activity); + +private: + Context* m_context; + QScopedPointer m_ui; +}; + diff --git a/api/logic/minecraft/auth-msa/mainwindow.ui b/api/logic/minecraft/auth-msa/mainwindow.ui new file mode 100644 index 000000000..32b341287 --- /dev/null +++ b/api/logic/minecraft/auth-msa/mainwindow.ui @@ -0,0 +1,72 @@ + + + MainWindow + + + + 0 + 0 + 1037 + 511 + + + + SmartMapsClient + + + true + + + + + + + SignIn Mojang + + + + + + + Qt::Horizontal + + + + + + + + + + Refresh + + + + + + + SignIn MSA + + + + + + + SignOut + + + + + + + Make Active + + + + + + + + + + diff --git a/libraries/katabasis/.gitignore b/libraries/katabasis/.gitignore new file mode 100644 index 000000000..35e189c5e --- /dev/null +++ b/libraries/katabasis/.gitignore @@ -0,0 +1,2 @@ +build/ +*.kdev4 diff --git a/libraries/katabasis/CMakeLists.txt b/libraries/katabasis/CMakeLists.txt new file mode 100644 index 000000000..7bbe1a8bb --- /dev/null +++ b/libraries/katabasis/CMakeLists.txt @@ -0,0 +1,60 @@ +cmake_minimum_required(VERSION 3.20) + +string(COMPARE EQUAL "${CMAKE_SOURCE_DIR}" "${CMAKE_BUILD_DIR}" IS_IN_SOURCE_BUILD) +if(IS_IN_SOURCE_BUILD) + message(FATAL_ERROR "You are building Katabasis in-source. Please separate the build tree from the source tree.") +endif() + +if (CMAKE_SYSTEM_NAME STREQUAL "Linux") + if(CMAKE_HOST_SYSTEM_VERSION MATCHES ".*[Mm]icrosoft.*" OR + CMAKE_HOST_SYSTEM_VERSION MATCHES ".*WSL.*" + ) + message(FATAL_ERROR "Building Katabasis is not supported in Linux-on-Windows distributions. Use a real Linux machine, not a fraudulent one.") + endif() +endif() + +project(Katabasis) +enable_testing() + +set(CMAKE_AUTOMOC ON) +set(CMAKE_INCLUDE_CURRENT_DIR ON) + +set(CMAKE_CXX_STANDARD_REQUIRED true) +set(CMAKE_C_STANDARD_REQUIRED true) +set(CMAKE_CXX_STANDARD 11) +set(CMAKE_C_STANDARD 11) + +find_package(Qt5 COMPONENTS Core Network REQUIRED) + +set( katabasis_PRIVATE + src/OAuth2.cpp + + src/JsonResponse.cpp + src/JsonResponse.h + src/PollServer.cpp + src/Reply.cpp + src/ReplyServer.cpp + src/Requestor.cpp +) + +set( katabasis_PUBLIC + include/katabasis/OAuth2.h + + include/katabasis/Globals.h + include/katabasis/PollServer.h + include/katabasis/Reply.h + include/katabasis/ReplyServer.h + + include/katabasis/Requestor.h + include/katabasis/RequestParameter.h +) + +add_library( Katabasis STATIC ${katabasis_PRIVATE} ${katabasis_PUBLIC} ) +target_link_libraries(Katabasis Qt5::Core Qt5::Network) + +# needed for statically linked Katabasis in shared libs on x86_64 +set_target_properties(Katabasis + PROPERTIES POSITION_INDEPENDENT_CODE TRUE +) + +target_include_directories(Katabasis PUBLIC include PRIVATE src include/katabasis) diff --git a/libraries/katabasis/LICENSE b/libraries/katabasis/LICENSE new file mode 100644 index 000000000..9ac8d42fb --- /dev/null +++ b/libraries/katabasis/LICENSE @@ -0,0 +1,23 @@ +Copyright (c) 2012, Akos Polster +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, this +list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright notice, +this list of conditions and the following disclaimer in the documentation +and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/libraries/katabasis/README.md b/libraries/katabasis/README.md new file mode 100644 index 000000000..a4dc09944 --- /dev/null +++ b/libraries/katabasis/README.md @@ -0,0 +1,36 @@ +# Katabasis - MS-flavoerd OAuth for Qt, derived from the O2 library + +This library's sole purpose is to make interacting with MSA and various MSA and XBox authenticated services less painful. + +It may be possible to backport some of the changes to O2 in the future, but for the sake of going fast, all compatibility concerns have been ignored. + +[You can find the original library's git repository here.](https://github.com/pipacs/o2) + +Notes to contributors: + + * Please follow the coding style of the existing source, where reasonable + * Code contributions are released under Simplified BSD License, as specified in LICENSE. Do not contribute if this license does not suit your code + * If you are interested in working on this, come to the MultiMC Discord server and talk first + +## Installation + +Clone the Github repository, integrate the it into your CMake build system. + +The library is static only, dynamic linking and system-wide installation are out of scope and undesirable. + +## Usage + +At this stage, don't, unless you want to help with the library itself. + +This is an experimental fork of the O2 library and is undergoing a big design/architecture shift in order to support different features: + +* Multiple accounts +* Multi-stage authentication/authorization schemes +* Tighter control over token chains and their storage +* Talking to complex APIs and individually authorized microservices +* Token lifetime management, 'offline mode' and resilience in face of network failures +* Token and claims/entitlements validation +* Caching of some API results +* XBox magic +* Mojang magic +* Generally, magic that you would spend weeks on researching while getting confused by contradictory/incomplete documentation (if any is available) diff --git a/libraries/katabasis/acknowledgements.md b/libraries/katabasis/acknowledgements.md new file mode 100644 index 000000000..c1c8a3d49 --- /dev/null +++ b/libraries/katabasis/acknowledgements.md @@ -0,0 +1,110 @@ +# O2 library by Akos Polster and contributors + +[The origin of this fork.](https://github.com/pipacs/o2) + +> Copyright (c) 2012, Akos Polster +> All rights reserved. +> +> Redistribution and use in source and binary forms, with or without +> modification, are permitted provided that the following conditions are met: +> +> * Redistributions of source code must retain the above copyright notice, this +> list of conditions and the following disclaimer. +> +> * Redistributions in binary form must reproduce the above copyright notice, +> this list of conditions and the following disclaimer in the documentation +> and/or other materials provided with the distribution. +> +> THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +> AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +> IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +> DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +> FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +> DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +> SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +> CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +> OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +> OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +# SimpleCrypt by Andre Somers + +Cryptographic methods for Qt. + +> Copyright (c) 2011, Andre Somers +> All rights reserved. +> +> Redistribution and use in source and binary forms, with or without +> modification, are permitted provided that the following conditions are met: +> +> * Redistributions of source code must retain the above copyright +> notice, this list of conditions and the following disclaimer. +> * Redistributions in binary form must reproduce the above copyright +> notice, this list of conditions and the following disclaimer in the +> documentation and/or other materials provided with the distribution. +> * Neither the name of the Rathenau Instituut, Andre Somers nor the +> names of its contributors may be used to endorse or promote products +> derived from this software without specific prior written permission. +> +> THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +> ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +> WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +> DISCLAIMED. IN NO EVENT SHALL ANDRE SOMERS BE LIABLE FOR ANY +> DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +> (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +> LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +> ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +> (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +> SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +# Mandeep Sandhu + +Configurable settings storage, Twitter XAuth specialization, new demos, cleanups. + +> "Hi Akos, +> +> I'm writing this mail to confirm that my contributions to the O2 library, available here https://github.com/pipacs/o2, can be freely distributed according to the project's license (as shown in the LICENSE file). +> +> Regards, +> -mandeep" + +# Sergey Gavrushkin + +FreshBooks specialization + +# Theofilos Intzoglou + +Hubic specialization + +# Dimitar + +SurveyMonkey specialization + +# David Brooks + +CMake related fixes and improvements. + +# Lukas Vogel + +Spotify support + +# Alan Garny + +Windows DLL build support + +# MartinMikita + +Bug fixes + +# Larry Shaffer + +Versioning, shared lib, install target and header support + +# Gilmanov Ildar + +Bug fixes, support for ```qml``` module + +# Fabian Vogt + +Bug fixes, support for building without Qt keywords enabled + diff --git a/libraries/katabasis/include/katabasis/Bits.h b/libraries/katabasis/include/katabasis/Bits.h new file mode 100644 index 000000000..3fd2f5304 --- /dev/null +++ b/libraries/katabasis/include/katabasis/Bits.h @@ -0,0 +1,33 @@ +#pragma once + +#include +#include +#include +#include + +namespace Katabasis { +enum class Activity { + Idle, + LoggingIn, + LoggingOut, + Refreshing +}; + +enum class Validity { + None, + Assumed, + Certain +}; + +struct Token { + QDateTime issueInstant; + QDateTime notAfter; + QString token; + QString refresh_token; + QVariantMap extra; + + Validity validity = Validity::None; + bool persistent = true; +}; + +} diff --git a/libraries/katabasis/include/katabasis/Globals.h b/libraries/katabasis/include/katabasis/Globals.h new file mode 100644 index 000000000..512745d3c --- /dev/null +++ b/libraries/katabasis/include/katabasis/Globals.h @@ -0,0 +1,59 @@ +#pragma once + +namespace Katabasis { + +// Common constants +const char ENCRYPTION_KEY[] = "12345678"; +const char MIME_TYPE_XFORM[] = "application/x-www-form-urlencoded"; +const char MIME_TYPE_JSON[] = "application/json"; + +// OAuth 1/1.1 Request Parameters +const char OAUTH_CALLBACK[] = "oauth_callback"; +const char OAUTH_CONSUMER_KEY[] = "oauth_consumer_key"; +const char OAUTH_NONCE[] = "oauth_nonce"; +const char OAUTH_SIGNATURE[] = "oauth_signature"; +const char OAUTH_SIGNATURE_METHOD[] = "oauth_signature_method"; +const char OAUTH_TIMESTAMP[] = "oauth_timestamp"; +const char OAUTH_VERSION[] = "oauth_version"; +// OAuth 1/1.1 Response Parameters +const char OAUTH_TOKEN[] = "oauth_token"; +const char OAUTH_TOKEN_SECRET[] = "oauth_token_secret"; +const char OAUTH_CALLBACK_CONFIRMED[] = "oauth_callback_confirmed"; +const char OAUTH_VERFIER[] = "oauth_verifier"; + +// OAuth 2 Request Parameters +const char OAUTH2_RESPONSE_TYPE[] = "response_type"; +const char OAUTH2_CLIENT_ID[] = "client_id"; +const char OAUTH2_CLIENT_SECRET[] = "client_secret"; +const char OAUTH2_USERNAME[] = "username"; +const char OAUTH2_PASSWORD[] = "password"; +const char OAUTH2_REDIRECT_URI[] = "redirect_uri"; +const char OAUTH2_SCOPE[] = "scope"; +const char OAUTH2_GRANT_TYPE_CODE[] = "code"; +const char OAUTH2_GRANT_TYPE_TOKEN[] = "token"; +const char OAUTH2_GRANT_TYPE_PASSWORD[] = "password"; +const char OAUTH2_GRANT_TYPE_DEVICE[] = "urn:ietf:params:oauth:grant-type:device_code"; +const char OAUTH2_GRANT_TYPE[] = "grant_type"; +const char OAUTH2_API_KEY[] = "api_key"; +const char OAUTH2_STATE[] = "state"; +const char OAUTH2_CODE[] = "code"; + +// OAuth 2 Response Parameters +const char OAUTH2_ACCESS_TOKEN[] = "access_token"; +const char OAUTH2_REFRESH_TOKEN[] = "refresh_token"; +const char OAUTH2_EXPIRES_IN[] = "expires_in"; +const char OAUTH2_DEVICE_CODE[] = "device_code"; +const char OAUTH2_USER_CODE[] = "user_code"; +const char OAUTH2_VERIFICATION_URI[] = "verification_uri"; +const char OAUTH2_VERIFICATION_URL[] = "verification_url"; // Google sign-in +const char OAUTH2_VERIFICATION_URI_COMPLETE[] = "verification_uri_complete"; +const char OAUTH2_INTERVAL[] = "interval"; + +// Parameter values +const char AUTHORIZATION_CODE[] = "authorization_code"; + +// Standard HTTP headers +const char HTTP_HTTP_HEADER[] = "HTTP"; +const char HTTP_AUTHORIZATION_HEADER[] = "Authorization"; + +} diff --git a/libraries/katabasis/include/katabasis/OAuth2.h b/libraries/katabasis/include/katabasis/OAuth2.h new file mode 100644 index 000000000..4361691ce --- /dev/null +++ b/libraries/katabasis/include/katabasis/OAuth2.h @@ -0,0 +1,233 @@ +#pragma once + +#include +#include +#include +#include + +#include "Reply.h" +#include "RequestParameter.h" +#include "Bits.h" + +namespace Katabasis { + +class ReplyServer; +class PollServer; + + +/* + * FIXME: this is not as simple as it should be. it squishes 4 different grant flows into one big ball of mud + * This serves no practical purpose and simply makes the code less readable / maintainable. + * + * Therefore: Split this into the 4 different OAuth2 flows that people can use as authentication steps. Write tests/examples for all of them. + */ + +/// Simple OAuth2 authenticator. +class OAuth2: public QObject +{ + Q_OBJECT +public: + Q_ENUMS(GrantFlow) + +public: + + struct Options { + QString userAgent = QStringLiteral("Katabasis/1.0"); + QString redirectionUrl = QStringLiteral("http://localhost:%1"); + QString responseType = QStringLiteral("code"); + QString scope; + QString clientIdentifier; + QString clientSecret; + QUrl authorizationUrl; + QUrl accessTokenUrl; + QVector listenerPorts = { 0 }; + }; + + /// Authorization flow types. + enum GrantFlow { + GrantFlowAuthorizationCode, ///< @see http://tools.ietf.org/html/draft-ietf-oauth-v2-15#section-4.1 + GrantFlowImplicit, ///< @see http://tools.ietf.org/html/draft-ietf-oauth-v2-15#section-4.2 + GrantFlowResourceOwnerPasswordCredentials, + GrantFlowDevice ///< @see https://tools.ietf.org/html/rfc8628#section-1 + }; + + /// Authorization flow. + GrantFlow grantFlow(); + void setGrantFlow(GrantFlow value); + +public: + /// Are we authenticated? + bool linked(); + + /// Authentication token. + QString token(); + + /// Provider-specific extra tokens, available after a successful authentication + QVariantMap extraTokens(); + + /// Page content on local host after successful oauth. + /// Provide it in case you do not want to close the browser, but display something + QByteArray replyContent() const; + void setReplyContent(const QByteArray &value); + +public: + + // TODO: remove + /// Resource owner username. + /// instances with the same (username, password) share the same "linked" and "token" properties. + QString username(); + void setUsername(const QString &value); + + // TODO: remove + /// Resource owner password. + /// instances with the same (username, password) share the same "linked" and "token" properties. + QString password(); + void setPassword(const QString &value); + + // TODO: remove + /// API key. + QString apiKey(); + void setApiKey(const QString &value); + + // TODO: remove + /// Allow ignoring SSL errors? + /// E.g. SurveyMonkey fails on Mac due to SSL error. Ignoring the error circumvents the problem + bool ignoreSslErrors(); + void setIgnoreSslErrors(bool ignoreSslErrors); + + // TODO: put in `Options` + /// User-defined extra parameters to append to request URL + QVariantMap extraRequestParams(); + void setExtraRequestParams(const QVariantMap &value); + + // TODO: split up the class into multiple, each implementing one OAuth2 flow + /// Grant type (if non-standard) + QString grantType(); + void setGrantType(const QString &value); + +public: + /// Constructor. + /// @param parent Parent object. + explicit OAuth2(Options & opts, Token & token, QObject *parent = 0, QNetworkAccessManager *manager = 0); + + /// Get refresh token. + QString refreshToken(); + + /// Get token expiration time + QDateTime expires(); + +public slots: + /// Authenticate. + virtual void link(); + + /// De-authenticate. + virtual void unlink(); + + /// Refresh token. + bool refresh(); + + /// Handle situation where reply server has opted to close its connection + void serverHasClosed(bool paramsfound = false); + +signals: + /// Emitted when a token refresh has been completed or failed. + void refreshFinished(QNetworkReply::NetworkError error); + + /// Emitted when client needs to open a web browser window, with the given URL. + void openBrowser(const QUrl &url); + + /// Emitted when client can close the browser window. + void closeBrowser(); + + /// Emitted when client needs to show a verification uri and user code + void showVerificationUriAndCode(const QUrl &uri, const QString &code); + + /// Emitted when authentication/deauthentication succeeded. + void linkingSucceeded(); + + /// Emitted when authentication/deauthentication failed. + void linkingFailed(); + + void activityChanged(Activity activity); + +public slots: + /// Handle verification response. + virtual void onVerificationReceived(QMap); + +protected slots: + /// Handle completion of a token request. + virtual void onTokenReplyFinished(); + + /// Handle failure of a token request. + virtual void onTokenReplyError(QNetworkReply::NetworkError error); + + /// Handle completion of a refresh request. + virtual void onRefreshFinished(); + + /// Handle failure of a refresh request. + virtual void onRefreshError(QNetworkReply::NetworkError error); + + /// Handle completion of a Device Authorization Request + virtual void onDeviceAuthReplyFinished(); + +protected: + /// Build HTTP request body. + QByteArray buildRequestBody(const QMap ¶meters); + + /// Set refresh token. + void setRefreshToken(const QString &v); + + /// Set token expiration time. + void setExpires(QDateTime v); + + /// Start polling authorization server + void startPollServer(const QVariantMap ¶ms); + + /// Set authentication token. + void setToken(const QString &v); + + /// Set the linked state + void setLinked(bool v); + + /// Set extra tokens found in OAuth response + void setExtraTokens(QVariantMap extraTokens); + + /// Set local reply server + void setReplyServer(ReplyServer *server); + + ReplyServer * replyServer() const; + + /// Set local poll server + void setPollServer(PollServer *server); + + PollServer * pollServer() const; + + void updateActivity(Activity activity); + +protected: + QString username_; + QString password_; + + Options options_; + + QVariantMap extraReqParams_; + QString apiKey_; + QNetworkAccessManager *manager_ = nullptr; + ReplyList timedReplies_; + GrantFlow grantFlow_; + QString grantType_; + +protected: + QString redirectUri_; + Token &token_; + + // this should be part of the reply server impl + QByteArray replyContent_; + +private: + ReplyServer *replyServer_ = nullptr; + PollServer *pollServer_ = nullptr; + Activity activity_ = Activity::Idle; +}; + +} diff --git a/libraries/katabasis/include/katabasis/PollServer.h b/libraries/katabasis/include/katabasis/PollServer.h new file mode 100644 index 000000000..77103867c --- /dev/null +++ b/libraries/katabasis/include/katabasis/PollServer.h @@ -0,0 +1,48 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +class QNetworkAccessManager; + +namespace Katabasis { + +/// Poll an authorization server for token +class PollServer : public QObject +{ + Q_OBJECT + +public: + explicit PollServer(QNetworkAccessManager * manager, const QNetworkRequest &request, const QByteArray & payload, int expiresIn, QObject *parent = 0); + + /// Seconds to wait between polling requests + Q_PROPERTY(int interval READ interval WRITE setInterval) + int interval() const; + void setInterval(int interval); + +signals: + void verificationReceived(QMap); + void serverClosed(bool); // whether it has found parameters + +public slots: + void startPolling(); + +protected slots: + void onPollTimeout(); + void onExpiration(); + void onReplyFinished(); + +protected: + QNetworkAccessManager *manager_; + const QNetworkRequest request_; + const QByteArray payload_; + const int expiresIn_; + QTimer expirationTimer; + QTimer pollTimer; +}; + +} diff --git a/libraries/katabasis/include/katabasis/Reply.h b/libraries/katabasis/include/katabasis/Reply.h new file mode 100644 index 000000000..3af1d49f0 --- /dev/null +++ b/libraries/katabasis/include/katabasis/Reply.h @@ -0,0 +1,60 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace Katabasis { + +/// A network request/reply pair that can time out. +class Reply: public QTimer { + Q_OBJECT + +public: + Reply(QNetworkReply *reply, int timeOut = 60 * 1000, QObject *parent = 0); + +signals: + void error(QNetworkReply::NetworkError); + +public slots: + /// When time out occurs, the QNetworkReply's error() signal is triggered. + void onTimeOut(); + +public: + QNetworkReply *reply; +}; + +/// List of O2Replies. +class ReplyList { +public: + ReplyList() { ignoreSslErrors_ = false; } + + /// Destructor. + /// Deletes all O2Reply instances in the list. + virtual ~ReplyList(); + + /// Create a new O2Reply from a QNetworkReply, and add it to this list. + void add(QNetworkReply *reply); + + /// Add an O2Reply to the list, while taking ownership of it. + void add(Reply *reply); + + /// Remove item from the list that corresponds to a QNetworkReply. + void remove(QNetworkReply *reply); + + /// Find an O2Reply in the list, corresponding to a QNetworkReply. + /// @return Matching O2Reply or NULL. + Reply *find(QNetworkReply *reply); + + bool ignoreSslErrors(); + void setIgnoreSslErrors(bool ignoreSslErrors); + +protected: + QList replies_; + bool ignoreSslErrors_; +}; + +} diff --git a/libraries/katabasis/include/katabasis/ReplyServer.h b/libraries/katabasis/include/katabasis/ReplyServer.h new file mode 100644 index 000000000..bf47df693 --- /dev/null +++ b/libraries/katabasis/include/katabasis/ReplyServer.h @@ -0,0 +1,53 @@ +#pragma once + +#include +#include +#include +#include + +namespace Katabasis { + +/// HTTP server to process authentication response. +class ReplyServer: public QTcpServer { + Q_OBJECT + +public: + explicit ReplyServer(QObject *parent = 0); + + /// Page content on local host after successful oauth - in case you do not want to close the browser, but display something + Q_PROPERTY(QByteArray replyContent READ replyContent WRITE setReplyContent) + QByteArray replyContent(); + void setReplyContent(const QByteArray &value); + + /// Seconds to keep listening *after* first response for a callback with token content + Q_PROPERTY(int timeout READ timeout WRITE setTimeout) + int timeout(); + void setTimeout(int timeout); + + /// Maximum number of callback tries to accept, in case some don't have token content (favicons, etc.) + Q_PROPERTY(int callbackTries READ callbackTries WRITE setCallbackTries) + int callbackTries(); + void setCallbackTries(int maxtries); + + QString uniqueState(); + void setUniqueState(const QString &state); + +signals: + void verificationReceived(QMap); + void serverClosed(bool); // whether it has found parameters + +public slots: + void onIncomingConnection(); + void onBytesReady(); + QMap parseQueryParams(QByteArray *data); + void closeServer(QTcpSocket *socket = 0, bool hasparameters = false); + +protected: + QByteArray replyContent_; + int timeout_; + int maxtries_; + int tries_; + QString uniqueState_; +}; + +} diff --git a/libraries/katabasis/include/katabasis/RequestParameter.h b/libraries/katabasis/include/katabasis/RequestParameter.h new file mode 100644 index 000000000..ca36934a3 --- /dev/null +++ b/libraries/katabasis/include/katabasis/RequestParameter.h @@ -0,0 +1,15 @@ +#pragma once + +namespace Katabasis { + +/// Request parameter (name-value pair) participating in authentication. +struct RequestParameter { + RequestParameter(const QByteArray &n, const QByteArray &v): name(n), value(v) {} + bool operator <(const RequestParameter &other) const { + return (name == other.name)? (value < other.value): (name < other.name); + } + QByteArray name; + QByteArray value; +}; + +} diff --git a/libraries/katabasis/include/katabasis/Requestor.h b/libraries/katabasis/include/katabasis/Requestor.h new file mode 100644 index 000000000..61437f762 --- /dev/null +++ b/libraries/katabasis/include/katabasis/Requestor.h @@ -0,0 +1,116 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include + +#include "Reply.h" + +namespace Katabasis { + +class OAuth2; + +/// Makes authenticated requests. +class Requestor: public QObject { + Q_OBJECT + +public: + explicit Requestor(QNetworkAccessManager *manager, OAuth2 *authenticator, QObject *parent = 0); + ~Requestor(); + + + /// Some services require the access token to be sent as a Authentication HTTP header + /// and refuse requests with the access token in the query. + /// This function allows to use or ignore the access token in the query. + /// The default value of `true` means that the query will contain the access token. + /// By setting the value to false, the query will not contain the access token. + /// See: + /// https://tools.ietf.org/html/draft-ietf-oauth-v2-bearer-16#section-4.3 + /// https://tools.ietf.org/html/rfc6750#section-2.3 + + void setAddAccessTokenInQuery(bool value); + + /// Some services require the access token to be sent as a Authentication HTTP header. + /// This is the case for Twitch and Mixer. + /// When the access token expires and is refreshed, O2Requestor::retry() needs to update the Authentication HTTP header. + /// In order to do so, O2Requestor needs to know the format of the Authentication HTTP header. + void setAccessTokenInAuthenticationHTTPHeaderFormat(const QString &value); + +public slots: + /// Make a GET request. + /// @return Request ID or -1 if there are too many requests in the queue. + int get(const QNetworkRequest &req, int timeout = 60*1000); + + /// Make a POST request. + /// @return Request ID or -1 if there are too many requests in the queue. + int post(const QNetworkRequest &req, const QByteArray &data, int timeout = 60*1000); + int post(const QNetworkRequest &req, QHttpMultiPart* data, int timeout = 60*1000); + + /// Make a PUT request. + /// @return Request ID or -1 if there are too many requests in the queue. + int put(const QNetworkRequest &req, const QByteArray &data, int timeout = 60*1000); + int put(const QNetworkRequest &req, QHttpMultiPart* data, int timeout = 60*1000); + + /// Make a HEAD request. + /// @return Request ID or -1 if there are too many requests in the queue. + int head(const QNetworkRequest &req, int timeout = 60*1000); + + /// Make a custom request. + /// @return Request ID or -1 if there are too many requests in the queue. + int customRequest(const QNetworkRequest &req, const QByteArray &verb, const QByteArray &data, int timeout = 60*1000); + +signals: + + /// Emitted when a request has been completed or failed. + void finished(int id, QNetworkReply::NetworkError error, QByteArray data, QList headers); + + /// Emitted when an upload has progressed. + void uploadProgress(int id, qint64 bytesSent, qint64 bytesTotal); + +protected slots: + /// Handle refresh completion. + void onRefreshFinished(QNetworkReply::NetworkError error); + + /// Handle request finished. + void onRequestFinished(); + + /// Handle request error. + void onRequestError(QNetworkReply::NetworkError error); + + /// Re-try request (after successful token refresh). + void retry(); + + /// Finish the request, emit finished() signal. + void finish(); + + /// Handle upload progress. + void onUploadProgress(qint64 uploaded, qint64 total); + +protected: + int setup(const QNetworkRequest &request, QNetworkAccessManager::Operation operation, const QByteArray &verb = QByteArray()); + + enum Status { + Idle, Requesting, ReRequesting + }; + + QNetworkAccessManager *manager_; + OAuth2 *authenticator_; + QNetworkRequest request_; + QByteArray data_; + QHttpMultiPart* multipartData_; + QNetworkReply *reply_; + Status status_; + int id_; + QNetworkAccessManager::Operation operation_; + QUrl url_; + ReplyList timedReplies_; + QNetworkReply::NetworkError error_; + bool addAccessTokenInQuery_; + QString accessTokenInAuthenticationHTTPHeaderFormat_; + bool rawData_; +}; + +} diff --git a/libraries/katabasis/src/JsonResponse.cpp b/libraries/katabasis/src/JsonResponse.cpp new file mode 100644 index 000000000..63384d121 --- /dev/null +++ b/libraries/katabasis/src/JsonResponse.cpp @@ -0,0 +1,26 @@ +#include "JsonResponse.h" + +#include +#include +#include +#include + +namespace Katabasis { + +QVariantMap parseJsonResponse(const QByteArray &data) { + QJsonParseError err; + QJsonDocument doc = QJsonDocument::fromJson(data, &err); + if (err.error != QJsonParseError::NoError) { + qWarning() << "parseTokenResponse: Failed to parse token response due to err:" << err.errorString(); + return QVariantMap(); + } + + if (!doc.isObject()) { + qWarning() << "parseTokenResponse: Token response is not an object"; + return QVariantMap(); + } + + return doc.object().toVariantMap(); +} + +} diff --git a/libraries/katabasis/src/JsonResponse.h b/libraries/katabasis/src/JsonResponse.h new file mode 100644 index 000000000..e7fe7e30d --- /dev/null +++ b/libraries/katabasis/src/JsonResponse.h @@ -0,0 +1,12 @@ +#pragma once + +#include + +class QByteArray; + +namespace Katabasis { + + /// Parse JSON data into a QVariantMap +QVariantMap parseJsonResponse(const QByteArray &data); + +} diff --git a/libraries/katabasis/src/OAuth2.cpp b/libraries/katabasis/src/OAuth2.cpp new file mode 100644 index 000000000..6cc03a0d2 --- /dev/null +++ b/libraries/katabasis/src/OAuth2.cpp @@ -0,0 +1,668 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "katabasis/OAuth2.h" +#include "katabasis/PollServer.h" +#include "katabasis/ReplyServer.h" +#include "katabasis/Globals.h" + +#include "JsonResponse.h" + +namespace { +// ref: https://tools.ietf.org/html/rfc8628#section-3.2 +// Exception: Google sign-in uses "verification_url" instead of "*_uri" - we'll accept both. +bool hasMandatoryDeviceAuthParams(const QVariantMap& params) +{ + if (!params.contains(Katabasis::OAUTH2_DEVICE_CODE)) + return false; + + if (!params.contains(Katabasis::OAUTH2_USER_CODE)) + return false; + + if (!(params.contains(Katabasis::OAUTH2_VERIFICATION_URI) || params.contains(Katabasis::OAUTH2_VERIFICATION_URL))) + return false; + + if (!params.contains(Katabasis::OAUTH2_EXPIRES_IN)) + return false; + + return true; +} + +QByteArray createQueryParameters(const QList ¶meters) { + QByteArray ret; + bool first = true; + for( auto & h: parameters) { + if (first) { + first = false; + } else { + ret.append("&"); + } + ret.append(QUrl::toPercentEncoding(h.name) + "=" + QUrl::toPercentEncoding(h.value)); + } + return ret; +} +} + +namespace Katabasis { + +OAuth2::OAuth2(Options & opts, Token & token, QObject *parent, QNetworkAccessManager *manager) : QObject(parent), token_(token) { + manager_ = manager ? manager : new QNetworkAccessManager(this); + grantFlow_ = GrantFlowAuthorizationCode; + qRegisterMetaType("QNetworkReply::NetworkError"); + options_ = opts; +} + +bool OAuth2::linked() { + return token_.validity != Validity::None; +} +void OAuth2::setLinked(bool v) { + qDebug() << "OAuth2::setLinked:" << (v? "true": "false"); + token_.validity = v ? Validity::Certain : Validity::None; +} + +QString OAuth2::token() { + return token_.token; +} +void OAuth2::setToken(const QString &v) { + token_.token = v; +} + +QByteArray OAuth2::replyContent() const { + return replyContent_; +} + +void OAuth2::setReplyContent(const QByteArray &value) { + replyContent_ = value; + if (replyServer_) { + replyServer_->setReplyContent(replyContent_); + } +} + +QVariantMap OAuth2::extraTokens() { + return token_.extra; +} + +void OAuth2::setExtraTokens(QVariantMap extraTokens) { + token_.extra = extraTokens; +} + +void OAuth2::setReplyServer(ReplyServer * server) +{ + delete replyServer_; + + replyServer_ = server; + replyServer_->setReplyContent(replyContent_); +} + +ReplyServer * OAuth2::replyServer() const +{ + return replyServer_; +} + +void OAuth2::setPollServer(PollServer *server) +{ + if (pollServer_) + pollServer_->deleteLater(); + + pollServer_ = server; +} + +PollServer *OAuth2::pollServer() const +{ + return pollServer_; +} + +OAuth2::GrantFlow OAuth2::grantFlow() { + return grantFlow_; +} + +void OAuth2::setGrantFlow(OAuth2::GrantFlow value) { + grantFlow_ = value; +} + +QString OAuth2::username() { + return username_; +} + +void OAuth2::setUsername(const QString &value) { + username_ = value; +} + +QString OAuth2::password() { + return password_; +} + +void OAuth2::setPassword(const QString &value) { + password_ = value; +} + +QVariantMap OAuth2::extraRequestParams() +{ + return extraReqParams_; +} + +void OAuth2::setExtraRequestParams(const QVariantMap &value) +{ + extraReqParams_ = value; +} + +QString OAuth2::grantType() +{ + if (!grantType_.isEmpty()) + return grantType_; + + switch (grantFlow_) { + case GrantFlowAuthorizationCode: + return OAUTH2_GRANT_TYPE_CODE; + case GrantFlowImplicit: + return OAUTH2_GRANT_TYPE_TOKEN; + case GrantFlowResourceOwnerPasswordCredentials: + return OAUTH2_GRANT_TYPE_PASSWORD; + case GrantFlowDevice: + return OAUTH2_GRANT_TYPE_DEVICE; + } + + return QString(); +} + +void OAuth2::setGrantType(const QString &value) +{ + grantType_ = value; +} + +void OAuth2::updateActivity(Activity activity) +{ + if(activity_ != activity) { + activity_ = activity; + emit activityChanged(activity_); + } +} + +void OAuth2::link() { + qDebug() << "OAuth2::link"; + + // Create the reply server if it doesn't exist + if(replyServer() == NULL) { + ReplyServer * replyServer = new ReplyServer(this); + connect(replyServer, &ReplyServer::verificationReceived, this, &OAuth2::onVerificationReceived); + connect(replyServer, &ReplyServer::serverClosed, this, &OAuth2::serverHasClosed); + setReplyServer(replyServer); + } + + if (linked()) { + qDebug() << "OAuth2::link: Linked already"; + emit linkingSucceeded(); + return; + } + + setLinked(false); + setToken(""); + setExtraTokens(QVariantMap()); + setRefreshToken(QString()); + setExpires(QDateTime()); + + if (grantFlow_ == GrantFlowAuthorizationCode || grantFlow_ == GrantFlowImplicit) { + + QString uniqueState = QUuid::createUuid().toString().remove(QRegExp("([^a-zA-Z0-9]|[-])")); + + // FIXME: this should be part of a 'redirection handler' that would get injected into O2 + { + quint16 foundPort = 0; + // Start listening to authentication replies + if (!replyServer()->isListening()) { + auto ports = options_.listenerPorts; + for(auto & port: ports) { + if (replyServer()->listen(QHostAddress::Any, port)) { + foundPort = replyServer()->serverPort(); + qDebug() << "OAuth2::link: Reply server listening on port " << foundPort; + break; + } + } + if(foundPort == 0) { + qWarning() << "OAuth2::link: Reply server failed to start listening on any port out of " << ports; + emit linkingFailed(); + return; + } + } + + // Save redirect URI, as we have to reuse it when requesting the access token + redirectUri_ = options_.redirectionUrl.arg(foundPort); + replyServer()->setUniqueState(uniqueState); + } + + // Assemble intial authentication URL + QUrl url(options_.authorizationUrl); + QUrlQuery query(url); + QList > parameters; + query.addQueryItem(OAUTH2_RESPONSE_TYPE, (grantFlow_ == GrantFlowAuthorizationCode)? OAUTH2_GRANT_TYPE_CODE: OAUTH2_GRANT_TYPE_TOKEN); + query.addQueryItem(OAUTH2_CLIENT_ID, options_.clientIdentifier); + query.addQueryItem(OAUTH2_REDIRECT_URI, redirectUri_); + query.addQueryItem(OAUTH2_SCOPE, options_.scope.replace( " ", "+" )); + query.addQueryItem(OAUTH2_STATE, uniqueState); + if (!apiKey_.isEmpty()) { + query.addQueryItem(OAUTH2_API_KEY, apiKey_); + } + for(auto iter = extraReqParams_.begin(); iter != extraReqParams_.end(); iter++) { + query.addQueryItem(iter.key(), iter.value().toString()); + } + url.setQuery(query); + + // Show authentication URL with a web browser + qDebug() << "OAuth2::link: Emit openBrowser" << url.toString(); + emit openBrowser(url); + updateActivity(Activity::LoggingIn); + } else if (grantFlow_ == GrantFlowResourceOwnerPasswordCredentials) { + QList parameters; + parameters.append(RequestParameter(OAUTH2_CLIENT_ID, options_.clientIdentifier.toUtf8())); + if ( !options_.clientSecret.isEmpty() ) { + parameters.append(RequestParameter(OAUTH2_CLIENT_SECRET, options_.clientSecret.toUtf8())); + } + parameters.append(RequestParameter(OAUTH2_USERNAME, username_.toUtf8())); + parameters.append(RequestParameter(OAUTH2_PASSWORD, password_.toUtf8())); + parameters.append(RequestParameter(OAUTH2_GRANT_TYPE, OAUTH2_GRANT_TYPE_PASSWORD)); + parameters.append(RequestParameter(OAUTH2_SCOPE, options_.scope.toUtf8())); + if ( !apiKey_.isEmpty() ) + parameters.append(RequestParameter(OAUTH2_API_KEY, apiKey_.toUtf8())); + foreach (QString key, extraRequestParams().keys()) { + parameters.append(RequestParameter(key.toUtf8(), extraRequestParams().value(key).toByteArray())); + } + QByteArray payload = createQueryParameters(parameters); + + qDebug() << "OAuth2::link: Sending token request for resource owner flow"; + QUrl url(options_.accessTokenUrl); + QNetworkRequest tokenRequest(url); + tokenRequest.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-www-form-urlencoded"); + QNetworkReply *tokenReply = manager_->post(tokenRequest, payload); + + connect(tokenReply, SIGNAL(finished()), this, SLOT(onTokenReplyFinished()), Qt::QueuedConnection); + connect(tokenReply, SIGNAL(error(QNetworkReply::NetworkError)), this, SLOT(onTokenReplyError(QNetworkReply::NetworkError)), Qt::QueuedConnection); + updateActivity(Activity::LoggingIn); + } + else if (grantFlow_ == GrantFlowDevice) { + QList parameters; + parameters.append(RequestParameter(OAUTH2_CLIENT_ID, options_.clientIdentifier.toUtf8())); + parameters.append(RequestParameter(OAUTH2_SCOPE, options_.scope.toUtf8())); + QByteArray payload = createQueryParameters(parameters); + + QUrl url(options_.authorizationUrl); + QNetworkRequest deviceRequest(url); + deviceRequest.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-www-form-urlencoded"); + QNetworkReply *tokenReply = manager_->post(deviceRequest, payload); + + connect(tokenReply, SIGNAL(finished()), this, SLOT(onDeviceAuthReplyFinished()), Qt::QueuedConnection); + connect(tokenReply, SIGNAL(error(QNetworkReply::NetworkError)), this, SLOT(onTokenReplyError(QNetworkReply::NetworkError)), Qt::QueuedConnection); + updateActivity(Activity::LoggingIn); + } +} + +void OAuth2::unlink() { + qDebug() << "OAuth2::unlink"; + updateActivity(Activity::LoggingOut); + // FIXME: implement logout flows... if they exist + token_ = Token(); + updateActivity(Activity::Idle); +} + +void OAuth2::onVerificationReceived(const QMap response) { + qDebug() << "OAuth2::onVerificationReceived: Emitting closeBrowser()"; + emit closeBrowser(); + + if (response.contains("error")) { + qWarning() << "OAuth2::onVerificationReceived: Verification failed:" << response; + emit linkingFailed(); + updateActivity(Activity::Idle); + return; + } + + if (grantFlow_ == GrantFlowAuthorizationCode) { + // NOTE: access code is temporary and should never be saved anywhere! + auto access_code = response.value(QString(OAUTH2_GRANT_TYPE_CODE)); + + // Exchange access code for access/refresh tokens + QString query; + if(!apiKey_.isEmpty()) + query = QString("?" + QString(OAUTH2_API_KEY) + "=" + apiKey_); + QNetworkRequest tokenRequest(QUrl(options_.accessTokenUrl.toString() + query)); + tokenRequest.setHeader(QNetworkRequest::ContentTypeHeader, MIME_TYPE_XFORM); + tokenRequest.setRawHeader("Accept", MIME_TYPE_JSON); + QMap parameters; + parameters.insert(OAUTH2_GRANT_TYPE_CODE, access_code); + parameters.insert(OAUTH2_CLIENT_ID, options_.clientIdentifier); + if ( !options_.clientSecret.isEmpty() ) { + parameters.insert(OAUTH2_CLIENT_SECRET, options_.clientSecret); + } + parameters.insert(OAUTH2_REDIRECT_URI, redirectUri_); + parameters.insert(OAUTH2_GRANT_TYPE, AUTHORIZATION_CODE); + QByteArray data = buildRequestBody(parameters); + + qDebug() << QString("OAuth2::onVerificationReceived: Exchange access code data:\n%1").arg(QString(data)); + + QNetworkReply *tokenReply = manager_->post(tokenRequest, data); + timedReplies_.add(tokenReply); + connect(tokenReply, SIGNAL(finished()), this, SLOT(onTokenReplyFinished()), Qt::QueuedConnection); + connect(tokenReply, SIGNAL(error(QNetworkReply::NetworkError)), this, SLOT(onTokenReplyError(QNetworkReply::NetworkError)), Qt::QueuedConnection); + } else if (grantFlow_ == GrantFlowImplicit || grantFlow_ == GrantFlowDevice) { + // Check for mandatory tokens + if (response.contains(OAUTH2_ACCESS_TOKEN)) { + qDebug() << "OAuth2::onVerificationReceived: Access token returned for implicit or device flow"; + setToken(response.value(OAUTH2_ACCESS_TOKEN)); + if (response.contains(OAUTH2_EXPIRES_IN)) { + bool ok = false; + int expiresIn = response.value(OAUTH2_EXPIRES_IN).toInt(&ok); + if (ok) { + qDebug() << "OAuth2::onVerificationReceived: Token expires in" << expiresIn << "seconds"; + setExpires(QDateTime::currentDateTimeUtc().addSecs(expiresIn)); + } + } + if (response.contains(OAUTH2_REFRESH_TOKEN)) { + setRefreshToken(response.value(OAUTH2_REFRESH_TOKEN)); + } + setLinked(true); + emit linkingSucceeded(); + } else { + qWarning() << "OAuth2::onVerificationReceived: Access token missing from response for implicit or device flow"; + emit linkingFailed(); + } + updateActivity(Activity::Idle); + } else { + setToken(response.value(OAUTH2_ACCESS_TOKEN)); + setRefreshToken(response.value(OAUTH2_REFRESH_TOKEN)); + updateActivity(Activity::Idle); + } +} + +void OAuth2::onTokenReplyFinished() { + qDebug() << "OAuth2::onTokenReplyFinished"; + QNetworkReply *tokenReply = qobject_cast(sender()); + if (!tokenReply) + { + qDebug() << "OAuth2::onTokenReplyFinished: reply is null"; + return; + } + if (tokenReply->error() == QNetworkReply::NoError) { + QByteArray replyData = tokenReply->readAll(); + + // Dump replyData + // SENSITIVE DATA in RelWithDebInfo or Debug builds + //qDebug() << "OAuth2::onTokenReplyFinished: replyData\n"; + //qDebug() << QString( replyData ); + + QVariantMap tokens = parseJsonResponse(replyData); + + // Dump tokens + qDebug() << "OAuth2::onTokenReplyFinished: Tokens returned:\n"; + foreach (QString key, tokens.keys()) { + // SENSITIVE DATA in RelWithDebInfo or Debug builds, so it is truncated first + qDebug() << key << ": "<< tokens.value( key ).toString(); + } + + // Check for mandatory tokens + if (tokens.contains(OAUTH2_ACCESS_TOKEN)) { + qDebug() << "OAuth2::onTokenReplyFinished: Access token returned"; + setToken(tokens.take(OAUTH2_ACCESS_TOKEN).toString()); + bool ok = false; + int expiresIn = tokens.take(OAUTH2_EXPIRES_IN).toInt(&ok); + if (ok) { + qDebug() << "OAuth2::onTokenReplyFinished: Token expires in" << expiresIn << "seconds"; + setExpires(QDateTime::currentDateTimeUtc().addSecs(expiresIn)); + } + setRefreshToken(tokens.take(OAUTH2_REFRESH_TOKEN).toString()); + setExtraTokens(tokens); + timedReplies_.remove(tokenReply); + setLinked(true); + emit linkingSucceeded(); + } else { + qWarning() << "OAuth2::onTokenReplyFinished: Access token missing from response"; + emit linkingFailed(); + } + } + tokenReply->deleteLater(); + updateActivity(Activity::Idle); +} + +void OAuth2::onTokenReplyError(QNetworkReply::NetworkError error) { + QNetworkReply *tokenReply = qobject_cast(sender()); + if (!tokenReply) + { + qDebug() << "OAuth2::onTokenReplyError: reply is null"; + } else { + qWarning() << "OAuth2::onTokenReplyError: " << error << ": " << tokenReply->errorString(); + qDebug() << "OAuth2::onTokenReplyError: " << tokenReply->readAll(); + timedReplies_.remove(tokenReply); + } + + setToken(QString()); + setRefreshToken(QString()); + emit linkingFailed(); +} + +QByteArray OAuth2::buildRequestBody(const QMap ¶meters) { + QByteArray body; + bool first = true; + foreach (QString key, parameters.keys()) { + if (first) { + first = false; + } else { + body.append("&"); + } + QString value = parameters.value(key); + body.append(QUrl::toPercentEncoding(key) + QString("=").toUtf8() + QUrl::toPercentEncoding(value)); + } + return body; +} + +QDateTime OAuth2::expires() { + return token_.notAfter; +} +void OAuth2::setExpires(QDateTime v) { + token_.notAfter = v; +} + +void OAuth2::startPollServer(const QVariantMap ¶ms) +{ + 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"; + + QUrl url(options_.accessTokenUrl); + QNetworkRequest authRequest(url); + authRequest.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-www-form-urlencoded"); + + const QString deviceCode = params[OAUTH2_DEVICE_CODE].toString(); + const QString grantType = grantType_.isEmpty() ? OAUTH2_GRANT_TYPE_DEVICE : grantType_; + + QList parameters; + parameters.append(RequestParameter(OAUTH2_CLIENT_ID, options_.clientIdentifier.toUtf8())); + if ( !options_.clientSecret.isEmpty() ) { + parameters.append(RequestParameter(OAUTH2_CLIENT_SECRET, options_.clientSecret.toUtf8())); + } + parameters.append(RequestParameter(OAUTH2_CODE, deviceCode.toUtf8())); + parameters.append(RequestParameter(OAUTH2_GRANT_TYPE, grantType.toUtf8())); + QByteArray payload = createQueryParameters(parameters); + + PollServer * pollServer = new PollServer(manager_, authRequest, payload, expiresIn, this); + if (params.contains(OAUTH2_INTERVAL)) { + int interval = params[OAUTH2_INTERVAL].toInt(&ok); + if (ok) + pollServer->setInterval(interval); + } + connect(pollServer, SIGNAL(verificationReceived(QMap)), this, SLOT(onVerificationReceived(QMap))); + connect(pollServer, SIGNAL(serverClosed(bool)), this, SLOT(serverHasClosed(bool))); + setPollServer(pollServer); + pollServer->startPolling(); +} + +QString OAuth2::refreshToken() { + return token_.refresh_token; +} +void OAuth2::setRefreshToken(const QString &v) { + qDebug() << "OAuth2::setRefreshToken" << v << "..."; + token_.refresh_token = v; +} + +bool OAuth2::refresh() { + qDebug() << "OAuth2::refresh: Token: ..." << refreshToken().right(7); + + if (refreshToken().isEmpty()) { + qWarning() << "OAuth2::refresh: No refresh token"; + onRefreshError(QNetworkReply::AuthenticationRequiredError); + return false; + } + if (options_.accessTokenUrl.isEmpty()) { + qWarning() << "OAuth2::refresh: Refresh token URL not set"; + onRefreshError(QNetworkReply::AuthenticationRequiredError); + return false; + } + + updateActivity(Activity::Refreshing); + + QNetworkRequest refreshRequest(options_.accessTokenUrl); + refreshRequest.setHeader(QNetworkRequest::ContentTypeHeader, MIME_TYPE_XFORM); + QMap parameters; + parameters.insert(OAUTH2_CLIENT_ID, options_.clientIdentifier); + if ( !options_.clientSecret.isEmpty() ) { + parameters.insert(OAUTH2_CLIENT_SECRET, options_.clientSecret); + } + parameters.insert(OAUTH2_REFRESH_TOKEN, refreshToken()); + parameters.insert(OAUTH2_GRANT_TYPE, OAUTH2_REFRESH_TOKEN); + + QByteArray data = buildRequestBody(parameters); + QNetworkReply *refreshReply = manager_->post(refreshRequest, data); + timedReplies_.add(refreshReply); + connect(refreshReply, SIGNAL(finished()), this, SLOT(onRefreshFinished()), Qt::QueuedConnection); + connect(refreshReply, SIGNAL(error(QNetworkReply::NetworkError)), this, SLOT(onRefreshError(QNetworkReply::NetworkError)), Qt::QueuedConnection); + return true; +} + +void OAuth2::onRefreshFinished() { + QNetworkReply *refreshReply = qobject_cast(sender()); + + if (refreshReply->error() == QNetworkReply::NoError) { + QByteArray reply = refreshReply->readAll(); + QVariantMap tokens = parseJsonResponse(reply); + setToken(tokens.value(OAUTH2_ACCESS_TOKEN).toString()); + setExpires(QDateTime::currentDateTimeUtc().addSecs(tokens.value(OAUTH2_EXPIRES_IN).toInt())); + QString refreshToken = tokens.value(OAUTH2_REFRESH_TOKEN).toString(); + if(!refreshToken.isEmpty()) { + setRefreshToken(refreshToken); + } + else { + qDebug() << "No new refresh token. Keep the old one."; + } + timedReplies_.remove(refreshReply); + setLinked(true); + emit linkingSucceeded(); + emit refreshFinished(QNetworkReply::NoError); + qDebug() << " New token expires in" << expires() << "seconds"; + } else { + qDebug() << "OAuth2::onRefreshFinished: Error" << (int)refreshReply->error() << refreshReply->errorString(); + } + refreshReply->deleteLater(); + updateActivity(Activity::Idle); +} + +void OAuth2::onRefreshError(QNetworkReply::NetworkError error) { + QNetworkReply *refreshReply = qobject_cast(sender()); + qWarning() << "OAuth2::onRefreshError: " << error; + unlink(); + timedReplies_.remove(refreshReply); + emit refreshFinished(error); +} + +void OAuth2::onDeviceAuthReplyFinished() +{ + qDebug() << "OAuth2::onDeviceAuthReplyFinished"; + QNetworkReply *tokenReply = qobject_cast(sender()); + if (!tokenReply) + { + qDebug() << "OAuth2::onDeviceAuthReplyFinished: reply is null"; + return; + } + if (tokenReply->error() == QNetworkReply::NoError) { + QByteArray replyData = tokenReply->readAll(); + + // Dump replyData + // SENSITIVE DATA in RelWithDebInfo or Debug builds + //qDebug() << "OAuth2::onDeviceAuthReplyFinished: replyData\n"; + //qDebug() << QString( replyData ); + + QVariantMap params = parseJsonResponse(replyData); + + // Dump tokens + qDebug() << "OAuth2::onDeviceAuthReplyFinished: Tokens returned:\n"; + foreach (QString key, params.keys()) { + // SENSITIVE DATA in RelWithDebInfo or Debug builds, so it is truncated first + qDebug() << key << ": "<< params.value( key ).toString(); + } + + // Check for mandatory parameters + if (hasMandatoryDeviceAuthParams(params)) { + qDebug() << "OAuth2::onDeviceAuthReplyFinished: Device auth request response"; + + const QString userCode = params.take(OAUTH2_USER_CODE).toString(); + QUrl uri = params.take(OAUTH2_VERIFICATION_URI).toUrl(); + if (uri.isEmpty()) + uri = params.take(OAUTH2_VERIFICATION_URL).toUrl(); + + if (params.contains(OAUTH2_VERIFICATION_URI_COMPLETE)) + emit openBrowser(params.take(OAUTH2_VERIFICATION_URI_COMPLETE).toUrl()); + + emit showVerificationUriAndCode(uri, userCode); + + startPollServer(params); + } else { + qWarning() << "OAuth2::onDeviceAuthReplyFinished: Mandatory parameters missing from response"; + emit linkingFailed(); + updateActivity(Activity::Idle); + } + } + tokenReply->deleteLater(); +} + +void OAuth2::serverHasClosed(bool paramsfound) +{ + if ( !paramsfound ) { + // server has probably timed out after receiving first response + emit linkingFailed(); + } + // poll server is not re-used for later auth requests + setPollServer(NULL); +} + +QString OAuth2::apiKey() { + return apiKey_; +} + +void OAuth2::setApiKey(const QString &value) { + apiKey_ = value; +} + +bool OAuth2::ignoreSslErrors() { + return timedReplies_.ignoreSslErrors(); +} + +void OAuth2::setIgnoreSslErrors(bool ignoreSslErrors) { + timedReplies_.setIgnoreSslErrors(ignoreSslErrors); +} + +} diff --git a/libraries/katabasis/src/PollServer.cpp b/libraries/katabasis/src/PollServer.cpp new file mode 100644 index 000000000..1083c5994 --- /dev/null +++ b/libraries/katabasis/src/PollServer.cpp @@ -0,0 +1,123 @@ +#include +#include + +#include "katabasis/PollServer.h" +#include "JsonResponse.h" + +namespace { +QMap toVerificationParams(const QVariantMap &map) +{ + QMap params; + for (QVariantMap::const_iterator i = map.constBegin(); + i != map.constEnd(); ++i) + { + params[i.key()] = i.value().toString(); + } + return params; +} +} + +namespace Katabasis { + +PollServer::PollServer(QNetworkAccessManager *manager, const QNetworkRequest &request, const QByteArray &payload, int expiresIn, QObject *parent) + : QObject(parent) + , manager_(manager) + , request_(request) + , payload_(payload) + , expiresIn_(expiresIn) +{ + expirationTimer.setTimerType(Qt::VeryCoarseTimer); + expirationTimer.setInterval(expiresIn * 1000); + expirationTimer.setSingleShot(true); + connect(&expirationTimer, SIGNAL(timeout()), this, SLOT(onExpiration())); + expirationTimer.start(); + + pollTimer.setTimerType(Qt::VeryCoarseTimer); + pollTimer.setInterval(5 * 1000); + pollTimer.setSingleShot(true); + connect(&pollTimer, SIGNAL(timeout()), this, SLOT(onPollTimeout())); +} + +int PollServer::interval() const +{ + return pollTimer.interval() / 1000; +} + +void PollServer::setInterval(int interval) +{ + pollTimer.setInterval(interval * 1000); +} + +void PollServer::startPolling() +{ + if (expirationTimer.isActive()) { + pollTimer.start(); + } +} + +void PollServer::onPollTimeout() +{ + qDebug() << "PollServer::onPollTimeout: retrying"; + QNetworkReply * reply = manager_->post(request_, payload_); + connect(reply, SIGNAL(finished()), this, SLOT(onReplyFinished())); +} + +void PollServer::onExpiration() +{ + pollTimer.stop(); + emit serverClosed(false); +} + +void PollServer::onReplyFinished() +{ + QNetworkReply *reply = qobject_cast(sender()); + + if (!reply) { + qDebug() << "PollServer::onReplyFinished: reply is null"; + return; + } + + QByteArray replyData = reply->readAll(); + QMap params = toVerificationParams(parseJsonResponse(replyData)); + + // Dump replyData + // SENSITIVE DATA in RelWithDebInfo or Debug builds + // qDebug() << "PollServer::onReplyFinished: replyData\n"; + // qDebug() << QString( replyData ); + + if (reply->error() == QNetworkReply::TimeoutError) { + // rfc8628#section-3.2 + // "On encountering a connection timeout, clients MUST unilaterally + // reduce their polling frequency before retrying. The use of an + // exponential backoff algorithm to achieve this, such as doubling the + // polling interval on each such connection timeout, is RECOMMENDED." + setInterval(interval() * 2); + pollTimer.start(); + } + else { + QString error = params.value("error"); + if (error == "slow_down") { + // rfc8628#section-3.2 + // "A variant of 'authorization_pending', the authorization request is + // still pending and polling should continue, but the interval MUST + // be increased by 5 seconds for this and all subsequent requests." + setInterval(interval() + 5); + pollTimer.start(); + } + else if (error == "authorization_pending") { + // keep trying - rfc8628#section-3.2 + // "The authorization request is still pending as the end user hasn't + // yet completed the user-interaction steps (Section 3.3)." + pollTimer.start(); + } + else { + expirationTimer.stop(); + emit serverClosed(true); + // let O2 handle the other cases + emit verificationReceived(params); + } + } + reply->deleteLater(); +} + +} diff --git a/libraries/katabasis/src/Reply.cpp b/libraries/katabasis/src/Reply.cpp new file mode 100644 index 000000000..775b92021 --- /dev/null +++ b/libraries/katabasis/src/Reply.cpp @@ -0,0 +1,62 @@ +#include +#include + +#include "katabasis/Reply.h" + +namespace Katabasis { + +Reply::Reply(QNetworkReply *r, int timeOut, QObject *parent): QTimer(parent), reply(r) { + setSingleShot(true); + connect(this, SIGNAL(error(QNetworkReply::NetworkError)), reply, SIGNAL(error(QNetworkReply::NetworkError)), Qt::QueuedConnection); + connect(this, SIGNAL(timeout()), this, SLOT(onTimeOut()), Qt::QueuedConnection); + start(timeOut); +} + +void Reply::onTimeOut() { + emit error(QNetworkReply::TimeoutError); +} + +ReplyList::~ReplyList() { + foreach (Reply *timedReply, replies_) { + delete timedReply; + } +} + +void ReplyList::add(QNetworkReply *reply) { + if (reply && ignoreSslErrors()) + reply->ignoreSslErrors(); + add(new Reply(reply)); +} + +void ReplyList::add(Reply *reply) { + replies_.append(reply); +} + +void ReplyList::remove(QNetworkReply *reply) { + Reply *o2Reply = find(reply); + if (o2Reply) { + o2Reply->stop(); + (void)replies_.removeOne(o2Reply); + } +} + +Reply *ReplyList::find(QNetworkReply *reply) { + foreach (Reply *timedReply, replies_) { + if (timedReply->reply == reply) { + return timedReply; + } + } + return 0; +} + +bool ReplyList::ignoreSslErrors() +{ + return ignoreSslErrors_; +} + +void ReplyList::setIgnoreSslErrors(bool ignoreSslErrors) +{ + ignoreSslErrors_ = ignoreSslErrors; +} + +} diff --git a/libraries/katabasis/src/ReplyServer.cpp b/libraries/katabasis/src/ReplyServer.cpp new file mode 100755 index 000000000..4598b18af --- /dev/null +++ b/libraries/katabasis/src/ReplyServer.cpp @@ -0,0 +1,182 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "katabasis/Globals.h" +#include "katabasis/ReplyServer.h" + +namespace Katabasis { + +ReplyServer::ReplyServer(QObject *parent): QTcpServer(parent), + timeout_(15), maxtries_(3), tries_(0) { + qDebug() << "O2ReplyServer: Starting"; + connect(this, SIGNAL(newConnection()), this, SLOT(onIncomingConnection())); + replyContent_ = ""; +} + +void ReplyServer::onIncomingConnection() { + qDebug() << "O2ReplyServer::onIncomingConnection: Receiving..."; + QTcpSocket *socket = nextPendingConnection(); + connect(socket, SIGNAL(readyRead()), this, SLOT(onBytesReady()), Qt::UniqueConnection); + connect(socket, SIGNAL(disconnected()), socket, SLOT(deleteLater())); + + // Wait for a bit *after* first response, then close server if no useable data has arrived + // Helps with implicit flow, where a URL fragment may need processed by local user-agent and + // sent as secondary query string callback, or additional requests make it through first, + // like for favicons, etc., before such secondary callbacks are fired + QTimer *timer = new QTimer(socket); + timer->setObjectName("timeoutTimer"); + connect(timer, SIGNAL(timeout()), this, SLOT(closeServer())); + timer->setSingleShot(true); + timer->setInterval(timeout() * 1000); + connect(socket, SIGNAL(readyRead()), timer, SLOT(start())); +} + +void ReplyServer::onBytesReady() { + if (!isListening()) { + // server has been closed, stop processing queued connections + return; + } + qDebug() << "O2ReplyServer::onBytesReady: Processing request"; + // NOTE: on first call, the timeout timer is started + QTcpSocket *socket = qobject_cast(sender()); + if (!socket) { + qWarning() << "O2ReplyServer::onBytesReady: No socket available"; + return; + } + QByteArray reply; + reply.append("HTTP/1.0 200 OK \r\n"); + reply.append("Content-Type: text/html; charset=\"utf-8\"\r\n"); + reply.append(QString("Content-Length: %1\r\n\r\n").arg(replyContent_.size()).toLatin1()); + reply.append(replyContent_); + socket->write(reply); + qDebug() << "O2ReplyServer::onBytesReady: Sent reply"; + + QByteArray data = socket->readAll(); + QMap queryParams = parseQueryParams(&data); + if (queryParams.isEmpty()) { + if (tries_ < maxtries_ ) { + qDebug() << "O2ReplyServer::onBytesReady: No query params found, waiting for more callbacks"; + ++tries_; + return; + } else { + tries_ = 0; + qWarning() << "O2ReplyServer::onBytesReady: No query params found, maximum callbacks received"; + closeServer(socket, false); + return; + } + } + if (!uniqueState_.isEmpty() && !queryParams.contains(QString(OAUTH2_STATE))) { + qDebug() << "O2ReplyServer::onBytesReady: Malicious or service request"; + closeServer(socket, true); + return; // Malicious or service (e.g. favicon.ico) request + } + qDebug() << "O2ReplyServer::onBytesReady: Query params found, closing server"; + closeServer(socket, true); + emit verificationReceived(queryParams); +} + +QMap ReplyServer::parseQueryParams(QByteArray *data) { + qDebug() << "O2ReplyServer::parseQueryParams"; + + //qDebug() << QString("O2ReplyServer::parseQueryParams data:\n%1").arg(QString(*data)); + + QString splitGetLine = QString(*data).split("\r\n").first(); + splitGetLine.remove("GET "); + splitGetLine.remove("HTTP/1.1"); + splitGetLine.remove("\r\n"); + splitGetLine.prepend("http://localhost"); + QUrl getTokenUrl(splitGetLine); + + QList< QPair > tokens; + QUrlQuery query(getTokenUrl); + tokens = query.queryItems(); + QMap queryParams; + QPair tokenPair; + foreach (tokenPair, tokens) { + // FIXME: We are decoding key and value again. This helps with Google OAuth, but is it mandated by the standard? + QString key = QUrl::fromPercentEncoding(QByteArray().append(tokenPair.first.trimmed().toLatin1())); + QString value = QUrl::fromPercentEncoding(QByteArray().append(tokenPair.second.trimmed().toLatin1())); + queryParams.insert(key, value); + } + return queryParams; +} + +void ReplyServer::closeServer(QTcpSocket *socket, bool hasparameters) +{ + if (!isListening()) { + return; + } + + qDebug() << "O2ReplyServer::closeServer: Initiating"; + int port = serverPort(); + + if (!socket && sender()) { + QTimer *timer = qobject_cast(sender()); + if (timer) { + qWarning() << "O2ReplyServer::closeServer: Closing due to timeout"; + timer->stop(); + socket = qobject_cast(timer->parent()); + timer->deleteLater(); + } + } + if (socket) { + QTimer *timer = socket->findChild("timeoutTimer"); + if (timer) { + qDebug() << "O2ReplyServer::closeServer: Stopping socket's timeout timer"; + timer->stop(); + } + socket->disconnectFromHost(); + } + close(); + qDebug() << "O2ReplyServer::closeServer: Closed, no longer listening on port" << port; + emit serverClosed(hasparameters); +} + +QByteArray ReplyServer::replyContent() { + return replyContent_; +} + +void ReplyServer::setReplyContent(const QByteArray &value) { + replyContent_ = value; +} + +int ReplyServer::timeout() +{ + return timeout_; +} + +void ReplyServer::setTimeout(int timeout) +{ + timeout_ = timeout; +} + +int ReplyServer::callbackTries() +{ + return maxtries_; +} + +void ReplyServer::setCallbackTries(int maxtries) +{ + maxtries_ = maxtries; +} + +QString ReplyServer::uniqueState() +{ + return uniqueState_; +} + +void ReplyServer::setUniqueState(const QString &state) +{ + uniqueState_ = state; +} + +} diff --git a/libraries/katabasis/src/Requestor.cpp b/libraries/katabasis/src/Requestor.cpp new file mode 100644 index 000000000..7b6d26796 --- /dev/null +++ b/libraries/katabasis/src/Requestor.cpp @@ -0,0 +1,304 @@ +#include + +#include +#include +#include +#include + +#include "katabasis/Requestor.h" +#include "katabasis/OAuth2.h" +#include "katabasis/Globals.h" + +namespace Katabasis { + +Requestor::Requestor(QNetworkAccessManager *manager, OAuth2 *authenticator, QObject *parent): QObject(parent), reply_(NULL), status_(Idle), addAccessTokenInQuery_(true), rawData_(false) { + manager_ = manager; + authenticator_ = authenticator; + if (authenticator) { + timedReplies_.setIgnoreSslErrors(authenticator->ignoreSslErrors()); + } + qRegisterMetaType("QNetworkReply::NetworkError"); + connect(authenticator, &OAuth2::refreshFinished, this, &Requestor::onRefreshFinished, Qt::QueuedConnection); +} + +Requestor::~Requestor() { +} + +void Requestor::setAddAccessTokenInQuery(bool value) { + addAccessTokenInQuery_ = value; +} + +void Requestor::setAccessTokenInAuthenticationHTTPHeaderFormat(const QString &value) { + accessTokenInAuthenticationHTTPHeaderFormat_ = value; +} + +int Requestor::get(const QNetworkRequest &req, int timeout/* = 60*1000*/) { + if (-1 == setup(req, QNetworkAccessManager::GetOperation)) { + return -1; + } + reply_ = manager_->get(request_); + timedReplies_.add(new Reply(reply_, timeout)); + connect(reply_, SIGNAL(error(QNetworkReply::NetworkError)), this, SLOT(onRequestError(QNetworkReply::NetworkError)), Qt::QueuedConnection); + connect(reply_, SIGNAL(finished()), this, SLOT(onRequestFinished()), Qt::QueuedConnection); + return id_; +} + +int Requestor::post(const QNetworkRequest &req, const QByteArray &data, int timeout/* = 60*1000*/) { + if (-1 == setup(req, QNetworkAccessManager::PostOperation)) { + return -1; + } + rawData_ = true; + data_ = data; + reply_ = manager_->post(request_, data_); + timedReplies_.add(new Reply(reply_, timeout)); + connect(reply_, SIGNAL(error(QNetworkReply::NetworkError)), this, SLOT(onRequestError(QNetworkReply::NetworkError)), Qt::QueuedConnection); + connect(reply_, SIGNAL(finished()), this, SLOT(onRequestFinished()), Qt::QueuedConnection); + connect(reply_, SIGNAL(uploadProgress(qint64,qint64)), this, SLOT(onUploadProgress(qint64,qint64))); + return id_; +} + +int Requestor::post(const QNetworkRequest & req, QHttpMultiPart* data, int timeout/* = 60*1000*/) +{ + if (-1 == setup(req, QNetworkAccessManager::PostOperation)) { + return -1; + } + rawData_ = false; + multipartData_ = data; + reply_ = manager_->post(request_, multipartData_); + multipartData_->setParent(reply_); + timedReplies_.add(new Reply(reply_, timeout)); + connect(reply_, SIGNAL(error(QNetworkReply::NetworkError)), this, SLOT(onRequestError(QNetworkReply::NetworkError)), Qt::QueuedConnection); + connect(reply_, SIGNAL(finished()), this, SLOT(onRequestFinished()), Qt::QueuedConnection); + connect(reply_, SIGNAL(uploadProgress(qint64,qint64)), this, SLOT(onUploadProgress(qint64,qint64))); + return id_; +} + +int Requestor::put(const QNetworkRequest &req, const QByteArray &data, int timeout/* = 60*1000*/) { + if (-1 == setup(req, QNetworkAccessManager::PutOperation)) { + return -1; + } + rawData_ = true; + data_ = data; + reply_ = manager_->put(request_, data_); + timedReplies_.add(new Reply(reply_, timeout)); + connect(reply_, SIGNAL(error(QNetworkReply::NetworkError)), this, SLOT(onRequestError(QNetworkReply::NetworkError)), Qt::QueuedConnection); + connect(reply_, SIGNAL(finished()), this, SLOT(onRequestFinished()), Qt::QueuedConnection); + connect(reply_, SIGNAL(uploadProgress(qint64,qint64)), this, SLOT(onUploadProgress(qint64,qint64))); + return id_; +} + +int Requestor::put(const QNetworkRequest & req, QHttpMultiPart* data, int timeout/* = 60*1000*/) +{ + if (-1 == setup(req, QNetworkAccessManager::PutOperation)) { + return -1; + } + rawData_ = false; + multipartData_ = data; + reply_ = manager_->put(request_, multipartData_); + multipartData_->setParent(reply_); + timedReplies_.add(new Reply(reply_, timeout)); + connect(reply_, SIGNAL(error(QNetworkReply::NetworkError)), this, SLOT(onRequestError(QNetworkReply::NetworkError)), Qt::QueuedConnection); + connect(reply_, SIGNAL(finished()), this, SLOT(onRequestFinished()), Qt::QueuedConnection); + connect(reply_, SIGNAL(uploadProgress(qint64,qint64)), this, SLOT(onUploadProgress(qint64,qint64))); + return id_; +} + +int Requestor::customRequest(const QNetworkRequest &req, const QByteArray &verb, const QByteArray &data, int timeout/* = 60*1000*/) +{ + (void)timeout; + + if (-1 == setup(req, QNetworkAccessManager::CustomOperation, verb)) { + return -1; + } + data_ = data; + QBuffer * buffer = new QBuffer; + buffer->setData(data_); + reply_ = manager_->sendCustomRequest(request_, verb, buffer); + buffer->setParent(reply_); + timedReplies_.add(new Reply(reply_)); + connect(reply_, SIGNAL(error(QNetworkReply::NetworkError)), this, SLOT(onRequestError(QNetworkReply::NetworkError)), Qt::QueuedConnection); + connect(reply_, SIGNAL(finished()), this, SLOT(onRequestFinished()), Qt::QueuedConnection); + connect(reply_, SIGNAL(uploadProgress(qint64,qint64)), this, SLOT(onUploadProgress(qint64,qint64))); + return id_; +} + +int Requestor::head(const QNetworkRequest &req, int timeout/* = 60*1000*/) +{ + if (-1 == setup(req, QNetworkAccessManager::HeadOperation)) { + return -1; + } + reply_ = manager_->head(request_); + timedReplies_.add(new Reply(reply_, timeout)); + connect(reply_, SIGNAL(error(QNetworkReply::NetworkError)), this, SLOT(onRequestError(QNetworkReply::NetworkError)), Qt::QueuedConnection); + connect(reply_, SIGNAL(finished()), this, SLOT(onRequestFinished()), Qt::QueuedConnection); + return id_; +} + +void Requestor::onRefreshFinished(QNetworkReply::NetworkError error) { + if (status_ != Requesting) { + qWarning() << "O2Requestor::onRefreshFinished: No pending request"; + return; + } + if (QNetworkReply::NoError == error) { + QTimer::singleShot(100, this, &Requestor::retry); + } else { + error_ = error; + QTimer::singleShot(10, this, &Requestor::finish); + } +} + +void Requestor::onRequestFinished() { + if (status_ == Idle) { + return; + } + if (reply_ != qobject_cast(sender())) { + return; + } + if (reply_->error() == QNetworkReply::NoError) { + QTimer::singleShot(10, this, SLOT(finish())); + } +} + +void Requestor::onRequestError(QNetworkReply::NetworkError error) { + qWarning() << "O2Requestor::onRequestError: Error" << (int)error; + if (status_ == Idle) { + return; + } + if (reply_ != qobject_cast(sender())) { + return; + } + int httpStatus = reply_->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + qWarning() << "O2Requestor::onRequestError: HTTP status" << httpStatus << reply_->attribute(QNetworkRequest::HttpReasonPhraseAttribute).toString(); + if ((status_ == Requesting) && (httpStatus == 401)) { + // Call OAuth2::refresh. Note the O2 instance might live in a different thread + if (QMetaObject::invokeMethod(authenticator_, "refresh")) { + return; + } + qCritical() << "O2Requestor::onRequestError: Invoking remote refresh failed"; + } + error_ = error; + QTimer::singleShot(10, this, SLOT(finish())); +} + +void Requestor::onUploadProgress(qint64 uploaded, qint64 total) { + if (status_ == Idle) { + qWarning() << "O2Requestor::onUploadProgress: No pending request"; + return; + } + if (reply_ != qobject_cast(sender())) { + return; + } + // Restart timeout because request in progress + Reply *o2Reply = timedReplies_.find(reply_); + if(o2Reply) + o2Reply->start(); + emit uploadProgress(id_, uploaded, total); +} + +int Requestor::setup(const QNetworkRequest &req, QNetworkAccessManager::Operation operation, const QByteArray &verb) { + static int currentId; + + if (status_ != Idle) { + qWarning() << "O2Requestor::setup: Another request pending"; + return -1; + } + + request_ = req; + operation_ = operation; + id_ = currentId++; + url_ = req.url(); + + QUrl url = url_; + if (addAccessTokenInQuery_) { + QUrlQuery query(url); + query.addQueryItem(OAUTH2_ACCESS_TOKEN, authenticator_->token()); + url.setQuery(query); + } + + request_.setUrl(url); + + // If the service require the access token to be sent as a Authentication HTTP header, we add the access token. + if (!accessTokenInAuthenticationHTTPHeaderFormat_.isEmpty()) { + request_.setRawHeader(HTTP_AUTHORIZATION_HEADER, accessTokenInAuthenticationHTTPHeaderFormat_.arg(authenticator_->token()).toLatin1()); + } + + if (!verb.isEmpty()) { + request_.setRawHeader(HTTP_HTTP_HEADER, verb); + } + + status_ = Requesting; + error_ = QNetworkReply::NoError; + return id_; +} + +void Requestor::finish() { + QByteArray data; + if (status_ == Idle) { + qWarning() << "O2Requestor::finish: No pending request"; + return; + } + data = reply_->readAll(); + status_ = Idle; + timedReplies_.remove(reply_); + reply_->disconnect(this); + reply_->deleteLater(); + QList headers = reply_->rawHeaderPairs(); + emit finished(id_, error_, data, headers); +} + +void Requestor::retry() { + if (status_ != Requesting) { + qWarning() << "O2Requestor::retry: No pending request"; + return; + } + timedReplies_.remove(reply_); + reply_->disconnect(this); + reply_->deleteLater(); + QUrl url = url_; + if (addAccessTokenInQuery_) { + QUrlQuery query(url); + query.addQueryItem(OAUTH2_ACCESS_TOKEN, authenticator_->token()); + url.setQuery(query); + } + request_.setUrl(url); + + // If the service require the access token to be sent as a Authentication HTTP header, + // we update the access token when retrying. + if(!accessTokenInAuthenticationHTTPHeaderFormat_.isEmpty()) { + request_.setRawHeader(HTTP_AUTHORIZATION_HEADER, accessTokenInAuthenticationHTTPHeaderFormat_.arg(authenticator_->token()).toLatin1()); + } + + status_ = ReRequesting; + switch (operation_) { + case QNetworkAccessManager::GetOperation: + reply_ = manager_->get(request_); + break; + case QNetworkAccessManager::PostOperation: + reply_ = rawData_ ? manager_->post(request_, data_) : manager_->post(request_, multipartData_); + break; + case QNetworkAccessManager::CustomOperation: + { + QBuffer * buffer = new QBuffer; + buffer->setData(data_); + reply_ = manager_->sendCustomRequest(request_, request_.rawHeader(HTTP_HTTP_HEADER), buffer); + buffer->setParent(reply_); + } + break; + case QNetworkAccessManager::PutOperation: + reply_ = rawData_ ? manager_->post(request_, data_) : manager_->put(request_, multipartData_); + break; + case QNetworkAccessManager::HeadOperation: + reply_ = manager_->head(request_); + break; + default: + assert(!"Unspecified operation for request"); + reply_ = manager_->get(request_); + break; + } + timedReplies_.add(reply_); + connect(reply_, SIGNAL(error(QNetworkReply::NetworkError)), this, SLOT(onRequestError(QNetworkReply::NetworkError)), Qt::QueuedConnection); + connect(reply_, SIGNAL(finished()), this, SLOT(onRequestFinished()), Qt::QueuedConnection); + connect(reply_, SIGNAL(uploadProgress(qint64,qint64)), this, SLOT(onUploadProgress(qint64,qint64))); +} + +}