diff --git a/TODO.md b/TODO.md index a66bd10f..4b586ccd 100644 --- a/TODO.md +++ b/TODO.md @@ -1,7 +1,5 @@ # TODO -- fallback page -- SSO page - sever list - cursor shape in HBox/HTabbedBox pages over fields - login with account already added → infinite spinner in room list @@ -208,10 +206,8 @@ - `m.id.thirdparty` - `m.id.phone` - `m.login.recaptcha` (need browser, just use fallback?) - - `m.login.oauth2` - `m.login.email.identity` - `m.login.msisdn` (phone) - - `m.login.sso` + `m.login.token` - `m.login.dummy` - Web page fallback diff --git a/src/backend/backend.py b/src/backend/backend.py index d37f29c4..f9478bc7 100644 --- a/src/backend/backend.py +++ b/src/backend/backend.py @@ -22,6 +22,7 @@ from .models.items import Account, Event from .models.model import Model from .models.model_store import ModelStore from .presence import Presence +from .sso_server import SSOServer from .user_files import Accounts, History, Theme, UISettings, UIState # Logging configuration @@ -103,6 +104,9 @@ class Backend: self.clients: Dict[str, MatrixClient] = {} + self._sso_server: Optional[SSOServer] = None + self._sso_server_task: Optional[asyncio.Future] = None + self.profile_cache: Dict[str, nio.ProfileGetResponse] = {} self.get_profile_locks: DefaultDict[str, asyncio.Lock] = \ DefaultDict(asyncio.Lock) # {user_id: lock} @@ -153,21 +157,57 @@ class Backend: await client.close() - async def login_client(self, - user: str, - password: str, - device_id: Optional[str] = None, - homeserver: str = "https://matrix.org", - order: Optional[int] = None, + async def password_auth( + self, user: str, password: str, homeserver: str, ) -> str: - """Create and register a `MatrixClient`, login and return a user ID.""" + """Create & register a `MatrixClient`, login using the password + and return the user ID we get. + """ - client = MatrixClient( - self, user=user, homeserver=homeserver, device_id=device_id, - ) + client = MatrixClient(self, user=user, homeserver=homeserver) + return await self._do_login(client, password=password) + + + async def start_sso_auth(self, homeserver: str) -> str: + """Start SSO server and return URL to open in the user's browser. + + See the `sso_server.SSOServer` class documentation. + Once the returned URL has been opened in the user's browser + (done from QML), `MatrixClient.continue_sso_auth()` should be called. + """ + + server = SSOServer(homeserver) + self._sso_server = server + self._sso_server_task = asyncio.ensure_future(server.wait_for_token()) + return server.url_to_open + + + async def continue_sso_auth(self) -> str: + """Wait for the started SSO server to get a token, then login. + + `MatrixClient.start_sso_auth()` must be called first. + Creates and register a `MatrixClient` for logging in. + Returns the user ID we get from logging in. + """ + + if not self._sso_server or not self._sso_server_task: + raise RuntimeError("Must call Backend.start_sso_auth() first") + + await self._sso_server_task + homeserver = self._sso_server.for_homeserver + token = self._sso_server_task.result() + self._sso_server_task = None + self._sso_server = None + + client = MatrixClient(self, homeserver=homeserver) + return await self._do_login(client, token=token) + + + async def _do_login(self, client: MatrixClient, **login_kwargs) -> str: + """Create and register the `MatrixClient`, login and return user ID.""" try: - await client.login(password, order=order) + await client.login(**login_kwargs) except MatrixError: await client.close() raise @@ -178,7 +218,6 @@ class Backend: return client.user_id self.clients[client.user_id] = client - return client.user_id @@ -187,7 +226,7 @@ class Backend: user_id: str, token: str, device_id: str, - homeserver: str = "https://matrix.org", + homeserver: str, state: str = "online", status_msg: str = "", ) -> None: diff --git a/src/backend/matrix_client.py b/src/backend/matrix_client.py index 11d7efbc..b27d9974 100644 --- a/src/backend/matrix_client.py +++ b/src/backend/matrix_client.py @@ -191,6 +191,7 @@ class MatrixClient(nio.AsyncClient): self.upload_tasks: Dict[UUID, asyncio.Task] = {} self.send_message_tasks: Dict[UUID, asyncio.Task] = {} + self._presence: str = "" self.first_sync_done: asyncio.Event = asyncio.Event() self.first_sync_date: Optional[datetime] = None self.last_sync_error: Optional[Exception] = None @@ -212,6 +213,33 @@ class MatrixClient(nio.AsyncClient): ) + @property + def healthy(self) -> bool: + """Return whether we're syncing and last sync was successful.""" + + task = self.sync_task + + if not task or not self.first_sync_date or self.last_sync_error: + return False + + return not task.done() + + + @property + def default_device_name(self) -> str: + """Device name to set at login if the user hasn't set a custom one.""" + + os_name = platform.system() + + if not os_name: # unknown OS + return __display_name__ + + # On Linux, the kernel version is returned, so for a one-time-set + # device name it would quickly be outdated. + os_ver = platform.release() if os_name == "Windows" else "" + return f"{__display_name__} on {os_name} {os_ver}".rstrip() + + async def _send(self, *args, **kwargs) -> nio.Response: """Raise a `MatrixError` subclass for any `nio.ErrorResponse`. @@ -228,54 +256,38 @@ class MatrixClient(nio.AsyncClient): return response - @staticmethod - def default_device_name() -> str: - """Device name to set at login if the user hasn't set a custom one.""" - - os_name = platform.system() - - if not os_name: # unknown OS - return __display_name__ - - # On Linux, the kernel version is returned, so for a one-time-set - # device name it would quickly be outdated. - os_ver = platform.release() if os_name == "Windows" else "" - return f"{__display_name__} on {os_name} {os_ver}".rstrip() - - async def login( - self, - password: str, - device_name: str = "", - order: Optional[int] = None, + self, password: Optional[str] = None, token: Optional[str] = None, ) -> None: - """Login to the server using the account's password.""" + """Login to server using `m.login.password` or `m.login.token` flows. - await super().login( - password, device_name or self.default_device_name(), - ) + Login can be done with the account's password (if the server supports + this flow) OR a token obtainable through various means. - saved = await self.backend.saved_accounts.read() + One of the way to obtain a token is to follow the `m.login.sso` flow + first, see `Backend.start_sso_auth()` & `Backend.continue_sso_auth()`. + """ - if order is None and not saved.values(): - order = 0 - elif order is None: + await super().login(password, self.default_device_name, token) + + order = 0 + saved_accounts = await self.backend.saved_accounts.read() + + if saved_accounts: order = max( account.get("order", i) - for i, account in enumerate(saved.values()) + for i, account in enumerate(saved_accounts.values()) ) + 1 - # Get or create account model - # We need to create account model in here, because _start() needs it - account = self.models["accounts"].setdefault( + # We need to create account model item here, because _start() needs it + item = self.models["accounts"].setdefault( self.user_id, Account(self.user_id, order), ) - # TODO: set presence on login - self._presence: str = "online" - account.presence = Presence.State.online - account.connecting = True - self.start_task = asyncio.ensure_future(self._start()) + # TODO: be abke to set presence before logging in + item.set_fields(presence=Presence.State.online, connecting=True) + self._presence = "online" + self.start_task = asyncio.ensure_future(self._start()) async def resume( @@ -299,7 +311,7 @@ class MatrixClient(nio.AsyncClient): if state != "offline": account.connecting = True - self.start_task = asyncio.ensure_future(self._start()) + self.start_task = asyncio.ensure_future(self._start()) async def logout(self) -> None: @@ -326,17 +338,6 @@ class MatrixClient(nio.AsyncClient): await self.close() - @property - def healthy(self) -> bool: - """Return whether we're syncing and last sync was successful.""" - - task = self.sync_task - - if not task or not self.first_sync_date or self.last_sync_error: - return False - - return not task.done() - async def _start(self) -> None: """Fetch our user profile, server config and enter the sync loop.""" diff --git a/src/backend/sso_server.py b/src/backend/sso_server.py new file mode 100644 index 00000000..477ef75c --- /dev/null +++ b/src/backend/sso_server.py @@ -0,0 +1,99 @@ +# SPDX-License-Identifier: LGPL-3.0-or-later + +import asyncio +from http.server import BaseHTTPRequestHandler, HTTPServer +from urllib.parse import parse_qs, quote, urlparse +from . import __display_name__ + +_SUCCESS_HTML_PAGE = """ + + + """ + __display_name__ + """ + + + + +
+""" + + +class _SSORequestHandler(BaseHTTPRequestHandler): + def do_GET(self) -> None: + parameters = parse_qs(urlparse(self.path).query) + + if "loginToken" in parameters: + self.server._token = parameters["loginToken"][0] # type: ignore + self.send_response(200) + self.send_header("Content-type", "text/html") + self.end_headers() + self.wfile.write(_SUCCESS_HTML_PAGE.encode()) + else: + self.send_error(400, "missing loginToken parameter") + + self.close_connection = True + + +class SSOServer(HTTPServer): + """Local HTTP server to retrieve a SSO login token. + + Call `SSOServer.wait_for_token()` in a background task to start waiting + for a SSO login token from the Matrix homeserver. + + Once the task is running, the user must open `SSOServer.url_to_open` in + their browser, where they will be able to complete the login process. + Once they are done, the homeserver will call us back with a login token + and the `SSOServer.wait_for_token()` task will return. + """ + + def __init__(self, for_homeserver: str) -> None: + self.for_homeserver: str = for_homeserver + self._token: str = "" + + # Pick the first available port + super().__init__(("127.0.0.1", 0), _SSORequestHandler) + + + @property + def url_to_open(self) -> str: + """URL for the user to open in their browser, to do the SSO process.""" + + return "%s/_matrix/client/r0/login/sso/redirect?redirectUrl=%s" % ( + self.for_homeserver, + quote(f"http://{self.server_address[0]}:{self.server_port}/"), + ) + + + async def wait_for_token(self) -> str: + """Wait until the homeserver gives us a login token and return it.""" + + loop = asyncio.get_event_loop() + + while not self._token: + await loop.run_in_executor(None, self.handle_request) + + return self._token diff --git a/src/gui/Pages/AddAccount/AddAccount.qml b/src/gui/Pages/AddAccount/AddAccount.qml index 342398d4..4d896e89 100644 --- a/src/gui/Pages/AddAccount/AddAccount.qml +++ b/src/gui/Pages/AddAccount/AddAccount.qml @@ -7,8 +7,12 @@ import "../../Base" HSwipeView { id: swipeView clip: true - interactive: currentIndex !== 0 || signIn.serverUrl - onCurrentItemChanged: if (currentIndex === 0) serverBrowser.takeFocus() + interactive: serverBrowser.acceptedUrl + onCurrentItemChanged: + currentIndex === 0 ? + serverBrowser.takeFocus() : + signInLoader.takeFocus() + Component.onCompleted: serverBrowser.takeFocus() HPage { @@ -26,6 +30,8 @@ HSwipeView { HPage { id: tabPage + enabled: swipeView.currentItem === this + HTabbedBox { anchors.centerIn: parent width: Math.min(implicitWidth, tabPage.availableWidth) @@ -37,11 +43,32 @@ HSwipeView { HTabButton { text: qsTr("Reset") } } - SignIn { - id: signIn - serverUrl: serverBrowser.acceptedUrl - displayUrl: serverBrowser.acceptedUserUrl - onExitRequested: swipeView.currentIndex = 0 + HLoader { + id: signInLoader + + readonly property Component signInPassword: SignInPassword { + serverUrl: serverBrowser.acceptedUrl + displayUrl: serverBrowser.acceptedUserUrl + onExitRequested: swipeView.currentIndex = 0 + } + + readonly property Component signInSso: SignInSso { + serverUrl: serverBrowser.acceptedUrl + displayUrl: serverBrowser.acceptedUserUrl + onExitRequested: swipeView.currentIndex = 0 + } + + function takeFocus() { if (item) item.takeFocus() } + + sourceComponent: + serverBrowser.loginFlows.includes("m.login.password") ? + signInPassword : + + serverBrowser.loginFlows.includes("m.login.sso") && + serverBrowser.loginFlows.includes("m.login.token") ? + signInSso : + + null } Register {} diff --git a/src/gui/Pages/AddAccount/ServerBrowser.qml b/src/gui/Pages/AddAccount/ServerBrowser.qml index 99d13af1..50bbd633 100644 --- a/src/gui/Pages/AddAccount/ServerBrowser.qml +++ b/src/gui/Pages/AddAccount/ServerBrowser.qml @@ -11,7 +11,7 @@ HBox { property string acceptedUserUrl: "" property string acceptedUrl: "" - property var loginFlows: ["m.login.password"] + property var loginFlows: [] property string saveName: "serverBrowser" property var saveProperties: ["acceptedUserUrl"] @@ -30,8 +30,20 @@ HBox { connectFuture = py.callCoro("server_info", args, ([url, flows]) => { connectTimeout.stop() errorMessage.text = "" + connectFuture = null + + if (! ( + flows.includes("m.login.password") || + ( + flows.includes("m.login.sso") && + flows.includes("m.login.token") + ) + )) { + errorMessage.text = + qsTr("No supported sign-in method for this homeserver.") + return + } - connectFuture = null acceptedUrl = url acceptedUserUrl = args[0] loginFlows = flows diff --git a/src/gui/Pages/AddAccount/SignIn.qml b/src/gui/Pages/AddAccount/SignInBase.qml similarity index 51% rename from src/gui/Pages/AddAccount/SignIn.qml rename to src/gui/Pages/AddAccount/SignInBase.qml index 1fface65..df01b72e 100644 --- a/src/gui/Pages/AddAccount/SignIn.qml +++ b/src/gui/Pages/AddAccount/SignInBase.qml @@ -12,66 +12,42 @@ HFlickableColumnPage { property string serverUrl property string displayUrl: serverUrl - property var loginFuture: null - signal exitRequested() + property var loginFuture: null readonly property int security: serverUrl.startsWith("https://") ? - SignIn.Security.Secure : + SignInBase.Security.Secure : ["//localhost", "//127.0.0.1", "//:1"].includes( serverUrl.split(":")[1], ) ? - SignIn.Security.LocalHttp : + SignInBase.Security.LocalHttp : - SignIn.Security.Insecure + SignInBase.Security.Insecure - function takeFocus() { idField.item.forceActiveFocus() } + default property alias innerData: inner.data + readonly property alias rememberAccount: rememberAccount + readonly property alias errorMessage: errorMessage + readonly property alias applyButton: applyButton - function signIn() { - if (page.loginFuture) page.loginFuture.cancel() + signal exitRequested() + function finishSignIn(receivedUserId) { errorMessage.text = "" + page.loginFuture = null - const args = [ - idField.item.text.trim(), passwordField.item.text, - undefined, page.serverUrl, - ] + py.callCoro( + rememberAccount.checked ? + "saved_accounts.add": + "saved_accounts.delete", - page.loginFuture = py.callCoro("login_client", args, userId => { - errorMessage.text = "" - page.loginFuture = null + [receivedUserId] + ) - print(rememberAccount.checked) - py.callCoro( - rememberAccount.checked ? - "saved_accounts.add": "saved_accounts.delete", - - [userId] - ) - - pageLoader.showPage( - "AccountSettings/AccountSettings", {userId} - ) - - }, (type, args, error, traceback, uuid) => { - page.loginFuture = null - - let txt = qsTr( - "Invalid request, login type or unknown error: %1", - ).arg(type) - - type === "MatrixForbidden" ? - txt = qsTr("Invalid username or password") : - - type === "MatrixUserDeactivated" ? - txt = qsTr("This account was deactivated") : - - utils.showError(type, traceback, uuid) - - errorMessage.text = txt - }) + pageLoader.showPage( + "AccountSettings/AccountSettings", {userId: receivedUserId} + ) } function cancel() { @@ -91,12 +67,11 @@ HFlickableColumnPage { footer: AutoDirectionLayout { ApplyButton { id: applyButton - enabled: idField.item.text.trim() && passwordField.item.text + text: qsTr("Sign in") icon.name: "sign-in" loading: page.loginFuture !== null disableWhileLoading: false - onClicked: page.signIn() } CancelButton { @@ -104,27 +79,28 @@ HFlickableColumnPage { } } - onKeyboardAccept: if (applyButton.enabled) page.signIn() + onKeyboardAccept: if (applyButton.enabled) applyButton.clicked() onKeyboardCancel: page.cancel() + Component.onDestruction: if (loginFuture) loginFuture.cancel() HButton { icon.name: "sign-in-" + ( - page.security === SignIn.Security.Insecure ? "insecure" : - page.security === SignIn.Security.LocalHttp ? "local-http" : + page.security === SignInBase.Security.Insecure ? "insecure" : + page.security === SignInBase.Security.LocalHttp ? "local-http" : "secure" ) icon.color: - page.security === SignIn.Security.Insecure ? + page.security === SignInBase.Security.Insecure ? theme.colors.negativeBackground : - page.security === SignIn.Security.LocalHttp ? + page.security === SignInBase.Security.LocalHttp ? theme.colors.middleBackground : theme.colors.positiveBackground text: - page.security === SignIn.Security.Insecure ? + page.security === SignInBase.Security.Insecure ? page.serverUrl : page.displayUrl.replace(/^(https?:\/\/)?(www\.)?/, "") @@ -134,27 +110,9 @@ HFlickableColumnPage { Layout.maximumWidth: parent.width } - HLabeledItem { - id: idField - label.text: qsTr("Username:") - - Layout.fillWidth: true - - HTextField { - width: parent.width - } - } - - HLabeledItem { - id: passwordField - label.text: qsTr("Password:") - - Layout.fillWidth: true - - HTextField { - width: parent.width - echoMode: HTextField.Password - } + HColumnLayout { + id: inner + spacing: page.column.spacing } HCheckBox { diff --git a/src/gui/Pages/AddAccount/SignInPassword.qml b/src/gui/Pages/AddAccount/SignInPassword.qml new file mode 100644 index 00000000..3971fc90 --- /dev/null +++ b/src/gui/Pages/AddAccount/SignInPassword.qml @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later + +import QtQuick 2.12 +import QtQuick.Layouts 1.12 +import "../../Base" +import "../../Base/Buttons" + +SignInBase { + id: page + + function takeFocus() { idField.item.forceActiveFocus() } + + function signIn() { + if (page.loginFuture) page.loginFuture.cancel() + + errorMessage.text = "" + + page.loginFuture = py.callCoro( + "password_auth", + [idField.item.text.trim(), passField.item.text, page.serverUrl], + page.finishSignIn, + + (type, args, error, traceback, uuid) => { + page.loginFuture = null + + let txt = qsTr( + "Invalid request, login type or unknown error: %1", + ).arg(type) + + type === "MatrixForbidden" ? + txt = qsTr("Invalid username or password") : + + type === "MatrixUserDeactivated" ? + txt = qsTr("This account was deactivated") : + + utils.showError(type, traceback, uuid) + + page.errorMessage.text = txt + }, + ) + } + + + applyButton.enabled: idField.item.text.trim() && passField.item.text + applyButton.onClicked: page.signIn() + + HLabeledItem { + id: idField + label.text: qsTr("Username:") + + Layout.fillWidth: true + + HTextField { + width: parent.width + } + } + + HLabeledItem { + id: passField + label.text: qsTr("Password:") + + Layout.fillWidth: true + + HTextField { + width: parent.width + echoMode: HTextField.Password + } + } +} diff --git a/src/gui/Pages/AddAccount/SignInSso.qml b/src/gui/Pages/AddAccount/SignInSso.qml new file mode 100644 index 00000000..2aaccb72 --- /dev/null +++ b/src/gui/Pages/AddAccount/SignInSso.qml @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later + +import QtQuick 2.12 +import QtQuick.Layouts 1.12 +import "../../Base" +import "../../Base/Buttons" + +SignInBase { + id: page + + function takeFocus() { urlField.forceActiveFocus() } + + function startSignIn() { + errorMessage.text = "" + + page.loginFuture = py.callCoro("start_sso_auth", [serverUrl], url => { + urlField.text = url + urlField.cursorPosition = 0 + + Qt.openUrlExternally(url) + + page.loginFuture = py.callCoro("continue_sso_auth", [], userId => { + page.loginFuture = null + page.finishSignIn(userId) + }) + }) + } + + + applyButton.text: qsTr("Waiting") + applyButton.loading: true + Component.onCompleted: page.startSignIn() + + HLabel { + wrapMode: HLabel.Wrap + text: qsTr( + "Complete the single sign-on process in your web browser to " + + "continue.\n\n" + + "If no page appeared, you can also manually open this address:" + ) + + Layout.fillWidth: true + } + + HTextArea { + id: urlField + width: parent.width + readOnly: true + radius: 0 + wrapMode: HTextArea.WrapAnywhere + + Layout.fillWidth: true + Layout.fillHeight: true + } +} diff --git a/src/gui/Pages/Chat/Timeline/EventImage.qml b/src/gui/Pages/Chat/Timeline/EventImage.qml index af62e859..23d42cb7 100644 --- a/src/gui/Pages/Chat/Timeline/EventImage.qml +++ b/src/gui/Pages/Chat/Timeline/EventImage.qml @@ -86,6 +86,7 @@ HMxcImage { gesturePolicy: TapHandler.ReleaseWithinBounds onTapped: { + print(loader.mediaUrl, loader.singleMediaInfo.media_http_url) if (eventList.selectedCount) { eventDelegate.toggleChecked() return diff --git a/src/gui/UI.qml b/src/gui/UI.qml index a391abb1..060ac154 100644 --- a/src/gui/UI.qml +++ b/src/gui/UI.qml @@ -10,6 +10,7 @@ import "MainPane" Item { id: mainUI + enabled: ! window.anyPopup property bool accountsPresent: ModelStore.get("accounts").count > 0 || py.startupAnyAccountsSaved