From a382e9e81d7f4a1a440a7b0130250f5c2b54c4b9 Mon Sep 17 00:00:00 2001 From: peppelinux Date: Thu, 15 Oct 2020 15:33:18 +0200 Subject: [PATCH] v1.0.0-rc --- CHANGES | 13 ++++++++ README.rst | 37 +++++++++++++++++++++++ djangosaml2/apps.py | 3 ++ djangosaml2/backends.py | 2 ++ djangosaml2/middleware.py | 10 +++---- djangosaml2/signals.py | 18 +++++++++++ djangosaml2/templatetags/__init__.py | 0 djangosaml2/templatetags/idplist.py | 45 ++++++++++++++++++++++++++++ djangosaml2/views.py | 16 ++++++---- tox.ini | 2 +- 10 files changed, 135 insertions(+), 11 deletions(-) create mode 100644 djangosaml2/signals.py create mode 100644 djangosaml2/templatetags/__init__.py create mode 100644 djangosaml2/templatetags/idplist.py diff --git a/CHANGES b/CHANGES index 97c4a3f0..58b2bfae 100644 --- a/CHANGES +++ b/CHANGES @@ -1,6 +1,19 @@ Changes ======= +v1.0.0 (2020-10-15) +------------------- +- General refactor with Django ClassViews + +0.50.0 (2020-10-15) +------------------- +- Discovery Service support + +0.40.1 (2020-09-08) +------------------- +- [BugFix] HTTP-REDIRECT Authn Requests with optional signature now works. +- [BugFix] SameSite - SuspiciousOperation issue in middleware (Issue #220) + 0.40.0 (2020-08-07) ------------------- - Allow a SSO request without any attributes besides the NameID info. Backwards-incompatible changes to allow easier behaviour differentiation, two methods now receive the idp identifier (+ **kwargs were added to introduce possible similar changes in the future with less breaking effect): diff --git a/README.rst b/README.rst index 889fa0eb..071a29ee 100644 --- a/README.rst +++ b/README.rst @@ -140,12 +140,27 @@ For example:: import saml2 SAML_LOGOUT_REQUEST_PREFERRED_BINDING = saml2.BINDING_HTTP_POST +Ignore Logout errors +-------------------- +When logging out, a SAML IDP will return an error on invalid conditions, such as the IDP-side session being expired. +Use the following setting to ignore these errors and perform a local Django logout nonetheless:: + + SAML_IGNORE_LOGOUT_ERRORS = True + Signed Logout Request ------------------------ Idp's like Okta require a signed logout response to validate and logout a user. Here's a sample config with all required SP/IDP settings:: "logout_requests_signed": True, +Discovery Service +----------------- +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. + Changes in the urls.py file --------------------------- @@ -493,6 +508,28 @@ Learn more about Django profile models at: https://docs.djangoproject.com/en/dev/topics/auth/customizing/#substituting-a-custom-user-model +Sometimes you need to use special logic to update the user object +depending on the SAML2 attributes and the mapping described above +is simply not enough. For these cases djangosaml2 provides a Django +signal that you can listen to. In order to do so you can add the +following code to your app:: + + from djangosaml2.signals import pre_user_save + + def custom_update_user(sender=User, instance, attributes, user_modified, **kargs) + ... + return True # I modified the user object + + +Your handler will receive the user object, the list of SAML attributes +and a flag telling you if the user is already modified and need +to be saved after your handler is executed. If your handler +modifies the user object it should return True. Otherwise it should +return False. This way djangosaml2 will know if it should save +the user object so you don't need to do it and no more calls to +the save method are issued. + + IdP setup ========= Congratulations, you have finished configuring the SP side of the federation. diff --git a/djangosaml2/apps.py b/djangosaml2/apps.py index 8a69a6cf..e03f7c8d 100644 --- a/djangosaml2/apps.py +++ b/djangosaml2/apps.py @@ -4,3 +4,6 @@ class DjangoSaml2Config(AppConfig): name = 'djangosaml2' verbose_name = "DjangoSAML2" + + def ready(self): + from . import signals # noqa diff --git a/djangosaml2/backends.py b/djangosaml2/backends.py index a75c6cb8..204019e0 100644 --- a/djangosaml2/backends.py +++ b/djangosaml2/backends.py @@ -24,6 +24,8 @@ from django.core.exceptions import (ImproperlyConfigured, MultipleObjectsReturned) +from .signals import pre_user_save + logger = logging.getLogger('djangosaml2') diff --git a/djangosaml2/middleware.py b/djangosaml2/middleware.py index bd6c5e21..90eb40bf 100644 --- a/djangosaml2/middleware.py +++ b/djangosaml2/middleware.py @@ -43,11 +43,11 @@ def process_response(self, request, response): patch_vary_headers(response, ('Cookie',)) # relies and the global one if (modified or settings.SESSION_SAVE_EVERY_REQUEST) and not empty: - if request.session.get_expire_at_browser_close(): + if request.saml_session.get_expire_at_browser_close(): max_age = None expires = None else: - max_age = getattr(request, self.cookie_name).get_expiry_age() + max_age = request.saml_session.get_expiry_age() expires_time = time.time() + max_age expires = http_date(expires_time) # Save the session data and refresh the client cookie. @@ -67,8 +67,8 @@ def process_response(self, request, response): max_age=max_age, expires=expires, domain=settings.SESSION_COOKIE_DOMAIN, path=settings.SESSION_COOKIE_PATH, - secure=settings.SESSION_COOKIE_SECURE, - httponly=settings.SESSION_COOKIE_HTTPONLY, - samesite=settings.SESSION_COOKIE_SAMESITE + secure=settings.SESSION_COOKIE_SECURE or None, + httponly=settings.SESSION_COOKIE_HTTPONLY or None, + samesite=None ) return response diff --git a/djangosaml2/signals.py b/djangosaml2/signals.py new file mode 100644 index 00000000..52ba1fa0 --- /dev/null +++ b/djangosaml2/signals.py @@ -0,0 +1,18 @@ +# Copyright (C) 2011-2012 Yaco Sistemas (http://www.yaco.es) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import django.dispatch + +pre_user_save = django.dispatch.Signal(providing_args=['attributes', 'user_modified']) +post_authenticated = django.dispatch.Signal(providing_args=['session_info', 'request']) diff --git a/djangosaml2/templatetags/__init__.py b/djangosaml2/templatetags/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/djangosaml2/templatetags/idplist.py b/djangosaml2/templatetags/idplist.py new file mode 100644 index 00000000..cf4eccad --- /dev/null +++ b/djangosaml2/templatetags/idplist.py @@ -0,0 +1,45 @@ +# Copyright (C) 2011-2012 Yaco Sistemas (http://www.yaco.es) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from django import template + +from djangosaml2.conf import config_settings_loader +from djangosaml2.utils import available_idps + +register = template.Library() + + +class IdPListNode(template.Node): + + def __init__(self, variable_name): + self.variable_name = variable_name + + def render(self, context): + conf = config_settings_loader() + context[self.variable_name] = available_idps(conf) + return '' + + +@register.tag +def idplist(parser, token): + try: + tag_name, as_part, variable = token.split_contents() + except ValueError: + raise template.TemplateSyntaxError( + '%r tag requires two arguments' % token.contents.split()[0]) + if not as_part == 'as': + raise template.TemplateSyntaxError( + '%r tag first argument must be the literal "as"' % tag_name) + + return IdPListNode(variable) diff --git a/djangosaml2/views.py b/djangosaml2/views.py index fa563b79..33b67a16 100644 --- a/djangosaml2/views.py +++ b/djangosaml2/views.py @@ -99,6 +99,7 @@ class LoginView(SPConfigMixin, View): If set to None or nonexistent template, default form from the saml2 library will be rendered. """ + logger.debug('Login process started') wayf_template = 'djangosaml2/wayf.html' authorization_error_template = 'djangosaml2/auth_error.html' @@ -477,11 +478,12 @@ def get(self, request, *args, **kwargs): logger.debug('Returning form to the IdP to continue the logout process') body = ''.join(http_info['data']) return HttpResponse(body) - if binding == BINDING_HTTP_REDIRECT: + elif binding == BINDING_HTTP_REDIRECT: logger.debug('Redirecting to the IdP to continue the logout process') return HttpResponseRedirect(get_location(http_info)) - logger.error('Unknown binding: %s', binding) - return HttpResponseServerError('Failed to log out') + else: + logger.error('Unknown binding: %s', binding) + return HttpResponseServerError('Failed to log out') # We must have had a soap logout return finish_logout(request, logout_info) @@ -516,7 +518,11 @@ def do_logout_service(self, request, data, binding): if 'SAMLResponse' in data: # we started the logout logger.debug('Receiving a logout response from the IdP') - response = client.parse_logout_request_response(data['SAMLResponse'], binding) + try: + response = client.parse_logout_request_response(data['SAMLResponse'], binding) + except StatusError as e: + response = None + logger.warning("Error logging out from remote provider: " + str(e)) state.sync() return finish_logout(request, response) @@ -553,7 +559,7 @@ def do_logout_service(self, request, data, binding): def finish_logout(request, response, next_page=None): - if response and response.status_ok(): + if (getattr(settings, 'SAML_IGNORE_LOGOUT_ERRORS', False) or (response and response.status_ok())): if next_page is None and hasattr(settings, 'LOGOUT_REDIRECT_URL'): next_page = settings.LOGOUT_REDIRECT_URL logger.debug('Performing django logout with a next_page of %s', next_page) diff --git a/tox.ini b/tox.ini index 812d530c..1306156b 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [tox] envlist = - py{3.6,3.7,3.8}-django{2.2,3.0,master} + py{3.6,3.7,3.8,3.9}-django{2.2,3.0,master} [testenv] commands =