Skip to content

Commit

Permalink
Merge pull request #103 from uw-it-aca/feature/additive-1-point-3
Browse files Browse the repository at this point in the history
Feature/additive 1 point 3
  • Loading branch information
mikeseibel authored Dec 4, 2024
2 parents a2df38b + 59c16f8 commit 6637605
Show file tree
Hide file tree
Showing 40 changed files with 1,458 additions and 803 deletions.
12 changes: 1 addition & 11 deletions .github/workflows/cicd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,6 @@ jobs:
strategy:
matrix:
python-version:
- '3.8'
- '3.10'
django-version:
- '3.2'
Expand All @@ -72,25 +71,16 @@ jobs:
- name: Upgrade Django ${{ matrix.django-version }}
run: pip install "Django~=${{ matrix.django-version }}.0"

- name: Setup Django
run: |
django-admin startproject project .
test -f ${CONF_PATH}/urls.py && cp ${CONF_PATH}/urls.py project/
test -f ${CONF_PATH}/settings.py && cat ${CONF_PATH}/settings.py >> project/settings.py
- name: Run Python Linters
uses: uw-it-aca/actions/python-linters@main
with:
app_name: ${APP_NAME}
exclude_paths: 'migrations'

- name: Run Migrations
run: python manage.py migrate

- name: Run Tests
run: |
python -m compileall ${APP_NAME}/
coverage run --source=${APP_NAME}/ manage.py test ${APP_NAME}
coverage run --source=${APP_NAME}/ ${APP_NAME}/runtests.py
- name: Report Test Coverage
if: |
Expand Down
132 changes: 92 additions & 40 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,49 +1,101 @@
# Django BLTI Provider

