Skip to content

Commit 3a03c6c

Browse files
authored
Merge branch 'master' into github-actions
2 parents f1ed2f1 + 521089e commit 3a03c6c

File tree

10 files changed

+174
-47
lines changed

10 files changed

+174
-47
lines changed

CHANGES

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ Thanks to plumdog
1919

2020
Thanks to plumdog
2121

22+
UNRELEASED
23+
----------
24+
- Allowed creating Users with multiple required fields.
25+
2226
0.17.1 (2018-07-16)
2327
----------
2428
- A 403 (permission denied) is now raised if a SAMLResponse is replayed, instead of 500.

README.rst

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,24 @@ setting::
315315
SAML_CONFIG_LOADER = 'python.path.to.your.callable'
316316

317317

318+
Custom error handler
319+
....................
320+
321+
When an error occurs during the authentication flow, djangosaml2 will render
322+
a simple error page with an error message and status code. You can customize
323+
this behaviour by specifying the path to your own error handler in the settings:
324+
325+
SAML_ACS_FAILURE_RESPONSE_FUNCTION = 'python.path.to.your.view'
326+
327+
This should be a view which takes a request, optional exception which occured
328+
and status code, and returns a response to serve the user. E.g. The default
329+
implementation looks like this::
330+
331+
def template_failure(request, exception=None, **kwargs):
332+
""" Renders a simple template with an error message. """
333+
return render(request, 'djangosaml2/login_error.html', {'exception': exception}, status=kwargs.get('status', 403))
334+
335+
318336
User attributes
319337
---------------
320338

djangosaml2/acs_failures.py

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,9 @@
44
# produce an output suitable for end user in case of SAML failure.
55
#
66

7-
from django.core.exceptions import PermissionDenied
87
from django.shortcuts import render
98

109

11-
def template_failure(request, status=403, **kwargs):
12-
""" Renders a SAML-specific template with general authentication error description. """
13-
return render(request, 'djangosaml2/login_error.html', status=status)
14-
15-
16-
def exception_failure(request, exc_class=PermissionDenied, **kwargs):
17-
""" Rather than using a custom SAML specific template that is rendered on failure,
18-
this makes use of a standard exception handling machinery present in Django
19-
and thus ends up rendering a project-wide error page for Permission Denied exceptions.
20-
"""
21-
raise exc_class
10+
def template_failure(request, exception=None, status=403, **kwargs):
11+
""" Renders a simple template with an error message. """
12+
return render(request, 'djangosaml2/login_error.html', {'exception': exception}, status=status)

djangosaml2/backends.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -147,19 +147,21 @@ def _get_or_create_saml2_user(self, main_attribute, attributes, attribute_mappin
147147
main_attribute)
148148
django_user_main_attribute = self.get_django_user_main_attribute()
149149
user_query_args = self.get_user_query_args(main_attribute)
150-
user_create_defaults = {django_user_main_attribute: main_attribute}
151150

152151
User = get_saml_user_model()
152+
built = False
153153
try:
154-
user, created = User.objects.get_or_create(
155-
defaults=user_create_defaults, **user_query_args)
154+
user = User.objects.get(**user_query_args)
155+
except User.DoesNotExist:
156+
user = User(**{django_user_main_attribute: main_attribute})
157+
built = True
156158
except MultipleObjectsReturned:
157159
logger.error("There are more than one user with %s = %s",
158160
django_user_main_attribute, main_attribute)
159161
return None
160162

161-
if created:
162-
logger.debug('New user created')
163+
if built:
164+
logger.debug('Configuring new user "%s"', main_attribute)
163165
user = self.configure_user(user, attributes, attribute_mapping)
164166
else:
165167
logger.debug('User updated')

djangosaml2/tests/__init__.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import datetime
1919
import re
2020
import sys
21+
2122
from unittest import skip
2223

2324
from django.conf import settings
@@ -519,6 +520,43 @@ def test_idplist_templatetag(self):
519520

520521
self.assertEqual(rendered, expected)
521522

523+
def test_sigalg_not_passed_when_not_signing_request(self):
524+
# monkey patch SAML configuration
525+
settings.SAML_CONFIG = conf.create_conf(
526+
sp_host='sp.example.com',
527+
idp_hosts=['idp.example.com'],
528+
metadata_file='remote_metadata_one_idp.xml',
529+
)
530+
531+
with mock.patch(
532+
'djangosaml2.views.Saml2Client.prepare_for_authenticate',
533+
return_value=('session_id', {'url': 'fake'}),
534+
535+
) as prepare_for_auth_mock:
536+
self.client.get(reverse('saml2_login'))
537+
prepare_for_auth_mock.assert_called_once()
538+
_args, kwargs = prepare_for_auth_mock.call_args
539+
self.assertNotIn('sigalg', kwargs)
540+
541+
def test_sigalg_passed_when_signing_request(self):
542+
# monkey patch SAML configuration
543+
settings.SAML_CONFIG = conf.create_conf(
544+
sp_host='sp.example.com',
545+
idp_hosts=['idp.example.com'],
546+
metadata_file='remote_metadata_one_idp.xml',
547+
)
548+
549+
settings.SAML_CONFIG['service']['sp']['authn_requests_signed'] = True
550+
with mock.patch(
551+
'djangosaml2.views.Saml2Client.prepare_for_authenticate',
552+
return_value=('session_id', {'url': 'fake'}),
553+
554+
) as prepare_for_auth_mock:
555+
self.client.get(reverse('saml2_login'))
556+
prepare_for_auth_mock.assert_called_once()
557+
_args, kwargs = prepare_for_auth_mock.call_args
558+
self.assertIn('sigalg', kwargs)
559+
522560

523561
def test_config_loader(request):
524562
config = SPConfig()
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
X500ATTR_OID = 'urn:oid:2.5.4.'
2+
PKCS_9 = 'urn:oid:1.2.840.113549.1.9.1.'
3+
UCL_DIR_PILOT = 'urn:oid:0.9.2342.19200300.100.1.'
4+
5+
MAP = {
6+
'identifier': 'urn:oasis:names:tc:SAML:2.0:attrname-format:uri',
7+
'fro': {
8+
X500ATTR_OID+'3': 'first_name', # cn
9+
X500ATTR_OID+'4': 'last_name', # sn
10+
PKCS_9+'1': 'email',
11+
UCL_DIR_PILOT+'1': 'uid',
12+
},
13+
'to': {
14+
'first_name': X500ATTR_OID+'3',
15+
'last_name': X500ATTR_OID+'4',
16+
'email' : PKCS_9+'1',
17+
'uid': UCL_DIR_PILOT+'1',
18+
}
19+
}

djangosaml2/views.py

Lines changed: 51 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,20 @@
2929
from django.utils.http import is_safe_url
3030
from django.views.decorators.csrf import csrf_exempt
3131
from django.views.decorators.http import require_POST
32-
from saml2 import BINDING_HTTP_POST, BINDING_HTTP_REDIRECT
32+
from saml2 import BINDING_HTTP_REDIRECT, BINDING_HTTP_POST
33+
from saml2.client_base import LogoutError
34+
from saml2.metadata import entity_descriptor
3335
from saml2.ident import code, decode
3436
from saml2.metadata import entity_descriptor
3537
from saml2.response import (SignatureError, StatusAuthnFailed, StatusError,
3638
StatusNoAuthnContext, StatusRequestDenied,
3739
UnsolicitedResponse)
3840
from saml2.s_utils import UnsupportedBinding
41+
from saml2.response import (
42+
StatusError, StatusAuthnFailed, SignatureError, StatusRequestDenied,
43+
UnsolicitedResponse, StatusNoAuthnContext,
44+
)
45+
from saml2.mdstore import SourceNotFound
3946
from saml2.sigver import MissingKey
4047
from saml2.validate import ResponseLifetimeExceed, ToEarly
4148
from saml2.xmldsig import ( # support for SHA1 is required by spec
@@ -122,7 +129,15 @@ def login(request,
122129
})
123130

