Merge ~ack/maas:websocket-check-expired-sessions into maas:master

Proposed by Alberto Donato
Status: Merged
Approved by: Alberto Donato
Approved revision: cf8b5a2a0df025bfd2300e47570cbf90c5536a0f
Merge reported by: MAAS Lander
Merged at revision: not available
Proposed branch: ~ack/maas:websocket-check-expired-sessions
Merge into: maas:master
Diff against target: 229 lines (+117/-10)
2 files modified
src/maasserver/websockets/protocol.py (+40/-3)
src/maasserver/websockets/tests/test_protocol.py (+77/-7)
Reviewer Review Type Date Requested Status
Adam Collard (community) Approve
MAAS Lander Approve
Review via email: mp+438669@code.launchpad.net

Commit message

periodically check and disconnect expired websocket sessions

To post a comment you must log in.
Revision history for this message
Adam Collard (adam-collard) :
Revision history for this message
MAAS Lander (maas-lander) wrote :

UNIT TESTS
-b websocket-check-expired-sessions lp:~ack/maas/+git/maas into -b master lp:~maas-committers/maas

STATUS: SUCCESS
COMMIT: b51d70d96134059022a658085989b77eff1e78d2

review: Approve
Revision history for this message
Peter Makowski (petermakowski) :
571f65c... by Alberto Donato

address review comments

75903e7... by Alberto Donato

ensure session checker is stopped when unregisterRPCEvents fails

Revision history for this message
Alberto Donato (ack) wrote :

addressed review comments, thanks

c3cb678... by Alberto Donato

use ExitStack to ensure all methods are called on stop

cf8b5a2... by Alberto Donato

use code 1000 for session timeout close

Revision history for this message
MAAS Lander (maas-lander) wrote :

UNIT TESTS
-b websocket-check-expired-sessions lp:~ack/maas/+git/maas into -b master lp:~maas-committers/maas

STATUS: SUCCESS
COMMIT: 75903e789ee93dd9c276fba4a64e53c3096861a3

review: Approve
Revision history for this message
MAAS Lander (maas-lander) wrote :

UNIT TESTS
-b websocket-check-expired-sessions lp:~ack/maas/+git/maas into -b master lp:~maas-committers/maas

STATUS: SUCCESS
COMMIT: cf8b5a2a0df025bfd2300e47570cbf90c5536a0f