[![Build Status](https://github.com/uw-it-aca/django-blti/workflows/tests/badge.svg?branch=main)](https://github.com/uw-it-aca/django-blti/actions)
[![Coverage Status](https://coveralls.io/repos/github/uw-it-aca/django-blti/badge.svg?branch=main)](https://coveralls.io/github/uw-it-aca/django-blti?branch=main)
[![PyPi Version](https://img.shields.io/pypi/v/django-blti.svg)](https://pypi.python.org/pypi/django-blti)
![Python versions](https://img.shields.io/badge/python-3.10-blue.svg)

# Documentation

A Django application on which to build IMS BLTI Tool Providers

Installation
------------

**Project directory**

Install django-blti in your project.
django-blti is a Django web framework application intended so serve
as a base for [IMS LTI 1.3](https://www.imsglobal.org/spec/lti/v1p3)
Tool projects. It implements common class-based views providing launch
authentication, payload normalization, and role based authorization.
It also includes optional endpoints for tool development based on
mock payloads. We understand and regret that the ``b`` in the package
name is a little misleading, but it is what it is.

$ cd [project]
## Installation
```
$ pip install django-blti

Project settings.py
------------------

**INSTALLED_APPS**

'blti',

**MIDDLEWARE_CLASSES**

'django.middleware.common.CommonMiddleware',
'blti.middleware.CSRFHeaderMiddleware',
'blti.middleware.SessionHeaderMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',

**Additional settings**

# BLTI consumer key:secret pairs
LTI_CONSUMERS = {
'<unique_consumer_key>': '<32_or_more_bytes_of_entropy>'
}

# BLTI session object encryption values
BLTI_AES_KEY = b'<AES128_KEY>'
BLTI_AES_IV = b'<AES128_INIT_VECTOR>'

Project urls.py
---------------
```
## Django Configuration
It should be sufficient to add the app and supporting settings to ``project/settings.py``:
```
INSTALLED_APPS += ['blti']
# add session authentication based on lauch authentication
MIDDLEWARE_CLASSES += [
'blti.middleware.SessionHeaderMiddleware',
'blti.middleware.CSRFHeaderMiddleware',
'blti.middleware.SameSiteMiddleware'
'blti.middleware.LTISessionAuthenticationMiddleware',]
# relax samesite requirements, limit casual snooping
SESSION_COOKIE_SAMESITE = 'None'
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
# only necessary when running behind ingress proxy
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_SCHEME', 'https')
```
and expose the necessary authentication endpoints in ``project/urls.py``:
```
url(r'^blti/', include('blti.urls')),
```
## Class Based View
A tool is implemented by subclassing ``blti.views.BLTILaunchView``.

After successful launch authentication, the instance variable
``self.blti`` will hold the normalized payload data provided by
the class's ``self.launch_data_model`` method. The default method
provides a normalized data model for the Canvas LTI Platform.

Access control is applied based on the view's class variable:
```
authorized_role = 'member'
```
In addition to LTI-defined roles the following rollup roles are
also supported:
* public - no access restrictions
* member - viewable by staff, instructors, students, and observers
* admin - viewable by staff, instructors, and content developers
## LTI Tool Configuration
Deployed tool configuration is defined in the JSON file named
``tool.json`` in the location defined by the environment variable:
```
LTI_CONFIG_DIRECTORY = /etc/lti-config
```
The configuration file content is documented in the
[pylti1p3 README](https://github.com/dmitry-viskov/pylti1.3?tab=readme-ov-file#configuration).

In addition, a management command is available to simplify key
pair generation during configuration.
```
# python manage.py generate_credentials private.key public.key jwt.json
```
## Tool Development
This app also provides an optional development environment activated by
defining the environment variable:
```
LTI_DEVELOP_APP=my_app
```
and launch url named:
```
urlpatterns = [
re_path(r'^$', MyToolLaunchView.as_view(), name="lti-launch"),
]
```
And finally, to initiate the launch sequence, point your browser at ``/blti/dev``

A mocked JWT payload for Canvas is provided, but can be overridden by
creating the file ``resources/lti1p3/file/jwt.json`` in your tool's
app directory. django-blti will walk the list of ``INSTALLED_APPS``,
and use the first file by that name discovered.
### Project Examples
Visit [uw-id-aca/info-hub-lti](https://github.com/uw-it-aca/info-hub-lti) or
[uw-id-aca/library-guides-lti](https://github.com/uw-it-aca/library-guides-lti) or
to see LTI Tool examples based on launch views and mocked local development
environment.
## Legacy Support
LTI 1.1 launch authentication, authorization, and payload normalization is
also supported for the time being, but is no longer documented here.
45 changes: 11 additions & 34 deletions blti/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,50 +2,27 @@
# SPDX-License-Identifier: Apache-2.0


from blti.exceptions import BLTIException
import json
from base64 import b64decode, b64encode
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from blti.crypto import aes128cbc


class BLTIException(Exception):
pass
LTI_DATA_KEY = 'lti_launch_data'


class BLTI(object):
def __init__(self):
if not hasattr(settings, 'BLTI_AES_KEY'):
raise ImproperlyConfigured('Missing setting BLTI_AES_KEY')
if not hasattr(settings, 'BLTI_AES_IV'):
raise ImproperlyConfigured('Missing setting BLTI_AES_IV')

def set_session(self, request, **kwargs):
if not request.session.exists(request.session.session_key):
request.session.create()

kwargs['_blti_session_id'] = request.session.session_key
request.session['blti'] = self._encrypt_session(
self.filter_oauth_params(kwargs))
# filter oauth_* parameters
for key in list(filter(
lambda key: key.startswith('oauth_'), kwargs.keys())):
kwargs.pop(key)

request.session[LTI_DATA_KEY] = json.dumps(kwargs)

def get_session(self, request):
if 'blti' not in request.session:
try:
return json.loads(request.session[LTI_DATA_KEY])
except KeyError:
raise BLTIException('Invalid Session')

blti_data = self._decrypt_session(request.session['blti'])
if blti_data['_blti_session_id'] != request.session.session_key:
raise BLTIException('Invalid BLTI session data')

blti_data.pop('_blti_session_id', None)
return blti_data

def filter_oauth_params(self, params):
return {k: v for k, v in params.items() if not k.startswith('oauth_')}

def _encrypt_session(self, data):
aes = aes128cbc(settings.BLTI_AES_KEY, settings.BLTI_AES_IV)
return b64encode(aes.encrypt(json.dumps(data))).decode('utf8')

def _decrypt_session(self, string):
aes = aes128cbc(settings.BLTI_AES_KEY, settings.BLTI_AES_IV)
return json.loads(aes.decrypt(b64decode(string)))
35 changes: 35 additions & 0 deletions blti/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Copyright 2024 UW-IT, University of Washington
# SPDX-License-Identifier: Apache-2.0


import os
from django.conf import settings
from importlib import resources
from pylti1p3.tool_config import ToolConfJsonFile
from pylti1p3.contrib.django import DjangoCacheDataStorage


LTI1P3_CONFIG_DIRECTORY_NAME = 'lti_config'
LTI1P3_CONFIG_FILE_NAME = 'tool.json'


def get_tool_conf():
return ToolConfJsonFile(get_lti_config_path())


def get_launch_data_storage():
return DjangoCacheDataStorage()


def get_lti_config_directory():
return os.environ.get(
'LTI_CONFIG_DIRECTORY',
os.path.join(settings.BASE_DIR, LTI1P3_CONFIG_DIRECTORY_NAME))


def get_lti_config_path():
return os.path.join(get_lti_config_directory(), LTI1P3_CONFIG_FILE_NAME)


def get_lti_public_key_path(key_name):
return os.path.join(get_lti_config_directory(), key_name)
58 changes: 0 additions & 58 deletions blti/crypto.py

This file was deleted.

6 changes: 6 additions & 0 deletions blti/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Copyright 2024 UW-IT, University of Washington
# SPDX-License-Identifier: Apache-2.0


class BLTIException(Exception):
pass
Empty file added blti/management/__init__.py
Empty file.
Empty file.
44 changes: 44 additions & 0 deletions blti/management/commands/generate_credentials.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Copyright 2024 UW-IT, University of Washington
# SPDX-License-Identifier: Apache-2.0

from django.core.management.base import BaseCommand, CommandError
from jwcrypto.jwk import JWK
from Crypto.PublicKey import RSA
import json


KEY_LENGTH = 4096


class Command(BaseCommand):
help = 'Generate RSA keys and JWK for JWT signing'

def add_arguments(self, parser):
parser.add_argument('private_key_file', type=str, nargs=1)
parser.add_argument('public_key_file', type=str, nargs=1)
parser.add_argument('jwt_file', type=str, nargs=1)

def create_keys(self):
private_key = RSA.generate(KEY_LENGTH)
return (private_key.exportKey(format='PEM'),
private_key.publickey().exportKey(format='PEM'))

def create_jwk(self, public_key):
jwk_obj = JWK.from_pem(public_key)
public_jwk = json.loads(jwk_obj.export_public())
public_jwk['alg'] = 'RS256'
public_jwk['use'] = 'sig'
return json.dumps(public_jwk)

def handle(self, *args, **options):
private_key, public_key = self.create_keys()
public_jwk = self.create_jwk(public_key)

with open(options['private_key_file'][0], 'w') as f:
f.write(private_key.decode('utf-8'))

with open(options['public_key_file'][0], 'w') as f:
f.write(public_key.decode('utf-8'))

with open(options['jwt_file'][0], 'w') as f:
f.write(public_jwk)
Loading

0 comments on commit 6637605

Please sign in to comment.