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