Skip to content

Commit 8f6ecaa

Browse files
committed
Merge branch 'knaperek-master' into user-fetching-customization
2 parents d01a9d4 + b33f1e5 commit 8f6ecaa

File tree

9 files changed

+135
-9
lines changed

9 files changed

+135
-9
lines changed

.github/workflows/python-package.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ name: Python package
55

66
on:
77
push:
8-
branches: [ master, user-fetching-customization ]
8+
branches: [ master ]
99
pull_request:
1010
branches: [ master ]
1111

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,5 @@ venv
1212
tags
1313
.idea/
1414
.vscode/
15+
build/
16+
dist/

README.rst

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,24 @@ If you want to allow several authentication mechanisms in your project
113113
you should set the LOGIN_URL option to another view and put a link in such
114114
view to the ``/saml2/login/`` view.
115115

116+
Handling Post-Login Redirects
117+
-----------------------------
118+
It is often desireable for the client to maintain the URL state (or at least manage it) so that
119+
the URL once authentication has completed is consistent with the desired application state (such
120+
as retaining query parameters, etc.) By default, the HttpRequest objects get_host() method is used
121+
to determine the hostname of the server, and redirect URL's are allowed so long as the destination
122+
host matches the output of get_host(). However, in some cases it becomes desireable for additional
123+
hostnames to be used for the post-login redirect. In such cases, the setting::
124+
125+
SAML_ALLOWED_HOSTS = []
126+
127+
May be set to a list of allowed post-login redirect hostnames (note, the URL components beyond the hostname
128+
may be specified by the client - typically with the ?next= parameter.)
129+
130+
In the absence of a ?next= parameter, the LOGIN_REDIRECT_URL setting will be used (assuming the destination hostname
131+
either matches the output of get_host() or is included in the SAML_ALLOWED_HOSTS setting)
132+
133+
116134
Preferred Logout binding
117135
------------------------
118136
Use the following setting to choose your preferred binding for SP initiated logout requests::
@@ -206,6 +224,7 @@ We will see a typical configuration for protecting a Django project::
206224
'optional_attributes': ['eduPersonAffiliation'],
207225

