From b76c1c7647d2e33bc93e9cf48e75382886f2e71a Mon Sep 17 00:00:00 2001 From: Ostap Zherebetskyi Date: Fri, 8 May 2026 14:43:20 +0300 Subject: [PATCH 1/3] 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/3] 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 From 9b0c7a2af5014be8217b494509a40f7ba1dee044 Mon Sep 17 00:00:00 2001 From: Ostap Zherebetskyi Date: Mon, 11 May 2026 19:25:54 +0300 Subject: [PATCH 3/3] add external login --- api/auth.py | 144 +++++++++++++++++++++++++++-------------- api/base/middleware.py | 124 +++++++++++++++-------------------- api/base/urls.py | 2 +- 3 files changed, 149 insertions(+), 121 deletions(-) diff --git a/api/auth.py b/api/auth.py index 4648a1544bc..cac5622917b 100644 --- a/api/auth.py +++ b/api/auth.py @@ -1,37 +1,106 @@ 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.tasks import update_user_from_activity +from framework.celery_tasks.handlers import enqueue_task from framework.auth import cas from framework.auth.utils import print_cas_log, LogLevel +from django.conf import settings as api_settings +from django.utils import timezone def make_response_from_ticket(ticket, service_url): """ - Given a CAS ticket and service URL, attempt to validate the user and return user object. + Given a CAS ticket and service URL, attempt to validate the user and return a proper redirect response. :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 + :return: redirect response """ service_furl = furl(service_url) + # `service_url` is guaranteed to be removed of `ticket` parameter, which has been pulled off in + # `framework.sessions.before_request()`. 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) + user_updates = {} # serialize updates to user to be applied async + session_updates = {} # session updates to be applied immediately + # user found and authenticated 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 users check the TOS consent checkbox via CAS, CAS sets the attribute `termsOfServiceChecked` to `true` + # and then release it to OSF among other authentication attributes. When OSF receives it, it trusts CAS and + # updates the user object if this is THE FINAL STEP of the login flow. DON'T update TOS consent status when + # `external_credential == true` (i.e. w/ `action == 'authenticate'` or `action == 'external_first_login'`) + # since neither is the final step of a login flow. + tos_checked_via_cas = cas_resp.attributes.get('termsOfServiceChecked', 'false') == 'true' + if tos_checked_via_cas: + user_updates['accepted_terms_of_service'] = timezone.now() + print_cas_log(f'CAS TOS consent checked: {user.guids.first()._id}, {user.username}', LogLevel.INFO) + # if we successfully authenticate and a verification key is present, invalidate it + if user.verification_key: + user_updates['verification_key'] = None + + # if user is authenticated by external IDP, ask CAS to authenticate user for a second time + # this extra step will guarantee that 2FA are enforced + # current CAS session created by external login must be cleared first before authentication + if external_credential: + user.verification_key = cas.generate_verification_key() + user.save() + print_cas_log( + f'CAS response - redirect existing external IdP login to verification key login: user=[{user._id}]', + LogLevel.INFO, + ) + return user, user_updates, session_updates, cas.get_logout_url( + cas.get_login_url( + service_url, + username=user.username, + verification_key=user.verification_key, + ), + ) + # 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) + session_updates = { + 'auth_user_username': user.username, + 'auth_user_id': user._primary_key, + 'auth_user_fullname': user.fullname, + 'user_reference_uri': user.get_semantic_iri(), + } + return user, user_updates, session_updates, None + # first time login from external identity provider + if not user and external_credential and action == 'external_first_login': + print_cas_log( + f'CAS response - first login from external IdP: ' + f'external=[{external_credential}], action=[{action}]', + LogLevel.INFO, + ) + # orcid attributes can be marked private and not shared, default to orcid otherwise + fullname = f'{cas_resp.attributes.get("given-names", "")} {cas_resp.attributes.get("family-name", "")}'.strip() + session_updates = { + 'auth_user_external_id_provider': external_credential['provider'], + 'auth_user_external_id': external_credential['id'], + 'auth_user_fullname': fullname, + 'auth_user_external_first_login': True, + 'service_url': service_furl.url, + } + user_identity = f'{external_credential["provider"]}#{external_credential["id"]}' + print_cas_log( + f'Finalizing first-time login from external IdP - data updated: user=[{user_identity}]', + LogLevel.INFO, + ) + print_cas_log(f'CAS response - creating anonymous session: external=[{external_credential}]', LogLevel.INFO) + return None, None, session_updates, 'ang_route' # TODO: ANG route for email collection page + # Unauthorized: ticket could not be validated, or user does not exist. + print_cas_log('Ticket validation failed or user does not exist. Redirect back to service URL (logged out).', LogLevel.ERROR) + return None, None, None, None @require_GET def auth_login(request): @@ -41,52 +110,31 @@ def auth_login(request): # 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 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_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 - - # 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() + user, user_updates, session_updates, redirect_url = make_response_from_ticket(ticket, service_url.url) + response = HttpResponseRedirect(redirect_url if redirect_url else next_url) - session_key = session._get_or_create_session_key() - signed_session_key = ensure_str(itsdangerous.Signer(settings.SECRET_KEY).sign(session_key)) + if user: + from django.contrib.auth import login + login(request, user, backend='api.base.authentication.backends.ODMBackend') + if user_updates: + enqueue_task(update_user_from_activity.s(user._id, timezone.now().timestamp(), cas_login=True, updates=user_updates)) - 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 + from django.middleware.csrf import get_token + csrf_token = get_token(request) + response.set_cookie( + api_settings.CSRF_COOKIE_NAME, + csrf_token, + max_age=api_settings.CSRF_COOKIE_AGE, + domain=api_settings.CSRF_COOKIE_DOMAIN, + path=api_settings.CSRF_COOKIE_PATH, + httponly=api_settings.CSRF_COOKIE_HTTPONLY, + ) - csrf_token = get_token(request) - - response.set_cookie( - 'api-csrf', - csrf_token, - samesite='Lax', - ) + session = request.session + for key, value in session_updates.items() if session_updates else {}: + session[key] = value + session.save() return response diff --git a/api/base/middleware.py b/api/base/middleware.py index 92b58a6cebf..921a1b5ffed 100644 --- a/api/base/middleware.py +++ b/api/base/middleware.py @@ -25,6 +25,13 @@ from api.base import settings as api_settings from api.base.authentication.drf import drf_get_session_from_cookie +from django.contrib.sessions.backends.base import UpdateError +from django.contrib.sessions.exceptions import SessionInterrupted +from django.utils.cache import patch_vary_headers +from osf.utils.fields import ensure_str +import itsdangerous + + SessionStore = import_module(settings.SESSION_ENGINE).SessionStore @@ -119,16 +126,6 @@ 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 @@ -143,65 +140,48 @@ def process_request(self, request): 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 + 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( + api_settings.SESSION_COOKIE_NAME, + samesite=api_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: + # 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.', + ) + + signed_session_key = ensure_str(itsdangerous.Signer(settings.SECRET_KEY).sign(request.session.session_key)) + response.set_cookie( + api_settings.SESSION_COOKIE_NAME, + signed_session_key, + secure=api_settings.SESSION_COOKIE_SECURE, + httponly=api_settings.SESSION_COOKIE_HTTPONLY, + samesite=api_settings.SESSION_COOKIE_SAMESITE, + ) + return response diff --git a/api/base/urls.py b/api/base/urls.py index 3e55cd210c7..ea6cd57aa3e 100644 --- a/api/base/urls.py +++ b/api/base/urls.py @@ -87,7 +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'), + path('cas/auth/login', auth_login, name='login'), ] # Add django-silk URLs if it's in INSTALLED_APPS