Skip to content

Commit 2ca6df3

Browse files
author
Czémán Arnold
committed
Add SAML2 ECP login support with utility classes
1 parent e08ab6f commit 2ca6df3

File tree

3 files changed

+140
-27
lines changed

3 files changed

+140
-27
lines changed

djangosaml2/acs_failures.py

+6
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
from django.core.exceptions import PermissionDenied
99
from django.shortcuts import render
1010

11+
from djangosaml2.utils import SoapFaultResponse
12+
1113

1214
def template_failure(request, status=403, **kwargs):
1315
""" Renders a SAML-specific template with general authentication error description. """
@@ -20,3 +22,7 @@ def exception_failure(request, exc_class=PermissionDenied, **kwargs):
2022
and thus ends up rendering a project-wide error page for Permission Denied exceptions.
2123
"""
2224
raise exc_class
25+
26+
27+
def soap_failure(request, status=403, **kwargs):
28+
return SoapFaultResponse("Authentication Error. Access Denied.", status=status)

djangosaml2/utils.py

+33
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,13 @@
1414

1515
import django
1616
from django.conf import settings
17+
from django.http import HttpResponse
1718
from django.core.exceptions import ImproperlyConfigured
1819
from django.utils.http import is_safe_url
1920
from django.utils.module_loading import import_string
21+
22+
from saml2.soap import soap_fault, make_soap_enveloped_saml_thingy
23+
from saml2.schema.soapenv import fault_from_string
2024
from saml2.s_utils import UnknownSystemEntity
2125

2226

@@ -77,11 +81,40 @@ def fail_acs_response(request, *args, **kwargs):
7781
The default behavior uses SAML specific template that is rendered on any ACS error,
7882
but this can be simply changed so that PermissionDenied exception is raised instead.
7983
"""
84+
from djangosaml2.acs_failures import soap_failure
85+
soap = kwargs.get('soap', False)
86+
if soap:
87+
return soap_failure(request, *args, **kwargs)
88+
8089
failure_function = import_string(get_custom_setting('SAML_ACS_FAILURE_RESPONSE_FUNCTION',
8190
'djangosaml2.acs_failures.template_failure'))
8291
return failure_function(request, *args, **kwargs)
8392

8493

94+
class XmlResponse(HttpResponse):
95+
"""
96+
An HTTP response class with content type: text/xml.
97+
"""
98+
def __init__(self, content, **kwargs):
99+
kwargs.setdefault('content_type', 'text/xml')
100+
super(XmlResponse, self).__init__(content=content, **kwargs)
101+
102+
103+
class SoapFaultResponse(XmlResponse):
104+
"""
105+
An XML response with SOAP Fault content.
106+
"""
107+
def __init__(self,
108+
message=None,
109+
actor=None,
110+
code=None,
111+
detail=None,
112+
**kwargs):
113+
soap_message = make_soap_enveloped_saml_thingy(
114+
fault_from_string(soap_fault(message)))
115+
super(SoapFaultResponse, self).__init__(soap_message, **kwargs)
116+
117+
85118
def is_safe_url_compat(url, allowed_hosts=None, require_https=False):
86119
if django.VERSION >= (1, 11):
87120
return is_safe_url(url, allowed_hosts=allowed_hosts, require_https=require_https)

djangosaml2/views.py

+101-27
Original file line numberDiff line numberDiff line change
@@ -49,10 +49,17 @@
4949
from django.utils.six import text_type, binary_type, PY3
5050
from django.views.decorators.csrf import csrf_exempt
5151

52-
from saml2 import BINDING_HTTP_REDIRECT, BINDING_HTTP_POST
52+
from saml2 import (
53+
ecp, create_class_from_xml_string,
54+
BINDING_HTTP_REDIRECT, BINDING_HTTP_POST,
55+
)
56+
from saml2.client import Saml2Client
57+
from saml2.client_base import MIME_PAOS
5358
from saml2.metadata import entity_descriptor
5459
from saml2.ident import code, decode
5560
from saml2.sigver import MissingKey
61+
from saml2.ecp_client import PAOS_HEADER_INFO
62+
from saml2.profile.ecp import RelayState
5663
from saml2.s_utils import UnsupportedBinding
5764
from saml2.response import StatusError, StatusAuthnFailed, SignatureError, StatusRequestDenied
5865
from saml2.validate import ResponseLifetimeExceed, ToEarly
@@ -61,11 +68,11 @@
6168
from djangosaml2.cache import IdentityCache, OutstandingQueriesCache
6269
from djangosaml2.cache import StateCache
6370
from djangosaml2.conf import get_config
64-
from djangosaml2.overrides import Saml2Client
6571
from djangosaml2.signals import post_authenticated
6672
from djangosaml2.utils import (
6773
available_idps, fail_acs_response, get_custom_setting,
6874
get_idp_sso_supported_bindings, get_location, is_safe_url_compat,
75+
XmlResponse, SoapFaultResponse
6976
)
7077

