KSMTP

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

KDE's Doxygen guidelines are available online.