Skip to content
Snippets Groups Projects
Unverified Commit 955d2299 authored by Giovanni Cimolin da Silva's avatar Giovanni Cimolin da Silva Committed by GitHub
Browse files

[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: default avatarNed Batchelder <ned@nedbatchelder.com>

Co-authored-by: default avatarNed Batchelder <ned@nedbatchelder.com>
parent ea1d7dd8
No related branches found
No related tags found
No related merge requests found
Showing
with 251 additions and 10 deletions
...@@ -378,6 +378,7 @@ class LtiConsumer1p3: ...@@ -378,6 +378,7 @@ class LtiConsumer1p3:
"access_token": self.key_handler.encode_and_sign( "access_token": self.key_handler.encode_and_sign(
{ {
"sub": self.client_id, "sub": self.client_id,
"iss": self.iss,
"scopes": scopes_str "scopes": scopes_str
}, },
# Create token valid for 3600 seconds (1h) as per specification # Create token valid for 3600 seconds (1h) as per specification
......
...@@ -13,6 +13,10 @@ class TokenSignatureExpired(Lti1p3Exception): ...@@ -13,6 +13,10 @@ class TokenSignatureExpired(Lti1p3Exception):
pass pass
class UnauthorizedToken(Lti1p3Exception):
pass
class NoSuitableKeys(Lti1p3Exception): class NoSuitableKeys(Lti1p3Exception):
pass pass
......
"""
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)
"""
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)
...@@ -5,15 +5,21 @@ URL mappings for LTI Consumer plugin. ...@@ -5,15 +5,21 @@ URL mappings for LTI Consumer plugin.
from __future__ import absolute_import from __future__ import absolute_import
from django.conf import settings from django.conf import settings
from django.conf.urls import url from django.conf.urls import url, include
from .views import ( from rest_framework import routers
from lti_consumer.plugin.views import (
public_keyset_endpoint, public_keyset_endpoint,
launch_gate_endpoint, launch_gate_endpoint,
access_token_endpoint access_token_endpoint
) )
# LTI 1.3 APIs router
router = routers.SimpleRouter(trailing_slash=False)
urlpatterns = [ urlpatterns = [
url( url(
'lti_consumer/v1/public_keysets/{}$'.format(settings.USAGE_ID_PATTERN), 'lti_consumer/v1/public_keysets/{}$'.format(settings.USAGE_ID_PATTERN),
...@@ -29,5 +35,9 @@ urlpatterns = [ ...@@ -29,5 +35,9 @@ urlpatterns = [
'lti_consumer/v1/token/{}$'.format(settings.USAGE_ID_PATTERN), 'lti_consumer/v1/token/{}$'.format(settings.USAGE_ID_PATTERN),
access_token_endpoint, access_token_endpoint,
name='lti_consumer.access_token' name='lti_consumer.access_token'
),
url(
r'lti_consumer/v1/lti/(?P<lti_config_id>[-\w]+)/',
include(router.urls)
) )
] ]
...@@ -21,3 +21,6 @@ zipp<1.2.0 ...@@ -21,3 +21,6 @@ zipp<1.2.0
# Newer versions not available in python 3.5 # Newer versions not available in python 3.5
stevedore<=1.32.0 stevedore<=1.32.0
# Same as in edx-platform
djangorestframework==3.9.4
\ No newline at end of file
...@@ -9,4 +9,5 @@ mock ...@@ -9,4 +9,5 @@ mock
django-pyfs django-pyfs
edx_lint edx_lint
pycodestyle pycodestyle
djangorestframework
xblock-sdk xblock-sdk
...@@ -7,16 +7,17 @@ ...@@ -7,16 +7,17 @@
appdirs==1.4.4 # via -r requirements/base.txt, fs appdirs==1.4.4 # via -r requirements/base.txt, fs
astroid==2.3.3 # via pylint, pylint-celery astroid==2.3.3 # via pylint, pylint-celery
bleach==3.1.5 # via -r requirements/base.txt bleach==3.1.5 # via -r requirements/base.txt
boto3==1.14.53 # via fs-s3fs boto3==1.14.62 # via fs-s3fs
botocore==1.17.53 # via boto3, s3transfer botocore==1.17.62 # via boto3, s3transfer
certifi==2020.6.20 # via -r requirements/base.txt, requests certifi==2020.6.20 # via -r requirements/base.txt, requests
chardet==3.0.4 # via -r requirements/base.txt, requests chardet==3.0.4 # via -r requirements/base.txt, requests
click-log==0.3.2 # via edx-lint click-log==0.3.2 # via edx-lint
click==7.1.2 # via click-log, edx-lint click==7.1.2 # via click-log, edx-lint
coverage==5.2.1 # via coveralls coverage==5.3 # via coveralls
coveralls==2.1.2 # via -r requirements/test.in coveralls==2.1.2 # via -r requirements/test.in
ddt==1.4.1 # via -r requirements/test.in ddt==1.4.1 # via -r requirements/test.in
django-pyfs==2.2 # via -r requirements/test.in django-pyfs==2.2 # via -r requirements/test.in
djangorestframework==3.9.4 # via -c requirements/constraints.txt, -r requirements/test.in
docopt==0.6.2 # via coveralls docopt==0.6.2 # via coveralls
docutils==0.15.2 # via botocore docutils==0.15.2 # via botocore
edx-lint==1.5.2 # via -r requirements/test.in edx-lint==1.5.2 # via -r requirements/test.in
...@@ -56,7 +57,6 @@ six==1.15.0 # via -r requirements/base.txt, astroid, bleach, edx-l ...@@ -56,7 +57,6 @@ six==1.15.0 # via -r requirements/base.txt, astroid, bleach, edx-l
sqlparse==0.3.1 # via -r requirements/base.txt, django sqlparse==0.3.1 # via -r requirements/base.txt, django
stevedore==1.32.0 # via -c requirements/constraints.txt, -r requirements/base.txt, edx-opaque-keys stevedore==1.32.0 # via -c requirements/constraints.txt, -r requirements/base.txt, edx-opaque-keys
typed-ast==1.4.1 # via astroid typed-ast==1.4.1 # via astroid
typing==3.7.4.3 # via -r requirements/base.txt, fs
urllib3==1.25.10 # via -r requirements/base.txt, botocore, requests urllib3==1.25.10 # via -r requirements/base.txt, botocore, requests
web-fragments==0.3.2 # via -r requirements/base.txt, xblock, xblock-utils web-fragments==0.3.2 # via -r requirements/base.txt, xblock, xblock-utils
webencodings==0.5.1 # via -r requirements/base.txt, bleach webencodings==0.5.1 # via -r requirements/base.txt, bleach
......
...@@ -7,18 +7,19 @@ ...@@ -7,18 +7,19 @@
appdirs==1.4.4 # via -r requirements/test.txt, -r requirements/tox.txt, fs, virtualenv appdirs==1.4.4 # via -r requirements/test.txt, -r requirements/tox.txt, fs, virtualenv
astroid==2.3.3 # via -r requirements/test.txt, pylint, pylint-celery astroid==2.3.3 # via -r requirements/test.txt, pylint, pylint-celery
bleach==3.1.5 # via -r requirements/test.txt bleach==3.1.5 # via -r requirements/test.txt
boto3==1.14.53 # via -r requirements/test.txt, fs-s3fs boto3==1.14.62 # via -r requirements/test.txt, fs-s3fs
botocore==1.17.53 # via -r requirements/test.txt, boto3, s3transfer botocore==1.17.62 # via -r requirements/test.txt, boto3, s3transfer
certifi==2020.6.20 # via -r requirements/test.txt, requests certifi==2020.6.20 # via -r requirements/test.txt, requests
chardet==3.0.4 # via -r requirements/test.txt, requests chardet==3.0.4 # via -r requirements/test.txt, requests
click-log==0.3.2 # via -r requirements/test.txt, edx-lint click-log==0.3.2 # via -r requirements/test.txt, edx-lint
click==7.1.2 # via -r requirements/test.txt, click-log, edx-lint click==7.1.2 # via -r requirements/test.txt, click-log, edx-lint
coverage==5.2.1 # via -r requirements/test.txt, coveralls coverage==5.3 # via -r requirements/test.txt, coveralls
coveralls==2.1.2 # via -r requirements/test.txt coveralls==2.1.2 # via -r requirements/test.txt
ddt==1.4.1 # via -r requirements/test.txt ddt==1.4.1 # via -r requirements/test.txt
distlib==0.3.1 # via -r requirements/tox.txt, virtualenv distlib==0.3.1 # via -r requirements/tox.txt, virtualenv
django-pyfs==2.2 # via -r requirements/test.txt django-pyfs==2.2 # via -r requirements/test.txt
django==2.2.16 # via -c requirements/constraints.txt, -r requirements/test.txt, django-pyfs, edx-opaque-keys, xblock-sdk django==2.2.16 # via -c requirements/constraints.txt, -r requirements/test.txt, django-pyfs, edx-opaque-keys, xblock-sdk
djangorestframework==3.9.4 # via -c requirements/constraints.txt, -r requirements/test.txt
docopt==0.6.2 # via -r requirements/test.txt, coveralls docopt==0.6.2 # via -r requirements/test.txt, coveralls
docutils==0.15.2 # via -r requirements/test.txt, botocore docutils==0.15.2 # via -r requirements/test.txt, botocore
edx-lint==1.5.2 # via -r requirements/test.txt edx-lint==1.5.2 # via -r requirements/test.txt
...@@ -65,7 +66,6 @@ stevedore==1.32.0 # via -c requirements/constraints.txt, -r requirements ...@@ -65,7 +66,6 @@ stevedore==1.32.0 # via -c requirements/constraints.txt, -r requirements
toml==0.10.1 # via -r requirements/tox.txt, tox toml==0.10.1 # via -r requirements/tox.txt, tox
tox==3.20.0 # via -r requirements/tox.txt tox==3.20.0 # via -r requirements/tox.txt
typed-ast==1.4.1 # via -r requirements/test.txt, astroid typed-ast==1.4.1 # via -r requirements/test.txt, astroid
typing==3.7.4.3 # via -r requirements/test.txt, fs
urllib3==1.25.10 # via -r requirements/test.txt, botocore, requests urllib3==1.25.10 # via -r requirements/test.txt, botocore, requests
virtualenv==20.0.31 # via -r requirements/tox.txt, tox virtualenv==20.0.31 # via -r requirements/tox.txt, tox
web-fragments==0.3.2 # via -r requirements/test.txt, xblock, xblock-utils web-fragments==0.3.2 # via -r requirements/test.txt, xblock, xblock-utils
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment