├── src ├── __init__.py └── lti_toolbox │ ├── migrations │ ├── __init__.py │ ├── 0002_lticonsumer_url.py │ └── 0001_initial.py │ ├── __init__.py │ ├── apps.py │ ├── admin.py │ ├── factories.py │ ├── exceptions.py │ ├── utils.py │ ├── backend.py │ ├── views.py │ ├── models.py │ ├── validator.py │ ├── launch_params.py │ └── lti.py ├── tests ├── __init__.py └── lti_toolbox │ ├── __init__.py │ ├── test_utils.py │ ├── test_models.py │ ├── test_launch_params.py │ └── test_lti.py ├── sandbox ├── __init__.py ├── manage.py ├── wsgi.py ├── urls.py ├── forms.py ├── templates │ └── demo │ │ ├── consumer.html │ │ └── debug_infos.html ├── views.py └── settings.py ├── MANIFEST.in ├── setup.py ├── bin ├── manage ├── compose ├── pytest └── _config.sh ├── env.d └── development │ ├── common │ └── postgresql ├── .dockerignore ├── .gitignore ├── docker-compose.yml ├── docker └── files │ └── usr │ └── local │ └── bin │ └── entrypoint ├── LICENSE ├── gitlint └── gitlint_emoji.py ├── README.md ├── CHANGELOG.md ├── setup.cfg ├── Dockerfile ├── .gitlint ├── Makefile ├── .circleci └── config.yml └── .pylintrc /src/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /sandbox/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/lti_toolbox/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/lti_toolbox/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.md 3 | recursive-include src/lti_toolbox 4 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | from setuptools import setup 5 | 6 | setup() 7 | -------------------------------------------------------------------------------- /bin/manage: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # shellcheck source=bin/_config.sh 4 | source "$(dirname "${BASH_SOURCE[0]}")/_config.sh" 5 | 6 | _django_manage "$@" 7 | -------------------------------------------------------------------------------- /bin/compose: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # shellcheck source=bin/_config.sh 4 | source "$(dirname "${BASH_SOURCE[0]}")/_config.sh" 5 | 6 | _docker_compose "$@" 7 | -------------------------------------------------------------------------------- /bin/pytest: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | declare DOCKER_USER 4 | DOCKER_USER="$(id -u):$(id -g)" 5 | 6 | DOCKER_USER=${DOCKER_USER} docker compose run --rm django-lti-toolbox pytest "$@" 7 | -------------------------------------------------------------------------------- /env.d/development/common: -------------------------------------------------------------------------------- 1 | # Django 2 | DJANGO_SETTINGS_MODULE=settings 3 | DJANGO_CONFIGURATION=Development 4 | DJANGO_SECRET_KEY=ThisIsAnExampleKeyForDevPurposeOnly 5 | 6 | # Python 7 | PYTHONPATH=/app/sandbox 8 | -------------------------------------------------------------------------------- /src/lti_toolbox/__init__.py: -------------------------------------------------------------------------------- 1 | """lti_toolbox is a django application to handle LTI Provider related 2 | operations, like launch request verification and user authentication.""" 3 | 4 | # pylint: disable=invalid-name 5 | default_app_config = "lti_toolbox.apps.LtiProviderAppConfig" 6 | -------------------------------------------------------------------------------- /env.d/development/postgresql: -------------------------------------------------------------------------------- 1 | # Postgresql db container configuration 2 | POSTGRES_DB=lti 3 | POSTGRES_USER=fun 4 | POSTGRES_PASSWORD=pass 5 | 6 | # App database configuration 7 | DB_HOST=postgresql 8 | DB_NAME=lti 9 | DB_USER=fun 10 | DB_PASSWORD=pass 11 | DB_PORT=5432 12 | -------------------------------------------------------------------------------- /src/lti_toolbox/apps.py: -------------------------------------------------------------------------------- 1 | """lti_toolbox application.""" 2 | from django.apps import AppConfig 3 | from django.utils.translation import gettext_lazy as _ 4 | 5 | 6 | class LtiProviderAppConfig(AppConfig): 7 | """Configuration class for the lti_toolbox app.""" 8 | 9 | verbose_name = _("LTI Toolbox") 10 | name = "lti_toolbox" 11 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Python 2 | __pycache__ 3 | *.pyc 4 | **/__pycache__ 5 | **/*.pyc 6 | venv 7 | .venv 8 | 9 | # System-specific files 10 | .DS_Store 11 | **/.DS_Store 12 | 13 | # Docker 14 | docker-compose.* 15 | env.d 16 | 17 | # Docs 18 | docs 19 | *.md 20 | *.log 21 | 22 | # Development/test cache & configurations 23 | .cache 24 | .circleci 25 | .git 26 | .vscode 27 | db.sqlite3 28 | .mypy_cache 29 | -------------------------------------------------------------------------------- /sandbox/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """ 3 | django-lti-toolbox's sandbox management script. 4 | """ 5 | import os 6 | import sys 7 | 8 | if __name__ == "__main__": 9 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings") 10 | os.environ.setdefault("DJANGO_CONFIGURATION", "Development") 11 | 12 | from configurations.management import execute_from_command_line # noqa 13 | 14 | execute_from_command_line(sys.argv) 15 | -------------------------------------------------------------------------------- /sandbox/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for django-lti-toolbox sandbox project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/2.2/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from configurations.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings") 15 | os.environ.setdefault("DJANGO_CONFIGURATION", "Development") 16 | 17 | application = get_wsgi_application() 18 | -------------------------------------------------------------------------------- /src/lti_toolbox/migrations/0002_lticonsumer_url.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.5 on 2021-01-06 03:45 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("lti_toolbox", "0001_initial"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="lticonsumer", 15 | name="url", 16 | field=models.URLField( 17 | blank=True, 18 | help_text="URL of the LTI consumer website", 19 | max_length=1024, 20 | verbose_name="Consumer site URL", 21 | ), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # Environments 31 | .env 32 | .venv 33 | env/ 34 | venv/ 35 | ENV/ 36 | env.bak/ 37 | venv.bak/ 38 | env.d/development/crowdin 39 | 40 | # Logs 41 | *.log 42 | 43 | # Test & lint 44 | .coverage 45 | .pylint.d 46 | .pytest_cache 47 | db.sqlite3 48 | .mypy_cache 49 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | postgresql: 3 | image: postgres:16 4 | env_file: 5 | - env.d/development/postgresql 6 | ports: 7 | - "15432:5432" 8 | 9 | django-lti-toolbox: 10 | build: 11 | context: . 12 | target: development 13 | args: 14 | DOCKER_USER: ${DOCKER_USER:-1000} 15 | user: ${DOCKER_USER:-1000} 16 | image: django-lti-toolbox:development 17 | environment: 18 | PYLINTHOME: /app/.pylint.d 19 | env_file: 20 | - env.d/development/common 21 | - env.d/development/postgresql 22 | ports: 23 | - "8090:8000" 24 | volumes: 25 | - .:/app 26 | depends_on: 27 | - "postgresql" 28 | 29 | dockerize: 30 | image: jwilder/dockerize 31 | 32 | -------------------------------------------------------------------------------- /src/lti_toolbox/admin.py: -------------------------------------------------------------------------------- 1 | """Admin of the lti_toolbox application.""" 2 | 3 | from django.contrib import admin 4 | 5 | from .models import LTIConsumer, LTIPassport 6 | 7 | 8 | @admin.register(LTIConsumer) 9 | class LTIConsumerAdmin(admin.ModelAdmin): 10 | """Admin class for the LTIConsumer model.""" 11 | 12 | list_display = ( 13 | "slug", 14 | "title", 15 | ) 16 | 17 | 18 | @admin.register(LTIPassport) 19 | class LTIPassportAdmin(admin.ModelAdmin): 20 | """Admin class for the LTIPassport model.""" 21 | 22 | list_display = ( 23 | "title", 24 | "oauth_consumer_key", 25 | "is_enabled", 26 | ) 27 | 28 | readonly_fields = [ 29 | "oauth_consumer_key", 30 | "shared_secret", 31 | ] 32 | -------------------------------------------------------------------------------- /docker/files/usr/local/bin/entrypoint: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # The container user (see USER in the Dockerfile) is an un-privileged user that 4 | # does not exists and is not created during the build phase (see Dockerfile). 5 | # Hence, we use this entrypoint to wrap commands that will be run in the 6 | # container to create an entry for this user in the /etc/passwd file. 7 | # 8 | # The following environment variables may be passed to the container to 9 | # customize running user account: 10 | # 11 | # * USER_NAME: container user name (default: default) 12 | # * HOME : container user home directory (default: none) 13 | 14 | echo "🐳(entrypoint) creating user running in the container..." 15 | if ! whoami > /dev/null 2>&1; then 16 | if [ -w /etc/passwd ]; then 17 | echo "${USER_NAME:-default}:x:$(id -u):$(id -g):${USER_NAME:-default} user:${HOME}:/sbin/nologin" >> /etc/passwd 18 | fi 19 | fi 20 | 21 | echo "🐳(entrypoint) running your command: ${*}" 22 | exec "$@" 23 | -------------------------------------------------------------------------------- /src/lti_toolbox/factories.py: -------------------------------------------------------------------------------- 1 | """Factories for the ``lti_toolbox``.""" 2 | 3 | import factory 4 | from factory.django import DjangoModelFactory 5 | 6 | from . import models 7 | 8 | 9 | class LTIConsumerFactory(DjangoModelFactory): 10 | """Factory to create LTI consumer.""" 11 | 12 | class Meta: 13 | model = models.LTIConsumer 14 | django_get_or_create = ("slug",) 15 | 16 | slug = factory.Sequence(lambda n: f"consumer{n}") 17 | title = factory.Sequence(lambda n: f"Consumer {n}") 18 | url = factory.Sequence(lambda n: f"https://testserver/consumer-{n}") 19 | 20 | 21 | class LTIPassportFactory(DjangoModelFactory): 22 | """Factory to create LTI passport.""" 23 | 24 | class Meta: 25 | model = models.LTIPassport 26 | django_get_or_create = ( 27 | "title", 28 | "consumer", 29 | ) 30 | 31 | title = factory.Sequence(lambda n: f"passport {n}") 32 | 33 | consumer = factory.SubFactory(LTIConsumerFactory) 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020-present GIP FUN MOOC. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/lti_toolbox/exceptions.py: -------------------------------------------------------------------------------- 1 | """Custom exceptions for the lti_toolbox app.""" 2 | 3 | 4 | class LTIException(Exception): 5 | """Custom LTI exception for proper handling of LTI specific errors.""" 6 | 7 | 8 | class LTIRequestNotVerifiedException(LTIException): 9 | """ 10 | Custom LTI exception thrown when someone tries to access 11 | LTI parameters before verifying the request. 12 | """ 13 | 14 | def __init__(self): 15 | message = "You must verify the LTI request with verify() before accessing its parameters" 16 | super().__init__(message) 17 | 18 | 19 | class ParamException(Exception): 20 | """Custom Exception related to LTI param processing.""" 21 | 22 | 23 | class InvalidParamException(ParamException): 24 | """Custom Exception thrown when an invalid parameter is found in an LTI request.""" 25 | 26 | def __init__(self, param): 27 | message = f"{param:s} is not a valid param" 28 | super().__init__(message) 29 | 30 | 31 | class MissingParamException(ParamException): 32 | """ 33 | Custom Exception thrown when a required LTI parameter is missing in 34 | an LTI request. 35 | """ 36 | 37 | def __init__(self, param): 38 | message = f"missing param : {param:s}" 39 | super().__init__(message) 40 | -------------------------------------------------------------------------------- /sandbox/urls.py: -------------------------------------------------------------------------------- 1 | """ 2 | sandbox URLs 3 | """ 4 | from django.contrib import admin 5 | from django.urls import path, re_path, reverse_lazy 6 | from django.views.generic import RedirectView 7 | 8 | from views import LaunchURLWithAuth, SimpleLaunchURLVerification, demo_consumer 9 | 10 | urlpatterns = [ 11 | # / Redirects to the demo consumer 12 | re_path( 13 | r"^$", 14 | RedirectView.as_view(url=reverse_lazy("demo_consumer"), permanent=False), 15 | ), 16 | # Demo LTI consumer 17 | re_path(r"^consumer$", demo_consumer, name="demo_consumer"), 18 | # Simple LTI launch request verification 19 | path( 20 | "lti/launch-verification", 21 | SimpleLaunchURLVerification.as_view(), 22 | name="lti.launch-url-verification", 23 | ), 24 | # LTI launch request handler with authentication 25 | path( 26 | "lti/launch-auth", 27 | LaunchURLWithAuth.as_view(), 28 | name="lti.launch-url-auth", 29 | ), 30 | # Dynamic LTI launch request handler with authentication and a custom parameter (uuid) 31 | path( 32 | "lti/launch/", 33 | LaunchURLWithAuth.as_view(), 34 | name="lti.launch-url-auth-with-params", 35 | ), 36 | # Django admin 37 | path("admin/", admin.site.urls), 38 | ] 39 | -------------------------------------------------------------------------------- /gitlint/gitlint_emoji.py: -------------------------------------------------------------------------------- 1 | """ 2 | Gitlint extra rule to validate that the message title is of the form 3 | "() " 4 | """ 5 | from __future__ import unicode_literals 6 | 7 | import re 8 | 9 | import requests 10 | from gitlint.rules import CommitMessageTitle, LineRule, RuleViolation 11 | 12 | 13 | class GitmojiTitle(LineRule): 14 | """ 15 | This rule will enforce that each commit title is of the form "() " 16 | where gitmoji is an emoji from the list defined in https://gitmoji.carloscuesta.me and 17 | subject should be all lowercase 18 | """ 19 | 20 | id = "UC1" 21 | name = "title-should-have-gitmoji-and-scope" 22 | target = CommitMessageTitle 23 | 24 | def validate(self, title, _commit): 25 | """ 26 | Download the list possible gitmojis from the project's Github repository and check that 27 | title contains one of them. 28 | """ 29 | gitmojis = requests.get( 30 | "https://raw.githubusercontent.com/carloscuesta/gitmoji/master/packages/gitmojis/src/gitmojis.json" # noqa 31 | ).json()["gitmojis"] 32 | emojis = [item["emoji"] for item in gitmojis] 33 | pattern = r"^({:s})\(.*\)\s[a-z].*$".format("|".join(emojis)) 34 | if not re.search(pattern, title): 35 | violation_msg = 'Title does not match regex "() "' 36 | return [RuleViolation(self.id, violation_msg, title)] 37 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Django-lti-toolbox, a Django application to build LTI Tool Providers 2 | 3 | ## Overview 4 | 5 | `django-lti-toolbox` is a django application that makes it easier for you to create [LTI](https://en.wikipedia.org/wiki/Learning_Tools_Interoperability) Tool Providers web applications. 6 | 7 | This is a set of tools that let you manage LTI requests the way you want. 8 | 9 | It is based on top of the great [OAuthLib](https://github.com/oauthlib/oauthlib) library. 10 | 11 | ## Features 12 | 13 | - Verify LTI launch requests 14 | - Base views to build your own LTI launch request handlers 15 | - Sample Django authentication backend 16 | - Manage your LTI consumers from django admin 17 | - Demo project to quickly see it in action 18 | 19 | ## Try it with our demo project ! 20 | 21 | - Clone this repository (`git clone https://github.com/openfun/django-lti-toolbox.git`) 22 | 23 | - `cd django-lti-toolbox` 24 | 25 | - `make bootstrap` to initialize the dev environment 26 | 27 | - `make run` to start the services 28 | 29 | - Go to [http://localhost:8090/](http://localhost:8090/) and try the demo LTI consumer 30 | 31 | - Watch django logs with `make logs` 32 | 33 | ## Contributing 34 | 35 | This project is intended to be community-driven, so please, do not hesitate to 36 | get in touch if you have any question related to our implementation or design 37 | decisions. 38 | 39 | We try to raise our code quality standards and expect contributors to follow 40 | the recommandations from our 41 | [handbook](https://openfun.gitbooks.io/handbook/content). 42 | 43 | ## License 44 | 45 | This work is released under the MIT License (see [LICENSE](./LICENSE)). 46 | -------------------------------------------------------------------------------- /src/lti_toolbox/utils.py: -------------------------------------------------------------------------------- 1 | """This module contains helpers for testing purpose""" 2 | 3 | from urllib.parse import unquote 4 | 5 | from oauthlib import oauth1 6 | 7 | CONTENT_TYPE = "application/x-www-form-urlencoded" 8 | 9 | 10 | def sign_parameters(passport, lti_parameters, url): 11 | """ 12 | 13 | Args: 14 | passport: The LTIPassport to use to sign the oauth request 15 | lti_parameters: A dictionary of parameters to sign 16 | url: The LTI launch URL 17 | 18 | Returns: 19 | dict: The signed parameters 20 | """ 21 | 22 | signed_parameters = lti_parameters.copy() 23 | oauth_client = oauth1.Client( 24 | client_key=passport.oauth_consumer_key, client_secret=passport.shared_secret 25 | ) 26 | # Compute Authorization header which looks like: 27 | # Authorization: OAuth oauth_nonce="80966668944732164491378916897", 28 | # oauth_timestamp="1378916897", oauth_version="1.0", oauth_signature_method="HMAC-SHA1", 29 | # oauth_consumer_key="", oauth_signature="frVp4JuvT1mVXlxktiAUjQ7%2F1cw%3D" 30 | _uri, headers, _body = oauth_client.sign( 31 | url, 32 | http_method="POST", 33 | body=lti_parameters, 34 | headers={"Content-Type": CONTENT_TYPE}, 35 | ) 36 | 37 | # Parse headers to pass to template as part of context: 38 | oauth_dict = dict( 39 | param.strip().replace('"', "").split("=") 40 | for param in headers["Authorization"].split(",") 41 | ) 42 | 43 | signature = oauth_dict["oauth_signature"] 44 | oauth_dict["oauth_signature"] = unquote(signature) 45 | oauth_dict["oauth_nonce"] = oauth_dict.pop("OAuth oauth_nonce") 46 | signed_parameters.update(oauth_dict) 47 | return signed_parameters 48 | -------------------------------------------------------------------------------- /sandbox/forms.py: -------------------------------------------------------------------------------- 1 | """Forms definition for the demo LTI consumer.""" 2 | 3 | from django import forms 4 | from django.utils.translation import gettext_lazy as _ 5 | 6 | from lti_toolbox.models import LTIPassport 7 | 8 | 9 | class PassportChoiceField(forms.ModelChoiceField): 10 | """Select an LTI password""" 11 | 12 | def label_from_instance(self, obj): 13 | return f"{obj.consumer.slug} - {obj.title}" 14 | 15 | 16 | class LTIConsumerForm(forms.Form): 17 | """Form to configure the standalone LTI consumer.""" 18 | 19 | def __init__(self, *args, **kwargs): 20 | super().__init__(*args, **kwargs) 21 | 22 | passport = PassportChoiceField( 23 | queryset=LTIPassport.objects.all(), empty_label=None, label="Consumer" 24 | ) 25 | 26 | user_id = forms.CharField( 27 | label="User ID", 28 | max_length=100, 29 | initial="jojo", 30 | ) 31 | 32 | context_id = forms.CharField( 33 | label="Context ID", 34 | max_length=100, 35 | initial="course-v1:openfun+mathematics101+session01", 36 | ) 37 | 38 | course_title = forms.CharField( 39 | label="Course Title", max_length=100, initial="Mathematics 101" 40 | ) 41 | 42 | role = forms.ChoiceField( 43 | choices=( 44 | ("Student", _("Student")), 45 | ("Instructor", _("Instructor")), 46 | ) 47 | ) 48 | 49 | action = forms.ChoiceField( 50 | choices=( 51 | ("lti.launch-url-verification", "Verify LTI launch request"), 52 | ("lti.launch-url-auth", "Verify + authenticate user"), 53 | ( 54 | "lti.launch-url-auth-with-params", 55 | "Verify + authenticate user + dynamic URL", 56 | ), 57 | ), 58 | initial="simple", 59 | required=True, 60 | ) 61 | 62 | presentation_locale = forms.ChoiceField( 63 | choices=(("fr", "fr"), ("en", "en"), ("", "--none--")), 64 | initial="fr", 65 | label="Locale", 66 | required=False, 67 | ) 68 | -------------------------------------------------------------------------------- /tests/lti_toolbox/test_utils.py: -------------------------------------------------------------------------------- 1 | """Test the utils functions.""" 2 | 3 | from unittest import mock 4 | 5 | from django.test import TestCase 6 | 7 | from lti_toolbox.factories import LTIConsumerFactory 8 | from lti_toolbox.models import LTIPassport 9 | from lti_toolbox.utils import sign_parameters 10 | 11 | 12 | class SignParametersTestCase(TestCase): 13 | """Test the sign_parameters utils function""" 14 | 15 | @mock.patch( 16 | "oauthlib.oauth1.rfc5849.generate_nonce", 17 | return_value="59474787080480293391616018589", 18 | ) 19 | @mock.patch("oauthlib.oauth1.rfc5849.generate_timestamp", return_value="1616018589") 20 | def test_sign_parameters(self, mock_timestamp, mock_nonce): 21 | """Test the oauth 1.0 signature.""" 22 | 23 | consumer = LTIConsumerFactory( 24 | slug="test_lti", title="test consumer", url="http://testserver.com" 25 | ) 26 | 27 | mocked_passport = LTIPassport( 28 | title="test_generate_keys_on_save_p2", 29 | consumer=consumer, 30 | oauth_consumer_key="custom_consumer_key", 31 | shared_secret="random_shared_secret", # noqa: S106 32 | ) 33 | mocked_passport.save() 34 | 35 | parameters = {"test": "your_value"} 36 | 37 | signed_parameters = sign_parameters( 38 | mocked_passport, parameters, "http://testserver.com/" 39 | ) 40 | 41 | self.assertEqual( 42 | mock_timestamp.return_value, signed_parameters.get("oauth_timestamp") 43 | ) 44 | self.assertEqual(mock_nonce.return_value, signed_parameters.get("oauth_nonce")) 45 | self.assertEqual( 46 | "jyv1bLSHm94AFbT4plaehDnDMHE=", signed_parameters.get("oauth_signature") 47 | ) 48 | self.assertEqual("your_value", signed_parameters.get("test")) 49 | 50 | oauth_keys = { 51 | "oauth_consumer_key", 52 | "oauth_signature", 53 | "oauth_timestamp", 54 | "oauth_version", 55 | "oauth_signature_method", 56 | "oauth_nonce", 57 | } 58 | self.assertTrue(all(k in signed_parameters for k in oauth_keys)) 59 | self.assertTrue(all(k in signed_parameters for k in parameters)) 60 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic 7 | Versioning](https://semver.org/spec/v2.0.0.html). 8 | 9 | ## [Unreleased] 10 | 11 | ## [2.0.0] - 2024-07-15 12 | 13 | ### Changed 14 | 15 | - Compute `origin_url` from consumer URL instead of `HTTP_REFERER` 16 | 17 | ## [1.3.0] - 2024-04-25 18 | 19 | ### Added 20 | 21 | - Enhance the LTI class with `origin_url` and `is_moodle_format` properties 22 | 23 | ## [1.2.0] - 2023-09-13 24 | 25 | ### Added 26 | 27 | - Add an utils function sign_parameters used for testing 28 | - Enhance the LTI class with role-checking properties 29 | - Add an LTIRole enum 30 | 31 | ### Fixed 32 | 33 | - Rename ParamsMixins to ParamsMixin 34 | - Add string inheritance to the LTIMessageType enum 35 | 36 | ## [1.1.0] - 2023-08-30 37 | 38 | ### Added 39 | 40 | - Accept Content-Item selection request used in a deep linking context 41 | - Add an LTIMessageType enum 42 | 43 | ### Fixed 44 | 45 | - Use modern python method decorator for all LTI view classes 46 | 47 | ## [1.0.1] - 2022-02-03 48 | 49 | ### Fixed 50 | 51 | - Replace url method by re_path for django 4.0 compatibility 52 | 53 | ## [1.0.0] - 2021-01-13 54 | 55 | ### Added 56 | 57 | - Add an url field to the LTIConsumer model 58 | 59 | ## [1.0.0b1] - 2020-06-11 60 | 61 | ### Added 62 | 63 | - Import `lti_toolbox` library and tests from 64 | [Ashley](https://github.com/openfun/ashley) 65 | 66 | [Unreleased]: https://github.com/openfun/django-lti-toolbox/compare/v2.0.0...master 67 | [2.0.0]: https://github.com/openfun/django-lti-toolbox/compare/v1.3.0...v2.0.0 68 | [1.3.0]: https://github.com/openfun/django-lti-toolbox/compare/v1.2.0...v1.3.0 69 | [1.2.0]: https://github.com/openfun/django-lti-toolbox/compare/v1.1.0...v1.2.0 70 | [1.1.0]: https://github.com/openfun/django-lti-toolbox/compare/v1.0.1...v1.1.0 71 | [1.0.1]: https://github.com/openfun/django-lti-toolbox/compare/v1.0.0...v1.0.1 72 | [1.0.0]: https://github.com/openfun/django-lti-toolbox/compare/v1.0.0b1...v1.0.0 73 | [1.0.0b1]: https://github.com/openfun/django-lti-toolbox/compare/814377082b89abd6c7e47022462aefee2399e53d...v1.0.0b1 74 | -------------------------------------------------------------------------------- /sandbox/templates/demo/consumer.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Demo LTI Consumer 9 | 10 | 12 | 13 | 14 | 15 | 16 |
17 | 18 |
19 | 20 |
21 |
22 | {% load crispy_forms_tags %} 23 | 24 |
25 | {% csrf_token %} 26 | {{ form|crispy }} 27 | 28 |
29 |
30 |
31 | 32 |
33 | 34 | {% if lti_params %} 35 | {% autoescape off %} 36 |
37 | {% for name,value in lti_params.items %} 38 | 39 | {% endfor %} 40 |
41 | {% endautoescape %} 42 | 43 | 55 | 56 | 60 | {% endif %} 61 | 62 | 63 |
64 | 65 |
66 | 67 | 68 | 69 |
70 | 71 | 72 | 73 | 74 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = django-lti-toolbox 3 | version = 2.0.0 4 | description = A Django application to build LTI Tool Providers 5 | long_description = file:README.md 6 | long_description_content_type = text/markdown 7 | author = Open FUN (France Universite Numerique) 8 | author_email = fun.dev@fun-mooc.fr 9 | url = https://github.com/openfun/django-lti-toolbox 10 | license = MIT 11 | keywords = Django, LTI 12 | classifiers = 13 | Development Status :: 5 - Production/Stable 14 | Framework :: Django 15 | Intended Audience :: Developers 16 | License :: OSI Approved :: MIT License 17 | Natural Language :: English 18 | Programming Language :: Python :: 3 19 | Programming Language :: Python :: 3.8 20 | 21 | [options] 22 | include_package_data = True 23 | install_requires = 24 | Django 25 | oauthlib>=3.0.0 26 | package_dir = 27 | =src 28 | packages = find: 29 | zip_safe = True 30 | 31 | [options.extras_require] 32 | dev = 33 | bandit==1.6.2 34 | black==22.3.0 35 | flake8==3.7.9 36 | ipdb==0.12.2 37 | ipython==7.9.0 38 | isort==4.3.21 39 | mypy==0.761 40 | pyfakefs==3.7.1 41 | pylint-django==2.0.13 42 | pylint==2.4.4 43 | pytest-cov==2.8.1 44 | pytest-django==4.5.2 45 | pytest==7.4.0 46 | ci = 47 | twine==2.0.0 48 | sandbox = 49 | Django==4.2.4 50 | django-configurations==2.4.1 51 | factory_boy==2.12.0 52 | psycopg2-binary==2.8.4 53 | django-crispy-forms==1.9.1 54 | 55 | [options.packages.find] 56 | where = src 57 | 58 | [wheel] 59 | universal = 1 60 | 61 | ;; 62 | ;; Third-party packages configuration 63 | ;; 64 | [flake8] 65 | max-line-length = 99 66 | exclude = 67 | .git, 68 | .venv, 69 | build, 70 | venv, 71 | __pycache__, 72 | node_modules, 73 | */migrations/* 74 | 75 | [isort] 76 | known_ltitoolbox=lti_toolbox,sandbox 77 | include_trailing_comma=True 78 | line_length=88 79 | multi_line_output=3 80 | sections=FUTURE,STDLIB,THIRDPARTY,LTITOOLBOX,FIRSTPARTY,LOCALFOLDER 81 | skip_glob=venv,gitlint 82 | 83 | [tool:pytest] 84 | addopts = -v --cov-report term-missing 85 | python_files = 86 | test_*.py 87 | tests.py 88 | testpaths = 89 | tests 90 | 91 | [mypy] 92 | ignore_missing_imports = True 93 | 94 | [mypy-*.migrations.*] 95 | # Django migrations should not be type checked 96 | ignore_errors = True 97 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # -- Base image -- 2 | FROM python:3.8-slim as base 3 | 4 | # Upgrade pip to its latest release to speed up dependencies installation 5 | RUN pip install --upgrade pip 6 | 7 | # ---- Back-end builder image ---- 8 | FROM base as back-builder 9 | 10 | WORKDIR /builder 11 | 12 | # Copy required python dependencies 13 | COPY setup.py setup.cfg MANIFEST.in /builder/ 14 | COPY ./src/lti_toolbox /builder/src/lti_toolbox/ 15 | 16 | RUN mkdir /install && \ 17 | pip install --prefix=/install .[sandbox] 18 | 19 | # ---- Core application image ---- 20 | FROM base as core 21 | 22 | # Install gettext 23 | RUN apt-get update && \ 24 | apt-get install -y \ 25 | gettext && \ 26 | rm -rf /var/lib/apt/lists/* 27 | 28 | # Copy installed python dependencies 29 | COPY --from=back-builder /install /usr/local 30 | 31 | # Copy runtime-required files 32 | COPY ./sandbox /app/sandbox 33 | COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint 34 | 35 | # Give the "root" group the same permissions as the "root" user on /etc/passwd 36 | # to allow a user belonging to the root group to add new users; typically the 37 | # docker user (see entrypoint). 38 | RUN chmod g=u /etc/passwd 39 | 40 | # Un-privileged user running the application 41 | ARG DOCKER_USER 42 | USER ${DOCKER_USER} 43 | 44 | # We wrap commands run in this container by the following entrypoint that 45 | # creates a user on-the-fly with the container user ID (see USER) and root group 46 | # ID. 47 | ENTRYPOINT [ "/usr/local/bin/entrypoint" ] 48 | 49 | # ---- Development image ---- 50 | FROM core as development 51 | 52 | ENV PYTHONUNBUFFERED=1 53 | 54 | # Switch back to the root user to install development dependencies 55 | USER root:root 56 | 57 | WORKDIR /app 58 | 59 | # Copy all sources, not only runtime-required files 60 | COPY . /app/ 61 | 62 | # Uninstall lti_toolbox and re-install it in editable mode along with development 63 | # dependencies 64 | RUN pip uninstall -y django-lti-toolbox 65 | RUN pip install -e .[dev] 66 | 67 | # Restore the un-privileged user running the application 68 | ARG DOCKER_USER 69 | USER ${DOCKER_USER} 70 | 71 | # Target database host (e.g. database engine following docker-compose services 72 | # name) & port 73 | ENV DB_HOST=postgresql \ 74 | DB_PORT=5432 75 | 76 | # Run django development server 77 | CMD cd sandbox && \ 78 | python manage.py runserver 0.0.0.0:8000 79 | -------------------------------------------------------------------------------- /bin/_config.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -eo pipefail 4 | 5 | REPO_DIR="$(cd "$( dirname "${BASH_SOURCE[0]}" )/.." && pwd)" 6 | UNSET_USER=0 7 | COMPOSE_PROJECT="django-lti-toolbox" 8 | 9 | COMPOSE_FILE="${REPO_DIR}/docker-compose.yml" 10 | 11 | # _set_user: set (or unset) default user id used to run docker commands 12 | # 13 | # usage: _set_user 14 | # 15 | # You can override default user ID (the current host user ID), by defining the 16 | # USER_ID environment variable. 17 | # 18 | # To avoid running docker commands with a custom user, please set the 19 | # $UNSET_USER environment variable to 1. 20 | function _set_user() { 21 | 22 | if [ $UNSET_USER -eq 1 ]; then 23 | USER_ID="" 24 | return 25 | fi 26 | 27 | # USER_ID = USER_ID or `id -u` if USER_ID is not set 28 | USER_ID=${USER_ID:-$(id -u)} 29 | 30 | echo "🙋(user) ID: ${USER_ID}" 31 | } 32 | 33 | # docker_compose: wrap docker-compose command 34 | # 35 | # usage: docker_compose [options] [ARGS...] 36 | # 37 | # options: docker-compose command options 38 | # ARGS : docker-compose command arguments 39 | function _docker_compose() { 40 | 41 | echo "🐳(compose) project: '${COMPOSE_PROJECT}' file: '${COMPOSE_FILE}'" 42 | docker-compose \ 43 | -p "${COMPOSE_PROJECT}" \ 44 | -f "${COMPOSE_FILE}" \ 45 | --project-directory "${REPO_DIR}" \ 46 | "$@" 47 | } 48 | 49 | # _dc_run: wrap docker-compose run command 50 | # 51 | # usage: _dc_run [options] [ARGS...] 52 | # 53 | # options: docker-compose run command options 54 | # ARGS : docker-compose run command arguments 55 | function _dc_run() { 56 | _set_user 57 | 58 | user_args="--user=$USER_ID" 59 | if [ -z $USER_ID ]; then 60 | user_args="" 61 | fi 62 | 63 | _docker_compose run --rm $user_args "$@" 64 | } 65 | 66 | # _dc_exec: wrap docker-compose exec command 67 | # 68 | # usage: _dc_exec [options] [ARGS...] 69 | # 70 | # options: docker-compose exec command options 71 | # ARGS : docker-compose exec command arguments 72 | function _dc_exec() { 73 | _set_user 74 | 75 | echo "🐳(compose) exec command: '\$@'" 76 | 77 | user_args="--user=$USER_ID" 78 | if [ -z $USER_ID ]; then 79 | user_args="" 80 | fi 81 | 82 | _docker_compose exec $user_args "$@" 83 | } 84 | 85 | # _django_manage: wrap django's manage.py command with docker-compose 86 | # 87 | # usage : _django_manage [ARGS...] 88 | # 89 | # ARGS : django's manage.py command arguments 90 | function _django_manage() { 91 | _dc_run -w /app/sandbox "django-lti-toolbox" python manage.py "$@" 92 | } 93 | -------------------------------------------------------------------------------- /src/lti_toolbox/backend.py: -------------------------------------------------------------------------------- 1 | """This module contains an authentication backend based on LTI launch request.""" 2 | 3 | import logging 4 | from typing import Any 5 | 6 | from django.contrib.auth import get_user_model 7 | from django.contrib.auth.backends import ModelBackend 8 | from django.core.exceptions import PermissionDenied 9 | 10 | from lti_toolbox.lti import LTI 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | USER_MODEL = get_user_model() 15 | 16 | 17 | class LTIBackend(ModelBackend): 18 | """ 19 | Authentication backend used by the lti_toolbox.views.BaseLTIAuthView 20 | It authenticates a user from a verified LTI request and creates a User if necessary. 21 | 22 | You are encouraged to make your own authentication backend to add your own domain logic. 23 | """ 24 | 25 | def authenticate(self, request, username=None, password=None, **kwargs): 26 | """ 27 | Authenticate a user from an LTI request 28 | 29 | Args: 30 | request: django http request 31 | username: The (optional) username to authenticate 32 | password: The (optional) password of the user to authenticate 33 | kwargs: additional parameters 34 | 35 | Returns: 36 | An authenticated user or None 37 | """ 38 | 39 | lti_request = kwargs.get("lti_request") 40 | if not lti_request: 41 | return None 42 | 43 | if not lti_request.is_valid: 44 | raise PermissionDenied() 45 | 46 | username = self._get_mandatory_param(lti_request, "user_id") 47 | email = self._get_mandatory_param( 48 | lti_request, "lis_person_contact_email_primary" 49 | ) 50 | 51 | logger.debug("User %s authenticated from LTI request", username) 52 | 53 | try: 54 | user = USER_MODEL.objects.get_by_natural_key(username) 55 | except USER_MODEL.DoesNotExist: 56 | user = USER_MODEL.objects.create_user(username, email=email) 57 | logger.debug("User %s created in database", username) 58 | if not user.is_active: 59 | logger.debug("User %s is not active", user.username) 60 | raise PermissionDenied() 61 | return user 62 | 63 | @staticmethod 64 | def _get_mandatory_param(lti_request: LTI, param: str) -> Any: 65 | """Get an LTI parameter or throw an exception if not defined." 66 | 67 | Args: 68 | lti_request: The verified LTI request 69 | param: A LTI parameter name 70 | 71 | Returns: The parameter value 72 | 73 | Raises: 74 | PermissionDenied if the parameter is not defined 75 | """ 76 | value = lti_request.get_param(param) 77 | if not value: 78 | logger.debug("Unable to find param %s in LTI request", param) 79 | raise PermissionDenied() 80 | logger.debug("%s = %s", param, value) 81 | return value 82 | -------------------------------------------------------------------------------- /sandbox/templates/demo/debug_infos.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 8 | 9 |
10 | 11 |
12 | {{ message }} 13 |
14 | 15 | 16 |

