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
27namespace OCC {
28
29Q_LOGGING_CATEGORY(lcOauth, "sync.credentials.oauth", QtInfoMsg)
30
31OAuth::~OAuth()
32{
33}
34
35static void httpReplyAndClose(QTcpSocket *socket, const char *code, const char *html,
36 const char *moreHeaders = nullptr)
37{
38 if (!socket)
39 return; // socket can have been deleted if the browser was closed
40 socket->write("HTTP/1.1 ");
41 socket->write(code);
42 socket->write("\r\nContent-Type: text/html\r\nConnection: close\r\nContent-Length: ");
43 socket->write(QByteArray::number(qstrlen(html)));
44 if (moreHeaders) {
45 socket->write("\r\n");
46 socket->write(moreHeaders);
47 }
48 socket->write("\r\n\r\n");
49 socket->write(html);
50 socket->disconnectFromHost();
51 // We don't want that deleting the server too early prevent queued data to be sent on this socket.
52 // The socket will be deleted after disconnection because disconnected is connected to deleteLater
53 socket->setParent(nullptr);
54}
55
56void OAuth::start()
57{
58 // Listen on the socket to get a port which will be used in the redirect_uri
59 if (!_server.listen(QHostAddress::LocalHost)) {
60 emit result(NotSupported, QString());
61 return;
62 }
63
64 if (!openBrowser())
65 return;
66
67 QObject::connect(&_server, &QTcpServer::newConnection, this, [this] {
68 while (QPointer<QTcpSocket> socket = _server.nextPendingConnection()) {
69 QObject::connect(socket.data(), &QTcpSocket::disconnected, socket.data(), &QTcpSocket::deleteLater);
70 QObject::connect(socket.data(), &QIODevice::readyRead, this, [this, socket] {
71 QByteArray peek = socket->peek(qMin(socket->bytesAvailable(), 4000LL)); //The code should always be within the first 4K
72 if (peek.indexOf('\n') < 0)
73 return; // wait until we find a \n
74 QRegExp rx("^GET /\\?code=([a-zA-Z0-9]+)[& ]"); // Match a /?code=... URL
75 if (rx.indexIn(peek) != 0) {
76 httpReplyAndClose(socket, "404 Not Found", "<html><head><title>404 Not Found</title></head><body><center><h1>404 Not Found</h1></center></body></html>");
77 return;
78 }
79
80 QString code = rx.cap(1); // The 'code' is the first capture of the regexp
81
82 QUrl requestToken = Utility::concatUrlPath(_account->url().toString(), QLatin1String("/index.php/apps/oauth2/api/v1/token"));
83 QNetworkRequest req;
84 req.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-www-form-urlencoded");
85
86 QString basicAuth = QString("%1:%2").arg(
87 Theme::instance()->oauthClientId(), Theme::instance()->oauthClientSecret());
88 req.setRawHeader("Authorization", "Basic " + basicAuth.toUtf8().toBase64());
89 // We just added the Authorization header, don't let HttpCredentialsAccessManager tamper with it
90 req.setAttribute(HttpCredentials::DontAddCredentialsAttribute, true);
91
92 auto requestBody = new QBuffer;
93 QUrlQuery arguments(QString(
94 "grant_type=authorization_code&code=%1&redirect_uri=http://localhost:%2")
95 .arg(code, QString::number(_server.serverPort())));
96 requestBody->setData(arguments.query(QUrl::FullyEncoded).toLatin1());
97
98 auto job = _account->sendRequest("POST", requestToken, req, requestBody);
99 job->setTimeout(qMin(30 * 1000ll, job->timeoutMsec()));
100 QObject::connect(job, &SimpleNetworkJob::finishedSignal, this, [this, socket](QNetworkReply *reply) {
101 auto jsonData = reply->readAll();
102 QJsonParseError jsonParseError;
103 QJsonObject json = QJsonDocument::fromJson(jsonData, &jsonParseError).object();
104 QString accessToken = json["access_token"].toString();
105 QString refreshToken = json["refresh_token"].toString();
106 QString user = json["user_id"].toString();
107 QUrl messageUrl = json["message_url"].toString();
108
109 if (reply->error() != QNetworkReply::NoError || jsonParseError.error != QJsonParseError::NoError
110 || json.isEmpty() || refreshToken.isEmpty() || accessToken.isEmpty()
111 || json["token_type"].toString() != QLatin1String("Bearer")) {
112 QString errorReason;
113 QString errorFromJson = json["error"].toString();
114 if (!errorFromJson.isEmpty()) {
115 errorReason = tr("Error returned from the server: <em>%1</em>")
116 .arg(errorFromJson.toHtmlEscaped());
117 } else if (reply->error() != QNetworkReply::NoError) {
118 errorReason = tr("There was an error accessing the 'token' endpoint: <br><em>%1</em>")
119 .arg(reply->errorString().toHtmlEscaped());
120 } else if (jsonParseError.error != QJsonParseError::NoError) {
121 errorReason = tr("Could not parse the JSON returned from the server: <br><em>%1</em>")
122 .arg(jsonParseError.errorString());
123 } else {
124 errorReason = tr("The reply from the server did not contain all expected fields");
125 }
126 qCWarning(lcOauth) << "Error when getting the accessToken" << json << errorReason;
127 httpReplyAndClose(socket, "500 Internal Server Error",
128 tr("<h1>Login Error</h1><p>%1</p>").arg(errorReason).toUtf8().constData());
129 emit result(Error);
130 return;
131 }
132 if (!_expectedUser.isNull() && user != _expectedUser) {
133 // Connected with the wrong user
134 QString message = tr("<h1>Wrong user</h1>"
135 "<p>You logged-in with user <em>%1</em>, but must login with user <em>%2</em>.<br>"
136 "Please log out of %3 in another tab, then <a href='%4'>click here</a> "
137 "and log in as user %2</p>")
138 .arg(user, _expectedUser, Theme::instance()->appNameGUI(),
139 authorisationLink().toString(QUrl::FullyEncoded));
140 httpReplyAndClose(socket, "200 OK", message.toUtf8().constData());
141 // We are still listening on the socket so we will get the new connection
142 return;
143 }
144 const char *loginSuccessfullHtml = "<h1>Login Successful</h1><p>You can close this window.</p>";
145 if (messageUrl.isValid()) {
146 httpReplyAndClose(socket, "303 See Other", loginSuccessfullHtml,
147 QByteArray("Location: " + messageUrl.toEncoded()).constData());
148 } else {
149 httpReplyAndClose(socket, "200 OK", loginSuccessfullHtml);
150 }
151 emit result(LoggedIn, user, accessToken, refreshToken);
152 });
153 });
154 }
155 });
156}
157
158QUrl OAuth::authorisationLink() const
159{
160 Q_ASSERT(_server.isListening());
161 QUrlQuery query;
162 query.setQueryItems({ { QLatin1String("response_type"), QLatin1String("code") },
163 { QLatin1String("client_id"), Theme::instance()->oauthClientId() },
164 { QLatin1String("redirect_uri"), QLatin1String("http://localhost:") + QString::number(_server.serverPort()) } });
165 if (!_expectedUser.isNull())
166 query.addQueryItem("user", _expectedUser);
167 QUrl url = Utility::concatUrlPath(_account->url(), QLatin1String("/index.php/apps/oauth2/authorize"), query);
168 return url;
169}
170
171bool OAuth::openBrowser()
172{
173 if (!QDesktopServices::openUrl(authorisationLink())) {
174 // We cannot open the browser, then we claim we don't support OAuth.
175 emit result(NotSupported, QString());
176 return false;
177 }
178 return true;
179}
180
181} // namespace OCC
182