This commit is contained in:
Markos Gogoulos
2026-01-29 12:30:57 +02:00
parent 809cdccc42
commit 3131e76ef7
2 changed files with 108 additions and 13 deletions

View File

@@ -95,39 +95,80 @@ class DjangoMessageLaunch:
class DjangoSessionService:
"""Launch data storage using Django sessions"""
"""Launch data storage using Django sessions with cache fallback for state"""
def __init__(self, request):
self.request = request
self._session_key_prefix = 'lti1p3_'
self._cache_prefix = 'lti1p3_cache_'
def get_launch_data(self, key):
"""Get launch data from session"""
"""Get launch data from session or cache (for state keys)"""
# For state keys, try cache first (more reliable for cross-site flows)
if key.startswith('state-'):
cache_key = self._cache_prefix + key
cached_data = cache.get(cache_key)
if cached_data:
return json.loads(cached_data) if isinstance(cached_data, str) else cached_data
# Try session
session_key = self._session_key_prefix + key
data = self.request.session.get(session_key)
return json.loads(data) if data else None
def save_launch_data(self, key, data):
"""Save launch data to session"""
"""Save launch data to session and cache (for state keys)"""
# For state keys, save to both session and cache
if key.startswith('state-'):
cache_key = self._cache_prefix + key
# Store in cache for 10 minutes (longer than typical LTI flow)
cache.set(cache_key, json.dumps(data), timeout=600)
# Also save to session
session_key = self._session_key_prefix + key
self.request.session[session_key] = json.dumps(data)
self.request.session.modified = True
# Force immediate session save for concurrent requests
try:
self.request.session.save()
except Exception:
# If session save fails, we still have cache
pass
return True
def check_launch_data_storage_exists(self, key):
"""Check if launch data exists in session"""
"""Check if launch data exists in session or cache"""
# For state keys, check cache first
if key.startswith('state-'):
cache_key = self._cache_prefix + key
if cache.get(cache_key) is not None:
return True
# Check session
session_key = self._session_key_prefix + key
return session_key in self.request.session
def check_state_is_valid(self, state, nonce):
"""Check if state is valid - state is for CSRF protection, nonce is validated separately by JWT"""
import logging
logger = logging.getLogger(__name__)
state_key = f'state-{state}'
logger.error(f"[STATE VALIDATION] Checking state: {state}")
logger.error(f"[STATE VALIDATION] Looking for session key: {self._session_key_prefix + state_key}")
# List all session keys for debugging
all_keys = [k for k in self.request.session.keys() if k.startswith(self._session_key_prefix)]
logger.error(f"[STATE VALIDATION] All LTI session keys: {all_keys}")
state_data = self.get_launch_data(state_key)
if not state_data:
logger.error("[STATE VALIDATION] State NOT found in session")
return False
logger.error(f"[STATE VALIDATION] State found successfully: {state_data}")
# State exists - that's sufficient for CSRF protection
# Nonce validation is handled by PyLTI1p3 through JWT signature and claims validation
return True
@@ -136,12 +177,25 @@ class DjangoSessionService:
"""Check if nonce is valid (not used before) and mark it as used"""
nonce_key = f'nonce-{nonce}'
# Use cache for nonce checking (more reliable for cross-site flows)
cache_key = self._cache_prefix + nonce_key
# Check if nonce was already used
if self.check_launch_data_storage_exists(nonce_key):
if cache.get(cache_key) is not None:
return False
# Mark nonce as used
self.save_launch_data(nonce_key, {'used': True})
# Mark nonce as used in cache (expires in 10 minutes)
cache.set(cache_key, json.dumps({'used': True}), timeout=600)
# Also save to session for redundancy
try:
session_key = self._session_key_prefix + nonce_key
self.request.session[session_key] = json.dumps({'used': True})
self.request.session.modified = True
except Exception:
# If session fails, cache is sufficient
pass
return True
def set_state_valid(self, state, id_token_hash):

View File

