1 /*
2   SPDX-FileCopyrightText: 2010 BetterInbox <contact@betterinbox.com>
3   SPDX-FileContributor: Christophe Laveault <christophe@betterinbox.com>
4   SPDX-FileContributor: Gregory Schlomoff <gregory.schlomoff@gmail.com>
5 
6   SPDX-License-Identifier: LGPL-2.1-or-later
7 */
8 
9 #include "loginjob.h"
10 #include "job_p.h"
11 #include "ksmtp_debug.h"
12 #include "serverresponse_p.h"
13 #include "session_p.h"
14 
15 #include <KLocalizedString>
16 
17 #include <QJsonDocument>
18 #include <QJsonObject>
19 
20 extern "C" {
21 #include <sasl/sasl.h>
22 }
23 
24 namespace
25 {
26 static 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 }
35 
36 namespace KSmtp
37 {
38 class LoginJobPrivate : public JobPrivate
39 {
40 public:
LoginJobPrivate(LoginJob * job,Session * session,const QString & name)41     LoginJobPrivate(LoginJob *job, Session *session, const QString &name)
42         : JobPrivate(session, name)
43         , m_preferedAuthMode(LoginJob::Login)
44         , m_actualAuthMode(LoginJob::UnknownAuth)
45         , q(job)
46     {
47     }
48 
~LoginJobPrivate()49     ~LoginJobPrivate() override
50     {
51     }
52 
53     bool sasl_interact();
54     bool sasl_init();
55     bool sasl_challenge(const QByteArray &data);
56 
57     bool authenticate();
58     bool selectAuthentication();
59 
60     LoginJob::AuthMode authModeFromCommand(const QByteArray &mech) const;
61     QByteArray authCommand(LoginJob::AuthMode mode) const;
62 
63     QString m_userName;
64     QString m_password;
65     LoginJob::AuthMode m_preferedAuthMode;
66     LoginJob::AuthMode m_actualAuthMode;
67 
68     sasl_conn_t *m_saslConn = nullptr;
69     sasl_interact_t *m_saslClient = nullptr;
70 
71 private:
72     LoginJob *const q;
73 };
74 }
75 
76 using namespace KSmtp;
77 
LoginJob(Session * session)78 LoginJob::LoginJob(Session *session)
79     : Job(*new LoginJobPrivate(this, session, i18n("Login")))
80 {
81 }
82 
~LoginJob()83 LoginJob::~LoginJob()
84 {
85 }
86 
setUserName(const QString & userName)87 void LoginJob::setUserName(const QString &userName)
88 {
89     Q_D(LoginJob);
90     d->m_userName = userName;
91 }
92 
setPassword(const QString & password)93 void LoginJob::setPassword(const QString &password)
94 {
95     Q_D(LoginJob);
96     d->m_password = password;
97 }
98 
setPreferedAuthMode(AuthMode mode)99 void LoginJob::setPreferedAuthMode(AuthMode mode)
100 {
101     Q_D(LoginJob);
102 
103     if (mode == UnknownAuth) {
104         qCWarning(KSMTP_LOG) << "LoginJob: Cannot set preferred authentication mode to Unknown";
105         return;
106     }
107     d->m_preferedAuthMode = mode;
108 }
109 
usedAuthMode() const110 LoginJob::AuthMode LoginJob::usedAuthMode() const
111 {
112     return d_func()->m_actualAuthMode;
113 }
114 
doStart()115 void LoginJob::doStart()
116 {
117     Q_D(LoginJob);
118     if (d->sessionInternal()->negotiatedEncryption() == QSsl::UnknownProtocol && d->m_session->encryptionMode() != Session::Unencrypted) {
119         qFatal("LoginJob started despite session not being encrypted!");
120     }
121 
122     if (!d->authenticate()) {
123         emitResult();
124     }
125 }
126 
handleResponse(const ServerResponse & r)127 void LoginJob::handleResponse(const ServerResponse &r)
128 {
129     Q_D(LoginJob);
130 
131     // Handle server errors
132     handleErrors(r);
133 
134     // Send account data
135     if (r.isCode(334)) {
136         if (d->m_actualAuthMode == Plain) {
137             const QByteArray challengeResponse = '\0' + d->m_userName.toUtf8() + '\0' + d->m_password.toUtf8();
138             sendCommand(challengeResponse.toBase64());
139         } else {
140             if (!d->sasl_challenge(QByteArray::fromBase64(r.text()))) {
141                 emitResult();
142             }
143         }
144         return;
145     }
146 
147     // Final agreement
148     if (r.isCode(235)) {
149         d->sessionInternal()->setState(Session::Authenticated);
150         emitResult();
151     }
152 }
153 
selectAuthentication()154 bool LoginJobPrivate::selectAuthentication()
155 {
156     const QStringList availableModes = m_session->availableAuthModes();
157 
158     if (availableModes.contains(QString::fromLatin1(authCommand(m_preferedAuthMode)))) {
159         m_actualAuthMode = m_preferedAuthMode;
160     } else if (availableModes.contains(QString::fromLatin1(authCommand(LoginJob::Login)))) {
161         m_actualAuthMode = LoginJob::Login;
162     } else if (availableModes.contains(QString::fromLatin1(authCommand(LoginJob::Plain)))) {
163         m_actualAuthMode = LoginJob::Plain;
164     } else {
165         qCWarning(KSMTP_LOG) << "LoginJob: Couldn't choose an authentication method. Please retry with : " << availableModes;
166         q->setError(KJob::UserDefinedError);
167         q->setErrorText(i18n("Could not authenticate to the SMTP server because no matching authentication method has been found"));
168         return false;
169     }
170 
171     return true;
172 }
173 
sasl_init()174 bool LoginJobPrivate::sasl_init()
175 {
176     if (sasl_client_init(nullptr) != SASL_OK) {
177         qCWarning(KSMTP_LOG) << "Failed to initialize SASL";
178         return false;
179     }
180     return true;
181 }
182 
sasl_interact()183 bool LoginJobPrivate::sasl_interact()
184 {
185     sasl_interact_t *interact = m_saslClient;
186 
187     while (interact->id != SASL_CB_LIST_END) {
188         qCDebug(KSMTP_LOG) << "SASL_INTERACT Id" << interact->id;
189         switch (interact->id) {
190         case SASL_CB_AUTHNAME: {
191             // case SASL_CB_USER:
192             qCDebug(KSMTP_LOG) << "SASL_CB_[USER|AUTHNAME]: '" << m_userName << "'";
193             const auto username = m_userName.toUtf8();
194             interact->result = strdup(username.constData());
195             interact->len = username.size();
196             break;
197         }
198         case SASL_CB_PASS: {
199             qCDebug(KSMTP_LOG) << "SASL_CB_PASS: [hidden]";
200             const auto pass = m_password.toUtf8();
201             interact->result = strdup(pass.constData());
202             interact->len = pass.size();
203             break;
204         }
205         default:
206             interact->result = nullptr;
207             interact->len = 0;
208             break;
209         }
210         ++interact;
211     }
212 
213     return true;
214 }
215 
sasl_challenge(const QByteArray & challenge)216 bool LoginJobPrivate::sasl_challenge(const QByteArray &challenge)
217 {
218     int result = -1;
219     const char *out = nullptr;
220     uint outLen = 0;
221 
222     if (m_actualAuthMode == LoginJob::XOAuth2) {
223         QJsonDocument doc = QJsonDocument::fromJson(challenge);
224         if (!doc.isNull() && doc.isObject()) {
225             const auto obj = doc.object();
226             if (obj.value(QLatin1String("status")).toString() == QLatin1String("400")) {
227                 q->setError(LoginJob::TokenExpired);
228                 q->setErrorText(i18n("Token expired"));
229                 // https://developers.google.com/gmail/imap/xoauth2-protocol#error_response_2
230                 // "The client sends an empty response ("\r\n") to the challenge containing the error message."
231                 q->sendCommand("");
232                 return false;
233             }
234         }
235     }
236 
237     for (;;) {
238         result = sasl_client_step(m_saslConn, challenge.isEmpty() ? nullptr : challenge.constData(), challenge.size(), &m_saslClient, &out, &outLen);
239         if (result == SASL_INTERACT) {
240             if (!sasl_interact()) {
241                 q->setError(LoginJob::UserDefinedError);
242                 sasl_dispose(&m_saslConn);
243                 return false;
244             }
245         } else {
246             break;
247         }
248     }
249 
250     if (result != SASL_OK && result != SASL_CONTINUE) {
251         const QString saslError = QString::fromUtf8(sasl_errdetail(m_saslConn));
252         qCWarning(KSMTP_LOG) << "sasl_client_step failed: " << result << saslError;
253         q->setError(LoginJob::UserDefinedError);
254         q->setErrorText(saslError);
255         sasl_dispose(&m_saslConn);
256         return false;
257     }
258 
259     q->sendCommand(QByteArray::fromRawData(out, outLen).toBase64());
260 
261     return true;
262 }
263 
authenticate()264 bool LoginJobPrivate::authenticate()
265 {
266     if (!selectAuthentication()) {
267         return false;
268     }
269 
270     if (!sasl_init()) {
271         q->setError(LoginJob::UserDefinedError);
272         q->setErrorText(i18n("Login failed, cannot initialize the SASL library"));
273         return false;
274     }
275 
276     int result = sasl_client_new("smtp", m_session->hostName().toUtf8().constData(), nullptr, nullptr, callbacks, 0, &m_saslConn);
277     if (result != SASL_OK) {
278         const auto saslError = QString::fromUtf8(sasl_errdetail(m_saslConn));
279         q->setError(LoginJob::UserDefinedError);
280         q->setErrorText(saslError);
281         return false;
282     }
283 
284     uint outLen = 0;
285     const char *out = nullptr;
286     const char *actualMech = nullptr;
287     const auto authMode = authCommand(m_actualAuthMode);
288 
289     for (;;) {
290         qCDebug(KSMTP_LOG) << "Trying authmod" << authMode;
291         result = sasl_client_start(m_saslConn, authMode.constData(), &m_saslClient, &out, &outLen, &actualMech);
292         if (result == SASL_INTERACT) {
293             if (!sasl_interact()) {
294                 sasl_dispose(&m_saslConn);
295                 q->setError(LoginJob::UserDefinedError);
296                 return false;
297             }
298         } else {
299             break;
300         }
301     }
302 
303     m_actualAuthMode = authModeFromCommand(actualMech);
304 
305     if (result != SASL_CONTINUE && result != SASL_OK) {
306         const auto saslError = QString::fromUtf8(sasl_errdetail(m_saslConn));
307         qCWarning(KSMTP_LOG) << "sasl_client_start failed with:" << result << saslError;
308         q->setError(LoginJob::UserDefinedError);
309         q->setErrorText(saslError);
310         sasl_dispose(&m_saslConn);
311         return false;
312     }
313 
314     if (outLen == 0) {
315         q->sendCommand("AUTH " + authMode);
316     } else {
317         q->sendCommand("AUTH " + authMode + ' ' + QByteArray::fromRawData(out, outLen).toBase64());
318     }
319 
320     return true;
321 }
322 
authModeFromCommand(const QByteArray & mech) const323 LoginJob::AuthMode LoginJobPrivate::authModeFromCommand(const QByteArray &mech) const
324 {
325     if (qstrnicmp(mech.constData(), "PLAIN", 5) == 0) {
326         return LoginJob::Plain;
327     } else if (qstrnicmp(mech.constData(), "LOGIN", 5) == 0) {
328         return LoginJob::Login;
329     } else if (qstrnicmp(mech.constData(), "CRAM-MD5", 8) == 0) {
330         return LoginJob::CramMD5;
331     } else if (qstrnicmp(mech.constData(), "DIGEST-MD5", 10) == 0) {
332         return LoginJob::DigestMD5;
333     } else if (qstrnicmp(mech.constData(), "GSSAPI", 6) == 0) {
334         return LoginJob::GSSAPI;
335     } else if (qstrnicmp(mech.constData(), "NTLM", 4) == 0) {
336         return LoginJob::NTLM;
337     } else if (qstrnicmp(mech.constData(), "ANONYMOUS", 9) == 0) {
338         return LoginJob::Anonymous;
339     } else if (qstrnicmp(mech.constData(), "XOAUTH2", 7) == 0) {
340         return LoginJob::XOAuth2;
341     } else {
342         return LoginJob::UnknownAuth;
343     }
344 }
345 
authCommand(LoginJob::AuthMode mode) const346 QByteArray LoginJobPrivate::authCommand(LoginJob::AuthMode mode) const
347 {
348     switch (mode) {
349     case LoginJob::Plain:
350         return QByteArrayLiteral("PLAIN");
351     case LoginJob::Login:
352         return QByteArrayLiteral("LOGIN");
353     case LoginJob::CramMD5:
354         return QByteArrayLiteral("CRAM-MD5");
355     case LoginJob::DigestMD5:
356         return QByteArrayLiteral("DIGEST-MD5");
357     case LoginJob::GSSAPI:
358         return QByteArrayLiteral("GSSAPI");
359     case LoginJob::NTLM:
360         return QByteArrayLiteral("NTLM");
361     case LoginJob::Anonymous:
362         return QByteArrayLiteral("ANONYMOUS");
363     case LoginJob::XOAuth2:
364         return QByteArrayLiteral("XOAUTH2");
365     case LoginJob::UnknownAuth:
366         return ""; // Should not happen
367     }
368     return {};
369 }
370