├── 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 |
6 |
7 |
8 |
9 |
Session
10 |
11 | {% if not request.session.items %}Empty{% endif %}
12 |
13 | {% for item in request.session.items %}
14 | - {{item.0}}: {{item.1}}
15 | {% endfor %}
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 | {% if errors %}
25 |
26 |
Errors:
27 |
28 | {% for err in errors %}
29 | - {{err}}
30 | {% endfor %}
31 |
32 | {% if error_reason %}
33 |
{{error_reason}}
34 | {% endif %}
35 |
36 |
37 |
38 |
39 |
40 |
Errors:
41 |
42 | {% for err in errors %}
43 | - {{err}}
44 | {% endfor %}
45 |
46 | {% if error_reason %}
47 |
Reason: {{error_reason}}
48 | {% endif %}
49 |
50 | {% endif %}
51 |
52 | {% if not request.user.is_authenticated %}
53 |
54 |
Not authenticated
55 |
56 |
57 |
58 |
59 | {% endif %}
60 |
61 | {% if success_slo %}
62 |
63 |
Successfully logged out
64 |
65 |
66 |
67 |
68 | {% endif %}
69 |
70 |
71 | {% if request.user.is_authenticated %}
72 |
73 | {% if request.session.samlUserdata %}
74 |
75 |
76 |
77 | | Name |
78 | Value |
79 |
80 |
81 |
82 | {% for attr in request.session.samlUserdata.items %}
83 |
84 | | {{ attr.0 }} |
85 |
86 | {% for val in attr.1 %}
87 | {{ val }}
88 | {% endfor %}
89 | |
90 |
91 | {% endfor %}
92 |
93 |
94 | {% else %}
95 |
96 |
You don't have any attributes
97 |
98 |
99 |
100 |
101 | {% endif %}
102 |
103 |
104 |
105 |
Logout (SLO Not Implemented)
106 | {% else %}
107 |
114 | {% endif %}
115 |
116 |
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 |
--------------------------------------------------------------------------------