Skip to content

Commit

Permalink
Merge pull request #3 from Pearson-Advance/vue/PADV-263
Browse files Browse the repository at this point in the history
PADV-263: Implement LTI 1.3 authentication
  • Loading branch information
kuipumu authored Jan 30, 2023
2 parents 997fa57 + 02d12ef commit f83d82f
Show file tree
Hide file tree
Showing 24 changed files with 1,147 additions and 133 deletions.
9 changes: 9 additions & 0 deletions openedx_lti_tool_plugin/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
"""Admin configuration for openedx_lti_tool_plugin."""
from django.contrib import admin

from openedx_lti_tool_plugin.models import LtiProfile


@admin.register(LtiProfile)
class LtiProfileAdmin(admin.ModelAdmin):
"""Admin configuration for LtiProfile model."""
4 changes: 2 additions & 2 deletions openedx_lti_tool_plugin/apps.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
"""App configuration for `openedx_lti_tool_plugin`."""
"""App configuration for openedx_lti_tool_plugin."""
from django.apps import AppConfig


class OpenEdxLtiToolPluginConfig(AppConfig):
"""Configuration for the openedx_lti_tool_plugin Django application."""

name = 'openedx_lti_tool_plugin'
verbose_name = "Open edX LTI Tool Plugin"
verbose_name = 'Open edX LTI Tool Plugin'
plugin_app = {
'url_config': {
'lms.djangoapp': {
Expand Down
42 changes: 34 additions & 8 deletions openedx_lti_tool_plugin/auth.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
"""Authentication for `openedx_lti_tool_plugin`."""
"""Authentication for openedx_lti_tool_plugin."""
import logging
from typing import Optional

from django.contrib.auth.backends import ModelBackend
from django.http.request import HttpRequest

from openedx_lti_tool_plugin.models import LtiProfile, UserT

from .models import LtiProfile # pylint: disable=unused-import
log = logging.getLogger(__name__)


class LtiAuthenticationBackend(ModelBackend):
Expand All @@ -12,13 +18,33 @@ class LtiAuthenticationBackend(ModelBackend):
Returns None if no user profile is found.
"""

# pylint: disable=arguments-renamed
def authenticate(self, request, iss=None, aud=None, sub=None, **kwargs):
# pylint: disable=arguments-renamed,arguments-differ
def authenticate(
self,
request: HttpRequest,
iss: Optional[str] = None,
aud: Optional[str] = None,
sub: Optional[str] = None,
) -> Optional[UserT]:
"""Authenticate using LTI launch claims corresponding to a LTIProfile instance.
Args:
request: HTTP request object
iss (str, optional): LTI issuer claim. Defaults to None.
aud (str, optional): LTI audience claim. Defaults to None.
sub (str, optional): LTI subject claim. Defaults to None.
request: HTTP request object.
iss: LTI issuer claim.
aud: LTI audience claim.
sub: LTI subject claim.
Returns:
LTI profile user instance or None.
"""
log.debug('LTI 1.3 authentication: iss=%s, sub=%s, aud=%s', iss, sub, aud)

try:
profile = LtiProfile.objects.get_from_claims(iss=iss, aud=aud, sub=sub)
except LtiProfile.DoesNotExist:
return None

user = profile.user
log.debug('LTI 1.3 authentication profile: profile=%s user=%s', profile, user)

return user if self.user_can_authenticate(user) else None
41 changes: 41 additions & 0 deletions openedx_lti_tool_plugin/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Generated by Django 3.2.16 on 2023-01-17 15:44

from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import uuid


class Migration(migrations.Migration):

initial = True

dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

operations = [
migrations.CreateModel(
name='LtiProfile',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
('platform_id', models.URLField(help_text='Platform ID this profile belongs to.', max_length=255, verbose_name='Platform ID')),
('client_id', models.CharField(help_text='Client ID generated by the LTI platform.', max_length=255, verbose_name='Client ID')),
('subject_id', models.CharField(help_text='Identifies the entity that initiated the launch request.', max_length=255, verbose_name='Subject ID')),
('user', models.OneToOneField(editable=False, on_delete=django.db.models.deletion.CASCADE, related_name='openedx_lti_tool_plugin_lti_profile', to=settings.AUTH_USER_MODEL, verbose_name='Open edX user')),
],
options={
'verbose_name': 'LTI Profile',
'verbose_name_plural': 'LTI Profiles',
},
),
migrations.AddIndex(
model_name='ltiprofile',
index=models.Index(fields=['platform_id', 'client_id', 'subject_id'], name='lti_profile_identity_claims'),
),
migrations.AlterUniqueTogether(
name='ltiprofile',
unique_together={('platform_id', 'client_id', 'subject_id')},
),
]
Empty file.
109 changes: 103 additions & 6 deletions openedx_lti_tool_plugin/models.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,119 @@
"""Models for `openedx_lti_tool_plugin`."""
"""Models for openedx_lti_tool_plugin."""
from __future__ import annotations

import uuid
from typing import Tuple, TypeVar

from django.contrib.auth import get_user_model
from django.contrib.auth.models import AbstractBaseUser
from django.db import models
from django.utils.translation import gettext_lazy as _

from openedx_lti_tool_plugin.apps import OpenEdxLtiToolPluginConfig as app_config

UserT = TypeVar('UserT', bound=AbstractBaseUser)


class LtiProfileManager(models.Manager):
"""LTI 1.3 profile model manager."""

def get_from_claims(self, iss: str, aud: str, sub: str) -> LtiProfile:
"""Get an instance from LTI 1.3 launch claims.
Args:
iss: LTI platform id claim.
aud: LTI client id claim.
sub: LTI subject id claim.
Returns:
LTI profile instance from launch claims.
"""
return self.get(platform_id=iss, client_id=aud, subject_id=sub)

def get_or_create_from_claims(self, iss: str, aud: str, sub: str) -> Tuple[LtiProfile, bool]:
"""Get or create an instance from LTI 1.3 launch claims.
Args:
iss: LTI platform id claim.
aud: LTI client id claim.
sub: LTI subject id claim.
Returns:
LTI profile instance from launch claims and a
boolean specifying whether a new LTI profile was created.
"""
try:
return self.get_from_claims(iss=iss, aud=aud, sub=sub), False
except self.model.DoesNotExist:
return self.create(platform_id=iss, client_id=aud, subject_id=sub), True


class LtiProfile(models.Model):
"""LTI 1.3 profile for Open edX users.
A unique representation of the LTI subject
that initiated an LTI launch.
A unique representation of the LTI subject that initiated an LTI launch.
"""

objects = LtiProfileManager()
user = models.OneToOneField(get_user_model(), on_delete=models.CASCADE)
uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
user = models.OneToOneField(
get_user_model(),
on_delete=models.CASCADE,
related_name='openedx_lti_tool_plugin_lti_profile',
verbose_name=_('Open edX user'),
editable=False,
)
platform_id = models.URLField(
max_length=255,
verbose_name=_('Platform ID'),
help_text=_('Platform ID this profile belongs to.'),
)
client_id = models.CharField(
max_length=255,
verbose_name=_('Client ID'),
help_text=_('Client ID generated by the LTI platform.'),
)
subject_id = models.CharField(
max_length=255,
verbose_name=_('Subject ID'),
help_text=_('Identifies the entity that initiated the launch request.'),
)

class Meta:
"""Meta options."""

verbose_name = 'LTI Profile'
verbose_name_plural = 'LTI Profiles'
unique_together = ['platform_id', 'client_id', 'subject_id']
indexes = [
models.Index(
fields=['platform_id', 'client_id', 'subject_id'],
name='lti_profile_identity_claims',
),
]

def save(self, *args: tuple, **kwargs: dict):
"""Model save method.
In this method we try to validate if the LtiProfile contains an user,
if no user is found then we create a new user for this instance.
Args:
*args: Variable length argument list.
**kwargs: Arbitrary keyword arguments.
"""
if getattr(self, 'user', None):
return super().save(*args, **kwargs)

self.user = get_user_model().objects.create(
username=f'{app_config.name}.{self.uuid}',
email=f'{self.uuid}@{app_config.name}',
)
self.user.set_unusable_password() # LTI users can only auth throught LTI launches.
self.user.save()

return super().save(*args, **kwargs)

def __str__(self):
def __str__(self) -> str:
"""Get a string representation of this model instance."""
return f'<Lti1p3Profile, ID: {self.id}>'
return f'<LtiProfile, ID: {self.id}>'
7 changes: 5 additions & 2 deletions openedx_lti_tool_plugin/settings/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
For the full list of settings and their values, see
https://docs.djangoproject.com/en/3.2/ref/settings/
"""
from django.conf import LazySettings

# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = 'secret-key'
Expand All @@ -28,10 +29,12 @@
USE_TZ = True


def plugin_settings(settings): # pylint: disable=unused-argument
def plugin_settings(settings: LazySettings):
"""
Set of plugin settings used by the Open edX platform.
For more information please see:
https://github.com/edx/edx-platform/blob/master/openedx/core/djangoapps/plugins/README.rst
https://github.com/openedx/edx-django-utils/tree/master/edx_django_utils/plugins
"""
settings.OLTTP_ENABLE_LTI_TOOL = False
settings.AUTHENTICATION_BACKENDS.append('openedx_lti_tool_plugin.auth.LtiAuthenticationBackend')
5 changes: 3 additions & 2 deletions openedx_lti_tool_plugin/settings/production.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,13 @@
For the full list of settings and their values, see
https://docs.djangoproject.com/en/3.2/ref/settings/
"""
from django.conf import LazySettings


def plugin_settings(settings): # pylint: disable=unused-argument
def plugin_settings(settings: LazySettings): # pylint: disable=unused-argument
"""
Set of plugin settings used by the Open Edx platform.
For more information please see:
https://github.com/edx/edx-platform/blob/master/openedx/core/djangoapps/plugins/README.rst
https://github.com/openedx/edx-django-utils/tree/master/edx_django_utils/plugins
"""
26 changes: 26 additions & 0 deletions openedx_lti_tool_plugin/settings/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,37 @@
DEBUG = True

INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'openedx_lti_tool_plugin',
'pylti1p3.contrib.django.lti1p3_tool_config',
]

MIDDLEWARE = (
'django.contrib.sessions.middleware.SessionMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
)

TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]

DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
Expand All @@ -29,3 +51,7 @@
}

DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'

AUTHENTICATION_BACKENDS = ['openedx_lti_tool_plugin.auth.LtiAuthenticationBackend']

OLTTP_ENABLE_LTI_TOOL = True
4 changes: 4 additions & 0 deletions openedx_lti_tool_plugin/tests/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
"""Tests for the `openedx_lti_tool_plugin` module."""

ISS = 'http://foo.example.com/'
SUB = 'random-sub'
AUD = 'random.aud.app'
43 changes: 43 additions & 0 deletions openedx_lti_tool_plugin/tests/test_admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"""Tests for the openedx_lti_tool_plugin admin module."""
from django.contrib import admin
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.urls import path, reverse

from openedx_lti_tool_plugin.models import LtiProfile
from openedx_lti_tool_plugin.tests import AUD, ISS, SUB
from openedx_lti_tool_plugin.urls import urlpatterns


def get_admin_view_url(obj: LtiProfile, name: str) -> str:
"""Get admin URL from model instance."""
return f'admin:{obj._meta.app_label}_{type(obj).__name__.lower()}_{name}'


class TestLtiProfileAdmin(TestCase):
"""Test LTI 1.3 profile admin functionality."""

def setUp(self):
"""Test fixtures setup."""
self.user = get_user_model().objects.create_superuser(username='x', password='x', email='[email protected]')
self.client.force_login(self.user)
self.profile = LtiProfile.objects.create(platform_id=ISS, client_id=AUD, subject_id=SUB)
urlpatterns.append(path('admin/', admin.site.urls))

def test_add_view(self):
"""Test admin add view can be reached."""
url = reverse(get_admin_view_url(self.profile, 'add'))

self.assertEqual(self.client.get(url).status_code, 200)

def test_change_view(self):
"""Test admin change view can be reached."""
url = reverse(get_admin_view_url(self.profile, 'change'), args=(self.profile.pk,))

self.assertEqual(self.client.get(url).status_code, 200)

def test_delete_view(self):
"""Test admin delete view can be reached."""
url = reverse(get_admin_view_url(self.profile, 'delete'), args=(self.profile.pk,))

self.assertEqual(self.client.get(url).status_code, 200)
Loading

0 comments on commit f83d82f

Please sign in to comment.