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