aboutsummaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
authorKitsune Ral <Kitsune-Ral@users.sf.net>2020-03-20 08:32:10 +0100
committerGitHub <noreply@github.com>2020-03-20 08:32:10 +0100
commitc810c10da56fad0f5c07b2096cae73e8471d9f0e (patch)
treef473b9e7d157489200485f1f75a41de71778c3f1 /lib
parent792dc5dcc8e548030f9246f20a39ef6febee3910 (diff)
parentab3d0263b770e30de673c63740a5c26bcbf33e58 (diff)
downloadlibquotient-c810c10da56fad0f5c07b2096cae73e8471d9f0e.tar.gz
libquotient-c810c10da56fad0f5c07b2096cae73e8471d9f0e.zip
Merge pull request #389 from quotient-im/kitsune-login-flows
Support single sign-on
Diffstat (limited to 'lib')
-rw-r--r--lib/connection.cpp132
-rw-r--r--lib/connection.h70
-rw-r--r--lib/ssosession.cpp127
-rw-r--r--lib/ssosession.h44
4 files changed, 329 insertions, 44 deletions
diff --git a/lib/connection.cpp b/lib/connection.cpp
index 6ad24fba..0e6b1c84 100644
--- a/lib/connection.cpp
+++ b/lib/connection.cpp
@@ -30,7 +30,6 @@
#include "csapi/capabilities.h"
#include "csapi/joining.h"
#include "csapi/leaving.h"
-#include "csapi/login.h"
#include "csapi/logout.h"
#include "csapi/receipts.h"
#include "csapi/room_send.h"
@@ -111,6 +110,8 @@ public:
GetCapabilitiesJob* capabilitiesJob = nullptr;
GetCapabilitiesJob::Capabilities capabilities;
+ QVector<GetLoginFlowsJob::LoginFlow> loginFlows;
+
#ifdef Quotient_E2EE_ENABLED
QScopedPointer<EncryptionManager> encryptionManager;
#endif // Quotient_E2EE_ENABLED
@@ -124,8 +125,10 @@ public:
!= "json";
bool lazyLoading = false;
- void connectWithToken(const QString& userId, const QString& accessToken,
- const QString& deviceId);
+ template <typename... LoginArgTs>
+ void loginToServer(LoginArgTs&&... loginArgs);
+ void assumeIdentity(const QString& userId, const QString& accessToken,
+ const QString& deviceId);
void removeRoom(const QString& roomId);
template <typename EventT>
@@ -295,44 +298,50 @@ void Connection::resolveServer(const QString& mxid)
});
}
-void Connection::connectToServer(const QString& user, const QString& password,
+inline UserIdentifier makeUserIdentifier(const QString& id)
+{
+ return { QStringLiteral("m.id.user"), { { QStringLiteral("user"), id } } };
+}
+
+inline UserIdentifier make3rdPartyIdentifier(const QString& medium,
+ const QString& address)
+{
+ return { QStringLiteral("m.id.thirdparty"),
+ { { QStringLiteral("medium"), medium },
+ { QStringLiteral("address"), address } } };
+}
+
+void Connection::connectToServer(const QString& userId, const QString& password,
const QString& initialDeviceName,
const QString& deviceId)
{
- checkAndConnect(user, [=] {
- doConnectToServer(user, password, initialDeviceName, deviceId);
+ checkAndConnect(userId, [=] {
+ d->loginToServer(LoginFlows::Password.type, makeUserIdentifier(userId),
+ password, /*token*/ "", deviceId, initialDeviceName);
});
}
-void Connection::doConnectToServer(const QString& user, const QString& password,
- const QString& initialDeviceName,
- const QString& deviceId)
+
+SsoSession* Connection::prepareForSso(const QString& initialDeviceName,
+ const QString& deviceId)
{
- auto loginJob =
- callApi<LoginJob>(QStringLiteral("m.login.password"),
- UserIdentifier { QStringLiteral("m.id.user"),
- { { QStringLiteral("user"), user } } },
- password, /*token*/ "", deviceId, initialDeviceName);
- connect(loginJob, &BaseJob::success, this, [this, loginJob] {
- d->connectWithToken(loginJob->userId(), loginJob->accessToken(),
- loginJob->deviceId());
-#ifndef Quotient_E2EE_ENABLED
- qCWarning(E2EE) << "End-to-end encryption (E2EE) support is turned off.";
-#else // Quotient_E2EE_ENABLED
- d->encryptionManager->uploadIdentityKeys(this);
- d->encryptionManager->uploadOneTimeKeys(this);
-#endif // Quotient_E2EE_ENABLED
- });
- connect(loginJob, &BaseJob::failure, this, [this, loginJob] {
- emit loginError(loginJob->errorString(), loginJob->rawDataSample());
- });
+ return new SsoSession(this, initialDeviceName, deviceId);
}
-void Connection::connectWithToken(const QString& userId,
- const QString& accessToken,
- const QString& deviceId)
+void Connection::loginWithToken(const QByteArray& loginToken,
+ const QString& initialDeviceName,
+ const QString& deviceId)
+{
+ d->loginToServer(LoginFlows::Token.type,
+ makeUserIdentifier(/*user is encoded in loginToken*/ {}),
+ /*password*/ "", loginToken, deviceId, initialDeviceName);
+}
+
+void Connection::assumeIdentity(const QString& userId,
+ const QString& accessToken,
+ const QString& deviceId)
{
checkAndConnect(userId,
- [=] { d->connectWithToken(userId, accessToken, deviceId); });
+ [=] { d->assumeIdentity(userId, accessToken, deviceId); });
}
void Connection::reloadCapabilities()
@@ -365,9 +374,29 @@ bool Connection::loadingCapabilities() const
return !d->capabilities.roomVersions;
}
-void Connection::Private::connectWithToken(const QString& userId,
- const QString& accessToken,
- const QString& deviceId)
+template <typename... LoginArgTs>
+void Connection::Private::loginToServer(LoginArgTs&&... loginArgs)
+{
+ auto loginJob =
+ q->callApi<LoginJob>(std::forward<LoginArgTs>(loginArgs)...);
+ connect(loginJob, &BaseJob::success, q, [this, loginJob] {
+ assumeIdentity(loginJob->userId(), loginJob->accessToken(),
+ loginJob->deviceId());
+#ifndef Quotient_E2EE_ENABLED
+ qCWarning(E2EE) << "End-to-end encryption (E2EE) support is turned off.";
+#else // Quotient_E2EE_ENABLED
+ encryptionManager->uploadIdentityKeys(this);
+ encryptionManager->uploadOneTimeKeys(this);
+#endif // Quotient_E2EE_ENABLED
+ });
+ connect(loginJob, &BaseJob::failure, q, [this, loginJob] {
+ emit q->loginError(loginJob->errorString(), loginJob->rawDataSample());
+ });
+}
+
+void Connection::Private::assumeIdentity(const QString& userId,
+ const QString& accessToken,
+ const QString& deviceId)
{
data->setUserId(userId);
q->user(); // Creates a User object for the local user
@@ -376,10 +405,10 @@ void Connection::Private::connectWithToken(const QString& userId,
q->setObjectName(userId % '/' % deviceId);
qCDebug(MAIN) << "Using server" << data->baseUrl().toDisplayString()
<< "by user" << userId << "from device" << deviceId;
- AccountSettings accountSettings(userId);
#ifndef Quotient_E2EE_ENABLED
qCWarning(E2EE) << "End-to-end encryption (E2EE) support is turned off.";
#else // Quotient_E2EE_ENABLED
+ AccountSettings accountSettings(userId);
encryptionManager.reset(
new EncryptionManager(accountSettings.encryptionAccountPickle()));
if (accountSettings.encryptionAccountPickle().isEmpty()) {
@@ -1004,6 +1033,21 @@ QUrl Connection::homeserver() const { return d->data->baseUrl(); }
QString Connection::domain() const { return userId().section(':', 1); }
+QVector<GetLoginFlowsJob::LoginFlow> Connection::loginFlows() const
+{
+ return d->loginFlows;
+}
+
+bool Connection::supportsPasswordAuth() const
+{
+ return d->loginFlows.contains(LoginFlows::Password);
+}
+
+bool Connection::supportsSso() const
+{
+ return d->loginFlows.contains(LoginFlows::SSO);
+}
+
Room* Connection::room(const QString& roomId, JoinStates states) const
{
Room* room = d->roomMap.value({ roomId, false }, nullptr);
@@ -1400,11 +1444,21 @@ QByteArray Connection::generateTxnId() const
void Connection::setHomeserver(const QUrl& url)
{
- if (homeserver() == url)
- return;
+ if (homeserver() != url) {
+ d->data->setBaseUrl(url);
+ d->loginFlows.clear();
+ emit homeserverChanged(homeserver());
+ }
- d->data->setBaseUrl(url);
- emit homeserverChanged(homeserver());
+ // Whenever a homeserver is updated, retrieve available login flows from it
+ auto* j = callApi<GetLoginFlowsJob>(BackgroundRequest);
+ connect(j, &BaseJob::finished, this, [this, j] {
+ if (j->status().good())
+ d->loginFlows = j->flows();
+ else
+ d->loginFlows.clear();
+ emit loginFlowsChanged();
+ });
}
void Connection::saveRoomState(Room* r) const
diff --git a/lib/connection.h b/lib/connection.h
index b57f0ca8..9b222ca8 100644
--- a/lib/connection.h
+++ b/lib/connection.h
@@ -18,9 +18,11 @@
#pragma once
+#include "ssosession.h"
#include "joinstate.h"
#include "qt_connection_util.h"
+#include "csapi/login.h"
#include "csapi/create_room.h"
#include "events/accountdataevents.h"
@@ -36,6 +38,8 @@ namespace QtOlm {
class Account;
}
+Q_DECLARE_METATYPE(Quotient::GetLoginFlowsJob::LoginFlow)
+
namespace Quotient {
Q_NAMESPACE
@@ -58,6 +62,28 @@ class SendToDeviceJob;
class SendMessageJob;
class LeaveRoomJob;
+// To simplify comparisons of LoginFlows
+
+inline bool operator==(const GetLoginFlowsJob::LoginFlow& lhs,
+ const GetLoginFlowsJob::LoginFlow& rhs)
+{
+ return lhs.type == rhs.type;
+}
+
+inline bool operator!=(const GetLoginFlowsJob::LoginFlow& lhs,
+ const GetLoginFlowsJob::LoginFlow& rhs)
+{
+ return !(lhs == rhs);
+}
+
+/// Predefined login flows
+struct LoginFlows {
+ using LoginFlow = GetLoginFlowsJob::LoginFlow;
+ static inline const LoginFlow Password { "m.login.password" };
+ static inline const LoginFlow SSO { "m.login.sso" };
+ static inline const LoginFlow Token { "m.login.token" };
+};
+
class Connection;
using room_factory_t =
@@ -117,6 +143,9 @@ class Connection : public QObject {
Q_PROPERTY(QUrl homeserver READ homeserver WRITE setHomeserver NOTIFY
homeserverChanged)
Q_PROPERTY(QString domain READ domain NOTIFY homeserverChanged)
+ Q_PROPERTY(QVector<Quotient::GetLoginFlowsJob::LoginFlow> loginFlows READ loginFlows NOTIFY loginFlowsChanged)
+ Q_PROPERTY(bool supportsSso READ supportsSso NOTIFY loginFlowsChanged)
+ Q_PROPERTY(bool supportsPasswordAuth READ supportsPasswordAuth NOTIFY loginFlowsChanged)
Q_PROPERTY(bool cacheState READ cacheState WRITE setCacheState NOTIFY
cacheStateChanged)
Q_PROPERTY(bool lazyLoading READ lazyLoading WRITE setLazyLoading NOTIFY
@@ -281,6 +310,12 @@ public:
QUrl homeserver() const;
/** Get the domain name used for ids/aliases on the server */
QString domain() const;
+ /** Get the list of supported login flows */
+ QVector<GetLoginFlowsJob::LoginFlow> loginFlows() const;
+ /** Check whether the current homeserver supports password auth */
+ bool supportsPasswordAuth() const;
+ /** Check whether the current homeserver supports SSO */
+ bool supportsSso() const;
/** Find a room by its id and a mask of applicable states */
Q_INVOKABLE Quotient::Room*
room(const QString& roomId,
@@ -421,6 +456,21 @@ public:
std::forward<JobArgTs>(jobArgs)...);
}
+ /*! Get a request URL for a job with specified type and arguments
+ *
+ * This calls JobT::makeRequestUrl() prepending the connection's homeserver
+ * to the list of arguments.
+ */
+ template <typename JobT, typename... JobArgTs>
+ QUrl getUrlForApi(JobArgTs&&... jobArgs) const
+ {
+ return JobT::makeRequestUrl(homeserver(),
+ std::forward<JobArgTs>(jobArgs)...);
+ }
+
+ Q_INVOKABLE SsoSession* prepareForSso(const QString& initialDeviceName,
+ const QString& deviceId = {});
+
/** Generate a new transaction id. Transaction id's are unique within
* a single Connection object
*/
@@ -459,11 +509,23 @@ public slots:
/** Determine and set the homeserver from MXID */
void resolveServer(const QString& mxid);
- void connectToServer(const QString& user, const QString& password,
+ void connectToServer(const QString& userId, const QString& password,
const QString& initialDeviceName,
const QString& deviceId = {});
+ void loginWithToken(const QByteArray& loginToken,
+ const QString& initialDeviceName,
+ const QString& deviceId = {});
+ void assumeIdentity(const QString& userId, const QString& accessToken,
+ const QString& deviceId);
+ /*! @deprecated
+ * Use assumeIdentity() if you have an access token or
+ * loginWithToken() if you have a login token.
+ */
void connectWithToken(const QString& userId, const QString& accessToken,
- const QString& deviceId);
+ const QString& deviceId)
+ {
+ assumeIdentity(userId, accessToken, deviceId);
+ }
/// Explicitly request capabilities from the server
void reloadCapabilities();
@@ -609,6 +671,7 @@ signals:
void resolveError(QString error);
void homeserverChanged(QUrl baseUrl);
+ void loginFlowsChanged();
void capabilitiesLoaded();
void connected();
@@ -809,9 +872,6 @@ private:
* @param connectFn - a function to execute once the HS URL is good
*/
void checkAndConnect(const QString& userId, std::function<void()> connectFn);
- void doConnectToServer(const QString& user, const QString& password,
- const QString& initialDeviceName,
- const QString& deviceId = {});
static room_factory_t _roomFactory;
static user_factory_t _userFactory;
diff --git a/lib/ssosession.cpp b/lib/ssosession.cpp
new file mode 100644
index 00000000..0f8f96e1
--- /dev/null
+++ b/lib/ssosession.cpp
@@ -0,0 +1,127 @@
+#include "ssosession.h"
+
+#include "connection.h"
+#include "csapi/sso_login_redirect.h"
+
+#include <QtNetwork/QTcpServer>
+#include <QtNetwork/QTcpSocket>
+#include <QtCore/QCoreApplication>
+#include <QtCore/QStringBuilder>
+
+using namespace Quotient;
+
+struct SsoSession::Private {
+ Private(SsoSession* q, const QString& initialDeviceName = {},
+ const QString& deviceId = {}, Connection* connection = nullptr)
+ : initialDeviceName(initialDeviceName)
+ , deviceId(deviceId)
+ , connection(connection)
+ {
+ auto* server = new QTcpServer(q);
+ server->listen();
+ // The "/returnToApplication" part is just a hint for the end-user,
+ // the callback will work without it equally well.
+ callbackUrl = QStringLiteral("http://localhost:%1/returnToApplication")
+ .arg(server->serverPort());
+ ssoUrl = connection->getUrlForApi<RedirectToSSOJob>(callbackUrl);
+
+ QObject::connect(server, &QTcpServer::newConnection, q, [this, server] {
+ qCDebug(MAIN) << "SSO callback initiated";
+ socket = server->nextPendingConnection();
+ server->close();
+ QObject::connect(socket, &QTcpSocket::readyRead, socket, [this] {
+ requestData.append(socket->readAll());
+ if (!socket->atEnd() && !requestData.endsWith("\r\n\r\n")) {
+ qDebug(MAIN) << "Incomplete request, waiting for more data";
+ return;
+ }
+ processCallback();
+ });
+ QObject::connect(socket, &QTcpSocket::disconnected, socket,
+ [this] { socket->deleteLater(); });
+ });
+ }
+ void processCallback();
+ void sendHttpResponse(const QByteArray& code, const QByteArray& msg);
+ void onError(const QByteArray& code, const QString& errorMsg);
+
+ QString initialDeviceName;
+ QString deviceId;
+ Connection* connection;
+ QString callbackUrl {};
+ QUrl ssoUrl {};
+ QTcpSocket* socket = nullptr;
+ QByteArray requestData {};
+};
+
+SsoSession::SsoSession(Connection* connection, const QString& initialDeviceName,
+ const QString& deviceId)
+ : QObject(connection)
+ , d(std::make_unique<Private>(this, initialDeviceName, deviceId, connection))
+{
+ qCDebug(MAIN) << "SSO session constructed";
+}
+
+SsoSession::~SsoSession()
+{
+ qCDebug(MAIN) << "SSO session deconstructed";
+}
+
+QUrl SsoSession::ssoUrl() const { return d->ssoUrl; }
+
+QUrl SsoSession::callbackUrl() const { return d->callbackUrl; }
+
+void SsoSession::Private::processCallback()
+{
+ // https://matrix.org/docs/guides/sso-for-client-developers
+ // Inspired by Clementine's src/internet/core/localredirectserver.cpp
+ // (see at https://github.com/clementine-player/Clementine/)
+ const auto& requestParts = requestData.split(' ');
+ if (requestParts.size() < 2 || requestParts[1].isEmpty()) {
+ onError("400 Bad Request", tr("No login token in SSO callback"));
+ return;
+ }
+ const auto& QueryItemName = QStringLiteral("loginToken");
+ QUrlQuery query { QUrl(requestParts[1]).query() };
+ if (!query.hasQueryItem(QueryItemName)) {
+ onError("400 Bad Request", tr("Malformed single sign-on callback"));
+ }
+ qCDebug(MAIN) << "Found the token in SSO callback, logging in";
+ connection->loginWithToken(query.queryItemValue(QueryItemName).toLatin1(),
+ initialDeviceName, deviceId);
+ connect(connection, &Connection::connected, socket, [this] {
+ const QString msg =
+ "The application '" % QCoreApplication::applicationName()
+ % "' has successfully logged in as a user " % connection->userId()
+ % " with device id " % connection->deviceId()
+ % ". This window can be closed. Thank you.\r\n";
+ sendHttpResponse("200 OK", msg.toHtmlEscaped().toUtf8());
+ socket->disconnectFromHost();
+ });
+ connect(connection, &Connection::loginError, socket, [this] {
+ onError("401 Unauthorised", tr("Login failed"));
+ socket->disconnectFromHost();
+ });
+}
+
+void SsoSession::Private::sendHttpResponse(const QByteArray& code,
+ const QByteArray& msg)
+{
+ socket->write("HTTP/1.0 ");
+ socket->write(code);
+ socket->write("\r\n"
+ "Content-type: text/html;charset=UTF-8\r\n"
+ "\r\n\r\n");
+ socket->write(msg);
+ socket->write("\r\n");
+}
+
+void SsoSession::Private::onError(const QByteArray& code,
+ const QString& errorMsg)
+{
+ qCWarning(MAIN).nospace() << errorMsg;
+ sendHttpResponse(code, "<h3>" + errorMsg.toUtf8() + "</h3>");
+ // [kitsune] Yeah, I know, dirty. Maybe the "right" way would be to have
+ // an intermediate signal but that seems just a fight for purity.
+ emit connection->loginError(errorMsg, requestData);
+}
diff --git a/lib/ssosession.h b/lib/ssosession.h
new file mode 100644
index 00000000..5845cd4d
--- /dev/null
+++ b/lib/ssosession.h
@@ -0,0 +1,44 @@
+#pragma once
+
+#include <QtCore/QUrl>
+#include <QtCore/QObject>
+
+#include <memory>
+
+class QTcpServer;
+class QTcpSocket;
+
+namespace Quotient {
+class Connection;
+
+/*! Single sign-on (SSO) session encapsulation
+ *
+ * This class is responsible for setting up of a new SSO session, providing
+ * a URL to be opened (usually, in a web browser) and handling the callback
+ * response after completing the single sign-on, all the way to actually
+ * logging the user in. It does NOT open and render the SSO URL, it only does
+ * the necessary backstage work.
+ *
+ * Clients only need to open the URL; the rest is done for them.
+ * Client code can look something like:
+ * \code
+ * QDesktopServices::openUrl(
+ * connection->prepareForSso(initialDeviceName)->ssoUrl());
+ * \endcode
+ */
+class SsoSession : public QObject {
+ Q_OBJECT
+ Q_PROPERTY(QUrl ssoUrl READ ssoUrl CONSTANT)
+ Q_PROPERTY(QUrl callbackUrl READ callbackUrl CONSTANT)
+public:
+ SsoSession(Connection* connection, const QString& initialDeviceName,
+ const QString& deviceId = {});
+ ~SsoSession() override;
+ QUrl ssoUrl() const;
+ QUrl callbackUrl() const;
+
+private:
+ class Private;
+ std::unique_ptr<Private> d;
+};
+} // namespace Quotient