@@ -77,6 +77,13 @@ class OIDCLoginView(View):
def handle_oidc_login(self, request):
"""Handle OIDC login initiation"""
try:
# Ensure session exists and has a session key
if not request.session.session_key:
request.session.create()
logger.error(f"[OIDC LOGIN DEBUG] Created new session: {request.session.session_key}")
else:
logger.error(f"[OIDC LOGIN DEBUG] Using existing session: {request.session.session_key}")
target_link_uri = request.GET.get('target_link_uri') or request.POST.get('target_link_uri')
iss = request.GET.get('iss') or request.POST.get('iss')
client_id = request.GET.get('client_id') or request.POST.get('client_id')
@@ -116,8 +123,15 @@ class OIDCLoginView(View):
if cmid:
launch_data['cmid'] = cmid
logger.error(f"[OIDC LOGIN DEBUG] Generated state: {state}")
logger.error(f"[OIDC LOGIN DEBUG] Saving launch data with media_friendly_token: {launch_data.get('media_friendly_token')}")
session_service.save_launch_data(f'state-{state}', launch_data)
# Verify state was saved
saved_data = session_service.get_launch_data(f'state-{state}')
logger.error(f"[OIDC LOGIN DEBUG] Verified saved state data: {saved_data}")
params = {
'response_type': 'id_token',
'redirect_uri': target_link_uri,
@@ -141,7 +155,21 @@ class OIDCLoginView(View):
logger.error(f"[OIDC LOGIN DEBUG] Has media_friendly_token: {bool(media_friendly_token)}")
logger.error(f"[OIDC LOGIN DEBUG] cmid: {cmid}")
return HttpResponseRedirect(redirect_url)
response = HttpResponseRedirect(redirect_url)
# Ensure session cookie is set in response
if request.session.session_key:
response.set_cookie(
key=request.session.cookie_name,
value=request.session.session_key,
max_age=request.session.get_expiry_age(),
expires=request.session.get_expiry_date(),
domain=request.session.get_cookie_domain(),
path=request.session.get_cookie_path(),
secure=request.session.cookie_secure,
httponly=request.session.cookie_httponly,
samesite=request.session.cookie_samesite,
)
return response
except Exception:
raise
@@ -173,6 +201,10 @@ class LaunchView(View):
try:
id_token = request.POST.get('id_token')
state = request.POST.get('state')
logger.error(f"[LTI LAUNCH DEBUG] Received state: {state}")
if not id_token:
raise ValueError("Missing id_token in launch request")
@@ -191,6 +223,12 @@ class LaunchView(View):
session_service = DjangoSessionService(request)
cookie_service = DjangoSessionService(request)
# Retrieve stored OIDC login data using state parameter
stored_oidc_data = None
if state:
stored_oidc_data = session_service.get_launch_data(f'state-{state}')
logger.error(f"[LTI LAUNCH DEBUG] Retrieved stored OIDC data: {stored_oidc_data}")
class CustomMessageLaunch(MessageLaunch):
def _get_request_param(self, key):
"""Override to properly get request parameters"""
@@ -235,14 +273,17 @@ class LaunchView(View):
create_lti_session(request, user, message_launch, platform)
# Check for media_friendly_token in custom claims
# Check for media_friendly_token in multiple places:
# 1. Custom claims (from Moodle LTI configuration)
media_token = custom_claims.get('media_friendly_token')
logger.error(f"[LTI LAUNCH DEBUG] media_token from custom_claims: {media_token}")
if media_token:
logger.error(f"[LTI LAUNCH DEBUG] Found media_friendly_token in custom claims: {media_token}")
# Check if media token was passed via target_link_uri query parameter (from filter launch)
logger.error(f"[LTI LAUNCH DEBUG] About to check target_link_uri, media_token is: {media_token}")
# 2. Stored OIDC data (from filter-based launches)
if not media_token and stored_oidc_data:
media_token = stored_oidc_data.get('media_friendly_token')
logger.error(f"[LTI LAUNCH DEBUG] media_token from stored OIDC data: {media_token}")
# 3. Target link URI query parameters (fallback)
if not media_token:
target_link_uri = launch_data.get('https://purl.imsglobal.org/spec/lti/claim/target_link_uri', '')
logger.error(f"[LTI LAUNCH DEBUG] target_link_uri: {target_link_uri}")