├── tests ├── __init__.py ├── data │ ├── __init__.py │ └── configs.py ├── urls.py ├── test_settings.py ├── settings.py ├── test_urls.py ├── mock_certs │ ├── sp.crt │ ├── myprovider.crt │ └── sp.key ├── test_models.py ├── test_auth.py └── test_utils.py ├── example ├── example │ ├── __init__.py │ ├── views.py │ ├── urls.py │ ├── asgi.py │ ├── wsgi.py │ ├── templates │ │ ├── base.html │ │ └── index.html │ └── settings.py ├── gunicorn.conf.py ├── requirements.txt ├── manage.py ├── certs │ ├── sp.crt │ ├── cert.pem │ ├── key.pem │ └── sp.key └── README.md ├── src └── saml2_pro_auth │ ├── migrations │ ├── __init__.py │ └── 0001_initial.py │ ├── __init__.py │ ├── apps.py │ ├── admin.py │ ├── urls.py │ ├── constants.py │ ├── settings.py │ ├── utils.py │ ├── json_field.py │ ├── auth.py │ ├── views.py │ └── models.py ├── setup.py ├── MANIFEST.in ├── requirements-dev.txt ├── pyproject.toml ├── manage.py ├── .pre-commit-config.yaml ├── .github └── workflows │ └── build-and-test.yml ├── tox.ini ├── LICENSE ├── setup.cfg ├── .gitignore └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/data/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/example/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/saml2_pro_auth/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup() 4 | -------------------------------------------------------------------------------- /src/saml2_pro_auth/__init__.py: -------------------------------------------------------------------------------- 1 | default_app_config = "saml2_pro_auth.apps.Saml2ProAuthConfig" 2 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import include, path 2 | 3 | import saml2_pro_auth.urls as saml_urls 4 | 5 | urlpatterns = [ 6 | path("sso/saml/", include((saml_urls))), 7 | ] 8 | -------------------------------------------------------------------------------- /example/gunicorn.conf.py: -------------------------------------------------------------------------------- 1 | from glob import glob 2 | 3 | certfile = "certs/cert.pem" 4 | keyfile = "certs/key.pem" 5 | reload = True 6 | reload_extra_files = glob("*.py") + glob("*.html") 7 | -------------------------------------------------------------------------------- /example/requirements.txt: -------------------------------------------------------------------------------- 1 | # Minimal requirements file 2 | # These should install the rest (Django, python3-saml, xmlsec, etc) 3 | -e .. 4 | gunicorn==20.0.4 5 | whitenoise==5.2.0 6 | django>=2.2.24,<3.0 7 | -------------------------------------------------------------------------------- /src/saml2_pro_auth/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class Saml2ProAuthConfig(AppConfig): 5 | name = "saml2_pro_auth" 6 | verbose_name = "Django SAML2 Pro Authentication" 7 | -------------------------------------------------------------------------------- /src/saml2_pro_auth/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from .models import SamlProvider 4 | 5 | 6 | @admin.register(SamlProvider) 7 | class SamlProviderAdmin(admin.ModelAdmin): 8 | readonly_fields = ("id",) 9 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.md 3 | include Pipfile 4 | include tox.ini 5 | include *.yaml 6 | include manage.py 7 | include requirements*.txt 8 | recursive-include tests *.py 9 | recursive-include tests *.crt 10 | recursive-include tests *.key 11 | recursive-exclude example * 12 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | # Minimal requirements file 2 | # These should install the rest (Django, python3-saml, xmlsec, etc) 3 | -e . 4 | whitenoise 5 | tox 6 | check-manifest 7 | pylint 8 | pylint-common 9 | pylint-django 10 | twine 11 | coverage 12 | pytest 13 | pytest-django 14 | pytest-cov 15 | pre-commit 16 | isort 17 | black==20.8b1 18 | tox-factor 19 | -------------------------------------------------------------------------------- /example/example/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import redirect 2 | from django.views.generic.list import ListView 3 | 4 | from saml2_pro_auth.models import SamlProvider 5 | 6 | 7 | class IndexView(ListView): 8 | 9 | model = SamlProvider 10 | template_name = "index.html" 11 | 12 | 13 | def logout(request): 14 | request.session.flush() 15 | return redirect("index") 16 | -------------------------------------------------------------------------------- /tests/test_settings.py: -------------------------------------------------------------------------------- 1 | from django.test import SimpleTestCase 2 | from django.test.utils import override_settings 3 | 4 | from saml2_pro_auth.settings import app_settings 5 | 6 | 7 | class SettingsTests(SimpleTestCase): 8 | @override_settings(SAML_AUTO_CREATE_USERS=False) 9 | def test_can_override(self): 10 | self.assertEqual(app_settings.SAML_AUTO_CREATE_USERS, False) 11 | -------------------------------------------------------------------------------- /example/example/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.urls import include, path 3 | 4 | import saml2_pro_auth.urls as saml_urls 5 | 6 | from .views import IndexView, logout 7 | 8 | urlpatterns = [ 9 | path("admin/", admin.site.urls), 10 | path("", IndexView.as_view(), name="index"), 11 | path("logout/", logout, name="logout"), 12 | path("", include((saml_urls), namespace="saml")), 13 | ] 14 | -------------------------------------------------------------------------------- /example/example/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for example project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.1/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example.settings") 15 | 16 | application = get_asgi_application() 17 | -------------------------------------------------------------------------------- /example/example/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for example project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.1/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools >= 40.6.0", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [tool.isort] 6 | profile = "black" 7 | include_trailing_comma = true 8 | use_parentheses = true 9 | force_grid_wrap = 0 10 | src_paths = ["src", "tests"] 11 | 12 | [tool.black] 13 | line-length = 88 14 | include = '\.pyi?$' 15 | exclude = ''' 16 | /( 17 | \.eggs 18 | | \.git 19 | | \.hg 20 | | \.mypy_cache 21 | | \.tox 22 | | \.venv 23 | | _build 24 | | buck-out 25 | | build 26 | | dist 27 | | tests/.*/setup.py 28 | )/ 29 | ''' 30 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from .data.configs import MOCK_SAML2_CONFIG 4 | 5 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 6 | BASE_DIR = Path(__file__).resolve().parent.parent 7 | 8 | SECRET_KEY = "TESTSECRET" 9 | 10 | INSTALLED_APPS = [ 11 | "django.contrib.auth", 12 | "django.contrib.contenttypes", 13 | "django.contrib.sessions", 14 | "saml2_pro_auth", 15 | ] 16 | 17 | DATABASES = { 18 | "default": { 19 | "ENGINE": "django.db.backends.sqlite3", 20 | "NAME": str(BASE_DIR / "db.sqlite3"), 21 | } 22 | } 23 | 24 | ALLOWED_HOSTS = ["*"] 25 | 26 | ROOT_URLCONF = "tests.urls" 27 | 28 | SAML_PROVIDERS = MOCK_SAML2_CONFIG 29 | -------------------------------------------------------------------------------- /example/example/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | SAML2 Demo App 11 | 12 | 13 | 14 |
15 |

16 | SAML2 Demo App 17 |

