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

KDE's Doxygen guidelines are available online.