208226
# in this section the list of IdPs we talk to are defined
227+
# This is not mandatory! All the IdP available in the metadata will be considered.
209228
'idp': {
210229
# we do not need a WAYF service since there is
211230
# only an IdP defined here. This IdP should be
@@ -320,7 +339,7 @@ Custom error handler
320339

321340
When an error occurs during the authentication flow, djangosaml2 will render
322341
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:
342+
this behaviour by specifying the path to your own error handler in the settings::
324343

325344
SAML_ACS_FAILURE_RESPONSE_FUNCTION = 'python.path.to.your.view'
326345

@@ -377,10 +396,12 @@ can set in the settings.py file::
377396

378397
This setting is True by default.
379398

399+
The following setting lets you specify a URL for redirection after a successful
400+
authentication::
401+
380402
ACS_DEFAULT_REDIRECT_URL = reverse_lazy('some_url_name')
381403

382-
This setting lets you specify a URL for redirection after a successful
383-
authentication. Particularly useful when you only plan to use
404+
Particularly useful when you only plan to use
384405
IdP initiated login and the IdP does not have a configured RelayState
385406
parameter. The default is ``/``.
386407

djangosaml2/tests/__init__.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,11 @@
1616

1717
import base64
1818
import datetime
19+
import mock
1920
import re
2021
import sys
21-
from unittest import mock, skip
22+
23+
from unittest import skip
2224

2325
from django.conf import settings
2426
from django.contrib.auth import SESSION_KEY, get_user_model
@@ -35,6 +37,7 @@
3537
from djangosaml2.conf import get_config
3638
from djangosaml2.signals import post_authenticated
3739
from djangosaml2.tests import conf
40+
from djangosaml2.tests.utils import SAMLPostFormParser
3841
from djangosaml2.tests.auth_response import auth_response
3942
from djangosaml2.views import finish_logout
4043

@@ -106,6 +109,35 @@ def render_template(self, text):
106109
def b64_for_post(self, xml_text, encoding='utf-8'):
107110
return base64.b64encode(xml_text.encode(encoding)).decode('ascii')
108111

112+
def test_unsigned_post_authn_request(self):
113+
"""
114+
Test that unsigned authentication requests via POST binding
115+
does not error.
116+
117+
https://github.com/knaperek/djangosaml2/issues/168
118+
"""
119+
settings.SAML_CONFIG = conf.create_conf(
120+
sp_host='sp.example.com',
121+
idp_hosts=['idp.example.com'],
122+
metadata_file='remote_metadata_post_binding.xml',
123+
authn_requests_signed=False
124+
)
125+
response = self.client.get(reverse('saml2_login'))
126+
127+
self.assertEqual(response.status_code, 200)
128+
129+
# Using POST-binding returns a page with form containing the SAMLRequest
130+
response_parser = SAMLPostFormParser()
131+
response_parser.feed(response.content.decode('utf-8'))
132+
saml_request = response_parser.saml_request_value
133+
expected_request = """<samlp:AuthnRequest xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" AssertionConsumerServiceURL="http://sp.example.com/saml2/acs/" Destination="https://idp.example.com/simplesaml/saml2/idp/SSOService.php" ID="XXXXXXXXXXXXXXXXXXXXXX" IssueInstant="2010-01-01T00:00:00Z" ProtocolBinding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Version="2.0"><saml:Issuer Format="urn:oasis:names:tc:SAML:2.0:nameid-format:entity">http://sp.example.com/saml2/metadata/</saml:Issuer><samlp:NameIDPolicy AllowCreate="false" Format="urn:oasis:names:tc:SAML:2.0:nameid-format:persistent" /></samlp:AuthnRequest>"""
134+
135+
self.assertIsNotNone(saml_request)
136+
self.assertSAMLRequestsEquals(
137+
base64.b64decode(saml_request).decode('utf-8'),
138+
expected_request
139+
)
140+
109141
def test_login_evil_redirect(self):
110142
"""
111143
Make sure that if we give an URL other than our own host as the next

djangosaml2/tests/conf.py

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

2020

2121
def create_conf(sp_host='sp.example.com', idp_hosts=['idp.example.com'],
22-
metadata_file='remote_metadata.xml'):
22+
metadata_file='remote_metadata.xml', authn_requests_signed=None):
2323

2424
try:
2525
from saml2.sigver import get_xmlsec_binary
@@ -90,6 +90,9 @@ def create_conf(sp_host='sp.example.com', idp_hosts=['idp.example.com'],
9090
'valid_for': 24,
9191
}
9292

93+
if authn_requests_signed is not None:
94+
config['service']['sp']['authn_requests_signed'] = authn_requests_signed
95+
9396
for idp in idp_hosts:
9497
entity_id = 'https://%s/simplesaml/saml2/idp/metadata.php' % idp
9598
config['service']['sp']['idp'][entity_id] = {
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?xml version="1.0"?>
2+
<md:EntitiesDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
3+
<md:EntityDescriptor entityID="https://idp.example.com/simplesaml/saml2/idp/metadata.php">
4+
<md:IDPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
5+
<md:KeyDescriptor use="signing">
6+
<ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
7+
<ds:X509Data>
8+
<ds:X509Certificate>MIICgTCCAeoCCQCbOlrWDdX7FTANBgkqhkiG9w0BAQUFADCBhDELMAkGA1UEBhMCTk8xGDAWBgNVBAgTD0FuZHJlYXMgU29sYmVyZzEMMAoGA1UEBxMDRm9vMRAwDgYDVQQKEwdVTklORVRUMRgwFgYDVQQDEw9mZWlkZS5lcmxhbmcubm8xITAfBgkqhkiG9w0BCQEWEmFuZHJlYXNAdW5pbmV0dC5ubzAeFw0wNzA2MTUxMjAxMzVaFw0wNzA4MTQxMjAxMzVaMIGEMQswCQYDVQQGEwJOTzEYMBYGA1UECBMPQW5kcmVhcyBTb2xiZXJnMQwwCgYDVQQHEwNGb28xEDAOBgNVBAoTB1VOSU5FVFQxGDAWBgNVBAMTD2ZlaWRlLmVybGFuZy5ubzEhMB8GCSqGSIb3DQEJARYSYW5kcmVhc0B1bmluZXR0Lm5vMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDivbhR7P516x/S3BqKxupQe0LONoliupiBOesCO3SHbDrl3+q9IbfnfmE04rNuMcPsIxB161TdDpIesLCn7c8aPHISKOtPlAeTZSnb8QAu7aRjZq3+PbrP5uW3TcfCGPtKTytHOge/OlJbo078dVhXQ14d1EDwXJW1rRXuUt4C8QIDAQABMA0GCSqGSIb3DQEBBQUAA4GBACDVfp86HObqY+e8BUoWQ9+VMQx1ASDohBjwOsg2WykUqRXF+dLfcUH9dWR63CtZIKFDbStNomPnQz7nbK+onygwBspVEbnHuUihZq3ZUdmumQqCw4Uvs/1Uvq3orOo/WJVhTyvLgFVK2QarQ4/67OZfHd7R+POBXhophSMv1ZOo</ds:X509Certificate>
9+
</ds:X509Data>
10+
</ds:KeyInfo>
11+
</md:KeyDescriptor>
12+
<md:KeyDescriptor use="encryption">
13+
<ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
14+
<ds:X509Data>
15+
<ds:X509Certificate>MIICgTCCAeoCCQCbOlrWDdX7FTANBgkqhkiG9w0BAQUFADCBhDELMAkGA1UEBhMCTk8xGDAWBgNVBAgTD0FuZHJlYXMgU29sYmVyZzEMMAoGA1UEBxMDRm9vMRAwDgYDVQQKEwdVTklORVRUMRgwFgYDVQQDEw9mZWlkZS5lcmxhbmcubm8xITAfBgkqhkiG9w0BCQEWEmFuZHJlYXNAdW5pbmV0dC5ubzAeFw0wNzA2MTUxMjAxMzVaFw0wNzA4MTQxMjAxMzVaMIGEMQswCQYDVQQGEwJOTzEYMBYGA1UECBMPQW5kcmVhcyBTb2xiZXJnMQwwCgYDVQQHEwNGb28xEDAOBgNVBAoTB1VOSU5FVFQxGDAWBgNVBAMTD2ZlaWRlLmVybGFuZy5ubzEhMB8GCSqGSIb3DQEJARYSYW5kcmVhc0B1bmluZXR0Lm5vMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDivbhR7P516x/S3BqKxupQe0LONoliupiBOesCO3SHbDrl3+q9IbfnfmE04rNuMcPsIxB161TdDpIesLCn7c8aPHISKOtPlAeTZSnb8QAu7aRjZq3+PbrP5uW3TcfCGPtKTytHOge/OlJbo078dVhXQ14d1EDwXJW1rRXuUt4C8QIDAQABMA0GCSqGSIb3DQEBBQUAA4GBACDVfp86HObqY+e8BUoWQ9+VMQx1ASDohBjwOsg2WykUqRXF+dLfcUH9dWR63CtZIKFDbStNomPnQz7nbK+onygwBspVEbnHuUihZq3ZUdmumQqCw4Uvs/1Uvq3orOo/WJVhTyvLgFVK2QarQ4/67OZfHd7R+POBXhophSMv1ZOo</ds:X509Certificate>
16+
</ds:X509Data>
17+
</ds:KeyInfo>
18+
</md:KeyDescriptor>
19+
<md:SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://idp.example.com/simplesaml/saml2/idp/SingleLogoutService.php"/>
20+
<md:NameIDFormat>urn:oasis:names:tc:SAML:2.0:nameid-format:transient</md:NameIDFormat>
21+
<md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://idp.example.com/simplesaml/saml2/idp/SSOService.php"/>
22+
</md:IDPSSODescriptor>
23+
<md:Organization>
24+
<md:OrganizationName xml:lang="en">Lorenzo's test IdP</md:OrganizationName>
25+
<md:OrganizationDisplayName xml:lang="en">idp.example.com IdP</md:OrganizationDisplayName>
26+
<md:OrganizationURL xml:lang="en">http://idp.example.com/</md:OrganizationURL>
27+
</md:Organization>
28+
<md:ContactPerson contactType="technical">
29+
<md:SurName>Administrator</md:SurName>
30+
<md:EmailAddress>[email protected]</md:EmailAddress>
31+
</md:ContactPerson>
32+
</md:EntityDescriptor>
33+
</md:EntitiesDescriptor>

djangosaml2/tests/utils.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
from html.parser import HTMLParser
2+
3+
4+
class SAMLPostFormParser(HTMLParser):
5+
"""
6+
Parses the SAML Post binding form page for the SAMLRequest value.
7+
"""
8+
9+
saml_request_value = None
10+
11+
def handle_starttag(self, tag, attrs):
12+
attrs_dict = dict(attrs)
13+
14+
if tag != "input" or attrs_dict.get("name") != "SAMLRequest":
15+
return
16+
self.saml_request_value = attrs_dict.get("value")

djangosaml2/views.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
)
4545
from saml2.mdstore import SourceNotFound
4646
from saml2.sigver import MissingKey
47+
from saml2.samlp import AuthnRequest
4748
from saml2.validate import ResponseLifetimeExceed, ToEarly
4849
from saml2.xmldsig import ( # support for SHA1 is required by spec
4950
SIG_RSA_SHA1, SIG_RSA_SHA256)
@@ -105,9 +106,16 @@ def login(request,
105106
came_from = settings.LOGIN_REDIRECT_URL
106107

107108
# Ensure the user-originating redirection url is safe.
108-
if not is_safe_url(url=came_from, allowed_hosts={request.get_host()}):
109+
# By setting SAML_ALLOWED_HOSTS in settings.py the user may provide a list of "allowed"
110+
# hostnames for post-login redirects, much like one would specify ALLOWED_HOSTS .
111+
# If this setting is absent, the default is to use the hostname that was used for the current
112+
# request.
113+
saml_allowed_hosts = set(getattr(settings, 'SAML_ALLOWED_HOSTS', [request.get_host()]))
114+
115+
if not is_safe_url(url=came_from, allowed_hosts=saml_allowed_hosts):
109116
came_from = settings.LOGIN_REDIRECT_URL
110117

118+
111119
# if the user is already authenticated that maybe because of two reasons:
112120
# A) He has this URL in two browser windows and in the other one he
113121
# has already initiated the authenticated session.
@@ -221,6 +229,9 @@ def login(request,
221229
binding=binding,
222230
**kwargs)
223231
try:
232+
if isinstance(request_xml, AuthnRequest):
233+
# request_xml will be an instance of AuthnRequest if the message is not signed
234+
request_xml = str(request_xml)
224235
saml_request = base64.b64encode(bytes(request_xml, 'UTF-8')).decode('utf-8')
225236

226237
http_response = render(request, post_binding_form_template, {
@@ -348,7 +359,15 @@ def assertion_consumer_service(request,
348359
if not relay_state:
349360
logger.warning('The RelayState parameter exists but is empty')
350361
relay_state = default_relay_state
351-
if not is_safe_url(url=relay_state, allowed_hosts={request.get_host()}):
362+
363+
# Ensure the user-originating redirection url is safe.
364+
# By setting SAML_ALLOWED_HOSTS in settings.py the user may provide a list of "allowed"
365+
# hostnames for post-login redirects, much like one would specify ALLOWED_HOSTS .
366+
# If this setting is absent, the default is to use the hostname that was used for the current
367+
# request.
368+
saml_allowed_hosts = set(getattr(settings, 'SAML_ALLOWED_HOSTS', [request.get_host()]))
369+
370+
if not is_safe_url(url=relay_state, allowed_hosts=saml_allowed_hosts):
352371
relay_state = settings.LOGIN_REDIRECT_URL
353372
logger.debug('Redirecting to the RelayState: %s', relay_state)
354373
return HttpResponseRedirect(relay_state)

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ def read(*rnames):
6060
install_requires=[
6161
'defusedxml>=0.4.1',
6262
'Django>=2.2',
63-
'pysaml2>=4.6.0',
63+
'pysaml2>=5.0.0',
6464
],
6565
tests_require=[
6666
# Provides assert_called_once.

0 commit comments

Comments
 (0)