diff --git a/src/gui/creds/httpcredentialsgui.cpp b/src/gui/creds/httpcredentialsgui.cpp index 2aa658a7da..8c68589f72 100644 --- a/src/gui/creds/httpcredentialsgui.cpp +++ b/src/gui/creds/httpcredentialsgui.cpp @@ -72,6 +72,8 @@ void HttpCredentialsGui::performOAuthProcess() this, &HttpCredentialsGui::asyncAuthResult); connect(_asyncAuth.data(), &OAuth::destroyed, this, &HttpCredentialsGui::authorisationLinkChanged); + connect(_asyncAuth.data(), &OAuth::authorisationLinkChanged, + this, &HttpCredentialsGui::authorisationLinkChanged); _asyncAuth->start(); emit authorisationLinkChanged(); } diff --git a/src/gui/creds/oauth.cpp b/src/gui/creds/oauth.cpp index 91bdf190b1..63f0b53232 100644 --- a/src/gui/creds/oauth.cpp +++ b/src/gui/creds/oauth.cpp @@ -23,6 +23,7 @@ #include "theme.h" #include "networkjobs.h" #include "creds/httpcredentials.h" +#include namespace OCC { @@ -39,7 +40,7 @@ static void httpReplyAndClose(QTcpSocket *socket, const char *code, const char * return; // socket can have been deleted if the browser was closed socket->write("HTTP/1.1 "); socket->write(code); - socket->write("\r\nContent-Type: text/html\r\nConnection: close\r\nContent-Length: "); + socket->write("\r\nContent-Type: text/html; charset=utf-8\r\nConnection: close\r\nContent-Length: "); socket->write(QByteArray::number(qstrlen(html))); if (moreHeaders) { socket->write("\r\n"); @@ -61,8 +62,14 @@ void OAuth::start() return; } - if (!openBrowser()) - return; + quint32 buffer[24]; + QRandomGenerator::global()->fillRange(buffer); + _pkceCodeVerifier = QByteArray(reinterpret_cast(buffer), sizeof(buffer)).toBase64(QByteArray::Base64UrlEncoding); + Q_ASSERT(_pkceCodeVerifier.size() == 128); + + fetchWellKnown(); + + openBrowser(); QObject::connect(&_server, &QTcpServer::newConnection, this, [this] { while (QPointer socket = _server.nextPendingConnection()) { @@ -71,7 +78,7 @@ void OAuth::start() QByteArray peek = socket->peek(qMin(socket->bytesAvailable(), 4000LL)); //The code should always be within the first 4K if (peek.indexOf('\n') < 0) return; // wait until we find a \n - QRegExp rx("^GET /\\?code=([a-zA-Z0-9]+)[& ]"); // Match a /?code=... URL + QRegExp rx("^GET /\\?code=([a-zA-Z0-9_-]+)[& ]"); // Match a /?code=... URL if (rx.indexIn(peek) != 0) { httpReplyAndClose(socket, "404 Not Found", "404 Not Found

404 Not Found

"); return; @@ -79,7 +86,10 @@ void OAuth::start() QString code = rx.cap(1); // The 'code' is the first capture of the regexp - QUrl requestToken = Utility::concatUrlPath(_account->url().toString(), QLatin1String("/index.php/apps/oauth2/api/v1/token")); + QUrl requestToken = _tokenEndpoint.isValid() + ? _tokenEndpoint + : Utility::concatUrlPath(_account->url(), QLatin1String("/index.php/apps/oauth2/api/v1/token")); + QNetworkRequest req; req.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-www-form-urlencoded"); @@ -91,10 +101,9 @@ void OAuth::start() auto requestBody = new QBuffer; QUrlQuery arguments(QString( - "grant_type=authorization_code&code=%1&redirect_uri=http://localhost:%2") - .arg(code, QString::number(_server.serverPort()))); + "grant_type=authorization_code&code=%1&redirect_uri=http://localhost:%2&code_verifier=%3&scope=openid offline_access") + .arg(code, QString::number(_server.serverPort()), _pkceCodeVerifier)); requestBody->setData(arguments.query(QUrl::FullyEncoded).toLatin1()); - auto job = _account->sendRequest("POST", requestToken, req, requestBody); job->setTimeout(qMin(30 * 1000ll, job->timeoutMsec())); QObject::connect(job, &SimpleNetworkJob::finishedSignal, this, [this, socket](QNetworkReply *reply) { @@ -110,7 +119,9 @@ void OAuth::start() || jsonData.isEmpty() || json.isEmpty() || refreshToken.isEmpty() || accessToken.isEmpty() || json["token_type"].toString() != QLatin1String("Bearer")) { QString errorReason; - QString errorFromJson = json["error"].toString(); + QString errorFromJson = json["error_description"].toString(); + if (errorFromJson.isEmpty()) + QString errorFromJson = json["error"].toString(); if (!errorFromJson.isEmpty()) { errorReason = tr("Error returned from the server: %1") .arg(errorFromJson.toHtmlEscaped()); @@ -129,59 +140,130 @@ void OAuth::start() } else { errorReason = tr("The reply from the server did not contain all expected fields"); } - qCWarning(lcOauth) << "Error when getting the accessToken" << json << errorReason; + qCWarning(lcOauth) << "Error when getting the accessToken" << jsonData << errorReason; httpReplyAndClose(socket, "500 Internal Server Error", tr("

Login Error

%1

").arg(errorReason).toUtf8().constData()); emit result(Error); return; } - if (!_expectedUser.isNull() && user != _expectedUser) { - // Connected with the wrong user - QString message = tr("

Wrong user

" - "

You logged-in with user %1, but must login with user %2.
" - "Please log out of %3 in another tab, then click here " - "and log in as user %2

") - .arg(user, _expectedUser, Theme::instance()->appNameGUI(), - authorisationLink().toString(QUrl::FullyEncoded)); - httpReplyAndClose(socket, "200 OK", message.toUtf8().constData()); - // We are still listening on the socket so we will get the new connection + if (!user.isEmpty()) { + finalize(socket, accessToken, refreshToken, user, messageUrl); return; } - const char *loginSuccessfullHtml = "

Login Successful

You can close this window.

"; - if (messageUrl.isValid()) { - httpReplyAndClose(socket, "303 See Other", loginSuccessfullHtml, - QByteArray("Location: " + messageUrl.toEncoded()).constData()); - } else { - httpReplyAndClose(socket, "200 OK", loginSuccessfullHtml); - } - emit result(LoggedIn, user, accessToken, refreshToken); + // If the reply don't contains the user id, we must do another call to query it + JsonApiJob *job = new JsonApiJob(_account->sharedFromThis(), QLatin1String("ocs/v1.php/cloud/user"), this); + job->setTimeout(qMin(30 * 1000ll, job->timeoutMsec())); + QNetworkRequest req; + // We are not connected yet so we need to handle the authentication manually + req.setRawHeader("Authorization", "Bearer " + accessToken.toUtf8()); + // We just added the Authorization header, don't let HttpCredentialsAccessManager tamper with it + req.setAttribute(HttpCredentials::DontAddCredentialsAttribute, true); + job->startWithRequest(req); + QObject::connect(job, &JsonApiJob::jsonReceived, this, [=](const QJsonDocument &json) { + QString user = json.object().value("ocs").toObject().value("data").toObject().value("id").toString(); + finalize(socket, accessToken, refreshToken, user, messageUrl); + }); }); }); } }); } +void OAuth::finalize(QPointer socket, const QString &accessToken, + const QString &refreshToken, const QString &user, const QUrl &messageUrl) { + if (!_expectedUser.isNull() && user != _expectedUser) { + // Connected with the wrong user + QString message = tr("

Wrong user

" + "

You logged-in with user %1, but must login with user %2.
" + "Please log out of %3 in another tab, then click here " + "and log in as user %2

") + .arg(user, _expectedUser, Theme::instance()->appNameGUI(), + authorisationLink().toString(QUrl::FullyEncoded)); + httpReplyAndClose(socket, "200 OK", message.toUtf8().constData()); + // We are still listening on the socket so we will get the new connection + return; + } + const char *loginSuccessfullHtml = "

Login Successful

You can close this window.

"; + if (messageUrl.isValid()) { + httpReplyAndClose(socket, "303 See Other", loginSuccessfullHtml, + QByteArray("Location: " + messageUrl.toEncoded()).constData()); + } else { + httpReplyAndClose(socket, "200 OK", loginSuccessfullHtml); + } + emit result(LoggedIn, user, accessToken, refreshToken); +} + QUrl OAuth::authorisationLink() const { Q_ASSERT(_server.isListening()); QUrlQuery query; + QByteArray code_challenge = QCryptographicHash::hash(_pkceCodeVerifier, QCryptographicHash::Sha256) + .toBase64(QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals); query.setQueryItems({ { QLatin1String("response_type"), QLatin1String("code") }, { QLatin1String("client_id"), Theme::instance()->oauthClientId() }, - { QLatin1String("redirect_uri"), QLatin1String("http://localhost:") + QString::number(_server.serverPort()) } }); + { QLatin1String("redirect_uri"), QLatin1String("http://localhost:") + QString::number(_server.serverPort()) }, + { QLatin1String("code_challenge"), QString::fromLatin1(code_challenge) }, + { QLatin1String("code_challenge_method"), QLatin1String("S256")}, + { QLatin1String("scope"), QLatin1String("openid offline_access") }, + { QLatin1String("prompt"), QLatin1String("consent")} + }); if (!_expectedUser.isNull()) query.addQueryItem("user", _expectedUser); - QUrl url = Utility::concatUrlPath(_account->url(), QLatin1String("/index.php/apps/oauth2/authorize"), query); + QUrl url = _authEndpoint.isValid() + ? Utility::concatUrlPath(_authEndpoint, {}, query) + : Utility::concatUrlPath(_account->url(), QLatin1String("/index.php/apps/oauth2/authorize"), query); return url; } -bool OAuth::openBrowser() +void OAuth::authorisationLinkAsync(std::function callback) const { - if (!QDesktopServices::openUrl(authorisationLink())) { - // We cannot open the browser, then we claim we don't support OAuth. - emit result(NotSupported, QString()); - return false; + if (_wellKnownFinished) { + callback(authorisationLink()); + } else { + connect(this, &OAuth::authorisationLinkChanged, callback); } - return true; +} + +void OAuth::fetchWellKnown() +{ + QUrl wellKnownUrl = Utility::concatUrlPath(_account->url().toString(), QLatin1String("/.well-known/openid-configuration")); + QNetworkRequest req; + auto job = _account->sendRequest("GET", wellKnownUrl); + job->setTimeout(qMin(30 * 1000ll, job->timeoutMsec())); + QObject::connect(job, &SimpleNetworkJob::finishedSignal, this, [this](QNetworkReply *reply) { + _wellKnownFinished = true; + if (reply->error() != QNetworkReply::NoError) { + // Most likely the file does not exist, default to the normal endpoint + emit this->authorisationLinkChanged(authorisationLink()); + return; + } + auto jsonData = reply->readAll(); + QJsonParseError jsonParseError; + QJsonObject json = QJsonDocument::fromJson(jsonData, &jsonParseError).object(); + + if (jsonParseError.error == QJsonParseError::NoError) { + QString authEp = json["authorization_endpoint"].toString(); + if (!authEp.isEmpty()) + this->_authEndpoint = authEp; + QString tokenEp = json["token_endpoint"].toString(); + if (!tokenEp.isEmpty()) + this->_tokenEndpoint = tokenEp; + } else { + qCWarning(lcOauth) << "Json parse error in well-known: " << jsonParseError.errorString(); + } + + emit this->authorisationLinkChanged(authorisationLink()); + }); +} + +void OAuth::openBrowser() +{ + authorisationLinkAsync([this](const QUrl &link) { + if (!QDesktopServices::openUrl(link)) { + // We cannot open the browser, then we claim we don't support OAuth. + emit result(NotSupported, QString()); + } + }); } } // namespace OCC diff --git a/src/gui/creds/oauth.h b/src/gui/creds/oauth.h index 1c6b519e13..e4bf091344 100644 --- a/src/gui/creds/oauth.h +++ b/src/gui/creds/oauth.h @@ -27,15 +27,20 @@ namespace OCC { * * --> start() * | - * +----> openBrowser() open the browser to the login page, redirects to http://localhost:xxx + * +----> fetchWellKnown() query the ".well-known/openid-configuration" endpoint + * | + * +----> openBrowser() open the browser to the login page after fetchWellKnown finished. + * | Then the browser will redirect to http://localhost:xxx * | * +----> _server starts listening on a TCP port waiting for an HTTP request with a 'code' * | * v * request the access_token and the refresh_token via 'apps/oauth2/api/v1/token' * | - * v - * emit result(...) + * +-> Request the user_id is not present + * | | + * v v + * finalize(...): emit result(...) * */ class OAuth : public QObject @@ -54,8 +59,14 @@ public: Error }; Q_ENUM(Result); void start(); - bool openBrowser(); + void openBrowser(); QUrl authorisationLink() const; + /** + * Call the callback when the call to the well-known endpoint finishes. + * (or immediatly if it is ready) + * The callback will not be called if this object gets destroyed + */ + void authorisationLinkAsync(std::function callback) const; signals: /** @@ -64,9 +75,23 @@ signals: */ void result(OAuth::Result result, const QString &user = QString(), const QString &token = QString(), const QString &refreshToken = QString()); + /** + * emitted when the call to the well-known endpoint is finished + */ + void authorisationLinkChanged(const QUrl &); + private: - Account *_account; + + void fetchWellKnown(); + void finalize(QPointer socket, const QString &accessToken, + const QString &refreshToken, const QString &userId, const QUrl &messageUrl); + + Account* _account; QTcpServer _server; + bool _wellKnownFinished = false; + QUrl _authEndpoint; + QUrl _tokenEndpoint; + QByteArray _pkceCodeVerifier; public: QString _expectedUser; diff --git a/src/gui/wizard/owncloudoauthcredspage.cpp b/src/gui/wizard/owncloudoauthcredspage.cpp index f1f2fcacf8..d96b6b305f 100644 --- a/src/gui/wizard/owncloudoauthcredspage.cpp +++ b/src/gui/wizard/owncloudoauthcredspage.cpp @@ -54,8 +54,11 @@ OwncloudOAuthCredsPage::OwncloudOAuthCredsPage() QObject::connect(_ui.openLinkButton, &QWidget::customContextMenuRequested, [this](const QPoint &pos) { auto menu = new QMenu(_ui.openLinkButton); menu->addAction(tr("Copy link to clipboard"), this, [this] { - if (_asyncAuth) - QApplication::clipboard()->setText(_asyncAuth->authorisationLink().toString(QUrl::FullyEncoded)); + if (_asyncAuth) { + _asyncAuth->authorisationLinkAsync([](const QUrl &link) { + QApplication::clipboard()->setText(link.toString(QUrl::FullyEncoded)); + }); + } }); menu->setAttribute(Qt::WA_DeleteOnClose); menu->popup(_ui.openLinkButton->mapToGlobal(pos)); diff --git a/src/libsync/creds/httpcredentials.cpp b/src/libsync/creds/httpcredentials.cpp index 1dcd026312..21f82c9903 100644 --- a/src/libsync/creds/httpcredentials.cpp +++ b/src/libsync/creds/httpcredentials.cpp @@ -412,47 +412,64 @@ bool HttpCredentials::refreshAccessToken() if (_refreshToken.isEmpty()) return false; - QUrl requestToken = Utility::concatUrlPath(_account->url(), QLatin1String("/index.php/apps/oauth2/api/v1/token")); + QUrl wellKnownUrl = Utility::concatUrlPath(_account->url().toString(), QLatin1String("/.well-known/openid-configuration")); QNetworkRequest req; - req.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-www-form-urlencoded"); - - QString basicAuth = QString("%1:%2").arg( - Theme::instance()->oauthClientId(), Theme::instance()->oauthClientSecret()); - req.setRawHeader("Authorization", "Basic " + basicAuth.toUtf8().toBase64()); - req.setAttribute(HttpCredentials::DontAddCredentialsAttribute, true); - - auto requestBody = new QBuffer; - QUrlQuery arguments(QString("grant_type=refresh_token&refresh_token=%1").arg(_refreshToken)); - requestBody->setData(arguments.query(QUrl::FullyEncoded).toLatin1()); - - auto job = _account->sendRequest("POST", requestToken, req, requestBody); + auto job = _account->sendRequest("GET", wellKnownUrl); job->setTimeout(qMin(30 * 1000ll, job->timeoutMsec())); QObject::connect(job, &SimpleNetworkJob::finishedSignal, this, [this](QNetworkReply *reply) { - auto jsonData = reply->readAll(); - QJsonParseError jsonParseError; - QJsonObject json = QJsonDocument::fromJson(jsonData, &jsonParseError).object(); - QString accessToken = json["access_token"].toString(); - if (jsonParseError.error != QJsonParseError::NoError || json.isEmpty()) { - // Invalid or empty JSON: Network error maybe? - qCWarning(lcHttpCredentials) << "Error while refreshing the token" << reply->errorString() << jsonData << jsonParseError.errorString(); - } else if (accessToken.isEmpty()) { - // If the json was valid, but the reply did not contain an access token, the token - // is considered expired. (Usually the HTTP reply code is 400) - qCDebug(lcHttpCredentials) << "Expired refresh token. Logging out"; - _refreshToken.clear(); - } else { - _ready = true; - _password = accessToken; - _refreshToken = json["refresh_token"].toString(); - persist(); + QUrl requestTokenUrl = Utility::concatUrlPath(_account->url(), QLatin1String("/index.php/apps/oauth2/api/v1/token")); + if (reply->error() == QNetworkReply::NoError) { + auto jsonData = reply->readAll(); + QJsonParseError jsonParseError; + QJsonObject json = QJsonDocument::fromJson(jsonData, &jsonParseError).object(); + if (jsonParseError.error == QJsonParseError::NoError) { + QString tokenEp = json["token_endpoint"].toString(); + if (!tokenEp.isEmpty()) + requestTokenUrl = tokenEp; + } } - _isRenewingOAuthToken = false; - for (const auto &job : _retryQueue) { - if (job) - job->retry(); - } - _retryQueue.clear(); - emit fetched(); + + QNetworkRequest req; + req.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-www-form-urlencoded"); + + QString basicAuth = QString("%1:%2").arg( + Theme::instance()->oauthClientId(), Theme::instance()->oauthClientSecret()); + req.setRawHeader("Authorization", "Basic " + basicAuth.toUtf8().toBase64()); + req.setAttribute(HttpCredentials::DontAddCredentialsAttribute, true); + + auto requestBody = new QBuffer; + QUrlQuery arguments(QString("grant_type=refresh_token&refresh_token=%1").arg(_refreshToken)); + requestBody->setData(arguments.query(QUrl::FullyEncoded).toLatin1()); + + auto job = _account->sendRequest("POST", requestTokenUrl, req, requestBody); + job->setTimeout(qMin(30 * 1000ll, job->timeoutMsec())); + QObject::connect(job, &SimpleNetworkJob::finishedSignal, this, [this](QNetworkReply *reply) { + auto jsonData = reply->readAll(); + QJsonParseError jsonParseError; + QJsonObject json = QJsonDocument::fromJson(jsonData, &jsonParseError).object(); + QString accessToken = json["access_token"].toString(); + if (jsonParseError.error != QJsonParseError::NoError || json.isEmpty()) { + // Invalid or empty JSON: Network error maybe? + qCWarning(lcHttpCredentials) << "Error while refreshing the token" << reply->errorString() << jsonData << jsonParseError.errorString(); + } else if (accessToken.isEmpty()) { + // If the json was valid, but the reply did not contain an access token, the token + // is considered expired. (Usually the HTTP reply code is 400) + qCDebug(lcHttpCredentials) << "Expired refresh token. Logging out"; + _refreshToken.clear(); + } else { + _ready = true; + _password = accessToken; + _refreshToken = json["refresh_token"].toString(); + persist(); + } + _isRenewingOAuthToken = false; + for (const auto &job : _retryQueue) { + if (job) + job->retry(); + } + _retryQueue.clear(); + emit fetched(); + }); }); _isRenewingOAuthToken = true; return true; diff --git a/src/libsync/networkjobs.cpp b/src/libsync/networkjobs.cpp index 1a871896c4..2585602c30 100644 --- a/src/libsync/networkjobs.cpp +++ b/src/libsync/networkjobs.cpp @@ -808,7 +808,11 @@ void JsonApiJob::addQueryParams(const QUrlQuery ¶ms) void JsonApiJob::start() { - QNetworkRequest req; + startWithRequest(QNetworkRequest()); +} + +void OCC::JsonApiJob::startWithRequest(QNetworkRequest req) +{ req.setRawHeader("OCS-APIREQUEST", "true"); auto query = _additionalParams; query.addQueryItem(QLatin1String("format"), QLatin1String("json")); diff --git a/src/libsync/networkjobs.h b/src/libsync/networkjobs.h index eb257b56b4..1cdd790321 100644 --- a/src/libsync/networkjobs.h +++ b/src/libsync/networkjobs.h @@ -358,6 +358,10 @@ public: public slots: void start() Q_DECL_OVERRIDE; + /** + * Start which allow to specify a request that might contains already headers or attributes + */ + void startWithRequest(QNetworkRequest request); protected: bool finished() Q_DECL_OVERRIDE; diff --git a/test/testoauth.cpp b/test/testoauth.cpp index ec58b1d61f..e3a95009f7 100644 --- a/test/testoauth.cpp +++ b/test/testoauth.cpp @@ -132,7 +132,9 @@ public: account->setUrl(sOAuthTestServer); account->setCredentials(new FakeCredentials{fakeQnam}); fakeQnam->setParent(this); - fakeQnam->setOverride([this](QNetworkAccessManager::Operation op, const QNetworkRequest &req, QIODevice *device) { + fakeQnam->setOverride([this](QNetworkAccessManager::Operation op, const QNetworkRequest &req, QIODevice *device) { + if (req.url().path().endsWith(".well-known/openid-configuration")) + return this->wellKnownReply(op, req); ASSERT(device); ASSERT(device->bytesAvailable()>0); // OAuth2 always sends around POST data. return this->tokenReply(op, req); @@ -187,6 +189,11 @@ public: return new FakePostReply(op, req, std::move(payload), fakeQnam); } + virtual QNetworkReply *wellKnownReply(QNetworkAccessManager::Operation op, const QNetworkRequest &req) + { + return new FakeErrorReply(op, req, fakeQnam, 404); + } + virtual QByteArray tokenReplyPayload() const { QJsonDocument jsondata(QJsonObject{ { "access_token", "123" }, @@ -331,6 +338,39 @@ private slots: } test; test.test(); } + + void testWellKnown() { + struct Test : OAuthTestCase { + int redirectsDone = 0; + QNetworkReply * wellKnownReply(QNetworkAccessManager::Operation op, const QNetworkRequest & req) override { + ASSERT(op == QNetworkAccessManager::GetOperation); + QJsonDocument jsondata(QJsonObject{ + { "authorization_endpoint", QJsonValue( + "oauthtest://openidserver" + sOAuthTestServer.path() + "/index.php/apps/oauth2/authorize") }, + { "token_endpoint" , "oauthtest://openidserver/token_endpoint" } + }); + return new FakePayloadReply(op, req, jsondata.toJson(), fakeQnam); + } + + void openBrowserHook(const QUrl & url) override { + ASSERT(url.host() == "openidserver"); + QUrl url2 = url; + url2.setHost(sOAuthTestServer.host()); + OAuthTestCase::openBrowserHook(url2); + } + + QNetworkReply *tokenReply(QNetworkAccessManager::Operation op, const QNetworkRequest & request) override + { + ASSERT(browserReply); + ASSERT(request.url().toString().startsWith("oauthtest://openidserver/token_endpoint")); + auto req = request; + req.setUrl(request.url().toString().replace("oauthtest://openidserver/token_endpoint", + sOAuthTestServer.toString() + "/index.php/apps/oauth2/api/v1/token")); + return OAuthTestCase::tokenReply(op, req); + } + } test; + test.test(); + } }; QTEST_GUILESS_MAIN(TestOAuth)