1 /*
2  * Copyright (C) by Olivier Goffart <ogoffart@woboq.com>
3  *
4  * This program is free software; you can redistribute it and/or modify
5  * it under the terms of the GNU General Public License as published by
6  * the Free Software Foundation; either version 2 of the License, or
7  * (at your option) any later version.
8  *
9  * This program is distributed in the hope that it will be useful, but
10  * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
11  * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
12  * for more details.
13  */
14 
15 #include <QDesktopServices>
16 #include <QNetworkReply>
17 #include <QTimer>
18 #include <QBuffer>
19 #include "account.h"
20 #include "creds/oauth.h"
21 #include <QJsonObject>
22 #include <QJsonDocument>
23 #include "theme.h"
24 #include "networkjobs.h"
25 #include "creds/httpcredentials.h"
26 #include <QRandomGenerator>
27 
28 namespace OCC {
29 
30 Q_LOGGING_CATEGORY(lcOauth, "sync.credentials.oauth", QtInfoMsg)
31 
OAuth(Account * account,QObject * parent)32 OAuth::OAuth(Account *account, QObject *parent)
33 : QObject(parent)
34 , _account(account)
35 {
36 }
37 
~OAuth()38 OAuth::~OAuth()
39 {
40 }
41 
httpReplyAndClose(QPointer<QTcpSocket> socket,const QByteArray & code,const QByteArray & html,const QByteArray & moreHeaders={})42 static void httpReplyAndClose(QPointer<QTcpSocket> socket, const QByteArray &code, const QByteArray &html,
43                               const QByteArray &moreHeaders = {})
44 {
45     if (!socket)
46         return; // socket can have been deleted if the browser was closed
47     const QByteArray msg = QByteArrayLiteral("HTTP/1.1 ") %
48             code %
49             QByteArrayLiteral("\r\nContent-Type: text/html; charset=utf-8\r\nConnection: close\r\nContent-Length: ") %
50             QByteArray::number(html.length()) %
51             (!moreHeaders.isEmpty() ? QByteArrayLiteral("\r\n") % moreHeaders : QByteArray()) %
52             QByteArrayLiteral("\r\n\r\n") %
53             html;
54     qCDebug(lcOauth) << msg;
55     socket->write(msg);
56     socket->disconnectFromHost();
57     // We don't want that deleting the server too early prevent queued data to be sent on this socket.
58     // The socket will be deleted after disconnection because disconnected is connected to deleteLater
59     socket->setParent(nullptr);
60 }
61 
startAuthentication()62 void OAuth::startAuthentication()
63 {
64     // Listen on the socket to get a port which will be used in the redirect_uri
65     if (!_server.listen(QHostAddress::LocalHost)) {
66         emit result(NotSupported, QString());
67         return;
68     }
69 
70     _pkceCodeVerifier = generateRandomString(24);
71     OC_ASSERT(_pkceCodeVerifier.size() == 128)
72     _state = generateRandomString(8);
73 
74     connect(this, &OAuth::fetchWellKnownFinished, this, [this]{
75         Q_EMIT authorisationLinkChanged(authorisationLink());
76     });
77     fetchWellKnown();
78 
79     openBrowser();
80 
81     QObject::connect(&_server, &QTcpServer::newConnection, this, [this] {
82         while (QPointer<QTcpSocket> socket = _server.nextPendingConnection()) {
83             QObject::connect(socket.data(), &QTcpSocket::disconnected, socket.data(), &QTcpSocket::deleteLater);
84             QObject::connect(socket.data(), &QIODevice::readyRead, this, [this, socket] {
85                 const QByteArray peek = socket->peek(qMin(socket->bytesAvailable(), 4000LL)); //The code should always be within the first 4K
86                 if (!peek.contains('\n'))
87                     return; // wait until we find a \n
88                 qCDebug(lcOauth) << "Server provided:" << peek;
89                 const auto getPrefix = QByteArrayLiteral("GET /?");
90                 if (!peek.startsWith(getPrefix)) {
91                     httpReplyAndClose(socket, QByteArrayLiteral("404 Not Found"), QByteArrayLiteral("<html><head><title>404 Not Found</title></head><body><center><h1>404 Not Found</h1></center></body></html>"));
92                     return;
93                 }
94                 const auto endOfUrl = peek.indexOf(' ', getPrefix.length());
95                 const QUrlQuery args(QUrl::fromPercentEncoding(peek.mid(getPrefix.length(), endOfUrl - getPrefix.length())));
96                 if (args.queryItemValue(QStringLiteral("state")).toUtf8() != _state) {
97                     httpReplyAndClose(socket, QByteArrayLiteral("400 Bad Request"), QByteArrayLiteral("<html><head><title>400 Bad Request</title></head><body><center><h1>400 Bad Request</h1></center></body></html>"));
98                     return;
99                 }
100               auto job = postTokenRequest({
101                     { QStringLiteral("grant_type"), QStringLiteral("authorization_code") },
102                     { QStringLiteral("code"), args.queryItemValue(QStringLiteral("code")) },
103                     { QStringLiteral("redirect_uri"), QStringLiteral("http://localhost:%1").arg(_server.serverPort()) },
104                     { QStringLiteral("code_verifier"), QString::fromUtf8(_pkceCodeVerifier) },
105                     });
106                 QObject::connect(job, &SimpleNetworkJob::finishedSignal, this, [this, socket](QNetworkReply *reply) {
107                     const auto jsonData = reply->readAll();
108                     QJsonParseError jsonParseError;
109                     const QJsonObject json = QJsonDocument::fromJson(jsonData, &jsonParseError).object();
110                     QString fieldsError;
111                     const QString accessToken = getRequiredField(json, QStringLiteral("access_token"), &fieldsError).toString();
112                     const QString refreshToken = getRequiredField(json, QStringLiteral("refresh_token"), &fieldsError).toString();
113                     const QString tokenType = getRequiredField(json, QStringLiteral("token_type"), &fieldsError).toString().toLower();
114                     const QString user = json[QStringLiteral("user_id")].toString();
115                     const QUrl messageUrl = QUrl::fromEncoded(json[QStringLiteral("message_url")].toString().toUtf8());
116 
117                     if (reply->error() != QNetworkReply::NoError || jsonParseError.error != QJsonParseError::NoError
118                         || !fieldsError.isEmpty()
119                         || tokenType != QLatin1String("bearer")) {
120                         // do we have error message suitable for users?
121                         QString errorReason = json[QStringLiteral("error_description")].toString();
122                         if (errorReason.isEmpty()) {
123                             // fall back to technical error
124                             errorReason = json[QStringLiteral("error")].toString();
125                         }
126                         if (!errorReason.isEmpty()) {
127                             errorReason = tr("Error returned from the server: <em>%1</em>")
128                                               .arg(errorReason.toHtmlEscaped());
129                         } else if (reply->error() != QNetworkReply::NoError) {
130                             errorReason = tr("There was an error accessing the 'token' endpoint: <br><em>%1</em>")
131                                               .arg(reply->errorString().toHtmlEscaped());
132                         } else if (jsonData.isEmpty()) {
133                             // Can happen if a funky load balancer strips away POST data, e.g. BigIP APM my.policy
134                             errorReason = tr("Empty JSON from OAuth2 redirect");
135                             // We explicitly have this as error case since the json qcWarning output below is misleading,
136                             // it will show a fake json will null values that actually never was received like this as
137                             // soon as you access json["whatever"] the debug output json will claim to have "whatever":null
138                         } else if (jsonParseError.error != QJsonParseError::NoError) {
139                             errorReason = tr("Could not parse the JSON returned from the server: <br><em>%1</em>")
140                                               .arg(jsonParseError.errorString());
141                         } else if (tokenType != QStringLiteral("bearer")) {
142                             errorReason = tr("Unsupported token type: %1").arg(tokenType);
143                         } else if (!fieldsError.isEmpty()) {
144                             errorReason = tr("The reply from the server did not contain all expected fields\n:%1").arg(fieldsError);
145                         } else {
146                             errorReason = tr("Unknown Error");
147                         }
148                         qCWarning(lcOauth) << "Error when getting the accessToken" << errorReason << "received data:" << jsonData;
149                         httpReplyAndClose(socket, QByteArrayLiteral("500 Internal Server Error"),
150                             tr("<h1>Login Error</h1><p>%1</p>").arg(errorReason).toUtf8());
151                         emit result(Error);
152                         return;
153                     }
154                     if (!user.isEmpty()) {
155                         finalize(socket, accessToken, refreshToken, user, messageUrl);
156                         return;
157                     }
158                     // If the reply don't contains the user id, we must do another call to query it
159                     JsonApiJob *job = new JsonApiJob(_account->sharedFromThis(), QStringLiteral("ocs/v2.php/cloud/user"), this);
160                     job->setTimeout(qMin(30 * 1000ll, job->timeoutMsec()));
161                     QNetworkRequest req;
162                     // We are not connected yet so we need to handle the authentication manually
163                     req.setRawHeader("Authorization", "Bearer " + accessToken.toUtf8());
164                     // We just added the Authorization header, don't let HttpCredentialsAccessManager tamper with it
165                     req.setAttribute(HttpCredentials::DontAddCredentialsAttribute, true);
166                     job->startWithRequest(req);
167                     connect(job, &JsonApiJob::jsonReceived, this, [=](const QJsonDocument &json, int status) {
168                         if (status != 200) {
169                             httpReplyAndClose(socket, QByteArrayLiteral("500 Internal Server Error"),
170                                 tr("<h1>Login Error</h1><p>Failed to retrieve user info</p>").toUtf8());
171                             emit result(Error);
172                         } else {
173                             const QString user = json.object().value(QStringLiteral("ocs")).toObject().value(QStringLiteral("data")).toObject().value(QStringLiteral("id")).toString();
174                             finalize(socket, accessToken, refreshToken, user, messageUrl);
175                         }
176                     });
177                 });
178             });
179         }
180     });
181 }
182 
refreshAuthentication(const QString & refreshToken)183 void OAuth::refreshAuthentication(const QString &refreshToken)
184 {
185     connect(this, &OAuth::fetchWellKnownFinished, this, [this, refreshToken] {
186         auto job = postTokenRequest({ { QStringLiteral("grant_type"), QStringLiteral("refresh_token") },
187             { QStringLiteral("refresh_token"), refreshToken } });
188         connect(job, &SimpleNetworkJob::finishedSignal, this, [this, refreshToken](QNetworkReply *reply) {
189             const auto jsonData = reply->readAll();
190             QString accessToken;
191             QString newRefreshToken = refreshToken;
192             QJsonParseError jsonParseError;
193             // https://developer.okta.com/docs/reference/api/oidc/#response-properties-2
194             const QJsonObject json = QJsonDocument::fromJson(jsonData, &jsonParseError).object();
195             const QString error = json.value(QLatin1String("error")).toString();
196             if (!error.isEmpty()) {
197                 if (error == QLatin1String("invalid_grant") ||
198                     error == QLatin1String("invalid_request")) {
199                     newRefreshToken.clear();
200                 } else {
201                     qCWarning(lcOauth) << tr("Error while refreshing the token: %1 : %2").arg(error, json.value(QLatin1String("error_description")).toString());
202                 }
203             } else if (reply->error() != QNetworkReply::NoError) {
204                 qCWarning(lcOauth) << tr("Error while refreshing the token: %1 : %2").arg(reply->errorString(), QString::fromUtf8(jsonData));
205             } else {
206                 if (jsonParseError.error != QJsonParseError::NoError || json.isEmpty()) {
207                     // Invalid or empty JSON: Network error maybe?
208                     qCWarning(lcOauth) << tr("Error while refreshing the token: %1 : %2").arg(jsonParseError.errorString(), QString::fromUtf8(jsonData));
209                 } else {
210                     QString error;
211                     accessToken = getRequiredField(json, QStringLiteral("access_token"), &error).toString();
212                     if (!error.isEmpty()) {
213                         qCWarning(lcOauth) << tr("The reply from the server did not contain all expected fields\n:%1\nReceived data: %2").arg(error, QString::fromUtf8(jsonData));
214                     }
215 
216                     const auto refresh_token = json.find(QStringLiteral("refresh_token"));
217                     if (refresh_token != json.constEnd() )
218                     {
219                         newRefreshToken = refresh_token.value().toString();
220                     }
221                 }
222             }
223             Q_EMIT refreshFinished(accessToken, newRefreshToken);
224         });
225     });
226     fetchWellKnown();
227 }
228 
finalize(QPointer<QTcpSocket> socket,const QString & accessToken,const QString & refreshToken,const QString & user,const QUrl & messageUrl)229 void OAuth::finalize(QPointer<QTcpSocket> socket, const QString &accessToken,
230                      const QString &refreshToken, const QString &user, const QUrl &messageUrl) {
231     if (!_account->davUser().isNull() && user != _account->davUser()) {
232         // Connected with the wrong user
233         qCWarning(lcOauth) << "We expected the user" << _account->davUser() << "but the server answered with user" << user;
234         const QString message = tr("<h1>Wrong user</h1>"
235                                    "<p>You logged-in with user <em>%1</em>, but must login with user <em>%2</em>.<br>"
236                                    "Please log out of %3 in another tab, then <a href='%4'>click here</a> "
237                                    "and log in as user %2</p>")
238                                     .arg(user, _account->davUser(), Theme::instance()->appNameGUI(),
239                                         authorisationLink().toString(QUrl::FullyEncoded));
240         httpReplyAndClose(socket, QByteArrayLiteral("403 Forbidden"), message.toUtf8());
241         // We are still listening on the socket so we will get the new connection
242         return;
243     }
244     const auto loginSuccessfullHtml = QByteArrayLiteral("<h1>Login Successful</h1><p>You can close this window.</p>");
245     if (messageUrl.isValid()) {
246         httpReplyAndClose(socket, QByteArrayLiteral("303 See Other"), loginSuccessfullHtml,
247             QByteArrayLiteral("Location: ") + messageUrl.toEncoded());
248     } else {
249         httpReplyAndClose(socket, QByteArrayLiteral("200 OK"), loginSuccessfullHtml);
250     }
251     emit result(LoggedIn, user, accessToken, refreshToken);
252 }
253 
postTokenRequest(const QList<QPair<QString,QString>> & queryItems)254 SimpleNetworkJob *OAuth::postTokenRequest(const QList<QPair<QString, QString>> &queryItems)
255 {
256     const QUrl requestTokenUrl = _tokenEndpoint.isEmpty() ? Utility::concatUrlPath(_account->url(), QStringLiteral("/index.php/apps/oauth2/api/v1/token")) : _tokenEndpoint;
257     QNetworkRequest req;
258     req.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/x-www-form-urlencoded; charset=UTF-8"));
259     const QByteArray basicAuth = QStringLiteral("%1:%2").arg(Theme::instance()->oauthClientId(), Theme::instance()->oauthClientSecret()).toUtf8().toBase64();
260     req.setRawHeader("Authorization", "Basic " + basicAuth);
261     req.setAttribute(HttpCredentials::DontAddCredentialsAttribute, true);
262 
263     auto requestBody = new QBuffer;
264     QUrlQuery arguments;
265     arguments.setQueryItems(QList<QPair<QString, QString>> { { QStringLiteral("client_id"), Theme::instance()->oauthClientId() },
266                                 { QStringLiteral("client_secret"), Theme::instance()->oauthClientSecret() },
267                                 { QStringLiteral("scope"), Theme::instance()->openIdConnectScopes() } }
268         << queryItems);
269 
270     requestBody->setData(arguments.query(QUrl::FullyEncoded).toUtf8());
271 
272     auto job = _account->sendRequest("POST", requestTokenUrl, req, requestBody);
273     job->setTimeout(qMin(30 * 1000ll, job->timeoutMsec()));
274     job->setAuthenticationJob(true);
275     return job;
276 }
277 
generateRandomString(size_t size) const278 QByteArray OAuth::generateRandomString(size_t size) const
279 {
280     // TODO: do we need a varaible size?
281     std::vector<quint32> buffer(size, 0);
282     QRandomGenerator::global()->fillRange(buffer.data(), static_cast<qsizetype>(size));
283     return QByteArray(reinterpret_cast<char *>(buffer.data()), static_cast<int>(size * sizeof(quint32))).toBase64(QByteArray::Base64UrlEncoding);
284 }
285 
getRequiredField(const QJsonObject & json,const QString & s,QString * error)286 QVariant OAuth::getRequiredField(const QJsonObject &json, const QString &s, QString *error)
287 {
288     const auto out = json.constFind(s);
289     if (out == json.constEnd()) {
290         error->append(tr("\tError: Missing field %1\n").arg(s));
291         return QJsonValue();
292     }
293     return *out;
294 }
295 
authorisationLink() const296 QUrl OAuth::authorisationLink() const
297 {
298     Q_ASSERT(_server.isListening());
299     QUrlQuery query;
300     const QByteArray code_challenge = QCryptographicHash::hash(_pkceCodeVerifier, QCryptographicHash::Sha256)
301                                           .toBase64(QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals);
302     query.setQueryItems({ { QStringLiteral("response_type"), QStringLiteral("code") },
303         { QStringLiteral("client_id"), Theme::instance()->oauthClientId() },
304         { QStringLiteral("redirect_uri"), QStringLiteral("http://localhost:%1").arg(QString::number(_server.serverPort())) },
305         { QStringLiteral("code_challenge"), QString::fromLatin1(code_challenge) },
306         { QStringLiteral("code_challenge_method"), QStringLiteral("S256") },
307         { QStringLiteral("scope"), Theme::instance()->openIdConnectScopes() },
308         { QStringLiteral("prompt"), Theme::instance()->openIdConnectPrompt() },
309         { QStringLiteral("state"), QString::fromUtf8(_state) } });
310 
311     if (!_account->davUser().isNull()) {
312         const QString davUser = _account->davUser().replace(QLatin1Char('+'), QStringLiteral("%2B")); // Issue #7762;
313         // open id connect
314         query.addQueryItem(QStringLiteral("login_hint"), davUser);
315         // oc 10
316         query.addQueryItem(QStringLiteral("user"), davUser);
317     }
318     const QUrl url = _authEndpoint.isValid()
319         ? Utility::concatUrlPath(_authEndpoint, {}, query)
320         : Utility::concatUrlPath(_account->url(), QStringLiteral("/index.php/apps/oauth2/authorize"), query);
321     return url;
322 }
323 
authorisationLinkAsync(std::function<void (const QUrl &)> callback) const324 void OAuth::authorisationLinkAsync(std::function<void (const QUrl &)> callback) const
325 {
326     if (_wellKnownFinished) {
327         callback(authorisationLink());
328     } else {
329         connect(this, &OAuth::authorisationLinkChanged, callback);
330     }
331 }
332 
fetchWellKnown()333 void OAuth::fetchWellKnown()
334 {
335     const QPair<QString, QString> urls = Theme::instance()->oauthOverrideAuthUrl();
336     if (!urls.first.isNull())
337     {
338         OC_ASSERT(!urls.second.isNull());
339         _authEndpoint = QUrl::fromUserInput(urls.first);
340         _tokenEndpoint = QUrl::fromUserInput(urls.second);
341         _wellKnownFinished = true;
342          Q_EMIT fetchWellKnownFinished();
343     }
344     else
345     {
346         QUrl wellKnownUrl = Utility::concatUrlPath(_account->url(), QStringLiteral("/.well-known/openid-configuration"));
347         QNetworkRequest req;
348         auto job = _account->sendRequest("GET", wellKnownUrl);
349         job->setFollowRedirects(false);
350         job->setAuthenticationJob(true);
351         job->setTimeout(qMin(30 * 1000ll, job->timeoutMsec()));
352         QObject::connect(job, &SimpleNetworkJob::finishedSignal, this, [this](QNetworkReply *reply) {
353             _wellKnownFinished = true;
354             if (reply->error() != QNetworkReply::NoError) {
355                 // Most likely the file does not exist, default to the normal endpoint
356                 Q_EMIT fetchWellKnownFinished();
357                 return;
358             }
359             const auto jsonData = reply->readAll();
360             QJsonParseError jsonParseError;
361             const QJsonObject json = QJsonDocument::fromJson(jsonData, &jsonParseError).object();
362 
363             if (jsonParseError.error == QJsonParseError::NoError) {
364                 QString authEp = json[QStringLiteral("authorization_endpoint")].toString();
365                 if (!authEp.isEmpty())
366                     this->_authEndpoint = QUrl::fromEncoded(authEp.toUtf8());
367                 QString tokenEp = json[QStringLiteral("token_endpoint")].toString();
368                 if (!tokenEp.isEmpty())
369                     this->_tokenEndpoint = QUrl::fromEncoded(tokenEp.toUtf8());
370             } else if (jsonParseError.error == QJsonParseError::IllegalValue) {
371                 qCDebug(lcOauth) << ".well-known did not return json, the server most does not support oidc";
372             } else {
373                 qCWarning(lcOauth) << "Json parse error in well-known: " << jsonParseError.errorString();
374             }
375             Q_EMIT fetchWellKnownFinished();
376         });
377     }
378 }
379 
openBrowser()380 void OAuth::openBrowser()
381 {
382     authorisationLinkAsync([this](const QUrl &link) {
383         if (!QDesktopServices::openUrl(link)) {
384             qCWarning(lcOauth) << "QDesktopServices::openUrl Failed";
385             // We cannot open the browser, then we claim we don't support OAuth.
386             emit result(NotSupported, QString());
387         }
388     });
389 }
390 
391 } // namespace OCC
392