From d8a2d6253cacd82fdd5702476a42ff8fbe2082e1 Mon Sep 17 00:00:00 2001
From: Bogdan Bodnar <bogdan.bodnar@nic.cz>
Date: Fri, 8 Mar 2019 15:15:34 +0100
Subject: [PATCH 1/6] Add filesystem authentication.

---
 foris_ws/__main__.py                  |  9 +++-
 foris_ws/authentication/filesystem.py | 67 +++++++++++++++++++++++++++
 setup.py                              |  1 +
 3 files changed, 76 insertions(+), 1 deletion(-)
 create mode 100644 foris_ws/authentication/filesystem.py

diff --git a/foris_ws/__main__.py b/foris_ws/__main__.py
index 37bafcb..0a32a4d 100755
--- a/foris_ws/__main__.py
+++ b/foris_ws/__main__.py
@@ -56,11 +56,16 @@ def main() -> typing.NoReturn:
     parser = argparse.ArgumentParser(prog="foris-ws")
     parser.add_argument("-d", "--debug", dest="debug", action="store_true", default=False)
     parser.add_argument('--version', action='version', version=__version__)
+
+    auth_choices = ["filesystem", "none"]
+    if "ubus" in available_buses:
+        auth_choices.append("ubus")
     parser.add_argument(
         "-a", "--authentication", type=str,
-        choices=["ubus", "none"] if "ubus" in available_buses else ["none"],
+        choices=auth_choices,
         help="Which authentication method should be used", required=True
     )
+
     parser.add_argument(
         "--host", type=str, help="Hostname of the websocket server.", required=True
     )
@@ -122,6 +127,8 @@ def main() -> typing.NoReturn:
 
     if options.authentication == "ubus":
         from foris_ws.authentication.ubus import authenticate
+    elif options.authentication == "filesystem":
+        from foris_ws.authentication.filesystem import authenticate
     elif options.authentication == "none":
         from foris_ws.authentication.none import authenticate
 
