Skip to content

Commit 79fffd4

Browse files
committed
Merge branch 'master' into ACS_customizable_0.18
# Conflicts: # djangosaml2/views.py
2 parents d35348d + 46f9b83 commit 79fffd4

File tree

9 files changed

+129
-36
lines changed

9 files changed

+129
-36
lines changed

.travis.yml

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
dist: bionic
12
language: python
23

34
sudo: false
@@ -6,36 +7,50 @@ matrix:
67
include:
78
- python: 2.7
89
env: TOX_ENV=py27-django18
9-
- python: 3.4
10-
env: TOX_ENV=py34-django18
1110
- python: 2.7
1211
env: TOX_ENV=py27-django19
13-
- python: 3.4
14-
env: TOX_ENV=py34-django19
1512
- python: 3.5
1613
env: TOX_ENV=py35-django19
1714
- python: 2.7
1815
env: TOX_ENV=py27-django110
19-
- python: 3.4
20-
env: TOX_ENV=py34-django110
2116
- python: 3.5
2217
env: TOX_ENV=py35-django110
2318
- python: 2.7
2419
env: TOX_ENV=py27-django111
25-
- python: 3.4
26-
env: TOX_ENV=py34-django111
2720
- python: 3.5
2821
env: TOX_ENV=py35-django111
2922
- python: 3.6
3023
env: TOX_ENV=py36-django111
3124
- python: 3.5
32-
env: TOX_ENV=py35-djangomaster
25+
env: TOX_ENV=py35-django20
26+
- python: 3.6
27+
env: TOX_ENV=py36-django20
28+
- python: 3.7
29+
env: TOX_ENV=py37-django20
30+
- python: 3.5
31+
env: TOX_ENV=py35-django21
32+
- python: 3.6
33+
env: TOX_ENV=py36-django21
34+
- python: 3.7
35+
env: TOX_ENV=py37-django21
36+
- python: 3.5
37+
env: TOX_ENV=py35-django22
38+
- python: 3.6
39+
env: TOX_ENV=py36-django22
40+
- python: 3.7
41+
env: TOX_ENV=py37-django22
42+
- python: 3.6
43+
env: TOX_ENV=py36-django30
44+
- python: 3.7
45+
env: TOX_ENV=py37-django30
3346
- python: 3.6
3447
env: TOX_ENV=py36-djangomaster
48+
- python: 3.7
49+
env: TOX_ENV=py37-djangomaster
3550
fast_finish: true
3651
allow_failures:
37-
- env: TOX_ENV=py35-djangomaster
3852
- env: TOX_ENV=py36-djangomaster
53+
- env: TOX_ENV=py37-djangomaster
3954

4055
addons:
4156
apt:

CHANGES

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,17 @@
11
Changes
22
=======
3+
0.18.1 (2020-02-15)
4+
----------
5+
- Fixed regression from 0.18.0. Thanks to OskarPersson
6+
7+
0.18.0 (2020-02-14)
8+
----------
9+
- Django 3.0 support. Thanks to OskarPersson
10+
- forceauthn and allowcreate support. Thanks to peppelinux
11+
- Dropped support for Python 3.4
12+
- Also thanks to WebSpider, mhindery, DylannCordel, habi3000 for various fixes and improvements
13+
14+
Thanks to plumdog
315

416
TBD
517
----------

README.rst

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,13 @@ We will see a typical configuration for protecting a Django project::
192192
saml2.BINDING_HTTP_POST),
193193
],
194194
},
195-
195+
# Mandates that the identity provider MUST authenticate the
196+
# presenter directly rather than rely on a previous security context.
197+
'force_authn': False,
198+
199+
# Enable AllowCreate in NameIDPolicy.
200+
'name_id_format_allow_create': False,
201+
196202
# attributes that this project need to identify a user
197203
'required_attributes': ['uid'],
198204

