Skip to content

Commit

Permalink
Merge pull request #261 from peppelinux/dev
Browse files Browse the repository at this point in the history
v1.1.0
  • Loading branch information
Giuseppe De Marco authored Mar 31, 2021
2 parents 4eec525 + 18e1159 commit bc2d8a3
Show file tree
Hide file tree
Showing 4 changed files with 149 additions and 46 deletions.
34 changes: 33 additions & 1 deletion README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,15 @@ may be specified by the client - typically with the ?next= parameter.)
In the absence of a ?next= parameter, the LOGIN_REDIRECT_URL setting will be used (assuming the destination hostname
either matches the output of get_host() or is included in the SAML_ALLOWED_HOSTS setting)

Preferred sso binding
---------------------
Use the following setting to choose your preferred binding for SP initiated sso requests::

SAML_DEFAULT_BINDING

For example::

SAML_DEFAULT_BINDING = saml2.BINDING_HTTP_POST

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

Discovery Service
-----------------
If you want to use a SAML Discovery Service, all you need is adding:
If you want to use a SAML Discovery Service, all you need is adding::

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

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


Idp hinting
-----------
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::

SAML2_IDPHINT_PARAM = 'idphint'

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.
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.

For example::

import requests
import urllib
idphint = {'idphint': [
urllib.parse.quote_plus(b'https://that.idp.example.org/metadata'),
urllib.parse.quote_plus(b'https://another.entitydi.org')]
}
param = urllib.parse.urlencode(idphint)
# param is "idphint=%5B%27https%253A%252F%252Fthat.idp.example.org%252Fmetadata%27%2C+%27https%253A%252F%252Fanother.entitydi.org%27%5D"
requests.get(f'http://djangosaml2.sp.fqdn.org/saml2/login/?{param}')

see AARC Blueprint specs `here <https://zenodo.org/record/4596667/files/AARC-G061-A_specification_for_IdP_hinting.pdf>`_.

Changes in the urls.py file
---------------------------

Expand Down
54 changes: 54 additions & 0 deletions djangosaml2/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,23 @@
# See the License for the specific language governing permissions and
# limitations under the License.
import base64
import logging
import re
import urllib
import zlib
from typing import Optional

from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from django.http import HttpResponseRedirect
from django.utils.http import is_safe_url
from saml2.config import SPConfig
from saml2.s_utils import UnknownSystemEntity


logger = logging.getLogger(__name__)


def get_custom_setting(name: str, default=None):
return getattr(settings, name, default)

Expand Down Expand Up @@ -106,3 +111,52 @@ def get_session_id_from_saml2(saml2_xml):
def get_subject_id_from_saml2(saml2_xml):
saml2_xml = saml2_xml if isinstance(saml2_xml, str) else saml2_xml.decode()
re.findall('">([a-z0-9]+)</saml:NameID>', saml2_xml)[0]

def add_param_in_url(url:str, param_key:str, param_value:str):
params = list(url.split('?'))
params.append(f'{param_key}={param_value}')
new_url = params[0] + '?' +''.join(params[1:])
return new_url

def add_idp_hinting(request, http_response) -> bool:
idphin_param = getattr(settings, 'SAML2_IDPHINT_PARAM', 'idphint')
params = urllib.parse.urlencode(request.GET)

if idphin_param not in request.GET.keys():
return False

idphint = request.GET[idphin_param]
# validation : TODO -> improve!
if idphint[0:4] != 'http':
logger.warning(
f'Idp hinting: "{idphint}" doesn\'t contain a valid value.'
'idphint paramenter ignored.'
)
return False

if http_response.status_code in (302, 303):
# redirect binding
# urlp = urllib.parse.urlparse(http_response.url)
new_url = add_param_in_url(http_response.url,
idphin_param, idphint)
return HttpResponseRedirect(new_url)

elif http_response.status_code == 200:
# post binding
res = re.search(r'action="(?P<url>[a-z0-9\:\/\_\-\.]*)"',
http_response.content.decode(), re.I)
if not res:
return False
orig_url = res.groupdict()['url']
#
new_url = add_param_in_url(orig_url, idphin_param, idphint)
content = http_response.content.decode()\
.replace(orig_url, new_url)\
.encode()
return HttpResponse(content)

else:
logger.warning(
f'Idp hinting: cannot detect request type [{http_response.status_code}]'
)
return False
105 changes: 61 additions & 44 deletions djangosaml2/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

import base64
import logging
import saml2

from django.conf import settings
from django.contrib import auth
Expand All @@ -31,7 +32,6 @@
from django.views.decorators.csrf import csrf_exempt
from django.views.generic import View
from django.utils.module_loading import import_string
from saml2 import BINDING_HTTP_POST, BINDING_HTTP_REDIRECT
from saml2.client_base import LogoutError
from saml2.config import SPConfig
from saml2.ident import code, decode
Expand All @@ -52,7 +52,7 @@
from .conf import get_config
from .exceptions import IdPConfigurationMissing
from .overrides import Saml2Client
from .utils import (available_idps, get_custom_setting,
from .utils import (add_idp_hinting, available_idps, get_custom_setting,
get_idp_sso_supported_bindings, get_location,
validate_referral_url)

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

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

# ensure our selected binding is supported by the IDP
supported_bindings = get_idp_sso_supported_bindings(
selected_idp, config=conf)

if binding not in supported_bindings:
logger.debug('Binding %s not in IDP %s supported bindings: %s',
binding, selected_idp, supported_bindings)
if binding == BINDING_HTTP_POST:
logger.warning('IDP %s does not support %s, trying %s',
selected_idp, binding, BINDING_HTTP_REDIRECT)
binding = BINDING_HTTP_REDIRECT
logger.debug(
f'Binding {binding} not in IDP {selected_idp} '
f'supported bindings: {supported_bindings}. Trying to switch ...',
)
if binding == saml2.BINDING_HTTP_POST:
logger.warning(
f'IDP {selected_idp} does not support {binding} '
f'trying {saml2.BINDING_HTTP_REDIRECT}',
)
binding = saml2.BINDING_HTTP_REDIRECT
else:
logger.warning('IDP %s does not support %s, trying %s',
selected_idp, binding, BINDING_HTTP_POST)
binding = BINDING_HTTP_POST
logger.warning(
f'IDP {selected_idp} does not support {binding} '
f'trying {saml2.BINDING_HTTP_POST}',
)
binding = saml2.BINDING_HTTP_POST
# if switched binding still not supported, give up
if binding not in supported_bindings:
raise UnsupportedBinding(
'IDP %s does not support %s or %s', selected_idp, BINDING_HTTP_POST, BINDING_HTTP_REDIRECT)
f'IDP {selected_idp} does not support '
f'{saml2.BINDING_HTTP_POST} and {saml2.BINDING_HTTP_REDIRECT}'
)

client = Saml2Client(conf)
http_response = None

kwargs = {}
# SSO options
sign_requests = getattr(conf, '_sp_authn_requests_signed', False)
sso_kwargs = {}
if sign_requests:
sso_kwargs["sigalg"] = settings.SAML_CONFIG['service']['sp']\
.get('signing_algorithm',
saml2.xmldsig.SIG_RSA_SHA256)
sso_kwargs["digest_alg"] = settings.SAML_CONFIG['service']['sp']\
.get('digest_algorithm',
saml2.xmldsig.DIGEST_SHA256)

# pysaml needs a string otherwise: "cannot serialize True (type bool)"
if getattr(conf, '_sp_force_authn', False):
kwargs['force_authn'] = "true"
sso_kwargs['force_authn'] = "true"
if getattr(conf, '_sp_allow_create', False):
kwargs['allow_create'] = "true"
sso_kwargs['allow_create'] = "true"

# custom nsprefixes
sso_kwargs['nsprefix'] = get_namespace_prefixes()

