Commit 99bd36d3 by Xavier Antoviaque

xblock-external-ui: Add XBlock API call to render XBlock views

Included commits:
* xblock-external-ui: Include CSRF token in the API answer
* xblock-external-ui: Adds support for CORS headers (cross-domain request)
* xblock-external-ui: Include full path when building local_url
* xblock-external-ui: Fix TestHandleXBlockCallback & bok_choy, add tests
* xblock-external-ui: Only return `instance` in `_invoke_xblock_handler()`
* xblock-external-ui: Group resources by hash tag to avoid duplicate loads
* xblock-external-ui: Alternate referer check for CORS requests
parent fbe40764
"""
Middleware for handling CSRF checks with CORS requests
When processing HTTPS requests, the default CSRF middleware checks that the referer
domain and protocol is the same as the request's domain and protocol. This is meant
to avoid a type of attack for sites which serve their content with both HTTP and HTTPS,
with a man in the middle on the HTTP requests.
https://github.com/django/django/blob/b91c385e324f1cb94d20e2ad146372c259d51d3b/django/middleware/csrf.py#L117
This doesn't work well with CORS requests, which aren't vulnerable to this attack when
the server from which the request is coming uses HTTPS too, as it prevents the man in the
middle attack vector.
We thus do the CSRF check of requests coming from an authorized CORS host separately
in this middleware, applying the same protections as the default CSRF middleware, but
without the referrer check, when both the request and the referer use HTTPS.
"""
import logging
import urlparse
from django.conf import settings
from django.middleware.csrf import CsrfViewMiddleware
log = logging.getLogger(__name__)
class CorsCSRFMiddleware(CsrfViewMiddleware):
def is_enabled(self, request):
if not settings.FEATURES.get('ENABLE_CORS_HEADERS'):
return False
referer = request.META.get('HTTP_REFERER')
if referer is None or referer == '':
return False
referer_parts = urlparse.urlparse(referer)
if referer_parts.hostname not in getattr(settings, 'CORS_ORIGIN_WHITELIST', []):
return False
if not request.is_secure() or referer_parts.scheme != 'https':
return False
return True
def process_view(self, request, callback, callback_args, callback_kwargs):
log.warn('oooooo')
if not self.is_enabled(request):
return
is_secure_default = request.is_secure
def is_secure_patched():
return False
request.is_secure = is_secure_patched
res = super(CorsCSRFMiddleware, self).process_view(request, callback, callback_args, callback_kwargs)
request.is_secure = is_secure_default
return res
import hashlib
import json
import logging
import mimetypes
import static_replace
from collections import OrderedDict
from functools import partial
from requests.auth import HTTPBasicAuth
from dogapi import dog_stats_api
......@@ -11,6 +13,7 @@ from dogapi import dog_stats_api
from django.conf import settings
from django.contrib.auth.models import User
from django.core.cache import cache
from django.core.context_processors import csrf
from django.core.exceptions import PermissionDenied
from django.core.urlresolvers import reverse
from django.http import Http404, HttpResponse
......@@ -29,7 +32,7 @@ from student.models import anonymous_id_for_user, user_by_anonymous_id
from xblock.core import XBlock
from xblock.fields import Scope
from xblock.runtime import KvsFieldData, KeyValueStore
from xblock.exceptions import NoSuchHandlerError
from xblock.exceptions import NoSuchHandlerError, NoSuchViewError
from xblock.django.request import django_to_webob_request, webob_to_django_response
from xmodule.error_module import ErrorDescriptor, NonStaffErrorDescriptor
from xmodule.exceptions import NotFoundError, ProcessingError
......@@ -562,23 +565,19 @@ def xblock_resource(request, block_type, uri): # pylint: disable=unused-argumen
return HttpResponse(content, mimetype=mimetype)
def _invoke_xblock_handler(request, course_id, usage_id, handler, suffix, user):
def _get_module_by_usage_id(request, course_id, usage_id):
"""
Invoke an XBlock handler, either authenticated or not.
Gets a module instance based on its `usage_id` in a course, for a given request/user
Returns (location, descriptor, instance)
"""
location = unquote_slashes(usage_id)
user = request.user
# Check parameters and fail fast if there's a problem
if not Location.is_valid(location):
raise Http404("Invalid location")
# Check submitted files
files = request.FILES or {}
error_msg = _check_files_limits(files)
if error_msg:
return HttpResponse(json.dumps({'success': error_msg}))
try:
descriptor = modulestore().get_instance(course_id, location)
except ItemNotFoundError:
......@@ -590,13 +589,6 @@ def _invoke_xblock_handler(request, course_id, usage_id, handler, suffix, user):
)
raise Http404
tracking_context_name = 'module_callback_handler'
tracking_context = {
'module': {
'display_name': descriptor.display_name_with_default,
}
}
field_data_cache = FieldDataCache.cache_for_descriptor_descendents(
course_id,
user,
......@@ -609,6 +601,30 @@ def _invoke_xblock_handler(request, course_id, usage_id, handler, suffix, user):
log.debug("No module %s for user %s -- access denied?", location, user)
raise Http404
return instance
def _invoke_xblock_handler(request, course_id, usage_id, handler, suffix, user):
"""
Invoke an XBlock handler, either authenticated or not.
"""
# Check submitted files
files = request.FILES or {}
error_msg = _check_files_limits(files)
if error_msg:
return HttpResponse(json.dumps({'success': error_msg}))
instance = _get_module_by_usage_id(request, course_id, usage_id)
tracking_context_name = 'module_callback_handler'
tracking_context = {
'module': {
'display_name': instance.display_name_with_default,
}
}
req = django_to_webob_request(request)
try:
with tracker.get_tracker().context(tracking_context_name, tracking_context):
......@@ -637,6 +653,44 @@ def _invoke_xblock_handler(request, course_id, usage_id, handler, suffix, user):
return webob_to_django_response(resp)
def hash_resource(resource):
"""
Hash a :class:`xblock.fragment.FragmentResource
"""
md5 = hashlib.md5()
for data in resource:
md5.update(data)
return md5.hexdigest()
def xblock_view(request, course_id, usage_id, view_name):
"""
Returns the rendered view of a given XBlock, with related resources
Returns a json object containing two keys:
html: The rendered html of the view
resources: A list of tuples where the first element is the resource hash, and
the second is the resource description
"""
instance = _get_module_by_usage_id(request, course_id, usage_id)
try:
fragment = instance.render(view_name)
except NoSuchViewError:
log.exception("Attempt to render missing view on %s: %s", instance, view_name)
raise Http404
hashed_resources = OrderedDict()
for resource in fragment.resources:
hashed_resources[hash_resource(resource)] = resource
return JsonResponse({
'html': fragment.content,
'resources': hashed_resources.items(),
'csrf_token': str(csrf(request)['csrf_token']),
})
def get_score_bucket(grade, max_grade):
"""
Function to split arbitrary score ranges into 3 buckets.
......
......@@ -306,6 +306,28 @@ class TestHandleXBlockCallback(ModuleStoreTestCase, LoginEnrollmentTestCase):
'bad_dispatch',
)
def test_xblock_view_handler(self):
args=[
'edX/toy/2012_Fall',
quote_slashes('i4x://edX/toy/videosequence/Toy_Videos'),
'student_view'
]
xblock_view_url = reverse(
'xblock_view',
args=args
)
request = self.request_factory.get(xblock_view_url)
request.user = self.mock_user
response = render.xblock_view(request, *args)
self.assertEquals(200, response.status_code)
expected = ['csrf_token', 'html', 'resources']
content = json.loads(response.content)
for section in expected:
self.assertIn(section, content)
self.assertIn('<div class="xblock xblock-student_view xmodule_display', content['html'])
@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE)
class TestTOC(TestCase):
......
......@@ -276,6 +276,17 @@ if FEATURES.get('AUTH_USE_CAS'):
HOSTNAME_MODULESTORE_DEFAULT_MAPPINGS = ENV_TOKENS.get('HOSTNAME_MODULESTORE_DEFAULT_MAPPINGS',{})
############# CORS headers for cross-domain requests #################
if FEATURES.get('ENABLE_CORS_HEADERS'):
INSTALLED_APPS += ('corsheaders', 'cors_csrf')
MIDDLEWARE_CLASSES = (
'corsheaders.middleware.CorsMiddleware',
'cors_csrf.middleware.CorsCSRFMiddleware',
) + MIDDLEWARE_CLASSES
CORS_ALLOW_CREDENTIALS = True
CORS_ORIGIN_WHITELIST = ENV_TOKENS.get('CORS_ORIGIN_WHITELIST', ())
############################## SECURE AUTH ITEMS ###############
# Secret things: passwords, access keys, etc.
......
......@@ -88,7 +88,7 @@
"SEGMENT_IO_LMS": true,
"SERVER_EMAIL": "devops@example.com",
"SESSION_COOKIE_DOMAIN": null,
"SITE_NAME": "localhost",
"SITE_NAME": "localhost:8003",
"STATIC_ROOT_BASE": "** OVERRIDDEN **",
"STATIC_URL_BASE": "/static/",
"SYSLOG_SERVER": "",
......
......@@ -113,6 +113,9 @@ FEATURES = {
# with Shib. Feature was requested by Stanford's office of general counsel
'SHIB_DISABLE_TOS': False,
# Allows to configure the LMS to provide CORS headers to serve requests from other domains
'ENABLE_CORS_HEADERS': False,
# Can be turned off if course lists need to be hidden. Effects views and templates.
'COURSES_ARE_BROWSABLE': True,
......@@ -1275,6 +1278,17 @@ if FEATURES.get('AUTH_USE_CAS'):
INSTALLED_APPS += ('django_cas',)
MIDDLEWARE_CLASSES += ('django_cas.middleware.CASMiddleware',)
############# CORS headers for cross-domain requests #################
if FEATURES.get('ENABLE_CORS_HEADERS'):
INSTALLED_APPS += ('corsheaders', 'cors_csrf')
MIDDLEWARE_CLASSES = (
'corsheaders.middleware.CorsMiddleware',
'cors_csrf.middleware.CorsCSRFMiddleware',
) + MIDDLEWARE_CLASSES
CORS_ALLOW_CREDENTIALS = True
CORS_ORIGIN_WHITELIST = ()
###################### Registration ##################################
# For each of the fields, give one of the following values:
......
......@@ -4,6 +4,7 @@ Module implementing `xblock.runtime.Runtime` functionality for the LMS
import re
from django.conf import settings
from django.core.urlresolvers import reverse
from user_api import user_service
......@@ -106,10 +107,11 @@ class LmsHandlerUrls(object):
"""
local_resource_url for Studio
"""
return reverse('xblock_resource_url', kwargs={
path = reverse('xblock_resource_url', kwargs={
'block_type': block.scope_ids.block_type,
'uri': uri,
})
return '//{}{}'.format(settings.SITE_NAME, path)
class LmsPartitionService(PartitionService):
......
......@@ -190,6 +190,9 @@ if settings.COURSEWARE_ENABLED:
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/xblock/(?P<usage_id>[^/]*)/handler/(?P<handler>[^/]*)(?:/(?P<suffix>.*))?$',
'courseware.module_render.handle_xblock_callback',
name='xblock_handler'),
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/xblock/(?P<usage_id>[^/]*)/view/(?P<view_name>[^/]*)$',
'courseware.module_render.xblock_view',
name='xblock_view'),
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/xblock/(?P<usage_id>[^/]*)/handler_noauth/(?P<handler>[^/]*)(?:/(?P<suffix>.*))?$',
'courseware.module_render.handle_xblock_callback_noauth',
name='xblock_handler_noauth'),
......
......@@ -91,6 +91,9 @@ sphinx_rtd_theme==0.1.5
Babel==1.3
transifex-client==0.10
# Used to allow to configure CORS headers for cross-domain requests
django-cors-headers==0.12
# We've tried several times to update the debug toolbar to version 1.0.1,
# and had problems each time, resulting in us rolling back to 0.9.4. Before
# submitting another pull request to do this update, check the following:
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment