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