logger.debug('Redirecting user to the IdP via %s binding.', binding)
if binding == BINDING_HTTP_REDIRECT:
logger.debug(f'Redirecting user to the IdP via {binding} binding.')
if binding == saml2.BINDING_HTTP_REDIRECT:
try:
nsprefix = get_namespace_prefixes()
if sign_requests:
# do not sign the xml itself, instead use the sigalg to
# generate the signature as a URL param
sig_alg_option_map = {
'sha1': SIG_RSA_SHA1, 'sha256': SIG_RSA_SHA256}
sig_alg_option = getattr(
conf, '_sp_authn_requests_signed_alg', 'sha1')
kwargs["sigalg"] = sig_alg_option_map[sig_alg_option]
session_id, result = client.prepare_for_authenticate(
entityid=selected_idp, relay_state=next_path,
binding=binding, sign=sign_requests, nsprefix=nsprefix,
**kwargs)
binding=binding, sign=sign_requests,
**sso_kwargs)
except TypeError as e:
logger.error('Unable to know which IdP to use')
return HttpResponse(str(e))
else:
http_response = HttpResponseRedirect(get_location(result))
elif binding == BINDING_HTTP_POST:
elif binding == saml2.BINDING_HTTP_POST:
if self.post_binding_form_template:
# get request XML to build our own html based on the template
try:
Expand All @@ -256,7 +268,7 @@ def get(self, request, *args, **kwargs):
session_id, request_xml = client.create_authn_request(
location,
binding=binding,
**kwargs)
**sso_kwargs)
try:
if isinstance(request_xml, AuthnRequest):
# request_xml will be an instance of AuthnRequest if the message is not signed
Expand All @@ -271,11 +283,11 @@ def get(self, request, *args, **kwargs):
'RelayState': next_path,
},
})
except TemplateDoesNotExist:
pass
except TemplateDoesNotExist as e:
logger.error(f'TemplateDoesNotExist: {e}')

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

# success, so save the session ID and return our response
oq_cache = OutstandingQueriesCache(request.saml_session)
oq_cache.set(session_id, next_path)
logger.debug(
'Saving the session_id "%s" in the OutstandingQueries cache', oq_cache.__dict__)
return http_response
f'Saving the session_id "{oq_cache.__dict__}" '
'in the OutstandingQueries cache',
)

# idp hinting support, add idphint url parameter if present in this request
response = add_idp_hinting(request, http_response) or http_response
return response


@method_decorator(csrf_exempt, name='dispatch')
Expand Down Expand Up @@ -344,7 +361,7 @@ def post(self, request, attribute_mapping=None, create_unknown_user=None):
_exception = None
try:
response = client.parse_authn_request_response(request.POST['SAMLResponse'],
BINDING_HTTP_POST,
saml2.BINDING_HTTP_POST,
outstanding_queries)
except (StatusError, ToEarly) as e:
_exception = e
Expand Down Expand Up @@ -525,12 +542,12 @@ def get(self, request, *args, **kwargs):
for entityid, logout_info in result.items():
if isinstance(logout_info, tuple):
binding, http_info = logout_info
if binding == BINDING_HTTP_POST:
if binding == saml2.BINDING_HTTP_POST:
logger.debug(
'Returning form to the IdP to continue the logout process')
body = ''.join(http_info['data'])
return HttpResponse(body)
elif binding == BINDING_HTTP_REDIRECT:
elif binding == saml2.BINDING_HTTP_REDIRECT:
logger.debug(
'Redirecting to the IdP to continue the logout process')
return HttpResponseRedirect(get_location(http_info))
Expand Down Expand Up @@ -569,10 +586,10 @@ class LogoutView(SPConfigMixin, View):
logout_error_template = 'djangosaml2/logout_error.html'

def get(self, request, *args, **kwargs):
return self.do_logout_service(request, request.GET, BINDING_HTTP_REDIRECT, *args, **kwargs)
return self.do_logout_service(request, request.GET, saml2.BINDING_HTTP_REDIRECT, *args, **kwargs)

def post(self, request, *args, **kwargs):
return self.do_logout_service(request, request.POST, BINDING_HTTP_POST, *args, **kwargs)
return self.do_logout_service(request, request.POST, saml2.BINDING_HTTP_POST, *args, **kwargs)

def do_logout_service(self, request, data, binding):
logger.debug('Logout service started')
Expand Down
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ def read(*rnames):

setup(
name='djangosaml2',
version='1.0.7',
version='1.1.0',
description='pysaml2 integration for Django',
long_description=read('README.rst'),
classifiers=[
Expand Down

0 comments on commit bc2d8a3

Please sign in to comment.