Skip to content

Commit bc2d8a3

Browse files
author
Giuseppe De Marco
authored
Merge pull request #261 from peppelinux/dev
v1.1.0
2 parents 4eec525 + 18e1159 commit bc2d8a3

File tree

4 files changed

+149
-46
lines changed

4 files changed

+149
-46
lines changed

README.rst

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,15 @@ may be specified by the client - typically with the ?next= parameter.)
128128
In the absence of a ?next= parameter, the LOGIN_REDIRECT_URL setting will be used (assuming the destination hostname
129129
either matches the output of get_host() or is included in the SAML_ALLOWED_HOSTS setting)
130130

131+
Preferred sso binding
132+
---------------------
133+
Use the following setting to choose your preferred binding for SP initiated sso requests::
134+
135+
SAML_DEFAULT_BINDING
136+
137+
For example::
138+
139+
SAML_DEFAULT_BINDING = saml2.BINDING_HTTP_POST
131140

132141
Preferred Logout binding
133142
------------------------
@@ -155,13 +164,36 @@ Idp's like Okta require a signed logout response to validate and logout a user.
155164

156165
Discovery Service
157166
-----------------
158-
If you want to use a SAML Discovery Service, all you need is adding:
167+
If you want to use a SAML Discovery Service, all you need is adding::
159168

160169
SAML2_DISCO_URL = 'https://your.ds.example.net/'
161170

162171
Of course, with the real URL of your preferred Discovery Service.
163172

164173

174+
Idp hinting
175+
-----------
176+
If the SP uses an AIM Proxy it is possible to suggest the authentication IDP by adopting the _idphint_ parameter. The name of the `idphint` parameter is default, but it can also be changed using this parameter::
177+
178+
SAML2_IDPHINT_PARAM = 'idphint'
179+
180+
This will ensure that the user will not get a possible discovery service page for the selection of the IdP to use for the SSO.
181+
When Djagosaml2 receives an HTTP request at the resource, web path, configured for the saml2 login, it will detect the presence of the `idphint` parameter. If this is present, the authentication request will report this URL parameter within the http request relating to the SAML2 SSO binding.
182+
183+
For example::
184+
185+
import requests
186+
import urllib
187+
idphint = {'idphint': [
188+
urllib.parse.quote_plus(b'https://that.idp.example.org/metadata'),
189+
urllib.parse.quote_plus(b'https://another.entitydi.org')]
190+
}
191+
param = urllib.parse.urlencode(idphint)
192+
# param is "idphint=%5B%27https%253A%252F%252Fthat.idp.example.org%252Fmetadata%27%2C+%27https%253A%252F%252Fanother.entitydi.org%27%5D"
193+
requests.get(f'http://djangosaml2.sp.fqdn.org/saml2/login/?{param}')
194+
195+
see AARC Blueprint specs `here <https://zenodo.org/record/4596667/files/AARC-G061-A_specification_for_IdP_hinting.pdf>`_.
196+
165197
Changes in the urls.py file
166198
---------------------------
167199

djangosaml2/utils.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,18 +12,23 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414
import base64
15+
import logging
1516
import re
1617
import urllib
1718
import zlib
1819
from typing import Optional
1920

2021
from django.conf import settings
2122
from django.core.exceptions import ImproperlyConfigured
23+
from django.http import HttpResponseRedirect
2224
from django.utils.http import is_safe_url
2325
from saml2.config import SPConfig
2426
from saml2.s_utils import UnknownSystemEntity
2527

2628

29+
logger = logging.getLogger(__name__)
30+
31+
2732
def get_custom_setting(name: str, default=None):
2833
return getattr(settings, name, default)
2934

