Skip to content

Commit 244b1bf

Browse files
author
Czémán Arnold
committed
Add SAML2 ECP login support with utility classes
1 parent 74fdae8 commit 244b1bf

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
@@ -40,10 +40,17 @@
4040
from django.utils.six import text_type, binary_type, PY3
4141
from django.views.decorators.csrf import csrf_exempt
4242

43-
from saml2 import BINDING_HTTP_REDIRECT, BINDING_HTTP_POST
43+
from saml2 import (
44+
ecp, create_class_from_xml_string,
45+
BINDING_HTTP_REDIRECT, BINDING_HTTP_POST,
46+
)
47+
from saml2.client import Saml2Client
48+
from saml2.client_base import MIME_PAOS
4449
from saml2.metadata import entity_descriptor
4550
from saml2.ident import code, decode
4651
from saml2.sigver import MissingKey
52+
from saml2.ecp_client import PAOS_HEADER_INFO
53+
from saml2.profile.ecp import RelayState
4754
from saml2.s_utils import UnsupportedBinding
4855
from saml2.response import StatusError, StatusAuthnFailed, SignatureError, StatusRequestDenied
4956
from saml2.validate import ResponseLifetimeExceed, ToEarly
@@ -52,11 +59,11 @@
5259
from djangosaml2.cache import IdentityCache, OutstandingQueriesCache
5360
from djangosaml2.cache import StateCache
5461
from djangosaml2.conf import get_config
55-
from djangosaml2.overrides import Saml2Client
5662
from djangosaml2.signals import post_authenticated
5763
from djangosaml2.utils import (
5864
available_idps, fail_acs_response, get_custom_setting,
5965
get_idp_sso_supported_bindings, get_location, is_safe_url_compat,
66+
XmlResponse, SoapFaultResponse
6067
)
6168

6269

