Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 140 additions & 0 deletions api/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
from django.views.decorators.http import require_GET
from django.http import HttpResponseRedirect, HttpResponse
from furl import furl
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 a proper redirect response.

:param str ticket: CAS service ticket
:param str service_url: Service URL from which the authentication request originates
: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)
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):
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/')

service_url = furl(request.build_absolute_uri()).remove(args=['ticket'])
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)

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))

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,
)

session = request.session
for key, value in session_updates.items() if session_updates else {}:
session[key] = value
session.save()

return response
53 changes: 53 additions & 0 deletions api/base/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -132,3 +139,49 @@ def process_request(self, request):
request.session = drf_get_session_from_cookie(cookie)
else:
request.session = SessionStore()

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
4 changes: 3 additions & 1 deletion api/base/urls.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
from django.urls import include, re_path
from django.urls import include, re_path, path
from django.views.generic.base import RedirectView


from api.base import views
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'])

Expand Down Expand Up @@ -86,6 +87,7 @@
),
),
re_path(r'^$', RedirectView.as_view(pattern_name=views.root), name='redirect-to-root', kwargs={'version': default_version}),
path('cas/auth/login', auth_login, name='login'),
]

# Add django-silk URLs if it's in INSTALLED_APPS
Expand Down