Debug informations

17 | 18 |
19 |
Launch request URL : {{ request.path }}
20 |
21 | 22 | {% if debug_infos %} 23 | {% for section_title, section_items in debug_infos.items %} 24 | {% if section_items %} 25 |
26 |
{{ section_title }}
27 | 28 | 29 | 30 | {% for param_name, param_value in section_items.items %} 31 | 32 | 33 | 34 | 35 | {% endfor %} 36 | 37 |
{{ param_name }}{{ param_value }}
38 |
39 | {% endif %} 40 | {% endfor %} 41 | {% endif %} 42 | 43 | {% if request.POST %} 44 |
45 |
POST data
46 | 47 | 48 | 49 | {% for param_name, param_value in request.POST.items %} 50 | 51 | 52 | 53 | 54 | {% endfor %} 55 | 56 |
{{ param_name }}{{ param_value }}
57 |
58 | {% endif %} 59 | 60 |
61 | 62 |
63 |

Try me !

64 |

You want to test a replay attack ? Try to re-execute the same LTI request.

65 |
66 | 67 | 68 | 69 | 70 | Reload 71 |
72 |
73 | 74 | 75 | 76 |
77 | 78 | 79 | 80 | 81 | -------------------------------------------------------------------------------- /.gitlint: -------------------------------------------------------------------------------- 1 | # All these sections are optional, edit this file as you like. 2 | [general] 3 | # Ignore certain rules, you can reference them by their id or by their full name 4 | # ignore=title-trailing-punctuation, T3 5 | 6 | # verbosity should be a value between 1 and 3, the commandline -v flags take precedence over this 7 | # verbosity = 2 8 | 9 | # By default gitlint will ignore merge commits. Set to 'false' to disable. 10 | # ignore-merge-commits=true 11 | 12 | # By default gitlint will ignore fixup commits. Set to 'false' to disable. 13 | # ignore-fixup-commits=true 14 | 15 | # By default gitlint will ignore squash commits. Set to 'false' to disable. 16 | # ignore-squash-commits=true 17 | 18 | # Enable debug mode (prints more output). Disabled by default. 19 | # debug=true 20 | 21 | # Set the extra-path where gitlint will search for user defined rules 22 | # See http://jorisroovers.github.io/gitlint/user_defined_rules for details 23 | extra-path=gitlint/ 24 | 25 | # [title-max-length] 26 | # line-length=80 27 | 28 | [title-must-not-contain-word] 29 | # Comma-separated list of words that should not occur in the title. Matching is case 30 | # insensitive. It's fine if the keyword occurs as part of a larger word (so "WIPING" 31 | # will not cause a violation, but "WIP: my title" will. 32 | words=wip 33 | 34 | #[title-match-regex] 35 | # python like regex (https://docs.python.org/2/library/re.html) that the 36 | # commit-msg title must be matched to. 37 | # Note that the regex can contradict with other rules if not used correctly 38 | # (e.g. title-must-not-contain-word). 39 | #regex= 40 | 41 | # [B1] 42 | # B1 = body-max-line-length 43 | # line-length=120 44 | # [body-min-length] 45 | # min-length=5 46 | 47 | # [body-is-missing] 48 | # Whether to ignore this rule on merge commits (which typically only have a title) 49 | # default = True 50 | # ignore-merge-commits=false 51 | 52 | # [body-changed-file-mention] 53 | # List of files that need to be explicitly mentioned in the body when they are changed 54 | # This is useful for when developers often erroneously edit certain files or git submodules. 55 | # By specifying this rule, developers can only change the file when they explicitly reference 56 | # it in the commit message. 57 | # files=gitlint/rules.py,README.md 58 | 59 | # [author-valid-email] 60 | # python like regex (https://docs.python.org/2/library/re.html) that the 61 | # commit author email address should be matched to 62 | # For example, use the following regex if you only want to allow email addresses from foo.com 63 | # regex=[^@]+@foo.com 64 | 65 | [ignore-by-title] 66 | # Allow empty body & wrong title pattern only when bots (pyup/greenkeeper) 67 | # upgrade dependencies 68 | regex=^(⬆️.*|Update (.*) from (.*) to (.*)|(chore|fix)\(package\): update .*)$ 69 | ignore=B6,UC1 70 | 71 | # [ignore-by-body] 72 | # Ignore certain rules for commits of which the body has a line that matches a regex 73 | # E.g. Match bodies that have a line that that contain "release" 74 | # regex=(.*)release(.*) 75 | # 76 | # Ignore certain rules, you can reference them by their id or by their full name 77 | # Use 'all' to ignore all rules 78 | # ignore=T1,body-min-length 79 | -------------------------------------------------------------------------------- /src/lti_toolbox/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.10 on 2020-02-26 18:25 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | initial = True 10 | 11 | dependencies = [] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name="LTIConsumer", 16 | fields=[ 17 | ( 18 | "slug", 19 | models.SlugField( 20 | help_text="identifier for the consumer site", 21 | primary_key=True, 22 | serialize=False, 23 | verbose_name="consumer site identifier", 24 | ), 25 | ), 26 | ( 27 | "title", 28 | models.CharField( 29 | help_text="human readable title, to describe the LTI consumer", 30 | max_length=255, 31 | verbose_name="Title", 32 | ), 33 | ), 34 | ], 35 | options={ 36 | "verbose_name": "LTI consumer", 37 | "verbose_name_plural": "LTI consumers", 38 | "db_table": "lti_consumer", 39 | }, 40 | ), 41 | migrations.CreateModel( 42 | name="LTIPassport", 43 | fields=[ 44 | ( 45 | "id", 46 | models.AutoField( 47 | auto_created=True, 48 | primary_key=True, 49 | serialize=False, 50 | verbose_name="ID", 51 | ), 52 | ), 53 | ( 54 | "title", 55 | models.CharField( 56 | help_text="human readable title, to describe this LTI passport (i.e. : who will use it ?)", 57 | max_length=255, 58 | verbose_name="Title", 59 | ), 60 | ), 61 | ( 62 | "oauth_consumer_key", 63 | models.CharField( 64 | editable=False, 65 | help_text="oauth consumer key to authenticate an LTI consumer on the LTI provider", 66 | max_length=255, 67 | unique=True, 68 | verbose_name="oauth consumer key", 69 | ), 70 | ), 71 | ( 72 | "shared_secret", 73 | models.CharField( 74 | editable=False, 75 | help_text="LTI Shared secret", 76 | max_length=255, 77 | verbose_name="shared secret", 78 | ), 79 | ), 80 | ( 81 | "is_enabled", 82 | models.BooleanField( 83 | default=True, 84 | help_text="whether the passport is enabled", 85 | verbose_name="is enabled", 86 | ), 87 | ), 88 | ( 89 | "consumer", 90 | models.ForeignKey( 91 | on_delete=django.db.models.deletion.CASCADE, 92 | to="lti_toolbox.LTIConsumer", 93 | ), 94 | ), 95 | ], 96 | options={ 97 | "verbose_name": "LTI passport", 98 | "verbose_name_plural": "LTI passports", 99 | "db_table": "lti_passport", 100 | }, 101 | ), 102 | ] 103 | -------------------------------------------------------------------------------- /src/lti_toolbox/views.py: -------------------------------------------------------------------------------- 1 | """Views of the lti_toolbox django application.""" 2 | from abc import ABC, abstractmethod 3 | 4 | from django.contrib.auth import authenticate, login 5 | from django.http import HttpRequest, HttpResponse, HttpResponseForbidden 6 | from django.utils.decorators import method_decorator 7 | from django.views import View 8 | from django.views.decorators.csrf import csrf_exempt 9 | 10 | from lti_toolbox.exceptions import LTIException 11 | 12 | from .lti import LTI 13 | 14 | 15 | @method_decorator(csrf_exempt, name="dispatch") 16 | class BaseLTIView(ABC, View): 17 | """ 18 | Abstract view for handling views from an LTI request. 19 | 20 | This class verifies the authenticity of the incoming LTI requests. 21 | Subclasses must implement the _do_on_success() method to define custom 22 | processing of successful LTI requests. 23 | """ 24 | 25 | def post(self, request, *args, **kwargs) -> HttpResponse: # pylint: disable=W0613 26 | """Handler for POST requests.""" 27 | lti_request = LTI(request) 28 | try: 29 | lti_request.verify() 30 | return self._do_on_success(lti_request, *args, **kwargs) 31 | except LTIException as error: 32 | return self._do_on_failure(request, error) 33 | 34 | @abstractmethod 35 | def _do_on_success(self, lti_request: LTI, *args, **kwargs) -> HttpResponse: 36 | """Process the request when the LTI requests is verified.""" 37 | raise NotImplementedError() 38 | 39 | def _do_on_failure( # pylint: disable=R0201 40 | self, request: HttpRequest, error: LTIException # pylint: disable=W0613 41 | ) -> HttpResponse: 42 | """ 43 | Default handler for invalid LTI requests. 44 | You are encouraged to define your own handler according to your project needs. 45 | """ 46 | return HttpResponseForbidden("Invalid LTI request") 47 | 48 | 49 | @method_decorator(csrf_exempt, name="dispatch") 50 | class BaseLTIAuthView(ABC, View): 51 | """ 52 | Abstract view for handling authenticated views from an LTI request. 53 | 54 | This class verifies the authenticity of the incoming LTI requests, 55 | and performs user authentication. 56 | Subclasses must implement the _do_on_login() method to define custom 57 | processing of authenticated users via LTI. 58 | """ 59 | 60 | def post(self, request, *args, **kwargs) -> HttpResponse: # pylint: disable=W0613 61 | """Handler for POST requests.""" 62 | lti_request = LTI(request) 63 | try: 64 | lti_request.verify() 65 | user = authenticate(request, lti_request=lti_request) 66 | if user is not None: 67 | login(request, user) 68 | return self._do_on_login(lti_request) 69 | return self._do_on_authentication_failure(lti_request) 70 | except LTIException as error: 71 | return self._do_on_verification_failure(request, error) 72 | 73 | @abstractmethod 74 | def _do_on_login(self, lti_request: LTI) -> HttpResponse: 75 | """Process the request when the user is logged in via LTI""" 76 | raise NotImplementedError() 77 | 78 | def _do_on_authentication_failure( # pylint: disable=R0201 79 | self, lti_request: LTI # pylint: disable=W0613 80 | ) -> HttpResponse: 81 | """ 82 | Default handler for failed authentication. 83 | You are encouraged to define your own handler according to your project needs. 84 | """ 85 | return HttpResponseForbidden() 86 | 87 | def _do_on_verification_failure( # pylint: disable=R0201 88 | self, request: HttpRequest, error: LTIException # pylint: disable=W0613 89 | ) -> HttpResponse: 90 | """ 91 | Default handler for invalid LTI requests. 92 | You are encouraged to define your own handler according to your project needs. 93 | """ 94 | return HttpResponseForbidden("Invalid LTI request") 95 | -------------------------------------------------------------------------------- /tests/lti_toolbox/test_models.py: -------------------------------------------------------------------------------- 1 | """Test the lti_toolbox models""" 2 | from django.core.exceptions import ValidationError 3 | from django.test import TestCase 4 | 5 | from lti_toolbox.factories import LTIConsumerFactory 6 | from lti_toolbox.models import LTIPassport 7 | 8 | 9 | class LTIPassportTestCase(TestCase): 10 | """Test the LTIPassport class.""" 11 | 12 | def test_generate_consumer_key(self): 13 | """Basic testing of entropy in the consumer key generator.""" 14 | generated_keys = set() 15 | # basic testing for entropy 16 | for _ in range(1, 100): 17 | consumer_key = LTIPassport.generate_consumer_key() 18 | self.assertFalse(consumer_key in generated_keys) 19 | self.assertGreaterEqual(len(consumer_key), 20) 20 | generated_keys.add(consumer_key) 21 | 22 | def test_generate_secret(self): 23 | """Basic testing of entropy in the shared secret generator.""" 24 | generated_secret = set() 25 | for _ in range(1, 100): 26 | secret = LTIPassport.generate_shared_secret() 27 | self.assertFalse(secret in generated_secret) 28 | self.assertGreaterEqual(len(secret), 40) 29 | generated_secret.add(secret) 30 | 31 | def test_generate_keys_on_save(self): 32 | """Ensure that a shared secret and a consumer key are generated on save() if not defined""" 33 | consumer = LTIConsumerFactory(slug="test_generate_keys_on_save") 34 | passport = LTIPassport(title="test_generate_keys_on_save_p1", consumer=consumer) 35 | self.assertEqual("", passport.shared_secret) 36 | self.assertEqual("", passport.oauth_consumer_key) 37 | passport.save() 38 | self.assertNotEqual("", passport.shared_secret) 39 | self.assertGreaterEqual(len(passport.oauth_consumer_key), 20) 40 | self.assertGreaterEqual(len(passport.shared_secret), 40) 41 | self.assertNotEqual("", passport.oauth_consumer_key) 42 | 43 | passport2 = LTIPassport( 44 | title="test_generate_keys_on_save_p2", 45 | consumer=consumer, 46 | oauth_consumer_key="custom_consumer_key", 47 | ) 48 | self.assertEqual("", passport2.shared_secret) 49 | passport2.save() 50 | self.assertNotEqual("", passport2.shared_secret) 51 | self.assertEqual("custom_consumer_key", passport2.oauth_consumer_key) 52 | self.assertGreaterEqual(len(passport.shared_secret), 40) 53 | 54 | passport3 = LTIPassport( 55 | title="test_generate_keys_on_save_p3", 56 | consumer=consumer, 57 | shared_secret="custom_secret", 58 | ) 59 | self.assertEqual("", passport3.oauth_consumer_key) 60 | passport3.save() 61 | self.assertNotEqual("", passport3.oauth_consumer_key) 62 | self.assertEqual("custom_secret", passport3.shared_secret) 63 | self.assertGreaterEqual(len(passport.oauth_consumer_key), 20) 64 | 65 | passport4 = LTIPassport( 66 | title="test_generate_keys_on_save_p4", 67 | consumer=consumer, 68 | oauth_consumer_key="consumer_key", 69 | shared_secret="custom_secret", 70 | ) 71 | passport4.save() 72 | self.assertEqual("consumer_key", passport4.oauth_consumer_key) 73 | self.assertEqual("custom_secret", passport4.shared_secret) 74 | 75 | def test_optional_url(self): 76 | """ 77 | The url field of the model is optional and should be validated 78 | by the URLValidator. 79 | """ 80 | consumer1 = LTIConsumerFactory(url="") 81 | consumer1.full_clean() 82 | consumer1.save() 83 | self.assertEqual("", consumer1.url) 84 | 85 | consumer2 = LTIConsumerFactory(url="https://www.example.com/test") 86 | consumer2.full_clean() 87 | consumer2.save() 88 | self.assertEqual("https://www.example.com/test", consumer2.url) 89 | 90 | consumer3 = LTIConsumerFactory(url="xx:/invalid-url/") 91 | with self.assertRaises(ValidationError): 92 | consumer3.full_clean() 93 | consumer3.save() 94 | -------------------------------------------------------------------------------- /src/lti_toolbox/models.py: -------------------------------------------------------------------------------- 1 | """Declare the models related to lti provider.""" 2 | 3 | import secrets 4 | import string 5 | 6 | from django.db import models 7 | from django.utils.translation import gettext_lazy as _ 8 | 9 | 10 | class LTIConsumer(models.Model): 11 | """ 12 | Model representing an LTI Consumer. 13 | """ 14 | 15 | slug = models.SlugField( 16 | verbose_name=_("consumer site identifier"), 17 | primary_key=True, 18 | help_text=_("identifier for the consumer site"), 19 | ) 20 | 21 | title = models.CharField( 22 | max_length=255, 23 | verbose_name=_("Title"), 24 | help_text=_("human readable title, to describe the LTI consumer"), 25 | blank=False, 26 | ) 27 | 28 | url = models.URLField( 29 | max_length=1024, 30 | verbose_name=_("Consumer site URL"), 31 | help_text=_("URL of the LTI consumer website"), 32 | blank=True, 33 | ) 34 | 35 | class Meta: 36 | """Options for the ``LTIConsumer`` model.""" 37 | 38 | db_table = "lti_consumer" 39 | verbose_name = _("LTI consumer") 40 | verbose_name_plural = _("LTI consumers") 41 | 42 | def __str__(self): 43 | """Get the string representation of an instance.""" 44 | return self.title 45 | 46 | 47 | class LTIPassport(models.Model): 48 | """ 49 | Model representing an LTI passport for LTI consumers to interact with the django application. 50 | 51 | An LTI consumer can have multiple passports. 52 | 53 | An LTI passport stores credentials that can be used by an LTI consumer to interact with 54 | the django application acting as an LTI provider. 55 | """ 56 | 57 | consumer = models.ForeignKey(LTIConsumer, on_delete=models.CASCADE) 58 | 59 | title = models.CharField( 60 | max_length=255, 61 | verbose_name=_("Title"), 62 | help_text=_( 63 | "human readable title, to describe this LTI passport (i.e. : who will use it ?)" 64 | ), 65 | blank=False, 66 | ) 67 | 68 | oauth_consumer_key = models.CharField( 69 | max_length=255, 70 | verbose_name=_("oauth consumer key"), 71 | unique=True, 72 | help_text=_( 73 | "oauth consumer key to authenticate an LTI consumer on the LTI provider" 74 | ), 75 | editable=False, 76 | ) 77 | shared_secret = models.CharField( 78 | max_length=255, 79 | verbose_name=_("shared secret"), 80 | help_text=_("LTI Shared secret"), 81 | editable=False, 82 | ) 83 | is_enabled = models.BooleanField( 84 | verbose_name=_("is enabled"), 85 | help_text=_("whether the passport is enabled"), 86 | default=True, 87 | ) 88 | 89 | class Meta: 90 | """Options for the ``LTIPassport`` model.""" 91 | 92 | db_table = "lti_passport" 93 | verbose_name = _("LTI passport") 94 | verbose_name_plural = _("LTI passports") 95 | 96 | def __str__(self): 97 | """Get the string representation of an instance.""" 98 | return self.title 99 | 100 | # pylint: disable=arguments-differ 101 | def save(self, *args, **kwargs): 102 | """Generate the oauth consumer key and shared secret randomly upon creation. 103 | 104 | Parameters 105 | ---------- 106 | Args: 107 | args (list) Passed onto parent's `save` method 108 | dict (kwargs) : Passed onto parent's `save` method 109 | 110 | """ 111 | self.full_clean() 112 | if not self.oauth_consumer_key: 113 | self.oauth_consumer_key = self.generate_consumer_key() 114 | if not self.shared_secret: 115 | self.shared_secret = self.generate_shared_secret() 116 | super().save(*args, **kwargs) 117 | 118 | @staticmethod 119 | def generate_consumer_key() -> str: 120 | """Generate a random consumer key.""" 121 | oauth_consumer_key_chars = string.ascii_uppercase + string.digits 122 | oauth_consumer_key_size = secrets.choice(range(20, 30)) 123 | return "".join( 124 | secrets.choice(oauth_consumer_key_chars) 125 | for _ in range(oauth_consumer_key_size) 126 | ) 127 | 128 | @staticmethod 129 | def generate_shared_secret() -> str: 130 | """Generate a random consumer key.""" 131 | secret_chars = string.ascii_letters + string.digits + "!#$%&*+-=?@^_" 132 | secret_size = secrets.choice(range(40, 60)) 133 | return "".join(secrets.choice(secret_chars) for _ in range(secret_size)) 134 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # /!\ /!\ /!\ /!\ /!\ /!\ /!\ DISCLAIMER /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ 2 | # 3 | # This Makefile is only meant to be used for DEVELOPMENT purpose as we are 4 | # changing the user id that will run in the container. 5 | # 6 | # PLEASE DO NOT USE IT FOR YOUR CI/PRODUCTION/WHATEVER... 7 | # 8 | # /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ 9 | # 10 | # Note to developpers: 11 | # 12 | # While editing this file, please respect the following statements: 13 | # 14 | # 1. Every variable should be defined in the ad hoc VARIABLES section with a 15 | # relevant subsection 16 | # 2. Every new rule should be defined in the ad hoc RULES section with a 17 | # relevant subsection depending on the targeted service 18 | # 3. Rules should be sorted alphabetically within their section 19 | # 4. When a rule has multiple dependencies, you should: 20 | # - duplicate the rule name to add the help string (if required) 21 | # - write one dependency per line to increase readability and diffs 22 | # 5. .PHONY rule statement should be written after the corresponding rule 23 | # ============================================================================== 24 | # VARIABLES 25 | 26 | # -- Database 27 | 28 | DB_HOST = postgresql 29 | DB_PORT = 5432 30 | 31 | # -- Docker 32 | # Get the current user ID to use for docker run and docker exec commands 33 | DOCKER_UID = $(shell id -u) 34 | DOCKER_GID = $(shell id -g) 35 | DOCKER_USER = $(DOCKER_UID):$(DOCKER_GID) 36 | COMPOSE = DOCKER_USER=$(DOCKER_USER) docker-compose 37 | COMPOSE_RUN = $(COMPOSE) run --rm 38 | COMPOSE_RUN_APP = $(COMPOSE_RUN) django-lti-toolbox 39 | COMPOSE_TEST_RUN = $(COMPOSE_RUN) 40 | COMPOSE_TEST_RUN_APP = $(COMPOSE_TEST_RUN) django-lti-toolbox 41 | MANAGE = $(COMPOSE_RUN_APP) python sandbox/manage.py 42 | WAIT_DB = @$(COMPOSE_RUN) dockerize -wait tcp://$(DB_HOST):$(DB_PORT) -timeout 60s 43 | 44 | # ============================================================================== 45 | # RULES 46 | 47 | default: help 48 | 49 | # -- Project 50 | 51 | bootstrap: ## Prepare Docker images for the project 52 | bootstrap: \ 53 | build \ 54 | migrate 55 | .PHONY: bootstrap 56 | 57 | # -- Docker/compose 58 | build: ## build the app container 59 | @$(COMPOSE) build django-lti-toolbox 60 | .PHONY: build 61 | 62 | down: ## stop and remove containers, networks, images, and volumes 63 | @$(COMPOSE) down 64 | .PHONY: down 65 | 66 | logs: ## display app logs (follow mode) 67 | @$(COMPOSE) logs -f django-lti-toolbox 68 | .PHONY: logs 69 | 70 | run: ## start the development server using Docker 71 | @$(COMPOSE) up -d 72 | @echo "Wait for postgresql to be up..." 73 | @$(WAIT_DB) 74 | .PHONY: run 75 | 76 | status: ## an alias for "docker-compose ps" 77 | @$(COMPOSE) ps 78 | .PHONY: status 79 | 80 | stop: ## stop the development server using Docker 81 | @$(COMPOSE) stop 82 | .PHONY: stop 83 | 84 | # -- Backend 85 | 86 | # Nota bene: Black should come after isort just in case they don't agree... 87 | lint: ## lint back-end python sources 88 | lint: \ 89 | lint-isort \ 90 | lint-black \ 91 | lint-flake8 \ 92 | lint-mypy \ 93 | lint-pylint \ 94 | lint-bandit 95 | .PHONY: lint 96 | 97 | lint-bandit: ## lint back-end python sources with bandit 98 | @echo 'lint:bandit started…' 99 | @$(COMPOSE_TEST_RUN_APP) bandit -qr src sandbox 100 | .PHONY: lint-bandit 101 | 102 | lint-black: ## lint back-end python sources with black 103 | @echo 'lint:black started…' 104 | @$(COMPOSE_TEST_RUN_APP) black src sandbox tests 105 | .PHONY: lint-black 106 | 107 | lint-flake8: ## lint back-end python sources with flake8 108 | @echo 'lint:flake8 started…' 109 | @$(COMPOSE_TEST_RUN_APP) flake8 110 | .PHONY: lint-flake8 111 | 112 | lint-isort: ## automatically re-arrange python imports in back-end code base 113 | @echo 'lint:isort started…' 114 | @$(COMPOSE_TEST_RUN_APP) isort --recursive --atomic . 115 | .PHONY: lint-isort 116 | 117 | lint-mypy: ## type check back-end python sources with mypy 118 | @echo 'lint:mypy started…' 119 | @$(COMPOSE_TEST_RUN_APP) mypy src 120 | .PHONY: lint-mypy 121 | 122 | lint-pylint: ## lint back-end python sources with pylint 123 | @echo 'lint:pylint started…' 124 | @$(COMPOSE_TEST_RUN_APP) pylint src sandbox tests 125 | .PHONY: lint-pylint 126 | 127 | test: ## run back-end tests 128 | @$(COMPOSE_TEST_RUN_APP) pytest 129 | .PHONY: test 130 | 131 | migrate: ## run django migration for the sandbox project. 132 | @echo "$(BOLD)Running migrations$(RESET)" 133 | @$(COMPOSE) up -d postgresql 134 | @$(WAIT_DB) 135 | @$(MANAGE) migrate 136 | .PHONY: migrate 137 | 138 | 139 | # -- Misc 140 | help: 141 | @grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' 142 | .PHONY: help 143 | -------------------------------------------------------------------------------- /sandbox/views.py: -------------------------------------------------------------------------------- 1 | """Views of the lti_toolbox django application.""" 2 | import uuid 3 | from urllib.parse import unquote 4 | 5 | from django.http import HttpRequest, HttpResponse 6 | from django.shortcuts import render 7 | from django.urls import reverse 8 | from django.views.decorators.csrf import csrf_exempt 9 | from oauthlib import oauth1 10 | 11 | from lti_toolbox.exceptions import LTIException 12 | from lti_toolbox.factories import LTIConsumerFactory, LTIPassportFactory 13 | from lti_toolbox.lti import LTI 14 | from lti_toolbox.models import LTIPassport 15 | from lti_toolbox.views import BaseLTIAuthView, BaseLTIView 16 | 17 | from forms import LTIConsumerForm 18 | 19 | 20 | class SimpleLaunchURLVerification(BaseLTIView): 21 | """Example view to handle LTI launch request verification.""" 22 | 23 | def _do_on_success(self, lti_request: LTI, *args, **kwargs) -> HttpResponse: 24 | # Render a template with some debugging information 25 | context = { 26 | "message": "LTI request verified successfully", 27 | } 28 | return render(self.request, "demo/debug_infos.html", context) 29 | 30 | def _do_on_failure(self, request: HttpRequest, error: LTIException) -> HttpResponse: 31 | context = { 32 | "message": "INVALID LTI request (check your django logs for more details)", 33 | "message_class": "danger", 34 | } 35 | return render(self.request, "demo/debug_infos.html", context, status=403) 36 | 37 | 38 | class LaunchURLWithAuth(BaseLTIAuthView): 39 | """ 40 | Example view to handle LTI launch request with user authentication. 41 | 42 | It relies on the `lti_toolbox.backend.LTIBackend` authentication backend that 43 | has been defined in the `AUTHENTICATION_BACKENDS` setting. 44 | """ 45 | 46 | def _do_on_login(self, lti_request: LTI) -> HttpResponse: 47 | """Process the request when the user is logged in via LTI""" 48 | context = { 49 | "message": "LTI request verified + user authenticated successfully", 50 | "debug_infos": { 51 | "View parameters": self.kwargs, 52 | "Authenticated user": { 53 | "id": self.request.user.id, 54 | "username": self.request.user.username, 55 | "email": self.request.user.email, 56 | }, 57 | }, 58 | } 59 | return render(self.request, "demo/debug_infos.html", context) 60 | 61 | 62 | ################################################################################## 63 | # You can ignore the rest of the file since it's related to the demo LTI consumer # 64 | ################################################################################## 65 | 66 | 67 | @csrf_exempt 68 | def demo_consumer(request: HttpRequest) -> HttpResponse: 69 | """Display the demo LTI consumer""" 70 | 71 | # Ensure that at least the demo consumer exists with a passport 72 | consumer = LTIConsumerFactory(slug="dev_consumer", title="Dev consumer") 73 | passport = LTIPassportFactory(title="Dev passport", consumer=consumer) 74 | 75 | if request.method == "POST": 76 | form = LTIConsumerForm(request.POST) 77 | if form.is_valid(): 78 | launch_url = request.build_absolute_uri(_get_launch_url(form.cleaned_data)) 79 | lti_params = _generate_signed_parameters(form, launch_url, passport) 80 | return render( 81 | request, 82 | "demo/consumer.html", 83 | {"form": form, "lti_params": lti_params, "launch_url": launch_url}, 84 | ) 85 | else: 86 | form = LTIConsumerForm() 87 | 88 | return render(request, "demo/consumer.html", {"form": form}) 89 | 90 | 91 | def _generate_signed_parameters(form: LTIConsumerForm, url: str, passport: LTIPassport): 92 | 93 | user_id = form.cleaned_data["user_id"] 94 | 95 | lti_parameters = { 96 | "lti_message_type": "basic-lti-launch-request", 97 | "lti_version": "LTI-1p0", 98 | "resource_link_id": str(uuid.uuid4()), 99 | "lis_person_contact_email_primary": f"{user_id}@example.com", 100 | "lis_person_sourcedid": user_id, 101 | "user_id": form.cleaned_data["user_id"], 102 | "context_id": form.cleaned_data["context_id"], 103 | "context_title": form.cleaned_data["course_title"], 104 | "roles": form.cleaned_data["role"], 105 | } 106 | if form.cleaned_data["presentation_locale"]: 107 | lti_parameters["launch_presentation_locale"] = form.cleaned_data[ 108 | "presentation_locale" 109 | ] 110 | 111 | oauth_client = oauth1.Client( 112 | client_key=passport.oauth_consumer_key, client_secret=passport.shared_secret 113 | ) 114 | # Compute Authorization header which looks like: 115 | # Authorization: OAuth oauth_nonce="80966668944732164491378916897", 116 | # oauth_timestamp="1378916897", oauth_version="1.0", oauth_signature_method="HMAC-SHA1", 117 | # oauth_consumer_key="", oauth_signature="frVp4JuvT1mVXlxktiAUjQ7%2F1cw%3D" 118 | _uri, headers, _body = oauth_client.sign( 119 | url, 120 | http_method="POST", 121 | body=lti_parameters, 122 | headers={"Content-Type": "application/x-www-form-urlencoded"}, 123 | ) 124 | 125 | # Parse headers to pass to template as part of context: 126 | oauth_dict = dict( 127 | param.strip().replace('"', "").split("=") 128 | for param in headers["Authorization"].split(",") 129 | ) 130 | 131 | signature = oauth_dict["oauth_signature"] 132 | oauth_dict["oauth_signature"] = unquote(signature) 133 | oauth_dict["oauth_nonce"] = oauth_dict.pop("OAuth oauth_nonce") 134 | lti_parameters.update(oauth_dict) 135 | return lti_parameters 136 | 137 | 138 | def _get_launch_url(cleaned_data): 139 | if cleaned_data["action"] == "lti.launch-url-auth-with-params": 140 | view_kwargs = { 141 | "uuid": uuid.uuid5(uuid.NAMESPACE_DNS, cleaned_data["context_id"]) 142 | } 143 | else: 144 | view_kwargs = {} 145 | return reverse(cleaned_data["action"], kwargs=view_kwargs) 146 | -------------------------------------------------------------------------------- /src/lti_toolbox/validator.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module is an implementation of logic needed for checking 3 | OAuth 1.0 signed LTI launch requests. 4 | 5 | 6 | It is based on the work of django-lti-provider-auth 7 | (https://github.com/wachjose88/django-lti-provider-auth) 8 | 9 | Here is the original licence : 10 | 11 | Copyright (c) 2018 Josef Wachtler 12 | 13 | Permission is hereby granted, free of charge, to any person obtaining a copy 14 | of this software and associated documentation files (the "Software"), to deal 15 | in the Software without restriction, including without limitation the rights 16 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 17 | copies of the Software, and to permit persons to whom the Software is 18 | furnished to do so, subject to the following conditions: 19 | 20 | The above copyright notice and this permission notice shall be included in all 21 | copies or substantial portions of the Software. 22 | 23 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 24 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 25 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 26 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 27 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 28 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 29 | SOFTWARE. 30 | 31 | """ 32 | import logging 33 | import time 34 | 35 | from django.core.cache import InvalidCacheBackendError 36 | from django.core.cache import cache as default_cache 37 | from django.core.cache import caches 38 | from oauthlib.oauth1 import RequestValidator 39 | 40 | from .models import LTIPassport 41 | 42 | logger = logging.getLogger(__name__) 43 | 44 | 45 | class LTIRequestValidator(RequestValidator): 46 | """ 47 | This validator implements the RequestValidator from the oauthlib, but only with methods 48 | required for an LTI launch request. 49 | """ 50 | 51 | LTI_REPLAY_PROTECTION_CACHE = "lti_replay" 52 | 53 | @property 54 | def enforce_ssl(self): 55 | """ 56 | Returns: 57 | bool: True if SSL is mandatory, False otherwise 58 | """ 59 | return False 60 | 61 | @property 62 | def nonce_length(self): 63 | """ 64 | Returns: 65 | tuple: A tuple containing the min and max length of a nonce 66 | """ 67 | return 5, 50 68 | 69 | @property 70 | def dummy_client(self): 71 | """Dummy client used when an invalid client key is supplied. 72 | 73 | Returns: 74 | string: The dummy client key string. 75 | """ 76 | return "dummy_client_key_123456" 77 | 78 | def get_client_secret(self, client_key, request): 79 | """Retrieves the client secret associated with the client key. 80 | 81 | If an unknown client_key is given, it returns a valid dummy secret in 82 | order to avoid timing attacks. 83 | 84 | This method must allow the use of a dummy client_key value. 85 | Fetching the secret using the dummy key must take the same amount of 86 | time as fetching a secret for a valid client:: 87 | 88 | Args: 89 | client_key: The client/consumer key. 90 | request: The calling request. 91 | """ 92 | 93 | try: 94 | passport = LTIPassport.objects.get( 95 | oauth_consumer_key=client_key, is_enabled=True 96 | ) 97 | return passport.shared_secret 98 | except LTIPassport.DoesNotExist: 99 | return "dummy_client_sec_123456" 100 | 101 | def validate_client_key(self, client_key, request): 102 | """Validates that supplied client key is a registered and valid client. 103 | 104 | Args: 105 | client_key: The client/consumer key. 106 | request: The calling request 107 | 108 | Returns: 109 | bool: True if the client key is registered and valid 110 | """ 111 | return LTIPassport.objects.filter( 112 | oauth_consumer_key=client_key, is_enabled=True 113 | ).exists() 114 | 115 | # pylint: disable=too-many-arguments 116 | def validate_timestamp_and_nonce( 117 | self, 118 | client_key: str, 119 | timestamp: str, 120 | nonce: str, 121 | request, 122 | request_token=None, 123 | access_token=None, 124 | ): 125 | """Validates that the nonce has not been used before 126 | 127 | Per `Section 3.3`_ of the OAuth 1.0 spec. 128 | 129 | "A nonce is a random string, uniquely generated by the client to allow 130 | the server to verify that a request has never been made before and 131 | helps prevent replay attacks when requests are made over a non-secure 132 | channel. The nonce value MUST be unique across all requests with the 133 | same timestamp, client credentials, and token combinations." 134 | 135 | .. _`Section 3.3`: https://tools.ietf.org/html/rfc5849#section-3.3 136 | 137 | One of the first validation checks that will be made is for the validity 138 | of the nonce and timestamp, which are associated with a client key and 139 | possibly a token. If invalid then immediately fail the request 140 | by returning False. If the nonce/timestamp pair has been used before and 141 | you may just have detected a replay attack. Therefore, it is an essential 142 | part of OAuth security that you not allow nonce/timestamp reuse. 143 | Note that this validation check is done before checking the validity of 144 | the client and token.:: 145 | 146 | Args: 147 | client_key: The client/consumer key. 148 | timestamp: The ``oauth_timestamp`` request parameter. 149 | nonce: The ``oauth_nonce`` request parameter. 150 | request: The calling request 151 | request_token: Request token string, if any. 152 | access_token: Access token string, if any. 153 | 154 | Returns: 155 | bool: True if the timestamp and nonce has not been used before 156 | """ 157 | 158 | cache_timeout = 3600 159 | # Disallow usage of timestamp older than cache_timeout 160 | request_timestamp = int(timestamp) 161 | if request_timestamp < int(time.time()) - cache_timeout: 162 | logger.debug( 163 | "Timestamp is too old (ts = %s, consumer_key = %s, nonce = %s)", 164 | timestamp, 165 | client_key, 166 | nonce, 167 | ) 168 | return False 169 | 170 | try: 171 | cache = caches[self.LTI_REPLAY_PROTECTION_CACHE] 172 | except InvalidCacheBackendError: 173 | logger.debug( 174 | "Unable to find cache %s, fallback to default cache", 175 | self.LTI_REPLAY_PROTECTION_CACHE, 176 | ) 177 | cache = default_cache 178 | 179 | key = f"LTI_TS_NONCE:{client_key:s}:{timestamp:s}:{nonce:s}" 180 | 181 | if not cache.add(key, "1", cache_timeout): 182 | logger.warning( 183 | "Replayed timestamp/nonce detected (ts = %s, consumer_key = %s, nonce = %s)", 184 | timestamp, 185 | client_key, 186 | nonce, 187 | ) 188 | return False 189 | 190 | logger.debug( 191 | "Timestamp and nonce valid (ts = %s, consumer_key = %s, nonce = %s)", 192 | timestamp, 193 | client_key, 194 | nonce, 195 | ) 196 | return True 197 | -------------------------------------------------------------------------------- /src/lti_toolbox/launch_params.py: -------------------------------------------------------------------------------- 1 | """ 2 | Utilities to represent and validate LTI Launch parameters 3 | 4 | This is based on lti library (https://github.com/pylti/lti). 5 | """ 6 | from collections.abc import MutableMapping 7 | from enum import Enum 8 | from typing import List, Set, Union 9 | from urllib.parse import urlencode 10 | 11 | from .exceptions import InvalidParamException, MissingParamException 12 | 13 | DEFAULT_LTI_VERSION = "LTI-1.0" 14 | 15 | 16 | class LTIRole(str, Enum): 17 | """Enum describing all context roles of an LTI user.""" 18 | 19 | ADMINISTRATOR = "administrator" 20 | INSTRUCTOR = "instructor" 21 | LEARNER = "learner" 22 | TEACHER = "teacher" 23 | STAFF = "staff" 24 | STUDENT = "student" 25 | 26 | 27 | class LTIMessageType(str, Enum): 28 | """Enum describing all type of message received through LTI requests.""" 29 | 30 | LAUNCH_REQUEST = "basic-lti-launch-request" 31 | SELECTION_REQUEST = "ContentItemSelectionRequest" 32 | SELECTION_RESPONSE = "ContentItemSelection" 33 | 34 | 35 | LAUNCH_PARAMS_REQUIRED = {"lti_message_type", "lti_version", "resource_link_id"} 36 | 37 | LAUNCH_PARAMS_RECOMMENDED = { 38 | "context_id", 39 | "context_label", 40 | "context_title", 41 | "context_type", 42 | "launch_presentation_css_url", 43 | "launch_presentation_document_target", 44 | "launch_presentation_height", 45 | "launch_presentation_locale", 46 | "launch_presentation_return_url", 47 | "launch_presentation_width", 48 | "lis_person_contact_email_primary", 49 | "lis_person_name_family", 50 | "lis_person_name_full", 51 | "lis_person_name_given", 52 | "resource_link_description", 53 | "resource_link_title", 54 | "roles", 55 | "role_scope_mentor", 56 | "tool_consumer_info_product_family_code", 57 | "tool_consumer_info_version", 58 | "tool_consumer_instance_contact_email", 59 | "tool_consumer_instance_description", 60 | "tool_consumer_instance_guid", 61 | "tool_consumer_instance_name", 62 | "tool_consumer_instance_url", 63 | "user_id", 64 | "user_image", 65 | } 66 | 67 | LAUNCH_PARAMS_LIS = { 68 | "lis_course_offering_sourcedid", 69 | "lis_course_section_sourcedid", 70 | "lis_outcome_service_url", 71 | "lis_person_sourcedid", 72 | "lis_result_sourcedid", 73 | } 74 | 75 | LAUNCH_PARAMS_RETURN_URL = { 76 | "lti_errorlog", 77 | "lti_errormsg", 78 | "lti_log", 79 | "lti_msg", 80 | } 81 | 82 | LAUNCH_PARAMS_OAUTH = { 83 | "oauth_callback", 84 | "oauth_consumer_key", 85 | "oauth_nonce", 86 | "oauth_signature", 87 | "oauth_signature_method", 88 | "oauth_timestamp", 89 | "oauth_token", 90 | "oauth_version", 91 | } 92 | 93 | LAUNCH_PARAMS_IS_LIST = { 94 | "accept_media_types", 95 | "accept_presentation_document_targets", 96 | "context_type", 97 | "role_scope_mentor", 98 | "roles", 99 | } 100 | 101 | LAUNCH_PARAMS_CANVAS = {"selection_directive", "text"} 102 | 103 | CONTENT_PARAMS_REQUEST = { 104 | "accept_copy_advice", 105 | "accept_media_types", 106 | "accept_multiple", 107 | "accept_presentation_document_targets", 108 | "accept_unsigned", 109 | "auto_create", 110 | "can_confirm", 111 | "content_item_return_url", 112 | "data", 113 | "title", 114 | } 115 | 116 | CONTENT_PARAMS_RESPONSE = { 117 | "content_items", 118 | "lti_errorlog", 119 | "lti_errormsg", 120 | "lti_log", 121 | "lti_msg", 122 | } 123 | 124 | REGISTRATION_PARAMS = { 125 | "reg_key", 126 | "reg_password", 127 | "tc_profile_url", 128 | } 129 | 130 | LAUNCH_PARAMS = ( 131 | CONTENT_PARAMS_REQUEST 132 | | CONTENT_PARAMS_RESPONSE 133 | | LAUNCH_PARAMS_CANVAS 134 | | LAUNCH_PARAMS_LIS 135 | | LAUNCH_PARAMS_OAUTH 136 | | LAUNCH_PARAMS_RECOMMENDED 137 | | LAUNCH_PARAMS_REQUIRED 138 | | LAUNCH_PARAMS_RETURN_URL 139 | | REGISTRATION_PARAMS 140 | ) 141 | 142 | SELECTION_PARAMS_REQUIRED = { 143 | "lti_message_type", 144 | "lti_version", 145 | "accept_media_types", 146 | "accept_presentation_document_targets", 147 | "content_item_return_url", 148 | } 149 | SELECTION_PARAMS_SHOULD_NOT_BE_PASSED = { 150 | "resource_link_id", 151 | "resource_link_title", 152 | "resource_link_description", 153 | "launch_presentation_return_url", 154 | "lis_result_sourcedid", 155 | } 156 | 157 | SELECTION_PARAMS = LAUNCH_PARAMS - SELECTION_PARAMS_SHOULD_NOT_BE_PASSED 158 | 159 | 160 | class ParamsMixin(MutableMapping): 161 | """ 162 | Represents the params for an LTI request. Provides dict-like 163 | behavior through the use of the MutableMapping ABC mixin. Strictly 164 | enforces that params are valid LTI params. 165 | """ 166 | 167 | params_allowed: Set[str] = set() 168 | params_required: Set[str] = set() 169 | params_is_list: Set[str] = set() 170 | 171 | def __init__(self, *args, **kwargs): 172 | 173 | self._params = dict() 174 | self.update(*args, **kwargs) 175 | 176 | # now verify we only got valid launch params 177 | for k in self.keys(): 178 | if not self.valid_param(k): 179 | raise InvalidParamException(k) 180 | 181 | for param in self.params_required: 182 | if param not in self: 183 | raise MissingParamException(param) 184 | 185 | def _param_value(self, param: str) -> Union[str, List]: 186 | """Get the value of an LTI parameter. 187 | 188 | Args: 189 | param: LTI parameter name 190 | 191 | Returns: 192 | The value of the LTI parameter, as a str or a List, depending on the parameter. 193 | """ 194 | if param in self.params_is_list: 195 | return [x.strip() for x in self._params[param].split(",")] 196 | return self._params[param] 197 | 198 | def valid_param(self, param: str) -> bool: 199 | """Checks if an LTI parameter is valid or not. 200 | 201 | Args: 202 | param: LTI parameter name 203 | 204 | Returns: 205 | bool: True if the parameter is valid, False otherwise. 206 | 207 | """ 208 | if param.startswith("custom_") or param.startswith("ext_"): 209 | return True 210 | return param in self.params_allowed 211 | 212 | def __len__(self): 213 | return len(self._params) 214 | 215 | def __getitem__(self, item): 216 | if not self.valid_param(item): 217 | raise KeyError("{} is not a valid launch param".format(item)) 218 | try: 219 | return self._param_value(item) 220 | except KeyError: 221 | # catch and raise new KeyError in the proper context 222 | raise KeyError(item) 223 | 224 | def __setitem__(self, key, value): 225 | if not self.valid_param(key): 226 | raise InvalidParamException(key) 227 | if key in LAUNCH_PARAMS_IS_LIST: 228 | if isinstance(value, list): 229 | value = ",".join([x.strip() for x in value]) 230 | self._params[key] = value 231 | 232 | def __delitem__(self, key): 233 | if key in self._params: 234 | del self._params[key] 235 | 236 | def __iter__(self): 237 | return iter(self._params) 238 | 239 | @property 240 | def urlencoded(self) -> str: 241 | """Get the URL encoded representation of the LTI parameter list. 242 | 243 | Returns: 244 | str: URL encoded LTI parameters 245 | """ 246 | params = dict(self) 247 | # stringify any list values 248 | for key, value in params.items(): 249 | if isinstance(value, list): 250 | params[key] = ",".join(value) 251 | return urlencode(params) 252 | 253 | 254 | class LaunchParams(ParamsMixin): # pylint: disable=too-many-ancestors 255 | """Represents the params for an LTI Launch request.""" 256 | 257 | params_allowed = LAUNCH_PARAMS 258 | params_required = LAUNCH_PARAMS_REQUIRED 259 | params_is_list = LAUNCH_PARAMS_IS_LIST 260 | 261 | 262 | class SelectionParams(ParamsMixin): # pylint: disable=too-many-ancestors 263 | """Represents the params for an LTI Content-Item selection request.""" 264 | 265 | params_allowed = SELECTION_PARAMS 266 | params_required = SELECTION_PARAMS_REQUIRED 267 | params_is_list = LAUNCH_PARAMS_IS_LIST 268 | -------------------------------------------------------------------------------- /src/lti_toolbox/lti.py: -------------------------------------------------------------------------------- 1 | """LTI module that supports LTI 1.0.""" 2 | 3 | import re 4 | from typing import Any, Optional, Set 5 | from urllib.parse import urljoin 6 | 7 | from oauthlib.oauth1 import SignatureOnlyEndpoint 8 | 9 | from .exceptions import LTIException, LTIRequestNotVerifiedException, ParamException 10 | from .launch_params import ( 11 | LaunchParams, 12 | LTIMessageType, 13 | LTIRole, 14 | ParamsMixin, 15 | SelectionParams, 16 | ) 17 | from .models import LTIConsumer, LTIPassport 18 | from .validator import LTIRequestValidator 19 | 20 | 21 | class LTI: 22 | """The LTI object abstracts an LTI request. 23 | 24 | It provides properties and methods to inspect a launch or selection request. 25 | """ 26 | 27 | def __init__(self, request): 28 | """Initialize the LTI system. 29 | 30 | Args: 31 | request (HttpRequest) The request that holds the LTI parameters 32 | """ 33 | 34 | self.request = request 35 | self._valid = None 36 | self._params = {} 37 | self._verified = False 38 | 39 | def _process_params(self) -> ParamsMixin: 40 | """Process LTI parameters based on request type.""" 41 | is_selection_request = ( 42 | self.request.POST.get("lti_message_type") 43 | == LTIMessageType.SELECTION_REQUEST 44 | ) 45 | try: 46 | params = ( 47 | SelectionParams(self.request.POST) 48 | if is_selection_request 49 | else LaunchParams(self.request.POST) 50 | ) 51 | except ParamException as error: 52 | raise LTIException(f"Exception while processing parameters: {error}") 53 | 54 | return params 55 | 56 | def verify(self) -> bool: 57 | """Verify the LTI request. 58 | 59 | Returns: 60 | bool: True if the LTI request is valid. 61 | 62 | Raises: 63 | LTIException: Raised if request validation fails 64 | """ 65 | params = self._process_params() 66 | validator = LTIRequestValidator() 67 | oauth_endpoint = SignatureOnlyEndpoint(validator) 68 | 69 | self._valid, _ = oauth_endpoint.validate_request( 70 | uri=self.request.build_absolute_uri(), 71 | http_method=self.request.method, 72 | body=params.urlencoded, 73 | headers=self.request.headers, 74 | ) 75 | 76 | if self._valid is not True: 77 | raise LTIException("LTI verification failed") 78 | 79 | self._params = params 80 | self._verified = True 81 | 82 | return self._valid 83 | 84 | @property 85 | def is_valid(self) -> bool: 86 | """Check if the LTI request is verified and valid 87 | 88 | Returns: 89 | True if the request is verified and valid 90 | """ 91 | return self._verified and self._valid 92 | 93 | def get_param(self, name: str, default: Any = None): 94 | """Retrieve an LTI parameter value given its name. 95 | 96 | Args: 97 | name: Name of the LTI parameter 98 | default: Default value if the parameter is not found 99 | 100 | Returns: 101 | The value of the LTI parameter if it exists, or the default value otherwise. 102 | 103 | """ 104 | if not self._verified: 105 | raise LTIRequestNotVerifiedException() 106 | return self._params.get(name, default) 107 | 108 | def get_consumer(self) -> LTIConsumer: 109 | """Retrieve the LTI consumer that initiated the request.""" 110 | consumer_key = self.get_param("oauth_consumer_key") 111 | passport = LTIPassport.objects.get( 112 | oauth_consumer_key=consumer_key, is_enabled=True 113 | ) 114 | return passport.consumer 115 | 116 | def get_course_info(self) -> dict: 117 | """Retrieve course info in the LTI request. 118 | 119 | Returns: 120 | dict: A dictionary containing course information 121 | school_name: the school name 122 | course_name: the course name 123 | course_section: the course section 124 | 125 | """ 126 | if self.is_edx_format: 127 | groups = re.match(r"^course-v[0-9]:(.*)", self.get_param("context_id")) 128 | if groups is not None: 129 | part = groups.group(1).split("+") 130 | length = len(part) 131 | return { 132 | "school_name": part[0] if length >= 1 else None, 133 | "course_name": part[1] if length >= 2 else None, 134 | "course_run": part[2] if length >= 3 else None, 135 | } 136 | 137 | return { 138 | "school_name": self.get_param("tool_consumer_instance_name", None), 139 | "course_name": self.context_title, 140 | "course_run": None, 141 | } 142 | 143 | @property 144 | def origin_url(self): 145 | """Try to recreate the URL that was used to launch the LTI request.""" 146 | base_url = self.get_consumer().url 147 | if not base_url: 148 | return None 149 | if not base_url.endswith("/"): 150 | base_url = f"{base_url}/" 151 | context_id = self.get_param("context_id") 152 | 153 | url = None 154 | if self.is_edx_format: 155 | url = urljoin(base_url, f"course/{context_id}") 156 | elif self.is_moodle_format: 157 | url = urljoin(base_url, f"course/view.php?id={context_id}") 158 | 159 | return url 160 | 161 | @property 162 | def resource_link_title(self) -> Optional[str]: 163 | """Return the resource link id as default for its title.""" 164 | return self.get_param("resource_link_title", self.get_param("resource_link_id")) 165 | 166 | @property 167 | def context_title(self) -> Optional[str]: 168 | """Return the context id as default for its title.""" 169 | return self.get_param("context_title", self.get_param("context_id")) 170 | 171 | @property 172 | def roles(self): 173 | """LTI roles of the authenticated user. 174 | 175 | Returns: 176 | List[str]: normalized LTI roles from the session 177 | """ 178 | roles = self.get_param("roles", []) 179 | return list(map(str.lower, roles)) 180 | 181 | @property 182 | def is_edx_format(self): 183 | """Check if the LTI request comes from Open edX. 184 | 185 | Returns: 186 | boolean: True if the LTI request is sent by Open edX 187 | 188 | """ 189 | return re.search(r"^course-v[0-9]:.*$", self.get_param("context_id")) 190 | 191 | @property 192 | def is_moodle_format(self): 193 | """Check if the LTI request comes from Moodle. 194 | 195 | Returns 196 | ------- 197 | boolean 198 | True if the LTI request is sent by Moodle 199 | 200 | """ 201 | return self.get_param("tool_consumer_info_product_family_code", "") == "moodle" 202 | 203 | def _has_any_of_roles(self, roles: Set[str]): 204 | """Check if the LTI user has any of the provided roles.""" 205 | return not roles.isdisjoint(self.roles) 206 | 207 | @property 208 | def is_student(self): 209 | """Check if the LTI user is a student. 210 | 211 | Returns: 212 | boolean: True if the LTI user is a student. 213 | 214 | """ 215 | return self._has_any_of_roles({LTIRole.STUDENT, LTIRole.LEARNER}) 216 | 217 | @property 218 | def is_instructor(self): 219 | """Check if the LTI user is an instructor. 220 | 221 | Returns: 222 | boolean: True if the LTI user is an instructor. 223 | 224 | """ 225 | return self._has_any_of_roles( 226 | {LTIRole.INSTRUCTOR, LTIRole.TEACHER, LTIRole.STAFF} 227 | ) 228 | 229 | @property 230 | def is_administrator(self): 231 | """Check if the LTI user is an administrator. 232 | 233 | Returns: 234 | boolean: True if the LTI user is an administrator. 235 | 236 | """ 237 | return self._has_any_of_roles({LTIRole.ADMINISTRATOR}) 238 | 239 | @property 240 | def can_edit_content(self): 241 | """Check if the LTI user can edit LMS content.""" 242 | return self.is_administrator or self.is_instructor 243 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # Configuration file anchors 2 | generate-version-file: &generate-version-file 3 | run: 4 | name: Create a version.json 5 | command: | 6 | # Create a version.json à-la-mozilla 7 | # https://github.com/mozilla-services/Dockerflow/blob/master/docs/version_object.md 8 | printf '{"commit":"%s","version":"%s","source":"https://github.com/%s/%s","build":"%s"}\n' \ 9 | "$CIRCLE_SHA1" \ 10 | "$CIRCLE_TAG" \ 11 | "$CIRCLE_PROJECT_USERNAME" \ 12 | "$CIRCLE_PROJECT_REPONAME" \ 13 | "$CIRCLE_BUILD_URL" > sandbox/version.json 14 | 15 | version: 2 16 | jobs: 17 | # Git jobs 18 | # Check that the git history is clean and complies with our expectations 19 | lint-git: 20 | docker: 21 | - image: circleci/python:3.8-buster 22 | working_directory: ~/fun 23 | steps: 24 | - checkout 25 | # Make sure the changes don't add a "print" statement to the code base. 26 | # We should exclude the ".circleci" folder from the search as the very command that checks 27 | # the absence of "print" is including a "print(" itself. 28 | - run: 29 | name: enforce absence of print statements in code 30 | command: | 31 | ! git diff origin/master..HEAD -- . ':(exclude).circleci' | grep "print(" 32 | - run: 33 | name: Check absence of fixup commits 34 | command: | 35 | ! git log | grep 'fixup!' 36 | - run: 37 | name: Install gitlint 38 | command: | 39 | pip install --user gitlint 40 | - run: 41 | name: lint commit messages added to master 42 | command: | 43 | ~/.local/bin/gitlint --commits origin/master..HEAD 44 | 45 | # Check that the CHANGELOG has been updated in the current branch 46 | check-changelog: 47 | docker: 48 | - image: circleci/buildpack-deps:stretch-scm 49 | working_directory: ~/fun 50 | steps: 51 | - checkout 52 | - run: 53 | name: Check that the CHANGELOG has been modified in the current branch 54 | command: | 55 | git whatchanged --name-only --pretty="" origin..HEAD | grep CHANGELOG 56 | 57 | # Check that the CHANGELOG max line length does not exceed 80 characters 58 | lint-changelog: 59 | docker: 60 | - image: debian:stretch 61 | working_directory: ~/fun 62 | steps: 63 | - checkout 64 | - run: 65 | name: Check CHANGELOG max line length 66 | command: | 67 | # Get the longuest line width (ignoring release links) 68 | test $(cat CHANGELOG.md | grep -Ev "^\[.*\]: https://github.com/openfun" | wc -L) -le 80 69 | 70 | # ---- Backend jobs ---- 71 | # Build backend development environment 72 | build: 73 | docker: 74 | - image: circleci/python:3.8-buster 75 | working_directory: ~/fun 76 | steps: 77 | - checkout 78 | - restore_cache: 79 | keys: 80 | - v1-back-dependencies-{{ .Revision }} 81 | - run: 82 | name: Install development dependencies 83 | command: pip install --user .[dev,sandbox] 84 | - save_cache: 85 | paths: 86 | - ~/.local 87 | key: v1-back-dependencies-{{ .Revision }} 88 | 89 | lint: 90 | docker: 91 | - image: circleci/python:3.8-buster 92 | environment: 93 | PYTHONPATH: /home/circleci/fun/sandbox 94 | working_directory: ~/fun 95 | steps: 96 | - checkout 97 | - restore_cache: 98 | keys: 99 | - v1-back-dependencies-{{ .Revision }} 100 | - run: 101 | name: Lint code with flake8 102 | command: ~/.local/bin/flake8 103 | - run: 104 | name: Lint code with isort 105 | command: ~/.local/bin/isort --recursive --check-only . 106 | - run: 107 | name: Lint code with black 108 | command: ~/.local/bin/black src sandbox tests --check 109 | - run: 110 | name: Lint code with pylint 111 | command: ~/.local/bin/pylint src sandbox tests 112 | - run: 113 | name: Lint code with bandit 114 | command: ~/.local/bin/bandit -qr src/lti_toolbox sandbox 115 | - run: 116 | name: Type-check code with mypy 117 | command: ~/.local/bin/mypy src 118 | 119 | test: 120 | docker: 121 | - image: circleci/python:3.8-buster 122 | environment: 123 | DJANGO_SETTINGS_MODULE: settings 124 | DJANGO_CONFIGURATION: Test 125 | DJANGO_SECRET_KEY: ThisIsAnExampleKeyForTestPurposeOnly 126 | PYTHONPATH: /home/circleci/fun/sandbox 127 | DB_HOST: localhost 128 | DB_NAME: lti_toolbox 129 | DB_USER: fun 130 | DB_PASSWORD: pass 131 | DB_PORT: 5432 132 | # services 133 | - image: cimg/postgres:16.4 134 | environment: 135 | POSTGRES_DB: lti_toolbox 136 | POSTGRES_USER: fun 137 | POSTGRES_PASSWORD: pass 138 | working_directory: ~/fun 139 | steps: 140 | - checkout 141 | - restore_cache: 142 | keys: 143 | - v1-back-dependencies-{{ .Revision }} 144 | # While running tests, we need to make the /data directory writable for 145 | # the circleci user 146 | - run: 147 | name: Create writable /data 148 | command: | 149 | sudo mkdir /data && \ 150 | sudo chown circleci:circleci /data 151 | # Run back-end (Django) test suite 152 | # 153 | # Nota bene: to run the django test suite, we need to ensure that 154 | # Postgresql service is up and ready. To achieve this, we wrap the pytest 155 | # command execution with dockerize, a tiny tool installed in the CircleCI 156 | # image. In our case, dockerize will wait up to one minute that the database 157 | # opened its tcp port (5432). 158 | - run: 159 | name: Run tests 160 | command: | 161 | dockerize \ 162 | -wait tcp://localhost:5432 \ 163 | -timeout 60s \ 164 | ~/.local/bin/pytest 165 | 166 | # ---- Packaging jobs ---- 167 | package: 168 | docker: 169 | - image: circleci/python:3.8-buster 170 | working_directory: ~/fun 171 | steps: 172 | - checkout 173 | - run: 174 | name: Build python package 175 | command: python setup.py sdist bdist_wheel 176 | # Persist build packages to the workspace 177 | - persist_to_workspace: 178 | root: ~/fun 179 | paths: 180 | - dist 181 | # Store packages as artifacts to download/test them 182 | - store_artifacts: 183 | path: ~/fun/dist 184 | 185 | # Publishing to PyPI requires that: 186 | # * you already registered to pypi.org 187 | # * you have defined both the TWINE_USERNAME & TWINE_PASSWORD secret 188 | # environment variables in CircleCI UI (with your PyPI credentials) 189 | pypi: 190 | docker: 191 | - image: circleci/python:3.8-buster 192 | working_directory: ~/fun 193 | steps: 194 | - checkout 195 | # Restore built python packages 196 | - attach_workspace: 197 | at: ~/fun 198 | - run: 199 | name: List built packages 200 | command: ls dist/* 201 | - run: 202 | name: Install base requirements (twine) 203 | command: pip install --user .[ci] 204 | - run: 205 | name: Upload built packages to PyPI 206 | command: ~/.local/bin/twine upload dist/* 207 | 208 | workflows: 209 | version: 2 210 | 211 | django-lti-toolbox: 212 | jobs: 213 | # Git jobs 214 | # 215 | # Check validity of git history 216 | - lint-git: 217 | filters: 218 | tags: 219 | only: /.*/ 220 | # Check CHANGELOG update 221 | - check-changelog: 222 | filters: 223 | branches: 224 | ignore: master 225 | tags: 226 | only: /(?!^v).*/ 227 | - lint-changelog: 228 | filters: 229 | branches: 230 | ignore: master 231 | tags: 232 | only: /.*/ 233 | 234 | # Backend jobs 235 | # 236 | # Build, lint and test production and development Docker images 237 | # (debian-based) 238 | - build: 239 | filters: 240 | tags: 241 | only: /.*/ 242 | - lint: 243 | requires: 244 | - build 245 | filters: 246 | tags: 247 | only: /.*/ 248 | - test: 249 | requires: 250 | - build 251 | filters: 252 | tags: 253 | only: /.*/ 254 | 255 | # Packaging: python 256 | # 257 | # Build the python package 258 | - package: 259 | requires: 260 | - test 261 | filters: 262 | tags: 263 | only: /.*/ 264 | 265 | # PyPI publication. 266 | # 267 | # Publish python package to PYPI only if all build, lint and test jobs 268 | # succeed and it has been tagged with a tag starting with the letter v 269 | - pypi: 270 | requires: 271 | - package 272 | filters: 273 | branches: 274 | ignore: /.*/ 275 | tags: 276 | only: /^v.*/ 277 | -------------------------------------------------------------------------------- /sandbox/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for sandbox project. 3 | 4 | Generated by 'django-admin startproject' using Django 2.2. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/2.2/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/2.2/ref/settings/ 11 | """ 12 | 13 | import os 14 | 15 | from configurations import Configuration, values 16 | 17 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 18 | DATA_DIR = os.path.join("/", "data") 19 | 20 | # Disable pylint error "W0232: Class has no __init__ method", because base Configuration 21 | # class does not define an __init__ method. 22 | # pylint: disable = W0232 23 | 24 | 25 | class Base(Configuration): 26 | """ 27 | This is the base configuration every configuration (aka environment) should inherit from. It 28 | is recommended to configure third-party applications by creating a configuration mixins in 29 | ./configurations and compose the Base configuration with those mixins. 30 | It depends on an environment variable that SHOULD be defined: 31 | * DJANGO_SECRET_KEY 32 | You may also want to override default configuration by setting the following environment 33 | variables: 34 | * DB_NAME 35 | * DB_HOST 36 | * DB_PASSWORD 37 | * DB_USER 38 | """ 39 | 40 | AUTHENTICATION_BACKENDS = [ 41 | "lti_toolbox.backend.LTIBackend", 42 | "django.contrib.auth.backends.ModelBackend", 43 | ] 44 | 45 | DEBUG = False 46 | 47 | # Security 48 | ALLOWED_HOSTS = [] 49 | SECRET_KEY = values.Value(None) 50 | 51 | # SECURE_PROXY_SSL_HEADER allows to fix the scheme in Django's HttpRequest 52 | # object when your application is behind a reverse proxy. 53 | # 54 | # Keep this SECURE_PROXY_SSL_HEADER configuration only if : 55 | # - your Django app is behind a proxy. 56 | # - your proxy strips the X-Forwarded-Proto header from all incoming requests 57 | # - Your proxy sets the X-Forwarded-Proto header and sends it to Django 58 | # 59 | # In other cases, you should comment the following line to avoid security issues. 60 | SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") 61 | 62 | # Disable Samesite flag in session and csrf cookies, because a LTI tool provider app 63 | # is meant to run in an iframe on external websites. 64 | # Note : The better solution is to send a flag Samesite=none, because 65 | # modern browsers are considering Samesite=Lax by default when the flag is 66 | # not specified. 67 | # It will be possible to specify CSRF_COOKIE_SAMESITE="none" in Django 3.1 68 | CSRF_COOKIE_SAMESITE = None 69 | SESSION_COOKIE_SAMESITE = None 70 | 71 | # Privacy 72 | SECURE_REFERRER_POLICY = "same-origin" 73 | 74 | # Application definition 75 | ROOT_URLCONF = "urls" 76 | WSGI_APPLICATION = "wsgi.application" 77 | 78 | # Database 79 | DATABASES = { 80 | "default": { 81 | "ENGINE": values.Value( 82 | "django.db.backends.postgresql_psycopg2", 83 | environ_name="DB_ENGINE", 84 | environ_prefix=None, 85 | ), 86 | "NAME": values.Value("lti", environ_name="DB_NAME", environ_prefix=None), 87 | "USER": values.Value("fun", environ_name="DB_USER", environ_prefix=None), 88 | "PASSWORD": values.Value( 89 | "pass", environ_name="DB_PASSWORD", environ_prefix=None 90 | ), 91 | "HOST": values.Value( 92 | "localhost", environ_name="DB_HOST", environ_prefix=None 93 | ), 94 | "PORT": values.Value(5432, environ_name="DB_PORT", environ_prefix=None), 95 | } 96 | } 97 | 98 | # Templates 99 | TEMPLATES = [ 100 | { 101 | "BACKEND": "django.template.backends.django.DjangoTemplates", 102 | "DIRS": [os.path.join(BASE_DIR, "sandbox", "templates")], 103 | "APP_DIRS": True, 104 | "OPTIONS": { 105 | "context_processors": [ 106 | "django.contrib.auth.context_processors.auth", 107 | "django.contrib.messages.context_processors.messages", 108 | "django.template.context_processors.csrf", 109 | "django.template.context_processors.debug", 110 | "django.template.context_processors.i18n", 111 | "django.template.context_processors.media", 112 | "django.template.context_processors.request", 113 | "django.template.context_processors.tz", 114 | ], 115 | }, 116 | }, 117 | ] 118 | 119 | MIDDLEWARE = [ 120 | "django.middleware.security.SecurityMiddleware", 121 | "django.contrib.sessions.middleware.SessionMiddleware", 122 | "django.middleware.locale.LocaleMiddleware", 123 | "django.middleware.common.CommonMiddleware", 124 | "django.middleware.csrf.CsrfViewMiddleware", 125 | "django.contrib.auth.middleware.AuthenticationMiddleware", 126 | "django.contrib.messages.middleware.MessageMiddleware", 127 | ] 128 | 129 | # Django applications from the highest priority to the lowest 130 | INSTALLED_APPS = [ 131 | "django.contrib.admin", 132 | "django.contrib.auth", 133 | "django.contrib.contenttypes", 134 | "django.contrib.sessions", 135 | "django.contrib.messages", 136 | # LTI Toolbox 137 | "lti_toolbox", 138 | # Sandbox utilities 139 | "crispy_forms", 140 | ] 141 | 142 | CRISPY_TEMPLATE_PACK = "bootstrap4" 143 | 144 | # Cache 145 | CACHES = { 146 | "default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}, 147 | } 148 | 149 | 150 | class Development(Base): 151 | """ 152 | Development environment settings 153 | We set DEBUG to True and configure the server to respond from all hosts. 154 | """ 155 | 156 | DEBUG = True 157 | ALLOWED_HOSTS = ["*"] 158 | 159 | LOGGING = { 160 | "version": 1, 161 | "disable_existing_loggers": False, 162 | "formatters": { 163 | "verbose": { 164 | "format": "[%(levelname)s] [%(asctime)s] [%(module)s] " 165 | "%(process)d %(thread)d %(message)s" 166 | } 167 | }, 168 | "handlers": { 169 | "console": { 170 | "level": "DEBUG", 171 | "class": "logging.StreamHandler", 172 | "formatter": "verbose", 173 | } 174 | }, 175 | "loggers": { 176 | "oauthlib": {"handlers": ["console"], "level": "DEBUG", "propagate": True}, 177 | "lti_toolbox": { 178 | "handlers": ["console"], 179 | "level": "DEBUG", 180 | "propagate": True, 181 | }, 182 | "django": {"handlers": ["console"], "level": "INFO", "propagate": True}, 183 | }, 184 | } 185 | 186 | 187 | class Test(Base): 188 | """Test environment settings""" 189 | 190 | 191 | class ContinuousIntegration(Test): 192 | """ 193 | Continuous Integration environment settings 194 | nota bene: it should inherit from the Test environment. 195 | """ 196 | 197 | 198 | class Production(Base): 199 | """Production environment settings 200 | You must define the DJANGO_ALLOWED_HOSTS environment variable in Production 201 | configuration (and derived configurations): 202 | DJANGO_ALLOWED_HOSTS="foo.com,foo.fr" 203 | """ 204 | 205 | # Security 206 | ALLOWED_HOSTS = values.ListValue(None) 207 | CSRF_COOKIE_SECURE = True 208 | SECURE_BROWSER_XSS_FILTER = True 209 | SECURE_CONTENT_TYPE_NOSNIFF = True 210 | SESSION_COOKIE_SECURE = True 211 | # System check reference: 212 | # https://docs.djangoproject.com/en/2.2/ref/checks/#security 213 | SILENCED_SYSTEM_CHECKS = values.ListValue( 214 | [ 215 | # Allow to disable django.middleware.clickjacking.XFrameOptionsMiddleware 216 | # It is necessary since the LTI tool provider application will be displayed 217 | # in an iframe on external LMS sites. 218 | "security.W002", 219 | # SECURE_SSL_REDIRECT is not defined in the base configuration 220 | "security.W008", 221 | # No value is defined for SECURE_HSTS_SECONDS 222 | "security.W004", 223 | ] 224 | ) 225 | 226 | # For static files in production, we want to use a backend that includes a hash in 227 | # the filename, that is calculated from the file content, so that browsers always 228 | # get the updated version of each file. 229 | STORAGE = { 230 | "staticfiles": { 231 | "BACKEND": "django.contrib.staticfiles.storage.ManifestStaticFilesStorage", 232 | } 233 | } 234 | 235 | 236 | class Feature(Production): 237 | """ 238 | Feature environment settings 239 | 240 | nota bene: it should inherit from the Production environment. 241 | """ 242 | 243 | 244 | class Staging(Production): 245 | """ 246 | Staging environment settings 247 | 248 | nota bene: it should inherit from the Production environment. 249 | """ 250 | 251 | 252 | class PreProduction(Production): 253 | """ 254 | Pre-production environment settings 255 | 256 | nota bene: it should inherit from the Production environment. 257 | """ 258 | -------------------------------------------------------------------------------- /tests/lti_toolbox/test_launch_params.py: -------------------------------------------------------------------------------- 1 | """Test the LTI Launch parameters validator.""" 2 | 3 | from django.test import RequestFactory, TestCase 4 | 5 | from lti_toolbox.exceptions import InvalidParamException, MissingParamException 6 | from lti_toolbox.factories import LTIConsumerFactory, LTIPassportFactory 7 | from lti_toolbox.launch_params import LaunchParams, SelectionParams 8 | from lti_toolbox.utils import sign_parameters 9 | 10 | 11 | class LaunchParamTestCase(TestCase): 12 | """Test the LaunchParam class""" 13 | 14 | def setUp(self): 15 | """Override the setUp method to instantiate and serve a request factory.""" 16 | super().setUp() 17 | self.request_factory = RequestFactory() 18 | 19 | @staticmethod 20 | def _launch_params(lti_parameters): 21 | consumer = LTIConsumerFactory(slug="test_launch_params") 22 | passport = LTIPassportFactory(title="test passport", consumer=consumer) 23 | url = "http://testserver/lti/launch" 24 | signed_parameters = sign_parameters(passport, lti_parameters, url) 25 | return LaunchParams(signed_parameters) 26 | 27 | def test_only_required_parameters(self): 28 | """Test validation of a minimalistic LTI launch request with only required parameters.""" 29 | 30 | self._launch_params( 31 | { 32 | "lti_message_type": "basic-lti-launch-request", 33 | "lti_version": "LTI-1p0", 34 | "resource_link_id": "df7", 35 | } 36 | ) 37 | 38 | def test_missing_parameters(self): 39 | """Test missing required parameters.""" 40 | 41 | with self.assertRaises(MissingParamException): 42 | self._launch_params( 43 | { 44 | "lti_message_type": "basic-lti-launch-request", 45 | "resource_link_id": "df7", 46 | } 47 | ) 48 | 49 | with self.assertRaises(MissingParamException): 50 | self._launch_params( 51 | { 52 | "lti_message_type": "basic-lti-launch-request", 53 | "lti_version": "LTI-1p0", 54 | } 55 | ) 56 | 57 | with self.assertRaises(MissingParamException): 58 | self._launch_params({"lti_version": "LTI-1p0", "resource_link_id": "df7"}) 59 | 60 | def test_standard_request(self): 61 | """Test standard LTI launch request.""" 62 | 63 | self._launch_params( 64 | { 65 | "resource_link_id": "test-lms-3d09baddc21a365b7da5ae4d0aa5cb95", 66 | "lis_person_contact_email_primary": "jean-michel.test@example.com", 67 | "user_id": "cc09206e612fbdd5636f845dbf9676b3", 68 | "roles": "Instructor", 69 | "lis_result_sourcedid": "course-v1%3Atest%2B41018%2Bsession01:test-lms" 70 | "-3d09baddc21a365b7da5ae4d0aa5cb95:cc09206e612fbdd5636f845dbf9676b3", 71 | "context_id": "course-v1:test+41018+session01", 72 | "lti_version": "LTI-1p0", 73 | "launch_presentation_return_url": "", 74 | "lis_person_sourcedid": "jeanmich-t", 75 | "lti_message_type": "basic-lti-launch-request", 76 | } 77 | ) 78 | 79 | def test_custom_parameters(self): 80 | """Test LTI launch request with additional custom launch parameters.""" 81 | 82 | self._launch_params( 83 | { 84 | "resource_link_id": "test-lms-3d09baddc21a365b7da5ae4d0aa5cb95", 85 | "lis_person_contact_email_primary": "jean-michel.test@example.com", 86 | "user_id": "cc09206e612fbdd5636f845dbf9676b3", 87 | "roles": "Instructor", 88 | "lis_result_sourcedid": "course-v1%3Atest%2B41018%2Bsession01:test-lms" 89 | "-3d09baddc21a365b7da5ae4d0aa5cb95:cc09206e612fbdd5636f845dbf9676b3", 90 | "context_id": "course-v1:test+41018+session01", 91 | "lti_version": "LTI-1p0", 92 | "launch_presentation_return_url": "", 93 | "lis_person_sourcedid": "jeanmich-t", 94 | "lti_message_type": "basic-lti-launch-request", 95 | "custom_cohort_name": "cohort1", 96 | "custom_cohort_id": "ee4173aab07ea655999339f8d4fde0a2", 97 | } 98 | ) 99 | 100 | def test_urlencoded(self): 101 | """Test urlencoded representation of an LTI launch request.""" 102 | 103 | launch_params = LaunchParams( 104 | { 105 | "lti_message_type": "basic-lti-launch-request", 106 | "lti_version": "LTI-1p0", 107 | "resource_link_id": "df7", 108 | } 109 | ) 110 | expected = ( 111 | "lti_message_type=basic-lti-launch-request" 112 | "<i_version=LTI-1p0" 113 | "&resource_link_id=df7" 114 | ) 115 | self.assertEqual(expected, launch_params.urlencoded) 116 | 117 | def test_invalid_parameter(self): 118 | """Test behavior with invalid parameter in LTI launch request.""" 119 | with self.assertRaises(InvalidParamException): 120 | LaunchParams( 121 | { 122 | "lti_message_type": "basic-lti-launch-request", 123 | "lti_version": "LTI-1p0", 124 | "resource_link_id": "df7", 125 | "invalid_param": "foo", 126 | } 127 | ) 128 | 129 | 130 | class SelectionParamTestCase(TestCase): 131 | """Test the SelectionParam class""" 132 | 133 | def setUp(self): 134 | """Override the setUp method to instantiate and serve a request factory.""" 135 | super().setUp() 136 | self.request_factory = RequestFactory() 137 | 138 | @staticmethod 139 | def _selection_params(lti_parameters): 140 | consumer = LTIConsumerFactory(slug="test_launch_params") 141 | passport = LTIPassportFactory(title="test passport", consumer=consumer) 142 | url = "http://testserver/lti/launch" 143 | signed_parameters = sign_parameters(passport, lti_parameters, url) 144 | return SelectionParams(signed_parameters) 145 | 146 | def test_only_required_parameters(self): 147 | """Test validation of a minimalistic LTI Content-Item 148 | Selection request with only required parameters. 149 | """ 150 | 151 | self._selection_params( 152 | { 153 | "lti_message_type": "ContentItemSelectionRequest", 154 | "lti_version": "LTI-1p0", 155 | "accept_media_types": "application/vnd.ims.lti.v1.ltilink", 156 | "accept_presentation_document_targets": "frame,iframe,window", 157 | "content_item_return_url": "http://testserver/", 158 | } 159 | ) 160 | 161 | def test_missing_parameters(self): 162 | """Test missing required parameters.""" 163 | 164 | with self.assertRaises(MissingParamException): 165 | self._selection_params( 166 | { 167 | "lti_message_type": "ContentItemSelectionRequest", 168 | "lti_version": "LTI-1p0", 169 | "accept_media_types": "application/vnd.ims.lti.v1.ltilink", 170 | "accept_presentation_document_targets": "frame,iframe,window", 171 | } 172 | ) 173 | 174 | with self.assertRaises(MissingParamException): 175 | self._selection_params( 176 | { 177 | "lti_version": "LTI-1p0", 178 | "accept_media_types": "application/vnd.ims.lti.v1.ltilink", 179 | "accept_presentation_document_targets": "frame,iframe,window", 180 | "content_item_return_url": "http://test/", 181 | } 182 | ) 183 | 184 | with self.assertRaises(MissingParamException): 185 | self._selection_params( 186 | { 187 | "lti_message_type": "ContentItemSelectionRequest", 188 | "accept_media_types": "application/vnd.ims.lti.v1.ltilink", 189 | "accept_presentation_document_targets": "frame,iframe,window", 190 | "content_item_return_url": "http://test/", 191 | } 192 | ) 193 | 194 | def test_standard_request(self): 195 | """Test standard LTI Content-Item Selection request.""" 196 | 197 | self._selection_params( 198 | { 199 | "oauth_version": "1.0", 200 | "oauth_nonce": "fac452792511fd88c173f2208c1ad3c9", 201 | "oauth_timestamp": "1649681644", 202 | "oauth_consumer_key": "A9H5YBAYNERTBIBVEQS4", 203 | "user_id": "2", 204 | "lis_person_sourcedid": "", 205 | "roles": "Instructor", 206 | "context_id": "2", 207 | "context_label": "My first course", 208 | "context_title": "My first course", 209 | "context_type": "CourseSection", 210 | "lis_course_section_sourcedid": "", 211 | "lis_person_name_given": "Admin", 212 | "lis_person_name_family": "User", 213 | "lis_person_name_full": "Admin User", 214 | "ext_user_username": "admin", 215 | "lis_person_contact_email_primary": "demo@moodle.a", 216 | "launch_presentation_locale": "en", 217 | "ext_lms": "moodle-2", 218 | "tool_consumer_info_product_family_code": "moodle", 219 | "tool_consumer_info_version": "2021051706", 220 | "oauth_callback": "about:blank", 221 | "lti_version": "LTI-1p0", 222 | "lti_message_type": "ContentItemSelectionRequest", 223 | "tool_consumer_instance_guid": "1f60aaf6991f55818465e52f3d2879b7", 224 | "tool_consumer_instance_name": "Sandbox", 225 | "tool_consumer_instance_description": "Moodle sandbox demo", 226 | "accept_media_types": "application/vnd.ims.lti.v1.ltilink", 227 | "accept_presentation_document_targets": "frame,iframe,window", 228 | "accept_copy_advice": "false", 229 | "accept_multiple": "true", 230 | "accept_unsigned": "false", 231 | "auto_create": "false", 232 | "can_confirm": "false", 233 | "content_item_return_url": "https://woop.com", 234 | "title": ( 235 | "Marsha LTI provider (never empty : fallback to moodle external tool name)" 236 | ), 237 | "text": "(current activity description if exists)", 238 | "oauth_signature_method": "HMAC-SHA1", 239 | "oauth_signature": "GEetrp41W4gCH5m1Fe6RPhf55W4=", 240 | } 241 | ) 242 | 243 | def test_urlencoded(self): 244 | """Test urlencoded representation of an LTI launch request.""" 245 | 246 | selection_params = SelectionParams( 247 | { 248 | "lti_message_type": "ContentItemSelectionRequest", 249 | "lti_version": "LTI-1p0", 250 | "accept_media_types": "application/vnd.ims.lti.v1.ltilink", 251 | "accept_presentation_document_targets": "frame,iframe,window", 252 | "content_item_return_url": "http://testserver/", 253 | } 254 | ) 255 | expected = ( 256 | "lti_message_type=ContentItemSelectionRequest" 257 | "<i_version=LTI-1p0" 258 | "&accept_media_types=application%2Fvnd.ims.lti.v1.ltilink" 259 | "&accept_presentation_document_targets=frame%2Ciframe%2Cwindow" 260 | "&content_item_return_url=http%3A%2F%2Ftestserver%2F" 261 | ) 262 | self.assertEqual(expected, selection_params.urlencoded) 263 | 264 | def test_invalid_parameter(self): 265 | """Test behavior with invalid parameter in LTI launch request.""" 266 | with self.assertRaises(InvalidParamException): 267 | SelectionParams( 268 | { 269 | "lti_message_type": "ContentItemSelectionRequest", 270 | "lti_version": "LTI-1p0", 271 | "accept_media_types": "application/vnd.ims.lti.v1.ltilink", 272 | "accept_presentation_document_targets": "frame,iframe,window", 273 | "content_item_return_url": "http://testserver/", 274 | "invalid_param": "foo", 275 | } 276 | ) 277 | -------------------------------------------------------------------------------- /tests/lti_toolbox/test_lti.py: -------------------------------------------------------------------------------- 1 | """Test the LTI interconnection with an LTI consumer.""" 2 | 3 | from urllib.parse import urlencode 4 | 5 | from django.test import RequestFactory, TestCase 6 | 7 | from lti_toolbox.exceptions import LTIException, LTIRequestNotVerifiedException 8 | from lti_toolbox.factories import LTIConsumerFactory, LTIPassportFactory 9 | from lti_toolbox.launch_params import LTIRole 10 | from lti_toolbox.lti import LTI 11 | from lti_toolbox.utils import CONTENT_TYPE, sign_parameters 12 | 13 | 14 | class LTITestCase(TestCase): 15 | """Test the LTI class""" 16 | 17 | def setUp(self): 18 | """Override the setUp method to instantiate and serve a request factory.""" 19 | super().setUp() 20 | self.request_factory = RequestFactory() 21 | self._consumer = LTIConsumerFactory(slug="test_lti") 22 | self._passport = LTIPassportFactory( 23 | title="test passport", consumer=self._consumer 24 | ) 25 | self._url = "http://testserver/lti/launch" 26 | 27 | def _verified_lti_request(self, lti_parameters): 28 | signed_parameters = sign_parameters(self._passport, lti_parameters, self._url) 29 | lti = self._lti_request(signed_parameters, self._url) 30 | lti.verify() 31 | return lti 32 | 33 | def _lti_request(self, signed_parameters, url): 34 | request = self.request_factory.post( 35 | url, 36 | data=urlencode(signed_parameters), 37 | content_type=CONTENT_TYPE, 38 | ) 39 | return LTI(request) 40 | 41 | def test_verify_signature(self): 42 | """Test the oauth 1.0 signature verification""" 43 | 44 | lti_parameters = { 45 | "lti_message_type": "basic-lti-launch-request", 46 | "lti_version": "LTI-1p0", 47 | "resource_link_id": "df7", 48 | "context_id": "course-v1:fooschool+mathematics+0042", 49 | "roles": "Instructor", 50 | } 51 | 52 | signed_parameters = sign_parameters(self._passport, lti_parameters, self._url) 53 | lti = self._lti_request(signed_parameters, self._url) 54 | self.assertFalse(lti.is_valid) 55 | self.assertTrue(lti.verify()) 56 | self.assertTrue(lti.is_valid) 57 | 58 | # If we alter the signature (e.g. add "a" to it), the verification should fail 59 | signed_parameters["oauth_signature"] = "{:s}a".format( 60 | signed_parameters["oauth_signature"] 61 | ) 62 | lti = self._lti_request(signed_parameters, self._url) 63 | with self.assertRaises(LTIException): 64 | self.assertFalse(lti.verify()) 65 | self.assertFalse(lti.is_valid) 66 | 67 | def test_replay_attack(self): 68 | """Test a replay attack""" 69 | 70 | lti_parameters = { 71 | "lti_message_type": "basic-lti-launch-request", 72 | "lti_version": "LTI-1p0", 73 | "resource_link_id": "df7", 74 | "context_id": "course-v1:fooschool+mathematics+0042", 75 | "roles": "Instructor", 76 | } 77 | 78 | signed_parameters = sign_parameters(self._passport, lti_parameters, self._url) 79 | lti = self._lti_request(signed_parameters, self._url) 80 | self.assertTrue(lti.verify()) 81 | 82 | replayed_lti = self._lti_request(signed_parameters, self._url) 83 | with self.assertRaises(LTIException): 84 | self.assertFalse(replayed_lti.verify()) 85 | 86 | def test_invalid_param(self): 87 | """Test the behaviour of LTI verification when an invalid LTI parameter is given""" 88 | 89 | lti_parameters = { 90 | "lti_message_type": "basic-lti-launch-request", 91 | "lti_version": "LTI-1p0", 92 | "resource_link_id": "df7", 93 | "invalid_param": "hello!", 94 | } 95 | # Should always raise an LTException on failure 96 | with self.assertRaises(LTIException): 97 | self._verified_lti_request(lti_parameters) 98 | 99 | def test_get_param(self): 100 | """Test the get_param method""" 101 | lti_parameters = { 102 | "lti_message_type": "basic-lti-launch-request", 103 | "lti_version": "LTI-1p0", 104 | "resource_link_id": "df7", 105 | "custom_param": "custom value", 106 | } 107 | signed_parameters = sign_parameters(self._passport, lti_parameters, self._url) 108 | lti = self._lti_request(signed_parameters, self._url) 109 | with self.assertRaises(LTIRequestNotVerifiedException): 110 | lti.get_param("custom_param") 111 | 112 | lti.verify() 113 | self.assertEqual("custom value", lti.get_param("custom_param")) 114 | 115 | self.assertEqual( 116 | "default value", lti.get_param("custom_nonexistent_param", "default value") 117 | ) 118 | 119 | def test_get_consumer(self): 120 | """Test the retrieval of the consumer that initiated the launch request""" 121 | lti = self._verified_lti_request( 122 | { 123 | "lti_message_type": "basic-lti-launch-request", 124 | "lti_version": "LTI-1p0", 125 | "resource_link_id": "df7", 126 | } 127 | ) 128 | self.assertEqual(self._consumer.slug, lti.get_consumer().slug) 129 | 130 | def test_is_edx_format(self): 131 | """Test the detection of EdX course format""" 132 | lti_parameters = { 133 | "lti_message_type": "basic-lti-launch-request", 134 | "lti_version": "LTI-1p0", 135 | "resource_link_id": "df7", 136 | "context_id": "course-v1:fooschool+mathematics+0042", 137 | "roles": "Instructor", 138 | } 139 | lti = self._verified_lti_request(lti_parameters) 140 | self.assertTrue(lti.is_edx_format) 141 | 142 | lti_parameters.update({"context_id": "foo-context"}) 143 | lti = self._verified_lti_request(lti_parameters) 144 | self.assertFalse(lti.is_edx_format) 145 | 146 | def test_is_moodle_format(self): 147 | """Test the detection of Moodle course format""" 148 | lti_parameters = { 149 | "lti_message_type": "basic-lti-launch-request", 150 | "lti_version": "LTI-1p0", 151 | "resource_link_id": "df7", 152 | "context_id": "1542", 153 | "roles": "Instructor", 154 | "tool_consumer_info_product_family_code": "moodle", 155 | } 156 | lti = self._verified_lti_request(lti_parameters) 157 | self.assertTrue(lti.is_moodle_format) 158 | 159 | lti_parameters.update({"tool_consumer_info_product_family_code": ""}) 160 | lti = self._verified_lti_request(lti_parameters) 161 | self.assertFalse(lti.is_moodle_format) 162 | 163 | def test_course_info(self): 164 | """Test the detection of course information""" 165 | 166 | # EdX request 167 | lti_parameters = { 168 | "lti_message_type": "basic-lti-launch-request", 169 | "lti_version": "LTI-1p0", 170 | "resource_link_id": "df7", 171 | "context_id": "course-v1:fooschool+mathematics+0042", 172 | "context_title": "some context", 173 | "roles": "Instructor", 174 | } 175 | lti = self._verified_lti_request(lti_parameters) 176 | course_info = lti.get_course_info() 177 | self.assertEqual("fooschool", course_info.get("school_name")) 178 | self.assertEqual("mathematics", course_info.get("course_name")) 179 | self.assertEqual("0042", course_info.get("course_run")) 180 | 181 | # Non-EdX request 182 | lti_parameters.update( 183 | {"context_id": "foo-context", "tool_consumer_instance_name": "bar-school"} 184 | ) 185 | lti = self._verified_lti_request(lti_parameters) 186 | course_info = lti.get_course_info() 187 | self.assertEqual("bar-school", course_info.get("school_name")) 188 | self.assertEqual("some context", course_info.get("course_name")) 189 | self.assertIsNone(course_info.get("course_run")) 190 | 191 | def test_resource_link_title(self): 192 | """Test the retrieval of the resource_link_title""" 193 | 194 | lti_parameters = { 195 | "lti_message_type": "basic-lti-launch-request", 196 | "lti_version": "LTI-1p0", 197 | "resource_link_id": "df7", 198 | "context_id": "course-v1:fooschool+mathematics+0042", 199 | "context_title": "some context", 200 | "roles": "Instructor", 201 | } 202 | lti = self._verified_lti_request(lti_parameters) 203 | # If there is no resource_link_title parameter, it defaults to resource_link_id 204 | self.assertEqual("df7", lti.resource_link_title) 205 | 206 | lti_parameters.update({"resource_link_title": "some title"}) 207 | lti = self._verified_lti_request(lti_parameters) 208 | self.assertEqual("some title", lti.resource_link_title) 209 | 210 | def test_roles(self): 211 | """Test the retrieval of the roles""" 212 | lti = self._verified_lti_request( 213 | { 214 | "lti_message_type": "basic-lti-launch-request", 215 | "lti_version": "LTI-1p0", 216 | "resource_link_id": "df7", 217 | "roles": "Instructor", 218 | } 219 | ) 220 | self.assertEqual(["instructor"], lti.roles) 221 | 222 | lti = self._verified_lti_request( 223 | { 224 | "lti_message_type": "basic-lti-launch-request", 225 | "lti_version": "LTI-1p0", 226 | "resource_link_id": "df7", 227 | "roles": "Student,Moderator", 228 | } 229 | ) 230 | self.assertEqual(["student", "moderator"], lti.roles) 231 | 232 | def test_has_any_of_roles(self): 233 | # pylint: disable=protected-access 234 | """Test _has_any_of_roles property""" 235 | lti = self._verified_lti_request( 236 | { 237 | "lti_message_type": "basic-lti-launch-request", 238 | "lti_version": "LTI-1p0", 239 | "resource_link_id": "df7", 240 | "roles": "Instructor", 241 | } 242 | ) 243 | self.assertTrue(lti._has_any_of_roles({"instructor"})) 244 | self.assertTrue(lti._has_any_of_roles({"instructor", LTIRole.TEACHER})) 245 | self.assertFalse(lti._has_any_of_roles({"Instructor", LTIRole.TEACHER})) 246 | self.assertTrue(lti._has_any_of_roles({LTIRole.INSTRUCTOR, LTIRole.TEACHER})) 247 | 248 | def test_roles_check(self): 249 | """Test the roles-check properties""" 250 | lti = self._verified_lti_request( 251 | { 252 | "lti_message_type": "basic-lti-launch-request", 253 | "lti_version": "LTI-1p0", 254 | "resource_link_id": "df7", 255 | "roles": "Instructor", 256 | } 257 | ) 258 | self.assertTrue(lti.is_instructor) 259 | self.assertFalse(lti.is_administrator) 260 | self.assertFalse(lti.is_student) 261 | 262 | lti = self._verified_lti_request( 263 | { 264 | "lti_message_type": "basic-lti-launch-request", 265 | "lti_version": "LTI-1p0", 266 | "resource_link_id": "df7", 267 | "roles": "Student,Moderator", 268 | } 269 | ) 270 | self.assertFalse(lti.is_instructor) 271 | self.assertFalse(lti.is_administrator) 272 | self.assertTrue(lti.is_student) 273 | 274 | lti = self._verified_lti_request( 275 | { 276 | "lti_message_type": "basic-lti-launch-request", 277 | "lti_version": "LTI-1p0", 278 | "resource_link_id": "df7", 279 | "roles": "Administrator,Instructor", 280 | } 281 | ) 282 | self.assertTrue(lti.is_instructor) 283 | self.assertTrue(lti.is_administrator) 284 | self.assertFalse(lti.is_student) 285 | 286 | lti = self._verified_lti_request( 287 | { 288 | "lti_message_type": "basic-lti-launch-request", 289 | "lti_version": "LTI-1p0", 290 | "resource_link_id": "df7", 291 | "roles": "WrongRole", 292 | } 293 | ) 294 | self.assertFalse(lti.is_instructor) 295 | self.assertFalse(lti.is_administrator) 296 | self.assertFalse(lti.is_student) 297 | 298 | def test_can_edit_content(self): 299 | """Test can_edit_content property""" 300 | lti = self._verified_lti_request( 301 | { 302 | "lti_message_type": "basic-lti-launch-request", 303 | "lti_version": "LTI-1p0", 304 | "resource_link_id": "df7", 305 | "roles": "Instructor", 306 | } 307 | ) 308 | self.assertTrue(lti.can_edit_content) 309 | 310 | lti = self._verified_lti_request( 311 | { 312 | "lti_message_type": "basic-lti-launch-request", 313 | "lti_version": "LTI-1p0", 314 | "resource_link_id": "df7", 315 | "roles": "Student,Moderator", 316 | } 317 | ) 318 | self.assertFalse(lti.can_edit_content) 319 | 320 | lti = self._verified_lti_request( 321 | { 322 | "lti_message_type": "basic-lti-launch-request", 323 | "lti_version": "LTI-1p0", 324 | "resource_link_id": "df7", 325 | "roles": "Administrator,Instructor", 326 | } 327 | ) 328 | self.assertTrue(lti.can_edit_content) 329 | 330 | lti = self._verified_lti_request( 331 | { 332 | "lti_message_type": "basic-lti-launch-request", 333 | "lti_version": "LTI-1p0", 334 | "resource_link_id": "df7", 335 | "roles": "WrongRole", 336 | } 337 | ) 338 | self.assertFalse(lti.can_edit_content) 339 | 340 | def test_context_title(self): 341 | """Test the retrieval of the context_title""" 342 | lti_parameters = { 343 | "lti_message_type": "basic-lti-launch-request", 344 | "lti_version": "LTI-1p0", 345 | "resource_link_id": "df7", 346 | "context_id": "the context id", 347 | } 348 | lti = self._verified_lti_request(lti_parameters) 349 | # If context_title parameter is not defined, it defaults to the context_id 350 | self.assertEqual("the context id", lti.context_title) 351 | 352 | lti_parameters.update({"context_title": "the context title"}) 353 | lti = self._verified_lti_request(lti_parameters) 354 | self.assertEqual("the context title", lti.context_title) 355 | 356 | def test_lti_origin_url_edx(self): 357 | """Build origin_url for an edx request.""" 358 | lti_parameters = { 359 | "lti_message_type": "basic-lti-launch-request", 360 | "lti_version": "LTI-1p0", 361 | "resource_link_id": "df7", 362 | "context_id": "course-v1:fooschool+mathematics+0042", 363 | "roles": "Instructor", 364 | } 365 | lti = self._verified_lti_request(lti_parameters) 366 | 367 | self.assertEqual( 368 | lti.origin_url, 369 | "https://testserver/consumer-20/course/course-v1:fooschool+mathematics+0042", 370 | ) 371 | 372 | def test_lti_origin_url_moodle(self): 373 | """Build origin_url for an edx request.""" 374 | lti_parameters = { 375 | "lti_message_type": "basic-lti-launch-request", 376 | "lti_version": "LTI-1p0", 377 | "resource_link_id": "df7", 378 | "context_id": "123", 379 | "roles": "Instructor", 380 | "tool_consumer_info_product_family_code": "moodle", 381 | } 382 | lti = self._verified_lti_request(lti_parameters) 383 | 384 | self.assertEqual( 385 | lti.origin_url, "https://testserver/consumer-21/course/view.php?id=123" 386 | ) 387 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | 3 | # A comma-separated list of package or module names from where C extensions may 4 | # be loaded. Extensions are loading into the active Python interpreter and may 5 | # run arbitrary code 6 | extension-pkg-whitelist= 7 | 8 | # Add files or directories to the blacklist. They should be base names, not 9 | # paths. 10 | ignore=migrations 11 | 12 | # Add files or directories matching the regex patterns to the blacklist. The 13 | # regex matches against base names, not paths. 14 | ignore-patterns= 15 | 16 | # Python code to execute, usually for sys.path manipulation such as 17 | # pygtk.require(). 18 | #init-hook= 19 | 20 | # Use multiple processes to speed up Pylint. 21 | jobs=1 22 | 23 | # List of plugins (as comma separated values of python modules names) to load, 24 | # usually to register additional checkers. 25 | load-plugins=pylint_django 26 | 27 | # Pickle collected data for later comparisons. 28 | persistent=yes 29 | 30 | # Specify a configuration file. 31 | #rcfile= 32 | 33 | # When enabled, pylint would attempt to guess common misconfiguration and emit 34 | # user-friendly hints instead of false-positive error messages 35 | suggestion-mode=yes 36 | 37 | # Allow loading of arbitrary C extensions. Extensions are imported into the 38 | # active Python interpreter and may run arbitrary code. 39 | unsafe-load-any-extension=no 40 | 41 | 42 | [MESSAGES CONTROL] 43 | 44 | # Only show warnings with the listed confidence levels. Leave empty to show 45 | # all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED 46 | confidence= 47 | 48 | # Disable the message, report, category or checker with the given id(s). You 49 | # can either give multiple identifiers separated by comma (,) or put this 50 | # option multiple times (only on the command line, not in the configuration 51 | # file where it should appear only once).You can also use "--disable=all" to 52 | # disable everything first and then reenable specific checks. For example, if 53 | # you want to run only the similarities checker, you can use "--disable=all 54 | # --enable=similarities". If you want to run only the classes checker, but have 55 | # no Warning level messages displayed, use"--disable=all --enable=classes 56 | # --disable=W" 57 | disable=print-statement, 58 | parameter-unpacking, 59 | unpacking-in-except, 60 | old-raise-syntax, 61 | backtick, 62 | long-suffix, 63 | old-ne-operator, 64 | old-octal-literal, 65 | import-star-module-level, 66 | non-ascii-bytes-literal, 67 | raw-checker-failed, 68 | bad-inline-option, 69 | locally-disabled, 70 | locally-enabled, 71 | file-ignored, 72 | suppressed-message, 73 | useless-suppression, 74 | deprecated-pragma, 75 | apply-builtin, 76 | basestring-builtin, 77 | buffer-builtin, 78 | cmp-builtin, 79 | coerce-builtin, 80 | execfile-builtin, 81 | file-builtin, 82 | long-builtin, 83 | raw_input-builtin, 84 | reduce-builtin, 85 | standarderror-builtin, 86 | unicode-builtin, 87 | xrange-builtin, 88 | coerce-method, 89 | delslice-method, 90 | getslice-method, 91 | setslice-method, 92 | no-absolute-import, 93 | old-division, 94 | dict-iter-method, 95 | dict-view-method, 96 | next-method-called, 97 | metaclass-assignment, 98 | indexing-exception, 99 | raising-string, 100 | reload-builtin, 101 | oct-method, 102 | hex-method, 103 | nonzero-method, 104 | cmp-method, 105 | input-builtin, 106 | round-builtin, 107 | intern-builtin, 108 | unichr-builtin, 109 | map-builtin-not-iterating, 110 | zip-builtin-not-iterating, 111 | range-builtin-not-iterating, 112 | filter-builtin-not-iterating, 113 | using-cmp-argument, 114 | eq-without-hash, 115 | div-method, 116 | idiv-method, 117 | rdiv-method, 118 | exception-message-attribute, 119 | invalid-str-codec, 120 | sys-max-int, 121 | bad-python3-import, 122 | deprecated-string-function, 123 | deprecated-str-translate-call, 124 | deprecated-itertools-function, 125 | deprecated-types-field, 126 | next-method-defined, 127 | dict-items-not-iterating, 128 | dict-keys-not-iterating, 129 | dict-values-not-iterating, 130 | bad-continuation 131 | 132 | # Enable the message, report, category or checker with the given id(s). You can 133 | # either give multiple identifier separated by comma (,) or put this option 134 | # multiple time (only on the command line, not in the configuration file where 135 | # it should appear only once). See also the "--disable" option for examples. 136 | enable=c-extension-no-member 137 | 138 | 139 | [REPORTS] 140 | 141 | # Python expression which should return a note less than 10 (10 is the highest 142 | # note). You have access to the variables errors warning, statement which 143 | # respectively contain the number of errors / warnings messages and the total 144 | # number of statements analyzed. This is used by the global evaluation report 145 | # (RP0004). 146 | evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) 147 | 148 | # Template used to display messages. This is a python new-style format string 149 | # used to format the message information. See doc for all details 150 | #msg-template= 151 | 152 | # Set the output format. Available formats are text, parseable, colorized, json 153 | # and msvs (visual studio).You can also give a reporter class, eg 154 | # mypackage.mymodule.MyReporterClass. 155 | output-format=text 156 | 157 | # Tells whether to display a full report or only the messages 158 | reports=no 159 | 160 | # Activate the evaluation score. 161 | score=yes 162 | 163 | 164 | [REFACTORING] 165 | 166 | # Maximum number of nested blocks for function / method body 167 | max-nested-blocks=5 168 | 169 | # Complete name of functions that never returns. When checking for 170 | # inconsistent-return-statements if a never returning function is called then 171 | # it will be considered as an explicit return statement and no message will be 172 | # printed. 173 | never-returning-functions=optparse.Values,sys.exit 174 | 175 | 176 | [LOGGING] 177 | 178 | # Logging modules to check that the string format arguments are in logging 179 | # function parameter format 180 | logging-modules=logging 181 | 182 | 183 | [SPELLING] 184 | 185 | # Limits count of emitted suggestions for spelling mistakes 186 | max-spelling-suggestions=4 187 | 188 | # Spelling dictionary name. Available dictionaries: none. To make it working 189 | # install python-enchant package. 190 | spelling-dict= 191 | 192 | # List of comma separated words that should not be checked. 193 | spelling-ignore-words= 194 | 195 | # A path to a file that contains private dictionary; one word per line. 196 | spelling-private-dict-file= 197 | 198 | # Tells whether to store unknown words to indicated private dictionary in 199 | # --spelling-private-dict-file option instead of raising a message. 200 | spelling-store-unknown-words=no 201 | 202 | 203 | [MISCELLANEOUS] 204 | 205 | # List of note tags to take in consideration, separated by a comma. 206 | notes=FIXME, 207 | XXX, 208 | TODO 209 | 210 | 211 | [TYPECHECK] 212 | 213 | # List of decorators that produce context managers, such as 214 | # contextlib.contextmanager. Add to this list to register other decorators that 215 | # produce valid context managers. 216 | contextmanager-decorators=contextlib.contextmanager 217 | 218 | # List of members which are set dynamically and missed by pylint inference 219 | # system, and so shouldn't trigger E1101 when accessed. Python regular 220 | # expressions are accepted. 221 | generated-members= 222 | 223 | # Tells whether missing members accessed in mixin class should be ignored. A 224 | # mixin class is detected if its name ends with "mixin" (case insensitive). 225 | ignore-mixin-members=yes 226 | 227 | # This flag controls whether pylint should warn about no-member and similar 228 | # checks whenever an opaque object is returned when inferring. The inference 229 | # can return multiple potential results while evaluating a Python object, but 230 | # some branches might not be evaluated, which results in partial inference. In 231 | # that case, it might be useful to still emit no-member and other checks for 232 | # the rest of the inferred objects. 233 | ignore-on-opaque-inference=yes 234 | 235 | # List of class names for which member attributes should not be checked (useful 236 | # for classes with dynamically set attributes). This supports the use of 237 | # qualified names. 238 | ignored-classes=optparse.Values,thread._local,_thread._local,responses, 239 | Course,Organization,Page,Person,PersonTitle,Category 240 | 241 | # List of module names for which member attributes should not be checked 242 | # (useful for modules/projects where namespaces are manipulated during runtime 243 | # and thus existing member attributes cannot be deduced by static analysis. It 244 | # supports qualified module names, as well as Unix pattern matching. 245 | ignored-modules= 246 | 247 | # Show a hint with possible names when a member name was not found. The aspect 248 | # of finding the hint is based on edit distance. 249 | missing-member-hint=yes 250 | 251 | # The minimum edit distance a name should have in order to be considered a 252 | # similar match for a missing member name. 253 | missing-member-hint-distance=1 254 | 255 | # The total number of similar names that should be taken in consideration when 256 | # showing a hint for a missing member. 257 | missing-member-max-choices=1 258 | 259 | 260 | [VARIABLES] 261 | 262 | # List of additional names supposed to be defined in builtins. Remember that 263 | # you should avoid to define new builtins when possible. 264 | additional-builtins= 265 | 266 | # Tells whether unused global variables should be treated as a violation. 267 | allow-global-unused-variables=yes 268 | 269 | # List of strings which can identify a callback function by name. A callback 270 | # name must start or end with one of those strings. 271 | callbacks=cb_, 272 | _cb 273 | 274 | # A regular expression matching the name of dummy variables (i.e. expectedly 275 | # not used). 276 | dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ 277 | 278 | # Argument names that match this expression will be ignored. Default to name 279 | # with leading underscore 280 | ignored-argument-names=_.*|^ignored_|^unused_ 281 | 282 | # Tells whether we should check for unused import in __init__ files. 283 | init-import=no 284 | 285 | # List of qualified module names which can have objects that can redefine 286 | # builtins. 287 | redefining-builtins-modules=six.moves,past.builtins,future.builtins 288 | 289 | 290 | [FORMAT] 291 | 292 | # Expected format of line ending, e.g. empty (any line ending), LF or CRLF. 293 | expected-line-ending-format= 294 | 295 | # Regexp for a line that is allowed to be longer than the limit. 296 | ignore-long-lines=^\s*(# )??$ 297 | 298 | # Number of spaces of indent required inside a hanging or continued line. 299 | indent-after-paren=4 300 | 301 | # String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 302 | # tab). 303 | indent-string=' ' 304 | 305 | # Maximum number of characters on a single line. 306 | max-line-length=100 307 | 308 | # Maximum number of lines in a module 309 | max-module-lines=1000 310 | 311 | # List of optional constructs for which whitespace checking is disabled. `dict- 312 | # separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}. 313 | # `trailing-comma` allows a space between comma and closing bracket: (a, ). 314 | # `empty-line` allows space-only lines. 315 | no-space-check=trailing-comma, 316 | dict-separator 317 | 318 | # Allow the body of a class to be on the same line as the declaration if body 319 | # contains single statement. 320 | single-line-class-stmt=no 321 | 322 | # Allow the body of an if to be on the same line as the test if there is no 323 | # else. 324 | single-line-if-stmt=no 325 | 326 | 327 | [SIMILARITIES] 328 | 329 | # Ignore comments when computing similarities. 330 | ignore-comments=yes 331 | 332 | # Ignore docstrings when computing similarities. 333 | ignore-docstrings=yes 334 | 335 | # Ignore imports when computing similarities. 336 | ignore-imports=yes 337 | 338 | # Minimum lines number of a similarity. 339 | # First implementations of CMS wizards have common fields we do not want to factorize for now 340 | min-similarity-lines=35 341 | 342 | 343 | [BASIC] 344 | 345 | # Naming style matching correct argument names 346 | argument-naming-style=snake_case 347 | 348 | # Regular expression matching correct argument names. Overrides argument- 349 | # naming-style 350 | #argument-rgx= 351 | 352 | # Naming style matching correct attribute names 353 | attr-naming-style=snake_case 354 | 355 | # Regular expression matching correct attribute names. Overrides attr-naming- 356 | # style 357 | #attr-rgx= 358 | 359 | # Bad variable names which should always be refused, separated by a comma 360 | bad-names=foo, 361 | bar, 362 | baz, 363 | toto, 364 | tutu, 365 | tata 366 | 367 | # Naming style matching correct class attribute names 368 | class-attribute-naming-style=any 369 | 370 | # Regular expression matching correct class attribute names. Overrides class- 371 | # attribute-naming-style 372 | #class-attribute-rgx= 373 | 374 | # Naming style matching correct class names 375 | class-naming-style=PascalCase 376 | 377 | # Regular expression matching correct class names. Overrides class-naming-style 378 | #class-rgx= 379 | 380 | # Naming style matching correct constant names 381 | const-naming-style=UPPER_CASE 382 | 383 | # Regular expression matching correct constant names. Overrides const-naming- 384 | # style 385 | const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__)|urlpatterns|logger)$ 386 | 387 | # Minimum line length for functions/classes that require docstrings, shorter 388 | # ones are exempt. 389 | docstring-min-length=-1 390 | 391 | # Naming style matching correct function names 392 | function-naming-style=snake_case 393 | 394 | # Regular expression matching correct function names. Overrides function- 395 | # naming-style 396 | #function-rgx= 397 | 398 | # Good variable names which should always be accepted, separated by a comma 399 | good-names=i, 400 | j, 401 | k, 402 | cm, 403 | ex, 404 | Run, 405 | _ 406 | 407 | # Include a hint for the correct naming format with invalid-name 408 | include-naming-hint=no 409 | 410 | # Naming style matching correct inline iteration names 411 | inlinevar-naming-style=any 412 | 413 | # Regular expression matching correct inline iteration names. Overrides 414 | # inlinevar-naming-style 415 | #inlinevar-rgx= 416 | 417 | # Naming style matching correct method names 418 | method-naming-style=snake_case 419 | 420 | # Regular expression matching correct method names. Overrides method-naming- 421 | # style 422 | method-rgx=([a-z_][a-z0-9_]{2,50}|setUp|set[Uu]pClass|tearDown|tear[Dd]ownClass|assert[A-Z]\w*|maxDiff|test_[a-z0-9_]+)$ 423 | 424 | # Naming style matching correct module names 425 | module-naming-style=snake_case 426 | 427 | # Regular expression matching correct module names. Overrides module-naming- 428 | # style 429 | #module-rgx= 430 | 431 | # Colon-delimited sets of names that determine each other's naming style when 432 | # the name regexes allow several styles. 433 | name-group= 434 | 435 | # Regular expression which should only match function or class names that do 436 | # not require a docstring. 437 | no-docstring-rgx=^_ 438 | 439 | # List of decorators that produce properties, such as abc.abstractproperty. Add 440 | # to this list to register other decorators that produce valid properties. 441 | property-classes=abc.abstractproperty 442 | 443 | # Naming style matching correct variable names 444 | variable-naming-style=snake_case 445 | 446 | # Regular expression matching correct variable names. Overrides variable- 447 | # naming-style 448 | #variable-rgx= 449 | 450 | 451 | [IMPORTS] 452 | 453 | # Allow wildcard imports from modules that define __all__. 454 | allow-wildcard-with-all=no 455 | 456 | # Analyse import fallback blocks. This can be used to support both Python 2 and 457 | # 3 compatible code, which means that the block might have code that exists 458 | # only in one or another interpreter, leading to false positives when analysed. 459 | analyse-fallback-blocks=no 460 | 461 | # Deprecated modules which should not be used, separated by a comma 462 | deprecated-modules=optparse,tkinter.tix 463 | 464 | # Create a graph of external dependencies in the given file (report RP0402 must 465 | # not be disabled) 466 | ext-import-graph= 467 | 468 | # Create a graph of every (i.e. internal and external) dependencies in the 469 | # given file (report RP0402 must not be disabled) 470 | import-graph= 471 | 472 | # Create a graph of internal dependencies in the given file (report RP0402 must 473 | # not be disabled) 474 | int-import-graph= 475 | 476 | # Force import order to recognize a module as part of the standard 477 | # compatibility libraries. 478 | known-standard-library= 479 | 480 | # Force import order to recognize a module as part of a third party library. 481 | known-third-party=enchant 482 | 483 | 484 | [CLASSES] 485 | 486 | # List of method names used to declare (i.e. assign) instance attributes. 487 | defining-attr-methods=__init__, 488 | __new__, 489 | setUp 490 | 491 | # List of member names, which should be excluded from the protected access 492 | # warning. 493 | exclude-protected=_asdict, 494 | _fields, 495 | _replace, 496 | _source, 497 | _make 498 | 499 | # List of valid names for the first argument in a class method. 500 | valid-classmethod-first-arg=cls 501 | 502 | # List of valid names for the first argument in a metaclass class method. 503 | valid-metaclass-classmethod-first-arg=mcs 504 | 505 | 506 | [DESIGN] 507 | 508 | # Maximum number of arguments for function / method 509 | max-args=5 510 | 511 | # Maximum number of attributes for a class (see R0902). 512 | max-attributes=7 513 | 514 | # Maximum number of boolean expressions in a if statement 515 | max-bool-expr=5 516 | 517 | # Maximum number of branch for function / method body 518 | max-branches=12 519 | 520 | # Maximum number of locals for function / method body 521 | max-locals=15 522 | 523 | # Maximum number of parents for a class (see R0901). 524 | max-parents=7 525 | 526 | # Maximum number of public methods for a class (see R0904). 527 | max-public-methods=20 528 | 529 | # Maximum number of return / yield for function / method body 530 | max-returns=6 531 | 532 | # Maximum number of statements in function / method body 533 | max-statements=50 534 | 535 | # Minimum number of public methods for a class (see R0903). 536 | min-public-methods=0 537 | 538 | 539 | [EXCEPTIONS] 540 | 541 | # Exceptions that will emit a warning when being caught. Defaults to 542 | # "Exception" 543 | overgeneral-exceptions=Exception 544 | --------------------------------------------------------------------------------