djangosaml2/backends.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,9 @@ def authenticate(self, request, session_info=None, attribute_mapping=None,
8989
else:
9090
logger.error('The nameid is not available. Cannot find user without a nameid.')
9191
else:
92-
saml_user = self.get_attribute_value(django_user_main_attribute, attributes, attribute_mapping)
92+
saml_user = self.get_attribute_value(django_user_main_attribute,
93+
attributes,
94+
attribute_mapping)
9395

9496
if saml_user is None:
9597
logger.error('Could not find saml_user value')
@@ -111,7 +113,11 @@ def get_attribute_value(self, django_field, attributes, attribute_mapping):
111113
logger.debug('attribute_mapping: %s', attribute_mapping)
112114
for saml_attr, django_fields in attribute_mapping.items():
113115
if django_field in django_fields and saml_attr in attributes:
114-
saml_user = attributes[saml_attr][0]
116+
saml_user = attributes.get(saml_attr, [None])[0]
117+
if not saml_user:
118+
logger.error('attributes[saml_attr] attribute '
119+
'value is missing. Probably the user '
120+
'session is expired.')
115121
return saml_user
116122

117123
def is_authorized(self, attributes, attribute_mapping):

djangosaml2/exceptions.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
class IdPConfigurationMissing(Exception):
2+
pass

djangosaml2/tests/__init__.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,10 @@
3535
from django.utils.encoding import force_text
3636
except ImportError:
3737
from django.utils.text import force_text
38-
from django.utils.six.moves.urllib.parse import urlparse, parse_qs
38+
try:
39+
from django.utils.six.moves.urllib.parse import urlparse, parse_qs
40+
except ImportError:
41+
from urllib.parse import urlparse, parse_qs
3942

4043
from saml2.config import SPConfig
4144
from saml2.s_utils import decode_base64_and_inflate, deflate_and_base64_encode

djangosaml2/views.py

Lines changed: 50 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,15 @@
3232
from django.views.decorators.http import require_POST
3333
from django.shortcuts import render
3434
from django.template import TemplateDoesNotExist
35-
from django.utils.six import text_type, binary_type, PY3
35+
36+
try:
37+
from django.utils.six import text_type, binary_type, PY3
38+
except ImportError:
39+
import sys
40+
PY3 = sys.version_info[0] == 3
41+
text_type = str
42+
binary_type = bytes
43+
3644
from django.views.decorators.csrf import csrf_exempt
3745
from django.views.generic import View
3846
from django.utils.decorators import method_decorator
@@ -44,13 +52,14 @@
4452
from saml2.s_utils import UnsupportedBinding
4553
from saml2.response import (
4654
StatusError, StatusAuthnFailed, SignatureError, StatusRequestDenied,
47-
UnsolicitedResponse,
55+
UnsolicitedResponse, StatusNoAuthnContext,
4856
)
4957
from saml2.validate import ResponseLifetimeExceed, ToEarly
5058
from saml2.xmldsig import SIG_RSA_SHA1, SIG_RSA_SHA256 # support for SHA1 is required by spec
5159

5260
from djangosaml2.cache import IdentityCache, OutstandingQueriesCache
5361
from djangosaml2.cache import StateCache
62+
from djangosaml2.exceptions import IdPConfigurationMissing
5463
from djangosaml2.conf import get_config
5564
from djangosaml2.overrides import Saml2Client
5665
from djangosaml2.signals import post_authenticated
@@ -138,6 +147,13 @@ def login(request,
138147
selected_idp = request.GET.get('idp', None)
139148
conf = get_config(config_loader_path, request)
140149

150+
kwargs = {}
151+
# pysaml needs a string otherwise: "cannot serialize True (type bool)"
152+
if getattr(conf, '_sp_force_authn', False):
153+
kwargs['force_authn'] = "true"
154+
if getattr(conf, '_sp_allow_create', False):
155+
kwargs['allow_create'] = "true"
156+
141157
# is a embedded wayf needed?
142158
idps = available_idps(conf)
143159
if selected_idp is None and len(idps) > 1:
@@ -146,6 +162,13 @@ def login(request,
146162
'available_idps': idps.items(),
147163
'came_from': came_from,
148164
})
165+
else:
166+
# is the first one, otherwise next logger message will print None
167+
if not idps:
168+
raise IdPConfigurationMissing(('IdP configuration is missing or '
169+
'its metadata is expired.'))
170+
if selected_idp is None:
171+
selected_idp = list(idps.keys())[0]
149172

150173
# choose a binding to try first
151174
sign_requests = getattr(conf, '_sp_authn_requests_signed', False)
@@ -186,7 +209,7 @@ def login(request,
186209
session_id, result = client.prepare_for_authenticate(
187210
entityid=selected_idp, relay_state=came_from,
188211
binding=binding, sign=False, sigalg=sigalg,
189-
nsprefix=nsprefix)
212+
nsprefix=nsprefix, **kwargs)
190213
except TypeError as e:
191214
logger.error('Unable to know which IdP to use')
192215
return HttpResponse(text_type(e))
@@ -202,10 +225,11 @@ def login(request,
202225
return HttpResponse(text_type(e))
203226
session_id, request_xml = client.create_authn_request(
204227
location,
205-
binding=binding)
228+
binding=binding,
229+
**kwargs)
206230
try:
207231
if PY3:
208-
saml_request = base64.b64encode(binary_type(request_xml, 'UTF-8'))
232+
saml_request = base64.b64encode(binary_type(request_xml, 'UTF-8')).decode('utf-8')
209233
else:
210234
saml_request = base64.b64encode(binary_type(request_xml))
211235

@@ -283,25 +307,28 @@ def post(self,
283307
response = client.parse_authn_request_response(xmlstr, BINDING_HTTP_POST, outstanding_queries)
284308
except (StatusError, ToEarly):
285309
logger.exception("Error processing SAML Assertion.")
286-
return fail_acs_response(self.request)
310+
return fail_acs_response(request)
287311
except ResponseLifetimeExceed:
288312
logger.info("SAML Assertion is no longer valid. Possibly caused by network delay or replay attack.", exc_info=True)
289-
return fail_acs_response(self.request)
313+
return fail_acs_response(request)
290314
except SignatureError:
291315
logger.info("Invalid or malformed SAML Assertion.", exc_info=True)
292-
return fail_acs_response(self.request)
316+
return fail_acs_response(request)
293317
except StatusAuthnFailed:
294318
logger.info("Authentication denied for user by IdP.", exc_info=True)
295-
return fail_acs_response(self.request)
319+
return fail_acs_response(request)
296320
except StatusRequestDenied:
297321
logger.warning("Authentication interrupted at IdP.", exc_info=True)
298-
return fail_acs_response(self.request)
322+
return fail_acs_response(request)
323+
except StatusNoAuthnContext:
324+
logger.warning("Missing Authentication Context from IdP.", exc_info=True)
325+
return fail_acs_response(request)
299326
except MissingKey:
300327
logger.exception("SAML Identity Provider is not configured correctly: certificate key is missing!")
301-
return fail_acs_response(self.request)
328+
return fail_acs_response(request)
302329
except UnsolicitedResponse:
303330
logger.exception("Received SAMLResponse when no request has been made.")
304-
return fail_acs_response(self.request)
331+
return fail_acs_response(request)
305332

306333
if response is None:
307334
logger.warning("Invalid SAML Assertion received (unknown error).")
@@ -502,7 +529,17 @@ def do_logout_service(request, data, binding, config_loader_path=None, next_page
502529
relay_state=data.get('RelayState', ''))
503530
state.sync()
504531
auth.logout(request)
505-
return HttpResponseRedirect(get_location(http_info))
532+
if (
533+
http_info.get('method', 'GET') == 'POST' and
534+
'data' in http_info and
535+
('Content-type', 'text/html') in http_info.get('headers', [])
536+
):
537+
# need to send back to the IDP a signed POST response with user session
538+
# return HTML form content to browser with auto form validation
539+
# to finally send request to the IDP
540+
return HttpResponse(http_info['data'])
541+
else:
542+
return HttpResponseRedirect(get_location(http_info))
506543
else:
507544
logger.error('No SAMLResponse or SAMLRequest parameter found')
508545
raise Http404('No SAMLResponse or SAMLRequest parameter found')

setup.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,9 @@ def read(*rnames):
3131

3232
setup(
3333
name='djangosaml2',
34-
version='0.17.2',
34+
version='0.18.1',
3535
description='pysaml2 integration for Django',
36-
long_description='\n\n'.join([read('README.rst'), read('CHANGES')]),
36+
long_description=read('README.rst'),
3737
classifiers=[
3838
"Development Status :: 4 - Beta",
3939
"Environment :: Web Environment",
@@ -42,16 +42,20 @@ def read(*rnames):
4242
"Framework :: Django :: 1.9",
4343
"Framework :: Django :: 1.10",
4444
"Framework :: Django :: 1.11",
45+
"Framework :: Django :: 2.0",
46+
"Framework :: Django :: 2.1",
47+
"Framework :: Django :: 2.2",
48+
"Framework :: Django :: 3.0",
4549
"Intended Audience :: Developers",
4650
"License :: OSI Approved :: Apache Software License",
4751
"Operating System :: OS Independent",
4852
"Programming Language :: Python",
4953
"Programming Language :: Python :: 2",
5054
"Programming Language :: Python :: 2.7",
5155
"Programming Language :: Python :: 3",
52-
"Programming Language :: Python :: 3.4",
5356
"Programming Language :: Python :: 3.5",
5457
"Programming Language :: Python :: 3.6",
58+
"Programming Language :: Python :: 3.7",
5559
"Topic :: Internet :: WWW/HTTP",
5660
"Topic :: Internet :: WWW/HTTP :: WSGI",
5761
"Topic :: Security",
@@ -62,7 +66,7 @@ def read(*rnames):
6266
author_email="lgs@yaco.es",
6367
maintainer="Jozef Knaperek",
6468
url="https://github.com/knaperek/djangosaml2",
65-
download_url="https://pypi.python.org/pypi/djangosaml2",
69+
download_url="https://pypi.org/project/djangosaml2/",
6670
license='Apache 2.0',
6771
packages=find_packages(exclude=["tests", "tests.*"]),
6872
include_package_data=True,

tox.ini

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
[tox]
22
envlist =
3-
py{27,34,35}-django18
4-
py{27,34,35}-django19
5-
py{27,34,35}-django110
6-
py{27,34,35,36}-django111
7-
py{35,36}-djangomaster
3+
py{27,35}-django18
4+
py{27,35}-django19
5+
py{27,35}-django110
6+
py{27,35,36}-django111
7+
py{35,36,37}-django20
8+
py{35,36,37}-django21
9+
py{35,36,37}-django22
10+
py{36,37}-django30
11+
py{36,37}-djangomaster
812

913
[testenv]
1014
commands =
@@ -15,6 +19,10 @@ deps =
1519
django19: Django>=1.9,<1.10
1620
django110: Django>=1.10,<1.11
1721
django111: Django>=1.11,<2.0
22+
django20: Django>=2.0,<2.1
23+
django21: Django>=2.1,<2.2
24+
django22: Django>=2.2,<3.0
25+
django30: Django>=3.0,<3.1
1826
djangomaster: https://github.com/django/django/archive/master.tar.gz
1927
.[test]
2028

0 commit comments

Comments
 (0)