Repository: opsdroid/opsdroid (language: python)

Issue:
Add token to websockets connector
Currently, anyone that knows opdroid url and endpoint will be able to request a socket to initialize a websocket connection.

Ideally, we should allow users to select a token in the configuration settings. When opsdroid gets a request, if the token doesn't exist then we just reject the request.

This will also a nice feature to implement along side opsdroid-web v2


Discussion / hints:

Fix patch:
diff --git a/docs/connectors/websocket.md b/docs/connectors/websocket.md
index 4c297d0c4..eda98be3b 100644
--- a/docs/connectors/websocket.md
+++ b/docs/connectors/websocket.md
@@ -11,6 +11,7 @@ connectors:
     bot-name: "mybot" # default "opsdroid"
     max-connections: 10 # default is 10 users can be connected at once
     connection-timeout: 10 # default 10 seconds before requested socket times out
+    token: "secret-token" # Used to validate request before assigning socket
 ```
 
 ## Usage
@@ -29,5 +30,24 @@ Response
 }
 ```
 
+If you provided a `token` in your configuration, opsdroid will check if the token provided in the configuration exists in the request header and if it matches, if it doesn't opsdroid will return a `403` Forbidden error.
+
 #### `[WEBSOCKET] http://host:port/connector/websocket/{socket}`
 The websocket end point to connect to. Messages are sent and received as text broadcasts in the socket.
+
+You can send a single string to be parsed by opsdroid, but you can also send a json string payload containing the keys `message`, `user` and `socket`. These keys will then be passed to the `Message` event.
+
+For example:
+
+```python
+import json
+payload = json.dumps({"message": "hello, world", "user": "BobTheBuilder", "socket": "123"})
+
+websocket_connection.send_str(payload)
+```
+
+This payload will create a `Message` event with the following attributes:
+
+```python
+message = Message(text="hello, world", user="BobTheBuilder", target="123")
+```
\ No newline at end of file
diff --git a/opsdroid/configuration/example_configuration.yaml b/opsdroid/configuration/example_configuration.yaml
index 97136b4ae..f5a398f79 100644
--- a/opsdroid/configuration/example_configuration.yaml
+++ b/opsdroid/configuration/example_configuration.yaml
@@ -107,6 +107,8 @@ connectors:
     bot-name: "mybot" # default "opsdroid"
     max-connections: 10 # default is 10 users can be connected at once
     connection-timeout: 10 # default 10 seconds before requested socket times out
+    # Optional but recommended 
+    # token: "secret-token" # Used to validate request before assigning socket
   # Uncomment the connector(s) that you wish opsdroid to work on
 #
 #  ## Twitter (https://github.com/opsdroid/connector-twitter)
diff --git a/opsdroid/connector/websocket/__init__.py b/opsdroid/connector/websocket/__init__.py
index fbb0d72ff..9d75b3748 100644
--- a/opsdroid/connector/websocket/__init__.py
+++ b/opsdroid/connector/websocket/__init__.py
@@ -9,12 +9,49 @@
 from aiohttp import WSCloseCode
 from opsdroid.connector import Connector, register_event
 from opsdroid.events import Message
+import dataclasses
+from typing import Optional
 
 _LOGGER = logging.getLogger(__name__)
 HEADERS = {"Access-Control-Allow-Origin": "*"}
 CONFIG_SCHEMA = {"bot-name": str, "max-connections": int, "connection-timeout": int}
 
 
+@dataclasses.dataclass
+class WebsocketMessage:
+    """A message received from a websocket connection."""
+
+    message: str
+    user: Optional[str]
+    socket: Optional[str]
+
+    @classmethod
+    def parse_payload(cls, payload: str):
+        """Parse the payload of a websocket message.
+
+        We will try to parse the payload as a json string.
+        If that fails, we will use the default values which are:
+
+        message: str
+        user: None
+        socket: None
+
+        """
+        try:
+            data = json.loads(payload)
+            return cls(
+                message=data.get("message"),
+                user=data.get("user"),
+                socket=data.get("socket"),
+            )
+        except json.JSONDecodeError:
+            return cls(
+                message=payload,
+                user=None,
+                socket=None,
+            )
+
+
 class ConnectorWebsocket(Connector):
     """A connector which allows websocket connections."""
 
@@ -29,6 +66,7 @@ def __init__(self, config, opsdroid=None):
         self.active_connections = {}
         self.available_connections = []
         self.bot_name = self.config.get("bot-name", "opsdroid")
+        self.authorization_token = self.config.get("token")
 
     async def connect(self):
         """Connect to the chat service."""
@@ -53,6 +91,7 @@ async def disconnect(self):
 
     async def new_websocket_handler(self, request):
         """Handle for aiohttp creating websocket connections."""