7178

@@ -113,7 +120,13 @@ def login(request,
113120
If set to None or nonexistent template, default form from the saml2 library
114121
will be rendered.
115122
"""
116-
logger.debug('Login process started')
123+
is_ecp = ("HTTP_PAOS" in request.META and
124+
request.META["HTTP_PAOS"] == PAOS_HEADER_INFO and
125+
MIME_PAOS in request.META["HTTP_ACCEPT"])
126+
if is_ecp:
127+
logger.debug('ECP login process started')
128+
else:
129+
logger.debug('Login process started')
117130

118131
came_from = request.GET.get('next', settings.LOGIN_REDIRECT_URL)
119132
if not came_from:
@@ -138,11 +151,15 @@ def login(request,
138151
redirect_authenticated_user = getattr(settings, 'SAML_IGNORE_AUTHENTICATED_USERS_ON_LOGIN', True)
139152
if redirect_authenticated_user:
140153
return HttpResponseRedirect(came_from)
154+
elif is_ecp:
155+
return HttpResponse()
141156
else:
142157
logger.debug('User is already logged in')
143-
return render(request, authorization_error_template, {
144-
'came_from': came_from,
145-
})
158+
return render(
159+
request,
160+
authorization_error_template,
161+
{'came_from': came_from, }
162+
)
146163

147164
selected_idp = request.GET.get('idp', None)
148165
conf = get_config(config_loader_path, request)
@@ -151,10 +168,14 @@ def login(request,
151168
idps = available_idps(conf)
152169
if selected_idp is None and len(idps) > 1:
153170
logger.debug('A discovery process is needed')
154-
return render(request, wayf_template, {
171+
return render(
172+
request,
173+
wayf_template,
174+
{
155175
'available_idps': idps.items(),
156176
'came_from': came_from,
157-
})
177+
}
178+
)
158179

159180
# choose a binding to try first
160181
sign_requests = getattr(conf, '_sp_authn_requests_signed', False)
@@ -180,9 +201,37 @@ def login(request,
180201
selected_idp, BINDING_HTTP_POST, BINDING_HTTP_REDIRECT)
181202

182203
client = Saml2Client(conf)
204+
try:
205+
if is_ecp:
206+
(session_id, result) = ecp.ecp_auth_request(
207+
cls=client,
208+
entityid=None,
209+
relay_state=came_from
210+
)
211+
if not session_id > 0:
212+
logger.error("Error in ECP auth request.")
213+
else:
214+
(session_id, result) = client.prepare_for_authenticate(
215+
entityid=selected_idp, relay_state=came_from,
216+
binding=binding,
217+
)
218+
except TypeError as e:
219+
message = 'Unable to know which IdP to use'
220+
logger.error(message)
221+
if is_ecp:
222+
return SoapFaultResponse(message, status=400)
223+
return HttpResponseBadRequest(message)
224+
225+
logger.debug('Saving the session_id in the OutstandingQueries cache')
226+
oq_cache = OutstandingQueriesCache(request.session)
227+
oq_cache.set(session_id, came_from)
228+
229+
if is_ecp:
230+
logger.debug('Redirecting the ECP client to the IdP')
231+
return XmlResponse(result)
183232
http_response = None
233+
logger.debug('Redirecting user to the IdP via %s binding.', binding.split(':')[-1])
184234

185-
logger.debug('Redirecting user to the IdP via %s binding.', binding)
186235
if binding == BINDING_HTTP_REDIRECT:
187236
try:
188237
# do not sign the xml itself, instead use the sigalg to
@@ -261,45 +310,65 @@ def assertion_consumer_service(request,
261310
djangosaml2.backends.Saml2Backend that should be
262311
enabled in the settings.py
263312
"""
264-
attribute_mapping = attribute_mapping or get_custom_setting('SAML_ATTRIBUTE_MAPPING', {'uid': ('username', )})
265-
create_unknown_user = create_unknown_user if create_unknown_user is not None else \
266-
get_custom_setting('SAML_CREATE_UNKNOWN_USER', True)
267-
conf = get_config(config_loader_path, request)
268-
try:
269-
xmlstr = request.POST['SAMLResponse']
270-
except KeyError:
271-
logger.warning('Missing "SAMLResponse" parameter in POST data.')
272-
raise SuspiciousOperation
313+
is_ecp = MIME_PAOS == request.META["CONTENT_TYPE"]
314+
315+
attribute_mapping = attribute_mapping or get_custom_setting(
316+
'SAML_ATTRIBUTE_MAPPING', {'uid': ('username', )})
317+
create_unknown_user = create_unknown_user or get_custom_setting(
318+
'SAML_CREATE_UNKNOWN_USER', True)
319+
logger.debug('Assertion Consumer Service started')
273320

321+
conf = get_config(config_loader_path, request)
274322
client = Saml2Client(conf, identity_cache=IdentityCache(request.session))
275323

324+
if is_ecp:
325+
data = client.unpack_soap_message(request.body)
326+
relay_state_found = False
327+
for header in data["header"]:
328+
inst = create_class_from_xml_string(RelayState, header)
329+
if isinstance(inst, RelayState):
330+
relay_state_found = True
331+
if not relay_state_found:
332+
return SoapFaultResponse('Couldn\'t find RelayState data.',
333+
status=400)
334+
xmlstr = data["body"]
335+
else:
336+
if 'SAMLResponse' not in request.POST:
337+
return HttpResponseBadRequest(
338+
'Couldn\'t find "SAMLResponse" in POST data.')
339+
xmlstr = request.POST['SAMLResponse']
340+
276341
oq_cache = OutstandingQueriesCache(request.session)
277342
outstanding_queries = oq_cache.outstanding_queries()
278343

279344
try:
280-
response = client.parse_authn_request_response(xmlstr, BINDING_HTTP_POST, outstanding_queries)
345+
# process the authentication response
346+
binding = None if is_ecp else BINDING_HTTP_POST
347+
response = client.parse_authn_request_response(xmlstr, binding,
348+
outstanding_queries)
281349
except (StatusError, ToEarly):
282350
logger.exception("Error processing SAML Assertion.")
283-
return fail_acs_response(request)
351+
return fail_acs_response(request, soap=is_ecp)
284352
except ResponseLifetimeExceed:
285353
logger.info("SAML Assertion is no longer valid. Possibly caused by network delay or replay attack.", exc_info=True)
286-
return fail_acs_response(request)
354+
return fail_acs_response(request, soap=is_ecp)
287355
except SignatureError:
288356
logger.info("Invalid or malformed SAML Assertion.", exc_info=True)
289-
return fail_acs_response(request)
357+
return fail_acs_response(request, soap=is_ecp)
290358
except StatusAuthnFailed:
291359
logger.info("Authentication denied for user by IdP.", exc_info=True)
292-
return fail_acs_response(request)
360+
return fail_acs_response(request, soap=is_ecp)
293361
except StatusRequestDenied:
294362
logger.warning("Authentication interrupted at IdP.", exc_info=True)
295-
return fail_acs_response(request)
363+
return fail_acs_response(request, soap=is_ecp)
296364
except MissingKey:
297365
logger.exception("SAML Identity Provider is not configured correctly: certificate key is missing!")
298-
return fail_acs_response(request)
366+
return fail_acs_response(request, soap=is_ecp)
299367

300368
if response is None:
301369
logger.warning("Invalid SAML Assertion received (unknown error).")
302-
return fail_acs_response(request, status=400, exc_class=SuspiciousOperation)
370+
return fail_acs_response(request, status=400,
371+
exc_class=SuspiciousOperation, soap=is_ecp)
303372

304373
session_id = response.session_id()
305374
oq_cache.delete(session_id)
@@ -318,6 +387,10 @@ def assertion_consumer_service(request,
318387
attribute_mapping=attribute_mapping,
319388
create_unknown_user=create_unknown_user)
320389
if user is None:
390+
message = 'The user is None'
391+
logger.error(message)
392+
if is_ecp:
393+
return SoapFaultResponse(message, status=403)
321394
logger.warning("Could not authenticate user received in SAML Assertion. Session info: %s", session_info)
322395
raise PermissionDenied
323396

@@ -421,7 +494,7 @@ def logout_service_post(request, *args, **kwargs):
421494

422495

423496
def do_logout_service(request, data, binding, config_loader_path=None, next_page=None,
424-
logout_error_template='djangosaml2/logout_error.html'):
497+
logout_error_template='djangosaml2/logout_error.html'):
425498
"""SAML Logout Response endpoint
426499
427500
The IdP will send the logout response to this view,
@@ -509,4 +582,5 @@ def register_namespace_prefixes():
509582
for prefix, namespace in prefixes:
510583
ElementTree._namespace_map[namespace] = prefix
511584

585+
512586
register_namespace_prefixes()

0 commit comments

Comments
 (0)