review: Approve
Revision history for this message
Adam Collard (adam-collard) :
review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/src/maasserver/websockets/protocol.py b/src/maasserver/websockets/protocol.py
2index 6cdaad7..070b217 100644
3--- a/src/maasserver/websockets/protocol.py
4+++ b/src/maasserver/websockets/protocol.py
5@@ -5,6 +5,7 @@
6
7
8 from collections import deque
9+from contextlib import ExitStack
10 from functools import partial
11 from http.cookies import SimpleCookie
12 import json
13@@ -15,8 +16,10 @@ from django.conf import settings
14 from django.contrib.auth import BACKEND_SESSION_KEY, load_backend, SESSION_KEY
15 from django.core.exceptions import ValidationError
16 from django.http import HttpRequest
17+from django.utils import timezone
18 from twisted.internet.defer import fail, inlineCallbacks, returnValue, succeed
19 from twisted.internet.protocol import Factory, Protocol
20+from twisted.internet.task import LoopingCall
21 from twisted.python.modules import getModule
22 from twisted.web.server import NOT_DONE_YET
23
24@@ -380,16 +383,20 @@ class WebSocketFactory(Factory):
25 self.handlers = {}
26 self.clients = []
27 self.listener = listener
28+ self.session_checker = LoopingCall(self._check_sessions)
29+ self.session_checker_done = None
30 self.cacheHandlers()
31 self.registerNotifiers()
32
33 def startFactory(self):
34- """Register for RPC events."""
35+ self._cleanup_stack = ExitStack()
36 self.registerRPCEvents()
37+ self._cleanup_stack.callback(self.unregisterRPCEvents)
38+ self.session_checker_done = self.session_checker.start(5, now=True)
39+ self._cleanup_stack.callback(self.session_checker.stop)
40
41 def stopFactory(self):
42- """Unregister RPC events."""
43- self.unregisterRPCEvents()
44+ self._cleanup_stack.close()
45
46 def getSessionEngine(self):
47 """Returns the session engine being used by Django.
48@@ -491,3 +498,33 @@ class WebSocketFactory(Factory):
49 )
50 else:
51 return fail("Unable to get the 'controller' handler.")
52+
53+ @inlineCallbacks
54+ def _check_sessions(self):
55+ client_sessions = {
56+ client.session.session_key: client
57+ for client in self.clients
58+ if client.session is not None
59+ }
60+ client_session_keys = set(client_sessions)
61+
62+ def get_valid_sessions(session_keys):
63+ session_engine = self.getSessionEngine()
64+ Session = session_engine.SessionStore.get_model_class()
65+ return set(
66+ Session.objects.filter(
67+ session_key__in=session_keys,
68+ expire_date__gt=timezone.now(),
69+ ).values_list("session_key", flat=True)
70+ )
71+
72+ valid_session_keys = yield deferToDatabase(
73+ get_valid_sessions,
74+ client_session_keys,
75+ )
76+ # drop connections for expired sessions
77+ for session_key in client_session_keys - valid_session_keys:
78+ if client := client_sessions.get(session_key):
79+ client.loseConnection(STATUSES.NORMAL, "Session expired")
80+
81+ returnValue(None)
82diff --git a/src/maasserver/websockets/tests/test_protocol.py b/src/maasserver/websockets/tests/test_protocol.py
83index 92dda5a..419a883 100644
84--- a/src/maasserver/websockets/tests/test_protocol.py
85+++ b/src/maasserver/websockets/tests/test_protocol.py
86@@ -3,6 +3,7 @@
87
88
89 from collections import deque
90+from datetime import datetime, timedelta
91 import json
92 import random
93 from unittest.mock import MagicMock, sentinel
94@@ -93,7 +94,7 @@ class TestWebSocketProtocol(MAASTransactionServerTestCase):
95 return json.loads(call[0][0].decode("ascii"))
96
97 def test_connectionMade_sets_the_request(self):
98- protocol, factory = self.make_protocol(patch_authenticate=False)
99+ protocol, _ = self.make_protocol(patch_authenticate=False)
100 self.patch_autospec(protocol, "authenticate")
101 # Be sure the request field is populated by the time that
102 # processMessages() is called.
103@@ -122,7 +123,7 @@ class TestWebSocketProtocol(MAASTransactionServerTestCase):
104 )
105
106 def test_connectionMade_sets_the_request_default_server_name_port(self):
107- protocol, factory = self.make_protocol(patch_authenticate=False)
108+ protocol, _ = self.make_protocol(patch_authenticate=False)
109 self.patch_autospec(protocol, "authenticate")
110 self.patch_autospec(protocol, "processMessages")
111 mock_splithost = self.patch_autospec(protocol_module, "splithost")
112@@ -134,7 +135,7 @@ class TestWebSocketProtocol(MAASTransactionServerTestCase):
113 self.assertEqual(protocol.request.META["SERVER_PORT"], 5248)
114
115 def test_connectionMade_processes_messages(self):
116- protocol, factory = self.make_protocol(patch_authenticate=False)
117+ protocol, _ = self.make_protocol(patch_authenticate=False)
118 self.patch_autospec(protocol, "authenticate")
119 self.patch_autospec(protocol, "processMessages")
120 protocol.authenticate.return_value = defer.succeed(True)
121@@ -161,7 +162,7 @@ class TestWebSocketProtocol(MAASTransactionServerTestCase):
122 self.assertNotIn(protocol, factory.clients)
123
124 def test_connectionMade_extracts_sessionid_and_csrftoken(self):
125- protocol, factory = self.make_protocol(patch_authenticate=False)
126+ protocol, _ = self.make_protocol(patch_authenticate=False)
127 sessionid = maas_factory.make_name("sessionid")
128 csrftoken = maas_factory.make_name("csrftoken")
129 cookies = {
130@@ -194,7 +195,7 @@ class TestWebSocketProtocol(MAASTransactionServerTestCase):
131 self.assertEqual([], factory.clients)
132
133 def test_loseConnection_writes_to_log(self):
134- protocol, factory = self.make_protocol()
135+ protocol, _ = self.make_protocol()
136 status = random.randint(1000, 1010)
137 reason = maas_factory.make_name("reason")
138 with TwistedLoggerFixture() as logger:
139@@ -331,7 +332,7 @@ class TestWebSocketProtocol(MAASTransactionServerTestCase):
140 @wait_for_reactor
141 @inlineCallbacks
142 def test_authenticate_calls_loseConnection_if_csrftoken_is_missing(self):
143- user, session_id = yield deferToDatabase(self.get_user_and_session_id)
144+ _, session_id = yield deferToDatabase(self.get_user_and_session_id)
145 uri = self.make_ws_uri(csrftoken=None)
146 protocol, _ = self.make_protocol(
147 patch_authenticate=False, transport_uri=uri
148@@ -981,7 +982,7 @@ class TestWebSocketFactoryTransactional(
149 controller = yield deferToDatabase(
150 transactional(maas_factory.make_RackController)
151 )
152- protocol, factory = self.make_protocol_with_factory(user=user)
153+ _, factory = self.make_protocol_with_factory(user=user)
154 mock_onNotify = self.patch(factory, "onNotify")
155 controller_handler = MagicMock()
156 factory.handlers["controller"] = controller_handler
157@@ -995,3 +996,72 @@ class TestWebSocketFactoryTransactional(
158 controller.system_id,
159 ),
160 )
161+
162+ @wait_for_reactor
163+ @inlineCallbacks
164+ def test_check_sessions(self):
165+ factory = self.make_factory()
166+
167+ session_engine = factory.getSessionEngine()
168+ Session = session_engine.SessionStore.get_model_class()
169+ key1 = maas_factory.make_string()
170+ key2 = maas_factory.make_string()
171+
172+ def make_sessions():
173+ now = datetime.utcnow()
174+ delta = timedelta(hours=1)
175+ # first session is expired, second one is valid
176+ return (
177+ Session.objects.create(
178+ session_key=key1, expire_date=now - delta
179+ ),
180+ Session.objects.create(
181+ session_key=key2, expire_date=now + delta
182+ ),
183+ )
184+
185+ expired_session, active_session = yield deferToDatabase(make_sessions)
186+
187+ def make_protocol_with_session(session):
188+ protocol = factory.buildProtocol(None)
189+ protocol.transport = MagicMock()
190+ protocol.transport.cookies = b""
191+
192+ def authenticate(*args):
193+ protocol.session = session
194+ return defer.succeed(True)
195+
196+ self.patch(protocol, "authenticate", authenticate)
197+ self.patch(protocol, "loseConnection")
198+ return protocol
199+
200+ expired_proto = make_protocol_with_session(expired_session)
201+ active_proto = make_protocol_with_session(active_session)
202+
203+ yield expired_proto.connectionMade()
204+ self.addCleanup(expired_proto.connectionLost, "")
205+ yield active_proto.connectionMade()
206+ self.addCleanup(active_proto.connectionLost, "")
207+
208+ yield factory.startFactory()
209+ factory.stopFactory()
210+ # wait until it's stopped, sessions are checked
211+ yield factory.session_checker_done
212+ # the first client gets disconnected
213+ expired_proto.loseConnection.assert_called_once_with(
214+ STATUSES.NORMAL, "Session expired"
215+ )
216+ active_proto.loseConnection.assert_not_called()
217+
218+ @wait_for_reactor
219+ @inlineCallbacks
220+ def test_session_checker_stopped_with_previous_failure(self):
221+ factory = self.make_factory()
222+ mock_session_checker = self.patch(factory, "session_checker")
223+ self.patch(factory, "unregisterRPCEvents").side_effect = Exception(
224+ "err"
225+ )
226+ yield factory.startFactory()
227+ err = self.assertRaises(Exception, factory.stopFactory)
228+ self.assertEqual(str(err), "err")
229+ mock_session_checker.stop.assert_called_once()

Subscribers

People subscribed via source and target branches