124131
selected_idp = request.GET.get('idp', None)
125-
conf = get_config(config_loader_path, request)
132+
try:
133+
conf = get_config(config_loader_path, request)
134+
except SourceNotFound as excp:
135+
msg = ('Error, IdP EntityID was not found '
136+
'in metadata: {}')
137+
logger.exception(msg.format(excp))
138+
return HttpResponse(msg.format(('Please contact '
139+
'technical support.')),
140+
status=500)
126141

127142
kwargs = {}
128143
# pysaml needs a string otherwise: "cannot serialize True (type bool)"
@@ -176,17 +191,18 @@ def login(request,
176191
logger.debug('Redirecting user to the IdP via %s binding.', binding)
177192
if binding == BINDING_HTTP_REDIRECT:
178193
try:
179-
# do not sign the xml itself, instead use the sigalg to
180-
# generate the signature as a URL param
181-
sig_alg_option_map = {'sha1': SIG_RSA_SHA1,
182-
'sha256': SIG_RSA_SHA256}
183-
sig_alg_option = getattr(conf, '_sp_authn_requests_signed_alg', 'sha1')
184-
sigalg = sig_alg_option_map[sig_alg_option] if sign_requests else None
185194
nsprefix = get_namespace_prefixes()
195+
if sign_requests:
196+
# do not sign the xml itself, instead use the sigalg to
197+
# generate the signature as a URL param
198+
sig_alg_option_map = {'sha1': SIG_RSA_SHA1,
199+
'sha256': SIG_RSA_SHA256}
200+
sig_alg_option = getattr(conf, '_sp_authn_requests_signed_alg', 'sha1')
201+
kwargs["sigalg"] = sig_alg_option_map[sig_alg_option]
186202
session_id, result = client.prepare_for_authenticate(
187203
entityid=selected_idp, relay_state=came_from,
188-
binding=binding, sign=False, sigalg=sigalg,
189-
nsprefix=nsprefix, **kwargs)
204+
binding=binding, sign=False, nsprefix=nsprefix,
205+
**kwargs)
190206
except TypeError as e:
191207
logger.error('Unable to know which IdP to use')
192208
return HttpResponse(str(e))
@@ -269,34 +285,34 @@ def assertion_consumer_service(request,
269285

270286
try:
271287
response = client.parse_authn_request_response(xmlstr, BINDING_HTTP_POST, outstanding_queries)
272-
except (StatusError, ToEarly):
288+
except (StatusError, ToEarly) as e:
273289
logger.exception("Error processing SAML Assertion.")
274-
return fail_acs_response(request)
275-
except ResponseLifetimeExceed:
290+
return fail_acs_response(request, exception=e)
291+
except ResponseLifetimeExceed as e:
276292
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)
278-
except SignatureError:
293+
return fail_acs_response(request, exception=e)
294+
except SignatureError as e:
279295
logger.info("Invalid or malformed SAML Assertion.", exc_info=True)
280-
return fail_acs_response(request)
281-
except StatusAuthnFailed:
296+
return fail_acs_response(request, exception=e)
297+
except StatusAuthnFailed as e:
282298
logger.info("Authentication denied for user by IdP.", exc_info=True)
283-
return fail_acs_response(request)
284-
except StatusRequestDenied:
299+
return fail_acs_response(request, exception=e)
300+
except StatusRequestDenied as e:
285301
logger.warning("Authentication interrupted at IdP.", exc_info=True)
286-
return fail_acs_response(request)
287-
except StatusNoAuthnContext:
302+
return fail_acs_response(request, exception=e)
303+
except StatusNoAuthnContext as e:
288304
logger.warning("Missing Authentication Context from IdP.", exc_info=True)
289-
return fail_acs_response(request)
290-
except MissingKey:
305+
return fail_acs_response(request, exception=e)
306+
except MissingKey as e:
291307
logger.exception("SAML Identity Provider is not configured correctly: certificate key is missing!")
292-
return fail_acs_response(request)
293-
except UnsolicitedResponse:
308+
return fail_acs_response(request, exception=e)
309+
except UnsolicitedResponse as e:
294310
logger.exception("Received SAMLResponse when no request has been made.")
295-
return fail_acs_response(request)
311+
return fail_acs_response(request, exception=e)
296312

