diff --git a/README.rst b/README.rst index 1631aff..9c0ea25 100644 --- a/README.rst +++ b/README.rst @@ -266,6 +266,8 @@ How to Contribute Release Log =========== +3.0.0: Add support for Django 4.2 and Python 3.12 (BREAKING CHANGES) + 2.3.0: Multiple updates from Tesorio across the years * Change acs redirect to sso login page diff --git a/django_saml2_auth/urls.py b/django_saml2_auth/urls.py index c811caf..e560bc2 100644 --- a/django_saml2_auth/urls.py +++ b/django_saml2_auth/urls.py @@ -1,10 +1,11 @@ -from django.conf.urls import url +from django.urls import path + from . import views -app_name = 'django_saml2_auth' +app_name = "django_saml2_auth" urlpatterns = [ - url(r'^acs/$', views.acs, name="acs"), - url(r'^welcome/$', views.welcome, name="welcome"), - url(r'^denied/$', views.denied, name="denied"), + path("acs/", views.acs, name="acs"), + path("welcome/", views.welcome, name="welcome"), + path("denied/", views.denied, name="denied"), ] diff --git a/django_saml2_auth/views.py b/django_saml2_auth/views.py index 052f51e..0c2819c 100644 --- a/django_saml2_auth/views.py +++ b/django_saml2_auth/views.py @@ -1,7 +1,18 @@ #!/usr/bin/env python -# -*- coding:utf-8 -*- import logging + +from django import get_version +from django.conf import settings +from django.contrib.auth import get_user_model, login, logout +from django.contrib.auth.decorators import login_required +from django.contrib.auth.models import Group +from django.http import HttpResponseRedirect +from django.shortcuts import render +from django.template import TemplateDoesNotExist +from django.utils.http import url_has_allowed_host_and_scheme +from django.views.decorators.csrf import csrf_exempt +from pkg_resources import parse_version from saml2 import ( BINDING_HTTP_POST, BINDING_HTTP_REDIRECT, @@ -10,55 +21,33 @@ from saml2.client import Saml2Client from saml2.config import Config as Saml2Config -from django import get_version -from pkg_resources import parse_version -from django.conf import settings -from django.contrib.auth.models import Group -from django.contrib.auth.decorators import login_required -from django.contrib.auth import login, logout, get_user_model -from django.shortcuts import render -from django.views.decorators.csrf import csrf_exempt -from django.template import TemplateDoesNotExist -from django.http import HttpResponseRedirect -from django.utils.http import is_safe_url - -from rest_auth.utils import jwt_encode - - # default User or custom User. Now both will work. User = get_user_model() logger = logging.getLogger(__name__) -try: - import urllib2 as _urllib -except: - import urllib.request as _urllib - import urllib.error - import urllib.parse - -if parse_version(get_version()) >= parse_version('1.7'): +if parse_version(get_version()) >= parse_version("1.7"): from django.utils.module_loading import import_string else: from django.utils.module_loading import import_by_path as import_string def get_current_domain(r): - if 'ASSERTION_URL' in settings.SAML2_AUTH: - return settings.SAML2_AUTH['ASSERTION_URL'] - return '{scheme}://{host}'.format( - scheme='https' if r.is_secure() else 'http', + if "ASSERTION_URL" in settings.SAML2_AUTH: + return settings.SAML2_AUTH["ASSERTION_URL"] + return "{scheme}://{host}".format( + scheme="https" if r.is_secure() else "http", host=r.get_host(), ) def get_reverse(objs): - '''In order to support different django version, I have to do this ''' - if parse_version(get_version()) >= parse_version('2.0'): + """In order to support different django version, I have to do this""" + if parse_version(get_version()) >= parse_version("2.0"): from django.urls import reverse else: - from django.core.urlresolvers import reverse - if objs.__class__.__name__ not in ['list', 'tuple']: + from django.urls import reverse + if objs.__class__.__name__ not in ["list", "tuple"]: objs = [objs] for obj in objs: @@ -66,29 +55,28 @@ def get_reverse(objs): return reverse(obj) except: pass - raise Exception('We got a URL reverse issue: %s. This is a known issue but please still submit a ticket at https://github.com/fangli/django-saml2-auth/issues/new' % str(objs)) + raise Exception( + "We got a URL reverse issue: %s. This is a known issue but please still submit a ticket at https://github.com/fangli/django-saml2-auth/issues/new" + % str(objs) + ) def _get_metadata(): # BEGIN TESORIO CHANGES - if 'METADATA_INLINE' in settings.SAML2_AUTH: + if "METADATA_INLINE" in settings.SAML2_AUTH: # Inline is another option provided by pySAML2 for providing a metadata # The other two options are: file path and auto conf url # There is a PR on django-saml2-auth, for adding this feature: # https://github.com/fangli/django-saml2-auth/pull/67/files - return { - 'inline': [settings.SAML2_AUTH['METADATA_INLINE']] - } - elif 'METADATA_LOCAL_FILE_PATH' in settings.SAML2_AUTH: - # END TESORIO CHANGES - return { - 'local': [settings.SAML2_AUTH['METADATA_LOCAL_FILE_PATH']] - } + return {"inline": [settings.SAML2_AUTH["METADATA_INLINE"]]} + elif "METADATA_LOCAL_FILE_PATH" in settings.SAML2_AUTH: + # END TESORIO CHANGES + return {"local": [settings.SAML2_AUTH["METADATA_LOCAL_FILE_PATH"]]} else: return { - 'remote': [ + "remote": [ { - "url": settings.SAML2_AUTH['METADATA_AUTO_CONF_URL'], + "url": settings.SAML2_AUTH["METADATA_AUTO_CONF_URL"], }, ] } @@ -115,34 +103,34 @@ def _get_saml_client(domain, metadata_conf_url, metadata_conf_raw=None): # We will give priority to the raw XML file if it exist # settings.SAML2_AUTH['METADATA_AUTO_CONF_URL'] = metadata_conf_url if metadata_conf_raw: - metadata = {'inline': [metadata_conf_raw]} - elif 'METADATA_INLINE' in settings.SAML2_AUTH: - metadata = {'inline': [settings.SAML2_AUTH['METADATA_INLINE']]} + metadata = {"inline": [metadata_conf_raw]} + elif "METADATA_INLINE" in settings.SAML2_AUTH: + metadata = {"inline": [settings.SAML2_AUTH["METADATA_INLINE"]]} else: metadata = { - 'remote': [ - {'url': metadata_conf_url}, + "remote": [ + {"url": metadata_conf_url}, ] } # metadata = _get_metadata() -# END TESORIO CHANGES - acs_url = domain + get_reverse([acs, 'acs', 'django_saml2_auth:acs']) + # END TESORIO CHANGES + acs_url = domain + get_reverse([acs, "acs", "django_saml2_auth:acs"]) saml_settings = { - 'metadata': metadata, - 'service': { - 'sp': { - 'endpoints': { - 'assertion_consumer_service': [ + "metadata": metadata, + "service": { + "sp": { + "endpoints": { + "assertion_consumer_service": [ (acs_url, BINDING_HTTP_REDIRECT), - (acs_url, BINDING_HTTP_POST) + (acs_url, BINDING_HTTP_POST), ], }, - 'allow_unsolicited': True, - 'authn_requests_signed': False, - 'logout_requests_signed': True, - 'want_assertions_signed': True, - 'want_response_signed': False, + "allow_unsolicited": True, + "authn_requests_signed": False, + "logout_requests_signed": True, + "want_assertions_signed": True, + "want_response_signed": False, }, }, } @@ -152,11 +140,13 @@ def _get_saml_client(domain, metadata_conf_url, metadata_conf_raw=None): # saml_settings['entityid'] = settings.SAML2_AUTH['ENTITY_ID'] # # pysaml2>4.5 requires EntityId to be set - saml_settings['entityid'] = acs_url + saml_settings["entityid"] = acs_url # END TESORIO CHANGES - if 'NAME_ID_FORMAT' in settings.SAML2_AUTH: - saml_settings['service']['sp']['name_id_format'] = settings.SAML2_AUTH['NAME_ID_FORMAT'] + if "NAME_ID_FORMAT" in settings.SAML2_AUTH: + saml_settings["service"]["sp"]["name_id_format"] = settings.SAML2_AUTH[ + "NAME_ID_FORMAT" + ] spConfig = Saml2Config() spConfig.load(saml_settings) @@ -168,27 +158,38 @@ def _get_saml_client(domain, metadata_conf_url, metadata_conf_raw=None): @login_required def welcome(r): try: - return render(r, 'django_saml2_auth/welcome.html', {'user': r.user}) + return render(r, "django_saml2_auth/welcome.html", {"user": r.user}) except TemplateDoesNotExist: - return HttpResponseRedirect(settings.SAML2_AUTH.get('DEFAULT_NEXT_URL', get_reverse('admin:index'))) + return HttpResponseRedirect( + settings.SAML2_AUTH.get("DEFAULT_NEXT_URL", get_reverse("admin:index")) + ) def denied(r): - return render(r, 'django_saml2_auth/denied.html') + return render(r, "django_saml2_auth/denied.html") def _create_new_user(username, email, firstname, lastname): user = User.objects.create_user(username, email) user.first_name = firstname user.last_name = lastname - groups = [Group.objects.get(name=x) for x in settings.SAML2_AUTH.get('NEW_USER_PROFILE', {}).get('USER_GROUPS', [])] - if parse_version(get_version()) >= parse_version('2.0'): + groups = [ + Group.objects.get(name=x) + for x in settings.SAML2_AUTH.get("NEW_USER_PROFILE", {}).get("USER_GROUPS", []) + ] + if parse_version(get_version()) >= parse_version("2.0"): user.groups.set(groups) else: user.groups = groups - user.is_active = settings.SAML2_AUTH.get('NEW_USER_PROFILE', {}).get('ACTIVE_STATUS', True) - user.is_staff = settings.SAML2_AUTH.get('NEW_USER_PROFILE', {}).get('STAFF_STATUS', True) - user.is_superuser = settings.SAML2_AUTH.get('NEW_USER_PROFILE', {}).get('SUPERUSER_STATUS', False) + user.is_active = settings.SAML2_AUTH.get("NEW_USER_PROFILE", {}).get( + "ACTIVE_STATUS", True + ) + user.is_staff = settings.SAML2_AUTH.get("NEW_USER_PROFILE", {}).get( + "STAFF_STATUS", True + ) + user.is_superuser = settings.SAML2_AUTH.get("NEW_USER_PROFILE", {}).get( + "SUPERUSER_STATUS", False + ) user.save() return user @@ -197,61 +198,83 @@ def _create_new_user(username, email, firstname, lastname): def acs(r): # BEGIN TESORIO CHANGES # saml_client = _get_saml_client(get_current_domain(r)) - saml_metadata_conf_url = r.session.get('saml_metadata_conf_url') - saml_metadata_conf_raw = r.session.get('saml_metadata_conf_raw') + saml_metadata_conf_url = r.session.get("saml_metadata_conf_url") + saml_metadata_conf_raw = r.session.get("saml_metadata_conf_raw") if not saml_metadata_conf_url and not saml_metadata_conf_raw: logger.info("No saml_metadata_conf found", extra={"session": dict(r.session)}) - return HttpResponseRedirect(get_reverse('sso_login')) + return HttpResponseRedirect(get_reverse("sso_login")) - saml_client = _get_saml_client(get_current_domain(r), saml_metadata_conf_url, saml_metadata_conf_raw) + saml_client = _get_saml_client( + get_current_domain(r), saml_metadata_conf_url, saml_metadata_conf_raw + ) # END TESORIO CHANGES - resp = r.POST.get('SAMLResponse', None) - next_url = r.session.get('login_next_url', settings.SAML2_AUTH.get('DEFAULT_NEXT_URL', get_reverse('admin:index'))) + resp = r.POST.get("SAMLResponse", None) + next_url = r.session.get( + "login_next_url", + settings.SAML2_AUTH.get("DEFAULT_NEXT_URL", get_reverse("admin:index")), + ) if not resp: - return HttpResponseRedirect(get_reverse([denied, 'denied', 'django_saml2_auth:denied'])) + return HttpResponseRedirect( + get_reverse([denied, "denied", "django_saml2_auth:denied"]) + ) authn_response = saml_client.parse_authn_request_response( - resp, entity.BINDING_HTTP_POST) + resp, entity.BINDING_HTTP_POST + ) if authn_response is None: - return HttpResponseRedirect(get_reverse([denied, 'denied', 'django_saml2_auth:denied'])) + return HttpResponseRedirect( + get_reverse([denied, "denied", "django_saml2_auth:denied"]) + ) user_identity = authn_response.get_identity() if user_identity is None: - return HttpResponseRedirect(get_reverse([denied, 'denied', 'django_saml2_auth:denied'])) - - user_email = user_identity[settings.SAML2_AUTH.get('ATTRIBUTES_MAP', {}).get('email', 'Email')][0] - user_name = user_identity[settings.SAML2_AUTH.get('ATTRIBUTES_MAP', {}).get('username', 'UserName')][0] - user_first_name = user_identity[settings.SAML2_AUTH.get('ATTRIBUTES_MAP', {}).get('first_name', 'FirstName')][0] - user_last_name = user_identity[settings.SAML2_AUTH.get('ATTRIBUTES_MAP', {}).get('last_name', 'LastName')][0] + return HttpResponseRedirect( + get_reverse([denied, "denied", "django_saml2_auth:denied"]) + ) + + user_email = user_identity[ + settings.SAML2_AUTH.get("ATTRIBUTES_MAP", {}).get("email", "Email") + ][0] + user_name = user_identity[ + settings.SAML2_AUTH.get("ATTRIBUTES_MAP", {}).get("username", "UserName") + ][0] + user_first_name = user_identity[ + settings.SAML2_AUTH.get("ATTRIBUTES_MAP", {}).get("first_name", "FirstName") + ][0] + user_last_name = user_identity[ + settings.SAML2_AUTH.get("ATTRIBUTES_MAP", {}).get("last_name", "LastName") + ][0] try: # BEGIN TESORIO CHANGES # target_user = User.objects.get(username=user_name) target_user = User.objects.get(email__iexact=user_email) # END TESORIO CHANGES - if settings.SAML2_AUTH.get('TRIGGER', {}).get('BEFORE_LOGIN', None): - import_string(settings.SAML2_AUTH['TRIGGER']['BEFORE_LOGIN'])(user_identity) + if settings.SAML2_AUTH.get("TRIGGER", {}).get("BEFORE_LOGIN", None): + import_string(settings.SAML2_AUTH["TRIGGER"]["BEFORE_LOGIN"])(user_identity) except User.DoesNotExist: # BEGIN TESORIO CHANGES # new_user_should_be_created = settings.SAML2_AUTH.get('CREATE_USER', True) - # if new_user_should_be_created: + # if new_user_should_be_created: # target_user = _create_new_user(user_name, user_email, user_first_name, user_last_name) # if settings.SAML2_AUTH.get('TRIGGER', {}).get('CREATE_USER', None): # import_string(settings.SAML2_AUTH['TRIGGER']['CREATE_USER'])(user_identity) # is_new_user = True # else: # return HttpResponseRedirect(get_reverse([denied, 'denied', 'django_saml2_auth:denied'])) - logger.warning("SSO user was not found: {}".format(user_email)) - return HttpResponseRedirect('/login/?sso_login_no_user=true') + logger.warning(f"SSO user was not found: {user_email}") + return HttpResponseRedirect("/login/?sso_login_no_user=true") r.session.flush() if target_user.is_active: - target_user.backend = 'django.contrib.auth.backends.ModelBackend' + target_user.backend = "django.contrib.auth.backends.ModelBackend" login(r, target_user) else: - return HttpResponseRedirect(get_reverse([denied, 'denied', 'django_saml2_auth:denied'])) + return HttpResponseRedirect( + get_reverse([denied, "denied", "django_saml2_auth:denied"]) + ) # BEGIN TESORIO CHANGES # if settings.SAML2_AUTH.get('USE_JWT') is True: @@ -277,42 +300,54 @@ def acs(r): def signin(r): try: - import urlparse as _urlparse from urllib import unquote + + import urlparse as _urlparse except: import urllib.parse as _urlparse from urllib.parse import unquote - next_url = r.GET.get('next', settings.SAML2_AUTH.get('DEFAULT_NEXT_URL', get_reverse('admin:index'))) + next_url = r.GET.get( + "next", settings.SAML2_AUTH.get("DEFAULT_NEXT_URL", get_reverse("admin:index")) + ) try: - if 'next=' in unquote(next_url): - next_url = _urlparse.parse_qs(_urlparse.urlparse(unquote(next_url)).query)['next'][0] + if "next=" in unquote(next_url): + next_url = _urlparse.parse_qs(_urlparse.urlparse(unquote(next_url)).query)[ + "next" + ][0] except: - next_url = r.GET.get('next', settings.SAML2_AUTH.get('DEFAULT_NEXT_URL', get_reverse('admin:index'))) + next_url = r.GET.get( + "next", + settings.SAML2_AUTH.get("DEFAULT_NEXT_URL", get_reverse("admin:index")), + ) # Only permit signin requests where the next_url is a safe URL - if parse_version(get_version()) >= parse_version('2.0'): - url_ok = is_safe_url(next_url, None) + if parse_version(get_version()) >= parse_version("2.0"): + url_ok = url_has_allowed_host_and_scheme(next_url, None) else: - url_ok = is_safe_url(next_url) + url_ok = url_has_allowed_host_and_scheme(next_url) if not url_ok: - return HttpResponseRedirect(get_reverse([denied, 'denied', 'django_saml2_auth:denied'])) + return HttpResponseRedirect( + get_reverse([denied, "denied", "django_saml2_auth:denied"]) + ) - r.session['login_next_url'] = next_url + r.session["login_next_url"] = next_url # BEGIN TESORIO CHANGES # saml_client = _get_saml_client(get_current_domain(r)) saml_client = _get_saml_client( - get_current_domain(r), r.session.get('saml_metadata_conf_url'), r.session.get('saml_metadata_conf_raw') + get_current_domain(r), + r.session.get("saml_metadata_conf_url"), + r.session.get("saml_metadata_conf_raw"), ) # END TESORIO CHANGES _, info = saml_client.prepare_for_authenticate() redirect_url = None - for key, value in info['headers']: - if key == 'Location': + for key, value in info["headers"]: + if key == "Location": redirect_url = value break @@ -321,4 +356,4 @@ def signin(r): def signout(r): logout(r) - return render(r, 'django_saml2_auth/signout.html') + return render(r, "django_saml2_auth/signout.html") diff --git a/setup.py b/setup.py index f09920b..44e8059 100644 --- a/setup.py +++ b/setup.py @@ -4,64 +4,43 @@ """ from codecs import open -from setuptools import (setup, find_packages) from os import path +from setuptools import find_packages, setup + here = path.abspath(path.dirname(__file__)) # Get the long description from the README file -with open(path.join(here, 'README.rst'), encoding='utf-8') as f: +with open(path.join(here, "README.rst"), encoding="utf-8") as f: long_description = f.read() setup( - name='django_saml2_auth', - - version='2.3.0', - - description='Django SAML2 Authentication Made Easy. Easily integrate with SAML2 SSO identity providers like Okta', + name="django_saml2_auth", + version="3.0.0", + description="Django SAML2 Authentication Made Easy. Easily integrate with SAML2 SSO identity providers like Okta", long_description=long_description, - - url='https://github.com/fangli/django-saml2-auth', - - author='Fang Li', - author_email='surivlee+djsaml2auth@gmail.com', - - license='Apache 2.0', - + url="https://github.com/fangli/django-saml2-auth", + author="Fang Li", + author_email="surivlee+djsaml2auth@gmail.com", + license="Apache 2.0", classifiers=[ # 3 - Alpha # 4 - Beta # 5 - Production/Stable - 'Development Status :: 5 - Production/Stable', - - 'Intended Audience :: Developers', - 'Topic :: Software Development :: Libraries :: Python Modules', - - 'License :: OSI Approved :: Apache Software License', - - 'Framework :: Django :: 1.5', - 'Framework :: Django :: 1.6', - 'Framework :: Django :: 1.7', - 'Framework :: Django :: 1.8', - 'Framework :: Django :: 1.9', - 'Framework :: Django :: 1.10', - - 'Programming Language :: Python :: 2', - 'Programming Language :: Python :: 2.6', - 'Programming Language :: Python :: 2.7', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.3', - 'Programming Language :: Python :: 3.4', - 'Programming Language :: Python :: 3.5', - 'Programming Language :: Python :: 3.6', + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "Topic :: Software Development :: Libraries :: Python Modules", + "License :: OSI Approved :: Apache Software License", + "Framework :: Django :: 4.2", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.12", ], - - keywords='Django SAML2 Authentication Made Easy, integrate with SAML2 SSO such as Okta easily', - + keywords="Django SAML2 Authentication Made Easy, integrate with SAML2 SSO such as Okta easily", packages=find_packages(), - - install_requires=['pysaml2>=4.5.0', - 'djangorestframework-jwt', - 'django-rest-auth', ], + install_requires=[ + "pysaml2>=4.5.0", + "djangorestframework-jwt", + "django-rest-auth", + ], include_package_data=True, )