-
Notifications
You must be signed in to change notification settings - Fork 84
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
[BD-24] [TNL-7318] BB-2355: Add LTI support and Django authentication…
… extension. (#105) * Implement extensions and view support for LTI * Add missing iss to token * Add missing requirement, upgrade requirements * Improving comment * Add missing exception statement * Update lti_consumer/lti_1p3/tests/extensions/rest_framework/test_authentication.py Co-authored-by: Ned Batchelder <[email protected]> Co-authored-by: Ned Batchelder <[email protected]>
- Loading branch information
1 parent
ea1d7dd
commit 955d229
Showing
13 changed files
with
251 additions
and
10 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Empty file.
Empty file.
79 changes: 79 additions & 0 deletions
79
lti_consumer/lti_1p3/extensions/rest_framework/authentication.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,79 @@ | ||
""" | ||
Django REST Framework extensions for LTI 1.3 & LTI Advantage implementation. | ||
Implements a custom authentication class to be used by LTI Advantage extensions. | ||
""" | ||
from django.utils.translation import ugettext as _ | ||
from rest_framework import authentication | ||
from rest_framework import exceptions | ||
|
||
from lti_consumer.models import LtiConfiguration | ||
|
||
|
||
class Lti1p3ApiAuthentication(authentication.BaseAuthentication): | ||
""" | ||
LTI 1.3 Token based authentication. | ||
Clients should authenticate by passing the token key in the "Authorization". | ||
LTI 1.3 expects a token like the following: | ||
Authorization: Bearer jwt-token | ||
Since the base implementation of this library uses JWT tokens, we expect | ||
a RSA256 signed token that contains the allowed scopes. | ||
""" | ||
keyword = 'Bearer' | ||
|
||
def authenticate(self, request): | ||
""" | ||
Authenticate an LTI 1.3 Tool. | ||
This doesn't return a user, but let's the external access and commit | ||
changes. | ||
TODO: Consider creating an user for LTI operations, both to keep track | ||
of changes and to use Django's authorization flow. | ||
""" | ||
auth = request.headers.get('Authorization', '').split() | ||
lti_config_id = request.parser_context['kwargs'].get('lti_config_id') | ||
|
||
# Check if auth token is present on request and is correctly formatted. | ||
if not auth or auth[0].lower() != self.keyword.lower(): | ||
msg = _('Missing LTI 1.3 authentication token.') | ||
raise exceptions.AuthenticationFailed(msg) | ||
|
||
if len(auth) == 1: | ||
msg = _('Invalid token header. No credentials provided.') | ||
raise exceptions.AuthenticationFailed(msg) | ||
|
||
if len(auth) > 2: | ||
msg = _('Invalid token header. Token string should not contain spaces.') | ||
raise exceptions.AuthenticationFailed(msg) | ||
|
||
# Retrieve LTI configuration or fail if it doesn't exist | ||
try: | ||
lti_configuration = LtiConfiguration.objects.get(pk=lti_config_id) | ||
lti_consumer = lti_configuration.get_lti_consumer() | ||
except Exception: | ||
msg = _('LTI configuration not found.') | ||
raise exceptions.AuthenticationFailed(msg) | ||
|
||
# Verify token validity | ||
# This doesn't validate specific permissions, just checks if the token | ||
# is valid or not. | ||
try: | ||
lti_consumer.check_token(auth[1]) | ||
except Exception: | ||
msg = _('Invalid token signature.') | ||
raise exceptions.AuthenticationFailed(msg) | ||
|
||
# Passing parameters back to the view through the request in order | ||
# to avoid implementing a separate authentication backend or | ||
# keeping track of LTI "sessions" through a custom model. | ||
# With the LTI Configuration and consumer attached to the request | ||
# the views and permissions classes can make use of the | ||
# current LTI context to retrieve settings and decode the token passed. | ||
request.lti_configuration = lti_configuration | ||
request.lti_consumer = lti_consumer | ||
|
||
# Return (None, None) since this isn't tied to any authentication | ||
# backend on Django, and it's just used for LTI endpoints. | ||
return (None, None) |
Empty file.
Empty file.
143 changes: 143 additions & 0 deletions
143
lti_consumer/lti_1p3/tests/extensions/rest_framework/test_authentication.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,143 @@ | ||
""" | ||
Unit tests for LTI 1.3 consumer implementation | ||
""" | ||
from __future__ import absolute_import, unicode_literals | ||
|
||
import ddt | ||
from mock import MagicMock, patch | ||
|
||
from Cryptodome.PublicKey import RSA | ||
from django.test.testcases import TestCase | ||
from rest_framework import exceptions | ||
|
||
from lti_consumer.models import LtiConfiguration | ||
from lti_consumer.lti_1p3.consumer import LtiConsumer1p3 | ||
from lti_consumer.lti_1p3.extensions.rest_framework.authentication import Lti1p3ApiAuthentication | ||
|
||
|
||
# Variables required for testing and verification | ||
ISS = "http://test-platform.example/" | ||
OIDC_URL = "http://test-platform/oidc" | ||
LAUNCH_URL = "http://test-platform/launch" | ||
CLIENT_ID = "1" | ||
DEPLOYMENT_ID = "1" | ||
NONCE = "1234" | ||
STATE = "ABCD" | ||
# Consider storing a fixed key | ||
RSA_KEY_ID = "1" | ||
RSA_KEY = RSA.generate(2048).export_key('PEM') | ||
|
||
|
||
@ddt.ddt | ||
class TestLtiAuthentication(TestCase): | ||
""" | ||
Unit tests for Lti1p3ApiAuthentication class | ||
""" | ||
def setUp(self): | ||
super(TestLtiAuthentication, self).setUp() | ||
|
||
# Set up consumer | ||
self.lti_consumer = LtiConsumer1p3( | ||
iss=ISS, | ||
lti_oidc_url=OIDC_URL, | ||
lti_launch_url=LAUNCH_URL, | ||
client_id=CLIENT_ID, | ||
deployment_id=DEPLOYMENT_ID, | ||
rsa_key=RSA_KEY, | ||
rsa_key_id=RSA_KEY_ID, | ||
# Use the same key for testing purposes | ||
tool_key=RSA_KEY, | ||
) | ||
|
||
# Create LTI Configuration | ||
self.lti_configuration = LtiConfiguration.objects.create( | ||
version=LtiConfiguration.LTI_1P3, | ||
) | ||
|
||
# Patch call that retrieves config from modulestore | ||
# We're not testing the model here | ||
self._lti_block_patch = patch( | ||
'lti_consumer.models.LtiConfiguration.get_lti_consumer', | ||
return_value=self.lti_consumer, | ||
) | ||
self.addCleanup(self._lti_block_patch.stop) | ||
self._lti_block_patch.start() | ||
|
||
def _make_request(self): | ||
""" | ||
Returns a Mock Request that can be used to test the LTI auth. | ||
""" | ||
mock_request = MagicMock() | ||
|
||
# Generate a valid access token | ||
token = self.lti_consumer.key_handler.encode_and_sign( | ||
{ | ||
"sub": self.lti_consumer.client_id, | ||
"iss": self.lti_consumer.iss, | ||
"scopes": "", | ||
}, | ||
expiration=3600 | ||
) | ||
mock_request.headers = { | ||
"Authorization": "Bearer {}".format(token), | ||
} | ||
|
||
# Set the lti config id in the "url" | ||
mock_request.parser_context = {"kwargs": { | ||
"lti_config_id": self.lti_configuration.id, | ||
}} | ||
|
||
return mock_request | ||
|
||
@ddt.data( | ||
None, | ||
"", | ||
"Bearer", | ||
"Bearer invalid token", | ||
# Valid token format, but cannot be decoded | ||
"Bearer invalid", | ||
) | ||
def test_invalid_auth_token(self, token): | ||
""" | ||
Test invalid and auth token in auth mechanism. | ||
""" | ||
mock_request = self._make_request() | ||
|
||
# Either set invalid token or clear headers | ||
if token is not None: | ||
mock_request.headers = { | ||
"Authorization": token, | ||
} | ||
else: | ||
mock_request.headers = {} | ||
|
||
with self.assertRaises(exceptions.AuthenticationFailed): | ||
auth = Lti1p3ApiAuthentication() | ||
auth.authenticate(mock_request) | ||
|
||
def test_no_lti_config(self): | ||
""" | ||
Test that the login is invalid if LTI config doesn't exist. | ||
""" | ||
mock_request = self._make_request() | ||
mock_request.parser_context = {"kwargs": { | ||
"lti_config_id": 0, # Django id field is never zero | ||
}} | ||
|
||
with self.assertRaises(exceptions.AuthenticationFailed): | ||
auth = Lti1p3ApiAuthentication() | ||
auth.authenticate(mock_request) | ||
|
||
def test_lti_login_succeeds(self): | ||
""" | ||
Test if login successful and that the LTI Consumer and token | ||
are attached to request. | ||
""" | ||
mock_request = self._make_request() | ||
|
||
# Run auth | ||
auth = Lti1p3ApiAuthentication() | ||
auth.authenticate(mock_request) | ||
|
||
# Check request | ||
self.assertEqual(mock_request.lti_consumer, self.lti_consumer) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -9,4 +9,5 @@ mock | |
django-pyfs | ||
edx_lint | ||
pycodestyle | ||
djangorestframework | ||
xblock-sdk |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters