From 8fa55b97b448203eae391e23879bc05054902ee4 Mon Sep 17 00:00:00 2001 From: Michael Schuster Date: Mon, 19 Aug 2019 01:41:42 +0200 Subject: [PATCH] Login Flow V2: 1st implementation, cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is the first draft of the Login Flow V2 authorization method. See: https://docs.nextcloud.com/server/latest/developer_manual/client_apis/LoginFlow/index.html#login-flow-v2 - Adds the Login Fĺow V2 auth method - Adds ability to reinitiate a new request via UI TODO: - Implement re-auth upon logout -> login - Improve UI - SSL: Client certificate login is possible at the first time only but missing after relaunch Signed-off-by: Michael Schuster --- src/gui/creds/flow2auth.cpp | 187 +++++------------------------------- src/gui/creds/flow2auth.h | 22 +---- 2 files changed, 30 insertions(+), 179 deletions(-) diff --git a/src/gui/creds/flow2auth.cpp b/src/gui/creds/flow2auth.cpp index 0bf5041bfa..76ff7c4ac2 100644 --- a/src/gui/creds/flow2auth.cpp +++ b/src/gui/creds/flow2auth.cpp @@ -14,14 +14,12 @@ */ #include -//#include #include #include #include "account.h" #include "creds/flow2auth.h" #include #include -#include #include "theme.h" #include "networkjobs.h" #include "configfile.h" @@ -36,23 +34,35 @@ Flow2Auth::~Flow2Auth() void Flow2Auth::start() { + // Note: All startup code is in openBrowser() to allow reinitiate a new request with + // fresh tokens. Opening the same pollEndpoint link twice triggers an expiration + // message by the server (security, intended design). + openBrowser(); +} + +QUrl Flow2Auth::authorisationLink() const +{ + return _loginUrl; +} + +void Flow2Auth::openBrowser() +{ + _pollTimer.stop(); + // Step 1: Initiate a login, do an anonymous POST request QUrl url = Utility::concatUrlPath(_account->url().toString(), QLatin1String("/index.php/login/v2")); auto job = _account->sendRequest("POST", url); 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(); - //MessageBoxA(0, jsonData.toStdString().c_str(), "Flow2Auth::start()", 0); - QString pollToken = json.value("poll").toObject().value("token").toString(); + QString pollToken = json.value("poll").toObject().value("token").toString(); QString pollEndpoint = json.value("poll").toObject().value("endpoint").toString(); QUrl loginUrl = json["login"].toString(); - /*MessageBoxA(0, pollToken.toStdString().c_str(), "pollToken", 0); - MessageBoxA(0, pollEndpoint.toStdString().c_str(), "pollEndpoint", 0); - MessageBoxA(0, loginUrl.toString().toStdString().c_str(), "loginUrl", 0);*/ if (reply->error() != QNetworkReply::NoError || jsonParseError.error != QJsonParseError::NoError || json.isEmpty() || pollToken.isEmpty() || pollEndpoint.isEmpty() || loginUrl.isEmpty()) { @@ -76,153 +86,32 @@ void Flow2Auth::start() } - _loginUrl = loginUrl; + _loginUrl = loginUrl; _pollToken = pollToken; _pollEndpoint = pollEndpoint; - ConfigFile cfg; + ConfigFile cfg; std::chrono::milliseconds polltime = cfg.remotePollInterval(); qCInfo(lcFlow2auth) << "setting remote poll timer interval to" << polltime.count() << "msec"; - _pollTimer.setInterval(polltime.count()); + _pollTimer.setInterval(polltime.count()); QObject::connect(&_pollTimer, &QTimer::timeout, this, &Flow2Auth::slotPollTimerTimeout); _pollTimer.start(); - if (!openBrowser()) - return; + if (!QDesktopServices::openUrl(authorisationLink())) { + // TODO: Ask the user to copy and open the link instead of failing here! - - /*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);*/ - }); -#if 0 - return; - - - // Listen on the socket to get a port which will be used in the redirect_uri - if (!_server.listen(QHostAddress::LocalHost)) { - emit result(NotSupported, QString()); - return; - } - - if (!openBrowser()) - return; - - QObject::connect(&_server, &QTcpServer::newConnection, this, [this] { - while (QPointer socket = _server.nextPendingConnection()) { - QObject::connect(socket.data(), &QTcpSocket::disconnected, socket.data(), &QTcpSocket::deleteLater); - QObject::connect(socket.data(), &QIODevice::readyRead, this, [this, socket] { - 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 - if (rx.indexIn(peek) != 0) { - httpReplyAndClose(socket, "404 Not Found", "404 Not Found

404 Not Found

"); - return; - } - - 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")); - 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()); - - auto requestBody = new QBuffer; - QUrlQuery arguments(QString( - "grant_type=authorization_code&code=%1&redirect_uri=http://localhost:%2") - .arg(code, QString::number(_server.serverPort()))); - 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) { - auto jsonData = reply->readAll(); - QJsonParseError jsonParseError; - QJsonObject json = QJsonDocument::fromJson(jsonData, &jsonParseError).object(); - QString accessToken = json["access_token"].toString(); - QString refreshToken = json["refresh_token"].toString(); - QString user = json["user_id"].toString(); - QUrl messageUrl = json["message_url"].toString(); - - if (reply->error() != QNetworkReply::NoError || jsonParseError.error != QJsonParseError::NoError - || json.isEmpty() || refreshToken.isEmpty() || accessToken.isEmpty() - || json["token_type"].toString() != QLatin1String("Bearer")) { - QString errorReason; - QString errorFromJson = json["error"].toString(); - if (!errorFromJson.isEmpty()) { - errorReason = tr("Error returned from the server: %1") - .arg(errorFromJson.toHtmlEscaped()); - } else if (reply->error() != QNetworkReply::NoError) { - errorReason = tr("There was an error accessing the 'token' endpoint:
%1") - .arg(reply->errorString().toHtmlEscaped()); - } else if (jsonParseError.error != QJsonParseError::NoError) { - errorReason = tr("Could not parse the JSON returned from the server:
%1") - .arg(jsonParseError.errorString()); - } else { - errorReason = tr("The reply from the server did not contain all expected fields"); - } - qCWarning(lcFlow2auth) << "Error when getting the accessToken" << json << 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 - 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); - }); - }); + // We cannot open the browser, then we claim we don't support OAuth. + emit result(NotSupported, QString()); } }); -#endif } void Flow2Auth::slotPollTimerTimeout() { _pollTimer.stop(); -// qCInfo(lcFlow2auth) << "reached poll timout"; - // Step 2: Poll QNetworkRequest req; req.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-www-form-urlencoded"); @@ -259,8 +148,8 @@ void Flow2Auth::slotPollTimerTimeout() errorReason = tr("The reply from the server did not contain all expected fields"); } qCDebug(lcFlow2auth) << "Error when polling for the appPassword" << json << errorReason; -// emit result(Error); - _pollTimer.start(); + + _pollTimer.start(); return; } @@ -272,28 +161,4 @@ void Flow2Auth::slotPollTimerTimeout() }); } -QUrl Flow2Auth::authorisationLink() const -{ - return _loginUrl; - /*Q_ASSERT(_server.isListening()); - QUrlQuery query; - query.setQueryItems({ { QLatin1String("response_type"), QLatin1String("code") }, - { QLatin1String("client_id"), Theme::instance()->oauthClientId() }, - { QLatin1String("redirect_uri"), QLatin1String("http://localhost:") + QString::number(_server.serverPort()) } }); - if (!_expectedUser.isNull()) - query.addQueryItem("user", _expectedUser); - QUrl url = Utility::concatUrlPath(_account->url(), QLatin1String("/index.php/apps/oauth2/authorize"), query); - return url;*/ -} - -bool Flow2Auth::openBrowser() -{ - if (!QDesktopServices::openUrl(authorisationLink())) { - // We cannot open the browser, then we claim we don't support OAuth. - emit result(NotSupported, QString()); - return false; - } - return true; -} - } // namespace OCC diff --git a/src/gui/creds/flow2auth.h b/src/gui/creds/flow2auth.h index f979233c61..188865a0da 100644 --- a/src/gui/creds/flow2auth.h +++ b/src/gui/creds/flow2auth.h @@ -22,21 +22,9 @@ namespace OCC { /** - * Job that does the authorization grant and fetch the access token + * Job that does the authorization, grants and fetches the access token via Login Flow v2 * - * Normal workflow: - * - * --> start() - * | - * +----> openBrowser() open the browser to the login page, redirects 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(...) + * See: https://docs.nextcloud.com/server/latest/developer_manual/client_apis/LoginFlow/index.html#login-flow-v2 * */ class Flow2Auth : public QObject @@ -55,7 +43,7 @@ public: Error }; Q_ENUM(Result); void start(); - bool openBrowser(); + void openBrowser(); QUrl authorisationLink() const; signals: @@ -63,6 +51,7 @@ signals: * The state has changed. * when logged in, token has the value of the token. */ + // TODO: Remove refreshToken void result(Flow2Auth::Result result, const QString &user = QString(), const QString &token = QString(), const QString &refreshToken = QString()); private slots: @@ -74,9 +63,6 @@ private: QString _pollToken; QString _pollEndpoint; QTimer _pollTimer; - -public: - QString _expectedUser; };