From b76c1c7647d2e33bc93e9cf48e75382886f2e71a Mon Sep 17 00:00:00 2001 From: Ostap Zherebetskyi Date: Fri, 8 May 2026 14:43:20 +0300 Subject: [PATCH 1/2] Proof of Concept for Login via CAS in Django --- api/auth.py | 61 +++++++++++++++++++++++++++++++++++ api/base/middleware.py | 73 ++++++++++++++++++++++++++++++++++++++++++ api/base/urls.py | 4 ++- 3 files changed, 137 insertions(+), 1 deletion(-) create mode 100644 api/auth.py diff --git a/api/auth.py b/api/auth.py new file mode 100644 index 00000000000..8a76474db78 --- /dev/null +++ b/api/auth.py @@ -0,0 +1,61 @@ +from django.views.decorators.http import require_GET +from django.http import HttpResponseRedirect, HttpResponse +from website import settings +# from framework.auth.cas import CasClient +from osf.models import OSFUser + + +@require_GET +def auth_login(request): + ticket = request.GET.get('ticket') + if not ticket: + return HttpResponse('Missing ticket', status=400) + + # redirect to Angular + next_url = request.GET.get('next', 'http://localhost:4200/') + response = HttpResponseRedirect(next_url) + + from osf.utils.fields import ensure_str + from django.contrib.auth import login + import itsdangerous + + user = OSFUser.objects.get(username='test@mail.com') + login(request, user, backend='api.base.authentication.backends.ODMBackend') + session = request.session + data = { + 'auth_user_username': user.username, + 'auth_user_id': user._primary_key, + 'auth_user_fullname': user.fullname, + 'user_reference_uri': user.get_semantic_iri(), + } + for key, value in data.items() if data else {}: + session[key] = value + + # Note: session.modified is set to False here to prevent Django from saving the session again in SessionMiddleware.process_response, + # which would overwrite the session cookie set here with an unsigned version. + # Setting cookie can be done in process_response by adding session_key signing. + session.modified = False + session.save() + + session_key = session._get_or_create_session_key() + signed_session_key = ensure_str(itsdangerous.Signer(settings.SECRET_KEY).sign(session_key)) + + response.set_cookie( + 'osf', + signed_session_key, + domain=settings.OSF_COOKIE_DOMAIN, + secure=settings.SESSION_COOKIE_SECURE, + httponly=settings.SESSION_COOKIE_HTTPONLY, + samesite=settings.SESSION_COOKIE_SAMESITE, + ) + from django.middleware.csrf import get_token + + csrf_token = get_token(request) + + response.set_cookie( + 'api-csrf', + csrf_token, + samesite='Lax', + ) + + return response diff --git a/api/base/middleware.py b/api/base/middleware.py index bec771aba61..92b58a6cebf 100644 --- a/api/base/middleware.py +++ b/api/base/middleware.py @@ -119,6 +119,16 @@ def process_response(self, request, response): return response +# import time +# from importlib import import_module + +# from django.contrib.sessions.backends.base import UpdateError +# from django.contrib.sessions.exceptions import SessionInterrupted +# from django.utils.cache import patch_vary_headers +# from django.utils.deprecation import MiddlewareMixin +# from django.utils.http import http_date + + class UnsignCookieSessionMiddleware(SessionMiddleware): """ Overrides the process_request hook of SessionMiddleware @@ -132,3 +142,66 @@ def process_request(self, request): request.session = drf_get_session_from_cookie(cookie) else: request.session = SessionStore() + + # Example of process_response with session cookie signing. Not used currently as signing is done in auth_login view and session cookie is set there. + # def process_response(self, request, response): + # """ + # If request.session was modified, or if the configuration is to save the + # session every time, save the changes and set a session cookie or delete + # the session cookie if the session has been emptied. + # """ + # try: + # accessed = request.session.accessed + # modified = request.session.modified + # empty = request.session.is_empty() + # except AttributeError: + # return response + # # First check if we need to delete this cookie. + # # The session should be deleted only if the session is entirely empty. + # if settings.SESSION_COOKIE_NAME in request.COOKIES and empty: + # response.delete_cookie( + # settings.SESSION_COOKIE_NAME, + # path=settings.SESSION_COOKIE_PATH, + # domain=settings.SESSION_COOKIE_DOMAIN, + # samesite=settings.SESSION_COOKIE_SAMESITE, + # ) + # patch_vary_headers(response, ("Cookie",)) + # else: + # if accessed: + # patch_vary_headers(response, ("Cookie",)) + # if (modified or settings.SESSION_SAVE_EVERY_REQUEST) and not empty: + # if request.session.get_expire_at_browser_close(): + # max_age = None + # expires = None + # else: + # max_age = request.session.get_expiry_age() + # expires_time = time.time() + max_age + # expires = http_date(expires_time) + # # Save the session data and refresh the client cookie. + # # Skip session save for 5xx responses. + # if response.status_code < 500: + # try: + # request.session.save() + # except UpdateError: + # raise SessionInterrupted( + # "The request's session was deleted before the " + # "request completed. The user may have logged " + # "out in a concurrent request, for example." + # ) + + # from osf.utils.fields import ensure_str + # import itsdangerous + + # signed_session_key = ensure_str(itsdangerous.Signer(settings.SECRET_KEY).sign(request.session.session_key)) + # response.set_cookie( + # settings.SESSION_COOKIE_NAME, + # signed_session_key, + # max_age=max_age, + # expires=expires, + # domain=settings.SESSION_COOKIE_DOMAIN, + # path=settings.SESSION_COOKIE_PATH, + # secure=settings.SESSION_COOKIE_SECURE or None, + # httponly=settings.SESSION_COOKIE_HTTPONLY or None, + # samesite=settings.SESSION_COOKIE_SAMESITE, + # ) + # return response diff --git a/api/base/urls.py b/api/base/urls.py index 142e2df34c2..3e55cd210c7 100644 --- a/api/base/urls.py +++ b/api/base/urls.py @@ -1,4 +1,4 @@ -from django.urls import include, re_path +from django.urls import include, re_path, path from django.views.generic.base import RedirectView @@ -6,6 +6,7 @@ from api.base import settings from api.base import versioning from api.providers.views import RegistrationBulkCreate +from api.auth import auth_login default_version = versioning.decimal_version_to_url_path(settings.REST_FRAMEWORK['DEFAULT_VERSION']) @@ -86,6 +87,7 @@ ), ), re_path(r'^$', RedirectView.as_view(pattern_name=views.root), name='redirect-to-root', kwargs={'version': default_version}), + path('login', auth_login, name='login'), ] # Add django-silk URLs if it's in INSTALLED_APPS From 80afed0514de3fb4303c2a403d314c107f811041 Mon Sep 17 00:00:00 2001 From: Ostap Zherebetskyi Date: Fri, 8 May 2026 15:30:59 +0300 Subject: [PATCH 2/2] Django CAS ticket validation --- api/auth.py | 47 +++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 39 insertions(+), 8 deletions(-) diff --git a/api/auth.py b/api/auth.py index 8a76474db78..4648a1544bc 100644 --- a/api/auth.py +++ b/api/auth.py @@ -1,9 +1,37 @@ from django.views.decorators.http import require_GET from django.http import HttpResponseRedirect, HttpResponse +from furl import furl from website import settings -# from framework.auth.cas import CasClient -from osf.models import OSFUser +from framework.auth import cas +from framework.auth.utils import print_cas_log, LogLevel +def make_response_from_ticket(ticket, service_url): + """ + Given a CAS ticket and service URL, attempt to validate the user and return user object. + + :param str ticket: CAS service ticket + :param str service_url: Service URL from which the authentication request originates + :return: user object if authentication is successful, otherwise an HttpResponse with an error message and status code + """ + + service_furl = furl(service_url) + if 'ticket' in service_furl.args: + service_furl.remove(args=['ticket']) + client = cas.get_client() + cas_resp = client.service_validate(ticket, service_furl.url) + if cas_resp.authenticated: + user, external_credential, action = cas.get_user_from_cas_resp(cas_resp) + if user and action == 'authenticate': + print_cas_log( + f'CAS response - authenticating user: user=[{user._id}], ' + f'external=[{external_credential}], action=[{action}]', + LogLevel.INFO, + ) + # if user is authenticated by CAS + print_cas_log(f'CAS response - finalizing authentication: user=[{user._id}]', LogLevel.INFO) + return user + + return HttpResponse('CAS authentication failed', status=401) @require_GET def auth_login(request): @@ -19,14 +47,17 @@ def auth_login(request): from django.contrib.auth import login import itsdangerous - user = OSFUser.objects.get(username='test@mail.com') - login(request, user, backend='api.base.authentication.backends.ODMBackend') + service_url = furl(request.build_absolute_uri()).remove(args=['ticket']) + user_or_response = make_response_from_ticket(ticket, service_url.url) + if isinstance(user_or_response, HttpResponse): + return user_or_response + login(request, user_or_response, backend='api.base.authentication.backends.ODMBackend') session = request.session data = { - 'auth_user_username': user.username, - 'auth_user_id': user._primary_key, - 'auth_user_fullname': user.fullname, - 'user_reference_uri': user.get_semantic_iri(), + 'auth_user_username': user_or_response.username, + 'auth_user_id': user_or_response._primary_key, + 'auth_user_fullname': user_or_response.fullname, + 'user_reference_uri': user_or_response.get_semantic_iri(), } for key, value in data.items() if data else {}: session[key] = value