diff --git a/README.rst b/README.rst index 245c130..b491d0e 100644 --- a/README.rst +++ b/README.rst @@ -45,6 +45,34 @@ The `API Reference `_ documen * **TRAME_SERVER** : If set to true, this will prevent browser from opening by default +**WebSocket authentication** + +Clients must provide a secret to connect to the WebSocket. The secret +comes from (in order of precedence): + +* ``--authKeyFile PATH`` : File whose (stripped) content is used as the secret. +* ``--authKey KEY`` : Secret provided directly on the command line. +* Otherwise, a random secret is generated every time the server starts. + +The browser client reads the secret from the ``?secret=...`` URL parameter, +so the URLs printed at startup, the browser opened automatically and the +desktop window (``--app``) already include it:: + + App running at: + - Local: http://localhost:8080/?secret=qcNXdzVSWzM9kiVkgSqh9Nk1lPiX0fBDTDaFtmCkx_M + - Network: http://127.0.0.1:8080/?secret=qcNXdzVSWzM9kiVkgSqh9Nk1lPiX0fBDTDaFtmCkx_M + +Use ``--hide-secret`` to keep the secret out of the console output (and +therefore out of any captured logs). With a generated secret this means +the printed URLs cannot be used to connect, so combine it with +``--authKey`` / ``--authKeyFile`` or with a launcher that delivers the +secret to the client. + +When building a client URL yourself (iframe, testing, ...), use +``server.url_query_params`` once the server is started to get the +parameters to append (e.g. ``{"secret": "..."}``). + + **Life cycle callbacks** Life cycle events are directly managed on the application controller diff --git a/docs/source/usage.rst b/docs/source/usage.rst index 88360fd..33999ae 100644 --- a/docs/source/usage.rst +++ b/docs/source/usage.rst @@ -24,3 +24,38 @@ The ``name`` parameter can be used to specify the server name. For example: >>> server = Server(name="my_server") >>> server.name 'my_server' + +WebSocket authentication +------------------------ + +Clients must provide a secret to connect to the WebSocket. The secret +comes from (in order of precedence): + +* ``--authKeyFile PATH``: file whose (stripped) content is used as the secret. +* ``--authKey KEY``: secret provided directly on the command line. +* Otherwise, a random secret is generated every time the server starts. + +The browser client reads the secret from the ``?secret=...`` URL parameter. +The URLs printed at startup, the browser opened automatically and the +desktop window (``--app``) already include it: + +.. code-block:: console + + $ python app.py --server + App running at: + - Local: http://localhost:8080/?secret=qcNXdzVSWzM9kiVkgSqh9Nk1lPiX0fBDTDaFtmCkx_M + - Network: http://127.0.0.1:8080/?secret=qcNXdzVSWzM9kiVkgSqh9Nk1lPiX0fBDTDaFtmCkx_M + +To keep the secret out of the console output (and therefore out of any +captured logs), add ``--hide-secret``. With a generated secret, the printed +URLs then cannot be used to connect, so combine it with ``--authKey`` / +``--authKeyFile`` or with a launcher that delivers the secret to the client. + +When building a client URL yourself (iframe, testing, ...), use +:attr:`trame_server.Server.url_query_params` once the server is started: + +>>> from urllib.parse import urlencode +>>> url = f"http://localhost:{server.port}/?{urlencode(server.url_query_params)}" + +Python clients (``trame_server.client.Client``) take the secret as an +argument of ``connect(url, secret=...)``. diff --git a/examples/client-server/README.md b/examples/client-server/README.md index 9ad4089..f94df79 100644 --- a/examples/client-server/README.md +++ b/examples/client-server/README.md @@ -1,6 +1,6 @@ Example on how to test this example ```bash -python ./server.py --port 1234 --server & -python ./client.py --port 1235 --url ws://localhost:1234/ws --server +python ./server.py --port 1234 --server --authKey my-secret & +python ./client.py --port 1235 --url ws://localhost:1234/ws --secret my-secret --server ``` diff --git a/examples/client-server/client.py b/examples/client-server/client.py index 796a5e4..b762a4f 100644 --- a/examples/client-server/client.py +++ b/examples/client-server/client.py @@ -7,6 +7,7 @@ server = get_server(client_type="vue2") state, ctrl = server.state, server.controller server.cli.add_argument("--url") +server.cli.add_argument("--secret", help="--authKey used by the remote server") client = Client() @@ -74,9 +75,9 @@ def on_attachment(attch_data, **_): @ctrl.add("on_server_ready") def connect_to_server(**_): - url = server.cli.parse_args().url - print(f"Client connect to {url}") - asynchronous.create_task(client.connect(url, secret="wslink-secret")) + args = server.cli.parse_args() + print(f"Client connect to {args.url}") + asynchronous.create_task(client.connect(args.url, secret=args.secret)) with SinglePageLayout(server) as layout: diff --git a/src/trame_server/core.py b/src/trame_server/core.py index 90fa79d..724dea6 100644 --- a/src/trame_server/core.py +++ b/src/trame_server/core.py @@ -402,6 +402,13 @@ def cli(self): help="Use OS built-in browser", action="store_true", ) + self._cli_parser.add_argument( + "--hide-secret", + help="""Do not print the WebSocket authentication key + (the '?secret=...' URL parameter) in the console output""", + dest="hide_secret", + action="store_true", + ) self._cli_parser.add_argument( "--no-http", help="Do not serve anything over http", @@ -412,7 +419,9 @@ def cli(self): "--authKeyFile", help="""Path to a File that contains the Authentication key for clients to connect to the WebSocket. - This takes precedence over '-a, --authKey' from wslink.""", + This takes precedence over '-a, --authKey' from wslink. + When neither is provided, a random key is generated. + The key is reported as a '?secret=...' URL parameter.""", ) self._cli_parser.add_argument( "--hot-reload", @@ -822,6 +831,22 @@ def port(self) -> int: return self._running_port + @property + def url_query_params(self) -> dict[str, str]: + """ + URL query parameters a browser client needs to connect, i.e. + ``{"secret": "..."}`` holding the WebSocket authentication key. + The key comes from ``--authKeyFile`` or ``--authKey``, otherwise it is + randomly generated at start, so only rely on it once the server is + started (e.g. in ``on_server_ready``). + + >>> from urllib.parse import urlencode + >>> url = ( + ... f"http://localhost:{server.port}/?{urlencode(server.url_query_params)}" + ... ) + """ + return CoreServer.url_query_params() + @property def server_options(self): """Once started, you can retrieve the server options used""" diff --git a/src/trame_server/protocol.py b/src/trame_server/protocol.py index 4c3743f..77a2ad9 100644 --- a/src/trame_server/protocol.py +++ b/src/trame_server/protocol.py @@ -1,7 +1,9 @@ import inspect import logging import os +import secrets from pathlib import Path +from urllib.parse import urlencode from trame_common.utils import profiler from wslink import register as exportRpc @@ -15,8 +17,12 @@ log = logging.getLogger(__name__) +# wslink's default value for '--authKey' +DEFAULT_AUTH_KEY = "wslink-secret" + + class CoreServer(ServerProtocol): - authentication_token = "wslink-secret" + authentication_token = DEFAULT_AUTH_KEY server = None # --------------------------------------------------------------- @@ -35,14 +41,38 @@ def bind_server(server): @staticmethod def add_arguments(parser): server.add_arguments(parser) + # Detect when no key is provided so a random one can be generated + # instead of relying on a well known default value. + parser.set_defaults(authKey=None) @staticmethod def configure(args): - if args.authKeyFile: + """ + Resolve the WebSocket authentication secret from the parsed CLI args. + + Precedence: ``--authKeyFile``, then ``--authKey``, otherwise a random + secret is generated (a new one on every call). + """ + if getattr(args, "authKeyFile", None): with Path(args.authKeyFile).open("r") as key_file: CoreServer.authentication_token = key_file.read().strip() - else: + elif getattr(args, "authKey", None): CoreServer.authentication_token = args.authKey + else: + CoreServer.authentication_token = secrets.token_urlsafe(32) + + @staticmethod + def url_query_params(): + """ + URL query parameters a browser client needs to authenticate, + i.e. ``{"secret": }``. + """ + return {"secret": CoreServer.authentication_token} + + @staticmethod + def url_query(): + """URL query string (including the leading ``?``) for a browser client""" + return f"?{urlencode(CoreServer.url_query_params())}" @staticmethod def server_start( diff --git a/src/trame_server/utils/browser.py b/src/trame_server/utils/browser.py index af81954..9a3b691 100644 --- a/src/trame_server/utils/browser.py +++ b/src/trame_server/utils/browser.py @@ -1,6 +1,9 @@ +from trame_server.protocol import CoreServer + + def open_browser(server): args = server.cli.parse_known_args()[0] - local_url = f"http://{args.host}:{server.port}/" + local_url = f"http://{args.host}:{server.port}/{CoreServer.url_query()}" try: import webbrowser # noqa: PLC0415 diff --git a/src/trame_server/utils/desktop.py b/src/trame_server/utils/desktop.py index ff56697..4d68ceb 100644 --- a/src/trame_server/utils/desktop.py +++ b/src/trame_server/utils/desktop.py @@ -2,6 +2,8 @@ import threading from multiprocessing import Process, Queue +from trame_server.protocol import CoreServer + from .asynchronous import handle_task_result try: @@ -65,6 +67,7 @@ def __init__( self, title=None, port=None, + url_query="", msg_queue=None, action_queue=None, debug=False, @@ -75,6 +78,7 @@ def __init__( Process.__init__(self) self._title = title self._port = port + self._url_query = url_query self._msg_queue = msg_queue self._action_queue = action_queue self._menu = menu or [] @@ -115,7 +119,7 @@ def run_in_thread(self, loop): def run(self): self._main_window = webview.create_window( title=self._title, - url=f"http://localhost:{self._port}/index.html", + url=f"http://localhost:{self._port}/index.html{self._url_query}", **self._window_args, ) if hasattr(self._main_window, "events"): @@ -159,6 +163,7 @@ async def process_msg(): client_process = BrowserProcess( title=server.state.trame__title, port=server.port, + url_query=CoreServer.url_query(), msg_queue=_msg_queue, action_queue=_window_action_queue, debug=server.options.get("desktop_debug"), diff --git a/src/trame_server/utils/server.py b/src/trame_server/utils/server.py index ae2ef4c..73f87e4 100644 --- a/src/trame_server/utils/server.py +++ b/src/trame_server/utils/server.py @@ -1,10 +1,14 @@ import socket import sys +from trame_server.protocol import CoreServer + def print_informations(server): options = server.server_options - local_url = f"http://{options.host}:{server.port}/" + hide_secret = getattr(options, "hide_secret", False) + query = "" if hide_secret else CoreServer.url_query() + local_url = f"http://{options.host}:{server.port}/{query}" print() print("App running at:") print(f" - Local: {local_url}") @@ -15,9 +19,13 @@ def print_informations(server): except Exception: host_name = socket.gethostname() host_ip = socket.gethostbyname(host_name) - print(f" - Network: http://{host_ip}:{server.port}/") + print(f" - Network: http://{host_ip}:{server.port}/{query}") except socket.gaierror: - print(f" - Network: http://{options.host}:{server.port}/") + print(f" - Network: http://{options.host}:{server.port}/{query}") + + if hide_secret: + print() + print("The '?secret=...' URL parameter is hidden (--hide-secret).") print() print( diff --git a/tests/test_client.py b/tests/test_client.py index 6b0ff59..8799174 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -5,6 +5,8 @@ import pytest_asyncio from trame.app import asynchronous, get_client, get_server +from trame_server.protocol import CoreServer + @pytest_asyncio.fixture async def server(): @@ -23,7 +25,7 @@ async def server(): async def client(server): url = f"ws://localhost:{server.port}/ws" client = get_client(url) - asynchronous.create_task(client.connect(secret="wslink-secret")) + asynchronous.create_task(client.connect(secret=CoreServer.authentication_token)) for _ in range(10): if client.connected == 2: diff --git a/tests/test_protocol.py b/tests/test_protocol.py index 7a29803..20ca812 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -12,7 +12,7 @@ from trame.app import asynchronous, get_client, get_server from trame_server.client import ConnectionStatus, WsLinkSession -from trame_server.protocol import CoreServer +from trame_server.protocol import DEFAULT_AUTH_KEY, CoreServer @pytest_asyncio.fixture @@ -30,7 +30,7 @@ async def server(): @pytest_asyncio.fixture async def client(server): client = get_client(f"ws://localhost:{server.port}/ws") - asynchronous.create_task(client.connect(secret="wslink-secret")) + asynchronous.create_task(client.connect(secret=CoreServer.authentication_token)) for _ in range(20): if client.connected == ConnectionStatus.CONNECTED: @@ -57,7 +57,7 @@ async def session(server): session.pushed_states = [] session.register_subscription("trame.state.topic", session.pushed_states.append) listen_task = asynchronous.create_task(session.listen()) - await (await session.auth(secret="wslink-secret")) + await (await session.auth(secret=CoreServer.authentication_token)) try: yield session finally: @@ -224,6 +224,18 @@ def test_configure_auth_key(tmp_path): key_file.write_text("from-file\n") CoreServer.configure(Namespace(authKeyFile=str(key_file), authKey="unused")) assert CoreServer.authentication_token == "from-file" + assert CoreServer.url_query_params() == {"secret": "from-file"} + assert CoreServer.url_query() == "?secret=from-file" + + # No key provided => random token exposed through the url + CoreServer.configure(Namespace(authKeyFile=None, authKey=None)) + token = CoreServer.authentication_token + assert token not in {None, "", DEFAULT_AUTH_KEY} + assert CoreServer.url_query_params() == {"secret": token} + assert CoreServer.url_query() == f"?secret={token}" + + CoreServer.configure(Namespace(authKeyFile=None, authKey=None)) + assert CoreServer.authentication_token != token finally: CoreServer.authentication_token = previous @@ -297,7 +309,9 @@ async def test_connected_status_follows_authentication(server): assert client.connected == ConnectionStatus.DISCONNECTED statuses = [] - task = asynchronous.create_task(client.connect(secret="wslink-secret")) + task = asynchronous.create_task( + client.connect(secret=CoreServer.authentication_token) + ) deadline = asyncio.get_running_loop().time() + 5 while asyncio.get_running_loop().time() < deadline: status = client.connected @@ -346,7 +360,9 @@ async def test_connect_failure_allows_reconnect(server): # Not stuck in CONNECTING, so a new attempt goes through task = asynchronous.create_task( - client.connect(f"ws://localhost:{server.port}/ws", secret="wslink-secret") + client.connect( + f"ws://localhost:{server.port}/ws", secret=CoreServer.authentication_token + ) ) for _ in range(50): if client.connected == ConnectionStatus.CONNECTED: diff --git a/tests/test_utils_extra.py b/tests/test_utils_extra.py index c10434e..a24d7c5 100644 --- a/tests/test_utils_extra.py +++ b/tests/test_utils_extra.py @@ -11,6 +11,7 @@ import more_itertools import pytest +from trame_server.protocol import CoreServer from trame_server.ui import VirtualNodeManager from trame_server.utils import asynchronous, browser, hot_reload, logger, server from trame_server.utils.argument_parser import ArgumentParser @@ -104,20 +105,25 @@ def test_argument_parser_trame_args(monkeypatch, argv): # ----------------------------------------------------------------------------- -def _fake_server(host="localhost", port=1234): +def _fake_server(host="localhost", port=1234, hide_secret=False): cli = ArgumentParser() cli.add_argument("--host", default=host) return SimpleNamespace( cli=cli, port=port, - server_options=Namespace(host=host), + server_options=Namespace(host=host, hide_secret=hide_secret), ) +def _set_auth(monkeypatch, token): + monkeypatch.setattr(CoreServer, "authentication_token", token) + + @pytest.mark.asyncio async def test_open_browser(monkeypatch): import webbrowser # noqa: PLC0415 + _set_auth(monkeypatch, "user key&x") open_mock = MagicMock() monkeypatch.setattr(webbrowser, "open", open_mock) @@ -126,7 +132,48 @@ async def test_open_browser(monkeypatch): assert "--server" in buf.getvalue() await asyncio.sleep(0.2) - open_mock.assert_called_once_with("http://localhost:1234/") + open_mock.assert_called_once_with("http://localhost:1234/?secret=user+key%26x") + + +@pytest.mark.asyncio +async def test_open_browser_secret(monkeypatch): + import webbrowser # noqa: PLC0415 + + _set_auth(monkeypatch, "abc_123-XYZ") + open_mock = MagicMock() + monkeypatch.setattr(webbrowser, "open", open_mock) + + with io.StringIO() as buf, redirect_stdout(buf): + browser.open_browser(_fake_server()) + + await asyncio.sleep(0.2) + open_mock.assert_called_once_with("http://localhost:1234/?secret=abc_123-XYZ") + + +def test_print_informations_secret(monkeypatch): + _set_auth(monkeypatch, "abc_123-XYZ") + monkeypatch.setattr(socket, "gethostbyname", lambda _: "10.0.0.1") + + with io.StringIO() as buf, redirect_stdout(buf): + server.print_informations(_fake_server()) + output = buf.getvalue() + + assert "Local: http://localhost:1234/?secret=abc_123-XYZ" in output + assert "Network: http://10.0.0.1:1234/?secret=abc_123-XYZ" in output + + +def test_print_informations_hide_secret(monkeypatch): + _set_auth(monkeypatch, "abc_123-XYZ") + monkeypatch.setattr(socket, "gethostbyname", lambda _: "10.0.0.1") + + with io.StringIO() as buf, redirect_stdout(buf): + server.print_informations(_fake_server(hide_secret=True)) + output = buf.getvalue() + + assert "abc_123-XYZ" not in output + assert "Local: http://localhost:1234/\n" in output + assert "Network: http://10.0.0.1:1234/\n" in output + assert "--hide-secret" in output def test_open_browser_error(monkeypatch):