From 3131e76ef79b90788773641a7875911c27e56541 Mon Sep 17 00:00:00 2001 From: Markos Gogoulos Date: Thu, 29 Jan 2026 12:30:57 +0200 Subject: [PATCH] this --- lti/adapters.py | 68 ++++++++++++++++++++++++++++++++++++++++++++----- lti/views.py | 53 +++++++++++++++++++++++++++++++++----- 2 files changed, 108 insertions(+), 13 deletions(-) diff --git a/lti/adapters.py b/lti/adapters.py index 7336e17c..dc576dba 100644 --- a/lti/adapters.py +++ b/lti/adapters.py @@ -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): diff --git a/lti/views.py b/lti/views.py index c3496b56..a8e7b8bf 100644 --- a/lti/views.py +++ b/lti/views.py @@ -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}")