297313
if response is None:
298314
logger.warning("Invalid SAML Assertion received (unknown error).")
299-
return fail_acs_response(request, status=400, exc_class=SuspiciousOperation)
315+
return fail_acs_response(request, status=400, exception=SuspiciousOperation('Unknown SAML2 error'))
300316

301317
session_id = response.session_id()
302318
oq_cache.delete(session_id)
@@ -316,7 +332,7 @@ def assertion_consumer_service(request,
316332
create_unknown_user=create_unknown_user)
317333
if user is None:
318334
logger.warning("Could not authenticate user received in SAML Assertion. Session info: %s", session_info)
319-
raise PermissionDenied
335+
return fail_acs_response(request, exception=PermissionDenied('No user could be authenticated.'))
320336

321337
auth.login(request, user)
322338
_set_subject_id(request.session, session_info['name_id'])
@@ -376,7 +392,13 @@ def logout(request, config_loader_path=None):
376392
'The session does not contain the subject id for user %s',
377393
request.user)
378394

379-
result = client.global_logout(subject_id)
395+
try:
396+
result = client.global_logout(subject_id)
397+
except LogoutError as exp:
398+
logger.exception('Error Handled - SLO not supported by IDP: {}'.format(exp))
399+
auth.logout(request)
400+
state.sync()
401+
return HttpResponseRedirect('/')
380402

381403
state.sync()
382404

setup.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515

1616
import codecs
1717
import os
18-
import sys
1918
from setuptools import setup, find_packages
2019

2120

@@ -63,4 +62,8 @@ def read(*rnames):
6362
'Django>=2.2',
6463
'pysaml2>=4.6.0',
6564
],
65+
tests_require=[
66+
# Provides assert_called_once.
67+
'mock;python_version < "3.6"',
68+
]
6669
)

tests/testprofiles/models.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,13 @@ class StandaloneUserModel(models.Model):
3030
USERNAME_FIELD.
3131
"""
3232
username = models.CharField(max_length=30, unique=True)
33+
34+
35+
class RequiredFieldUser(models.Model):
36+
email = models.EmailField(unique=True)
37+
email_verified = models.BooleanField()
38+
39+
USERNAME_FIELD = 'email'
40+
41+
def set_unusable_password(self):
42+
pass

tests/testprofiles/tests.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,26 @@ def test_invalid_model_attribute_log(self):
122122
logs.output,
123123
)
124124

125+
@override_settings(AUTH_USER_MODEL='testprofiles.RequiredFieldUser')
126+
def test_create_user_with_required_fields(self):
127+
backend = Saml2Backend()
128+
attribute_mapping = {
129+
'mail': ['email'],
130+
'mail_verified': ['email_verified']
131+
}
132+
attributes = {
133+
'mail': ['[email protected]'],
134+
'mail_verified': [True],
135+
}
136+
# User creation does not fail if several fields are required.
137+
user = backend._get_or_create_saml2_user(
138+
139+
attributes,
140+
attribute_mapping,
141+
)
142+
self.assertEquals(user.email, '[email protected]')
143+
self.assertIs(user.email_verified, True)
144+
125145
def test_django_user_main_attribute(self):
126146
backend = Saml2Backend()
127147

0 commit comments

Comments
 (0)