Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions addons/wiki/settings/defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions addons/wiki/templates/edit.mako
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
78 changes: 77 additions & 1 deletion addons/wiki/tests/test_wiki.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -3380,3 +3380,79 @@ 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
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'
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)

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)

# 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)

payload = jwt.decode(
token,
self._y_websocket_secret,
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))

url = self.project.web_url_for('project_wiki_view', wname=self.wname)
# 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(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', ''):
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), '')

29 changes: 29 additions & 0 deletions addons/wiki/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -65,6 +68,32 @@ def get_sharejs_uuid(node, wname):
)) if private_uuid else None


def generate_y_websocket_token(doc_id, user_id):
"""
Generate a signed JWT for y-websocket connection authorization.

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 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(
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."""

Expand Down
6 changes: 6 additions & 0 deletions addons/wiki/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -325,10 +325,15 @@ 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,
auth.user._id,
)
else:
if not wiki_page and wiki_key != 'home':
raise WIKI_PAGE_NOT_FOUND_ERROR
Expand Down Expand Up @@ -366,6 +371,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),
Expand Down
Loading