KIMAP

loginjob.cpp
1/*
2 SPDX-FileCopyrightText: 2009 Kevin Ottens <ervin@kde.org>
3 SPDX-FileCopyrightText: 2009 Andras Mantia <amantia@kde.org>
4
5 SPDX-License-Identifier: LGPL-2.0-or-later
6*/
7
8#include "loginjob.h"
9
10#include <KLocalizedString>
11
12#include "kimap_debug.h"
13
14#include "capabilitiesjob.h"
15#include "job_p.h"
16#include "response_p.h"
17#include "rfccodecs.h"
18#include "session_p.h"
19
20#include "common.h"
21
22extern "C" {
23#include <sasl/sasl.h>
24}
25
26static const sasl_callback_t callbacks[] = {{SASL_CB_ECHOPROMPT, nullptr, nullptr},
27 {SASL_CB_NOECHOPROMPT, nullptr, nullptr},
28 {SASL_CB_GETREALM, nullptr, nullptr},
29 {SASL_CB_USER, nullptr, nullptr},
30 {SASL_CB_AUTHNAME, nullptr, nullptr},
31 {SASL_CB_PASS, nullptr, nullptr},
32 {SASL_CB_CANON_USER, nullptr, nullptr},
33 {SASL_CB_LIST_END, nullptr, nullptr}};
34
35namespace KIMAP
36{
37class LoginJobPrivate : public JobPrivate
38{
39public:
40 enum AuthState { PreStartTlsCapability = 0, StartTls, Capability, Login, Authenticate };
41
42 LoginJobPrivate(LoginJob *job, Session *session, const QString &name)
43 : JobPrivate(session, name)
44 , q(job)
45 , encryptionMode(LoginJob::Unencrypted)
46 , authState(Login)
47 , plainLoginDisabled(false)
48 {
49 conn = nullptr;
50 client_interact = nullptr;
51 }
52 ~LoginJobPrivate()
53 {
54 }
55 bool sasl_interact();
56
57 bool startAuthentication();
58 bool answerChallenge(const QByteArray &data);
59 void sslResponse(bool response);
60 void saveServerGreeting(const Response &response);
61
62 LoginJob *const q;
63
64 QString userName;
65 QString authorizationName;
66 QString password;
67 QString serverGreeting;
68
69 LoginJob::EncryptionMode encryptionMode;
70 QString authMode;
71 AuthState authState;
72 QStringList capabilities;
73 bool plainLoginDisabled;
74
75 sasl_conn_t *conn;
76 sasl_interact_t *client_interact;
77};
78}
79
80using namespace KIMAP;
81
82bool LoginJobPrivate::sasl_interact()
83{
84 qCDebug(KIMAP_LOG) << "sasl_interact";
85 sasl_interact_t *interact = client_interact;
86
87 // some mechanisms do not require username && pass, so it doesn't need a popup
88 // window for getting this info
89 for (; interact->id != SASL_CB_LIST_END; interact++) {
90 if (interact->id == SASL_CB_AUTHNAME || interact->id == SASL_CB_PASS) {
91 // TODO: dialog for use name??
92 break;
93 }
94 }
95
96 interact = client_interact;
97 while (interact->id != SASL_CB_LIST_END) {
98 qCDebug(KIMAP_LOG) << "SASL_INTERACT id:" << interact->id;
99 switch (interact->id) {
100 case SASL_CB_AUTHNAME:
101 if (!authorizationName.isEmpty()) {
102 qCDebug(KIMAP_LOG) << "SASL_CB_[AUTHNAME]: '" << authorizationName << "'";
103 interact->result = strdup(authorizationName.toUtf8().constData());
104 interact->len = strlen((const char *)interact->result);
105 break;
106 }
107 [[fallthrough]];
108 case SASL_CB_USER:
109 qCDebug(KIMAP_LOG) << "SASL_CB_[USER|AUTHNAME]: '" << userName << "'";
110 interact->result = strdup(userName.toUtf8().constData());
111 interact->len = strlen((const char *)interact->result);
112 break;
113 case SASL_CB_PASS:
114 qCDebug(KIMAP_LOG) << "SASL_CB_PASS: [hidden]";
115 interact->result = strdup(password.toUtf8().constData());
116 interact->len = strlen((const char *)interact->result);
117 break;
118 default:
119 interact->result = nullptr;
120 interact->len = 0;
121 break;
122 }
123 interact++;
124 }
125 return true;
126}
127
128LoginJob::LoginJob(Session *session)
129 : Job(*new LoginJobPrivate(this, session, i18n("Login")))
130{
131 Q_D(LoginJob);
132 qCDebug(KIMAP_LOG) << this;
133}
134
135LoginJob::~LoginJob()
136{
137 qCDebug(KIMAP_LOG) << this;
138}
139
140QString LoginJob::userName() const
141{
142 Q_D(const LoginJob);
143 return d->userName;
144}
145
146void LoginJob::setUserName(const QString &userName)
147{
148 Q_D(LoginJob);
149 d->userName = userName;
150}
151
152QString LoginJob::authorizationName() const
153{
154 Q_D(const LoginJob);
155 return d->authorizationName;
156}
157
158void LoginJob::setAuthorizationName(const QString &authorizationName)
159{
160 Q_D(LoginJob);
161 d->authorizationName = authorizationName;
162}
163
164QString LoginJob::password() const
165{
166 Q_D(const LoginJob);
167 return d->password;
168}
169
170void LoginJob::setPassword(const QString &password)
171{
172 Q_D(LoginJob);
173 d->password = password;
174}
175
176void LoginJob::doStart()
177{
178 Q_D(LoginJob);
179
180 qCDebug(KIMAP_LOG) << this;
181 // Don't authenticate on a session in the authenticated state
182 if (session()->state() == Session::Authenticated || session()->state() == Session::Selected) {
183 setError(UserDefinedError);
184 setErrorText(i18n("IMAP session in the wrong state for authentication"));
185 emitResult();
186 return;
187 }
188
189 // Get notified once encryption is successfully negotiated
190 connect(d->sessionInternal(), &KIMAP::SessionPrivate::encryptionNegotiationResult, this, [d](bool result) {
191 d->sslResponse(result);
192 });
193
194 // Trigger encryption negotiation only if needed
195 EncryptionMode encryptionMode = d->encryptionMode;
196
197 const auto negotiatedEncryption = d->sessionInternal()->negotiatedEncryption();
198 if (negotiatedEncryption != QSsl::UnknownProtocol) {
199 // If the socket is already encrypted, proceed to the next state
200 d->sslResponse(true);
201 return;
202 }
203
204 if (encryptionMode == SSLorTLS) {
205 // Negotiation got started by Session, but didn't complete yet. Continue in sslResponse.
206 } else if (encryptionMode == STARTTLS) {
207 // Check if STARTTLS is supported
208 d->authState = LoginJobPrivate::PreStartTlsCapability;
209 d->tags << d->sessionInternal()->sendCommand("CAPABILITY");
210 } else if (encryptionMode == Unencrypted) {
211 if (d->authMode.isEmpty()) {
212 d->authState = LoginJobPrivate::Login;
213 qCDebug(KIMAP_LOG) << "sending LOGIN";
214 d->tags << d->sessionInternal()->sendCommand("LOGIN",
215 '"' + quoteIMAP(d->userName).toUtf8() + '"' + ' ' + '"' + quoteIMAP(d->password).toUtf8() + '"');
216 } else {
217 if (!d->startAuthentication()) {
218 emitResult();
219 }
220 }
221 }
222}
223
224void LoginJob::handleResponse(const Response &response)
225{
226 Q_D(LoginJob);
227
228 if (response.content.isEmpty()) {
229 return;
230 }
231
232 // set the actual command name for standard responses
233 QString commandName = i18n("Login");
234 if (d->authState == LoginJobPrivate::Capability) {
235 commandName = i18n("Capability");
236 } else if (d->authState == LoginJobPrivate::StartTls) {
237 commandName = i18n("StartTls");
238 }
239
240 enum ResponseCode { OK, ERR, UNTAGGED, CONTINUATION, MALFORMED };
241
242 QByteArray tag = response.content.first().toString();
243 ResponseCode code = OK;
244
245 qCDebug(KIMAP_LOG) << commandName << tag;
246
247 if (tag == "+") {
248 code = CONTINUATION;
249 } else if (tag == "*") {
250 if (response.content.size() < 2) {
251 code = MALFORMED; // Received empty untagged response
252 } else {
253 code = UNTAGGED;
254 }
255 } else if (d->tags.contains(tag)) {
256 if (response.content.size() < 2) {
257 code = MALFORMED;
258 } else if (response.content[1].toString() == "OK") {
259 code = OK;
260 } else {
261 code = ERR;
262 }
263 }
264
265 switch (code) {
266 case MALFORMED:
267 // We'll handle it later
268 break;
269
270 case ERR:
271 // server replied with NO or BAD for SASL authentication
272 if (d->authState == LoginJobPrivate::Authenticate) {
273 sasl_dispose(&d->conn);
274 }
275
276 setError(UserDefinedError);
277 setErrorText(i18n("%1 failed, server replied: %2", commandName, QLatin1StringView(response.toString().constData())));
278 emitResult();
279 return;
280
281 case UNTAGGED:
282 // The only untagged response interesting for us here is CAPABILITY
283 if (response.content[1].toString() == "CAPABILITY") {
284 d->capabilities.clear();
285 QList<Response::Part>::const_iterator p = response.content.begin() + 2;
286 while (p != response.content.end()) {
287 QString capability = QLatin1StringView(p->toString());
288 d->capabilities << capability;
289 if (capability == QLatin1StringView("LOGINDISABLED")) {
290 d->plainLoginDisabled = true;
291 }
292 ++p;
293 }
294 qCDebug(KIMAP_LOG) << "Capabilities updated: " << d->capabilities;
295 }
296 break;
297
298 case CONTINUATION:
299 if (d->authState != LoginJobPrivate::Authenticate) {
300 // Received unexpected continuation response for something
301 // other than AUTHENTICATE command
302 code = MALFORMED;
303 break;
304 }
305
306 if (d->authMode == QLatin1StringView("PLAIN")) {
307 if (response.content.size() > 1 && response.content.at(1).toString() == "OK") {
308 return;
309 }
310 QByteArray challengeResponse;
311 if (!d->authorizationName.isEmpty()) {
312 challengeResponse += d->authorizationName.toUtf8();
313 }
314 challengeResponse += '\0';
315 challengeResponse += d->userName.toUtf8();
316 challengeResponse += '\0';
317 challengeResponse += d->password.toUtf8();
318 challengeResponse = challengeResponse.toBase64();
319 d->sessionInternal()->sendData(challengeResponse);
320 } else if (response.content.size() >= 2) {
321 if (!d->answerChallenge(QByteArray::fromBase64(response.content[1].toString()))) {
322 emitResult(); // error, we're done
323 }
324 } else {
325 // Received empty continuation for authMode other than PLAIN
326 code = MALFORMED;
327 }
328 break;
329
330 case OK:
331
332 switch (d->authState) {
333 case LoginJobPrivate::PreStartTlsCapability:
334 if (d->capabilities.contains(QLatin1StringView("STARTTLS"))) {
335 d->authState = LoginJobPrivate::StartTls;
336 d->tags << d->sessionInternal()->sendCommand("STARTTLS");
337 } else {
338 qCWarning(KIMAP_LOG) << "STARTTLS not supported by server!";
339 setError(UserDefinedError);
340 setErrorText(i18n("STARTTLS is not supported by the server, try using SSL/TLS instead."));
341 emitResult();
342 }
343 break;
344
345 case LoginJobPrivate::StartTls:
346 d->sessionInternal()->startSsl(QSsl::SecureProtocols);
347 break;
348
349 case LoginJobPrivate::Capability:
350 // If encryption was requested, verify that it's negotiated before logging in
351 if (d->encryptionMode != Unencrypted && d->sessionInternal()->negotiatedEncryption() == QSsl::UnknownProtocol) {
352 setError(LoginJob::UserDefinedError);
353 setErrorText(i18n("Internal error, tried to login before encryption"));
354 emitResult();
355 break;
356 }
357
358 // cleartext login, if enabled
359 if (d->authMode.isEmpty()) {
360 if (d->plainLoginDisabled) {
361 setError(UserDefinedError);
362 setErrorText(i18n("Login failed, plain login is disabled by the server."));
363 emitResult();
364 } else {
365 d->authState = LoginJobPrivate::Login;
366 d->tags << d->sessionInternal()->sendCommand("LOGIN",
367 '"' + quoteIMAP(d->userName).toUtf8() + '"' + ' ' + '"' + quoteIMAP(d->password).toUtf8()
368 + '"');
369 }
370 } else {
371 bool authModeSupported = false;
372 // find the selected SASL authentication method
373 for (const QString &capability : std::as_const(d->capabilities)) {
374 if (capability.startsWith(QLatin1StringView("AUTH="))) {
375 if (QStringView(capability).mid(5) == d->authMode) {
376 authModeSupported = true;
377 break;
378 }
379 }
380 }
381 if (!authModeSupported) {
382 setError(UserDefinedError);
383 setErrorText(i18n("Login failed, authentication mode %1 is not supported by the server.", d->authMode));
384 emitResult();
385 } else if (!d->startAuthentication()) {
386 emitResult(); // problem, we're done
387 }
388 }
389 break;
390
391 case LoginJobPrivate::Authenticate:
392 sasl_dispose(&d->conn); // SASL authentication done
393 // Fall through
394 [[fallthrough]];
395 case LoginJobPrivate::Login:
396 d->saveServerGreeting(response);
397 emitResult(); // got an OK, command done
398 break;
399 }
400 }
401
402 if (code == MALFORMED) {
403 setErrorText(i18n("%1 failed, malformed reply from the server.", commandName));
404 emitResult();
405 }
406}
407
408bool LoginJobPrivate::startAuthentication()
409{
410 // SASL authentication
411 if (!initSASL()) {
412 q->setError(LoginJob::UserDefinedError);
413 q->setErrorText(i18n("Login failed, client cannot initialize the SASL library."));
414 return false;
415 }
416
417 authState = LoginJobPrivate::Authenticate;
418 const char *out = nullptr;
419 uint outlen = 0;
420 const char *mechusing = nullptr;
421
422 int result = sasl_client_new("imap", m_session->hostName().toLatin1().constData(), nullptr, nullptr, callbacks, 0, &conn);
423 if (result != SASL_OK) {
424 const QString saslError = QString::fromUtf8(sasl_errdetail(conn));
425 qCWarning(KIMAP_LOG) << "sasl_client_new failed with:" << result << saslError;
426 q->setError(LoginJob::UserDefinedError);
427 q->setErrorText(saslError);
428 return false;
429 }
430
431 do {
432 qCDebug(KIMAP_LOG) << "Trying authmod" << authMode.toLatin1();
433 result = sasl_client_start(conn,
434 authMode.toLatin1().constData(),
435 &client_interact,
436 capabilities.contains(QLatin1StringView("SASL-IR")) ? &out : nullptr,
437 &outlen,
438 &mechusing);
439
440 if (result == SASL_INTERACT) {
441 if (!sasl_interact()) {
442 sasl_dispose(&conn);
443 q->setError(LoginJob::UserDefinedError); // TODO: check up the actual error
444 return false;
445 }
446 }
447 } while (result == SASL_INTERACT);
448
449 if (result != SASL_CONTINUE && result != SASL_OK) {
450 const QString saslError = QString::fromUtf8(sasl_errdetail(conn));
451 qCWarning(KIMAP_LOG) << "sasl_client_start failed with:" << result << saslError;
452 q->setError(LoginJob::UserDefinedError);
453 q->setErrorText(saslError);
454 sasl_dispose(&conn);
455 return false;
456 }
457
458 QByteArray tmp = QByteArray::fromRawData(out, outlen);
459 QByteArray challenge = tmp.toBase64();
460
461 if (challenge.isEmpty()) {
462 tags << sessionInternal()->sendCommand("AUTHENTICATE", authMode.toLatin1());
463 } else {
464 tags << sessionInternal()->sendCommand("AUTHENTICATE", authMode.toLatin1() + ' ' + challenge);
465 }
466
467 return true;
468}
469
470bool LoginJobPrivate::answerChallenge(const QByteArray &data)
471{
472 QByteArray challenge = data;
473 int result = -1;
474 const char *out = nullptr;
475 uint outlen = 0;
476 do {
477 result = sasl_client_step(conn, challenge.isEmpty() ? nullptr : challenge.data(), challenge.size(), &client_interact, &out, &outlen);
478
479 if (result == SASL_INTERACT) {
480 if (!sasl_interact()) {
481 q->setError(LoginJob::UserDefinedError); // TODO: check up the actual error
482 sasl_dispose(&conn);
483 return false;
484 }
485 }
486 } while (result == SASL_INTERACT);
487
488 if (result != SASL_CONTINUE && result != SASL_OK) {
489 const QString saslError = QString::fromUtf8(sasl_errdetail(conn));
490 qCWarning(KIMAP_LOG) << "sasl_client_step failed with:" << result << saslError;
491 q->setError(LoginJob::UserDefinedError); // TODO: check up the actual error
492 q->setErrorText(saslError);
493 sasl_dispose(&conn);
494 return false;
495 }
496
497 QByteArray tmp = QByteArray::fromRawData(out, outlen);
498 challenge = tmp.toBase64();
499
500 sessionInternal()->sendData(challenge);
501
502 return true;
503}
504
505void LoginJobPrivate::sslResponse(bool response)
506{
507 if (response) {
508 authState = LoginJobPrivate::Capability;
509 tags << sessionInternal()->sendCommand("CAPABILITY");
510 } else {
511 q->setError(LoginJob::UserDefinedError);
512 q->setErrorText(i18n("Login failed, TLS negotiation failed."));
513 encryptionMode = LoginJob::Unencrypted;
514 q->emitResult();
515 }
516}
517
518void LoginJob::setEncryptionMode(EncryptionMode mode)
519{
520 Q_D(LoginJob);
521 d->encryptionMode = mode;
522}
523
524LoginJob::EncryptionMode LoginJob::encryptionMode()
525{
526 Q_D(LoginJob);
527 return d->encryptionMode;
528}
529
530void LoginJob::setAuthenticationMode(AuthenticationMode mode)
531{
532 Q_D(LoginJob);
533 switch (mode) {
534 case ClearText:
535 d->authMode = QLatin1StringView("");
536 break;
537 case Login:
538 d->authMode = QStringLiteral("LOGIN");
539 break;
540 case Plain:
541 d->authMode = QStringLiteral("PLAIN");
542 break;
543 case CramMD5:
544 d->authMode = QStringLiteral("CRAM-MD5");
545 break;
546 case DigestMD5:
547 d->authMode = QStringLiteral("DIGEST-MD5");
548 break;
549 case GSSAPI:
550 d->authMode = QStringLiteral("GSSAPI");
551 break;
552 case Anonymous:
553 d->authMode = QStringLiteral("ANONYMOUS");
554 break;
555 case XOAuth2:
556 d->authMode = QStringLiteral("XOAUTH2");
557 break;
558 default:
559 d->authMode = QString();
560 }
561}
562
563void LoginJob::connectionLost()
564{
565 Q_D(LoginJob);
566
567 qCWarning(KIMAP_LOG) << "Connection to server lost " << d->m_socketError;
568 if (d->m_socketError == QAbstractSocket::SslHandshakeFailedError) {
569 setError(KJob::UserDefinedError);
570 setErrorText(i18n("SSL handshake failed."));
571 emitResult();
572 } else {
573 setError(ERR_COULD_NOT_CONNECT);
574 setErrorText(i18n("Connection to server lost."));
575 emitResult();
576 }
577}
578
579void LoginJobPrivate::saveServerGreeting(const Response &response)
580{
581 // Concatenate the parts of the server response into a string, while dropping the first two parts
582 // (the response tag and the "OK" code), and being careful not to add useless extra whitespace.
583
584 for (int i = 2; i < response.content.size(); i++) {
585 if (response.content.at(i).type() == Response::Part::List) {
586 serverGreeting += QLatin1Char('(');
587 const QList<QByteArray> itemLst = response.content.at(i).toList();
588 for (const QByteArray &item : itemLst) {
589 serverGreeting += QLatin1StringView(item) + QLatin1Char(' ');
590 }
591 serverGreeting.chop(1);
592 serverGreeting += QStringLiteral(") ");
593 } else {
594 serverGreeting += QLatin1StringView(response.content.at(i).toString()) + QLatin1Char(' ');
595 }
596 }
597 serverGreeting.chop(1);
598}
599
600QString LoginJob::serverGreeting() const
601{
602 Q_D(const LoginJob);
603 return d->serverGreeting;
604}
605
606#include "moc_loginjob.cpp"
void setErrorText(const QString &errorText)
void emitResult()
void result(KJob *job)
Capabilities capabilities() const
void setError(int errorCode)
QString i18n(const char *text, const TYPE &arg...)
QString name(StandardShortcut id)
const char * constData() const const
QByteArray first(qsizetype n) const const
QByteArray fromBase64(const QByteArray &base64, Base64Options options)
QByteArray fromRawData(const char *data, qsizetype size)
bool isEmpty() const const
QByteArray toBase64(Base64Options options) const const
const_reference at(qsizetype i) const const
QMetaObject::Connection connect(const QObject *sender, PointerToMemberFunction signal, Functor functor)
UnknownProtocol
void chop(qsizetype n)
QString fromUtf8(QByteArrayView str)
bool isEmpty() const const
bool startsWith(QChar c, Qt::CaseSensitivity cs) const const
QByteArray toLatin1() const const
QByteArray toUtf8() const const
bool contains(QLatin1StringView str, Qt::CaseSensitivity cs) const const
This file is part of the IMAP support library and defines the RfcCodecs class.
KIMAP_EXPORT QString quoteIMAP(const QString &src)
Replaces " with \" and \ with \\ " and \ characters.
Q_D(Todo)
This file is part of the KDE documentation.
Documentation copyright © 1996-2024 The KDE developers.
Generated on Tue Mar 26 2024 11:14:37 by doxygen 1.10.0 written by Dimitri van Heesch, © 1997-2006

KDE's Doxygen guidelines are available online.