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