mirror of
https://github.com/mediacms-io/mediacms.git
synced 2026-01-20 07:12:58 -05:00
all
This commit is contained in:
@@ -1,13 +1,11 @@
|
||||
# Generated by Django 5.2.6 on 2025-12-24 15:08
|
||||
# Generated by Django 5.2.6 on 2025-12-29 16:15
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('files', '0014_alter_subtitle_options_and_more'),
|
||||
('lti', '__first__'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
@@ -21,11 +19,4 @@ class Migration(migrations.Migration):
|
||||
name='lti_context_id',
|
||||
field=models.CharField(blank=True, db_index=True, help_text='LTI context ID from platform', max_length=255),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='category',
|
||||
name='lti_platform',
|
||||
field=models.ForeignKey(
|
||||
blank=True, help_text='LTI Platform if this is an LTI course', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='categories', to='lti.ltiplatform'
|
||||
),
|
||||
),
|
||||
]
|
||||
21
files/migrations/0016_category_lti_platform.py
Normal file
21
files/migrations/0016_category_lti_platform.py
Normal file
@@ -0,0 +1,21 @@
|
||||
# Generated by Django 5.2.6 on 2025-12-29 16:15
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('files', '0015_category_is_lms_course_category_lti_context_id'),
|
||||
('lti', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='category',
|
||||
name='lti_platform',
|
||||
field=models.ForeignKey(
|
||||
blank=True, help_text='LTI Platform if this is an LTI course', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='categories', to='lti.ltiplatform'
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -8,10 +8,15 @@ import json
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from django.core.cache import cache
|
||||
from pylti1p3.message_launch import MessageLaunch
|
||||
from pylti1p3.oidc_login import OIDCLogin
|
||||
from pylti1p3.registration import Registration
|
||||
from pylti1p3.request import Request
|
||||
from pylti1p3.tool_config import ToolConfAbstract
|
||||
|
||||
from .keys import load_private_key
|
||||
from .models import LTIPlatform
|
||||
|
||||
|
||||
class DjangoRequest(Request):
|
||||
"""Django request adapter for PyLTI1p3"""
|
||||
@@ -57,8 +62,6 @@ class DjangoOIDCLogin:
|
||||
|
||||
def get_redirect(self, redirect_url):
|
||||
"""Get the redirect object for OIDC login"""
|
||||
from pylti1p3.oidc_login import OIDCLogin
|
||||
|
||||
oidc_login = OIDCLogin(self.lti_request, self.tool_config, session_service=self.launch_data_storage, cookie_service=self.launch_data_storage)
|
||||
|
||||
return oidc_login.enable_check_cookies().redirect(redirect_url)
|
||||
@@ -75,7 +78,6 @@ class DjangoMessageLaunch:
|
||||
|
||||
def validate(self):
|
||||
"""Validate the LTI launch message"""
|
||||
from pylti1p3.message_launch import MessageLaunch
|
||||
|
||||
# Create custom MessageLaunch that properly implements _get_request_param
|
||||
class CustomMessageLaunch(MessageLaunch):
|
||||
@@ -224,8 +226,6 @@ class DjangoToolConfig(ToolConfAbstract):
|
||||
if config.get('auth_audience'):
|
||||
registration.set_auth_audience(config.get('auth_audience'))
|
||||
registration.set_key_set_url(config.get('key_set_url'))
|
||||
if config.get('key_set'):
|
||||
registration.set_key_set(config.get('key_set'))
|
||||
|
||||
return registration
|
||||
|
||||
@@ -247,8 +247,6 @@ class DjangoToolConfig(ToolConfAbstract):
|
||||
if config.get('auth_audience'):
|
||||
registration.set_auth_audience(config.get('auth_audience'))
|
||||
registration.set_key_set_url(config.get('key_set_url'))
|
||||
if config.get('key_set'):
|
||||
registration.set_key_set(config.get('key_set'))
|
||||
|
||||
return registration
|
||||
|
||||
@@ -280,25 +278,26 @@ class DjangoToolConfig(ToolConfAbstract):
|
||||
return self.find_registration_by_params(iss, client_id)
|
||||
|
||||
def get_jwks(self, iss, client_id=None):
|
||||
"""Get JWKS from configuration"""
|
||||
if iss not in self._config:
|
||||
return None
|
||||
|
||||
config_dict = self._config[iss]
|
||||
if client_id and config_dict.get('client_id') != client_id:
|
||||
return None
|
||||
|
||||
return config_dict.get('key_set')
|
||||
"""Get JWKS from configuration - returns None to fetch from URL"""
|
||||
# No caching - PyLTI1p3 will fetch from key_set_url
|
||||
return None
|
||||
|
||||
def get_iss(self):
|
||||
"""Get all issuers"""
|
||||
return list(self._config.keys())
|
||||
|
||||
def get_jwk(self, iss=None, client_id=None):
|
||||
"""
|
||||
Get private JWK for signing Deep Linking responses
|
||||
|
||||
PyLTI1p3 calls this to get the tool's private key for signing
|
||||
"""
|
||||
# Return MediaCMS's private key (same for all platforms)
|
||||
return load_private_key()
|
||||
|
||||
@classmethod
|
||||
def from_platform(cls, platform):
|
||||
"""Create ToolConfig from LTIPlatform model instance"""
|
||||
from .models import LTIPlatform
|
||||
|
||||
if isinstance(platform, LTIPlatform):
|
||||
config = {platform.platform_id: platform.get_lti_config()}
|
||||
return cls(config)
|
||||
@@ -307,10 +306,8 @@ class DjangoToolConfig(ToolConfAbstract):
|
||||
|
||||
@classmethod
|
||||
def from_all_platforms(cls):
|
||||
"""Create ToolConfig with all active platforms"""
|
||||
from .models import LTIPlatform
|
||||
|
||||
platforms = LTIPlatform.objects.filter(active=True)
|
||||
"""Create ToolConfig with all platforms"""
|
||||
platforms = LTIPlatform.objects.filter()
|
||||
config = {}
|
||||
|
||||
for platform in platforms:
|
||||
|
||||
67
lti/admin.py
67
lti/admin.py
@@ -10,6 +10,7 @@ from .models import (
|
||||
LTIPlatform,
|
||||
LTIResourceLink,
|
||||
LTIRoleMapping,
|
||||
LTIToolKeys,
|
||||
LTIUserMapping,
|
||||
)
|
||||
from .services import LTINRPSClient
|
||||
@@ -19,28 +20,20 @@ from .services import LTINRPSClient
|
||||
class LTIPlatformAdmin(admin.ModelAdmin):
|
||||
"""Admin for LTI Platforms (Moodle instances)"""
|
||||
|
||||
list_display = ['name', 'platform_id', 'client_id', 'active_badge', 'nrps_enabled', 'deep_linking_enabled', 'created_at']
|
||||
list_filter = ['active', 'enable_nrps', 'enable_deep_linking', 'created_at']
|
||||
list_display = ['name', 'platform_id', 'client_id', 'nrps_enabled', 'deep_linking_enabled', 'created_at']
|
||||
list_filter = ['enable_nrps', 'enable_deep_linking', 'created_at']
|
||||
search_fields = ['name', 'platform_id', 'client_id']
|
||||
readonly_fields = ['created_at', 'updated_at', 'key_set_updated']
|
||||
readonly_fields = ['created_at', 'updated_at']
|
||||
|
||||
fieldsets = (
|
||||
('Basic Information', {'fields': ('name', 'platform_id', 'client_id', 'active')}),
|
||||
('Basic Information', {'fields': ('name', 'platform_id', 'client_id')}),
|
||||
('OIDC Endpoints', {'fields': ('auth_login_url', 'auth_token_url', 'auth_audience')}),
|
||||
('JWK Configuration', {'fields': ('key_set_url', 'key_set', 'key_set_updated'), 'classes': ('collapse',)}),
|
||||
('JWK Configuration', {'fields': ('key_set_url',), 'classes': ('collapse',)}),
|
||||
('Deployment & Features', {'fields': ('deployment_ids', 'enable_nrps', 'enable_deep_linking')}),
|
||||
('Auto-Provisioning Settings', {'fields': ('auto_create_categories', 'auto_create_users', 'auto_sync_roles', 'remove_from_groups_on_unenroll')}),
|
||||
('Auto-Provisioning Settings', {'fields': ('remove_from_groups_on_unenroll',)}),
|
||||
('Timestamps', {'fields': ('created_at', 'updated_at'), 'classes': ('collapse',)}),
|
||||
)
|
||||
|
||||
def active_badge(self, obj):
|
||||
"""Display active status as badge"""
|
||||
if obj.active:
|
||||
return format_html('<span style="color: green;">✓ Active</span>')
|
||||
return format_html('<span style="color: red;">✗ Inactive</span>')
|
||||
|
||||
active_badge.short_description = 'Status'
|
||||
|
||||
def nrps_enabled(self, obj):
|
||||
return '✓' if obj.enable_nrps else '✗'
|
||||
|
||||
@@ -171,14 +164,14 @@ class LTIRoleMappingAdmin(admin.ModelAdmin):
|
||||
class LTILaunchLogAdmin(admin.ModelAdmin):
|
||||
"""Admin for LTI Launch Logs"""
|
||||
|
||||
list_display = ['created_at', 'platform', 'user_link', 'launch_type', 'success_badge', 'ip_address']
|
||||
list_display = ['created_at', 'platform', 'user_link', 'launch_type', 'success_badge']
|
||||
list_filter = ['success', 'launch_type', 'platform', 'created_at']
|
||||
search_fields = ['user__username', 'ip_address', 'error_message']
|
||||
search_fields = ['user__username', 'error_message']
|
||||
readonly_fields = ['created_at', 'claims']
|
||||
date_hierarchy = 'created_at'
|
||||
|
||||
fieldsets = (
|
||||
('Launch Info', {'fields': ('platform', 'user', 'resource_link', 'launch_type', 'success', 'ip_address', 'created_at')}),
|
||||
('Launch Info', {'fields': ('platform', 'user', 'resource_link', 'launch_type', 'success', 'created_at')}),
|
||||
('Error Details', {'fields': ('error_message',), 'classes': ('collapse',)}),
|
||||
('Claims Data', {'fields': ('claims',), 'classes': ('collapse',)}),
|
||||
)
|
||||
@@ -204,3 +197,43 @@ class LTILaunchLogAdmin(admin.ModelAdmin):
|
||||
def has_change_permission(self, request, obj=None):
|
||||
"""Make launch logs read-only"""
|
||||
return False
|
||||
|
||||
|
||||
@admin.register(LTIToolKeys)
|
||||
class LTIToolKeysAdmin(admin.ModelAdmin):
|
||||
"""Admin for LTI Tool RSA Keys"""
|
||||
|
||||
list_display = ['key_id', 'created_at', 'updated_at']
|
||||
readonly_fields = ['key_id', 'created_at', 'updated_at', 'public_key_display']
|
||||
|
||||
fieldsets = (
|
||||
('Key Information', {'fields': ('key_id', 'created_at', 'updated_at')}),
|
||||
('Public Key (for JWKS)', {'fields': ('public_key_display',)}),
|
||||
('Private Key (Keep Secure!)', {'fields': ('private_key_jwk',), 'classes': ('collapse',), 'description': '⚠️ This is your private signing key. Do not share it!'}),
|
||||
)
|
||||
|
||||
actions = ['regenerate_keys']
|
||||
|
||||
def public_key_display(self, obj):
|
||||
"""Display public key in readable format"""
|
||||
import json
|
||||
|
||||
return format_html('<pre>{}</pre>', json.dumps(obj.public_key_jwk, indent=2))
|
||||
|
||||
public_key_display.short_description = 'Public Key (JWK)'
|
||||
|
||||
def regenerate_keys(self, request, queryset):
|
||||
"""Regenerate keys for selected instances"""
|
||||
for key_obj in queryset:
|
||||
key_obj.generate_keys()
|
||||
self.message_user(request, f"Keys regenerated for {key_obj.key_id}", messages.SUCCESS)
|
||||
|
||||
regenerate_keys.short_description = 'Regenerate RSA keys'
|
||||
|
||||
def has_add_permission(self, request):
|
||||
"""Only allow one key pair - disable manual add if exists"""
|
||||
return not LTIToolKeys.objects.exists()
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
"""Prevent accidental deletion of keys"""
|
||||
return False
|
||||
|
||||
12
lti/apps.py
12
lti/apps.py
@@ -1,5 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
from .keys import ensure_keys_exist
|
||||
|
||||
|
||||
class LtiConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
@@ -7,6 +9,10 @@ class LtiConfig(AppConfig):
|
||||
verbose_name = 'LTI 1.3 Integration'
|
||||
|
||||
def ready(self):
|
||||
"""Import signal handlers when app is ready"""
|
||||
# Import any signals here if needed in the future
|
||||
pass
|
||||
"""Initialize LTI app - ensure keys exist"""
|
||||
# Ensure RSA key pair exists for signing Deep Linking responses
|
||||
try:
|
||||
ensure_keys_exist()
|
||||
except Exception:
|
||||
# Don't block startup if key generation fails
|
||||
pass
|
||||
|
||||
@@ -4,6 +4,8 @@ LTI Deep Linking 2.0 for MediaCMS
|
||||
Allows instructors to select media from MediaCMS library and embed in Moodle courses
|
||||
"""
|
||||
|
||||
import traceback
|
||||
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.http import JsonResponse
|
||||
from django.shortcuts import render
|
||||
@@ -11,6 +13,8 @@ from django.urls import reverse
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views import View
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from pylti1p3.deep_link import DeepLink
|
||||
from pylti1p3.deep_link_resource import DeepLinkResource
|
||||
|
||||
from files.models import Media
|
||||
from files.views.media import MediaList
|
||||
@@ -125,9 +129,6 @@ class SelectMediaView(View):
|
||||
"""
|
||||
Create JWT response for deep linking using PyLTI1p3
|
||||
"""
|
||||
from pylti1p3.deep_link import DeepLink
|
||||
from pylti1p3.deep_link_resource import DeepLinkResource
|
||||
|
||||
try:
|
||||
platform_id = deep_link_data['platform_id']
|
||||
platform = LTIPlatform.objects.get(id=platform_id)
|
||||
@@ -182,7 +183,5 @@ class SelectMediaView(View):
|
||||
|
||||
except Exception as e:
|
||||
# Log error for debugging
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
raise ValueError(f"Failed to create Deep Linking JWT: {str(e)}")
|
||||
|
||||
@@ -260,9 +260,7 @@ def apply_lti_roles(user, platform, lti_roles, rbac_group):
|
||||
if role_global:
|
||||
global_role = get_higher_privilege_global(global_role, role_global)
|
||||
|
||||
# Apply global role if auto_sync_roles is enabled
|
||||
if platform.auto_sync_roles:
|
||||
user.set_role_from_mapping(global_role)
|
||||
user.set_role_from_mapping(global_role)
|
||||
|
||||
# Determine group role
|
||||
group_role = 'member'
|
||||
|
||||
38
lti/keys.py
Normal file
38
lti/keys.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""
|
||||
LTI Key Management for MediaCMS
|
||||
|
||||
Manages RSA keys for signing Deep Linking responses (stored in database)
|
||||
"""
|
||||
|
||||
|
||||
def load_private_key():
|
||||
"""Load private key from database"""
|
||||
from .models import LTIToolKeys
|
||||
|
||||
key_obj = LTIToolKeys.get_or_create_keys()
|
||||
return key_obj.private_key_jwk
|
||||
|
||||
|
||||
def load_public_key():
|
||||
"""Load public key from database"""
|
||||
from .models import LTIToolKeys
|
||||
|
||||
key_obj = LTIToolKeys.get_or_create_keys()
|
||||
return key_obj.public_key_jwk
|
||||
|
||||
|
||||
def get_jwks():
|
||||
"""
|
||||
Get JWKS (JSON Web Key Set) for public keys
|
||||
|
||||
Returns public keys in JWKS format for the /lti/jwks/ endpoint
|
||||
"""
|
||||
public_key = load_public_key()
|
||||
return {'keys': [public_key]}
|
||||
|
||||
|
||||
def ensure_keys_exist():
|
||||
"""Ensure key pair exists in database, generate if not"""
|
||||
from .models import LTIToolKeys
|
||||
|
||||
LTIToolKeys.get_or_create_keys()
|
||||
@@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.2.6 on 2025-12-24 15:18
|
||||
# Generated by Django 5.2.6 on 2025-12-29 16:15
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
@@ -9,11 +9,27 @@ class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('files', '0015_category_is_lms_course_category_lti_context_id'),
|
||||
('rbac', '0003_alter_rbacgroup_members'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='LTIToolKeys',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('key_id', models.CharField(default='mediacms-lti-key', help_text='Key identifier', max_length=255, unique=True)),
|
||||
('private_key_jwk', models.JSONField(help_text='Private key in JWK format (for signing)')),
|
||||
('public_key_jwk', models.JSONField(help_text='Public key in JWK format (for JWKS endpoint)')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'LTI Tool Keys',
|
||||
'verbose_name_plural': 'LTI Tool Keys',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='LTIPlatform',
|
||||
fields=[
|
||||
@@ -25,18 +41,12 @@ class Migration(migrations.Migration):
|
||||
('auth_token_url', models.URLField(help_text='OAuth2 token endpoint URL')),
|
||||
('auth_audience', models.URLField(blank=True, help_text='OAuth2 audience (optional)', null=True)),
|
||||
('key_set_url', models.URLField(help_text="Platform's public JWK Set URL")),
|
||||
('key_set', models.JSONField(blank=True, help_text='Cached JWK Set (auto-fetched)', null=True)),
|
||||
('key_set_updated', models.DateTimeField(blank=True, help_text='Last time JWK Set was fetched', null=True)),
|
||||
('deployment_ids', models.JSONField(default=list, help_text='List of deployment IDs for this platform')),
|
||||
('enable_nrps', models.BooleanField(default=True, help_text='Enable Names and Role Provisioning Service')),
|
||||
('enable_deep_linking', models.BooleanField(default=True, help_text='Enable Deep Linking 2.0')),
|
||||
('auto_create_categories', models.BooleanField(default=True, help_text='Automatically create categories for courses')),
|
||||
('auto_create_users', models.BooleanField(default=True, help_text='Automatically create users on first launch')),
|
||||
('auto_sync_roles', models.BooleanField(default=True, help_text='Automatically sync user roles from LTI')),
|
||||
('remove_from_groups_on_unenroll', models.BooleanField(default=False, help_text="Remove users from RBAC groups when they're no longer in the course")),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('active', models.BooleanField(default=True, help_text='Whether this platform is currently active')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'LTI Platform',
|
||||
@@ -80,7 +90,6 @@ class Migration(migrations.Migration):
|
||||
('success', models.BooleanField(db_index=True, default=True, help_text='Whether the launch was successful')),
|
||||
('error_message', models.TextField(blank=True, help_text='Error message if launch failed')),
|
||||
('claims', models.JSONField(help_text='Sanitized LTI claims from the launch')),
|
||||
('ip_address', models.GenericIPAddressField(blank=True, help_text='IP address of the user', null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||
(
|
||||
'user',
|
||||
@@ -111,13 +120,7 @@ class Migration(migrations.Migration):
|
||||
'global_role',
|
||||
models.CharField(
|
||||
blank=True,
|
||||
choices=[
|
||||
('user', 'Authenticated User'),
|
||||
('advancedUser', 'Advanced User'),
|
||||
('editor', 'MediaCMS Editor'),
|
||||
('manager', 'MediaCMS Manager'),
|
||||
('admin', 'MediaCMS Administrator'),
|
||||
],
|
||||
choices=[('advancedUser', 'Advanced User'), ('editor', 'MediaCMS Editor'), ('manager', 'MediaCMS Manager'), ('admin', 'MediaCMS Administrator')],
|
||||
help_text='MediaCMS global role to assign',
|
||||
max_length=20,
|
||||
),
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import json
|
||||
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from django.db import models
|
||||
from jwcrypto import jwk
|
||||
|
||||
|
||||
class LTIPlatform(models.Model):
|
||||
@@ -13,22 +19,16 @@ class LTIPlatform(models.Model):
|
||||
auth_audience = models.URLField(blank=True, null=True, help_text="OAuth2 audience (optional)")
|
||||
|
||||
key_set_url = models.URLField(help_text="Platform's public JWK Set URL")
|
||||
key_set = models.JSONField(blank=True, null=True, help_text="Cached JWK Set (auto-fetched)")
|
||||
key_set_updated = models.DateTimeField(null=True, blank=True, help_text="Last time JWK Set was fetched")
|
||||
|
||||
deployment_ids = models.JSONField(default=list, help_text="List of deployment IDs for this platform")
|
||||
enable_nrps = models.BooleanField(default=True, help_text="Enable Names and Role Provisioning Service")
|
||||
enable_deep_linking = models.BooleanField(default=True, help_text="Enable Deep Linking 2.0")
|
||||
|
||||
auto_create_categories = models.BooleanField(default=True, help_text="Automatically create categories for courses")
|
||||
auto_create_users = models.BooleanField(default=True, help_text="Automatically create users on first launch")
|
||||
auto_sync_roles = models.BooleanField(default=True, help_text="Automatically sync user roles from LTI")
|
||||
remove_from_groups_on_unenroll = models.BooleanField(default=False, help_text="Remove users from RBAC groups when they're no longer in the course")
|
||||
|
||||
# Timestamps
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
active = models.BooleanField(default=True, help_text="Whether this platform is currently active")
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'LTI Platform'
|
||||
@@ -47,7 +47,6 @@ class LTIPlatform(models.Model):
|
||||
'auth_token_url': self.auth_token_url,
|
||||
'auth_audience': self.auth_audience,
|
||||
'key_set_url': self.key_set_url,
|
||||
'key_set': self.key_set,
|
||||
'deployment_ids': self.deployment_ids,
|
||||
}
|
||||
|
||||
@@ -149,7 +148,6 @@ class LTILaunchLog(models.Model):
|
||||
error_message = models.TextField(blank=True, help_text="Error message if launch failed")
|
||||
claims = models.JSONField(help_text="Sanitized LTI claims from the launch")
|
||||
|
||||
ip_address = models.GenericIPAddressField(null=True, blank=True, help_text="IP address of the user")
|
||||
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
|
||||
|
||||
class Meta:
|
||||
@@ -165,3 +163,75 @@ class LTILaunchLog(models.Model):
|
||||
status = "✓" if self.success else "✗"
|
||||
user_str = self.user.username if self.user else "Unknown"
|
||||
return f"{status} {user_str} @ {self.platform.name} ({self.created_at.strftime('%Y-%m-%d %H:%M')})"
|
||||
|
||||
|
||||
class LTIToolKeys(models.Model):
|
||||
"""
|
||||
Stores MediaCMS's RSA key pair for signing LTI responses (e.g., Deep Linking)
|
||||
|
||||
Only one instance should exist (singleton pattern)
|
||||
"""
|
||||
|
||||
key_id = models.CharField(max_length=255, unique=True, default='mediacms-lti-key', help_text='Key identifier')
|
||||
|
||||
# JWK format keys
|
||||
private_key_jwk = models.JSONField(help_text='Private key in JWK format (for signing)')
|
||||
public_key_jwk = models.JSONField(help_text='Public key in JWK format (for JWKS endpoint)')
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'LTI Tool Keys'
|
||||
verbose_name_plural = 'LTI Tool Keys'
|
||||
|
||||
def __str__(self):
|
||||
return f"LTI Keys ({self.key_id})"
|
||||
|
||||
@classmethod
|
||||
def get_or_create_keys(cls):
|
||||
"""Get or create the default key pair"""
|
||||
key_obj, created = cls.objects.get_or_create(
|
||||
key_id='mediacms-lti-key',
|
||||
defaults={'private_key_jwk': {}, 'public_key_jwk': {}}, # Will be populated by save()
|
||||
)
|
||||
|
||||
# If keys are empty, generate them
|
||||
if created or not key_obj.private_key_jwk or not key_obj.public_key_jwk:
|
||||
key_obj.generate_keys()
|
||||
|
||||
return key_obj
|
||||
|
||||
def generate_keys(self):
|
||||
"""Generate new RSA key pair"""
|
||||
# Generate RSA key pair
|
||||
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048, backend=default_backend())
|
||||
|
||||
public_key = private_key.public_key()
|
||||
|
||||
# Convert to PEM
|
||||
private_pem = private_key.private_bytes(encoding=serialization.Encoding.PEM, format=serialization.PrivateFormat.PKCS8, encryption_algorithm=serialization.NoEncryption())
|
||||
|
||||
public_pem = public_key.public_bytes(encoding=serialization.Encoding.PEM, format=serialization.PublicFormat.SubjectPublicKeyInfo)
|
||||
|
||||
# Convert to JWK
|
||||
private_jwk = jwk.JWK.from_pem(private_pem)
|
||||
public_jwk = jwk.JWK.from_pem(public_pem)
|
||||
|
||||
# Add metadata
|
||||
private_jwk_dict = json.loads(private_jwk.export())
|
||||
private_jwk_dict['kid'] = self.key_id
|
||||
private_jwk_dict['alg'] = 'RS256'
|
||||
private_jwk_dict['use'] = 'sig'
|
||||
|
||||
public_jwk_dict = json.loads(public_jwk.export_public())
|
||||
public_jwk_dict['kid'] = self.key_id
|
||||
public_jwk_dict['alg'] = 'RS256'
|
||||
public_jwk_dict['use'] = 'sig'
|
||||
|
||||
# Save to database
|
||||
self.private_key_jwk = private_jwk_dict
|
||||
self.public_key_jwk = public_jwk_dict
|
||||
self.save()
|
||||
|
||||
return private_jwk_dict, public_jwk_dict
|
||||
|
||||
31
lti/views.py
31
lti/views.py
@@ -40,6 +40,7 @@ from .handlers import (
|
||||
provision_lti_user,
|
||||
validate_lti_session,
|
||||
)
|
||||
from .keys import get_jwks
|
||||
from .models import LTILaunchLog, LTIPlatform, LTIResourceLink
|
||||
from .services import LTINRPSClient
|
||||
|
||||
@@ -82,7 +83,7 @@ class OIDCLoginView(View):
|
||||
return JsonResponse({'error': 'Missing required OIDC parameters'}, status=400)
|
||||
|
||||
# Get platform configuration
|
||||
platform = get_object_or_404(LTIPlatform, platform_id=iss, client_id=client_id, active=True)
|
||||
platform = get_object_or_404(LTIPlatform, platform_id=iss, client_id=client_id)
|
||||
|
||||
# Create tool config for this platform
|
||||
tool_config = DjangoToolConfig.from_platform(platform)
|
||||
@@ -168,7 +169,7 @@ class LaunchView(View):
|
||||
aud = unverified.get('aud')
|
||||
|
||||
# Get platform
|
||||
platform = get_object_or_404(LTIPlatform, platform_id=iss, client_id=aud, active=True)
|
||||
platform = get_object_or_404(LTIPlatform, platform_id=iss, client_id=aud)
|
||||
|
||||
# Create tool config
|
||||
tool_config = DjangoToolConfig.from_platform(platform)
|
||||
@@ -193,7 +194,6 @@ class LaunchView(View):
|
||||
claims = self.sanitize_claims(launch_data)
|
||||
|
||||
# Extract key claims
|
||||
sub = launch_data.get('sub')
|
||||
resource_link = launch_data.get('https://purl.imsglobal.org/spec/lti/claim/resource_link', {})
|
||||
resource_link_id = resource_link.get('id', 'default')
|
||||
roles = launch_data.get('https://purl.imsglobal.org/spec/lti/claim/roles', [])
|
||||
@@ -205,17 +205,7 @@ class LaunchView(View):
|
||||
# Deep linking request - handle separately
|
||||
return self.handle_deep_linking_launch(request, message_launch, platform, launch_data)
|
||||
|
||||
# Provision user
|
||||
if platform.auto_create_users:
|
||||
user = provision_lti_user(platform, launch_data)
|
||||
else:
|
||||
# Must find existing user
|
||||
from .models import LTIUserMapping
|
||||
|
||||
mapping = LTIUserMapping.objects.filter(platform=platform, lti_user_id=sub).first()
|
||||
if not mapping:
|
||||
raise ValueError("User auto-creation disabled and no existing mapping found")
|
||||
user = mapping.user
|
||||
user = provision_lti_user(platform, launch_data)
|
||||
|
||||
# Provision context (category + RBAC group)
|
||||
if 'https://purl.imsglobal.org/spec/lti/claim/context' in launch_data:
|
||||
@@ -231,7 +221,7 @@ class LaunchView(View):
|
||||
create_lti_session(request, user, message_launch, platform)
|
||||
|
||||
# Log successful launch
|
||||
LTILaunchLog.objects.create(platform=platform, user=user, resource_link=resource_link_obj, launch_type='resource_link', success=True, claims=claims, ip_address=get_client_ip(request))
|
||||
LTILaunchLog.objects.create(platform=platform, user=user, resource_link=resource_link_obj, launch_type='resource_link', success=True, claims=claims)
|
||||
|
||||
# Determine where to redirect
|
||||
redirect_url = self.determine_redirect(launch_data, resource_link_obj)
|
||||
@@ -245,7 +235,7 @@ class LaunchView(View):
|
||||
|
||||
# Log failed launch
|
||||
if platform:
|
||||
LTILaunchLog.objects.create(platform=platform, user=user, launch_type='resource_link', success=False, error_message=error_message, claims=claims, ip_address=get_client_ip(request))
|
||||
LTILaunchLog.objects.create(platform=platform, user=user, launch_type='resource_link', success=False, error_message=error_message, claims=claims)
|
||||
|
||||
return render(request, 'lti/launch_error.html', {'error': 'LTI Launch Failed', 'message': error_message}, status=400)
|
||||
|
||||
@@ -311,14 +301,13 @@ class JWKSView(View):
|
||||
"""
|
||||
JWKS Endpoint - Provides tool's public keys
|
||||
|
||||
Used by Moodle to validate signatures from MediaCMS
|
||||
Used by Moodle to validate signatures from MediaCMS (e.g., Deep Linking responses)
|
||||
"""
|
||||
|
||||
def get(self, request):
|
||||
"""Return tool's public JWK Set"""
|
||||
# For now, return empty JWKS since we're not signing responses
|
||||
# In the future, we can generate and store keys for signing deep linking responses
|
||||
jwks = {"keys": []}
|
||||
# Return public keys for signature validation
|
||||
jwks = get_jwks()
|
||||
|
||||
return JsonResponse(jwks, content_type='application/json')
|
||||
|
||||
@@ -390,7 +379,7 @@ class ManualSyncView(APIView):
|
||||
"""Manually trigger NRPS sync"""
|
||||
try:
|
||||
# Get platform
|
||||
platform = get_object_or_404(LTIPlatform, id=platform_id, active=True)
|
||||
platform = get_object_or_404(LTIPlatform, id=platform_id)
|
||||
|
||||
# Find resource link by context
|
||||
resource_link = LTIResourceLink.objects.filter(platform=platform, context_id=context_id).first()
|
||||
|
||||
Reference in New Issue
Block a user