diff --git a/foris_ws/authentication/filesystem.py b/foris_ws/authentication/filesystem.py
new file mode 100644
index 0000000..f0e6ff0
--- /dev/null
+++ b/foris_ws/authentication/filesystem.py
@@ -0,0 +1,67 @@
+#
+# foris-ws
+# Copyright (C) 2019 CZ.NIC, z.s.p.o. (http://www.nic.cz/)
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software Foundation,
+# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301  USA
+#
+
+import logging
+import re
+
+from http import HTTPStatus
+from typing import Optional, Tuple
+from websockets.http import Headers
+from werkzeug.contrib.cache import FileSystemCache
+
+logger = logging.getLogger(__name__)
+
+SESSIONS_DIR = "/tmp/foris-sessions"
+
+
+def authenticate(path: str, request_headers: Headers) -> Optional[Tuple[int, Headers, bytes]]:
+    """ Performs an authentication based on authentication token placed in cookie
+    and session saved by Flask to filesystem.
+
+    :param message: should contain clients initial request
+    :rtype: bool
+    """
+
+    logger.debug("Logging using authentication cookie of the filesystem session.")
+
+    if "Cookie" not in request_headers:
+        logger.debug("Missing cookie.")
+        return HTTPStatus.FORBIDDEN, Headers([]), b'Missing Cookie'
+
+    foris_ws_session_re = re.search(r'session=([^;\s]*)', request_headers["Cookie"])
+    if not foris_ws_session_re:
+        logger.debug("Missing foris.ws.session in cookie.")
+        return HTTPStatus.FORBIDDEN, Headers([]), b'Missing foris.ws.session in cookie'
+
+    session_id = foris_ws_session_re.group(1)
+    logger.debug("Using session id %s" % session_id)
+
+    fs_cache = FileSystemCache(SESSIONS_DIR)
+    data = fs_cache.get('session:' + session_id)
+
+    if data is None:
+        logger.debug("Session '%s' not found." % session_id)
+        return HTTPStatus.FORBIDDEN, Headers([]), b'Session not found'
+
+    if not data.get('logged', None):
+        logger.debug("Session '%s' found but not logged." % session_id)
+        return HTTPStatus.FORBIDDEN, Headers([]), b'Session not logged'
+
+    logger.debug("Connection granted.")
+    return None
diff --git a/setup.py b/setup.py
index ce85b46..8bcddf3 100644
--- a/setup.py
+++ b/setup.py
@@ -49,6 +49,7 @@ setup(
     extras_require={
         'ubus': ["ubus"],
         'mqtt': ["paho-mqtt"],
+        'fs_auth': ["werkzeug"],
     },
     tests_require=[
         'pytest',
-- 
GitLab


From 083eb23556cb5d6020a9254bbe8d2f99ddf807f5 Mon Sep 17 00:00:00 2001
From: Bogdan Bodnar <bogdan.bodnar@nic.cz>
Date: Fri, 8 Mar 2019 15:16:02 +0100
Subject: [PATCH 2/6] Add simple tests of filesystem auth.

---
 tests/test_auth_fs.py | 77 +++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 77 insertions(+)
 create mode 100644 tests/test_auth_fs.py

diff --git a/tests/test_auth_fs.py b/tests/test_auth_fs.py
new file mode 100644
index 0000000..08d3578
--- /dev/null
+++ b/tests/test_auth_fs.py
@@ -0,0 +1,77 @@
+#
+# foris-ws
+# Copyright (C) 2018 CZ.NIC, z.s.p.o. (http://www.nic.cz/)
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software Foundation,
+# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301  USA
+#
+
+import json
+from werkzeug.contrib.cache import FileSystemCache
+
+import pytest
+import websocket
+
+from .fixtures import mqtt_ws
+
+SESSIONS_DIR = "/tmp/foris-sessions"
+SESSIONS_ID = "some-testing-sessions-id"
+
+
+def test_fail(mqtt_ws):
+    _, _, host, port = mqtt_ws
+    ws = websocket.WebSocket()
+
+    with pytest.raises(websocket.WebSocketBadStatusException):
+        ws.connect(
+            "ws://%s:%d/" % ("[%s]" % host if ":" in host else host, port),
+            cookie="session=not-existed-session",
+        )
+        ws.close()
+
+
+def test_logged(mqtt_ws):
+    fs_cache = FileSystemCache(SESSIONS_DIR)
+    fs_cache.add('session:%s' % SESSIONS_ID, {'logged': True})
+
+    _, _, host, port = mqtt_ws
+    ws = websocket.WebSocket()
+
+    ws.connect(
+        "ws://%s:%d/" % ("[%s]" % host if ":" in host else host, port),
+        cookie="session=%s" % SESSIONS_ID,
+    )
+
+    ws.send(b'{"action": "subscribe", "params": ["testd"]}')
+    res = ws.recv()
+    assert json.loads(res)["result"]
+
+    ws.close()
+    fs_cache.clear()
+
+
+def test_not_logged(mqtt_ws):
+    fs_cache = FileSystemCache(SESSIONS_DIR)
+    fs_cache.add('session:%s' % SESSIONS_ID, {'logged': True})
+
+    _, _, host, port = mqtt_ws
+    ws = websocket.WebSocket()
+
+    with pytest.raises(websocket.WebSocketBadStatusException):
+        ws.connect(
+            "ws://%s:%d/" % ("[%s]" % host if ":" in host else host, port),
+            cookie="session=%s" % SESSIONS_ID,
+        )
+        ws.close()
+        fs_cache.clear()
-- 
GitLab


From c87e6bd90873b8e7a99e7b15ae411ca901ecc285 Mon Sep 17 00:00:00 2001
From: Bogdan Bodnar <bogdan.bodnar@nic.cz>
Date: Fri, 8 Mar 2019 15:34:55 +0100
Subject: [PATCH 3/6] Fix auth fs tests.

---
 setup.py              |  1 +
 tests/test_auth_fs.py | 10 ++++++++--
 2 files changed, 9 insertions(+), 2 deletions(-)

diff --git a/setup.py b/setup.py
index 8bcddf3..b347bd1 100644
--- a/setup.py
+++ b/setup.py
@@ -57,6 +57,7 @@ setup(
         'foris-controller',
         'ubus',
         'paho-mqtt',
+        'werkzeug'
     ],
     entry_points={
         "console_scripts": [
diff --git a/tests/test_auth_fs.py b/tests/test_auth_fs.py
index 08d3578..858d448 100644
--- a/tests/test_auth_fs.py
+++ b/tests/test_auth_fs.py
@@ -23,12 +23,16 @@ from werkzeug.contrib.cache import FileSystemCache
 import pytest
 import websocket
 
-from .fixtures import mqtt_ws
+from .fixtures import (
+    authentication, mosquitto_test, rpcd, ubusd_test,
+    address_family, mqtt_ws, mqtt_controller, ws_client, mqtt_notify,
+)
 
 SESSIONS_DIR = "/tmp/foris-sessions"
 SESSIONS_ID = "some-testing-sessions-id"
 
 
+@pytest.mark.parametrize("authentication", ["filesystem"], ids=["auth_fs"], indirect=True, scope="function")
 def test_fail(mqtt_ws):
     _, _, host, port = mqtt_ws
     ws = websocket.WebSocket()
@@ -41,6 +45,7 @@ def test_fail(mqtt_ws):
         ws.close()
 
 
+@pytest.mark.parametrize("authentication", ["filesystem"], ids=["auth_fs"], indirect=True, scope="function")
 def test_logged(mqtt_ws):
     fs_cache = FileSystemCache(SESSIONS_DIR)
     fs_cache.add('session:%s' % SESSIONS_ID, {'logged': True})
@@ -61,9 +66,10 @@ def test_logged(mqtt_ws):
     fs_cache.clear()
 
 
+@pytest.mark.parametrize("authentication", ["filesystem"], ids=["auth_fs"], indirect=True, scope="function")
 def test_not_logged(mqtt_ws):
     fs_cache = FileSystemCache(SESSIONS_DIR)
-    fs_cache.add('session:%s' % SESSIONS_ID, {'logged': True})
+    fs_cache.add('session:%s' % SESSIONS_ID, {})
 
     _, _, host, port = mqtt_ws
     ws = websocket.WebSocket()
-- 
GitLab


From bef120b4694b192388305c230f401b1ab4a9a9b5 Mon Sep 17 00:00:00 2001
From: Stepan Henek <stepan.henek@nic.cz>
Date: Mon, 11 Mar 2019 14:57:24 +0100
Subject: [PATCH 4/6] make mqtt really optional

---
 foris_ws/bus_listener.py | 22 ++++++----------------
 1 file changed, 6 insertions(+), 16 deletions(-)

diff --git a/foris_ws/bus_listener.py b/foris_ws/bus_listener.py
index b854f75..b1e4629 100644
--- a/foris_ws/bus_listener.py
+++ b/foris_ws/bus_listener.py
@@ -19,9 +19,8 @@
 
 import logging
 
-from typing import Type, Optional, Tuple
+from typing import Type, Dict, Any
 from foris_client.buses.base import BaseListener
-from foris_client.buses.mqtt import MqttListener
 
 from .connection import connections
 
@@ -34,6 +33,7 @@ def handler(notification: dict, controller_id: str):
     :param notification: notification to be sent
     :param controller_id: id of the controller from which the notification came
     """
+
     logger.debug("Handling bus notification from %s: %s", controller_id, notification)
     connections.publish_notification(controller_id, notification["module"], notification)
     logger.debug("Handling finished: %s - %s", controller_id, notification)
@@ -41,25 +41,15 @@ def handler(notification: dict, controller_id: str):
 
 def make_bus_listener(
     listener_class: Type[BaseListener],
-    socket_path: Optional[str] = None,
-    host: Optional[str] = None,
-    port: Optional[int] = None,
-    credentials: Optional[Tuple[str]] = None,
+    **listener_kwargs: Dict[str, Any],
 ) -> BaseListener:
     """ Prepares a new foris notification listener
 
     :param listener_class: listener class to be used (UbusListener, UnixSocketListner, ...)
-    :param socket_path: path to socket
-    :param host: mqtt host
-    :param port: mqtt port
-    :param credentils: path to mqtt passwd file
+    :param listener_kwargs: argument for the listener
     :returns: instantiated listener
     """
 
-    if listener_class is MqttListener:
-        logger.debug("Initializing bus listener (%s:%d)", host, port)
-        listener = listener_class(host, port, handler, credentials=credentials)
-    else:
-        logger.debug("Initializing bus listener (%s)", socket_path)
-        listener = listener_class(socket_path, handler)
+    logger.debug("Initializing bus listener (%s: %s)", listener_class, listener_kwargs)
+    listener = listener_class(**dict(handler=handler), **listener_kwargs)
     return listener
-- 
GitLab


From 3776b2751014b893eff6711d4176b1ffa6c04351 Mon Sep 17 00:00:00 2001
From: Stepan Henek <stepan.henek@nic.cz>
Date: Mon, 11 Mar 2019 15:20:58 +0100
Subject: [PATCH 5/6] tests: auth_fs session cleanup

---
 tests/test_auth_fs.py | 19 ++++++++++++++-----
 1 file changed, 14 insertions(+), 5 deletions(-)

diff --git a/tests/test_auth_fs.py b/tests/test_auth_fs.py
index 858d448..4315312 100644
--- a/tests/test_auth_fs.py
+++ b/tests/test_auth_fs.py
@@ -18,6 +18,7 @@
 #
 
 import json
+import shutil
 from werkzeug.contrib.cache import FileSystemCache
 
 import pytest
@@ -32,8 +33,18 @@ SESSIONS_DIR = "/tmp/foris-sessions"
 SESSIONS_ID = "some-testing-sessions-id"
 
 
+@pytest.fixture(scope="function")
+def fs_cache():
+
+    cache = FileSystemCache(SESSIONS_DIR)
+
+    yield cache
+
+    shutil.rmtree(SESSIONS_DIR, ignore_errors=False)
+
+
 @pytest.mark.parametrize("authentication", ["filesystem"], ids=["auth_fs"], indirect=True, scope="function")
-def test_fail(mqtt_ws):
+def test_fail(mqtt_ws, fs_cache):
     _, _, host, port = mqtt_ws
     ws = websocket.WebSocket()
 
@@ -46,8 +57,7 @@ def test_fail(mqtt_ws):
 
 
 @pytest.mark.parametrize("authentication", ["filesystem"], ids=["auth_fs"], indirect=True, scope="function")
-def test_logged(mqtt_ws):
-    fs_cache = FileSystemCache(SESSIONS_DIR)
+def test_logged(mqtt_ws, fs_cache):
     fs_cache.add('session:%s' % SESSIONS_ID, {'logged': True})
 
     _, _, host, port = mqtt_ws
@@ -67,8 +77,7 @@ def test_logged(mqtt_ws):
 
 
 @pytest.mark.parametrize("authentication", ["filesystem"], ids=["auth_fs"], indirect=True, scope="function")
-def test_not_logged(mqtt_ws):
-    fs_cache = FileSystemCache(SESSIONS_DIR)
+def test_not_logged(mqtt_ws, fs_cache):
     fs_cache.add('session:%s' % SESSIONS_ID, {})
 
     _, _, host, port = mqtt_ws
-- 
GitLab


From 3183174f55c601168a63054242d9d3c79e6b3a81 Mon Sep 17 00:00:00 2001
From: Stepan Henek <stepan.henek@nic.cz>
Date: Mon, 11 Mar 2019 15:32:12 +0100
Subject: [PATCH 6/6] foris-ws: nicer handling for optinal imports

---
 foris_ws/__main__.py | 27 +++++++++++++--------------
 1 file changed, 13 insertions(+), 14 deletions(-)

diff --git a/foris_ws/__main__.py b/foris_ws/__main__.py
index 0a32a4d..8544c4e 100755
--- a/foris_ws/__main__.py
+++ b/foris_ws/__main__.py
@@ -25,6 +25,7 @@ import typing
 import re
 import signal
 import websockets
+import importlib
 
 
 from . import __version__
@@ -34,21 +35,22 @@ from .ws_handling import connection_handler as ws_connection_handler
 logger = logging.getLogger(__name__)
 
 
-available_buses: typing.List[str] = ['unix-socket']
+def _extend_choices(names: typing.List[str], module_name: str, target_list: typing.List[str]):
+    try:
+        if importlib.util.find_spec(module_name):
+            target_list.extend(names)
+    except ModuleNotFoundError:
+        pass
 
 
-try:
-    __import__("ubus")
-    available_buses.append("ubus")
-except ModuleNotFoundError:
-    pass
+available_buses: typing.List[str] = ['unix-socket']
+_extend_choices(["ubus"], "ubus", available_buses)
+_extend_choices(["mqtt"], "paho.mqtt.client", available_buses)
 
 
-try:
-    __import__("paho.mqtt.client")
-    available_buses.append("mqtt")
-except ModuleNotFoundError:
-    pass
+auth_choices: typing.List[str] = ['none']
+_extend_choices(["ubus"], "ubus", auth_choices)
+_extend_choices(["filesystem"], "werkzeug.contrib.cache", auth_choices)
 
 
 def main() -> typing.NoReturn:
@@ -57,9 +59,6 @@ def main() -> typing.NoReturn:
     parser.add_argument("-d", "--debug", dest="debug", action="store_true", default=False)
     parser.add_argument('--version', action='version', version=__version__)
 
-    auth_choices = ["filesystem", "none"]
-    if "ubus" in available_buses:
-        auth_choices.append("ubus")
     parser.add_argument(
         "-a", "--authentication", type=str,
         choices=auth_choices,
-- 
GitLab