Add support for SSO authentication
This commit is contained in:
@@ -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:
|
||||
|
@@ -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."""
|
||||
|
99
src/backend/sso_server.py
Normal file
99
src/backend/sso_server.py
Normal file
@@ -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 = """<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>""" + __display_name__ + """</title>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body { background: hsl(0, 0%, 90%); }
|
||||
|
||||
@keyframes appear {
|
||||
0% { transform: scale(0); }
|
||||
45% { transform: scale(0); }
|
||||
80% { transform: scale(1.6); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
|
||||
.circle {
|
||||
width: 90px;
|
||||
height: 90px;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
margin: -45px 0 0 -45px;
|
||||
border-radius: 50%;
|
||||
font-size: 60px;
|
||||
line-height: 90px;
|
||||
text-align: center;
|
||||
background: hsl(203, 51%, 15%);
|
||||
color: hsl(162, 56%, 42%, 1);
|
||||
animation: appear 0.4s linear;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body><div class="circle">✓</div></body>
|
||||
</html>"""
|
||||
|
||||
|
||||
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
|
Reference in New Issue
Block a user