Repository: apluslms/a-plus

Issue:
A+ as LTI Tool v1.3: properly set up CSRF tokens and do not exempt unnecessarily
The pull request https://github.com/apluslms/a-plus/pull/1104 added the LTI Tool v1.3 feature. In that PR, the CSRF checks are skipped with exemptions. Investigate if it is possible to enable real CSRF checks in the LTI Tool views.


Discussion:
The pull request #1104 added the LTI Tool v1.3 feature. In that PR, the CSRF checks are skipped with exemptions. Investigate if it is possible to enable real CSRF checks in the LTI Tool views.

Resolving patch:
diff --git a/lti_tool/urls.py b/lti_tool/urls.py
--- a/lti_tool/urls.py
+++ b/lti_tool/urls.py
@@ -1,5 +1,4 @@
 from django.conf.urls import url
-from django.views.decorators.csrf import csrf_exempt
 
 from . import views
 from exercise.views import SubmissionPollView
@@ -27,19 +26,17 @@
     url(LTI_SELECT_CONTENT_PREFIX + '$',
         views.LtiSelectContentView.as_view(),
         name="lti-select-content"),
-    # csrf_exempt these for now, as proper csrf handling
-    # for these views proved too difficult so far
     url(LTI_SELECT_CONTENT_PREFIX + INSTANCE_URL_FILL +  '$',
-        csrf_exempt(views.LtiSelectCourseView.as_view()),
+        views.LtiSelectCourseView.as_view(),
         name="lti-select-course"),
     url(LTI_SELECT_CONTENT_PREFIX + MODULE_URL_FILL  + '$',
-        csrf_exempt(views.LtiSelectModuleView.as_view()),
+        views.LtiSelectModuleView.as_view(),
         name="lti-select-module"),
     url(LTI_SELECT_CONTENT_PREFIX + EXERCISE_URL_FILL + '$',
-        csrf_exempt(views.LtiSelectExerciseView.as_view()),
+        views.LtiSelectExerciseView.as_view(),
         name="lti-select-exercise"),
     url(LTI_PREFIX + r'launch/$',
-        csrf_exempt(views.LtiLaunchView.as_view()),
+        views.LtiLaunchView.as_view(),
         name="lti-launch"),
     url(LTI_SUBMISSION_URL_PREFIX + r'$',
         views.LtiSubmissionView.as_view(),
diff --git a/lti_tool/views.py b/lti_tool/views.py
--- a/lti_tool/views.py
+++ b/lti_tool/views.py
@@ -1,4 +1,5 @@
 import logging
+
 from django.http import HttpResponse
 from django.views.decorators.clickjacking import xframe_options_exempt
 from django.views.decorators.csrf import csrf_exempt
@@ -6,6 +7,7 @@
 from django.contrib.auth.models import User
 from django.contrib.auth import login
 from django.urls import reverse
+from django.utils.decorators import method_decorator
 from pylti1p3.contrib.django import DjangoOIDCLogin, DjangoMessageLaunch
 from pylti1p3.deep_link_resource import DeepLinkResource
 from pylti1p3.lineitem import LineItem
@@ -21,9 +23,10 @@
 from authorization.permissions import ACCESS
 from .utils import get_tool_conf, get_launch_data_storage, get_launch_url
 
+
 logger = logging.getLogger('aplus.lti_tool')
 
-# CSRF exempts due to difficulty in proper implementation. To re-review later.
+
 @csrf_exempt
 @xframe_options_exempt
 def lti_login(request):
@@ -35,11 +38,12 @@ def lti_login(request):
     return oidc_login.enable_check_cookies().redirect(target_link_uri)
 
 
+@method_decorator(csrf_exempt, name='dispatch')
+@method_decorator(xframe_options_exempt, name='dispatch')
 class LtiLaunchView(BaseRedirectView):
 
     access_mode = ACCESS.ANONYMOUS
 
-    @xframe_options_exempt
     def post(self, request, *args, **kwargs):
         tool_conf = get_tool_conf()
         launch_data_storage = get_launch_data_storage()
@@ -108,8 +112,7 @@ def post(self, request, *args, **kwargs):
 
 class LtiSessionMixin(BaseMixin):
 
-    @csrf_exempt
-    @xframe_options_exempt
+    @method_decorator(xframe_options_exempt)
     def dispatch(self, *args, **kwargs):
         return super().dispatch(*args, **kwargs)
 
@@ -169,6 +172,15 @@ class LtiExerciseView(LtiSessionMixin, ExerciseView):
     submission_url_name = "lti-submission"
     exercise_url_name = "lti-exercise"
 
+    # The dispatch method needs to be overridden here so that we can apply
+    # different method decorators specifically in this class.
+    # csrf_exempt is needed since it is used in ExerciseView and
+    # the exercise forms do not include any csrf token.
+    @method_decorator(csrf_exempt)
+    @method_decorator(xframe_options_exempt)
+    def dispatch(self, request, *args, **kwargs):
+        return super().dispatch(request, *args, **kwargs)
+
     def post(self, request, *args, **kwargs):
         return super().post(request, *args, **kwargs, redirect_view='lti-submission')
 


Test patch: