Branch data Line data Source code
1 : : #include "NextcloudAuth.h"
2 : :
3 : : #include <QDesktopServices>
4 : : #include <QJsonDocument>
5 : : #include <QJsonObject>
6 : : #include <QLoggingCategory>
7 : : #include <QNetworkAccessManager>
8 : : #include <QNetworkReply>
9 : : #include <QNetworkRequest>
10 : : #include <QTimer>
11 : : #include <QUrl>
12 : : #include <QUrlQuery>
13 : :
14 [ + + + - : 77 : Q_LOGGING_CATEGORY(lcNextcloudAuth, "mailjd.nextcloudauth")
+ - - - ]
15 : :
16 : : namespace {
17 : 30 : int effectivePort(const QUrl &url) {
18 [ + + ]: 30 : if (url.port() >= 0)
19 : 4 : return url.port();
20 [ + - ]: 78 : return url.scheme().compare(QStringLiteral("https"), Qt::CaseInsensitive) == 0
21 [ + + ]: 26 : ? 443
22 : 26 : : 80;
23 : : }
24 : :
25 : 17 : bool isSameOrigin(const QUrl &candidate, const QUrl &expected) {
26 [ + - + - : 51 : return candidate.isValid() && !candidate.host().isEmpty() &&
+ + - - ]
27 [ + - + - : 34 : candidate.scheme().compare(expected.scheme(), Qt::CaseInsensitive) ==
+ - + - -
- - - ]
28 [ + + ]: 32 : 0 &&
29 [ + - + - : 49 : candidate.host().compare(expected.host(), Qt::CaseInsensitive) == 0 &&
+ + + - -
- - - ]
30 [ + - + - : 30 : effectivePort(candidate) == effectivePort(expected) &&
+ - + - ]
31 [ + - + - : 66 : candidate.userName().isEmpty() && candidate.password().isEmpty();
+ - + - +
- + + + +
+ + - - -
- ]
32 : : }
33 : : } // namespace
34 : :
35 : 91 : NextcloudAuth::NextcloudAuth(QObject *parent) : QObject(parent) {
36 [ + - + - : 91 : m_nam = new QNetworkAccessManager(this);
- + - - ]
37 [ + - ]: 91 : m_nam->setRedirectPolicy(QNetworkRequest::SameOriginRedirectPolicy);
38 [ + - + - : 91 : m_pollTimer = new QTimer(this);
- + - - ]
39 [ + - ]: 91 : m_pollTimer->setInterval(2000);
40 [ + - ]: 91 : connect(m_pollTimer, &QTimer::timeout, this, &NextcloudAuth::poll);
41 : 91 : }
42 : :
43 : 170 : NextcloudAuth::~NextcloudAuth() { cancel(); }
44 : :
45 : 6 : void NextcloudAuth::setNetworkAccessManager(QNetworkAccessManager *nam) {
46 [ + - + - : 6 : if (m_nam && m_nam->parent() == this)
+ - ]
47 [ + - ]: 6 : delete m_nam;
48 : 6 : m_nam = nam;
49 [ + - ]: 6 : if (m_nam) {
50 : 6 : m_nam->setParent(this);
51 : 6 : m_nam->setRedirectPolicy(QNetworkRequest::SameOriginRedirectPolicy);
52 : : }
53 : 6 : }
54 : :
55 : 14 : void NextcloudAuth::startLogin(const QString &serverUrl) {
56 [ + - ]: 14 : cancel(); // Cancel any ongoing flow
57 : :
58 : 14 : QString url = serverUrl;
59 [ + - + + ]: 14 : if (url.endsWith('/'))
60 [ + - ]: 1 : url.chop(1);
61 : :
62 [ + - ]: 14 : QUrl parsedUrl(url);
63 [ + - + + ]: 14 : if (!isServerUrlAllowedForLogin(parsedUrl)) {
64 [ + - + - : 6 : qCWarning(lcNextcloudAuth)
+ + ]
65 [ + - + - ]: 3 : << "Rejected insecure Nextcloud Login Flow URL:" << url;
66 [ + - + - ]: 3 : emit loginFailed(tr("Nextcloud Login Flow requires HTTPS"));
67 : 3 : return;
68 : : }
69 : :
70 : : // T-611/SEC-10: Save original server URL for SSRF origin validation
71 : 11 : m_originalServerUrl = url;
72 : :
73 : : QNetworkRequest request{
74 [ + - + - : 11 : QUrl(url + QStringLiteral("/index.php/login/v2"))};
+ - ]
75 [ + - ]: 11 : request.setHeader(QNetworkRequest::ContentTypeHeader,
76 : 22 : QStringLiteral("application/x-www-form-urlencoded"));
77 : : // Nextcloud requires a User-Agent for the app name display
78 [ + - + - : 11 : request.setRawHeader("User-Agent", "MailJD/1.0");
+ - ]
79 : :
80 [ + - ]: 11 : m_currentReply = m_nam->post(request, QByteArray());
81 : 11 : connect(m_currentReply, &QNetworkReply::finished, this,
82 [ + - ]: 11 : &NextcloudAuth::onInitReply);
83 [ + + + + ]: 17 : }
84 : :
85 : 109 : void NextcloudAuth::cancel() {
86 : 109 : m_pollTimer->stop();
87 : 109 : m_pollCount = 0;
88 : 109 : m_pollEndpoint.clear();
89 : 109 : m_pollToken.clear();
90 [ + + ]: 109 : if (m_currentReply) {
91 : : // Qt 6.8: QNetworkReply::abort() emits 'finished' synchronously, which
92 : : // would re-enter the connected poll/onInit lambda (it nulls m_currentReply
93 : : // and may emit loginFailed/loginSuccess). Disconnect first so cancel()
94 : : // owns the cleanup exclusively, then abort+deleteLater safely.
95 : 2 : disconnect(m_currentReply, nullptr, this, nullptr);
96 : 2 : m_currentReply->abort();
97 : 2 : m_currentReply->deleteLater();
98 : 2 : m_currentReply = nullptr;
99 : : }
100 : 109 : }
101 : :
102 : 32 : bool NextcloudAuth::isPolling() const { return m_pollTimer->isActive(); }
103 : :
104 : 26 : bool NextcloudAuth::isServerUrlAllowedForLogin(const QUrl &url) {
105 [ + - + - : 52 : if (!url.isValid() || url.host().isEmpty())
+ - - + +
- - + -
- ]
106 : 0 : return false;
107 : :
108 [ + - + + ]: 26 : if (url.scheme() == QLatin1String("https"))
109 : 17 : return true;
110 : :
111 : : #ifdef MAILJD_UNIT_TEST
112 [ + - + - ]: 7 : if (url.scheme() == QLatin1String("http")) {
113 [ + - + - ]: 7 : const QString host = url.host().toLower();
114 [ + + ]: 13 : return host == QLatin1String("localhost") ||
115 [ + + - + ]: 13 : host == QLatin1String("127.0.0.1") ||
116 : 2 : host == QLatin1String("::1");
117 : 7 : }
118 : : #endif
119 : :
120 : 2 : return false;
121 : : }
122 : :
123 : 11 : void NextcloudAuth::onInitReply() {
124 [ + - + - ]: 11 : auto *reply = qobject_cast<QNetworkReply *>(sender());
125 [ - + ]: 11 : if (!reply)
126 : 4 : return;
127 [ + - ]: 11 : reply->deleteLater();
128 : 11 : m_currentReply = nullptr;
129 : :
130 [ + - + + ]: 11 : if (reply->error() != QNetworkReply::NoError) {
131 [ + - + - : 2 : qCWarning(lcNextcloudAuth)
+ + ]
132 [ + - + - : 1 : << "Login flow init failed:" << reply->errorString();
+ - ]
133 [ + - + - ]: 1 : emit loginFailed(reply->errorString());
134 : 1 : return;
135 : : }
136 : :
137 [ + - + - ]: 10 : QJsonDocument doc = QJsonDocument::fromJson(reply->readAll());
138 [ + - ]: 10 : QJsonObject root = doc.object();
139 : :
140 [ + - + - ]: 10 : QJsonObject pollObj = root[QStringLiteral("poll")].toObject();
141 [ + - + - ]: 10 : m_pollEndpoint = pollObj[QStringLiteral("endpoint")].toString();
142 [ + - + - ]: 10 : m_pollToken = pollObj[QStringLiteral("token")].toString();
143 [ + - + - ]: 10 : QString loginUrl = root[QStringLiteral("login")].toString();
144 : :
145 [ + + + - : 19 : if (m_pollEndpoint.isEmpty() || m_pollToken.isEmpty() ||
- + + + ]
146 : 9 : loginUrl.isEmpty()) {
147 [ + - + - ]: 1 : emit loginFailed(tr("Invalid Login Flow v2 response"));
148 : 1 : return;
149 : : }
150 : :
151 : : // T-611/SEC-10: Validate poll endpoint origin to prevent SSRF
152 [ + - ]: 9 : QUrl pollUrl(m_pollEndpoint);
153 [ + - ]: 9 : QUrl serverUrl(m_originalServerUrl);
154 [ + - + + ]: 9 : if (!isSameOrigin(pollUrl, serverUrl)) {
155 [ + - + - : 2 : qCWarning(lcNextcloudAuth)
+ + ]
156 [ + - + - ]: 1 : << "Rejected cross-origin poll endpoint:" << m_pollEndpoint
157 [ + - + - : 1 : << "(expected origin:" << m_originalServerUrl << ")";
+ - ]
158 [ + - + - ]: 1 : emit loginFailed(tr("Security error: poll endpoint has a foreign origin"));
159 : 1 : return;
160 : : }
161 : :
162 [ + - ]: 8 : const QUrl parsedLoginUrl(loginUrl);
163 [ + - + - : 16 : if (!isServerUrlAllowedForLogin(parsedLoginUrl) ||
+ + ]
164 [ + - + + ]: 8 : !isSameOrigin(parsedLoginUrl, serverUrl)) {
165 [ + - + - : 2 : qCWarning(lcNextcloudAuth)
+ + ]
166 [ + - + - ]: 1 : << "Rejected cross-origin login URL:" << loginUrl
167 [ + - + - : 1 : << "(expected origin:" << m_originalServerUrl << ")";
+ - ]
168 [ + - + - ]: 1 : emit loginFailed(tr("Security error: login URL has a foreign origin"));
169 : 1 : return;
170 : : }
171 : :
172 [ + - + - : 14 : qCInfo(lcNextcloudAuth) << "Opening browser for Nextcloud login";
+ - + + ]
173 [ + - ]: 7 : QDesktopServices::openUrl(parsedLoginUrl);
174 : :
175 : : // Start polling
176 : 7 : m_pollCount = 0;
177 [ + - ]: 7 : m_pollTimer->start();
178 [ + + + + : 24 : }
+ + + + +
+ + + +
+ ]
179 : :
180 : 69 : void NextcloudAuth::poll() {
181 [ + + ]: 69 : if (++m_pollCount > MaxPollAttempts) {
182 [ + - ]: 1 : m_pollTimer->stop();
183 : 1 : m_pollEndpoint.clear();
184 : 1 : m_pollToken.clear();
185 [ + - ]: 1 : if (m_currentReply)
186 [ + - ]: 1 : m_currentReply->abort();
187 [ + - + - : 2 : qCWarning(lcNextcloudAuth) << "Login flow timed out after"
+ - + + ]
188 [ + - + - ]: 1 : << (MaxPollAttempts * 2) << "seconds";
189 [ + - + - ]: 1 : emit loginFailed(tr("Login timed out (120 seconds)"));
190 : 61 : return;
191 : : }
192 : :
193 [ + + ]: 68 : if (m_currentReply) {
194 [ + - + - : 120 : qCDebug(lcNextcloudAuth)
+ + ]
195 [ + - ]: 60 : << "Skipping Nextcloud login poll while previous poll is in flight";
196 : 60 : return;
197 : : }
198 : :
199 [ + - + - ]: 8 : QNetworkRequest request{QUrl(m_pollEndpoint)};
200 [ + - ]: 8 : request.setHeader(QNetworkRequest::ContentTypeHeader,
201 : 16 : QStringLiteral("application/x-www-form-urlencoded"));
202 : :
203 [ + - ]: 8 : QUrlQuery params;
204 [ + - ]: 16 : params.addQueryItem(QStringLiteral("token"), m_pollToken);
205 : :
206 : 8 : m_currentReply =
207 [ + - + - : 8 : m_nam->post(request, params.query(QUrl::FullyEncoded).toUtf8());
+ - ]
208 [ + - ]: 8 : connect(m_currentReply, &QNetworkReply::finished, this, [this]() {
209 [ + - + - ]: 6 : auto *reply = qobject_cast<QNetworkReply *>(sender());
210 [ - + ]: 6 : if (!reply)
211 : 3 : return;
212 [ + - ]: 6 : reply->deleteLater();
213 : 6 : m_currentReply = nullptr;
214 : :
215 [ - + ]: 6 : if (m_pollToken.isEmpty())
216 : 0 : return;
217 : :
218 [ + - ]: 6 : int status = reply->attribute(
219 [ + - ]: 6 : QNetworkRequest::HttpStatusCodeAttribute).toInt();
220 : :
221 [ + + ]: 6 : if (status == 404) {
222 : : // Not yet authorized — keep polling
223 : 1 : return;
224 : : }
225 : :
226 [ + - + + ]: 5 : if (reply->error() != QNetworkReply::NoError) {
227 [ + - ]: 1 : m_pollTimer->stop();
228 : 1 : m_pollEndpoint.clear();
229 : 1 : m_pollToken.clear();
230 [ + - + - ]: 1 : emit loginFailed(reply->errorString());
231 : 1 : return;
232 : : }
233 : :
234 : : // Success!
235 [ + - ]: 4 : m_pollTimer->stop();
236 : :
237 [ + - + - ]: 4 : QJsonDocument doc = QJsonDocument::fromJson(reply->readAll());
238 [ + - ]: 4 : QJsonObject obj = doc.object();
239 : :
240 [ + - + - ]: 4 : QString server = obj[QStringLiteral("server")].toString();
241 [ + - + - ]: 4 : QString loginName = obj[QStringLiteral("loginName")].toString();
242 [ + - + - ]: 4 : QString appPassword = obj[QStringLiteral("appPassword")].toString();
243 : :
244 [ + + + - : 4 : if (server.isEmpty() || loginName.isEmpty() || appPassword.isEmpty()) {
- + + + ]
245 : 1 : m_pollEndpoint.clear();
246 : 1 : m_pollToken.clear();
247 [ + - + - ]: 1 : emit loginFailed(tr("Invalid response from server"));
248 : 1 : return;
249 : : }
250 : :
251 [ + - + - : 6 : qCInfo(lcNextcloudAuth) << "Login successful for" << loginName
+ - + - +
+ ]
252 [ + - + - ]: 3 : << "on" << server;
253 : 3 : m_pollEndpoint.clear();
254 : 3 : m_pollToken.clear();
255 [ + - ]: 3 : emit loginSuccess(server, loginName, appPassword);
256 [ + + + + : 8 : });
+ + + + +
+ ]
257 : 8 : }
|