@@ -104,7 +111,13 @@ def login(request,
104111
If set to None or nonexistent template, default form from the saml2 library
105112
will be rendered.
106113
"""
107-
logger.debug('Login process started')
114+
is_ecp = ("HTTP_PAOS" in request.META and
115+
request.META["HTTP_PAOS"] == PAOS_HEADER_INFO and
116+
MIME_PAOS in request.META["HTTP_ACCEPT"])
117+
if is_ecp:
118+
logger.debug('ECP login process started')
119+
else:
120+
logger.debug('Login process started')
108121

109122
came_from = request.GET.get('next', settings.LOGIN_REDIRECT_URL)
110123
if not came_from:
@@ -129,11 +142,15 @@ def login(request,
129142
redirect_authenticated_user = getattr(settings, 'SAML_IGNORE_AUTHENTICATED_USERS_ON_LOGIN', True)
130143
if redirect_authenticated_user:
131144
return HttpResponseRedirect(came_from)
145+
elif is_ecp:
146+
return HttpResponse()
132147
else:
133148
logger.debug('User is already logged in')
134-
return render(request, authorization_error_template, {
135-
'came_from': came_from,
136-
})
149+
return render(
150+
request,
151+
authorization_error_template,
152+
{'came_from': came_from, }
153+
)
137154

138155
selected_idp = request.GET.get('idp', None)
139156
conf = get_config(config_loader_path, request)
@@ -142,10 +159,14 @@ def login(request,
142159
idps = available_idps(conf)
143160
if selected_idp is None and len(idps) > 1:
144161
logger.debug('A discovery process is needed')
145-
return render(request, wayf_template, {
162+
return render(
163+
request,
164+
wayf_template,
165+
{
146166
'available_idps': idps.items(),
147167
'came_from': came_from,
148-
})
168+
}
169+
)
149170

150171
# choose a binding to try first
151172
sign_requests = getattr(conf, '_sp_authn_requests_signed', False)
@@ -171,9 +192,37 @@ def login(request,
171192
selected_idp, BINDING_HTTP_POST, BINDING_HTTP_REDIRECT)
172193

173194
client = Saml2Client(conf)
195+
try:
196+
if is_ecp:
197+
(session_id, result) = ecp.ecp_auth_request(
198+
cls=client,
199+
entityid=None,
200+
relay_state=came_from
201+
)
202+
if not session_id > 0:
203+
logger.error("Error in ECP auth request.")
204+
else:
205+
(session_id, result) = client.prepare_for_authenticate(
206+
entityid=selected_idp, relay_state=came_from,
207+
binding=binding,
208+
)
209+
except TypeError as e:
210+
message = 'Unable to know which IdP to use'
211+
logger.error(message)
212+
if is_ecp:
213+
return SoapFaultResponse(message, status=400)
214+
return HttpResponseBadRequest(message)
215+
216+
logger.debug('Saving the session_id in the OutstandingQueries cache')
217+
oq_cache = OutstandingQueriesCache(request.session)
218+
oq_cache.set(session_id, came_from)
219+
220+
if is_ecp:
221+
logger.debug('Redirecting the ECP client to the IdP')
222+
return XmlResponse(result)
174223
http_response = None
224+
logger.debug('Redirecting user to the IdP via %s binding.', binding.split(':')[-1])
175225

176-
logger.debug('Redirecting user to the IdP via %s binding.', binding)
177226
if binding == BINDING_HTTP_REDIRECT:
178227
try:
179228
# do not sign the xml itself, instead use the sigalg to
@@ -252,45 +301,65 @@ def assertion_consumer_service(request,
252301
djangosaml2.backends.Saml2Backend that should be
253302
enabled in the settings.py
254303
"""
255-
attribute_mapping = attribute_mapping or get_custom_setting('SAML_ATTRIBUTE_MAPPING', {'uid': ('username', )})
256-
create_unknown_user = create_unknown_user if create_unknown_user is not None else \
257-
get_custom_setting('SAML_CREATE_UNKNOWN_USER', True)
258-
conf = get_config(config_loader_path, request)
259-
try:
260-
xmlstr = request.POST['SAMLResponse']
261-
except KeyError:
262-
logger.warning('Missing "SAMLResponse" parameter in POST data.')
263-
raise SuspiciousOperation
304+
is_ecp = MIME_PAOS == request.META["CONTENT_TYPE"]
305+
306+
attribute_mapping = attribute_mapping or get_custom_setting(
307+
'SAML_ATTRIBUTE_MAPPING', {'uid': ('username', )})
308+
create_unknown_user = create_unknown_user or get_custom_setting(
309+
'SAML_CREATE_UNKNOWN_USER', True)
310+
logger.debug('Assertion Consumer Service started')
264311

312+
conf = get_config(config_loader_path, request)
265313
client = Saml2Client(conf, identity_cache=IdentityCache(request.session))
266314

315+
if is_ecp:
316+
data = client.unpack_soap_message(request.body)
317+
relay_state_found = False
318+
for header in data["header"]:
319+
inst = create_class_from_xml_string(RelayState, header)
320+
if isinstance(inst, RelayState):
321+
relay_state_found = True
322+
if not relay_state_found:
323+
return SoapFaultResponse('Couldn\'t find RelayState data.',
324+
status=400)
325+
xmlstr = data["body"]
326+
else:
327+
if 'SAMLResponse' not in request.POST:
328+
return HttpResponseBadRequest(
329+
'Couldn\'t find "SAMLResponse" in POST data.')
330+
xmlstr = request.POST['SAMLResponse']
331+
267332
oq_cache = OutstandingQueriesCache(request.session)
268333
outstanding_queries = oq_cache.outstanding_queries()
269334

270335
try:
271-
response = client.parse_authn_request_response(xmlstr, BINDING_HTTP_POST, outstanding_queries)
336+
# process the authentication response
337+
binding = None if is_ecp else BINDING_HTTP_POST
338+
response = client.parse_authn_request_response(xmlstr, binding,
339+
outstanding_queries)
272340
except (StatusError, ToEarly):
273341
logger.exception("Error processing SAML Assertion.")
274-
return fail_acs_response(request)
342+
return fail_acs_response(request, soap=is_ecp)
275343
except ResponseLifetimeExceed:
276344
logger.info("SAML Assertion is no longer valid. Possibly caused by network delay or replay attack.", exc_info=True)
277-
return fail_acs_response(request)
345+
return fail_acs_response(request, soap=is_ecp)
278346
except SignatureError:
279347
logger.info("Invalid or malformed SAML Assertion.", exc_info=True)
280-
return fail_acs_response(request)
348+
return fail_acs_response(request, soap=is_ecp)
281349
except StatusAuthnFailed:
282350
logger.info("Authentication denied for user by IdP.", exc_info=True)
283-
return fail_acs_response(request)
351+
return fail_acs_response(request, soap=is_ecp)
284352
except StatusRequestDenied:
285353
logger.warning("Authentication interrupted at IdP.", exc_info=True)
286-
return fail_acs_response(request)
354+
return fail_acs_response(request, soap=is_ecp)
287355
except MissingKey:
288356
logger.exception("SAML Identity Provider is not configured correctly: certificate key is missing!")
289-
return fail_acs_response(request)
357+
return fail_acs_response(request, soap=is_ecp)
290358

291359
if response is None:
292360
logger.warning("Invalid SAML Assertion received (unknown error).")
293-
return fail_acs_response(request, status=400, exc_class=SuspiciousOperation)
361+
return fail_acs_response(request, status=400,
362+
exc_class=SuspiciousOperation, soap=is_ecp)
294363

295364
session_id = response.session_id()
296365
oq_cache.delete(session_id)
@@ -309,6 +378,10 @@ def assertion_consumer_service(request,
309378
attribute_mapping=attribute_mapping,
310379
create_unknown_user=create_unknown_user)
311380
if user is None:
381+
message = 'The user is None'
382+
logger.error(message)
383+
if is_ecp:
384+
return SoapFaultResponse(message, status=403)
312385
logger.warning("Could not authenticate user received in SAML Assertion. Session info: %s", session_info)
313386
raise PermissionDenied
314387

@@ -412,7 +485,7 @@ def logout_service_post(request, *args, **kwargs):
412485

413486

414487
def do_logout_service(request, data, binding, config_loader_path=None, next_page=None,
415-
logout_error_template='djangosaml2/logout_error.html'):
488+
logout_error_template='djangosaml2/logout_error.html'):
416489
"""SAML Logout Response endpoint
417490
418491
The IdP will send the logout response to this view,
@@ -500,4 +573,5 @@ def register_namespace_prefixes():
500573
for prefix, namespace in prefixes:
501574
ElementTree._namespace_map[namespace] = prefix
502575

576+
503577
register_namespace_prefixes()

0 commit comments

Comments
 (0)