-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #3 from Pearson-Advance/vue/PADV-263
PADV-263: Implement LTI 1.3 authentication
- Loading branch information
Showing
24 changed files
with
1,147 additions
and
133 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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.""" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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}>' |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1 +1,5 @@ | ||
"""Tests for the `openedx_lti_tool_plugin` module.""" | ||
|
||
ISS = 'http://foo.example.com/' | ||
SUB = 'random-sub' | ||
AUD = 'random.aud.app' |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,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) |
Oops, something went wrong.