1 /*
2  * Copyright (C) by Olivier Goffart <ogoffart@woboq.com>
3  * Copyright (C) by Michael Schuster <michael@schuster.ms>
4  *
5  * This program is free software; you can redistribute it and/or modify
6  * it under the terms of the GNU General Public License as published by
7  * the Free Software Foundation; either version 2 of the License, or
8  * (at your option) any later version.
9  *
10  * This program is distributed in the hope that it will be useful, but
11  * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
12  * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
13  * for more details.
14  */
15 
16 #include <QDesktopServices>
17 #include <QApplication>
18 #include <QClipboard>
19 #include <QTimer>
20 #include <QBuffer>
21 #include "account.h"
22 #include "flow2auth.h"
23 #include <QJsonObject>
24 #include <QJsonDocument>
25 #include "theme.h"
26 #include "networkjobs.h"
27 #include "configfile.h"
28 #include "guiutility.h"
29 
30 namespace OCC {
31 
32 Q_LOGGING_CATEGORY(lcFlow2auth, "nextcloud.sync.credentials.flow2auth", QtInfoMsg)
33 
34 
Flow2Auth(Account * account,QObject * parent)35 Flow2Auth::Flow2Auth(Account *account, QObject *parent)
36     : QObject(parent)
37     , _account(account)
38     , _isBusy(false)
39     , _hasToken(false)
40 {
41     _pollTimer.setInterval(1000);
42     QObject::connect(&_pollTimer, &QTimer::timeout, this, &Flow2Auth::slotPollTimerTimeout);
43 }
44 
45 Flow2Auth::~Flow2Auth() = default;
46 
start()47 void Flow2Auth::start()
48 {
49     // Note: All startup code is in openBrowser() to allow reinitiate a new request with
50     //       fresh tokens. Opening the same pollEndpoint link twice triggers an expiration
51     //       message by the server (security, intended design).
52     openBrowser();
53 }
54 
authorisationLink() const55 QUrl Flow2Auth::authorisationLink() const
56 {
57     return _loginUrl;
58 }
59 
openBrowser()60 void Flow2Auth::openBrowser()
61 {
62     fetchNewToken(TokenAction::actionOpenBrowser);
63 }
64 
copyLinkToClipboard()65 void Flow2Auth::copyLinkToClipboard()
66 {
67     fetchNewToken(TokenAction::actionCopyLinkToClipboard);
68 }
69 
fetchNewToken(const TokenAction action)70 void Flow2Auth::fetchNewToken(const TokenAction action)
71 {
72     if(_isBusy)
73         return;
74 
75     _isBusy = true;
76     _hasToken = false;
77 
78     emit statusChanged(PollStatus::statusFetchToken, 0);
79 
80     // Step 1: Initiate a login, do an anonymous POST request
81     QUrl url = Utility::concatUrlPath(_account->url().toString(), QLatin1String("/index.php/login/v2"));
82     _enforceHttps = url.scheme() == QStringLiteral("https");
83 
84     // add 'Content-Length: 0' header (see https://github.com/nextcloud/desktop/issues/1473)
85     QNetworkRequest req;
86     req.setHeader(QNetworkRequest::ContentLengthHeader, "0");
87     req.setHeader(QNetworkRequest::UserAgentHeader, Utility::friendlyUserAgentString());
88 
89     auto job = _account->sendRequest("POST", url, req);
90     job->setTimeout(qMin(30 * 1000ll, job->timeoutMsec()));
91 
92     QObject::connect(job, &SimpleNetworkJob::finishedSignal, this, [this, action](QNetworkReply *reply) {
93         auto jsonData = reply->readAll();
94         QJsonParseError jsonParseError;
95         QJsonObject json = QJsonDocument::fromJson(jsonData, &jsonParseError).object();
96         QString pollToken, pollEndpoint, loginUrl;
97 
98         if (reply->error() == QNetworkReply::NoError && jsonParseError.error == QJsonParseError::NoError
99             && !json.isEmpty()) {
100             pollToken = json.value("poll").toObject().value("token").toString();
101             pollEndpoint = json.value("poll").toObject().value("endpoint").toString();
102             if (_enforceHttps && QUrl(pollEndpoint).scheme() != QStringLiteral("https")) {
103                 qCWarning(lcFlow2auth) << "Can not poll endpoint because the returned url" << pollEndpoint << "does not start with https";
104                 emit result(Error, tr("The polling URL does not start with HTTPS despite the login URL started with HTTPS. Login will not be possible because this might be a security issue. Please contact your administrator."));
105                 return;
106             }
107             loginUrl = json["login"].toString();
108         }
109 
110         if (reply->error() != QNetworkReply::NoError || jsonParseError.error != QJsonParseError::NoError
111             || json.isEmpty() || pollToken.isEmpty() || pollEndpoint.isEmpty() || loginUrl.isEmpty()) {
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(lcFlow2auth) << "Error when getting the loginUrl" << json << errorReason;
127             emit result(Error, errorReason);
128             _pollTimer.stop();
129             _isBusy = false;
130             return;
131         }
132 
133 
134         _loginUrl = loginUrl;
135 
136         if (_account->isUsernamePrefillSupported()) {
137             const auto userName = Utility::getCurrentUserName();
138             if (!userName.isEmpty()) {
139                 auto query = QUrlQuery(_loginUrl);
140                 query.addQueryItem(QStringLiteral("user"), userName);
141                 _loginUrl.setQuery(query);
142             }
143         }
144 
145         _pollToken = pollToken;
146         _pollEndpoint = pollEndpoint;
147 
148 
149         // Start polling
150         ConfigFile cfg;
151         std::chrono::milliseconds polltime = cfg.remotePollInterval();
152         qCInfo(lcFlow2auth) << "setting remote poll timer interval to" << polltime.count() << "msec";
153         _secondsInterval = (polltime.count() / 1000);
154         _secondsLeft = _secondsInterval;
155         emit statusChanged(PollStatus::statusPollCountdown, _secondsLeft);
156 
157         if(!_pollTimer.isActive()) {
158             _pollTimer.start();
159         }
160 
161 
162         switch(action)
163         {
164         case actionOpenBrowser:
165             // Try to open Browser
166             if (!Utility::openBrowser(authorisationLink())) {
167                 // We cannot open the browser, then we claim we don't support Flow2Auth.
168                 // Our UI callee will ask the user to copy and open the link.
169                 emit result(NotSupported);
170             }
171             break;
172         case actionCopyLinkToClipboard:
173             QApplication::clipboard()->setText(authorisationLink().toString(QUrl::FullyEncoded));
174             emit statusChanged(PollStatus::statusCopyLinkToClipboard, 0);
175             break;
176         }
177 
178         _isBusy = false;
179         _hasToken = true;
180     });
181 }
182 
slotPollTimerTimeout()183 void Flow2Auth::slotPollTimerTimeout()
184 {
185     if(_isBusy || !_hasToken)
186         return;
187 
188     _isBusy = true;
189 
190     _secondsLeft--;
191     if(_secondsLeft > 0) {
192         emit statusChanged(PollStatus::statusPollCountdown, _secondsLeft);
193         _isBusy = false;
194         return;
195     }
196     emit statusChanged(PollStatus::statusPollNow, 0);
197 
198     // Step 2: Poll
199     QNetworkRequest req;
200     req.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-www-form-urlencoded");
201 
202     auto requestBody = new QBuffer;
203     QUrlQuery arguments(QString("token=%1").arg(_pollToken));
204     requestBody->setData(arguments.query(QUrl::FullyEncoded).toLatin1());
205 
206     auto job = _account->sendRequest("POST", _pollEndpoint, req, requestBody);
207     job->setTimeout(qMin(30 * 1000ll, job->timeoutMsec()));
208 
209     QObject::connect(job, &SimpleNetworkJob::finishedSignal, this, [this](QNetworkReply *reply) {
210         auto jsonData = reply->readAll();
211         QJsonParseError jsonParseError;
212         QJsonObject json = QJsonDocument::fromJson(jsonData, &jsonParseError).object();
213         QUrl serverUrl;
214         QString loginName, appPassword;
215 
216         if (reply->error() == QNetworkReply::NoError && jsonParseError.error == QJsonParseError::NoError
217             && !json.isEmpty()) {
218             serverUrl = json["server"].toString();
219             if (_enforceHttps && serverUrl.scheme() != QStringLiteral("https")) {
220                 qCWarning(lcFlow2auth) << "Returned server url" << serverUrl << "does not start with https";
221                 emit result(Error, tr("The returned server URL does not start with HTTPS despite the login URL started with HTTPS. Login will not be possible because this might be a security issue. Please contact your administrator."));
222                 return;
223             }
224             loginName = json["loginName"].toString();
225             appPassword = json["appPassword"].toString();
226         }
227 
228         if (reply->error() != QNetworkReply::NoError || jsonParseError.error != QJsonParseError::NoError
229             || json.isEmpty() || serverUrl.isEmpty() || loginName.isEmpty() || appPassword.isEmpty()) {
230             QString errorReason;
231             QString errorFromJson = json["error"].toString();
232             if (!errorFromJson.isEmpty()) {
233                 errorReason = tr("Error returned from the server: <em>%1</em>")
234                                   .arg(errorFromJson.toHtmlEscaped());
235             } else if (reply->error() != QNetworkReply::NoError) {
236                 errorReason = tr("There was an error accessing the \"token\" endpoint: <br><em>%1</em>")
237                                   .arg(reply->errorString().toHtmlEscaped());
238             } else if (jsonParseError.error != QJsonParseError::NoError) {
239                 errorReason = tr("Could not parse the JSON returned from the server: <br><em>%1</em>")
240                                   .arg(jsonParseError.errorString());
241             } else {
242                 errorReason = tr("The reply from the server did not contain all expected fields");
243             }
244             qCDebug(lcFlow2auth) << "Error when polling for the appPassword" << json << errorReason;
245 
246             // We get a 404 until authentication is done, so don't show this error in the GUI.
247             if(reply->error() != QNetworkReply::ContentNotFoundError)
248                 emit result(Error, errorReason);
249 
250             // Forget sensitive data
251             appPassword.clear();
252             loginName.clear();
253 
254             // Failed: poll again
255             _secondsLeft = _secondsInterval;
256             _isBusy = false;
257             return;
258         }
259 
260         _pollTimer.stop();
261 
262         // Success
263         qCInfo(lcFlow2auth) << "Success getting the appPassword for user: " << loginName << ", server: " << serverUrl.toString();
264 
265         _account->setUrl(serverUrl);
266 
267         emit result(LoggedIn, QString(), loginName, appPassword);
268 
269         // Forget sensitive data
270         appPassword.clear();
271         loginName.clear();
272 
273         _loginUrl.clear();
274         _pollToken.clear();
275         _pollEndpoint.clear();
276 
277         _isBusy = false;
278         _hasToken = false;
279     });
280 }
281 
slotPollNow()282 void Flow2Auth::slotPollNow()
283 {
284     // poll now if we're not already doing so
285     if(_isBusy || !_hasToken)
286         return;
287 
288     _secondsLeft = 1;
289     slotPollTimerTimeout();
290 }
291 
292 } // namespace OCC
293