18 | 19 | {% block content %}{% endblock %} 20 |
21 | 22 | 23 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | """Run administrative tasks.""" 9 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.settings") 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == "__main__": 22 | main() 23 | -------------------------------------------------------------------------------- /example/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | """Run administrative tasks.""" 9 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example.settings") 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == "__main__": 22 | main() 23 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | fail_fast: true 2 | repos: 3 | - repo: local 4 | hooks: 5 | - id: check-manifest 6 | name: check-manifest 7 | entry: check-manifest 8 | language: system 9 | pass_filenames: false 10 | files: ^MANIFEST\.in$ 11 | 12 | - repo: https://github.com/psf/black 13 | rev: stable 14 | hooks: 15 | - id: black 16 | 17 | - repo: https://github.com/timothycrosley/isort 18 | rev: 5.4.0 19 | hooks: 20 | - id: isort 21 | additional_dependencies: [toml] 22 | exclude: ^.*/?setup\.py$ 23 | args: [--filter-files] 24 | 25 | - repo: https://github.com/pre-commit/pre-commit-hooks 26 | rev: v3.2.0 27 | hooks: 28 | - id: end-of-file-fixer 29 | - id: trailing-whitespace 30 | -------------------------------------------------------------------------------- /.github/workflows/build-and-test.yml: -------------------------------------------------------------------------------- 1 | name: build-and-test 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | python: [3.6, 3.7, 3.8, 3.9] 16 | 17 | steps: 18 | - uses: actions/checkout@v2 19 | - name: Install shared library dependencies 20 | run: sudo apt-get install libxml2-dev libxmlsec1-dev libxmlsec1-openssl 21 | - name: Setup Python 22 | uses: actions/setup-python@v2 23 | with: 24 | python-version: ${{ matrix.python }} 25 | - name: Upgrade packaging tools 26 | run: pip install --upgrade pip setuptools 27 | - name: Install Tox and any other packages 28 | run: pip install -r requirements-dev.txt 29 | - name: Run Tox factors for ${{ matrix.python }} 30 | run: TOXFACTOR=clean,py$(sed 's/\.//' <<< "${{ matrix.python }}") tox 31 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = clean,py{36,37,38,39}-django{22,30,31},build 3 | 4 | [testenv] 5 | deps = 6 | pytest 7 | pytest-cov 8 | pytest-django 9 | check-manifest 10 | coverage 11 | django22: Django >=2.2.20,<2.3 12 | django30: Django >=3.0,<3.1 13 | django31: Django >=3.1,<3.2 14 | commands = python -m pytest {posargs} 15 | 16 | [testenv:clean] 17 | allowlist_externals = sh 18 | deps = coverage 19 | skip_install = true 20 | commands = 21 | coverage erase 22 | python setup.py clean --all 23 | sh -c 'rm -rf dist/ src/*.egg-info' 24 | 25 | [testenv:build] 26 | deps = twine 27 | commands = 28 | python setup.py sdist bdist_wheel 29 | twine check dist/* 30 | 31 | [pytest] 32 | addopts = --ds=tests.settings 33 | --cov 34 | --cov-append 35 | --cov-report term-missing 36 | django_find_project = false 37 | 38 | [coverage:run] 39 | source=src,saml2_pro_auth 40 | 41 | [coverage:paths] 42 | source = 43 | src/ 44 | saml2_pro_auth/ 45 | .tox/*/lib/python*/site-packages/ 46 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 MindPoint Group 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tests/test_urls.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from django.urls import resolve, reverse 3 | 4 | import saml2_pro_auth.urls as urls 5 | 6 | 7 | class TestURLS(TestCase): 8 | def test_url_names_with_start_forward_slash(self): 9 | self.assertEqual( 10 | reverse("saml2_pro_auth:acs", kwargs={"provider": "testP"}), 11 | "/sso/saml/testP/acs/", 12 | ) 13 | self.assertEqual( 14 | reverse("saml2_pro_auth:login", kwargs={"provider": "testP"}), 15 | "/sso/saml/testP/login/", 16 | ) 17 | self.assertEqual( 18 | reverse("saml2_pro_auth:metadata", kwargs={"provider": "testP"}), 19 | "/sso/saml/testP/metadata/", 20 | ) 21 | 22 | def test_url_resolving_with_start_forward_slash(self): 23 | self.assertEqual( 24 | resolve("/sso/saml/classProvider/acs/").view_name, "saml2_pro_auth:acs" 25 | ) 26 | self.assertEqual( 27 | resolve("/sso/saml/classProvider/login/").view_name, "saml2_pro_auth:login" 28 | ) 29 | self.assertEqual( 30 | resolve("/sso/saml/classProvider/metadata/").view_name, 31 | "saml2_pro_auth:metadata", 32 | ) 33 | -------------------------------------------------------------------------------- /src/saml2_pro_auth/urls.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from django.urls import path, register_converter 4 | 5 | from .models import SamlProvider 6 | from .settings import app_settings 7 | from .views import AcsView, MetadataView, SsoView 8 | 9 | app_name = "saml2_pro_auth" 10 | 11 | 12 | class ProviderConverter: 13 | regex = "[a-zA-Z0-9-]{4,36}" # pylint: disable=anomalous-backslash-in-string 14 | 15 | def to_python(self, value): 16 | try: 17 | app_settings.SAML_PROVIDERS[value] 18 | except KeyError: 19 | try: 20 | SamlProvider.objects.get(pk=uuid.UUID(value)) 21 | except (SamlProvider.DoesNotExist, ValueError) as err: 22 | raise ValueError from err 23 | 24 | return value 25 | 26 | def to_url(self, value): 27 | return value 28 | 29 | 30 | register_converter(ProviderConverter, "samlp") 31 | 32 | 33 | # Class based views 34 | urlpatterns = [ 35 | path("/acs/", AcsView.as_view(), name="acs"), 36 | path("/login/", SsoView.as_view(), name="login"), 37 | path( 38 | "/metadata/", 39 | MetadataView.as_view(), 40 | name="metadata", 41 | ), 42 | ] 43 | -------------------------------------------------------------------------------- /example/certs/sp.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDVDCCAjwCCQCON14ZZ8fcaTANBgkqhkiG9w0BAQsFADBsMQswCQYDVQQGEwJV 3 | UzEPMA0GA1UECAwGRmFpbGVkMRAwDgYDVQQHDAdOb3doZXJlMRgwFgYDVQQKDA9F 4 | eGFtcGxlIENvbXBhbnkxDDAKBgNVBAsMA09yZzESMBAGA1UEAwwJbG9jYWxob3N0 5 | MB4XDTIwMTEwNDE1NDIzNFoXDTMwMTEwMjE1NDIzNFowbDELMAkGA1UEBhMCVVMx 6 | DzANBgNVBAgMBkZhaWxlZDEQMA4GA1UEBwwHTm93aGVyZTEYMBYGA1UECgwPRXhh 7 | bXBsZSBDb21wYW55MQwwCgYDVQQLDANPcmcxEjAQBgNVBAMMCWxvY2FsaG9zdDCC 8 | ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMDT5jl1OUazXidl2N8FD7tK 9 | xecj5GsEk9+Vs8womsSFNthxXpcw4dIIREL6rZzHmqk7JW1isy3p/uC4zC2h8axo 10 | 6B0hxi4lpFaCO3EqDsCS2h7LJrP/03AR9D9wW3evKfSb5d3BrB/nJJTUrmAdrcM7 11 | 0ec9uCMD1ewV4CpTzvLeflG4KLTCiWOPcde3+OKJtrY2FTxz16RyCbmiO3HsIiru 12 | IvsRLbL5vXMixIRBsrQLVs6ZpF+i7nLkjYEAzX0dowG+WWQrTGT/NTcbLD5oOSVy 13 | asUS5QM64y4QUpA0Vq8xSbrluUiB0+JQcFM9+5CMhU75PsYwK+hanEvHbL9h3+EC 14 | AwEAATANBgkqhkiG9w0BAQsFAAOCAQEAATqmjSvG7+mrY1Albq08gcg1/75A0ra9 15 | VyuhOZUs7BJLjcqCGz43sj1tBqAtVQQ5NIoJ1+G2YfuKrzJ7bYb2RZcuNiAdiDGT 16 | 4hjTmjG0+lDxYnqxYX51I8K/nqngQxNpcR8tbgus3KtgCw0jPT+el0Prg9P8oke7 17 | B0CnFQtYVwUSPJ/IHEPDYDXhiBWeu+kIUu4g5IP8ybLYXs9uVdb/7jxSJZFeBMYk 18 | 1x29W8s8b/0BRHNAgubgKDC61T7aYBjiAGHLw6jPiNdG0x8e2bLqewlQTvaoFoHp 19 | EAoXFXnyPlZ6FKWrJfVChJQwUaGNW9dAdlma67gJA0FeJPELZ8Eo9g== 20 | -----END CERTIFICATE----- 21 | -------------------------------------------------------------------------------- /example/certs/cert.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDVDCCAjwCCQCON14ZZ8fcaTANBgkqhkiG9w0BAQsFADBsMQswCQYDVQQGEwJV 3 | UzEPMA0GA1UECAwGRmFpbGVkMRAwDgYDVQQHDAdOb3doZXJlMRgwFgYDVQQKDA9F 4 | eGFtcGxlIENvbXBhbnkxDDAKBgNVBAsMA09yZzESMBAGA1UEAwwJbG9jYWxob3N0 5 | MB4XDTIwMTEwNDE1NDIzNFoXDTMwMTEwMjE1NDIzNFowbDELMAkGA1UEBhMCVVMx 6 | DzANBgNVBAgMBkZhaWxlZDEQMA4GA1UEBwwHTm93aGVyZTEYMBYGA1UECgwPRXhh 7 | bXBsZSBDb21wYW55MQwwCgYDVQQLDANPcmcxEjAQBgNVBAMMCWxvY2FsaG9zdDCC 8 | ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMDT5jl1OUazXidl2N8FD7tK 9 | xecj5GsEk9+Vs8womsSFNthxXpcw4dIIREL6rZzHmqk7JW1isy3p/uC4zC2h8axo 10 | 6B0hxi4lpFaCO3EqDsCS2h7LJrP/03AR9D9wW3evKfSb5d3BrB/nJJTUrmAdrcM7 11 | 0ec9uCMD1ewV4CpTzvLeflG4KLTCiWOPcde3+OKJtrY2FTxz16RyCbmiO3HsIiru 12 | IvsRLbL5vXMixIRBsrQLVs6ZpF+i7nLkjYEAzX0dowG+WWQrTGT/NTcbLD5oOSVy 13 | asUS5QM64y4QUpA0Vq8xSbrluUiB0+JQcFM9+5CMhU75PsYwK+hanEvHbL9h3+EC 14 | AwEAATANBgkqhkiG9w0BAQsFAAOCAQEAATqmjSvG7+mrY1Albq08gcg1/75A0ra9 15 | VyuhOZUs7BJLjcqCGz43sj1tBqAtVQQ5NIoJ1+G2YfuKrzJ7bYb2RZcuNiAdiDGT 16 | 4hjTmjG0+lDxYnqxYX51I8K/nqngQxNpcR8tbgus3KtgCw0jPT+el0Prg9P8oke7 17 | B0CnFQtYVwUSPJ/IHEPDYDXhiBWeu+kIUu4g5IP8ybLYXs9uVdb/7jxSJZFeBMYk 18 | 1x29W8s8b/0BRHNAgubgKDC61T7aYBjiAGHLw6jPiNdG0x8e2bLqewlQTvaoFoHp 19 | EAoXFXnyPlZ6FKWrJfVChJQwUaGNW9dAdlma67gJA0FeJPELZ8Eo9g== 20 | -----END CERTIFICATE----- 21 | -------------------------------------------------------------------------------- /tests/mock_certs/sp.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDXTCCAkWgAwIBAgIJAPOf8/5vIgHcMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV 3 | BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX 4 | aWRnaXRzIFB0eSBMdGQwHhcNMTcxMDMwMDk0NDA3WhcNMTgxMDMwMDk0NDA3WjBF 5 | MQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50 6 | ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB 7 | CgKCAQEArG8O2H9Z0jULNZcxEHgNsVW0qRbWYalt+tSjK5JaFgZA8py/7zICJQzz 8 | ZuBkRuuPhW/ZJKn+BiK54Xb1Z0stR6ZFu4AFnNVVOusvq2Hv7VhGdcGDc1CMDYOR 9 | 1RN5SAHfN2P3YlkhZy2gNFbeCFPtfpBFJgcJHgcAksP2o9z4LNm81GKn1E4cvV4i 10 | Gw4gm6kL7Vcd/sbk38hJWXeXIQinQevDl4hh0iAmFMUykR6YEFz3eYoelIewMb83 11 | 93qrvlDSkm+m0WrhImFEpsAnDwdKyDOyUOtMA2ESDg09X6Xy1n/juBvScpvpnbCv 12 | 3+IL5TWiAVj4Tnf5CdfUn8gOunQ9eQIDAQABo1AwTjAdBgNVHQ4EFgQUsd6Qrhks 13 | 5jn0g5dn/Ha//tCEZwMwHwYDVR0jBBgwFoAUsd6Qrhks5jn0g5dn/Ha//tCEZwMw 14 | DAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAYx50jpDDz5lH4OpDOU6Y 15 | HEHjHL/xYTXJ6TT18SX6Dpt1jjPpxmrtaDwRfQYERb2YG9go/IcoIc/ZQtwqwkj6 16 | fRpgcTAizPSKQlsWM88ooNLCLTDpKUlVgU4JzPzHkQmeBtgLg0Oxj7uxcLiCKGq8 17 | HQjxNh5x4TdDVofmdMcNKvMtj7Ly/t5fr37rG1tt60gnIYT6PQ89cw+Di2N+Cb+W 18 | 4M+CdjItIloTyDsnHD6mltZmw5iRS3W1YcJmaQnw02AaGSgjdjvsltfphffz4Kcv 19 | NZm6xEbumPsHx6by7JgNMguax5En1sj1mpmNd5mZoXzjwxfBR2hCSExRmCHmuJXq 20 | ew== 21 | -----END CERTIFICATE----- 22 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | # Example Django App 2 | 3 | SAML2 Authentication Django Demo 4 | 5 | --- 6 | 7 | ## Installation 8 | 9 | Setup a virtual environment and activate it: 10 | 11 | ```sh 12 | python -m venv venv 13 | source venv/bin/activate 14 | ``` 15 | 16 | Install the dependencies. This will install an editable version of the django-saml-pro-auth package one directory up. 17 | 18 | ```sh 19 | pip install -r requirements.txt 20 | ``` 21 | 22 | Update the values in `example/settings.py` and be sure to configure the `SAML_PROVIDERS` and `SAML_USERS_MAP`. Two examples have been included for reference. 23 | 24 | Configure your IdP with the relevant settings. 25 | 26 | **NOTE:** This will work with `http` or `https://127.0.0.1/` should work fine for testing in your IdP. 27 | 28 | Migrate and create the cache table if you are using the database cache backend. 29 | 30 | ```sh 31 | python manage.py migrate 32 | python manage.py createcachetable 33 | ``` 34 | 35 | Create a user in the database with an email or username attribute that matches an attribute in your IdP so you can test the full login flow. 36 | 37 | Run the server. You don't need to use `gunicorn` but this allows us to run the server with self-signed certificates so that some IdP's (GSuite and others) don't complain. 38 | 39 | ```sh 40 | gunicorn example.wsgi --conf gunicorn.conf.py 41 | ``` 42 | -------------------------------------------------------------------------------- /tests/mock_certs/myprovider.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDkTCCAnmgAwIBAgIQDUYw4DJOF71bP3U8A2c1OjANBgkqhkiG9w0BAQsFADAk 3 | MSIwIAYDVQQDDBlDZW50cmlmeSBDdXN0b21lciBBQUswMzMzMB4XDTE3MDcxMzE3 4 | NTUxN1oXDTM5MDEwMTAwMDAwMFowRDFCMEAGA1UEAww5Q2VudHJpZnkgQ3VzdG9t 5 | ZXIgQUFLMDMzMyBBcHBsaWNhdGlvbiBTaWduaW5nIENlcnRpZmljYXRlMIIBIjAN 6 | BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvE1H2qkhHXjHAlAdWsQTB1gR3tzL 7 | dWfeLHpxvg8DOa3qw3jaZA+1hIbStaxxXACOvYeqeZG3qGWCmfzq6XldpvfjbAs3 8 | TieGiaxs3bxA7w6zvNsoOk0k5oso+Kvlb9OJ76WGX/97lf3HmKnCvNJgdGJ6puzv 9 | BdBL+mbL4ynI7ee82w6oF1QXc+royP1TyZoCwRDL+GIRYl2Uz9REM1S0C3VfE1Qk 10 | Qc9XnYZkPtv2sN5iX41ayBRODkNdKHNzf8cmDo65Gm46U7r8KYlQTsZoKRvgVVO2 11 | 35B8pZ1+sHEWKo8M81afQhnAHhzAH/w78CkFy9U2nENZIX/I8Saazzu0owIDAQAB 12 | o4GeMIGbMBMGCisGAQQBgqZwAQkEBQwDMS4wMBcGCisGAQQBgqZwAQMECQwHQUFL 13 | MDMzMzAfBgNVHSMEGDAWgBS6svSuXvYzeUtzan8NS1PGi6sTHzAdBgNVHQ4EFgQU 14 | af3mnYCTd1mzB8PCsnV1lFd7ar4wDgYDVR0PAQH/BAQDAgWgMBsGCisGAQQBgqZw 15 | AQQEDQwLQXBwbGljYXRpb24wDQYJKoZIhvcNAQELBQADggEBAHJtugNj2rhq3Vj0 16 | r+35eybBKBcIbK8qIfJExDarXjFlb55Tz0724eeOikgT1poWVen/VSb/tSH0Ltvi 17 | mgP8zXIerXOdfYTaFGqjis4l0lBpQLBDfXpRUmJZHTUINE5OPsElA9b5wmJ1CgFF 18 | 9/mYOULQdZBL3tZwyrxKTJ/6zuzy3jJNCqG3O+NvTuAm3SZdYmD/k7WmHVlZt6op 19 | hdFCNVfpjgmQpxpwUjBE/DzjvQ/0A84I4IVdIRtoE9Ytk9DS+MWg0mxsBiwSSBHg 20 | CII69N0sgim+fnPnxLlaHDEI+dwrlGRjcm+9vAsu1xpTlgKYMpUOEZW8fhHmYDcE 21 | 3AGY4KA= 22 | -----END CERTIFICATE----- 23 | -------------------------------------------------------------------------------- /src/saml2_pro_auth/constants.py: -------------------------------------------------------------------------------- 1 | # NameID Formats from the SAML Core 2.0 spec (8.3 Name Identifier Format Identifiers) 2 | UNSPECIFIED = "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified" 3 | EMAIL = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress" 4 | PERSISTENT = "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent" 5 | TRANSIENT = "urn:oasis:names:tc:SAML:2.0:nameid-format:transient" 6 | X509SUBJECT = "urn:oasis:names:tc:SAML:1.1:nameid-format:X509SubjectName" 7 | WINDOWS = "urn:oasis:names:tc:SAML:1.1:nameid-format:WindowsDomainQualifiedName" 8 | KERBEROS = "urn:oasis:names:tc:SAML:2.0:nameid-format:kerberos" 9 | ENTITY = "urn:oasis:names:tc:SAML:2.0:nameid-format:entity" 10 | 11 | # Tuples of NameID Formats strings and friendly names for use in choices. 12 | NAMEID_FORMAT_CHOICES = [ 13 | (UNSPECIFIED, "Unspecified"), 14 | (EMAIL, "EmailAddress"), 15 | (PERSISTENT, "Persistent"), 16 | (TRANSIENT, "Transient"), 17 | (X509SUBJECT, "X509SubjectName"), 18 | (WINDOWS, "WindowsDomainQualifiedName"), 19 | (KERBEROS, "Kerberos"), 20 | (ENTITY, "Entity"), 21 | ] 22 | 23 | # Protocol Bindings 24 | HTTP_POST_BINDING = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" 25 | HTTP_REDIRECT_BINDING = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" 26 | SAML_PROTOCOL_BINDINGS = [ 27 | (HTTP_POST_BINDING, "HTTP-POST"), 28 | (HTTP_REDIRECT_BINDING, "HTTP-Redirect"), 29 | ] 30 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = django-saml2-pro-auth 3 | version = 1.0.1 4 | description = SAML2 authentication backend for Django wrapping OneLogins python-saml package https://github.com/onelogin/python3-saml 5 | long_description = file: README.md 6 | long_description_content_type=text/markdown 7 | author = Julie Davila 8 | author_email = julied@zibasec.io 9 | url = https://github.com/zibasec/django-saml2-pro-auth 10 | keywords = 11 | sso 12 | single-signon 13 | authentication 14 | saml 15 | saml2 16 | django 17 | development 18 | okta 19 | onelogin 20 | license = MIT License 21 | license_file = LICENSE 22 | classifiers = 23 | Development Status :: 4 - Beta 24 | Intended Audience :: Developers 25 | Framework :: Django 26 | Framework :: Django :: 2.2.20 27 | Framework :: Django :: 3.0.14 28 | Framework :: Django :: 3.1.8 29 | Topic :: Security 30 | Topic :: System :: Systems Administration :: Authentication/Directory 31 | License :: OSI Approved :: MIT License 32 | Programming Language :: Python 33 | Programming Language :: Python :: 3 34 | Programming Language :: Python :: 3.5 35 | Programming Language :: Python :: 3.6 36 | Programming Language :: Python :: 3.7 37 | Programming Language :: Python :: 3.8 38 | Programming Language :: Python :: 3.9 39 | 40 | [options] 41 | package_dir= 42 | =src 43 | include_package_data = True 44 | packages=find: 45 | install_requires = 46 | Django>=2.2.20 47 | python3-saml>=1.9.0 48 | python_requires = >=3.6 49 | 50 | [options.extras_require] 51 | dev = check-manifest 52 | 53 | [options.packages.find] 54 | where=src 55 | 56 | [bdist_wheel] 57 | universal=0 58 | -------------------------------------------------------------------------------- /tests/mock_certs/sp.key: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCsbw7Yf1nSNQs1 3 | lzEQeA2xVbSpFtZhqW361KMrkloWBkDynL/vMgIlDPNm4GRG64+Fb9kkqf4GIrnh 4 | dvVnSy1HpkW7gAWc1VU66y+rYe/tWEZ1wYNzUIwNg5HVE3lIAd83Y/diWSFnLaA0 5 | Vt4IU+1+kEUmBwkeBwCSw/aj3Pgs2bzUYqfUThy9XiIbDiCbqQvtVx3+xuTfyElZ 6 | d5chCKdB68OXiGHSICYUxTKRHpgQXPd5ih6Uh7Axvzf3equ+UNKSb6bRauEiYUSm 7 | wCcPB0rIM7JQ60wDYRIODT1fpfLWf+O4G9Jym+mdsK/f4gvlNaIBWPhOd/kJ19Sf 8 | yA66dD15AgMBAAECggEASxYJRukdudVzTiDKMru0arZBdygErn5S79jMPNr7839B 9 | pOPxCD2khXHAQ9oOkHtfAeIUkkyb2xwgCTf94L7xtrOGxgEXcJXNCV0EYwLAsX+x 10 | HvHnRb2bXv8d78UNGPgHtV0IioD5dK5/hosVIFc7odM86TRDGho+e6ptqik8wHpR 11 | Al+fFRW6c1NQedVE0H+AJX82YShpPyjFGr+VJ/nnwdOsbQ8eFRA97sUnQa4PDUkk 12 | XWHNDbuy2OBVqGALU84YjrBEUtLnRbnWZyeGR75LY6YTyh04SO8LgW+wrtHZIkn8 13 | 20JoEWt1uJhLRmFg/KBh3pUSg08o/Ve8+e9Jp40cSQKBgQDbcxE8lZOKhj3QUqpw 14 | zQi5w8EStMIzse/5WI/g993dNp5DPPTz13nIHrpU5g0wcLCIuLYjd26tRatgotWE 15 | zkowshYoV0xk1lfpzNqWAvDG2hLbeNsKuIBfZXN3QXU6MXDfX6cGrJ96fqTxydNb 16 | jmw0oitE046XN12pDCAzjujYBwKBgQDJJ1O8+lSZRk5ykZBjz6n8QIU8CA9mNSD9 17 | Nc6TvGrSsbFOCmttzE4tmd6tT05TaPzY58bFvk8GytJ+yRW3b4mm8lZGBl7pKEUr 18 | Q0PMOSHzmG3Ppd+dCpVOT3DwaxANF36CjJlwAbvarVFa0Acfl07r9GvSJ+I4kkKI 19 | R+N1tM3efwKBgAcebnq2p3ig7jRp2hmarSPJk+PVdU9UAGLoWpKDt/DLKssnmRKn 20 | 9M1nIchLRjZCEZf91frEjxST5AFYhvCt+H4n6MwaOOI0idmNybGAGut4e5AfFYv9 21 | fDyb/+joeLMQk4bLhZGT3ACPRy6Iy5B2yE/Uyu6Kpl+FbkZjnE/P3QHVAoGBALop 22 | wHD8SMFV9RJJL5WAQnSnjeciGoZgEzjkzFukHEUEmPB96jDCzXOcnR5OcFH3r1Jb 23 | J3YpC+BgY3FdTtDm1EGCtF+4U6x7TZCdfyiJk6drYe20OQCRI99G3GJU45UKMlZG 24 | I1cq852Nm+Zs8rrFARCUtBjaOp5almKkDZoJDCKnAoGBAIXVYCMNiyjHlTJAKShA 25 | 6xs2eOFRR7VoZZphqOZk6HzsON+Ef62QX2WellUQrb2OawvrCxlHyg0p1d8XGC7h 26 | VOzMvSSLwzWPxwgMpow+QQH9JEoD9rESpYUWDooWRN1GHiKtj02mnAERL7UNUnXO 27 | munDO3CLpmTFNWPYmfWd1DMo 28 | -----END PRIVATE KEY----- 29 | -------------------------------------------------------------------------------- /example/certs/key.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDA0+Y5dTlGs14n 3 | ZdjfBQ+7SsXnI+RrBJPflbPMKJrEhTbYcV6XMOHSCERC+q2cx5qpOyVtYrMt6f7g 4 | uMwtofGsaOgdIcYuJaRWgjtxKg7Aktoeyyaz/9NwEfQ/cFt3ryn0m+Xdwawf5ySU 5 | 1K5gHa3DO9HnPbgjA9XsFeAqU87y3n5RuCi0woljj3HXt/jiiba2NhU8c9ekcgm5 6 | ojtx7CIq7iL7ES2y+b1zIsSEQbK0C1bOmaRfou5y5I2BAM19HaMBvllkK0xk/zU3 7 | Gyw+aDklcmrFEuUDOuMuEFKQNFavMUm65blIgdPiUHBTPfuQjIVO+T7GMCvoWpxL 8 | x2y/Yd/hAgMBAAECggEAX82n9+ou79qUL/zhGEUagNJwqxthFG6szYxCTW/rHTKW 9 | gkkpVvLZb5Hd4G1NrrRZOjWBrew429oDYUFPaGiqex+QG1E5dpoLIVQJFntv4uvT 10 | ZTNH4mx7b5XsBUzclQU5UifWuCvOSfd8bFmS3XxBZduluT8n6nWwZmCkBEOpcW6O 11 | 1vyDFwHdRlFPsBgVTltR7yrWDvtxZ8dnit0E8vmcC+m7xpVXu/VKaG6mUPUY+Qi0 12 | FdY3VfAXq7NL7UK7UczK6Cq6g+poAKWSSv27m6R1EzMUe3njdFWpuLO2nwoRX+5Y 13 | BC+5yl86lCF4r/5hqgxX/S9qpiiT1ou+IFecV8OAwQKBgQDn9VikdyrK/W8G71Pj 14 | JZZXYAdPPNKMT21LEHn7mfZ1z7UXWGfIVIHdpldkX02M1TwNcapobmNhuVGJfm1t 15 | 12ewVIpAwnHv97gqNA4OJWqIZ0WNSyrADIhMZMZ2ja1PVEzpGtHNgjdQz/CcIvXc 16 | GR/FqjqMXa9FnJrTed5nsKKZSQKBgQDU0EhCejPaSt8rYNdVUONc3TWYlIscryLc 17 | uT0h/bGUMRldBaU8APifRwDlMunlblYLDRV/eigZlck0Y7Mf1XLs3+bQSXC/KNc4 18 | Syer13eYc5PqxEmw2nq4Ustcy0pSWi3JcojIz41zbFk74w0WrluRyYdfRObfNguF 19 | 9Z1JNjdp2QKBgQC5ePm3ED5cb4c+oVGPWDe9h+BwYG7umHgIxJT1NKfYjgv8LclW 20 | axQoWmCYtoe466wIB/I9bL70ngzvhvMTGGElooOlwpT+TzKoNFVkxFBJ32HC1+7H 21 | /31gsFfs5d5Fh1+0KKjHza5TZOG8x0uWAVThZftIz4RdghperJzEhn2NWQKBgQCd 22 | lbG8UCMvZLvGskohqekCbedvGae9UM6e5SgokGQ7mPPwFusY+JshzoESN1ZNhxt9 23 | yW1+3OTutSStf5o3W2ZjkxSmbYtocgSUccppi/7KS+NfN4RdyhqPfPeLuhlJy+8V 24 | uZMiJ9bVfojBOMsLXYb++F0epbXT2YBE7PBQMy5rWQKBgQDOh98JYHd4hsk1SGHP 25 | EEdpPhgzYRvIoPwUeX6E3CAFcUBsJntfdr+q7xHwFcN5FBZI6R+w4Bc6nKOpCb+H 26 | nguK432Mg/jhvdhCM7GO+T3uuUyX6K5U1mTyoRr1qBz0L5cF9klHRi7hoLAq0+uK 27 | X9agVp9WjZGqhhP+DhXjl1xe1w== 28 | -----END PRIVATE KEY----- 29 | -------------------------------------------------------------------------------- /example/certs/sp.key: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDA0+Y5dTlGs14n 3 | ZdjfBQ+7SsXnI+RrBJPflbPMKJrEhTbYcV6XMOHSCERC+q2cx5qpOyVtYrMt6f7g 4 | uMwtofGsaOgdIcYuJaRWgjtxKg7Aktoeyyaz/9NwEfQ/cFt3ryn0m+Xdwawf5ySU 5 | 1K5gHa3DO9HnPbgjA9XsFeAqU87y3n5RuCi0woljj3HXt/jiiba2NhU8c9ekcgm5 6 | ojtx7CIq7iL7ES2y+b1zIsSEQbK0C1bOmaRfou5y5I2BAM19HaMBvllkK0xk/zU3 7 | Gyw+aDklcmrFEuUDOuMuEFKQNFavMUm65blIgdPiUHBTPfuQjIVO+T7GMCvoWpxL 8 | x2y/Yd/hAgMBAAECggEAX82n9+ou79qUL/zhGEUagNJwqxthFG6szYxCTW/rHTKW 9 | gkkpVvLZb5Hd4G1NrrRZOjWBrew429oDYUFPaGiqex+QG1E5dpoLIVQJFntv4uvT 10 | ZTNH4mx7b5XsBUzclQU5UifWuCvOSfd8bFmS3XxBZduluT8n6nWwZmCkBEOpcW6O 11 | 1vyDFwHdRlFPsBgVTltR7yrWDvtxZ8dnit0E8vmcC+m7xpVXu/VKaG6mUPUY+Qi0 12 | FdY3VfAXq7NL7UK7UczK6Cq6g+poAKWSSv27m6R1EzMUe3njdFWpuLO2nwoRX+5Y 13 | BC+5yl86lCF4r/5hqgxX/S9qpiiT1ou+IFecV8OAwQKBgQDn9VikdyrK/W8G71Pj 14 | JZZXYAdPPNKMT21LEHn7mfZ1z7UXWGfIVIHdpldkX02M1TwNcapobmNhuVGJfm1t 15 | 12ewVIpAwnHv97gqNA4OJWqIZ0WNSyrADIhMZMZ2ja1PVEzpGtHNgjdQz/CcIvXc 16 | GR/FqjqMXa9FnJrTed5nsKKZSQKBgQDU0EhCejPaSt8rYNdVUONc3TWYlIscryLc 17 | uT0h/bGUMRldBaU8APifRwDlMunlblYLDRV/eigZlck0Y7Mf1XLs3+bQSXC/KNc4 18 | Syer13eYc5PqxEmw2nq4Ustcy0pSWi3JcojIz41zbFk74w0WrluRyYdfRObfNguF 19 | 9Z1JNjdp2QKBgQC5ePm3ED5cb4c+oVGPWDe9h+BwYG7umHgIxJT1NKfYjgv8LclW 20 | axQoWmCYtoe466wIB/I9bL70ngzvhvMTGGElooOlwpT+TzKoNFVkxFBJ32HC1+7H 21 | /31gsFfs5d5Fh1+0KKjHza5TZOG8x0uWAVThZftIz4RdghperJzEhn2NWQKBgQCd 22 | lbG8UCMvZLvGskohqekCbedvGae9UM6e5SgokGQ7mPPwFusY+JshzoESN1ZNhxt9 23 | yW1+3OTutSStf5o3W2ZjkxSmbYtocgSUccppi/7KS+NfN4RdyhqPfPeLuhlJy+8V 24 | uZMiJ9bVfojBOMsLXYb++F0epbXT2YBE7PBQMy5rWQKBgQDOh98JYHd4hsk1SGHP 25 | EEdpPhgzYRvIoPwUeX6E3CAFcUBsJntfdr+q7xHwFcN5FBZI6R+w4Bc6nKOpCb+H 26 | nguK432Mg/jhvdhCM7GO+T3uuUyX6K5U1mTyoRr1qBz0L5cF9klHRi7hoLAq0+uK 27 | X9agVp9WjZGqhhP+DhXjl1xe1w== 28 | -----END PRIVATE KEY----- 29 | -------------------------------------------------------------------------------- /tests/test_models.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | from saml2_pro_auth.models import SamlProvider 4 | from saml2_pro_auth.settings import PROVIDER_CONFIG_TEMPLATE 5 | 6 | 7 | class SamlProviderModelTest(TestCase): 8 | @classmethod 9 | def setUpTestData(cls): 10 | # Set up non-modified objects used by all test methods 11 | SamlProvider.objects.create( 12 | name="TestProvider", 13 | idp_issuer="FakeIssuer", 14 | idp_x509="NOTACERT", 15 | idp_sso_url="https://some.random.url/idp/", 16 | ) 17 | 18 | def test_object_name_is_name(self): 19 | provider = SamlProvider.objects.get(name="TestProvider") 20 | self.assertEqual(provider.name, str(provider)) 21 | 22 | def test_get_provider_config(self): 23 | provider = SamlProvider.objects.get(name="TestProvider") 24 | config = provider.get_provider_config(PROVIDER_CONFIG_TEMPLATE) 25 | self.assertEqual(config["idp"]["entityId"], provider.idp_issuer) 26 | self.assertEqual( 27 | config["idp"]["singleSignOnService"]["url"], provider.idp_sso_url 28 | ) 29 | self.assertEqual( 30 | config["idp"]["singleSignOnService"]["binding"], provider.idp_sso_binding 31 | ) 32 | self.assertEqual(config["idp"]["x509cert"], provider.idp_x509) 33 | self.assertEqual(config["sp"]["NameIDFormat"], provider.nameidformat) 34 | self.assertEqual( 35 | config["sp"]["assertionConsumerService"]["binding"], provider.sp_acs_binding 36 | ) 37 | self.assertEqual( 38 | config["security"]["wantMessagesSigned"], provider.sec_want_messages_signed 39 | ) 40 | self.assertEqual( 41 | config["security"]["wantAssertionsSigned"], 42 | provider.sec_want_assertions_signed, 43 | ) 44 | self.assertEqual( 45 | config["security"]["wantAssertionsEncrypted"], 46 | provider.sec_want_assertions_encrypted, 47 | ) 48 | -------------------------------------------------------------------------------- /src/saml2_pro_auth/settings.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | PROVIDER_CONFIG_TEMPLATE = { 4 | "strict": True, 5 | "sp": { 6 | "x509cert": "", 7 | "privateKey": "", 8 | }, 9 | # No one actually sets these fields in their metadata 10 | # "organization": { 11 | # "en-US": { 12 | # "name": "", 13 | # "displayname": "", 14 | # "url": "", 15 | # } 16 | # }, 17 | # "contactPerson": { 18 | # "technical": {"givenName": "", "emailAddress": ""}, 19 | # "support": {"givenName": "", "emailAddress": ""}, 20 | # }, 21 | "security": { 22 | "nameIdEncrypted": False, 23 | "authnRequestsSigned": True, 24 | "logoutRequestSigned": True, 25 | "logoutResponseSigned": True, 26 | "signMetadata": True, 27 | "wantMessagesSigned": True, 28 | "wantAssertionsSigned": False, 29 | "wantAssertionsEncrypted": False, 30 | "wantNameId": True, 31 | "wantNameIdEncrypted": False, 32 | "wantAttributeStatement": False, 33 | "signatureAlgorithm": "http://www.w3.org/2000/09/xmldsig#rsa-sha256", 34 | "digestAlgorithm": "http://www.w3.org/2001/04/xmlenc#sha256", 35 | }, 36 | } 37 | 38 | 39 | class Settings: 40 | """ 41 | A simple settings object that wraps Django settings 42 | """ 43 | 44 | @property 45 | def SAML_REDIRECT(self): 46 | return getattr(settings, "SAML_REDIRECT", "/") 47 | 48 | @property 49 | def SAML_USERS_LOOKUP_ATTRIBUTE(self): 50 | return getattr(settings, "SAML_USERS_LOOKUP_ATTRIBUTE", ("username", "NameId")) 51 | 52 | @property 53 | def SAML_USERS_SYNC_ATTRIBUTES(self): 54 | return getattr(settings, "SAML_USERS_SYNC_ATTRIBUTES", False) 55 | 56 | @property 57 | def SAML_USERS_STRICT_MAPPING(self): 58 | return getattr(settings, "SAML_USERS_STRICT_MAPPING", True) 59 | 60 | @property 61 | def SAML_PROVIDERS(self): 62 | return getattr(settings, "SAML_PROVIDERS", dict()) 63 | 64 | @property 65 | def SAML_PROVIDER_CONFIG_TEMPLATE(self): 66 | return getattr( 67 | settings, "SAML_PROVIDER_CONFIG_TEMPLATE", PROVIDER_CONFIG_TEMPLATE 68 | ) 69 | 70 | @property 71 | def SAML_USERS_MAP(self): 72 | return getattr(settings, "SAML_USERS_MAP", dict()) 73 | 74 | @property 75 | def SAML_AUTO_CREATE_USERS(self): 76 | return getattr(settings, "SAML_AUTO_CREATE_USERS", True) 77 | 78 | @property 79 | def SAML_CACHE(self): 80 | return getattr(settings, "SAML_CACHE", "default") 81 | 82 | @property 83 | def SAML_REPLAY_PROTECTION(self): 84 | return getattr(settings, "SAML_REPLAY_PROTECTION", True) 85 | 86 | @property 87 | def SAML_OVERRIDE_HOSTNAME(self): 88 | return getattr(settings, "SAML_OVERRIDE_HOSTNAME", "") 89 | 90 | 91 | app_settings = Settings() 92 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | Pipfile.lock 2 | **.orig 3 | 4 | # Swap 5 | [._]*.s[a-v][a-z] 6 | [._]*.sw[a-p] 7 | [._]s[a-rt-v][a-z] 8 | [._]ss[a-gi-z] 9 | [._]sw[a-p] 10 | 11 | # Session 12 | Session.vim 13 | Sessionx.vim 14 | 15 | # Temporary 16 | .netrwhist 17 | *~ 18 | # Auto-generated tag files 19 | tags 20 | # Persistent undo 21 | [._]*.un~ 22 | 23 | # vscode 24 | .vscode 25 | *.code-workspace 26 | 27 | # Local History for Visual Studio Code 28 | .history/ 29 | 30 | # Byte-compiled / optimized / DLL files 31 | __pycache__/ 32 | *.py[cod] 33 | *$py.class 34 | 35 | # C extensions 36 | *.so 37 | 38 | # Distribution / packaging 39 | .Python 40 | build/ 41 | develop-eggs/ 42 | dist/ 43 | downloads/ 44 | eggs/ 45 | .eggs/ 46 | lib/ 47 | lib64/ 48 | parts/ 49 | sdist/ 50 | var/ 51 | wheels/ 52 | share/python-wheels/ 53 | *.egg-info/ 54 | .installed.cfg 55 | *.egg 56 | MANIFEST 57 | 58 | # PyInstaller 59 | # Usually these files are written by a python script from a template 60 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 61 | *.manifest 62 | *.spec 63 | 64 | # Installer logs 65 | pip-log.txt 66 | pip-delete-this-directory.txt 67 | 68 | # Unit test / coverage reports 69 | htmlcov/ 70 | .tox/ 71 | .nox/ 72 | .coverage 73 | .coverage.* 74 | .cache 75 | nosetests.xml 76 | coverage.xml 77 | *.cover 78 | *.py,cover 79 | .hypothesis/ 80 | .pytest_cache/ 81 | cover/ 82 | 83 | # Translations 84 | *.mo 85 | *.pot 86 | 87 | # Django stuff: 88 | *.log 89 | local_settings.py 90 | db.sqlite3 91 | db.sqlite3-journal 92 | 93 | # Flask stuff: 94 | instance/ 95 | .webassets-cache 96 | 97 | # Scrapy stuff: 98 | .scrapy 99 | 100 | # Sphinx documentation 101 | docs/_build/ 102 | 103 | # PyBuilder 104 | .pybuilder/ 105 | target/ 106 | 107 | # Jupyter Notebook 108 | .ipynb_checkpoints 109 | 110 | # IPython 111 | profile_default/ 112 | ipython_config.py 113 | 114 | # pyenv 115 | # For a library or package, you might want to ignore these files since the code is 116 | # intended to run in multiple environments; otherwise, check them in: 117 | # .python-version 118 | 119 | # pipenv 120 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 121 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 122 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 123 | # install all needed dependencies. 124 | #Pipfile.lock 125 | 126 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 127 | __pypackages__/ 128 | 129 | # Celery stuff 130 | celerybeat-schedule 131 | celerybeat.pid 132 | 133 | # SageMath parsed files 134 | *.sage.py 135 | 136 | # Environments 137 | .env 138 | .venv 139 | env/ 140 | venv/ 141 | ENV/ 142 | env.bak/ 143 | venv.bak/ 144 | 145 | # Spyder project settings 146 | .spyderproject 147 | .spyproject 148 | 149 | # Rope project settings 150 | .ropeproject 151 | 152 | # mkdocs documentation 153 | /site 154 | 155 | # mypy 156 | .mypy_cache/ 157 | .dmypy.json 158 | dmypy.json 159 | 160 | # Pyre type checker 161 | .pyre/ 162 | 163 | # pytype static type analyzer 164 | .pytype/ 165 | 166 | # Cython debug symbols 167 | cython_debug/ 168 | 169 | # General 170 | .DS_Store 171 | .AppleDouble 172 | .LSOverride 173 | 174 | # Icon must end with two \r 175 | Icon 176 | 177 | # Thumbnails 178 | ._* 179 | 180 | # Files that might appear in the root of a volume 181 | .DocumentRevisions-V100 182 | .fseventsd 183 | .Spotlight-V100 184 | .TemporaryItems 185 | .Trashes 186 | .VolumeIcon.icns 187 | .com.apple.timemachine.donotpresent 188 | 189 | # Directories potentially created on remote AFP share 190 | .AppleDB 191 | .AppleDesktop 192 | Network Trash Folder 193 | Temporary Items 194 | .apdisk 195 | -------------------------------------------------------------------------------- /src/saml2_pro_auth/utils.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from typing import Tuple 3 | 4 | from django.urls import reverse 5 | from onelogin.saml2.auth import OneLogin_Saml2_Auth 6 | 7 | from .models import SamlProvider 8 | from .settings import app_settings 9 | 10 | 11 | class SAMLError(Exception): 12 | """ 13 | Used to indicate errors during SAML request/response processing 14 | """ 15 | 16 | 17 | class SAMLSettingsError(Exception): 18 | """ 19 | Used to indicate errors in the SAML settings 20 | """ 21 | 22 | 23 | def init_saml_auth( 24 | request, provider_key: str 25 | ) -> Tuple[OneLogin_Saml2_Auth, dict, dict]: 26 | """ 27 | Gets the SAML provider settings and returns a prepared SAML request and OneLogin Auth object 28 | """ 29 | saml_req = prepare_django_request(request) 30 | provider_settings, user_map = get_provider_settings(saml_req, provider_key) 31 | saml_req["lowercase_urlencoding"] = provider_settings.get( 32 | "lowercase_urlencoding", False 33 | ) 34 | saml_req["idp_initiated_auth"] = provider_settings.get("idp_initiated_auth", True) 35 | auth = OneLogin_Saml2_Auth(saml_req, provider_settings) 36 | return auth, saml_req, user_map 37 | 38 | 39 | def prepare_django_request(request) -> dict: 40 | """ 41 | Prepares the saml request object from the Django request 42 | """ 43 | if app_settings.SAML_OVERRIDE_HOSTNAME: 44 | http_host = app_settings.SAML_OVERRIDE_HOSTNAME 45 | else: 46 | http_host = request.get_host() 47 | 48 | if "HTTP_X_FORWARDED_FOR" in request.META: 49 | server_port = None 50 | https = request.META.get("HTTP_X_FORWARDED_PROTO") == "https" 51 | else: 52 | server_port = request.META.get("SERVER_PORT") 53 | https = request.is_secure() 54 | 55 | results = { 56 | "https": "on" if https else "off", 57 | "http_host": http_host, 58 | "script_name": request.META["PATH_INFO"], 59 | "get_data": request.GET.copy(), 60 | "post_data": request.POST.copy(), 61 | "query_string": request.META["QUERY_STRING"], 62 | } 63 | 64 | if server_port: 65 | # Empty port will make a (lonely) colon ':' appear on the URL, so 66 | # it's better not to include it at all. 67 | results["server_port"] = server_port 68 | 69 | return results 70 | 71 | 72 | def get_provider_settings(req: dict, provider_key: str) -> Tuple[dict, dict]: 73 | """ 74 | Returns the provider settings 75 | """ 76 | try: 77 | provider_settings = app_settings.SAML_PROVIDERS[provider_key] 78 | user_map = app_settings.SAML_USERS_MAP.get(provider_key, dict()) 79 | except KeyError: 80 | try: 81 | samlp = SamlProvider.objects.get(pk=uuid.UUID(provider_key)) 82 | provider_settings = samlp.get_provider_config( 83 | app_settings.SAML_PROVIDER_CONFIG_TEMPLATE 84 | ) 85 | user_map = samlp.attributes 86 | except (SamlProvider.DoesNotExist, ValueError) as err: 87 | raise SAMLSettingsError( 88 | "SAML_PROVIDERS is not defined in settings" 89 | ) from err 90 | 91 | urls = build_sp_urls(req, provider_key) 92 | # TODO: Skip if already defined in config 93 | provider_settings["sp"]["entityId"] = urls["entityId"] 94 | provider_settings["sp"]["assertionConsumerService"]["url"] = urls["acs_url"] 95 | return provider_settings, user_map 96 | 97 | 98 | def build_sp_urls(req: dict, provider_key: str) -> dict: 99 | """ 100 | Builds and returns the SP entity ID and acs URLs. 101 | TODO: Should eventually be expanded to return the SLS/SLO URLs as well. 102 | """ 103 | protocol = "https" if req["https"] == "on" else "http" 104 | 105 | # Get a path for the provider and then cut off the end of the path 106 | acs_path = reverse("saml2_pro_auth:acs", kwargs={"provider": provider_key}) 107 | base_path = f"{'/'.join(acs_path.split('/')[:-2])}/" 108 | entity_url = f"{protocol}://{req['http_host']}{base_path}" 109 | acs_url = f"{protocol}://{req['http_host']}{acs_path}" 110 | return { 111 | "entityId": entity_url, 112 | "acs_url": acs_url, 113 | } 114 | -------------------------------------------------------------------------------- /src/saml2_pro_auth/json_field.py: -------------------------------------------------------------------------------- 1 | """ 2 | Hacky patch to make JSONField work in sqlite for our tests. 3 | """ 4 | from django.conf import settings 5 | 6 | if "sqlite" in settings.DATABASES["default"]["ENGINE"]: 7 | 8 | import json 9 | 10 | from django import forms 11 | from django.core import exceptions 12 | from django.db.models import Field 13 | from django.forms.widgets import Textarea 14 | from django.utils.translation import gettext_lazy as _ 15 | 16 | class InvalidJSONInput(str): 17 | pass 18 | 19 | class JSONString(str): 20 | pass 21 | 22 | class JSONFormField(forms.CharField): 23 | default_error_messages = { 24 | "invalid": _("Enter a valid JSON."), 25 | } 26 | widget = Textarea 27 | 28 | def __init__(self, encoder=None, decoder=None, **kwargs): 29 | self.encoder = encoder 30 | self.decoder = decoder 31 | super().__init__(**kwargs) 32 | 33 | def to_python(self, value): 34 | if self.disabled: 35 | return value 36 | if value in self.empty_values: 37 | return None 38 | elif isinstance(value, (list, dict, int, float, JSONString)): 39 | return value 40 | try: 41 | converted = json.loads(value, cls=self.decoder) 42 | except json.JSONDecodeError: 43 | raise exceptions.ValidationError( 44 | self.error_messages["invalid"], 45 | code="invalid", 46 | params={"value": value}, 47 | ) 48 | if isinstance(converted, str): 49 | return JSONString(converted) 50 | else: 51 | return converted 52 | 53 | def bound_data(self, data, initial): 54 | if self.disabled: 55 | return initial 56 | try: 57 | return json.loads(data, cls=self.decoder) 58 | except json.JSONDecodeError: 59 | return InvalidJSONInput(data) 60 | 61 | def prepare_value(self, value): 62 | if isinstance(value, InvalidJSONInput): 63 | return value 64 | return json.dumps(value, ensure_ascii=False, cls=self.encoder) 65 | 66 | def has_changed(self, initial, data): 67 | if super().has_changed(initial, data): 68 | return True 69 | # For purposes of seeing whether something has changed, True isn't the 70 | # same as 1 and the order of keys doesn't matter. 71 | return json.dumps(initial, sort_keys=True, cls=self.encoder) != json.dumps( 72 | self.to_python(data), sort_keys=True, cls=self.encoder 73 | ) 74 | 75 | class JSONField(Field): 76 | empty_strings_allowed = False 77 | description = _("A JSON object") 78 | default_error_messages = { 79 | "invalid": _("Value must be valid JSON."), 80 | } 81 | _default_hint = ("dict", "{}") 82 | 83 | def db_type(self, connection): 84 | return "text" 85 | 86 | def from_db_value(self, value, expression, connection): 87 | if value is None: 88 | return value 89 | try: 90 | return json.loads(value) 91 | except json.JSONDecodeError: 92 | return value 93 | 94 | def get_prep_value(self, value): 95 | if value is None: 96 | return value 97 | return json.dumps(value) 98 | 99 | def validate(self, value, model_instance): 100 | super().validate(value, model_instance) 101 | try: 102 | json.dumps(value) 103 | except TypeError: 104 | raise exceptions.ValidationError( 105 | self.error_messages["invalid"], 106 | code="invalid", 107 | params={"value": value}, 108 | ) 109 | 110 | def value_to_string(self, obj): 111 | return self.value_from_object(obj) 112 | 113 | def formfield(self, **kwargs): 114 | return super().formfield( 115 | **{ 116 | "form_class": JSONFormField, 117 | **kwargs, 118 | } 119 | ) 120 | -------------------------------------------------------------------------------- /src/saml2_pro_auth/auth.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timezone 2 | 3 | from django.contrib.auth import get_user_model 4 | from django.core.cache import caches 5 | 6 | from .settings import app_settings 7 | from .utils import SAMLError, SAMLSettingsError 8 | 9 | 10 | def get_clean_map(user_map: dict, saml_data: dict) -> dict: 11 | final_map = dict() 12 | strict_mapping = app_settings.SAML_USERS_STRICT_MAPPING 13 | for usr_k, usr_v in user_map.items(): 14 | if strict_mapping and isinstance(usr_v, dict): 15 | if "default" in usr_v.keys(): 16 | raise SAMLSettingsError( 17 | "A default value is set for key %s in SAML_USER_MAP \ 18 | while SAML_USERS_STRICT_MAPPING is activated" 19 | % usr_k 20 | ) 21 | 22 | index = 0 23 | val = usr_v 24 | default = None 25 | if isinstance(usr_v, dict): 26 | index = usr_v.get("index", 0) 27 | val = usr_v.get("key", usr_k) 28 | default = usr_v.get("default", None) 29 | 30 | attr = saml_data.get(val, default) 31 | if isinstance(attr, list): 32 | attr = attr[index] 33 | 34 | if attr is None: 35 | if strict_mapping: 36 | raise SAMLError( 37 | "Response missing attribute %s while SAML_USERS_STRICT_MAPPING is activated" 38 | % usr_k 39 | ) 40 | 41 | continue 42 | 43 | final_map[usr_k] = attr 44 | 45 | return final_map 46 | 47 | 48 | class Backend: # pragma: no cover 49 | def user_can_authenticate(self, user): 50 | """ 51 | Reject users with is_active=False. Custom user models that don't have 52 | that attribute are allowed. 53 | """ 54 | is_active = getattr(user, "is_active", None) 55 | return is_active or is_active is None 56 | 57 | def authenticate(self, request, saml_auth=None, user_map=dict()): 58 | if not saml_auth: 59 | return None 60 | 61 | assertion_id = saml_auth.get_last_assertion_id() 62 | not_on_or_after = datetime.fromtimestamp( 63 | saml_auth.get_last_assertion_not_on_or_after(), tz=timezone.utc 64 | ) 65 | assertion_timeout = not_on_or_after - datetime.now(tz=timezone.utc) 66 | 67 | if app_settings.SAML_REPLAY_PROTECTION: 68 | # Store the assertion id in cache so we can ensure only once 69 | # processing during validity period 70 | cache = caches[app_settings.SAML_CACHE] 71 | if not cache.add( 72 | assertion_id, assertion_id, timeout=assertion_timeout.seconds 73 | ): 74 | # Check if adding the key worked, if the return is false the key already exists 75 | # so we fail auth. This should let us only process an assertion ID once 76 | return None 77 | 78 | UserModel = get_user_model() 79 | 80 | final_map = get_clean_map(user_map, saml_auth.get_attributes()) 81 | 82 | lookup_attr = app_settings.SAML_USERS_LOOKUP_ATTRIBUTE 83 | lookup_map = dict() 84 | if isinstance(lookup_attr, str): 85 | lookup_map = {lookup_attr: saml_auth.get_nameid()} 86 | elif isinstance(lookup_attr, (tuple, list)): 87 | if lookup_attr[1] == "NameId": 88 | lookup_map = {lookup_attr[0]: saml_auth.get_nameid()} 89 | else: 90 | lookup_map = {lookup_attr[0]: final_map[lookup_attr[1]]} 91 | else: 92 | raise SAMLSettingsError( 93 | "The value of SAML_USERS_LOOKUP_ATTRIBUTE must be a str, tuple, or list" 94 | ) 95 | 96 | sync_attributes = app_settings.SAML_USERS_SYNC_ATTRIBUTES 97 | create_users = app_settings.SAML_AUTO_CREATE_USERS 98 | 99 | try: 100 | if create_users and sync_attributes: 101 | user, _ = UserModel._default_manager.update_or_create( 102 | defaults=final_map, **lookup_map 103 | ) 104 | elif create_users: 105 | user, _ = UserModel._default_manager.get_or_create( 106 | defaults=final_map, **lookup_map 107 | ) 108 | else: 109 | user = UserModel._default_manager.get(**lookup_map) 110 | if sync_attributes: 111 | try: 112 | for key, val in final_map.items(): 113 | setattr(user, key, val) 114 | user.save() 115 | except Exception: 116 | pass 117 | except Exception as err: 118 | return None 119 | 120 | if self.user_can_authenticate(user): 121 | return user 122 | 123 | def get_user(self, user_id): 124 | UserModel = get_user_model() 125 | try: 126 | user = UserModel._default_manager.get(pk=user_id) 127 | except UserModel.DoesNotExist: 128 | return None 129 | 130 | return user if self.user_can_authenticate(user) else None 131 | 132 | 133 | SamlBackend = Backend 134 | -------------------------------------------------------------------------------- /src/saml2_pro_auth/views.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import REDIRECT_FIELD_NAME, authenticate, login 2 | from django.core.exceptions import SuspiciousOperation 3 | from django.http import HttpResponse 4 | from django.shortcuts import redirect 5 | from django.utils.decorators import method_decorator 6 | from django.views.decorators.csrf import csrf_exempt 7 | from django.views.generic.base import View 8 | from onelogin.saml2.errors import OneLogin_Saml2_Error 9 | from onelogin.saml2.utils import OneLogin_Saml2_Utils 10 | 11 | from .settings import app_settings 12 | from .utils import SAMLError, SAMLSettingsError, init_saml_auth 13 | 14 | 15 | class SamlBadRequest(SuspiciousOperation): 16 | pass 17 | 18 | 19 | class GenericSamlView(View): 20 | def dispatch(self, request, *args, **kwargs): 21 | """Initialize attributes shared by all view methods.""" 22 | try: 23 | auth, req, user_map = init_saml_auth(request, kwargs["provider"]) 24 | except (SAMLError, SAMLSettingsError, KeyError) as err: 25 | raise SAMLError("Invalid request or provider settings") from err 26 | 27 | kwargs["saml_auth"] = auth 28 | kwargs["saml_req"] = req 29 | kwargs["user_map"] = user_map 30 | return super().dispatch(request, *args, **kwargs) 31 | 32 | 33 | class MetadataView(GenericSamlView): 34 | http_method_names = ["get", "head"] 35 | 36 | def get(self, request, *args, **kwargs): 37 | saml_settings = kwargs["saml_auth"].get_settings() 38 | try: 39 | metadata_doc = saml_settings.get_sp_metadata() 40 | except OneLogin_Saml2_Error as err: 41 | raise SAMLError("Invalid SP metadata") from err 42 | 43 | errors = saml_settings.validate_metadata(metadata_doc) 44 | 45 | if errors: 46 | raise SAMLError(", ".join(errors)) 47 | 48 | return HttpResponse(content=metadata_doc, content_type="text/xml") 49 | 50 | 51 | @method_decorator(csrf_exempt, name="dispatch") 52 | class AcsView(GenericSamlView): 53 | http_method_names = ["post"] 54 | 55 | def post(self, request, *args, **kwargs): 56 | auth = kwargs["saml_auth"] 57 | req = kwargs["saml_req"] 58 | user_map = kwargs["user_map"] 59 | request_id = request.get_signed_cookie( 60 | "sp_auth", default=None, salt="saml2_pro_auth.authnrequestid", max_age=300 61 | ) 62 | if not req["idp_initiated_auth"] and request_id is None: 63 | raise SamlBadRequest("Bad Request") 64 | 65 | auth.process_response(request_id=request_id) 66 | errors = auth.get_errors() 67 | 68 | if not errors: 69 | user = authenticate( 70 | request=request, 71 | saml_auth=auth, 72 | user_map=user_map, 73 | ) 74 | if user is not None: 75 | try: 76 | login(request, user) 77 | except (ValueError, TypeError): 78 | error_reason = "Bad Request" 79 | if auth.get_settings().is_debug_active(): 80 | error_reason = "Login Failed" 81 | raise SamlBadRequest("%s" % error_reason) 82 | 83 | # Only write data into the session if everything is successful 84 | # and the user is logged in 85 | request.session["samlUserdata"] = auth.get_attributes() 86 | request.session["samlNameId"] = auth.get_nameid() 87 | request.session["samlSessionIndex"] = auth.get_session_index() 88 | relay_state = req["post_data"].get("RelayState", app_settings.SAML_REDIRECT) or "/" 89 | if relay_state and OneLogin_Saml2_Utils.get_self_url(req) != relay_state: 90 | response = redirect( 91 | auth.redirect_to(relay_state) 92 | ) 93 | else: 94 | response = redirect(OneLogin_Saml2_Utils.get_self_url(req)) 95 | else: 96 | error_reason = "Bad Request" 97 | if auth.get_settings().is_debug_active(): 98 | error_reason = "User lookup Failed" 99 | raise SamlBadRequest("%s" % error_reason) 100 | 101 | else: 102 | error_reason = "Bad Request" 103 | if auth.get_settings().is_debug_active(): 104 | error_reason = auth.get_last_error_reason() 105 | raise SamlBadRequest("%s" % error_reason) 106 | 107 | response.delete_cookie("sp_auth") 108 | return response 109 | 110 | 111 | class SsoView(GenericSamlView): 112 | http_method_names = ["get", "head"] 113 | 114 | def get(self, request, *args, **kwargs): 115 | # SP-SSO start request 116 | auth = kwargs["saml_auth"] 117 | req = kwargs["saml_req"] 118 | return_to = req["get_data"].get(REDIRECT_FIELD_NAME, app_settings.SAML_REDIRECT) or "/" 119 | saml_request = auth.login(return_to=return_to) 120 | response = redirect(saml_request) 121 | response.set_signed_cookie( 122 | "sp_auth", 123 | auth.get_last_request_id(), 124 | salt="saml2_pro_auth.authnrequestid", 125 | max_age=300, 126 | secure=req["https"] == "on", 127 | httponly=True, 128 | samesite=None, 129 | ) 130 | return response 131 | -------------------------------------------------------------------------------- /tests/data/configs.py: -------------------------------------------------------------------------------- 1 | MOCK_SAML2_CONFIG = { 2 | "functionProvider": { 3 | "strict": True, 4 | "debug": True, 5 | "sp": { 6 | "entityId": "https://example.com/sso/saml/metadata?provider=functionProvider", 7 | "assertionConsumerService": { 8 | "url": "https://example.com/sso/saml/?acs&provider=functionProvider", 9 | "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST", 10 | }, 11 | "singleLogoutService": { 12 | "url": "https://example.com/sso/saml/?sls&provider=functionProvider", 13 | "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect", 14 | }, 15 | "NameIDFormat": "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified", 16 | "x509cert": open("tests/mock_certs/sp.crt", "r").read(), 17 | "privateKey": open("tests/mock_certs/sp.key", "r").read(), 18 | }, 19 | "idp": { 20 | "entityId": "https://myprovider.example.com/0f3172cf", 21 | "singleSignOnService": { 22 | "url": "https://myprovider.example.com/applogin/appKey/0f3172cf/customerId/AA333", 23 | "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect", 24 | }, 25 | "singleLogoutService": { 26 | "url": "https://myprovider.example.com/applogout", 27 | "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect", 28 | }, 29 | "x509cert": open("tests/mock_certs/myprovider.crt", "r").read(), 30 | }, 31 | "organization": { 32 | "en-US": { 33 | "name": "example inc", 34 | "displayname": "Example Incorporated", 35 | "url": "example.com", 36 | } 37 | }, 38 | "contactPerson": { 39 | "technical": {"givenName": "Jane Doe", "emailAddress": "jdoe@examp.com"}, 40 | "support": {"givenName": "Jane Doe", "emailAddress": "jdoe@examp.com"}, 41 | }, 42 | "security": { 43 | "name_id_encrypted": False, 44 | "authn_requests_signed": True, 45 | "logout_requests_signed": False, 46 | "logout_response_signed": False, 47 | "sign_metadata": False, 48 | "want_messages_signed": False, 49 | "want_assertions_signed": True, 50 | "want_name_id": True, 51 | "want_name_id_encrypted": False, 52 | "want_assertions_encrypted": True, 53 | "signature_algorithm": "http://www.w3.org/2000/09/xmldsig#rsa-sha1", 54 | "digestAlgorithm": "http://www.w3.org/2001/04/xmlenc#sha256", 55 | }, 56 | }, 57 | "classProvider": { 58 | "strict": True, 59 | "debug": True, 60 | "lowercase_urlencoding": False, 61 | "idp_initiated_auth": True, 62 | "sp": { 63 | "entityId": "https://example.com/saml/metadata/classProvider/", 64 | "assertionConsumerService": { 65 | "url": "https://example.com/saml/acs/classProvider/", 66 | "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST", 67 | }, 68 | "singleLogoutService": { 69 | "url": "https://example.com/saml/sls/classProvider/", 70 | "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect", 71 | }, 72 | "NameIDFormat": "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified", 73 | "x509cert": open("tests/mock_certs/sp.crt", "r").read(), 74 | "privateKey": open("tests/mock_certs/sp.key", "r").read(), 75 | }, 76 | "idp": { 77 | "entityId": "https://myprovider.example.com/0f3172cf", 78 | "singleSignOnService": { 79 | "url": "https://myprovider.example.com/applogin/appKey/0f3172cf/customerId/AA333", 80 | "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect", 81 | }, 82 | "singleLogoutService": { 83 | "url": "https://myprovider.example.com/applogout", 84 | "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect", 85 | }, 86 | "x509cert": open("tests/mock_certs/myprovider.crt", "r").read(), 87 | }, 88 | "organization": { 89 | "en-US": { 90 | "name": "example inc", 91 | "displayname": "Example Incorporated", 92 | "url": "example.com", 93 | } 94 | }, 95 | "contactPerson": { 96 | "technical": {"givenName": "Jane Doe", "emailAddress": "jdoe@examp.com"}, 97 | "support": {"givenName": "Jane Doe", "emailAddress": "jdoe@examp.com"}, 98 | }, 99 | "security": { 100 | "name_id_encrypted": False, 101 | "authn_requests_signed": True, 102 | "logout_requests_signed": False, 103 | "logout_response_signed": False, 104 | "sign_metadata": False, 105 | "want_messages_signed": False, 106 | "want_assertions_signed": True, 107 | "want_name_id": True, 108 | "want_name_id_encrypted": False, 109 | "want_assertions_encrypted": True, 110 | "signature_algorithm": "http://www.w3.org/2000/09/xmldsig#rsa-sha1", 111 | "digestAlgorithm": "http://www.w3.org/2001/04/xmlenc#sha256", 112 | }, 113 | }, 114 | } 115 | -------------------------------------------------------------------------------- /src/saml2_pro_auth/models.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from copy import deepcopy 3 | 4 | from django.conf import settings 5 | from django.db import models 6 | 7 | try: 8 | from django.db.models import JSONField 9 | except ImportError: 10 | if "sqlite" in settings.DATABASES["default"]["ENGINE"]: 11 | from .json_field import JSONField 12 | else: 13 | from django.contrib.postgres.fields import JSONField 14 | 15 | from .constants import ( 16 | HTTP_POST_BINDING, 17 | NAMEID_FORMAT_CHOICES, 18 | SAML_PROTOCOL_BINDINGS, 19 | UNSPECIFIED, 20 | ) 21 | 22 | 23 | class SamlProvider(models.Model): 24 | id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) 25 | name = models.CharField( 26 | "Name", 27 | help_text="A descriptive name for the provider configuration.", 28 | max_length=50, 29 | blank=False, 30 | ) 31 | idp_issuer = models.TextField( 32 | "IdP Issuer (Entity ID)", 33 | help_text="The Issuer or Entity ID from your Identity Provider.", 34 | blank=False, 35 | max_length=1024, 36 | ) 37 | idp_x509 = models.TextField( 38 | "IdP Certificate", 39 | help_text="A PEM encoded public certificate provided by your Identity Provider.", 40 | blank=False, 41 | ) 42 | idp_sso_url = models.TextField( 43 | "IdP Single Sign-On URL", 44 | help_text="The single sign-on service URL provided by your IdP.", 45 | blank=False, 46 | max_length=2048, 47 | ) 48 | idp_sso_binding = models.CharField( 49 | "IdP Single Sign-On Binding", 50 | help_text="The single sign-on service protocol binding set by your IdP. HTTP-POST is recommended.", 51 | choices=SAML_PROTOCOL_BINDINGS, 52 | default=HTTP_POST_BINDING, 53 | blank=False, 54 | max_length=255, 55 | ) 56 | nameidformat = models.CharField( 57 | "NameID Format", 58 | help_text="Format of the assertions subject statement NameID attribute. This must match the format sent from your IdP.", 59 | choices=NAMEID_FORMAT_CHOICES, 60 | default=UNSPECIFIED, 61 | blank=False, 62 | max_length=255, 63 | ) 64 | sp_acs_binding = models.TextField( 65 | "SP Single Sign-On Binding", 66 | help_text="The single sign-on service protocol binding for this service provider. HTTP-POST is recommended.", 67 | choices=SAML_PROTOCOL_BINDINGS, 68 | default=HTTP_POST_BINDING, 69 | blank=False, 70 | max_length=1024, 71 | ) 72 | debug = models.BooleanField( 73 | "Debug", help_text="Enable settings debug messages.", default=False 74 | ) 75 | lowercase_urlencoding = models.BooleanField( 76 | "Support ADFS", 77 | help_text="Enable ADFS lowercase Url encoding support.", 78 | default=False, 79 | ) 80 | idp_initiated_auth = models.BooleanField( 81 | "Allow IdP Initiated Assertions", 82 | help_text="Accept unsolicited IdP initiated assertions.", 83 | default=False, 84 | ) 85 | sec_want_messages_signed = models.BooleanField( 86 | "Signed Responses", 87 | help_text="Require signed responses from the IdP.", 88 | default=True, 89 | ) 90 | sec_want_assertions_signed = models.BooleanField( 91 | "Signed Assertions", 92 | help_text="Require signed assertions from the IdP.", 93 | default=False, 94 | ) 95 | sec_want_assertions_encrypted = models.BooleanField( 96 | "Encrypted Assertions", 97 | help_text="Require encrypted assertions from the IdP.", 98 | default=False, 99 | ) 100 | attributes = JSONField( 101 | "Attribute Statements", 102 | help_text="Map attributes from the IdP to User fields.", 103 | default=dict, 104 | blank=True, 105 | ) 106 | 107 | def __str__(self): 108 | 109 | return self.name 110 | 111 | def get_provider_config(self, defaults): 112 | """ 113 | Interprolate settings from model into config 114 | """ 115 | config = deepcopy(defaults) 116 | config = dict( 117 | idp=dict( 118 | entityId=self.idp_issuer, 119 | x509cert=self.idp_x509, 120 | singleSignOnService=dict( 121 | url=self.idp_sso_url, 122 | binding=self.idp_sso_binding, 123 | ), 124 | ), 125 | sp={ 126 | **config.setdefault("sp", dict()), 127 | **{ 128 | "entityId": "", 129 | "NameIDFormat": self.nameidformat, 130 | "assertionConsumerService": dict( 131 | url="", 132 | binding=self.sp_acs_binding, 133 | ), 134 | }, 135 | }, 136 | security={ 137 | **config.setdefault("security", dict()), 138 | **{ 139 | "wantMessagesSigned": self.sec_want_messages_signed, 140 | "wantAssertionsSigned": self.sec_want_assertions_signed, 141 | "wantAssertionsEncrypted": self.sec_want_assertions_encrypted, 142 | }, 143 | }, 144 | ) 145 | config["debug"] = self.debug 146 | config["lowercase_urlencoding"] = self.lowercase_urlencoding 147 | config["idp_initiated_auth"] = self.idp_initiated_auth 148 | 149 | return config 150 | -------------------------------------------------------------------------------- /tests/test_auth.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from django.test.utils import override_settings 3 | 4 | from saml2_pro_auth.auth import get_clean_map 5 | from saml2_pro_auth.utils import SAMLSettingsError 6 | 7 | 8 | class TestAuth(TestCase): 9 | def test_mapping_users_with_index_values(self): 10 | user_map = { 11 | "email": {"index": 0, "key": "Email"}, 12 | "name": {"index": 0, "key": "Username"}, 13 | } 14 | 15 | saml_map = { 16 | "Username": ["montypython"], 17 | "lastName": ["Cleese"], 18 | "Email": ["montypython@example.com"], 19 | "firstName": ["John"], 20 | } 21 | 22 | merged_map = get_clean_map(user_map, saml_map) 23 | self.assertEqual(merged_map["email"], "montypython@example.com") 24 | self.assertEqual(merged_map["name"], "montypython") 25 | 26 | def test_mapping_users_without_index_values(self): 27 | user_map = {"email": "Email", "name": "Username"} 28 | 29 | saml_map = { 30 | "Username": ["montypython"], 31 | "lastName": ["Cleese"], 32 | "Email": ["montypython@example.com"], 33 | "firstName": ["John"], 34 | } 35 | 36 | merged_map = get_clean_map(user_map, saml_map) 37 | self.assertEqual(merged_map["email"], "montypython@example.com") 38 | self.assertEqual(merged_map["name"], "montypython") 39 | 40 | def test_mapping_users_with_mixed_value_styles(self): 41 | user_map = { 42 | "email": "Email", 43 | "name": {"index": 1, "key": "Username"}, 44 | "customer": {"key": "Client"}, 45 | } 46 | 47 | saml_map = { 48 | "Username": ["", "montypython"], 49 | "lastName": ["Cleese"], 50 | "Email": ["montypython@example.com"], 51 | "firstName": ["John"], 52 | "Client": ["examplecorp"], 53 | } 54 | 55 | merged_map = get_clean_map(user_map, saml_map) 56 | self.assertEqual(merged_map["email"], "montypython@example.com") 57 | self.assertEqual(merged_map["name"], "montypython") 58 | self.assertEqual(merged_map["customer"], "examplecorp") 59 | 60 | def test_mapping_users_with_default_values(self): 61 | user_map = { 62 | "email": "Email", 63 | "name": {"index": 1, "key": "Username", "default": "testUsername"}, 64 | "customer": {"key": "Client", "default": "testClient"}, 65 | } 66 | 67 | saml_map = { 68 | "Username": ["", "montypython"], 69 | "lastName": ["Cleese"], 70 | "Email": ["montypython@example.com"], 71 | "firstName": ["John"], 72 | "Client": ["examplecorp"], 73 | } 74 | 75 | self.assertRaises(SAMLSettingsError, get_clean_map, user_map, saml_map) 76 | 77 | @override_settings(SAML_USERS_STRICT_MAPPING=False) 78 | def test_non_strict_mapping_users_with_index_values(self): 79 | user_map = { 80 | "email": {"index": 0, "key": "Email"}, 81 | "name": {"index": 0, "key": "Username"}, 82 | "age": {"index": 0, "key": "Age"}, 83 | } 84 | 85 | saml_map = { 86 | "Username": ["montypython"], 87 | "lastName": ["Cleese"], 88 | "Email": ["montypython@example.com"], 89 | "firstName": ["John"], 90 | } 91 | 92 | merged_map = get_clean_map(user_map, saml_map) 93 | self.assertEqual(merged_map["email"], "montypython@example.com") 94 | self.assertEqual(merged_map["name"], "montypython") 95 | self.assertTrue("age" not in merged_map) 96 | 97 | @override_settings(SAML_USERS_STRICT_MAPPING=False) 98 | def test_non_strict_mapping_users_without_index_values(self): 99 | user_map = { 100 | "email": "Email", 101 | "name": "Username", 102 | "age": "Age", 103 | } 104 | 105 | saml_map = { 106 | "Username": ["montypython"], 107 | "lastName": ["Cleese"], 108 | "Email": ["montypython@example.com"], 109 | "firstName": ["John"], 110 | } 111 | 112 | merged_map = get_clean_map(user_map, saml_map) 113 | self.assertEqual(merged_map["email"], "montypython@example.com") 114 | self.assertEqual(merged_map["name"], "montypython") 115 | self.assertTrue("age" not in merged_map) 116 | 117 | @override_settings(SAML_USERS_STRICT_MAPPING=False) 118 | def test_non_strict_mapping_users_with_mixed_value_styles(self): 119 | user_map = { 120 | "email": "Email", 121 | "name": {"index": 1, "key": "Username"}, 122 | "customer": {"key": "Client"}, 123 | "age": "Age", 124 | } 125 | 126 | saml_map = { 127 | "Username": ["", "montypython"], 128 | "lastName": ["Cleese"], 129 | "Email": ["montypython@example.com"], 130 | "firstName": ["John"], 131 | "Client": ["examplecorp"], 132 | } 133 | 134 | merged_map = get_clean_map(user_map, saml_map) 135 | self.assertEqual(merged_map["email"], "montypython@example.com") 136 | self.assertEqual(merged_map["name"], "montypython") 137 | self.assertEqual(merged_map["customer"], "examplecorp") 138 | self.assertTrue("age" not in merged_map) 139 | 140 | @override_settings(SAML_USERS_STRICT_MAPPING=False) 141 | def test_non_strict_mapping_users_with_default_value(self): 142 | user_map = { 143 | "email": {"key": "Email"}, 144 | "name": {"key": "Username", "index": 1}, 145 | "is_superuser": {"key": "is_superuser", "default": False}, 146 | "is_staff": {"key": "is_staff", "default": True}, 147 | } 148 | 149 | saml_map = { 150 | "Username": ["", "montypython"], 151 | "lastName": ["Cleese"], 152 | "Email": ["montypython@example.com"], 153 | "firstName": ["John"], 154 | "Client": ["examplecorp"], 155 | } 156 | 157 | merged_map = get_clean_map(user_map, saml_map) 158 | self.assertEqual(merged_map["email"], "montypython@example.com") 159 | self.assertEqual(merged_map["name"], "montypython") 160 | self.assertEqual(merged_map["is_superuser"], False) 161 | self.assertEqual(merged_map["is_staff"], True) 162 | -------------------------------------------------------------------------------- /example/example/templates/index.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 | 5 | 21 |
22 |
23 | 24 | {% if errors %} 25 | 39 | 50 | {% endif %} 51 | 52 | {% if not request.user.is_authenticated %} 53 | 59 | {% endif %} 60 | 61 | {% if success_slo %} 62 | 68 | {% endif %} 69 | 70 |
71 | {% if request.user.is_authenticated %} 72 |
73 | {% if request.session.samlUserdata %} 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | {% for attr in request.session.samlUserdata.items %} 83 | 84 | 85 | 90 | 91 | {% endfor %} 92 | 93 |
NameValue
{{ attr.0 }} 86 | {% for val in attr.1 %} 87 | {{ val }} 88 | {% endfor %} 89 |
94 | {% else %} 95 | 101 | {% endif %} 102 |
103 |
104 |
105 | Logout (SLO Not Implemented) 106 | {% else %} 107 |
108 | Login Okta 109 | Login GSuite 110 | {% for provider in object_list %} 111 | {{provider.name}} 112 | {% endfor %} 113 |
114 | {% endif %} 115 |
116 |
117 | Okta Provider Metadata 118 | Gsuite Provider Metadata 119 | {% for provider in object_list %} 120 | {{provider.name}} Metadata 121 | {% endfor %} 122 |
123 | {% endblock %} 124 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import onelogin 2 | from django.test import RequestFactory, TestCase 3 | from django.test.utils import override_settings 4 | 5 | from saml2_pro_auth.utils import ( 6 | SAMLSettingsError, 7 | init_saml_auth, 8 | prepare_django_request, 9 | ) 10 | 11 | 12 | class TestUtils(TestCase): 13 | def test_init_saml_auth(self): 14 | factory = RequestFactory() 15 | request = factory.get( 16 | "/sso/saml/?provider=classProvider", **dict(HTTP_HOST="example.com") 17 | ) 18 | auth_obj, req, user_map = init_saml_auth(request, "classProvider") 19 | self.assertTrue(isinstance(auth_obj, onelogin.saml2.auth.OneLogin_Saml2_Auth)) 20 | self.assertTrue(isinstance(req, dict)) 21 | 22 | def test_get_provider_config_with_missing_provider(self): 23 | factory = RequestFactory() 24 | request = factory.get( 25 | "/sso/saml/?provider=MissingProvider", **dict(HTTP_HOST="example.com") 26 | ) 27 | self.assertRaises(SAMLSettingsError, init_saml_auth, request, "MissingProvider") 28 | 29 | def test_prepare_http_request_with_GET_no_proxy(self): 30 | factory = RequestFactory() 31 | request = factory.get( 32 | "/sso/saml/?provider=classProvider", **dict(HTTP_HOST="example.com") 33 | ) 34 | req = prepare_django_request(request) 35 | 36 | self.assertEqual(req["get_data"]["provider"], "classProvider") 37 | self.assertEqual(req["https"], "off") 38 | self.assertEqual(req["script_name"], "/sso/saml/") 39 | self.assertEqual(req["http_host"], "example.com") 40 | 41 | def test_prepare_https_request_with_GET_no_proxy(self): 42 | factory = RequestFactory() 43 | request = factory.get( 44 | "/sso/saml/?provider=classProvider", 45 | secure=True, 46 | **dict(HTTP_HOST="example.com") 47 | ) 48 | req = prepare_django_request(request) 49 | self.assertEqual(req["get_data"]["provider"], "classProvider") 50 | self.assertEqual(req["https"], "on") 51 | self.assertEqual(req["script_name"], "/sso/saml/") 52 | self.assertEqual(req["http_host"], "example.com") 53 | 54 | def test_prepare_http_request_with_GET_plus_proxy(self): 55 | factory = RequestFactory() 56 | request = factory.get( 57 | "/sso/saml/?provider=classProvider", 58 | **dict( 59 | HTTP_X_FORWARDED_FOR="10.10.10.10", 60 | HTTP_X_FORWARDED_PROTO="http", 61 | HTTP_HOST="example.com", 62 | ) 63 | ) 64 | req = prepare_django_request(request) 65 | self.assertEqual(req["get_data"]["provider"], "classProvider") 66 | self.assertEqual(req["https"], "off") 67 | self.assertEqual(req["script_name"], "/sso/saml/") 68 | self.assertEqual(req["http_host"], "example.com") 69 | 70 | def test_prepare_https_request_with_GET_plus_proxy(self): 71 | factory = RequestFactory() 72 | request = factory.get( 73 | "/sso/saml/?provider=classProvider", 74 | **dict( 75 | HTTP_X_FORWARDED_FOR="10.10.10.10", 76 | HTTP_X_FORWARDED_PROTO="https", 77 | HTTP_HOST="example.com", 78 | ) 79 | ) 80 | req = prepare_django_request(request) 81 | self.assertEqual(req["get_data"]["provider"], "classProvider") 82 | self.assertEqual(req["https"], "on") 83 | self.assertEqual(req["script_name"], "/sso/saml/") 84 | self.assertEqual(req["http_host"], "example.com") 85 | 86 | def test_prepare_http_request_with_POST_no_proxy(self): 87 | factory = RequestFactory() 88 | request = factory.post( 89 | "/sso/saml/?provider=classProvider", **dict(HTTP_HOST="example.com") 90 | ) 91 | req = prepare_django_request(request) 92 | 93 | self.assertEqual(req["get_data"]["provider"], "classProvider") 94 | self.assertEqual(req["https"], "off") 95 | self.assertEqual(req["script_name"], "/sso/saml/") 96 | self.assertEqual(req["http_host"], "example.com") 97 | 98 | def test_prepare_https_request_with_POST_no_proxy(self): 99 | factory = RequestFactory() 100 | request = factory.post( 101 | "/sso/saml/?provider=classProvider", 102 | secure=True, 103 | **dict(HTTP_HOST="example.com") 104 | ) 105 | req = prepare_django_request(request) 106 | self.assertEqual(req["get_data"]["provider"], "classProvider") 107 | self.assertEqual(req["https"], "on") 108 | self.assertEqual(req["script_name"], "/sso/saml/") 109 | self.assertEqual(req["http_host"], "example.com") 110 | 111 | def test_prepare_http_request_with_POST_plus_proxy(self): 112 | factory = RequestFactory() 113 | request = factory.post( 114 | "/sso/saml/?provider=classProvider", 115 | **dict( 116 | HTTP_X_FORWARDED_FOR="10.10.10.10", 117 | HTTP_X_FORWARDED_PROTO="http", 118 | HTTP_HOST="example.com", 119 | ) 120 | ) 121 | req = prepare_django_request(request) 122 | self.assertEqual(req["get_data"]["provider"], "classProvider") 123 | self.assertEqual(req["https"], "off") 124 | self.assertEqual(req["script_name"], "/sso/saml/") 125 | self.assertEqual(req["http_host"], "example.com") 126 | 127 | def test_prepare_https_request_with_POST_plus_proxy(self): 128 | factory = RequestFactory() 129 | request = factory.post( 130 | "/sso/saml/?provider=classProvider", 131 | **dict( 132 | HTTP_X_FORWARDED_FOR="10.10.10.10", 133 | HTTP_X_FORWARDED_PROTO="https", 134 | HTTP_HOST="example.com", 135 | ) 136 | ) 137 | req = prepare_django_request(request) 138 | self.assertEqual(req["get_data"]["provider"], "classProvider") 139 | self.assertEqual(req["https"], "on") 140 | self.assertEqual(req["script_name"], "/sso/saml/") 141 | self.assertEqual(req["http_host"], "example.com") 142 | 143 | @override_settings(SAML_OVERRIDE_HOSTNAME="abc.example.org") 144 | def test_prepare_request_with_overridden_host(self): 145 | factory = RequestFactory() 146 | request = factory.post( 147 | "/sso/saml/classProvider/acs/", 148 | **dict( 149 | HTTP_HOST="garbage.com", 150 | ) 151 | ) 152 | req = prepare_django_request(request) 153 | self.assertEqual(req["script_name"], "/sso/saml/classProvider/acs/") 154 | self.assertEqual(req["http_host"], "abc.example.org") 155 | -------------------------------------------------------------------------------- /src/saml2_pro_auth/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.3 on 2020-11-09 19:33 2 | 3 | import uuid 4 | 5 | from django.conf import settings 6 | from django.db import migrations, models 7 | 8 | try: 9 | from django.db.models import JSONField 10 | except ImportError: 11 | if "sqlite" in settings.DATABASES["default"]["ENGINE"]: 12 | from saml2_pro_auth.json_field import JSONField 13 | else: 14 | from django.contrib.postgres.fields import JSONField 15 | 16 | 17 | class Migration(migrations.Migration): 18 | 19 | initial = True 20 | 21 | dependencies = [] 22 | 23 | operations = [ 24 | migrations.CreateModel( 25 | name="SamlProvider", 26 | fields=[ 27 | ( 28 | "id", 29 | models.UUIDField( 30 | default=uuid.uuid4, 31 | editable=False, 32 | primary_key=True, 33 | serialize=False, 34 | ), 35 | ), 36 | ( 37 | "name", 38 | models.CharField( 39 | help_text="A descriptive name for the provider configuration.", 40 | max_length=50, 41 | verbose_name="Name", 42 | ), 43 | ), 44 | ( 45 | "idp_issuer", 46 | models.TextField( 47 | help_text="The Issuer or Entity ID from your Identity Provider.", 48 | max_length=1024, 49 | verbose_name="IdP Issuer (Entity ID)", 50 | ), 51 | ), 52 | ( 53 | "idp_x509", 54 | models.TextField( 55 | help_text="A PEM encoded public certificate provided by your Identity Provider.", 56 | verbose_name="IdP Certificate", 57 | ), 58 | ), 59 | ( 60 | "idp_sso_url", 61 | models.TextField( 62 | help_text="The single sign-on service URL provided by your IdP.", 63 | max_length=2048, 64 | verbose_name="IdP Single Sign-On URL", 65 | ), 66 | ), 67 | ( 68 | "idp_sso_binding", 69 | models.CharField( 70 | choices=[ 71 | ( 72 | "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST", 73 | "HTTP-POST", 74 | ), 75 | ( 76 | "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect", 77 | "HTTP-Redirect", 78 | ), 79 | ], 80 | default="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST", 81 | help_text="The single sign-on service protocol binding set by your IdP. HTTP-POST is recommended.", 82 | max_length=255, 83 | verbose_name="IdP Single Sign-On Binding", 84 | ), 85 | ), 86 | ( 87 | "nameidformat", 88 | models.CharField( 89 | choices=[ 90 | ( 91 | "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified", 92 | "Unspecified", 93 | ), 94 | ( 95 | "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", 96 | "EmailAddress", 97 | ), 98 | ( 99 | "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent", 100 | "Persistent", 101 | ), 102 | ( 103 | "urn:oasis:names:tc:SAML:2.0:nameid-format:transient", 104 | "Transient", 105 | ), 106 | ( 107 | "urn:oasis:names:tc:SAML:1.1:nameid-format:X509SubjectName", 108 | "X509SubjectName", 109 | ), 110 | ( 111 | "urn:oasis:names:tc:SAML:1.1:nameid-format:WindowsDomainQualifiedName", 112 | "WindowsDomainQualifiedName", 113 | ), 114 | ( 115 | "urn:oasis:names:tc:SAML:2.0:nameid-format:kerberos", 116 | "Kerberos", 117 | ), 118 | ( 119 | "urn:oasis:names:tc:SAML:2.0:nameid-format:entity", 120 | "Entity", 121 | ), 122 | ], 123 | default="urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified", 124 | help_text="Format of the assertions subject statement NameID attribute. This must match the format sent from your IdP.", 125 | max_length=255, 126 | verbose_name="NameID Format", 127 | ), 128 | ), 129 | ( 130 | "sp_acs_binding", 131 | models.TextField( 132 | choices=[ 133 | ( 134 | "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST", 135 | "HTTP-POST", 136 | ), 137 | ( 138 | "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect", 139 | "HTTP-Redirect", 140 | ), 141 | ], 142 | default="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST", 143 | help_text="The single sign-on service protocol binding for this service provider. HTTP-POST is recommended.", 144 | max_length=1024, 145 | verbose_name="SP Single Sign-On Binding", 146 | ), 147 | ), 148 | ( 149 | "debug", 150 | models.BooleanField( 151 | default=False, 152 | help_text="Enable settings debug messages.", 153 | verbose_name="Debug", 154 | ), 155 | ), 156 | ( 157 | "lowercase_urlencoding", 158 | models.BooleanField( 159 | default=False, 160 | help_text="Enable ADFS lowercase Url encoding support.", 161 | verbose_name="Support ADFS", 162 | ), 163 | ), 164 | ( 165 | "idp_initiated_auth", 166 | models.BooleanField( 167 | default=False, 168 | help_text="Accept unsolicited IdP initiated assertions.", 169 | verbose_name="Allow IdP Initiated Assertions", 170 | ), 171 | ), 172 | ( 173 | "sec_want_messages_signed", 174 | models.BooleanField( 175 | default=True, 176 | help_text="Require signed responses from the IdP.", 177 | verbose_name="Signed Responses", 178 | ), 179 | ), 180 | ( 181 | "sec_want_assertions_signed", 182 | models.BooleanField( 183 | default=False, 184 | help_text="Require signed assertions from the IdP.", 185 | verbose_name="Signed Assertions", 186 | ), 187 | ), 188 | ( 189 | "sec_want_assertions_encrypted", 190 | models.BooleanField( 191 | default=False, 192 | help_text="Require encrypted assertions from the IdP.", 193 | verbose_name="Encrypted Assertions", 194 | ), 195 | ), 196 | ( 197 | "attributes", 198 | JSONField( 199 | blank=True, 200 | default=dict, 201 | help_text="Map attributes from the IdP to User fields.", 202 | verbose_name="Attribute Statements", 203 | ), 204 | ), 205 | ], 206 | ), 207 | ] 208 | -------------------------------------------------------------------------------- /example/example/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for example project. 3 | 4 | Generated by 'django-admin startproject' using Django 3.1.3. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.1/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/3.1/ref/settings/ 11 | """ 12 | 13 | from pathlib import Path 14 | 15 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 16 | BASE_DIR = Path(__file__).resolve().parent.parent 17 | 18 | 19 | # Quick-start development settings - unsuitable for production 20 | # See https://docs.djangoproject.com/en/3.1/howto/deployment/checklist/ 21 | 22 | # SECURITY WARNING: keep the secret key used in production secret! 23 | SECRET_KEY = "%l*zzug09ec=vsb&xse@ftlzh+bie%)#h(!1zc5&u^xvh*e5d^" 24 | 25 | # SECURITY WARNING: don't run with debug turned on in production! 26 | DEBUG = True 27 | 28 | ALLOWED_HOSTS = ["127.0.0.1", "localhost"] 29 | 30 | 31 | # Application definition 32 | 33 | INSTALLED_APPS = [ 34 | "django.contrib.admin", 35 | "django.contrib.auth", 36 | "django.contrib.contenttypes", 37 | "django.contrib.sessions", 38 | "django.contrib.messages", 39 | "django.contrib.staticfiles", 40 | "saml2_pro_auth", 41 | ] 42 | 43 | MIDDLEWARE = [ 44 | "django.middleware.security.SecurityMiddleware", 45 | "whitenoise.middleware.WhiteNoiseMiddleware", 46 | "django.contrib.sessions.middleware.SessionMiddleware", 47 | "django.middleware.common.CommonMiddleware", 48 | "django.middleware.csrf.CsrfViewMiddleware", 49 | "django.contrib.auth.middleware.AuthenticationMiddleware", 50 | "django.contrib.messages.middleware.MessageMiddleware", 51 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 52 | ] 53 | 54 | ROOT_URLCONF = "example.urls" 55 | 56 | TEMPLATES = [ 57 | { 58 | "BACKEND": "django.template.backends.django.DjangoTemplates", 59 | "DIRS": [ 60 | Path(BASE_DIR, "example/templates"), 61 | ], 62 | "APP_DIRS": True, 63 | "OPTIONS": { 64 | "context_processors": [ 65 | "django.template.context_processors.debug", 66 | "django.template.context_processors.request", 67 | "django.contrib.auth.context_processors.auth", 68 | "django.contrib.messages.context_processors.messages", 69 | ], 70 | }, 71 | }, 72 | ] 73 | 74 | WSGI_APPLICATION = "example.wsgi.application" 75 | 76 | 77 | # Database 78 | # https://docs.djangoproject.com/en/3.1/ref/settings/#databases 79 | 80 | DATABASES = { 81 | "default": { 82 | "ENGINE": "django.db.backends.sqlite3", 83 | "NAME": str(BASE_DIR / "db.sqlite3"), 84 | } 85 | } 86 | 87 | 88 | # Password validation 89 | # https://docs.djangoproject.com/en/3.1/ref/settings/#auth-password-validators 90 | 91 | AUTH_PASSWORD_VALIDATORS = [ 92 | { 93 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", 94 | }, 95 | { 96 | "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", 97 | }, 98 | { 99 | "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", 100 | }, 101 | { 102 | "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", 103 | }, 104 | ] 105 | 106 | AUTHENTICATION_BACKENDS = [ 107 | "django.contrib.auth.backends.ModelBackend", 108 | "saml2_pro_auth.auth.SamlBackend", 109 | ] 110 | 111 | # Internationalization 112 | # https://docs.djangoproject.com/en/3.1/topics/i18n/ 113 | 114 | LANGUAGE_CODE = "en-us" 115 | 116 | TIME_ZONE = "UTC" 117 | 118 | USE_I18N = True 119 | 120 | USE_L10N = True 121 | 122 | USE_TZ = True 123 | 124 | 125 | # Static files (CSS, JavaScript, Images) 126 | # https://docs.djangoproject.com/en/3.1/howto/static-files/ 127 | 128 | STATIC_URL = "/static/" 129 | 130 | LOGIN_URL = "login/" 131 | LOGIN_REDIRECT_URL = "/" 132 | 133 | # TODO: Uncomment and use the below if you want to use this with a database backed cache. 134 | CACHES = { 135 | 'default': { 136 | 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', 137 | }, 138 | 'saml2_pro_auth': { 139 | 'BACKEND': 'django.core.cache.backends.db.DatabaseCache', 140 | 'LOCATION': 'saml2_pro_auth_cache', 141 | 'TIMEOUT': 300, 142 | } 143 | } 144 | 145 | # SAML2_PRO_AUTH Settings 146 | SAML_CACHE = "saml2_pro_auth" 147 | 148 | SAML_REDIRECT = "/" 149 | 150 | SAML_USERS_LOOKUP_ATTRIBUTE = ("username__iexact", "NameId") 151 | 152 | SAML_USERS_SYNC_ATTRIBUTES = True 153 | 154 | SAML_USERS_STRICT_MAPPING = False 155 | 156 | SAML_AUTO_CREATE_USERS = False 157 | 158 | SAML_USERS_MAP = { 159 | "exampleProvider": { 160 | "email": "email", 161 | "first_name": "first_name", 162 | "last_name": "last_name", 163 | }, 164 | "gsuiteProvider": { 165 | "email": "email", 166 | "first_name": "first_name", 167 | "last_name": "last_name", 168 | }, 169 | } 170 | 171 | SAML_PROVIDERS = { 172 | "oktaProvider": { 173 | "strict": True, 174 | "debug": True, 175 | "lowercase_urlencoding": False, 176 | "idp_initiated_auth": False, 177 | "sp": { 178 | "entityId": "https://127.0.0.1:8000/saml/sso/oktaProvider/", 179 | "assertionConsumerService": { 180 | "url": "https://127.0.0.1:8000/saml/acs/oktaProvider/", 181 | "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST", 182 | }, 183 | # SLS not currently implemented 184 | # "singleLogoutService": { 185 | # "url": "https://127.0.0.1:8000/saml/sls/oktaProvider/", 186 | # "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" 187 | # }, 188 | "NameIDFormat": "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified", 189 | ## For the cert/key you can place their content in 190 | ## the x509cert and privateKey params 191 | ## as single-line strings or place them in 192 | ## certs/sp.key and certs/sp.crt or you can supply a 193 | ## path via custom_base_path which should contain 194 | ## sp.crt and sp.key 195 | "x509cert": open(Path(BASE_DIR, "certs/sp.crt"), "r").read(), 196 | "privateKey": open(Path(BASE_DIR, "certs/sp.key"), "r").read(), 197 | }, 198 | "idp": { 199 | "entityId": "https://dev-1234567.okta.com", 200 | "singleSignOnService": { 201 | "url": "https://dev-1234567.okta.com/app/exampledev1234567_localtest_1/kdkdfjdfsklj/sso/saml", 202 | "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect", 203 | }, 204 | # SLS not currently implemented 205 | # "singleLogoutService": { 206 | # "url": "https://kdkdfjdfsklj.my.MyProvider.com/applogout", 207 | # "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" 208 | # }, 209 | "x509cert": "MIIBdDXXAasdadasd...", 210 | }, 211 | "organization": { 212 | "en-US": { 213 | "name": "example inc", 214 | "displayname": "Example Incorporated", 215 | "url": "example.com", 216 | } 217 | }, 218 | "contactPerson": { 219 | "technical": {"givenName": "Jane Doe", "emailAddress": "jdoe@examp.com"}, 220 | "support": {"givenName": "Jane Doe", "emailAddress": "jdoe@examp.com"}, 221 | }, 222 | "security": { 223 | "nameIdEncrypted": False, 224 | "authnRequestsSigned": True, 225 | "logoutRequestSigned": False, 226 | "logoutResponseSigned": False, 227 | "signMetadata": True, 228 | "wantMessagesSigned": True, 229 | "wantAssertionsSigned": True, 230 | "wantAssertionsEncrypted": False, 231 | "wantNameId": True, 232 | "wantNameIdEncrypted": False, 233 | "wantAttributeStatement": True, 234 | "signatureAlgorithm": "http://www.w3.org/2000/09/xmldsig#rsa-sha256", 235 | "digestAlgorithm": "http://www.w3.org/2001/04/xmlenc#sha256", 236 | }, 237 | }, 238 | "gsuiteProvider": { 239 | "strict": True, 240 | "debug": True, 241 | "lowercase_urlencoding": False, 242 | "idp_initiated_auth": False, 243 | "sp": { 244 | "entityId": "https://127.0.0.1:8000/saml/sso/gsuiteProvider/", 245 | "assertionConsumerService": { 246 | "url": "https://127.0.0.1:8000/saml/acs/gsuiteProvider/", 247 | "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST", 248 | }, 249 | # SLS not currently implemented 250 | # "singleLogoutService": { 251 | # "url": "https://127.0.0.1:8000/sso/saml/?sls", 252 | # "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" 253 | # }, 254 | "NameIDFormat": "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified", 255 | ## For the cert/key you can place their content in 256 | ## the x509cert and privateKey params 257 | ## as single-line strings or place them in 258 | ## certs/sp.key and certs/sp.crt or you can supply a 259 | ## path via custom_base_path which should contain 260 | ## sp.crt and sp.key 261 | "x509cert": open(Path(BASE_DIR, "certs/sp.crt"), "r").read(), 262 | "privateKey": open(Path(BASE_DIR, "certs/sp.key"), "r").read(), 263 | }, 264 | "idp": { 265 | "entityId": "https://accounts.google.com/o/saml2?idpid=kdkdfjdfsklj", 266 | "singleSignOnService": { 267 | "url": "https://accounts.google.com/o/saml2/idp?idpid=kdkdfjdfsklj", 268 | "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect", 269 | }, 270 | # SLS not currently implemented 271 | # "singleLogoutService": { 272 | # "url": "https://accounts.google.com/o/saml2/idp?idpid=kdkdfjdfsklj", 273 | # "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" 274 | # }, 275 | "x509cert": "MIIBdDXXAasdadasd...", 276 | }, 277 | "organization": { 278 | "en-US": { 279 | "name": "example inc", 280 | "displayname": "Example Incorporated", 281 | "url": "example.com", 282 | } 283 | }, 284 | "contactPerson": { 285 | "technical": {"givenName": "Jane Doe", "emailAddress": "jdoe@examp.com"}, 286 | "support": {"givenName": "Jane Doe", "emailAddress": "jdoe@examp.com"}, 287 | }, 288 | "security": { 289 | "nameIdEncrypted": False, 290 | "authnRequestsSigned": True, 291 | "logoutRequestSigned": False, 292 | "logoutResponseSigned": False, 293 | "signMetadata": True, 294 | "wantMessagesSigned": True, 295 | "wantAssertionsSigned": False, # True - GSuite doesn't support assertion signing 296 | "wantAssertionsEncrypted": False, # True - GSuite doesn't support assertion encryption 297 | "wantNameId": True, 298 | "wantNameIdEncrypted": False, 299 | "wantAttributeStatement": True, 300 | "signatureAlgorithm": "http://www.w3.org/2000/09/xmldsig#rsa-sha256", 301 | "digestAlgorithm": "http://www.w3.org/2001/04/xmlenc#sha256", 302 | }, 303 | }, 304 | } 305 | 306 | # SAML_OVERRIDE_HOSTNAME = "127.0.0.1" 307 | SAML_PROVIDER_CONFIG_TEMPLATE = { 308 | "strict": True, 309 | "sp": { 310 | "x509cert": open(Path(BASE_DIR, "certs/sp.crt"), "r").read(), 311 | "privateKey": open(Path(BASE_DIR, "certs/sp.key"), "r").read(), 312 | }, 313 | "security": { 314 | "nameIdEncrypted": False, 315 | "authnRequestsSigned": True, 316 | "logoutRequestSigned": True, 317 | "logoutResponseSigned": True, 318 | "signMetadata": True, 319 | "wantMessagesSigned": True, 320 | "wantAssertionsSigned": False, 321 | "wantAssertionsEncrypted": False, 322 | "wantNameId": True, 323 | "wantNameIdEncrypted": False, 324 | "wantAttributeStatement": False, 325 | "signatureAlgorithm": "http://www.w3.org/2000/09/xmldsig#rsa-sha256", 326 | "digestAlgorithm": "http://www.w3.org/2001/04/xmlenc#sha256", 327 | }, 328 | } 329 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Django SAML2 Pro Auth 2 | 3 | SAML2 authentication backend for Django 4 | 5 | --- 6 | 7 | [![build-status-badge]][build-status] 8 | [![pypi-version-badge]][pypi] 9 | [![license-badge]][license] 10 | 11 | [![pypi-pyverions-badge]][pypi] 12 | [![pypi-djverions-badge]][pypi] 13 | [![downloads-badge]][downloads] 14 | 15 | ## Requirements 16 | 17 | - Python (3.6, 3.7, 3.8, 3.9) 18 | - Django (2.2.20, 3.0.14, 3.1.8) 19 | - python3-saml (>=1.9.0) 20 | 21 | We **recommend** and only support patched versions of Python and Django that are still receiving updates. 22 | 23 | ## Installation 24 | 25 | `pip install django-saml2-pro-auth` 26 | 27 | ### Prerequisites 28 | 29 | The [python3-saml] package depends on the [xmlsec] package which requires the installation of native C libraries on your OS of choice. 30 | 31 | You will want to follow the instructions for setting up the native dependencies on your OS of choice. 32 | 33 | ## Configuration 34 | 35 | ### Django Settings 36 | 37 | Here is an example full configuration. Scroll down to read about each option 38 | 39 | ```python 40 | 41 | AUTHENTICATION_BACKENDS = [ 42 | 'saml2_pro_auth.auth.Backend' 43 | ] 44 | 45 | SAML_REDIRECT = '/' 46 | 47 | SAML_USERS_MAP = { 48 | "MyProvider" : { 49 | "email": dict(key="Email", index=0), 50 | "name": dict(key="Username", index=0) 51 | } 52 | } 53 | 54 | 55 | SAML_PROVIDERS = { 56 | "MyProvider": { 57 | "strict": True, 58 | "debug": False, 59 | "custom_base_path": "", # Optional, set if you are reading files from a custom location on disk 60 | "lowercase_urlencoding": False, # This can be set to True to enable ADFS compatibility 61 | "idp_initiated_auth": True, # This can be set to False to disable IdP-initiated auth 62 | "sp": { 63 | "entityId": "https://test.davila.io/sso/saml/metadata", 64 | "assertionConsumerService": { 65 | "url": "https://test.davila.io/sso/saml/?acs", 66 | "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" 67 | }, 68 | "singleLogoutService": { 69 | "url": "https://test.davila.io/sso/saml/?sls", 70 | "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" 71 | }, 72 | "NameIDFormat": "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified", 73 | ## For the cert/key you can place their content in 74 | ## the x509cert and privateKey params 75 | ## as single-line strings or place them in 76 | ## certs/sp.key and certs/sp.crt or you can supply a 77 | ## path via custom_base_path which should contain 78 | ## sp.crt and sp.key 79 | "x509cert": "", 80 | "privateKey": "", 81 | }, 82 | "idp": { 83 | "entityId": "https://kdkdfjdfsklj.my.MyProvider.com/0f3172cf-5aa6-40f4-8023-baf9d0996cec", 84 | "singleSignOnService": { 85 | "url": "https://kdkdfjdfsklj.my.MyProvider.com/applogin/appKey/0f3172cf-5aa6-40f4-8023-baf9d0996cec/customerId/kdkdfjdfsklj", 86 | "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" 87 | }, 88 | "singleLogoutService": { 89 | "url": "https://kdkdfjdfsklj.my.MyProvider.com/applogout", 90 | "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" 91 | }, 92 | "x509cert": open(os.path.join(BASE_DIR,'certs/MyProvider.crt'), 'r').read(), 93 | }, 94 | "organization": { 95 | "en-US": { 96 | "name": "example inc", 97 | "displayname": "Example Incorporated", 98 | "url": "example.com" 99 | } 100 | }, 101 | "contactPerson": { 102 | "technical": { 103 | "givenName": "Jane Doe", 104 | "emailAddress": "jdoe@examp.com" 105 | }, 106 | "support": { 107 | "givenName": "Jane Doe", 108 | "emailAddress": "jdoe@examp.com" 109 | } 110 | }, 111 | "security": { 112 | "nameIdEncrypted": False, 113 | "authnRequestsSigned": True, 114 | "logoutRequestSigned": False, 115 | "logoutResponseSigned": False, 116 | "signMetadata": True, 117 | "wantMessagesSigned": True, 118 | "wantAssertionsSigned": True, 119 | "wantAssertionsEncrypted": True, 120 | "wantNameId": True, 121 | "wantNameIdEncrypted": False, 122 | "wantAttributeStatement": True, 123 | # Algorithm that the toolkit will use on signing process. Options: 124 | # 'http://www.w3.org/2000/09/xmldsig#rsa-sha1' 125 | # 'http://www.w3.org/2000/09/xmldsig#dsa-sha1' 126 | # 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256' 127 | # 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha384' 128 | # 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha512' 129 | "signatureAlgorithm": "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256", 130 | 131 | # Algorithm that the toolkit will use on digest process. Options: 132 | # 'http://www.w3.org/2000/09/xmldsig#sha1' 133 | # 'http://www.w3.org/2001/04/xmlenc#sha256' 134 | # 'http://www.w3.org/2001/04/xmldsig-more#sha384' 135 | # 'http://www.w3.org/2001/04/xmlenc#sha512' 136 | 'digestAlgorithm': "http://www.w3.org/2001/04/xmlenc#sha256" 137 | } 138 | 139 | } 140 | } 141 | ``` 142 | 143 | **AUTHENTICATION_BACKENDS:** This is required exactly as in the example. It tells Django to use this as a valid auth mechanism. 144 | 145 | **SAML_ROUTE (optional, default=/sso/saml/):** This tells Django where to do all SAML related activities. The default route is `/saml/`. You still need to include the source urls in your own `urls.py`. For example: 146 | 147 | ```python 148 | from django.conf import settings 149 | from django.contrib import admin 150 | from django.urls import include, path 151 | 152 | import saml2_pro_auth.urls as saml_urls 153 | 154 | import profiles.urls 155 | import accounts.urls 156 | 157 | from . import views 158 | 159 | urlpatterns = [ 160 | path('', views.HomePage.as_view(), name='home'), 161 | path('about/', views.AboutPage.as_view(), name='about'), 162 | path('users/', include(profiles.urls, namespace='profiles')), 163 | path('admin/', include(admin.site.urls)), 164 | path('', include(accounts.urls, namespace='accounts')), 165 | path('', include(saml_urls, namespace='saml')), 166 | ] 167 | 168 | ``` 169 | 170 | So first import the urls via `import saml2_pro_auth.urls as saml_urls` (it's up to you if you want name it or not). Then add it to your patterns via `path('', include(saml_urls, namespace='saml'))`. This example will give you the default routes that this auth backend provides. You can also add any additional prefix to the path that you want here. 171 | 172 | If you want to use the old function-based view URLs you can import and use those instead. 173 | 174 | ```python 175 | import saml2_pro_auth.function_urls as saml_urls 176 | ``` 177 | 178 | **SAML_OVERRIDE_HOSTNAME (optional, default=""):** This allows you to set a specific hostname to be used in SAML requests. The default method to is detect the hostname from the `request` object. This generally works unless you are behind several layers of proxies or other caching layers. For example, running Django inside a Lambda function that is fronted by API Gateway and CloudFront could pose problems. This setting lets you set the value explicitly. The value should be a simple hostname or dotted path. Do not include a full URL, port, scheme, etc. 179 | 180 | ```python 181 | SAML_OVERRIDE_HOSTNAME = "app.example.org" 182 | ``` 183 | 184 | **SAML_CACHE (optional, default="default"):** This lets you specify a different cache backend configuration if need you a specific type of persistent cache mechanism that differs from the `CACHES["default"]`. A persistent cache is required for only once SAML assertion processing to work. This is an important security mechanism and should not be bypassed. In local development environments, the local memory, dummy, or file caches will work fine. For stateless or multi-server high availability environments you will want to use a shared, persistent cache. Storing this in the Database is likely the easiest solution since the data is small and the number of requests should be minimal. 185 | 186 | If your default cache is not using a shared persistent cache configuration you can add on and update this setting. 187 | 188 | ```python 189 | SAML_CACHE = "saml2_pro_auth" 190 | 191 | CACHES = { 192 | 'saml2_pro_auth': { 193 | 'BACKEND': 'django.core.cache.backends.db.DatabaseCache', 194 | 'LOCATION': 'saml2_pro_auth_cache', 195 | } 196 | } 197 | 198 | ``` 199 | 200 | **SAML_REPLAY_PROTECTION (optional, default=True):** This allows you to disable the only-once assertion processing protection (SAML assertion replay protection) mechanism. It currently relies on a shared persistent caching mechanism that may not be feasible in all environments. It is strongly recommend you to keep this enabled but if there are architectural reasons or there is a low risk of replay attacks then it can still be disabled. 201 | 202 | **SAML_REDIRECT (optional, default=None):** This tells the auth backend where to redirect users after they've logged in via the IdP. **NOTE**: This is not needed for _most_ users. Order of precedence is: SAML_REDIRECT value (if defined), RELAY_STATE provided in the SAML response, and the fallback is simply to go to the root path of your application. 203 | 204 | **SAML_USERS_MAP (required):** This is a dict of user attribute mapping dicts. This is what makes it possible to map the attributes as they come from your IdP into attributes that are part of your User model in Django. There a few ways you can define this. The dict keys (the left-side) are the attributes as defined in YOUR User model, the dict values (the right-side) are the attributes as supplied by your IdP. 205 | 206 | ```python 207 | ## Simplest Approach, when the SAML attributes supplied by the IdP are just plain strings 208 | ## This means my User model has an 'email' and 'name' attribute while my IdP passes 'Email' and 'Username' attrs 209 | SAML_USERS_MAP = { 210 | "myIdp" : { 211 | "email": "Email", 212 | "name": "Username 213 | } 214 | } 215 | ``` 216 | 217 | Sometimes, IdPs might provide values as Arrays (even when it really should just be a string). This package supports that too. For example, suppose your IdP supplied user attributes with the following data structure: 218 | `{"Email": ["foo@example.com"], "Username": "foo"}` 219 | You simply would make the key slightly more complex where `key` is the key and `index` represents the index where the desired value is located. See below: 220 | 221 | ```python 222 | SAML_USERS_MAP = { 223 | "myIdp" : { 224 | "email": {"key": "Email", "index": 0}, 225 | "name": "Username 226 | } 227 | ``` 228 | 229 | And of course, you can use the dict structure even when there IdP supplied attribute isn't an array. For example: 230 | 231 | ```python 232 | SAML_USERS_MAP = { 233 | "myIdp" : { 234 | "email": {"key": "Email"}, 235 | "name": {"key": "Username"} 236 | } 237 | ``` 238 | 239 | **SAML_USERS_LOOKUP_ATTRIBUTE (optional, default=("username", "NameId")):** 240 | A tuple that specifies the User model field and lookup type to be used for object lookup in the database, along with the attribute to match. It defaults to matching `username` to the `NameId` sent from the IdP. If you want to match against a different database field you would update the `key`, if you want to use a different attribute from the IdP you would update the `value`. 241 | 242 | The attribute you match on in the Django User model should have the "unique" flag set. 243 | (In the default User model in django only username has a unique contstraint in the DB, the same email could be used by multiple users) 244 | 245 | This can also include Django field lookup extensions. By default the lookup will be performed as an exact match. If you 246 | have an identity provider that sends case sensitive emails and you are storing the email in the `username` field you can still match emails in your database by using `username__iexact`. Anything before the double underscore will be used as the field name, everything after is used in the Django Query field lookup. 247 | 248 | Defaults to `("username", "NameId")` 249 | 250 | ```python 251 | SAML_USERS_LOOKUP_ATTRIBUTE = ("username__iexact", "NameId") 252 | ``` 253 | 254 | **SAML_USERS_SYNC_ATTRIBUTES (optional):** 255 | Specifies if the user attributes have to be updated at each login with those received from the IdP. 256 | 257 | Defaults to False 258 | 259 | ```python 260 | SAML_USERS_SYNC_ATTRIBUTES = True 261 | ``` 262 | 263 | **SAML_USERS_STRICT_MAPPING (optional):** 264 | Specifies if every user attribute defined in SAML_USER_MAP must be present 265 | in the saml response or not. 266 | 267 | Defaults to True 268 | 269 | ```python 270 | SAML_USERS_STRICT_MAPPING = False 271 | ``` 272 | 273 | If set to False, you can optionally specify a default value in the "SAML_USER_MAP" 274 | dict and it will set the value when the attribute is not present in the IdP response object. 275 | 276 | Example default value setting 277 | 278 | ```python 279 | # set default value for is_superuser and is_staff to False 280 | SAML_USERS_STRICT_MAPPING = False 281 | SAML_USERS_MAP = { 282 | "MyProvider" : { 283 | "email": dict(key="email", index=0), 284 | "username": dict(key="username", index=0), 285 | "is_superuser": dict(key="is_superuser", index=0, default=False), 286 | "is_staff": dict(key="is_staff", index=0, default=False) 287 | } 288 | } 289 | ``` 290 | 291 | **SAML_AUTO_CREATE_USERS (optional):** 292 | Specifies if you want users to be automatically created if they don't already exist in the database. 293 | 294 | Defaults to True 295 | 296 | ```python 297 | SAML_AUTO_CREATE_USERS = False 298 | ``` 299 | 300 | **SAML_PROVIDER_CONFIG_TEMPLATE** This is a base template to use for any `SamlProvider` model instances if you are using the settings model class. You can override any settings in this template to set your base configuration. This also helps you to stay DRY. 301 | 302 | ```python 303 | PROVIDER_CONFIG_TEMPLATE = { 304 | "strict": True, 305 | "sp": { 306 | "x509cert": "", 307 | "privateKey": "", 308 | }, 309 | # No one actually sets these fields in their metadata 310 | # "organization": { 311 | # "en-US": { 312 | # "name": "", 313 | # "displayname": "", 314 | # "url": "", 315 | # } 316 | # }, 317 | # "contactPerson": { 318 | # "technical": {"givenName": "", "emailAddress": ""}, 319 | # "support": {"givenName": "", "emailAddress": ""}, 320 | # }, 321 | "security": { 322 | "nameIdEncrypted": False, 323 | "authnRequestsSigned": True, 324 | "logoutRequestSigned": True, 325 | "logoutResponseSigned": True, 326 | "signMetadata": True, 327 | "wantMessagesSigned": True, 328 | "wantAssertionsSigned": False, 329 | "wantAssertionsEncrypted": False, 330 | "wantNameId": True, 331 | "wantNameIdEncrypted": False, 332 | "wantAttributeStatement": False, 333 | "signatureAlgorithm": "http://www.w3.org/2000/09/xmldsig#rsa-sha256", 334 | "digestAlgorithm": "http://www.w3.org/2001/04/xmlenc#sha256", 335 | }, 336 | } 337 | ``` 338 | 339 | **SAML_PROVIDERS:** This is an extended version of the OneLogin spec [python-saml and python3-saml packages](https://github.com/onelogin/python3-saml#settings). The big difference is here you supply a dict of settings dicts where the top most key(s) must map 1:1 to the top most keys in `SAML_USERS_MAP`. Also, this package allows you to ref the cert/key files via `open()` calls. This is to allow those of you with multiple external customers to login to your platform with any N number of IdPs. 340 | 341 | **NOTE:** Provider names (top level keys in the settings dict) must adhere to a `slug` like set of characters `[\w-]+` or `a-zA-Z0-9_-`. 342 | 343 | Extensions to the OneLogin settings dict spec: 344 | 345 | - The `lowercase_urlencoding` setting (default=False) can be specifed in your settings dict per provider. This allows you to support ADFS IdPs. 346 | - The `idp_initiated_auth` setting (default=True) can be specified in your settings dict per providfer This allows you to disable IdP-initiated flows on a provider-by-provider basis. You may want to consider disable IdP-initiated flows to avoid accepting unsolicited SAML assertions and eliminate a small class of vulnerabilities and potential CSRF attacks. Setting this value to `False` will disable IdP-initiated auth. 347 | 348 | ## Class-based View Routes 349 | 350 | | **Route** | **Uses** | 351 | |-------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| 352 | | `/saml/acs//` | The Assertion Consumer Service Endpoint. This is where your IdP will be POSTing assertions. | 353 | | `/saml/sso//` | Use this endpoint when you want to trigger an SP-initiated login. For example, this could be the `href` of a "Login with ClientX Okta" button. | 354 | | `/saml/metadata//` | This is where the SP (ie your Django App) has metadata. Some IdPs request this to generate configuration. | 355 | 356 | The class-based views and routes use a custom path converter `` to create URLs from provider name strings or to automatically match a top level key of your SAML_PROVIDERS settings on requests. This also has the benefit of returning the provider settings dict and sending it to the View automatically. You must ensure that your provider names adhere to a `slug` like set of characters `[\w-]+`. 357 | 358 | ## Legacy (Function-based View) Routes 359 | 360 | | **Route** | **Uses** | 361 | |-------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| 362 | | `/sso/saml/?acs&provider=MyProvider` | The Assertion Consumer Service Endpoint. This is where your IdP will be POSTing assertions. The 'provider' query string must have a value that matches a top level key of your SAML_PROVIDERS settings. | 363 | | `/sso/saml/metadata?provider=MyProvider` | This is where the SP (ie your Django App) has metadata. Some IdPs request this to generate configuration. The 'provider' query string must have a value that matches a top level key of your SAML_PROVIDERS settings. | 364 | | `/sso/saml/?provider=MyProvider` | Use this endpoint when you want to trigger an SP-initiated login. For example, this could be the `href`of a "Login with ClientX Okta" button. | 365 | 366 | ## Reverse URLs 367 | 368 | You can reference the above URLs using the standard Django `{% url ... %}` template tag or `reverse(...)` function. 369 | 370 | ```django 371 | {% url 'saml:metadata' provider='MyProvider' %} 372 | {% url 'saml:sso' provider='MyProvider' %} 373 | ``` 374 | 375 | Or for the function-based routes. 376 | 377 | ```django 378 | {% url 'saml:metadata' %}?provider=MyProvider 379 | {% url 'saml:saml2_auth' %}?acs&provider=MyProvider 380 | ``` 381 | 382 | ## Gotchas 383 | 384 | The following are things that you may run into issue with. Here are some tips. 385 | 386 | - Ensure the value of the SP `entityId` config matches up with what you supply in your IdPs configuration. 387 | - Your IdP may default to particular Signature type, usually `Assertion` or `Response` are the options. Depending on how you define your SAML provider config, it will dictate what this value should be. 388 | 389 | ## Wishlist and TODOs 390 | 391 | The following are things that arent present yet but would be cool to have 392 | 393 | - Implement logic for Single Logout Service 394 | - Integration test with full on mock saml interactions to test the actual backend auth 395 | - Tests add coverage to views and the authenticate() get_user() methods in the auth backend 396 | - Models (with multi-tentant support) for idp and sp in order to facilitate management via django admin 397 | - Add a proper CHANGELOG to release process. 398 | 399 | ## Release Process 400 | 401 | The following release process is manual for now but may be integrated into a CI action in the future. 402 | 403 | All code contributions are merged to the main branch through a standard pull request, test, review, merge process. At certain intervals new releases should be cut and pushed to PyPI. This is the standard process for creating new releases from the main branch. 404 | 405 | 1. Update the version information in `setup.cfg` e.g., `version = X.Y.Z` 406 | 1. Create a new `git` tag with the same version 407 | 408 | ```sh 409 | git tag -a -s vX.Y.Z -m 'Version X.Y.Z' 410 | ``` 411 | 412 | - `-s` requires you to have GPG and signing properly setup. 413 | 1. Push the tags to the remote 414 | 415 | ```sh 416 | git push --follow-tags origin vX.Y.Z 417 | ``` 418 | 419 | 1. Create the source and binary distributions and upload to PyPI. 420 | 421 | ```sh 422 | # runs 423 | # python setup.py sdist bdist_wheel 424 | # twine check dist/* 425 | tox -f build 426 | # upload to test pypi 427 | twine upload testpypi dist/* 428 | # upload to production pypi 429 | twine upload dist/* 430 | ``` 431 | 432 | 1. Create a release on GitHub 433 | 434 | [build-status]: https://github.com/zibasec/django-saml2-pro-auth/actions?query=workflow%3Abuild-and-test+branch%3Amaster 435 | [build-status-badge]: https://img.shields.io/github/workflow/status/zibasec/django-saml2-pro-auth/build-and-test/master 436 | [license]: https://raw.githubusercontent.com/zibasec/django-saml2-pro-auth/master/LICENSE 437 | [license-badge]: https://img.shields.io/github/license/zibasec/django-saml2-pro-auth 438 | [pypi]: https://pypi.org/project/django-saml2-pro-auth/ 439 | [pypi-version-badge]: https://img.shields.io/pypi/v/django-saml2-pro-auth.svg 440 | [pypi-pyverions-badge]: https://img.shields.io/pypi/pyversions/django-saml2-pro-auth.svg 441 | [pypi-djverions-badge]: https://img.shields.io/pypi/djversions/django-saml2-pro-auth.svg 442 | [downloads]: https://pepy.tech/project/django-saml2-pro-auth 443 | [downloads-badge]: https://pepy.tech/badge/django-saml2-pro-auth 444 | [python3-saml]: https://github.com/onelogin/python3-saml 445 | [xmlsec]: https://pypi.org/project/xmlsec/ 446 | --------------------------------------------------------------------------------