diff --git a/api/auth.py b/api/auth.py new file mode 100644 index 00000000000..4648a1544bc --- /dev/null +++ b/api/auth.py @@ -0,0 +1,92 @@ +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 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): + 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 + + 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() + + 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