+        await self.validate_request(request)
         if (
             len(self.active_connections) + len(self.available_connections)
             < self.max_connections
@@ -95,7 +134,13 @@ async def websocket_handler(self, request):
         self.active_connections[socket] = websocket
         async for msg in websocket:
             if msg.type == aiohttp.WSMsgType.TEXT:
-                message = Message(text=msg.data, user=None, target=None, connector=self)
+                payload = WebsocketMessage.parse_payload(msg.data)
+                message = Message(
+                    text=payload.message,
+                    user=payload.user,
+                    target=payload.socket,
+                    connector=self,
+                )
                 await self.opsdroid.parse(message)
             elif msg.type == aiohttp.WSMsgType.ERROR:
                 _LOGGER.error(
@@ -108,6 +153,20 @@ async def websocket_handler(self, request):
 
         return websocket
 
+    async def validate_request(self, request):
+        """Validate the request by looking at headers and the connector token.
+
+        If the token does not exist in the header, but exists in the configuration,
+        then we will simply return a Forbidden error.
+
+        """
+        client_token = request.headers.get("Authorization")
+        if self.authorization_token and (
+            client_token is None or client_token != self.authorization_token
+        ):
+            raise aiohttp.web.HTTPUnauthorized()
+        return True
+
     async def listen(self):
         """Listen for and parse new messages.
 
@@ -117,7 +176,7 @@ async def listen(self):
         """
 
     @register_event(Message)
-    async def send_message(self, message):
+    async def send_message(self, message: Message):
         """Respond with a message."""
         try:
             if message.target is None:


Test patch:
diff --git a/tests/test_connector_websocket.py b/tests/test_connector_websocket.py
index 847d3ac0d..556ae58e2 100644
--- a/tests/test_connector_websocket.py
+++ b/tests/test_connector_websocket.py
@@ -1,10 +1,14 @@
 import asyncio
 import unittest
+import pytest
+import json
 
 import asynctest
 import asynctest.mock as amock
+from aiohttp.web import HTTPUnauthorized
+
 from opsdroid.cli.start import configure_lang
-from opsdroid.connector.websocket import ConnectorWebsocket
+from opsdroid.connector.websocket import ConnectorWebsocket, WebsocketMessage
 from opsdroid.core import OpsDroid
 from opsdroid.events import Message
 
@@ -73,12 +77,14 @@ async def test_new_websocket_handler(self):
         connector.max_connections = 1
         self.assertEqual(len(connector.available_connections), 0)
 
-        response = await connector.new_websocket_handler(None)
+        mocked_request = amock.Mock()
+
+        response = await connector.new_websocket_handler(mocked_request)
         self.assertTrue(isinstance(response, aiohttp.web.Response))
         self.assertEqual(len(connector.available_connections), 1)
         self.assertEqual(response.status, 200)
 
-        fail_response = await connector.new_websocket_handler(None)
+        fail_response = await connector.new_websocket_handler(mocked_request)
         self.assertTrue(isinstance(fail_response, aiohttp.web.Response))
         self.assertEqual(fail_response.status, 429)
 
@@ -165,3 +171,46 @@ async def test_websocket_handler(self):
             self.assertEqual(type(response), aiohttp.web.Response)
             self.assertEqual(response.status, 408)
             self.assertFalse(connector.available_connections)
+
+
+def test_ConnectorMessage_dataclass():
+    payload = json.dumps({"message": "Hello, world!", "user": "Bob", "socket": "12345"})
+    data = WebsocketMessage.parse_payload(payload)
+
+    assert data.message == "Hello, world!"
+    assert data.user == "Bob"
+    assert data.socket == "12345"
+
+    text_message = WebsocketMessage.parse_payload("Hello, world!")
+    assert text_message.message == "Hello, world!"
+    assert text_message.user is None
+    assert text_message.socket is None
+
+
+@pytest.mark.asyncio
+async def test_validate_request():
+    config = {"token": "secret"}
+    connector = ConnectorWebsocket(config, opsdroid=OpsDroid())
+
+    request = amock.CoroutineMock()
+    request.headers = {"Authorization": "secret"}
+
+    is_valid = await connector.validate_request(request)
+    assert is_valid
+
+    request = amock.CoroutineMock()
+    request.headers = {}
+
+    with pytest.raises(HTTPUnauthorized):
+        await connector.validate_request(request)
+
+
+@pytest.mark.asyncio
+async def test_new_websocket_handler_no_token():
+    config = {"token": "secret"}
+    connector = ConnectorWebsocket(config, opsdroid=OpsDroid())
+
+    with pytest.raises(HTTPUnauthorized):
+        request = amock.CoroutineMock()
+        request.headers = {}
+        await connector.new_websocket_handler(request)