From 15d1bc9538c34a2acdc4e24369fa72590141c16b Mon Sep 17 00:00:00 2001 From: NishikawaKoharu Date: Mon, 31 Aug 2026 15:27:12 +0900 Subject: [PATCH 1/5] =?UTF-8?q?y-websocket=20JWT=E8=AA=8D=E8=A8=BC?= =?UTF-8?q?=E3=83=88=E3=83=BC=E3=82=AF=E3=83=B3=E7=99=BA=E8=A1=8C=E3=82=92?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- addons/wiki/settings/defaults.py | 5 +++ addons/wiki/templates/edit.mako | 1 + addons/wiki/tests/test_wiki.py | 53 +++++++++++++++++++++++++++++++- addons/wiki/utils.py | 25 +++++++++++++++ addons/wiki/views.py | 3 ++ 5 files changed, 86 insertions(+), 1 deletion(-) diff --git a/addons/wiki/settings/defaults.py b/addons/wiki/settings/defaults.py index 9fadefc5f61..bbf8ed0f0de 100644 --- a/addons/wiki/settings/defaults.py +++ b/addons/wiki/settings/defaults.py @@ -8,6 +8,11 @@ SHAREJS_PORT = 7007 SHAREJS_URL = '{}:{}'.format(SHAREJS_HOST, SHAREJS_PORT) +Y_WEBSOCKET_SECRET = os.environ.get('Y_WEBSOCKET_SECRET', '') +Y_WEBSOCKET_JWT_ALGORITHM = 'HS256' +# Token validity for WebSocket connection (seconds). Not enforced after connection is established. +Y_WEBSOCKET_TOKEN_TTL = int(os.environ.get('Y_WEBSOCKET_TOKEN_TTL', 8 * 60 * 60)) + Y_WEBSOCKET_HOST = 'localhost' Y_WEBSOCKET_PORT = 1234 Y_WEBSOCKET_URL = '{}:{}'.format(Y_WEBSOCKET_HOST, Y_WEBSOCKET_PORT) diff --git a/addons/wiki/templates/edit.mako b/addons/wiki/templates/edit.mako index c4fd10d92da..6356b6f15e6 100644 --- a/addons/wiki/templates/edit.mako +++ b/addons/wiki/templates/edit.mako @@ -522,6 +522,7 @@ ${parent.javascript_bottom()} metadata: { registration: true, docId: ${ sharejs_uuid | sjson, n }, + yWebsocketToken: ${ y_websocket_token | sjson, n }, userId: ${user_id | sjson, n }, userName: ${ user_full_name | sjson, n }, userUrl: ${ user_url | sjson, n }, diff --git a/addons/wiki/tests/test_wiki.py b/addons/wiki/tests/test_wiki.py index 6daffd6dead..62f48f36531 100644 --- a/addons/wiki/tests/test_wiki.py +++ b/addons/wiki/tests/test_wiki.py @@ -35,7 +35,7 @@ from addons.wiki.utils import ( get_sharejs_uuid, generate_private_uuid, share_db, delete_share_doc, migrate_uuid, format_wiki_version, serialize_wiki_settings, serialize_wiki_widget, - check_file_object_in_node + check_file_object_in_node, generate_y_websocket_token ) from addons.wiki.views import WIKI_IMPORT_TASK_ALREADY_EXISTS from addons.wiki import tasks @@ -3380,3 +3380,54 @@ def test_project_update_wiki_page_sort(self): self.assertEqual(result_wiki_child_page1, {'parent_id': wiki_page2_id, 'sort_order': 1}) self.assertEqual(result_wiki_child_page2, {'parent_id': wiki_page2_id, 'sort_order': 2}) self.assertEqual(result_wiki_child_page3, {'parent_id': wiki_child_page2_id, 'sort_order': 1}) + +@pytest.mark.enable_bookmark_creation +@mock.patch('addons.wiki.settings.Y_WEBSOCKET_SECRET', 'test-y-websocket-secret') +class TestYWebsocketToken(OsfTestCase): + + def setUp(self): + super(TestYWebsocketToken, self).setUp() + self.user = AuthUserFactory() + self.project = ProjectFactory(is_public=True, creator=self.user) + self.wname = 'foo.bar' + self.wkey = to_mongo_key(self.wname) + + def test_token_generated_for_editor(self): + import jwt + from addons.wiki import settings as wiki_settings + + url = self.project.web_url_for('project_wiki_view', wname=self.wname) + res = self.app.get(url, auth=self.user.auth) + assert_equal(res.status_code, 200) + + body = res.body.decode() + sharejs_uuid = get_sharejs_uuid(self.project, self.wname) + assert_in(sharejs_uuid, body) + + token_match = re.search(r'"yWebsocketToken":\s*"([^"]+)"', body) + assert_true(token_match) + token = token_match.group(1) + assert_true(token) + + payload = jwt.decode( + token, + wiki_settings.Y_WEBSOCKET_SECRET, + algorithms=[wiki_settings.Y_WEBSOCKET_JWT_ALGORITHM], + ) + assert_equal(payload['doc_id'], sharejs_uuid) + + def test_token_not_visible_without_write_permission(self): + WikiPage.objects.create_for_node(self.project, self.wname, 'some content', Auth(self.user)) + + url = self.project.web_url_for('project_wiki_view', wname=self.wname) + res = self.app.get(url) + assert_equal(res.status_code, 200) + + body = res.body.decode() + assert_not_in(get_sharejs_uuid(self.project, self.wname), body) + assert_not_in('"yWebsocketToken":', body) + + def test_generate_y_websocket_token_without_secret(self): + with mock.patch('addons.wiki.settings.Y_WEBSOCKET_SECRET', ''): + assert_equal(generate_y_websocket_token('doc-id'), '') + diff --git a/addons/wiki/utils.py b/addons/wiki/utils.py index 39af0660fb0..51304a4410c 100644 --- a/addons/wiki/utils.py +++ b/addons/wiki/utils.py @@ -4,10 +4,13 @@ import uuid import unicodedata import ssl +import datetime from future.moves.urllib.parse import quote +import jwt from pymongo import MongoClient import requests +from django.utils import timezone from bs4 import BeautifulSoup from django.apps import apps @@ -65,6 +68,28 @@ def get_sharejs_uuid(node, wname): )) if private_uuid else None +def generate_y_websocket_token(doc_id): + """ + Generate a signed JWT for y-websocket connection authorization. + Returns an empty string when Y_WEBSOCKET_SECRET is not configured. + """ + secret = wiki_settings.Y_WEBSOCKET_SECRET + if not secret or not doc_id: + return '' + + payload = { + 'doc_id': doc_id, + 'exp': timezone.now() + datetime.timedelta(seconds=wiki_settings.Y_WEBSOCKET_TOKEN_TTL), + } + token = jwt.encode( + payload, + secret, + algorithm=wiki_settings.Y_WEBSOCKET_JWT_ALGORITHM, + ) + if isinstance(token, bytes): + return token.decode() + return token + def delete_share_doc(node, wname): """Deletes share document and removes namespace from model.""" diff --git a/addons/wiki/views.py b/addons/wiki/views.py index a1051ba9110..952daf12838 100644 --- a/addons/wiki/views.py +++ b/addons/wiki/views.py @@ -325,10 +325,12 @@ def project_wiki_view(auth, wname, path=None, **kwargs): rendered_before_update = False markdown = '' + y_websocket_token = '' if can_edit: if wiki_key not in node.wiki_private_uuids: wiki_utils.generate_private_uuid(node, wiki_name) sharejs_uuid = wiki_utils.get_sharejs_uuid(node, wiki_name) + y_websocket_token = wiki_utils.generate_y_websocket_token(sharejs_uuid) else: if not wiki_page and wiki_key != 'home': raise WIKI_PAGE_NOT_FOUND_ERROR @@ -366,6 +368,7 @@ def project_wiki_view(auth, wname, path=None, **kwargs): 'sharejs_uuid': sharejs_uuid or '', 'sharejs_url': settings.SHAREJS_URL, 'y_websocket_url': settings.Y_WEBSOCKET_URL, + 'y_websocket_token': y_websocket_token, 'is_current': is_current, 'version_settings': version_settings, 'pages_current': _get_wiki_pages_latest(node), From 370b5f4541c6914dc1b884f103beb1b1228ba047 Mon Sep 17 00:00:00 2001 From: NishikawaKoharu Date: Wed, 16 Sep 2026 13:02:44 +0900 Subject: [PATCH 2/5] =?UTF-8?q?y-websocket=20JWT=E8=AA=8D=E8=A8=BC?= =?UTF-8?q?=E3=81=AECI=E3=83=86=E3=82=B9=E3=83=88=E3=82=92=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- addons/wiki/tests/test_wiki.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/addons/wiki/tests/test_wiki.py b/addons/wiki/tests/test_wiki.py index 62f48f36531..1d81732dac3 100644 --- a/addons/wiki/tests/test_wiki.py +++ b/addons/wiki/tests/test_wiki.py @@ -3400,8 +3400,10 @@ def test_token_generated_for_editor(self): res = self.app.get(url, auth=self.user.auth) assert_equal(res.status_code, 200) + self.project.reload() body = res.body.decode() sharejs_uuid = get_sharejs_uuid(self.project, self.wname) + assert_true(sharejs_uuid) assert_in(sharejs_uuid, body) token_match = re.search(r'"yWebsocketToken":\s*"([^"]+)"', body) @@ -3420,12 +3422,20 @@ def test_token_not_visible_without_write_permission(self): WikiPage.objects.create_for_node(self.project, self.wname, 'some content', Auth(self.user)) url = self.project.web_url_for('project_wiki_view', wname=self.wname) - res = self.app.get(url) + # Generate sharejs uuid / token path as an editor first (same pattern as TestWikiUuid). + res = self.app.get(url, auth=self.user.auth) assert_equal(res.status_code, 200) + self.project.reload() + sharejs_uuid = get_sharejs_uuid(self.project, self.wname) + assert_true(sharejs_uuid) + # Users without write permission should not receive uuid or a non-empty token. + res = self.app.get(url) + assert_equal(res.status_code, 200) body = res.body.decode() - assert_not_in(get_sharejs_uuid(self.project, self.wname), body) - assert_not_in('"yWebsocketToken":', body) + assert_not_in(sharejs_uuid, body) + token_match = re.search(r'"yWebsocketToken":\s*"([^"]+)"', body) + assert_false(token_match) def test_generate_y_websocket_token_without_secret(self): with mock.patch('addons.wiki.settings.Y_WEBSOCKET_SECRET', ''): From 15c2cf91fa7f3e5bddafcaf7a6096e2e0d44f26f Mon Sep 17 00:00:00 2001 From: NishikawaKoharu Date: Wed, 16 Sep 2026 14:46:04 +0900 Subject: [PATCH 3/5] =?UTF-8?q?y-websocket=20JWT=E8=AA=8D=E8=A8=BC?= =?UTF-8?q?=E3=83=86=E3=82=B9=E3=83=88=E3=81=AEmock=E3=82=92setUp=E3=81=AB?= =?UTF-8?q?=E5=A4=89=E6=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- addons/wiki/tests/test_wiki.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/addons/wiki/tests/test_wiki.py b/addons/wiki/tests/test_wiki.py index 1d81732dac3..3646235013f 100644 --- a/addons/wiki/tests/test_wiki.py +++ b/addons/wiki/tests/test_wiki.py @@ -3382,11 +3382,20 @@ def test_project_update_wiki_page_sort(self): self.assertEqual(result_wiki_child_page3, {'parent_id': wiki_child_page2_id, 'sort_order': 1}) @pytest.mark.enable_bookmark_creation -@mock.patch('addons.wiki.settings.Y_WEBSOCKET_SECRET', 'test-y-websocket-secret') class TestYWebsocketToken(OsfTestCase): def setUp(self): super(TestYWebsocketToken, self).setUp() + # Class-level @mock.patch is unreliable under pytest here; patch in setUp + # so generate_y_websocket_token sees a non-empty secret during the request. + self._y_websocket_secret = 'test-y-websocket-secret' + self._secret_patcher = mock.patch( + 'addons.wiki.settings.Y_WEBSOCKET_SECRET', + self._y_websocket_secret, + ) + self._secret_patcher.start() + self.addCleanup(self._secret_patcher.stop) + self.user = AuthUserFactory() self.project = ProjectFactory(is_public=True, creator=self.user) self.wname = 'foo.bar' @@ -3413,7 +3422,7 @@ def test_token_generated_for_editor(self): payload = jwt.decode( token, - wiki_settings.Y_WEBSOCKET_SECRET, + self._y_websocket_secret, algorithms=[wiki_settings.Y_WEBSOCKET_JWT_ALGORITHM], ) assert_equal(payload['doc_id'], sharejs_uuid) From 0c1040848e4a60a04e05924e1c0151d69f5b33bd Mon Sep 17 00:00:00 2001 From: NishikawaKoharu Date: Wed, 16 Sep 2026 16:10:14 +0900 Subject: [PATCH 4/5] =?UTF-8?q?y-websocket=20JWT=E8=AA=8D=E8=A8=BC?= =?UTF-8?q?=E3=83=86=E3=82=B9=E3=83=88=E3=81=AE=E3=83=88=E3=83=BC=E3=82=AF?= =?UTF-8?q?=E3=83=B3=E6=A4=9C=E5=87=BAregex=E3=82=92=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- addons/wiki/tests/test_wiki.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/addons/wiki/tests/test_wiki.py b/addons/wiki/tests/test_wiki.py index 3646235013f..9c298339445 100644 --- a/addons/wiki/tests/test_wiki.py +++ b/addons/wiki/tests/test_wiki.py @@ -3415,7 +3415,8 @@ def test_token_generated_for_editor(self): assert_true(sharejs_uuid) assert_in(sharejs_uuid, body) - token_match = re.search(r'"yWebsocketToken":\s*"([^"]+)"', body) + # edit.mako renders `yWebsocketToken: "..."`, not JSON `"yWebsocketToken": "..."`. + token_match = re.search(r'yWebsocketToken:\s*"([^"]+)"', body) assert_true(token_match) token = token_match.group(1) assert_true(token) @@ -3443,7 +3444,7 @@ def test_token_not_visible_without_write_permission(self): assert_equal(res.status_code, 200) body = res.body.decode() assert_not_in(sharejs_uuid, body) - token_match = re.search(r'"yWebsocketToken":\s*"([^"]+)"', body) + token_match = re.search(r'yWebsocketToken:\s*"([^"]+)"', body) assert_false(token_match) def test_generate_y_websocket_token_without_secret(self): From c194e79d11cbd89ea1691b072c01e738796ba113 Mon Sep 17 00:00:00 2001 From: NishikawaKoharu Date: Fri, 25 Sep 2026 14:45:34 +0900 Subject: [PATCH 5/5] =?UTF-8?q?y-websocket=20JWT=E3=81=AB=E5=88=A9?= =?UTF-8?q?=E7=94=A8=E8=80=85=E8=AD=98=E5=88=A5=E5=AD=90sub=E3=82=92?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- addons/wiki/tests/test_wiki.py | 7 ++++++- addons/wiki/utils.py | 10 +++++++--- addons/wiki/views.py | 5 ++++- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/addons/wiki/tests/test_wiki.py b/addons/wiki/tests/test_wiki.py index 9c298339445..885a6e82a81 100644 --- a/addons/wiki/tests/test_wiki.py +++ b/addons/wiki/tests/test_wiki.py @@ -3427,6 +3427,7 @@ def test_token_generated_for_editor(self): algorithms=[wiki_settings.Y_WEBSOCKET_JWT_ALGORITHM], ) assert_equal(payload['doc_id'], sharejs_uuid) + assert_equal(payload['sub'], self.user._id) def test_token_not_visible_without_write_permission(self): WikiPage.objects.create_for_node(self.project, self.wname, 'some content', Auth(self.user)) @@ -3449,5 +3450,9 @@ def test_token_not_visible_without_write_permission(self): def test_generate_y_websocket_token_without_secret(self): with mock.patch('addons.wiki.settings.Y_WEBSOCKET_SECRET', ''): - assert_equal(generate_y_websocket_token('doc-id'), '') + assert_equal(generate_y_websocket_token('doc-id', 'user-id'), '') + + def test_generate_y_websocket_token_without_user_id(self): + assert_equal(generate_y_websocket_token('doc-id', ''), '') + assert_equal(generate_y_websocket_token('doc-id', None), '') diff --git a/addons/wiki/utils.py b/addons/wiki/utils.py index 51304a4410c..947cf31b834 100644 --- a/addons/wiki/utils.py +++ b/addons/wiki/utils.py @@ -68,17 +68,21 @@ def get_sharejs_uuid(node, wname): )) if private_uuid else None -def generate_y_websocket_token(doc_id): +def generate_y_websocket_token(doc_id, user_id): """ Generate a signed JWT for y-websocket connection authorization. - Returns an empty string when Y_WEBSOCKET_SECRET is not configured. + + Includes ``sub`` (OSF user GUID) for connection traceability on the + y-websocket side. Returns an empty string when Y_WEBSOCKET_SECRET is not + configured or required claims are missing. """ secret = wiki_settings.Y_WEBSOCKET_SECRET - if not secret or not doc_id: + if not secret or not doc_id or not user_id: return '' payload = { 'doc_id': doc_id, + 'sub': user_id, 'exp': timezone.now() + datetime.timedelta(seconds=wiki_settings.Y_WEBSOCKET_TOKEN_TTL), } token = jwt.encode( diff --git a/addons/wiki/views.py b/addons/wiki/views.py index 952daf12838..8d8c25defa8 100644 --- a/addons/wiki/views.py +++ b/addons/wiki/views.py @@ -330,7 +330,10 @@ def project_wiki_view(auth, wname, path=None, **kwargs): if wiki_key not in node.wiki_private_uuids: wiki_utils.generate_private_uuid(node, wiki_name) sharejs_uuid = wiki_utils.get_sharejs_uuid(node, wiki_name) - y_websocket_token = wiki_utils.generate_y_websocket_token(sharejs_uuid) + y_websocket_token = wiki_utils.generate_y_websocket_token( + sharejs_uuid, + auth.user._id, + ) else: if not wiki_page and wiki_key != 'home': raise WIKI_PAGE_NOT_FOUND_ERROR