Skip to content

Commit 2b559ae

Browse files
authored
Merge pull request #77 from dmgav/websockets-sec
Security for WebSockets
2 parents be0025b + 0b1da36 commit 2b559ae

10 files changed

Lines changed: 266 additions & 25 deletions

File tree

.pre-commit-config.yaml

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,24 +2,24 @@ default_language_version:
22
python: python3
33
repos:
44
- repo: https://github.com/pre-commit/pre-commit-hooks
5-
rev: v4.5.0
5+
rev: v6.0.0
66
hooks:
77
- id: check-yaml
88
- id: end-of-file-fixer
99
- id: trailing-whitespace
1010
- repo: https://github.com/ambv/black
11-
rev: 24.2.0
11+
rev: 26.1.0
1212
hooks:
1313
- id: black
1414
- repo: https://github.com/pycqa/flake8
15-
rev: 7.0.0
15+
rev: 7.3.0
1616
hooks:
1717
- id: flake8
1818
- repo: https://github.com/pycqa/isort
19-
rev: 5.13.2
19+
rev: 7.0.0
2020
hooks:
2121
- id: isort
2222
- repo: https://github.com/kynan/nbstripout
23-
rev: 0.7.1
23+
rev: 0.9.0
2424
hooks:
2525
- id: nbstripout

bluesky_httpserver/authentication.py

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from datetime import datetime, timedelta
88
from typing import Optional
99

10-
from fastapi import APIRouter, Depends, HTTPException, Request, Response, Security
10+
from fastapi import APIRouter, Depends, HTTPException, Request, Response, Security, WebSocket
1111
from fastapi.openapi.models import APIKey, APIKeyIn
1212
from fastapi.responses import JSONResponse
1313
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm, SecurityScopes
@@ -202,7 +202,6 @@ def get_current_principal(
202202
# otherwise it is None. The original set of API key scopes is used for generating new
203203
# API keys.
204204
roles, scopes, api_key_scopes = {}, {}, None
205-
206205
if api_key is not None:
207206
if authenticators:
208207
# Tiled is in a multi-user configuration with authentication providers.
@@ -356,6 +355,41 @@ def get_current_principal(
356355
return principal
357356

358357

358+
def get_current_principal_websocket(
359+
websocket: WebSocket,
360+
scopes: str,
361+
):
362+
app = websocket.app
363+
security_scopes = SecurityScopes(scopes=scopes or [])
364+
settings = app.dependency_overrides[get_settings]()
365+
authenticators = app.dependency_overrides[get_authenticators]()
366+
api_access_manager = app.dependency_overrides[get_api_access_manager]()
367+
368+
auth_header = websocket.headers.get("Authorization", "")
369+
access_token, api_key = None, None
370+
# Currently we do not support authentication with tokens
371+
# if auth_header.startswith("Bearer "):
372+
# access_token = auth_header[len("Bearer") :].strip()
373+
if auth_header.startswith("ApiKey "):
374+
api_key = auth_header[len("ApiKey") :].strip()
375+
376+
principal = None
377+
try:
378+
principal = get_current_principal(
379+
request=websocket,
380+
security_scopes=security_scopes,
381+
access_token=access_token,
382+
api_key=api_key,
383+
settings=settings,
384+
authenticators=authenticators,
385+
api_access_manager=api_access_manager,
386+
)
387+
except HTTPException as ex:
388+
print(f"WebSocket connection failed: {ex}")
389+
390+
return principal
391+
392+
359393
def create_session(settings, identity_provider, id, scopes):
360394
with get_sessionmaker(settings.database_settings)() as db:
361395
# Have we seen this Identity before?

bluesky_httpserver/authorization/_defaults.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@
7373
"write:plan:control",
7474
"write:execute",
7575
"write:history:edit",
76+
"user:apikeys",
7677
}
7778

7879
_DEFAULT_SCOPES_USER = {
@@ -91,6 +92,7 @@
9192
"write:plan:control",
9293
"write:execute",
9394
"write:history:edit",
95+
"user:apikeys",
9496
}
9597

9698
_DEFAULT_SCOPES_OBSERVER = {
@@ -103,6 +105,7 @@
103105
"read:console",
104106
"read:lock",
105107
"read:testing",
108+
"user:apikeys",
106109
}
107110

108111
# =============================================================================================

bluesky_httpserver/routers/core_api.py

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
else:
1515
from pydantic_settings import BaseSettings
1616

17-
from ..authentication import get_current_principal
17+
from ..authentication import get_current_principal, get_current_principal_websocket
1818
from ..console_output import ConsoleOutputEventStream, StreamingResponseFromClass
1919
from ..resources import SERVER_RESOURCES as SR
2020
from ..settings import get_settings
@@ -1139,7 +1139,12 @@ def is_alive(self):
11391139

11401140

11411141
@router.websocket("/console_output/ws")
1142-
async def console_output_ws(websocket: WebSocket):
1142+
async def console_output_ws(websocket: WebSocket, scopes=["read:console"]):
1143+
principal = get_current_principal_websocket(websocket=websocket, scopes=scopes)
1144+
if not principal:
1145+
await websocket.close(code=4001, reason="Invalid token")
1146+
return
1147+
11431148
await websocket.accept()
11441149
q = SR.console_output_stream.add_queue(websocket)
11451150
wsmon = WebSocketMonitor(websocket)
@@ -1151,33 +1156,48 @@ async def console_output_ws(websocket: WebSocket):
11511156
await websocket.send_text(msg)
11521157
except asyncio.TimeoutError:
11531158
pass
1159+
except RuntimeError: # 'send' after the client is disconnected
1160+
pass
11541161
except WebSocketDisconnect:
11551162
pass
11561163
finally:
11571164
SR.console_output_stream.remove_queue(websocket)
11581165

11591166

11601167
@router.websocket("/status/ws")
1161-
async def status_ws(websocket: WebSocket):
1168+
async def status_ws(websocket: WebSocket, scopes=["read:monitor"]):
1169+
principal = get_current_principal_websocket(websocket=websocket, scopes=scopes)
1170+
if not principal:
1171+
await websocket.close(code=4001, reason="Invalid token")
1172+
return
1173+
11621174
await websocket.accept()
11631175
q = SR.system_info_stream.add_queue_status(websocket)
11641176
wsmon = WebSocketMonitor(websocket)
11651177
wsmon.start()
1178+
11661179
try:
11671180
while wsmon.is_alive:
11681181
try:
11691182
msg = await asyncio.wait_for(q.get(), timeout=1)
11701183
await websocket.send_text(msg)
11711184
except asyncio.TimeoutError:
11721185
pass
1186+
except RuntimeError: # 'send' after the client is disconnected
1187+
pass
11731188
except WebSocketDisconnect:
11741189
pass
11751190
finally:
11761191
SR.system_info_stream.remove_queue_status(websocket)
11771192

11781193

11791194
@router.websocket("/info/ws")
1180-
async def info_ws(websocket: WebSocket):
1195+
async def info_ws(websocket: WebSocket, scopes=["read:monitor"]):
1196+
principal = get_current_principal_websocket(websocket=websocket, scopes=scopes)
1197+
if not principal:
1198+
await websocket.close(code=4001, reason="Invalid token")
1199+
return
1200+
11811201
await websocket.accept()
11821202
q = SR.system_info_stream.add_queue_info(websocket)
11831203
wsmon = WebSocketMonitor(websocket)
@@ -1189,6 +1209,8 @@ async def info_ws(websocket: WebSocket):
11891209
await websocket.send_text(msg)
11901210
except asyncio.TimeoutError:
11911211
pass
1212+
except RuntimeError: # 'send' after the client is disconnected
1213+
pass
11921214
except WebSocketDisconnect:
11931215
pass
11941216
finally:
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import json
2+
import pprint
3+
import threading
4+
import time as ttime
5+
6+
import pytest
7+
from bluesky_queueserver.manager.tests.common import re_manager, re_manager_cmd # noqa F401
8+
from websockets.sync.client import connect
9+
10+
from .conftest import fastapi_server_fs # noqa: F401
11+
from .conftest import (
12+
SERVER_ADDRESS,
13+
SERVER_PORT,
14+
request_to_json,
15+
setup_server_with_config_file,
16+
wait_for_environment_to_be_closed,
17+
wait_for_environment_to_be_created,
18+
)
19+
20+
config_toy_test = """
21+
authentication:
22+
allow_anonymous_access: True
23+
providers:
24+
- provider: toy
25+
authenticator: bluesky_httpserver.authenticators:DictionaryAuthenticator
26+
args:
27+
users_to_passwords:
28+
bob: bob_password
29+
alice: alice_password
30+
cara: cara_password
31+
tom: tom_password
32+
api_access:
33+
policy: bluesky_httpserver.authorization:DictionaryAPIAccessControl
34+
args:
35+
users:
36+
bob:
37+
roles:
38+
- admin
39+
- expert
40+
alice:
41+
roles: advanced
42+
tom:
43+
roles: user
44+
"""
45+
46+
47+
class _ReceiveSystemInfoSocket(threading.Thread):
48+
"""
49+
Catch streaming console output by connecting to /console_output/ws socket and
50+
save messages to the buffer.
51+
"""
52+
53+
def __init__(self, *, endpoint, api_key=None, token=None, **kwargs):
54+
super().__init__(**kwargs)
55+
self.received_data_buffer = []
56+
self._exit = False
57+
self._api_key = api_key
58+
self._token = token
59+
self._endpoint = endpoint
60+
61+
def run(self):
62+
websocket_uri = f"ws://{SERVER_ADDRESS}:{SERVER_PORT}/api{self._endpoint}"
63+
if self._token is not None:
64+
additional_headers = {"Authorization": f"Bearer {self._token}"}
65+
elif self._api_key is not None:
66+
additional_headers = {"Authorization": f"ApiKey {self._api_key}"}
67+
else:
68+
additional_headers = {}
69+
70+
try:
71+
with connect(websocket_uri, additional_headers=additional_headers) as websocket:
72+
while not self._exit:
73+
try:
74+
msg_json = websocket.recv(timeout=0.1, decode=False)
75+
try:
76+
msg = json.loads(msg_json)
77+
self.received_data_buffer.append(msg)
78+
except json.JSONDecodeError:
79+
pass
80+
except TimeoutError:
81+
pass
82+
except Exception as ex:
83+
print(f"Failed to connect to server: {ex}")
84+
85+
def stop(self):
86+
"""
87+
Call this method to stop the thread. Then send a request to the server so that some output
88+
is printed in ``stdout``.
89+
"""
90+
self._exit = True
91+
92+
def __del__(self):
93+
self.stop()
94+
95+
96+
# fmt: off
97+
@pytest.mark.parametrize("ws_auth_type", ["apikey", "apikey_invalid", "none"])
98+
# fmt: on
99+
def test_websocket_auth_01(
100+
tmpdir,
101+
monkeypatch,
102+
re_manager_cmd, # noqa: F811
103+
fastapi_server_fs, # noqa: F811
104+
ws_auth_type,
105+
):
106+
"""
107+
Test authentication for websockets. The test is run only on ``/status/ws`` websocket.
108+
The other websockets are expected to use the same authentication scheme.
109+
"""
110+
111+
# Start RE Manager
112+
params = ["--zmq-publish-console", "ON"]
113+
re_manager_cmd(params)
114+
115+
setup_server_with_config_file(config_file_str=config_toy_test, tmpdir=tmpdir, monkeypatch=monkeypatch)
116+
fastapi_server_fs()
117+
118+
resp1 = request_to_json("post", "/auth/provider/toy/token", login=("bob", "bob_password"))
119+
assert "access_token" in pprint.pformat(resp1)
120+
token = resp1["access_token"]
121+
122+
resp3 = request_to_json(
123+
"post", "/auth/apikey", json={"expires_in": 900, "note": "API key for testing"}, token=token
124+
)
125+
assert "secret" in resp3, pprint.pformat(resp3)
126+
assert "note" in resp3, pprint.pformat(resp3)
127+
assert resp3["note"] == "API key for testing"
128+
assert resp3["scopes"] == ["inherit"]
129+
api_key = resp3["secret"]
130+
131+
endpoint = "/status/ws"
132+
if ws_auth_type == "none":
133+
ws_params = {}
134+
elif ws_auth_type == "apikey":
135+
ws_params = {"api_key": api_key}
136+
elif ws_auth_type == "apikey_invalid":
137+
ws_params = {"api_key": "InvalidApiKey"}
138+
# elif ws_auth_type == "token":
139+
# ws_params = {"token": token}
140+
# elif ws_auth_type == "token_invalid":
141+
# ws_params = {"token": "InvalidToken"}
142+
else:
143+
assert False, f"Unknown authentication type: {ws_auth_type!r}"
144+
145+
rsc = _ReceiveSystemInfoSocket(endpoint=endpoint, **ws_params)
146+
rsc.start()
147+
ttime.sleep(1) # Wait until the client connects to the socket
148+
149+
resp1 = request_to_json("post", "/environment/open", api_key=api_key)
150+
assert resp1["success"] is True, pprint.pformat(resp1)
151+
152+
assert wait_for_environment_to_be_created(timeout=10, api_key=api_key)
153+
154+
resp2b = request_to_json("post", "/environment/close", api_key=api_key)
155+
assert resp2b["success"] is True, pprint.pformat(resp2b)
156+
157+
assert wait_for_environment_to_be_closed(timeout=10, api_key=api_key)
158+
159+
# Wait until capture is complete
160+
ttime.sleep(2)
161+
rsc.stop()
162+
rsc.join()
163+
164+
buffer = rsc.received_data_buffer
165+
if ws_auth_type in ("none", "apikey_invalid", "token_invalid"):
166+
assert len(buffer) == 0
167+
elif ws_auth_type in ("apikey", "token"):
168+
assert len(buffer) > 0
169+
for msg in buffer:
170+
assert "time" in msg, msg
171+
assert isinstance(msg["time"], float), msg
172+
assert "msg" in msg
173+
assert isinstance(msg["msg"], dict)
174+
else:
175+
assert False, f"Unknown authentication type: {ws_auth_type!r}"

bluesky_httpserver/tests/test_authenticators.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@
22

33
import pytest
44

5+
# fmt: off
56
from ..authenticators import LDAPAuthenticator
67

78

8-
# fmt: off
99
@pytest.mark.parametrize("ldap_server_address, ldap_server_port", [
1010
("localhost", 1389),
1111
("localhost:1389", 904), # Random port, ignored

bluesky_httpserver/tests/test_console_output.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -353,7 +353,8 @@ def __init__(self, api_key=API_KEY_FOR_TESTS, **kwargs):
353353

354354
def run(self):
355355
websocket_uri = f"ws://{SERVER_ADDRESS}:{SERVER_PORT}/api/console_output/ws"
356-
with connect(websocket_uri) as websocket:
356+
additional_headers = {"Authorization": f"ApiKey {self._api_key}"}
357+
with connect(websocket_uri, additional_headers=additional_headers) as websocket:
357358
while not self._exit:
358359
try:
359360
msg_json = websocket.recv(timeout=0.1, decode=False)

0 commit comments

Comments
 (0)