@@ -106,3 +111,52 @@ def get_session_id_from_saml2(saml2_xml):
106111
def get_subject_id_from_saml2(saml2_xml):
107112
saml2_xml = saml2_xml if isinstance(saml2_xml, str) else saml2_xml.decode()
108113
re.findall('">([a-z0-9]+)</saml:NameID>', saml2_xml)[0]
114+
115+
def add_param_in_url(url:str, param_key:str, param_value:str):
116+
params = list(url.split('?'))
117+
params.append(f'{param_key}={param_value}')
118+
new_url = params[0] + '?' +''.join(params[1:])
119+
return new_url
120+
121+
def add_idp_hinting(request, http_response) -> bool:
122+
idphin_param = getattr(settings, 'SAML2_IDPHINT_PARAM', 'idphint')
123+
params = urllib.parse.urlencode(request.GET)
124+
125+
if idphin_param not in request.GET.keys():
126+
return False
127+
128+
idphint = request.GET[idphin_param]
129+
# validation : TODO -> improve!
130+
if idphint[0:4] != 'http':
131+
logger.warning(
132+
f'Idp hinting: "{idphint}" doesn\'t contain a valid value.'
133+
'idphint paramenter ignored.'
134+
)
135+
return False
136+
137+
if http_response.status_code in (302, 303):
138+
# redirect binding
139+
# urlp = urllib.parse.urlparse(http_response.url)
140+
new_url = add_param_in_url(http_response.url,
141+
idphin_param, idphint)
142+
return HttpResponseRedirect(new_url)
143+
144+
elif http_response.status_code == 200:
145+
# post binding
146+
res = re.search(r'action="(?P<url>[a-z0-9\:\/\_\-\.]*)"',
147+
http_response.content.decode(), re.I)
148+
if not res:
149+
return False
150+
orig_url = res.groupdict()['url']
151+
#
152+
new_url = add_param_in_url(orig_url, idphin_param, idphint)
153+
content = http_response.content.decode()\
154+
.replace(orig_url, new_url)\
155+
.encode()
156+
return HttpResponse(content)
157+
158+
else:
159+
logger.warning(
160+
f'Idp hinting: cannot detect request type [{http_response.status_code}]'
161+
)
162+
return False

djangosaml2/views.py

Lines changed: 61 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
import base64
1717
import logging
18+
import saml2
1819

1920
from django.conf import settings
2021
from django.contrib import auth
@@ -31,7 +32,6 @@
3132
from django.views.decorators.csrf import csrf_exempt
3233
from django.views.generic import View
3334
from django.utils.module_loading import import_string
34-
from saml2 import BINDING_HTTP_POST, BINDING_HTTP_REDIRECT
3535
from saml2.client_base import LogoutError
3636
from saml2.config import SPConfig
3737
from saml2.ident import code, decode
@@ -52,7 +52,7 @@
5252
from .conf import get_config
5353
from .exceptions import IdPConfigurationMissing
5454
from .overrides import Saml2Client
55-
from .utils import (available_idps, get_custom_setting,
55+
from .utils import (add_idp_hinting, available_idps, get_custom_setting,
5656
get_idp_sso_supported_bindings, get_location,
5757
validate_referral_url)
5858

@@ -191,61 +191,73 @@ def get(self, request, *args, **kwargs):
191191
selected_idp = list(configured_idps.keys())[0]
192192

193193
# choose a binding to try first
194-
sign_requests = getattr(conf, '_sp_authn_requests_signed', False)
195-
binding = BINDING_HTTP_POST if sign_requests else BINDING_HTTP_REDIRECT
196-
logger.debug('Trying binding %s for IDP %s', binding, selected_idp)
194+
binding = getattr(settings, 'SAML_DEFAULT_BINDING', saml2.BINDING_HTTP_POST)
195+
logger.debug(f'Trying binding {binding} for IDP {selected_idp}')
197196

198197
# ensure our selected binding is supported by the IDP
199198
supported_bindings = get_idp_sso_supported_bindings(
200199
selected_idp, config=conf)
200+
201201
if binding not in supported_bindings:
202-
logger.debug('Binding %s not in IDP %s supported bindings: %s',
203-
binding, selected_idp, supported_bindings)
204-
if binding == BINDING_HTTP_POST:
205-
logger.warning('IDP %s does not support %s, trying %s',
206-
selected_idp, binding, BINDING_HTTP_REDIRECT)
207-
binding = BINDING_HTTP_REDIRECT
202+
logger.debug(
203+
f'Binding {binding} not in IDP {selected_idp} '
204+
f'supported bindings: {supported_bindings}. Trying to switch ...',
205+
)
206+
if binding == saml2.BINDING_HTTP_POST:
207+
logger.warning(
208+
f'IDP {selected_idp} does not support {binding} '
209+
f'trying {saml2.BINDING_HTTP_REDIRECT}',
210+
)
211+
binding = saml2.BINDING_HTTP_REDIRECT
208212
else:
209-
logger.warning('IDP %s does not support %s, trying %s',
210-
selected_idp, binding, BINDING_HTTP_POST)
211-
binding = BINDING_HTTP_POST
213+
logger.warning(
214+
f'IDP {selected_idp} does not support {binding} '
215+
f'trying {saml2.BINDING_HTTP_POST}',
216+
)
217+
binding = saml2.BINDING_HTTP_POST
212218
# if switched binding still not supported, give up
213219
if binding not in supported_bindings:
214220
raise UnsupportedBinding(
215-
'IDP %s does not support %s or %s', selected_idp, BINDING_HTTP_POST, BINDING_HTTP_REDIRECT)
221+
f'IDP {selected_idp} does not support '
222+
f'{saml2.BINDING_HTTP_POST} and {saml2.BINDING_HTTP_REDIRECT}'
223+
)
216224

