Skip to content

Commit 5317107

Browse files
authored
Merge pull request #139 from sheilatron/ACS_customizable
More extensible view for assertion consumer service
2 parents 9700447 + a8e3a7a commit 5317107

File tree

4 files changed

+142
-100
lines changed

4 files changed

+142
-100
lines changed

CHANGES

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ Changes
1414
- py38 Test fixes
1515
- CI with Github actions
1616
- Backend restructuring for easier subclassing
17+
- Assertion consumer service now more extensible as a class-based view
18+
with hooks that can be overridden by subclass implementations.
1719

1820
0.18.1 (2020-02-15)
1921
----------

djangosaml2/tests/urls.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020

2121
urlpatterns = [
2222
url(r'^login/$', views.login, name='saml2_login'),
23-
url(r'^acs/$', views.assertion_consumer_service, name='saml2_acs'),
23+
url(r'^acs/$', views.AssertionConsumerServiceView.as_view(), name='saml2_acs'),
2424
url(r'^logout/$', views.logout, name='saml2_logout'),
2525
url(r'^ls/$', views.logout_service, name='saml2_ls'),
2626
url(r'^ls/post/$', views.logout_service_post, name='saml2_ls_post'),

djangosaml2/urls.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919

2020
urlpatterns = [
2121
path('login/', views.login, name='saml2_login'),
22-
path('acs/', views.assertion_consumer_service, name='saml2_acs'),
22+
path('acs/', views.AssertionConsumerServiceView.as_view(), name='saml2_acs'),
2323
path('logout/', views.logout, name='saml2_logout'),
2424
path('ls/', views.logout_service, name='saml2_ls'),
2525
path('ls/post/', views.logout_service_post, name='saml2_ls_post'),

djangosaml2/views.py

Lines changed: 138 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -27,15 +27,13 @@
2727
from django.shortcuts import render
2828
from django.template import TemplateDoesNotExist
2929
from django.views.decorators.csrf import csrf_exempt
30-
from django.views.decorators.http import require_POST
30+
from django.views.generic import View
31+
from django.utils.decorators import method_decorator
32+
3133
from saml2 import BINDING_HTTP_REDIRECT, BINDING_HTTP_POST
3234
from saml2.client_base import LogoutError
3335
from saml2.metadata import entity_descriptor
3436
from saml2.ident import code, decode
35-
from saml2.metadata import entity_descriptor
36-
from saml2.response import (SignatureError, StatusAuthnFailed, StatusError,
37-
StatusNoAuthnContext, StatusRequestDenied,
38-
UnsolicitedResponse)
3937
from saml2.s_utils import UnsupportedBinding
4038
from saml2.response import (
4139
StatusError, StatusAuthnFailed, SignatureError, StatusRequestDenied,
@@ -255,103 +253,145 @@ def login(request,
255253
return http_response
256254

257255

258-
@require_POST
259-
@csrf_exempt
260-
def assertion_consumer_service(request,
261-
config_loader_path=None,
262-
attribute_mapping=None,
263-
create_unknown_user=None):
264-
"""SAML Authorization Response endpoint
265-
266-
The IdP will send its response to this view, which
267-
will process it with pysaml2 help and log the user
268-
in using the custom Authorization backend
269-
djangosaml2.backends.Saml2Backend that should be
270-
enabled in the settings.py
256+
class AssertionConsumerServiceView(View):
257+
"""
258+
The IdP will send its response to this view, which will process it using pysaml2 and
259+
log the user in using whatever SAML authentication backend has been enabled in
260+
settings.py. The `djangosaml2.backends.Saml2Backend` can be used for this purpose,
261+
though some implementations may instead register their own subclasses of Saml2Backend.
271262
"""
272-
attribute_mapping = attribute_mapping or get_custom_setting('SAML_ATTRIBUTE_MAPPING', {'uid': ('username', )})
273-
create_unknown_user = create_unknown_user if create_unknown_user is not None else \
274-
get_custom_setting('SAML_CREATE_UNKNOWN_USER', True)
275-
conf = get_config(config_loader_path, request)
276-
xmlstr = request.POST.get('SAMLResponse')
277-
if not xmlstr:
278-
logger.warning('Missing "SAMLResponse" parameter in POST data.')
279-
raise SuspiciousOperation
280263

281-
client = Saml2Client(conf, identity_cache=IdentityCache(request.session))
264+
@method_decorator(csrf_exempt)
265+
def dispatch(self, request, *args, **kwargs):
266+
"""
267+
This view needs to be CSRF exempt because it is called prior to login.
268+
"""
269+
return super(AssertionConsumerServiceView, self).dispatch(request, *args, **kwargs)
270+
271+
@method_decorator(csrf_exempt)
272+
def post(self,
273+
request,
274+
config_loader_path=None,
275+
attribute_mapping=None,
276+
create_unknown_user=None):
277+
"""
278+
SAML Authorization Response endpoint
279+
"""
280+
attribute_mapping = attribute_mapping or get_custom_setting('SAML_ATTRIBUTE_MAPPING', {'uid': ('username', )})
281+
create_unknown_user = create_unknown_user if create_unknown_user is not None else \
282+
get_custom_setting('SAML_CREATE_UNKNOWN_USER', True)
283+
conf = get_config(config_loader_path, request)
284+
try:
285+
xmlstr = request.POST['SAMLResponse']
286+
except KeyError:
287+
logger.warning('Missing "SAMLResponse" parameter in POST data.')
288+
raise SuspiciousOperation
282289

283-
oq_cache = OutstandingQueriesCache(request.session)
284-
outstanding_queries = oq_cache.outstanding_queries()
290+
client = Saml2Client(conf, identity_cache=IdentityCache(self.request.session))
291+
292+
oq_cache = OutstandingQueriesCache(self.request.session)
293+
outstanding_queries = oq_cache.outstanding_queries()
294+
295+
try:
296+
response = client.parse_authn_request_response(xmlstr, BINDING_HTTP_POST, outstanding_queries)
297+
except (StatusError, ToEarly) as e:
298+
logger.exception("Error processing SAML Assertion.")
299+
return fail_acs_response(request, exception=e)
300+
except ResponseLifetimeExceed as e:
301+
logger.info("SAML Assertion is no longer valid. Possibly caused by network delay or replay attack.", exc_info=True)
302+
return fail_acs_response(request, exception=e)
303+
except SignatureError as e:
304+
logger.info("Invalid or malformed SAML Assertion.", exc_info=True)
305+
return fail_acs_response(request, exception=e)
306+
except StatusAuthnFailed as e:
307+
logger.info("Authentication denied for user by IdP.", exc_info=True)
308+
return fail_acs_response(request, exception=e)
309+
except StatusRequestDenied as e:
310+
logger.warning("Authentication interrupted at IdP.", exc_info=True)
311+
return fail_acs_response(request, exception=e)
312+
except StatusNoAuthnContext as e:
313+
logger.warning("Missing Authentication Context from IdP.", exc_info=True)
314+
return fail_acs_response(request, exception=e)
315+
except MissingKey as e:
316+
logger.exception("SAML Identity Provider is not configured correctly: certificate key is missing!")
317+
return fail_acs_response(request, exception=e)
318+
except UnsolicitedResponse as e:
319+
logger.exception("Received SAMLResponse when no request has been made.")
320+
return fail_acs_response(request, exception=e)
321+
322+
if response is None:
323+
logger.warning("Invalid SAML Assertion received (unknown error).")
324+
return fail_acs_response(request, status=400, exception=SuspiciousOperation('Unknown SAML2 error'))
325+
326+
session_id = response.session_id()
327+
oq_cache.delete(session_id)
328+
329+
# authenticate the remote user
330+
session_info = response.session_info()
331+
332+
if callable(attribute_mapping):
333+
attribute_mapping = attribute_mapping()
334+
if callable(create_unknown_user):
335+
create_unknown_user = create_unknown_user()
336+
337+
logger.debug('Trying to authenticate the user. Session info: %s', session_info)
338+
user = auth.authenticate(request=request,
339+
session_info=session_info,
340+
attribute_mapping=attribute_mapping,
341+
create_unknown_user=create_unknown_user)
342+
if user is None:
343+
logger.warning("Could not authenticate user received in SAML Assertion. Session info: %s", session_info)
344+
return fail_acs_response(request, exception=PermissionDenied('No user could be authenticated.'))
345+
346+
auth.login(self.request, user)
347+
_set_subject_id(self.request.session, session_info['name_id'])
348+
logger.debug("User %s authenticated via SSO.", user)
349+
logger.debug('Sending the post_authenticated signal')
350+
351+
post_authenticated.send_robust(sender=user, session_info=session_info)
352+
self.customize_session(user, session_info)
353+
354+
relay_state = self.build_relay_state()
355+
custom_redirect_url = self.custom_redirect(user, relay_state, session_info)
356+
if custom_redirect_url:
357+
return HttpResponseRedirect(custom_redirect_url)
358+
relay_state = validate_referral_url(request, relay_state)
359+
logger.debug('Redirecting to the RelayState: %s', relay_state)
360+
return HttpResponseRedirect(relay_state)
361+
362+
def build_relay_state(self):
363+
"""
364+
The relay state is a URL used to redirect the user to the view where they came from.
365+
"""
366+
default_relay_state = get_custom_setting('ACS_DEFAULT_REDIRECT_URL',
367+
settings.LOGIN_REDIRECT_URL)
368+
relay_state = self.request.POST.get('RelayState', '/')
369+
relay_state = self.customize_relay_state(relay_state)
370+
if not relay_state:
371+
logger.warning('The RelayState parameter exists but is empty')
372+
relay_state = default_relay_state
373+
return relay_state
374+
375+
def customize_session(self, user, session_info):
376+
"""
377+
Subclasses can use this for customized functionality around user sessions.
378+
"""
379+
380+
def customize_relay_state(self, relay_state):
381+
"""
382+
Subclasses may override this method to implement custom logic for relay state.
383+
"""
384+
return relay_state
385+
386+
def custom_redirect(self, user, relay_state, session_info):
387+
"""
388+
Subclasses may override this method to implement custom logic for redirect.
389+
390+
For example, some sites may require user registration if the user has not
391+
yet been provisioned.
392+
"""
393+
return None
285394

286-
try:
287-
response = client.parse_authn_request_response(xmlstr, BINDING_HTTP_POST, outstanding_queries)
288-
except (StatusError, ToEarly) as e:
289-
logger.exception("Error processing SAML Assertion.")
290-
return fail_acs_response(request, exception=e)
291-
except ResponseLifetimeExceed as e:
292-
logger.info("SAML Assertion is no longer valid. Possibly caused by network delay or replay attack.", exc_info=True)
293-
return fail_acs_response(request, exception=e)
294-
except SignatureError as e:
295-
logger.info("Invalid or malformed SAML Assertion.", exc_info=True)
296-
return fail_acs_response(request, exception=e)
297-
except StatusAuthnFailed as e:
298-
logger.info("Authentication denied for user by IdP.", exc_info=True)
299-
return fail_acs_response(request, exception=e)
300-
except StatusRequestDenied as e:
301-
logger.warning("Authentication interrupted at IdP.", exc_info=True)
302-
return fail_acs_response(request, exception=e)
303-
except StatusNoAuthnContext as e:
304-
logger.warning("Missing Authentication Context from IdP.", exc_info=True)
305-
return fail_acs_response(request, exception=e)
306-
except MissingKey as e:
307-
logger.exception("SAML Identity Provider is not configured correctly: certificate key is missing!")
308-
return fail_acs_response(request, exception=e)
309-
except UnsolicitedResponse as e:
310-
logger.exception("Received SAMLResponse when no request has been made.")
311-
return fail_acs_response(request, exception=e)
312-
313-
if response is None:
314-
logger.warning("Invalid SAML Assertion received (unknown error).")
315-
return fail_acs_response(request, status=400, exception=SuspiciousOperation('Unknown SAML2 error'))
316-
317-
session_id = response.session_id()
318-
oq_cache.delete(session_id)
319-
320-
# authenticate the remote user
321-
session_info = response.session_info()
322-
323-
if callable(attribute_mapping):
324-
attribute_mapping = attribute_mapping()
325-
if callable(create_unknown_user):
326-
create_unknown_user = create_unknown_user()
327-
328-
logger.debug('Trying to authenticate the user. Session info: %s', session_info)
329-
user = auth.authenticate(request=request,
330-
session_info=session_info,
331-
attribute_mapping=attribute_mapping,
332-
create_unknown_user=create_unknown_user)
333-
if user is None:
334-
logger.warning("Could not authenticate user received in SAML Assertion. Session info: %s", session_info)
335-
return fail_acs_response(request, exception=PermissionDenied('No user could be authenticated.'))
336-
337-
auth.login(request, user)
338-
_set_subject_id(request.session, session_info['name_id'])
339-
logger.debug("User %s authenticated via SSO.", user)
340-
341-
logger.debug('Sending the post_authenticated signal')
342-
post_authenticated.send_robust(sender=user, session_info=session_info)
343-
344-
# redirect the user to the view where he came from
345-
default_relay_state = get_custom_setting('ACS_DEFAULT_REDIRECT_URL',
346-
settings.LOGIN_REDIRECT_URL)
347-
relay_state = request.POST.get('RelayState', default_relay_state)
348-
if not relay_state:
349-
logger.warning('The RelayState parameter exists but is empty')
350-
relay_state = default_relay_state
351-
relay_state = validate_referral_url(request, relay_state)
352-
353-
logger.debug('Redirecting to the RelayState: %s', relay_state)
354-
return HttpResponseRedirect(relay_state)
355395

356396

357397
@login_required

0 commit comments

Comments
 (0)