KIMAP2

loginjob.cpp
1/*
2 Copyright (c) 2009 Kevin Ottens <ervin@kde.org>
3 Copyright (c) 2009 Andras Mantia <amantia@kde.org>
4 Copyright (c) 2017 Christian Mollekopf <mollekopf@kolabsys.com>
5
6 This library is free software; you can redistribute it and/or modify it
7 under the terms of the GNU Library General Public License as published by
8 the Free Software Foundation; either version 2 of the License, or (at your
9 option) any later version.
10
11 This library is distributed in the hope that it will be useful, but WITHOUT
12 ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
13 FITNESS FOR A PARTICULAR PURPOSE. See the GNU Library General Public
14 License for more details.
15
16 You should have received a copy of the GNU Library General Public License
17 along with this library; see the file COPYING.LIB. If not, write to the
18 Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
19 02110-1301, USA.
20*/
21
22#include "loginjob.h"
23
24#include "kimap_debug.h"
25
26#include "job_p.h"
27#include "message_p.h"
28#include "session_p.h"
29#include "rfccodecs.h"
30
31#include "common.h"
32
33extern "C" {
34#include <sasl/sasl.h>
35}
36
37static const sasl_callback_t callbacks[] = {
38 { SASL_CB_ECHOPROMPT, Q_NULLPTR, nullptr },
39 { SASL_CB_NOECHOPROMPT, Q_NULLPTR, nullptr },
40 { SASL_CB_GETREALM, Q_NULLPTR, nullptr },
41 { SASL_CB_USER, Q_NULLPTR, nullptr },
42 { SASL_CB_AUTHNAME, Q_NULLPTR, nullptr },
43 { SASL_CB_PASS, Q_NULLPTR, nullptr },
44 { SASL_CB_CANON_USER, Q_NULLPTR, nullptr },
45 { SASL_CB_LIST_END, Q_NULLPTR, nullptr }
46};
47
48namespace KIMAP2
49{
50class LoginJobPrivate : public JobPrivate
51{
52public:
53 enum AuthState {
54 StartTls = 0,
55 Capability,
56 Login,
57 Authenticate
58 };
59
60 LoginJobPrivate(LoginJob *job, Session *session, const QString &name) : JobPrivate(session, name), q(job)
61 {
62 conn = Q_NULLPTR;
63 client_interact = Q_NULLPTR;
64 }
65 ~LoginJobPrivate() { }
66 bool sasl_interact();
67
68 bool startAuthentication();
69 void sendPlainLogin();
70 bool answerChallenge(const QByteArray &data);
71 void sslResponse(bool response);
72 void saveServerGreeting(const Message &response);
73 void login();
74 void retrieveCapabilities();
75
76 LoginJob *q;
77
78 QString userName;
79 QString authorizationName;
80 QString password;
81 QString serverGreeting;
82
84 bool startTls = false;
85 QString authMode;
86 AuthState authState = Login;
87 QStringList capabilities;
88 bool plainLoginDisabled = false;
89 bool connectionIsEncrypted = false;
90
91 sasl_conn_t *conn;
92 sasl_interact_t *client_interact;
93};
94}
95
96using namespace KIMAP2;
97
98bool LoginJobPrivate::sasl_interact()
99{
100 qCDebug(KIMAP2_LOG) << "sasl_interact";
101 sasl_interact_t *interact = client_interact;
102
103 //some mechanisms do not require username && pass, so it doesn't need a popup
104 //window for getting this info
105 for (; interact->id != SASL_CB_LIST_END; interact++) {
106 if (interact->id == SASL_CB_AUTHNAME ||
107 interact->id == SASL_CB_PASS) {
108 //TODO: dialog for use name??
109 break;
110 }
111 }
112
113 interact = client_interact;
114 while (interact->id != SASL_CB_LIST_END) {
115 qCDebug(KIMAP2_LOG) << "SASL_INTERACT id:" << interact->id;
116 switch (interact->id) {
117 case SASL_CB_AUTHNAME:
118 if (!authorizationName.isEmpty()) {
119 qCDebug(KIMAP2_LOG) << "SASL_CB_[AUTHNAME]: '" << authorizationName << "'";
120 interact->result = strdup(authorizationName.toUtf8());
121 interact->len = strlen((const char *) interact->result);
122 break;
123 }
124 case SASL_CB_USER:
125 qCDebug(KIMAP2_LOG) << "SASL_CB_[USER|AUTHNAME]: '" << userName << "'";
126 interact->result = strdup(userName.toUtf8());
127 interact->len = strlen((const char *) interact->result);
128 break;
129 case SASL_CB_PASS:
130 qCDebug(KIMAP2_LOG) << "SASL_CB_PASS: [hidden]";
131 interact->result = strdup(password.toUtf8());
132 interact->len = strlen((const char *) interact->result);
133 break;
134 default:
135 interact->result = Q_NULLPTR;
136 interact->len = 0;
137 break;
138 }
139 interact++;
140 }
141 //FIXME This should return false at least in some cases
142 return true;
143}
144
145LoginJob::LoginJob(Session *session)
146 : Job(*new LoginJobPrivate(this, session, QString::fromUtf8("Login")))
147{
148 qCDebug(KIMAP2_LOG) << this;
149}
150
151LoginJob::~LoginJob()
152{
153 qCDebug(KIMAP2_LOG) << this;
154}
155
156QString LoginJob::userName() const
157{
158 Q_D(const LoginJob);
159 return d->userName;
160}
161
162void LoginJob::setUserName(const QString &userName)
163{
164 Q_D(LoginJob);
165 d->userName = userName;
166}
167
168QString LoginJob::authorizationName() const
169{
170 Q_D(const LoginJob);
171 return d->authorizationName;
172}
173
174void LoginJob::setAuthorizationName(const QString &authorizationName)
175{
176 Q_D(LoginJob);
177 d->authorizationName = authorizationName;
178}
179
180QString LoginJob::password() const
181{
182 Q_D(const LoginJob);
183 return d->password;
184}
185
186void LoginJob::setPassword(const QString &password)
187{
188 Q_D(LoginJob);
189 d->password = password;
190}
191
192/*
193 * The IMAP authentication procedure is unfortunately ridiculously complicated due to the many different options:
194 *
195 * An IMAP Session always has the following structure:
196 * * Connection is established.
197 * * Server sends greeting.
198 * * Client authenticates somehow.
199 * * .....
200 *
201 * If the we have a plain connection it's simple:
202 * * Wait for the greeting
203 * * Login using the chosen authentication mechanism
204 *
205 * If we're using TLS (without STARTTLS, so directly):
206 * * Immediately initiate TLS handshake.
207 * * Wait for the greeting
208 * * Get CAPABILITIES to figure out which AUTH mechs are supported
209 * * Login using the chosen authentication mechanism
210 *
211 * If we're using TLS with STARTTLS:
212 * * Wait for the greeting (on the unencrypted connection)
213 * * Send STARTTLS and wait for OK
214 * * Initiate TLS handshake
215 * * Get CAPABILITIES to figure out which AUTH mechs are supported
216 * * Login using the chosen authentication mechanism
217 */
218void LoginJob::doStart()
219{
220 Q_D(LoginJob);
221
222 qCDebug(KIMAP2_LOG) << "doStart" << this;
223
224 connect(d->sessionInternal(), SIGNAL(encryptionNegotiationResult(bool)), this, SLOT(sslResponse(bool)));
225
226 if (session()->state() == Session::Disconnected) {
227 auto guard = new QObject(this);
228 QObject::connect(session(), &Session::stateChanged, guard, [d, guard](KIMAP2::Session::State newState, KIMAP2::Session::State) {
229 qCDebug(KIMAP2_LOG) << "Session state changed" << newState;
230 d->login();
231 delete guard;
232 });
233 if (!d->startTls && d->encryptionMode != QSsl::UnknownProtocol) {
234 //We have to encrypt for the greeting
235 d->sessionInternal()->startSsl(d->encryptionMode);
236 }
237 //We wait for the server greeting
238 return;
239 } else {
240 qCInfo(KIMAP2_LOG) << "Session is ready, carring on";
241 //The session is ready, we can carry on.
242 d->login();
243 }
244
245}
246
247void LoginJobPrivate::login()
248{
249 // Don't authenticate on a session in the authenticated state
250 if (q->session()->isConnected()) {
251 q->setError(LoginJob::UserDefinedError);
252 q->setErrorText(QString::fromUtf8("IMAP session in the wrong state for authentication"));
253 q->emitResult();
254 return;
255 }
256
257 if (startTls) {
258 //With STARTTLS we have to try to upgrade our connection before the login
259 qCInfo(KIMAP2_LOG) << "Starting with tls";
260 authState = LoginJobPrivate::StartTls;
261 sendCommand("STARTTLS", {});
262 return;
263 } else {
264 //If this is supposed to be unecrypted or already encrypted we can retrieve capabilties. Otherwise we wait for the sslResponse.
265 if (encryptionMode == QSsl::UnknownProtocol || connectionIsEncrypted) {
266 retrieveCapabilities();
267 } else {
268 qCInfo(KIMAP2_LOG) << "Waiting for encryption before retrieveing capabilities.";
269 }
270 }
271
272}
273
274void LoginJobPrivate::sslResponse(bool response)
275{
276 qCDebug(KIMAP2_LOG) << "Got an ssl response " << response;
277 connectionIsEncrypted = response;
278 if (response) {
279 //It's possible that we receive the ssl info before we receive the server greeting.
280 //In that case we're still in the Disconnected state and shouldn't retrieve the capabilities just yet.
281 //We'll try again via login once the state changes.
282 if (m_session->state() != Session::Disconnected) {
283 retrieveCapabilities();
284 }
285 } else {
286 q->setError(LoginFailed);
287 q->setErrorText(QString::fromUtf8("Login failed, TLS negotiation failed."));
288 encryptionMode = QSsl::UnknownProtocol;
289 q->emitResult();
290 }
291}
292
293void LoginJobPrivate::retrieveCapabilities()
294{
295 qCDebug(KIMAP2_LOG) << "Retrieving capabilities.";
296 authState = LoginJobPrivate::Capability;
297 sendCommand("CAPABILITY", {});
298}
299
300void LoginJob::handleResponse(const Message &response)
301{
302 Q_D(LoginJob);
303
304 if (response.content.isEmpty()) {
305 return;
306 }
307
308 //set the actual command name for standard responses
309 QString commandName = QStringLiteral("Login");
310 if (d->authState == LoginJobPrivate::Capability) {
311 commandName = QStringLiteral("Capability");
312 } else if (d->authState == LoginJobPrivate::StartTls) {
313 commandName = QStringLiteral("StartTls");
314 }
315
316 enum ResponseCode {
317 OK,
318 ERR,
319 UNTAGGED,
320 CONTINUATION,
321 MALFORMED
322 };
323
324 QByteArray tag = response.content.first().toString();
325 ResponseCode code = OK;
326
327 qCDebug(KIMAP2_LOG) << commandName << tag;
328
329 if (tag == "+") {
330 code = CONTINUATION;
331 } else if (tag == "*") {
332 if (response.content.size() < 2) {
333 code = MALFORMED; // Received empty untagged response
334 } else {
335 code = UNTAGGED;
336 }
337 } else if (d->tags.contains(tag)) {
338 if (response.content.size() < 2) {
339 code = MALFORMED;
340 } else if (response.content[1].toString() == "OK") {
341 code = OK;
342 } else {
343 code = ERR;
344 }
345 }
346
347 switch (code) {
348 case MALFORMED:
349 // We'll handle it later
350 break;
351
352 case ERR:
353 //server replied with NO or BAD for SASL authentication
354 if (d->authState == LoginJobPrivate::Authenticate) {
355 sasl_dispose(&d->conn);
356 }
357
358 setError(LoginFailed);
359 setErrorText(QString("%1 failed, server replied: %2").arg(commandName).arg(QLatin1String(response.toString().constData())));
360 emitResult();
361 return;
362
363 case UNTAGGED:
364 // The only untagged response interesting for us here is CAPABILITY
365 if (response.content[1].toString() == "CAPABILITY") {
366 QList<Message::Part>::const_iterator p = response.content.begin() + 2;
367 while (p != response.content.end()) {
368 QString capability = QLatin1String(p->toString());
369 d->capabilities << capability;
370 if (capability == QLatin1String("LOGINDISABLED")) {
371 d->plainLoginDisabled = true;
372 }
373 ++p;
374 }
375 qCInfo(KIMAP2_LOG) << "Capabilities updated: " << d->capabilities;
376 }
377 break;
378
379 case CONTINUATION:
380 if (d->authState != LoginJobPrivate::Authenticate) {
381 // Received unexpected continuation response for something
382 // other than AUTHENTICATE command
383 code = MALFORMED;
384 break;
385 }
386
387 if (d->authMode == QLatin1String("PLAIN")) {
388 if (response.content.size() > 1 && response.content.at(1).toString() == "OK") {
389 return;
390 }
391 QByteArray challengeResponse;
392 if (!d->authorizationName.isEmpty()) {
393 challengeResponse += d->authorizationName.toUtf8();
394 }
395 challengeResponse += '\0';
396 challengeResponse += d->userName.toUtf8();
397 challengeResponse += '\0';
398 challengeResponse += d->password.toUtf8();
399 challengeResponse = challengeResponse.toBase64();
400 d->sessionInternal()->sendData(challengeResponse);
401 } else if (response.content.size() >= 2) {
402 if (!d->answerChallenge(QByteArray::fromBase64(response.content[1].toString()))) {
403 emitResult(); //error, we're done
404 }
405 } else {
406 // Received empty continuation for authMode other than PLAIN
407 code = MALFORMED;
408 }
409 break;
410
411 case OK:
412 switch (d->authState) {
413 case LoginJobPrivate::StartTls:
414 //Start encryption and wait for sslResponse
415 d->sessionInternal()->startSsl(d->encryptionMode);
416 break;
417 case LoginJobPrivate::Capability:
418 //cleartext login, if enabled
419 if (d->authMode.isEmpty()) {
420 if (d->plainLoginDisabled) {
421 setError(LoginFailed);
422 setErrorText(QString("Login failed, plain login is disabled by the server."));
423 emitResult();
424 } else {
425 d->sendPlainLogin();
426 }
427 } else {
428 bool authModeSupported = false;
429 //PLAIN is always supported as defined in the standard. We should also get an AUTH= capability, but in case a server doesn't properly announce it we'll just accept it anyways.
430 if (d->authMode == "PLAIN") {
431 authModeSupported = true;
432 }
433 //find the selected SASL authentication method
434 Q_FOREACH (const QString &capability, d->capabilities) {
435 if (capability.startsWith(QLatin1String("AUTH="))) {
436 if (capability.mid(5) == d->authMode) {
437 authModeSupported = true;
438 break;
439 }
440 }
441 }
442 if (!authModeSupported) {
443 setError(LoginFailed);
444 setErrorText(QString("Login failed, authentication mode %1 is not supported by the server.").arg(d->authMode));
445 emitResult();
446 } else if (!d->startAuthentication()) {
447 emitResult(); //problem, we're done
448 }
449 }
450 break;
451
452 case LoginJobPrivate::Authenticate:
453 sasl_dispose(&d->conn); //SASL authentication done
454 // Fall through
455 case LoginJobPrivate::Login:
456 d->saveServerGreeting(response);
457 emitResult(); //got an OK, command done
458 break;
459
460 }
461
462 }
463
464 if (code == MALFORMED) {
465 setErrorText(QString("%1 failed, malformed reply from the server.").arg(commandName));
466 emitResult();
467 }
468}
469
470bool LoginJobPrivate::startAuthentication()
471{
472 //SASL authentication
473 if (!initSASL()) {
474 q->setError(LoginFailed);
475 q->setErrorText(QString("Login failed, client cannot initialize the SASL library."));
476 return false;
477 }
478
479 authState = LoginJobPrivate::Authenticate;
480 const char *out = Q_NULLPTR;
481 uint outlen = 0;
482 const char *mechusing = Q_NULLPTR;
483
484 int result = sasl_client_new("imap", m_session->hostName().toLatin1(), Q_NULLPTR, nullptr, callbacks, 0, &conn);
485 if (result != SASL_OK) {
486 qCWarning(KIMAP2_LOG) << "sasl_client_new failed with:" << result;
487 q->setError(LoginFailed);
488 q->setErrorText(QString::fromUtf8(sasl_errdetail(conn)));
489 return false;
490 }
491
492 do {
493 result = sasl_client_start(conn, authMode.toLatin1(), &client_interact, capabilities.contains(QStringLiteral("SASL-IR")) ? &out : Q_NULLPTR, &outlen, &mechusing);
494
495 if (result == SASL_INTERACT) {
496 if (!sasl_interact()) {
497 sasl_dispose(&conn);
498 q->setError(LoginFailed); //TODO: check up the actual error
499 q->setErrorText(QString("sasl_interact failed"));
500 return false;
501 }
502 }
503 } while (result == SASL_INTERACT);
504
505 if (result != SASL_CONTINUE && result != SASL_OK) {
506 qCWarning(KIMAP2_LOG) << "sasl_client_start failed with:" << result;
507 q->setError(LoginFailed);
508 q->setErrorText(QString::fromUtf8(sasl_errdetail(conn)));
509 sasl_dispose(&conn);
510 return false;
511 }
512
513 QByteArray tmp = QByteArray::fromRawData(out, outlen);
514 QByteArray challenge = tmp.toBase64();
515
516 if (challenge.isEmpty()) {
517 sendCommand("AUTHENTICATE", authMode.toLatin1());
518 } else {
519 sendCommand("AUTHENTICATE", authMode.toLatin1() + ' ' + challenge);
520 }
521
522 return true;
523}
524
525void LoginJobPrivate::sendPlainLogin()
526{
527 authState = LoginJobPrivate::Login;
528 qCDebug(KIMAP2_LOG) << "sending LOGIN";
529 sendCommand("LOGIN",
530 '"' + quoteIMAP(userName).toUtf8() + '"' +
531 ' ' +
532 '"' + quoteIMAP(password).toUtf8() + '"');
533}
534
535bool LoginJobPrivate::answerChallenge(const QByteArray &data)
536{
537 QByteArray challenge = data;
538 int result = -1;
539 const char *out = Q_NULLPTR;
540 uint outlen = 0;
541 do {
542 result = sasl_client_step(conn, challenge.isEmpty() ? Q_NULLPTR : challenge.data(),
543 challenge.size(),
544 &client_interact,
545 &out, &outlen);
546
547 if (result == SASL_INTERACT) {
548 if (!sasl_interact()) {
549 q->setError(LoginFailed); //TODO: check up the actual error
550 q->setErrorText(QString("sasl_interact failed"));
551 sasl_dispose(&conn);
552 return false;
553 }
554 }
555 } while (result == SASL_INTERACT);
556
557 if (result != SASL_CONTINUE && result != SASL_OK) {
558 qCWarning(KIMAP2_LOG) << "sasl_client_step failed with:" << result;
559 q->setError(LoginFailed); //TODO: check up the actual error
560 q->setErrorText(QString::fromUtf8(sasl_errdetail(conn)));
561 sasl_dispose(&conn);
562 return false;
563 }
564
565 QByteArray tmp = QByteArray::fromRawData(out, outlen);
566 challenge = tmp.toBase64();
567
568 sessionInternal()->sendData(challenge);
569
570 return true;
571}
572
573void LoginJob::setEncryptionMode(QSsl::SslProtocol mode, bool startTls)
574{
575 Q_D(LoginJob);
576 d->encryptionMode = mode;
577 d->startTls = startTls;
578}
579
580QSsl::SslProtocol LoginJob::encryptionMode()
581{
582 Q_D(LoginJob);
583 return d->encryptionMode;
584}
585
586void LoginJob::setAuthenticationMode(AuthenticationMode mode)
587{
588 Q_D(LoginJob);
589 switch (mode) {
590 case ClearText: d->authMode = QLatin1String("");
591 break;
592 case Login: d->authMode = QStringLiteral("LOGIN");
593 break;
594 case Plain: d->authMode = QStringLiteral("PLAIN");
595 break;
596 case CramMD5: d->authMode = QStringLiteral("CRAM-MD5");
597 break;
598 case DigestMD5: d->authMode = QStringLiteral("DIGEST-MD5");
599 break;
600 case GSSAPI: d->authMode = QStringLiteral("GSSAPI");
601 break;
602 case Anonymous: d->authMode = QStringLiteral("ANONYMOUS");
603 break;
604 case XOAuth2: d->authMode = QStringLiteral("XOAUTH2");
605 break;
606 default:
607 d->authMode = QStringLiteral("");
608 }
609}
610
611void LoginJob::connectionLost()
612{
613 Q_D(LoginJob);
614
615 qCWarning(KIMAP2_LOG) << "Connection to server lost " << d->m_socketError;
616 if (d->m_socketError == QSslSocket::SslHandshakeFailedError) {
617 setError(SslHandshakeFailed);
618 setErrorText(QString::fromUtf8("SSL handshake failed."));
619 emitResult();
620 } else if (d->m_socketError == QSslSocket::HostNotFoundError) {
621 setError(HostNotFound);
622 setErrorText(QString::fromUtf8("Host not found."));
623 emitResult();
624 } else {
625 setError(CouldNotConnect);
626 setErrorText(QString::fromUtf8("Connection to server lost."));
627 emitResult();
628 }
629}
630
631void LoginJobPrivate::saveServerGreeting(const Message &response)
632{
633 // Concatenate the parts of the server response into a string, while dropping the first two parts
634 // (the response tag and the "OK" code), and being careful not to add useless extra whitespace.
635
636 for (int i = 2; i < response.content.size(); i++) {
637 if (response.content.at(i).type() == Message::Part::List) {
638 serverGreeting += QLatin1Char('(');
639 foreach (const QByteArray &item, response.content.at(i).toList()) {
640 serverGreeting += QLatin1String(item) + QLatin1Char(' ');
641 }
642 serverGreeting.chop(1);
643 serverGreeting += QStringLiteral(") ");
644 } else {
645 serverGreeting += QLatin1String(response.content.at(i).toString()) + QLatin1Char(' ');
646 }
647 }
648 serverGreeting.chop(1);
649}
650
651QString LoginJob::serverGreeting() const
652{
653 Q_D(const LoginJob);
654 return d->serverGreeting;
655}
656
657#include "moc_loginjob.cpp"
void setErrorText(const QString &errorText)
void emitResult()
void setError(int errorCode)
QString name(StandardShortcut id)
KLEO_EXPORT std::unique_ptr< GpgME::DefaultAssuanTransaction > sendCommand(std::shared_ptr< GpgME::Context > &assuanContext, const std::string &command, GpgME::Error &err)
char at(qsizetype i) 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
QObject(QObject *parent)
QMetaObject::Connection connect(const QObject *sender, PointerToMemberFunction signal, Functor functor)
void chop(qsizetype n)
QString fromUtf8(QByteArrayView str)
bool isEmpty() const const
QString mid(qsizetype position, qsizetype n) 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.
KIMAP2_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:21:18 by doxygen 1.10.0 written by Dimitri van Heesch, © 1997-2006

KDE's Doxygen guidelines are available online.