217225
client = Saml2Client(conf)
218226
http_response = None
219227

220-
kwargs = {}
228+
# SSO options
229+
sign_requests = getattr(conf, '_sp_authn_requests_signed', False)
230+
sso_kwargs = {}
231+
if sign_requests:
232+
sso_kwargs["sigalg"] = settings.SAML_CONFIG['service']['sp']\
233+
.get('signing_algorithm',
234+
saml2.xmldsig.SIG_RSA_SHA256)
235+
sso_kwargs["digest_alg"] = settings.SAML_CONFIG['service']['sp']\
236+
.get('digest_algorithm',
237+
saml2.xmldsig.DIGEST_SHA256)
238+
221239
# pysaml needs a string otherwise: "cannot serialize True (type bool)"
222240
if getattr(conf, '_sp_force_authn', False):
223-
kwargs['force_authn'] = "true"
241+
sso_kwargs['force_authn'] = "true"
224242
if getattr(conf, '_sp_allow_create', False):
225-
kwargs['allow_create'] = "true"
243+
sso_kwargs['allow_create'] = "true"
244+
245+
# custom nsprefixes
246+
sso_kwargs['nsprefix'] = get_namespace_prefixes()
226247

227-
logger.debug('Redirecting user to the IdP via %s binding.', binding)
228-
if binding == BINDING_HTTP_REDIRECT:
248+
logger.debug(f'Redirecting user to the IdP via {binding} binding.')
249+
if binding == saml2.BINDING_HTTP_REDIRECT:
229250
try:
230-
nsprefix = get_namespace_prefixes()
231-
if sign_requests:
232-
# do not sign the xml itself, instead use the sigalg to
233-
# generate the signature as a URL param
234-
sig_alg_option_map = {
235-
'sha1': SIG_RSA_SHA1, 'sha256': SIG_RSA_SHA256}
236-
sig_alg_option = getattr(
237-
conf, '_sp_authn_requests_signed_alg', 'sha1')
238-
kwargs["sigalg"] = sig_alg_option_map[sig_alg_option]
239251
session_id, result = client.prepare_for_authenticate(
240252
entityid=selected_idp, relay_state=next_path,
241-
binding=binding, sign=sign_requests, nsprefix=nsprefix,
242-
**kwargs)
253+
binding=binding, sign=sign_requests,
254+
**sso_kwargs)
243255
except TypeError as e:
244256
logger.error('Unable to know which IdP to use')
245257
return HttpResponse(str(e))
246258
else:
247259
http_response = HttpResponseRedirect(get_location(result))
248-
elif binding == BINDING_HTTP_POST:
260+
elif binding == saml2.BINDING_HTTP_POST:
249261
if self.post_binding_form_template:
250262
# get request XML to build our own html based on the template
251263
try:
@@ -256,7 +268,7 @@ def get(self, request, *args, **kwargs):
256268
session_id, request_xml = client.create_authn_request(
257269
location,
258270
binding=binding,
259-
**kwargs)
271+
**sso_kwargs)
260272
try:
261273
if isinstance(request_xml, AuthnRequest):
262274
# request_xml will be an instance of AuthnRequest if the message is not signed
@@ -271,11 +283,11 @@ def get(self, request, *args, **kwargs):
271283
'RelayState': next_path,
272284
},
273285
})
274-
except TemplateDoesNotExist:
275-
pass
286+
except TemplateDoesNotExist as e:
287+
logger.error(f'TemplateDoesNotExist: {e}')
276288

277289
if not http_response:
278-
# use the html provided by pysaml2 if no template was specified or it didn't exist
290+
# use the html provided by pysaml2 if no template was specified or it doesn't exist
279291
try:
280292
session_id, result = client.prepare_for_authenticate(
281293
entityid=selected_idp, relay_state=next_path,
@@ -286,14 +298,19 @@ def get(self, request, *args, **kwargs):
286298
else:
287299
http_response = HttpResponse(result['data'])
288300
else:
289-
raise UnsupportedBinding('Unsupported binding: %s', binding)
301+
raise UnsupportedBinding(f'Unsupported binding: {binding}')
290302

291303
# success, so save the session ID and return our response
292304
oq_cache = OutstandingQueriesCache(request.saml_session)
293305
oq_cache.set(session_id, next_path)
294306
logger.debug(
295-
'Saving the session_id "%s" in the OutstandingQueries cache', oq_cache.__dict__)
296-
return http_response
307+
f'Saving the session_id "{oq_cache.__dict__}" '
308+
'in the OutstandingQueries cache',
309+
)
310+
311+
# idp hinting support, add idphint url parameter if present in this request
312+
response = add_idp_hinting(request, http_response) or http_response
313+
return response
297314

298315

299316
@method_decorator(csrf_exempt, name='dispatch')
@@ -344,7 +361,7 @@ def post(self, request, attribute_mapping=None, create_unknown_user=None):
344361
_exception = None
345362
try:
346363
response = client.parse_authn_request_response(request.POST['SAMLResponse'],
347-
BINDING_HTTP_POST,
364+
saml2.BINDING_HTTP_POST,
348365
outstanding_queries)
349366
except (StatusError, ToEarly) as e:
350367
_exception = e
@@ -525,12 +542,12 @@ def get(self, request, *args, **kwargs):
525542
for entityid, logout_info in result.items():
526543
if isinstance(logout_info, tuple):
527544
binding, http_info = logout_info
528-
if binding == BINDING_HTTP_POST:
545+
if binding == saml2.BINDING_HTTP_POST:
529546
logger.debug(
530547
'Returning form to the IdP to continue the logout process')
531548
body = ''.join(http_info['data'])
532549
return HttpResponse(body)
533-
elif binding == BINDING_HTTP_REDIRECT:
550+
elif binding == saml2.BINDING_HTTP_REDIRECT:
534551
logger.debug(
535552
'Redirecting to the IdP to continue the logout process')
536553
return HttpResponseRedirect(get_location(http_info))
@@ -569,10 +586,10 @@ class LogoutView(SPConfigMixin, View):
569586
logout_error_template = 'djangosaml2/logout_error.html'
570587

571588
def get(self, request, *args, **kwargs):
572-
return self.do_logout_service(request, request.GET, BINDING_HTTP_REDIRECT, *args, **kwargs)
589+
return self.do_logout_service(request, request.GET, saml2.BINDING_HTTP_REDIRECT, *args, **kwargs)
573590

574591
def post(self, request, *args, **kwargs):
575-
return self.do_logout_service(request, request.POST, BINDING_HTTP_POST, *args, **kwargs)
592+
return self.do_logout_service(request, request.POST, saml2.BINDING_HTTP_POST, *args, **kwargs)
576593

577594
def do_logout_service(self, request, data, binding):
578595
logger.debug('Logout service started')

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ def read(*rnames):
2424

2525
setup(
2626
name='djangosaml2',
27-
version='1.0.7',
27+
version='1.1.0',
2828
description='pysaml2 integration for Django',
2929
long_description=read('README.rst'),
3030
classifiers=[

0 commit comments

Comments
 (0)