├── testapp ├── __init__.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ └── bootstrap.py ├── wsgi.py ├── urls.py ├── views.py ├── templates │ └── home.html └── settings.py ├── sp ├── migrations │ ├── __init__.py │ ├── 0011_idp_require_attributes.py │ ├── 0005_auto_20210323_1526.py │ ├── 0007_auto_20210323_1535.py │ ├── 0015_idp_logout_request_signed_idp_logout_response_signed.py │ ├── 0006_remove_idp_slug.py │ ├── 0013_idp_prepare_request_method_idp_update_user_method.py │ ├── 0014_alter_idp_options_idp_sort_order.py │ ├── 0004_auto_20200401_1328.py │ ├── 0003_auto_20200331_1934.py │ ├── 0012_idp_authn_comparison_idp_authn_context.py │ ├── 0010_auto_20210326_1923.py │ ├── 0008_auto_20210323_1644.py │ ├── 0009_idpuser.py │ ├── 0002_auto_20191205_1758.py │ └── 0001_initial.py ├── __init__.py ├── apps.py ├── templates │ ├── admin │ │ └── sp │ │ │ └── idp │ │ │ └── change_form.html │ └── sp │ │ ├── error.html │ │ ├── unauth.html │ │ └── test.html ├── urls.py ├── backends.py ├── admin.py ├── utils.py ├── views.py └── models.py ├── MANIFEST.in ├── .gitignore ├── setup.py ├── manage.py ├── setup.cfg ├── LICENSE ├── CHANGELOG.md └── README.md /testapp/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /sp/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /testapp/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /testapp/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | recursive-include sp/templates * 2 | -------------------------------------------------------------------------------- /sp/__init__.py: -------------------------------------------------------------------------------- 1 | __version_info__ = (0, 8, 0) 2 | __version__ = ".".join(str(i) for i in __version_info__) 3 | 4 | default_app_config = "sp.apps.SPConfig" 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.egg-info 3 | __pycache__ 4 | .DS_Store 5 | .project 6 | .pydevproject 7 | .vscode 8 | .nova 9 | /db.sqlite3 10 | /dist 11 | /build 12 | /pip-wheel-metadata 13 | -------------------------------------------------------------------------------- /testapp/wsgi.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.core.wsgi import get_wsgi_application 4 | 5 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testapp.settings") 6 | application = get_wsgi_application() 7 | -------------------------------------------------------------------------------- /sp/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | 5 | class SPConfig(AppConfig): 6 | name = "sp" 7 | verbose_name = _("SAML SP") 8 | default_auto_field = "django.db.models.AutoField" 9 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | # Note, to build a wheel that does not include testapp (bdist_wheel does not respect 4 | # find/exclude): 5 | # python setup.py sdist && pip wheel --no-index --no-deps --wheel-dir dist dist/*.tar.gz 6 | 7 | setup() 8 | -------------------------------------------------------------------------------- /testapp/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.urls import include, path 3 | 4 | from . import views 5 | 6 | urlpatterns = [ 7 | path("", views.home, name="home"), 8 | path("sso//", include("sp.urls")), 9 | path("admin/", admin.site.urls), 10 | ] 11 | -------------------------------------------------------------------------------- /testapp/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render 2 | 3 | from sp.models import IdP 4 | from sp.utils import get_session_idp 5 | 6 | 7 | def home(request): 8 | return render( 9 | request, 10 | "home.html", 11 | {"idp": get_session_idp(request), "idps": IdP.objects.filter(is_active=True)}, 12 | ) 13 | -------------------------------------------------------------------------------- /sp/templates/admin/sp/idp/change_form.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/change_form.html" %} 2 | 3 | {% load i18n %} 4 | 5 | {% block object-tools-items %} 6 |
  • {% trans "View Metadata" %}
  • 7 |
  • {% trans "Test IdP" %}
  • 8 | {{ block.super }} 9 | {% endblock object-tools-items %} 10 | -------------------------------------------------------------------------------- /sp/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from . import views 4 | 5 | urlpatterns = [ 6 | path("", views.metadata, name="sp-idp-metadata"), 7 | path("acs/", views.acs, name="sp-idp-acs"), 8 | path("slo/", views.slo, name="sp-idp-slo"), 9 | path("login/", views.login, name="sp-idp-login"), 10 | path("test/", views.login, {"test": True}, name="sp-idp-test"), 11 | path("verify/", views.login, {"verify": True}, name="sp-idp-verify"), 12 | path("logout/", views.logout, name="sp-idp-logout"), 13 | ] 14 | -------------------------------------------------------------------------------- /sp/migrations/0011_idp_require_attributes.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.5 on 2022-06-15 15:37 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("sp", "0010_auto_20210326_1923"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="idp", 14 | name="require_attributes", 15 | field=models.BooleanField( 16 | default=True, 17 | help_text="Ensures the IdP provides attributes on responses.", 18 | ), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /sp/migrations/0005_auto_20210323_1526.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.7 on 2021-03-23 15:26 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("sp", "0004_auto_20200401_1328"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="idp", 14 | name="url_params", 15 | field=models.JSONField( 16 | verbose_name="URL Parameters", 17 | default=None, 18 | null=True, 19 | help_text="Application-specific URL path parameters.", 20 | ), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /sp/migrations/0007_auto_20210323_1535.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.7 on 2021-03-23 15:35 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("sp", "0006_remove_idp_slug"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="idp", 14 | name="url_params", 15 | field=models.JSONField( 16 | verbose_name="URL Parameters", 17 | default=dict, 18 | blank=True, 19 | help_text="Application-specific URL path parameters.", 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 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testapp.settings") 9 | try: 10 | from django.core.management import execute_from_command_line 11 | except ImportError as exc: 12 | raise ImportError( 13 | "Couldn't import Django. Are you sure it's installed and " 14 | "available on your PYTHONPATH environment variable? Did you " 15 | "forget to activate a virtual environment?" 16 | ) from exc 17 | execute_from_command_line(sys.argv) 18 | 19 | 20 | if __name__ == "__main__": 21 | main() 22 | -------------------------------------------------------------------------------- /sp/migrations/0015_idp_logout_request_signed_idp_logout_response_signed.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0 on 2023-12-04 15:29 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("sp", "0014_alter_idp_options_idp_sort_order"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="idp", 14 | name="logout_request_signed", 15 | field=models.BooleanField(default=False), 16 | ), 17 | migrations.AddField( 18 | model_name="idp", 19 | name="logout_response_signed", 20 | field=models.BooleanField(default=False), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /sp/migrations/0006_remove_idp_slug.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.7 on 2021-03-23 15:29 2 | 3 | from django.db import migrations 4 | 5 | 6 | def move_slug(apps, schema_editor): 7 | IdP = apps.get_model("sp", "IdP") 8 | db_alias = schema_editor.connection.alias 9 | for idp in IdP.objects.using(db_alias).all(): 10 | idp.url_params = {"idp_slug": idp.slug} 11 | idp.save() 12 | 13 | 14 | class Migration(migrations.Migration): 15 | dependencies = [ 16 | ("sp", "0005_auto_20210323_1526"), 17 | ] 18 | 19 | operations = [ 20 | migrations.RunPython(move_slug), 21 | migrations.RemoveField( 22 | model_name="idp", 23 | name="slug", 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /sp/migrations/0013_idp_prepare_request_method_idp_update_user_method.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.5 on 2022-10-04 18:33 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("sp", "0012_idp_authn_comparison_idp_authn_context"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="idp", 14 | name="prepare_request_method", 15 | field=models.CharField(blank=True, max_length=200), 16 | ), 17 | migrations.AddField( 18 | model_name="idp", 19 | name="update_user_method", 20 | field=models.CharField(blank=True, max_length=200), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /sp/migrations/0014_alter_idp_options_idp_sort_order.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.7 on 2023-11-29 15:40 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("sp", "0013_idp_prepare_request_method_idp_update_user_method"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterModelOptions( 13 | name="idp", 14 | options={ 15 | "ordering": ("sort_order", "name"), 16 | "verbose_name": "identity provider", 17 | }, 18 | ), 19 | migrations.AddField( 20 | model_name="idp", 21 | name="sort_order", 22 | field=models.IntegerField(default=0), 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /sp/templates/sp/error.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 3 | 4 | 5 | {% trans "SSO Error" %} - {{ idp }} 6 | 7 | 8 | 9 |
    10 |

    {% trans "SSO Error" %}

    11 |

    {{ reason }}

    12 |

    {% trans "State" %}: {{ state }}

    13 | {% if errors %} 14 | 19 | {% endif %} 20 |
    21 | 22 | 23 | -------------------------------------------------------------------------------- /sp/templates/sp/unauth.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 3 | 4 | 5 | {% trans "Authentication Error" %} - {{ idp }} 6 | 7 | 8 | 9 |
    10 |

    {% if verify %}{% trans "Verification Error" %}{% else %}{% trans "Authentication Error" %}{% endif %}

    11 |

    {% blocktrans with nameid=nameid %}Could not authenticate user "{{ nameid }}".{% endblocktrans %}

    12 | {% if verify %}

    {% blocktrans with user=user %}Expected verification credentials for "{{ user }}".{% endblocktrans %}

    {% endif %} 13 |
    14 | 15 | 16 | -------------------------------------------------------------------------------- /sp/migrations/0004_auto_20200401_1328.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.4 on 2020-04-01 13:28 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("sp", "0003_auto_20200331_1934"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="idp", 14 | name="authenticate_method", 15 | field=models.CharField(blank=True, max_length=200), 16 | ), 17 | migrations.AlterField( 18 | model_name="idp", 19 | name="base_url", 20 | field=models.CharField( 21 | help_text=( 22 | "Root URL for the site, including http/https, no trailing slash." 23 | ), 24 | max_length=200, 25 | verbose_name="Base URL", 26 | ), 27 | ), 28 | migrations.AlterField( 29 | model_name="idp", 30 | name="login_method", 31 | field=models.CharField(blank=True, max_length=200), 32 | ), 33 | ] 34 | -------------------------------------------------------------------------------- /sp/migrations/0003_auto_20200331_1934.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.4 on 2020-03-31 19:34 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("sp", "0002_auto_20191205_1758"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="idp", 14 | name="base_url", 15 | field=models.CharField( 16 | help_text=( 17 | "Root URL for the site, including http/https, no trailing slash." 18 | ), 19 | max_length=200, 20 | verbose_name="Base URL", 21 | ), 22 | ), 23 | migrations.AlterField( 24 | model_name="idpattribute", 25 | name="is_nameid", 26 | field=models.BooleanField( 27 | default=False, 28 | help_text=( 29 | "Check if this should be the unique identifier of the SSO " 30 | "identity." 31 | ), 32 | verbose_name="Is NameID", 33 | ), 34 | ), 35 | ] 36 | -------------------------------------------------------------------------------- /sp/migrations/0012_idp_authn_comparison_idp_authn_context.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.8 on 2022-11-02 18:55 2 | 3 | from django.db import migrations, models 4 | 5 | import sp.models 6 | 7 | PPT = "urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport" 8 | 9 | 10 | class Migration(migrations.Migration): 11 | dependencies = [ 12 | ("sp", "0011_idp_require_attributes"), 13 | ] 14 | 15 | operations = [ 16 | migrations.AddField( 17 | model_name="idp", 18 | name="authn_comparison", 19 | field=models.CharField( 20 | default="exact", 21 | help_text="The Comparison attribute on RequestedAuthnContext.", 22 | max_length=100, 23 | ), 24 | ), 25 | migrations.AddField( 26 | model_name="idp", 27 | name="authn_context", 28 | field=models.JSONField( 29 | default=sp.models._default_authn_context, 30 | help_text=( 31 | f"true ({PPT}), false, or a list of AuthnContextClassRef names." 32 | ), 33 | ), 34 | ), 35 | ] 36 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = django-saml-sp 3 | version = attr: sp.__version__ 4 | url = https://github.com/imsweb/django-saml-sp 5 | author = Dan Watson 6 | author_email = watsond@imsweb.com 7 | description = A Django application for running one or more SAML service providers (SP). 8 | long_description = file: README.md 9 | long_description_content_type = text/markdown 10 | license = BSD-3-Clause 11 | classifiers = 12 | Development Status :: 3 - Alpha 13 | Environment :: Web Environment 14 | Framework :: Django 15 | Intended Audience :: Developers 16 | License :: OSI Approved :: BSD License 17 | Operating System :: OS Independent 18 | Programming Language :: Python 19 | Programming Language :: Python :: 3 20 | Programming Language :: Python :: 3 :: Only 21 | project_urls = 22 | Source = https://github.com/imsweb/django-saml-sp 23 | 24 | [options] 25 | python_requires = >=3.6 26 | packages = sp, sp.migrations 27 | include_package_data = true 28 | zip_safe = false 29 | install_requires = 30 | cryptography 31 | python3-saml 32 | 33 | [flake8] 34 | max-line-length = 88 35 | exclude = .git,__pycache__,build,dist 36 | ignore = E231,W503 37 | 38 | [isort] 39 | profile = black 40 | -------------------------------------------------------------------------------- /sp/migrations/0010_auto_20210326_1923.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.7 on 2021-03-26 19:23 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("sp", "0009_idpuser"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="idp", 14 | name="associate_users", 15 | field=models.BooleanField( 16 | default=True, 17 | verbose_name="Associate existing users with this IdP by username", 18 | ), 19 | ), 20 | migrations.AddField( 21 | model_name="idp", 22 | name="username_prefix", 23 | field=models.CharField( 24 | blank=True, 25 | max_length=20, 26 | help_text="Prefix for usernames generated by this IdP", 27 | ), 28 | ), 29 | migrations.AddField( 30 | model_name="idp", 31 | name="username_suffix", 32 | field=models.CharField( 33 | blank=True, 34 | max_length=20, 35 | help_text="Suffix for usernames generated by this IdP", 36 | ), 37 | ), 38 | ] 39 | -------------------------------------------------------------------------------- /sp/migrations/0008_auto_20210323_1644.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.7 on 2021-03-23 16:44 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("sp", "0007_auto_20210323_1535"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="idp", 14 | name="logout_method", 15 | field=models.CharField(blank=True, max_length=200), 16 | ), 17 | migrations.AddField( 18 | model_name="idp", 19 | name="logout_redirect", 20 | field=models.CharField( 21 | blank=True, 22 | help_text="URL name or path to redirect after logout.", 23 | max_length=200, 24 | ), 25 | ), 26 | migrations.AddField( 27 | model_name="idp", 28 | name="logout_triggers_slo", 29 | field=models.BooleanField( 30 | verbose_name="Logout triggers SLO", 31 | default=False, 32 | help_text=( 33 | "Whether logging out should trigger a SLO request to the IdP." 34 | ), 35 | ), 36 | ), 37 | migrations.AddField( 38 | model_name="idp", 39 | name="state_timeout", 40 | field=models.IntegerField( 41 | default=60, 42 | help_text=( 43 | "Time (in seconds) the SAML login request state is valid for." 44 | ), 45 | ), 46 | ), 47 | ] 48 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2019, Information Management Services, Inc. 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /sp/migrations/0009_idpuser.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.7 on 2021-03-24 19:54 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("contenttypes", "0002_remove_content_type_name"), 10 | ("sp", "0008_auto_20210323_1644"), 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name="IdPUser", 16 | fields=[ 17 | ( 18 | "id", 19 | models.AutoField( 20 | auto_created=True, 21 | primary_key=True, 22 | serialize=False, 23 | verbose_name="ID", 24 | ), 25 | ), 26 | ("nameid", models.CharField(db_index=True, max_length=200)), 27 | ("user_id", models.CharField(max_length=100)), 28 | ( 29 | "content_type", 30 | models.ForeignKey( 31 | on_delete=django.db.models.deletion.CASCADE, 32 | related_name="idp_users", 33 | to="contenttypes.contenttype", 34 | ), 35 | ), 36 | ( 37 | "idp", 38 | models.ForeignKey( 39 | on_delete=django.db.models.deletion.CASCADE, 40 | related_name="users", 41 | to="sp.idp", 42 | ), 43 | ), 44 | ], 45 | options={ 46 | "unique_together": { 47 | ("idp", "content_type", "user_id"), 48 | ("idp", "nameid"), 49 | }, 50 | }, 51 | ), 52 | ] 53 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 0.8.0 2 | 3 | * Send `nameid` and `nameid_format` on SLO requests (#25). 4 | * Add a `sort_order` to the `IdP` model. 5 | 6 | 7 | ## 0.7.0 8 | 9 | * Updated certificate signing algorithm to SHA256 (#23). 10 | * Refactored usage of `RelayState` to not do unnecessary signing. This parameter is limited to 80 characters, almost all of which were being taken by the signature and timestamp, leaving very little room for redirect URLs. 11 | 12 | 13 | ## 0.6.1 14 | 15 | * Fix an issue with migrations on Oracle (#21). 16 | 17 | 18 | ## 0.6.0 19 | 20 | * Allow customization of `prepare_request` via a new `SP_PREPARE_REQUEST` setting, and a new `IdP.prepare_request_method` field. 21 | * Allow customization of how users are created and updated via a new `SP_UPDATE_USER` setting, and a new `IdP.update_user_method` field. Also make `SAMLAuthenticationBackend` more subclassing-friendly by having an `update_user` method available to override. 22 | * Support IdP-based session expiration with `JSONSerializer` when using Django 4.1 or later. 23 | 24 | 25 | ## 0.5.0 26 | 27 | * Removed `IdP.slug` in favor of an `IdP.url_params` JSON field containing the URL parameters that uniquely identify a configured IdP. Since unique JSON fields are not supported on all databases, you should ensure the the parameters are unique in your application. 28 | * Added an `SP_LOGOUT` setting, as well as `IdP.logout_method` and `IdP.logout_redirect` model fields to customize the logout process. 29 | * Support single logout (SLO), along with a new `IdP.logout_triggers_slo` to determine if a site logout should trigger an IdP SLO. 30 | 31 | **Upgrading from 0.4 [BREAKING]**: You will need to change your included URLs to have an `` path parameter. For instance, `path("sso/", include("sp.urls"))` becomes `path("sso//", include("sp.urls"))`. Going forward, you can use whatever path prefixes you like, named however you want. This is just for migrating existing IdPs. 32 | -------------------------------------------------------------------------------- /sp/templates/sp/test.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 3 | 4 | 5 | {% trans "IdP Test" %} - {{ idp }} 6 | 7 | 8 | 9 |
    10 |
    11 |

    {{ idp }}

    12 |
    13 | 14 |
    15 |
    {% trans "Name ID Format" %}
    16 |
    {{ nameid_format }}
    17 | 18 |
    {% trans "Name ID" %}
    19 |
    {{ nameid }}
    20 | 21 | {% if redir %} 22 |
    {% trans "Redirect" %}
    23 |
    {{ redir }}
    24 | {% endif %} 25 |
    26 | 27 |
    28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | {% for attr, value in attrs %} 39 | 40 | 41 | 42 | 43 | 44 | 45 | {% endfor %} 46 | 47 |
    {% trans "SAML Attribute" %}{% trans "Mapped Name" %}{% trans "NameID" %}{% trans "Value" %}
    {{ attr.saml_attribute }}{{ attr.mapped_name }}{% if attr.is_nameid %}✔{% endif %}{{ value }}
    48 |
    49 |
    50 | 51 | 52 | -------------------------------------------------------------------------------- /testapp/templates/home.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 3 | 4 | {% trans "SP Test Application" %} 5 | 6 | 7 | 8 |
    9 |

    {% trans "SP Test Application" %}

    10 | {% if user.is_authenticated %} 11 |

    {% blocktrans with user=user idp=idp|default:"Django" %}You are logged in as {{ user }} via {{ idp }}.{% endblocktrans %}

    12 |

    13 | {% if idp %} 14 | {% trans "Test" %} 15 | {% trans "Verify" %} 16 | {% trans "Log Out" %} 17 | {% else %} 18 | {% trans "Log Out" %} 19 | {% endif %} 20 |

    21 | {% else %} 22 |

    {% trans "You are not logged in." %}

    23 | 34 | {% endif %} 35 |
    36 | 37 | 38 | -------------------------------------------------------------------------------- /testapp/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 4 | 5 | SECRET_KEY = "%0b+&60t$-lmbewumz1$(43$0rw-2aaz8^#iw87d(-7vcq82pe" 6 | DEBUG = True 7 | ALLOWED_HOSTS = ["*"] 8 | 9 | INSTALLED_APPS = [ 10 | "django.contrib.admin", 11 | "django.contrib.auth", 12 | "django.contrib.contenttypes", 13 | "django.contrib.sessions", 14 | "django.contrib.messages", 15 | "django.contrib.staticfiles", 16 | "sp", 17 | "testapp", 18 | ] 19 | 20 | MIDDLEWARE = [ 21 | "django.middleware.security.SecurityMiddleware", 22 | "django.contrib.sessions.middleware.SessionMiddleware", 23 | "django.middleware.common.CommonMiddleware", 24 | "django.middleware.csrf.CsrfViewMiddleware", 25 | "django.contrib.auth.middleware.AuthenticationMiddleware", 26 | "django.contrib.messages.middleware.MessageMiddleware", 27 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 28 | ] 29 | 30 | ROOT_URLCONF = "testapp.urls" 31 | 32 | TEMPLATES = [ 33 | { 34 | "BACKEND": "django.template.backends.django.DjangoTemplates", 35 | "DIRS": [], 36 | "APP_DIRS": True, 37 | "OPTIONS": { 38 | "context_processors": [ 39 | "django.template.context_processors.debug", 40 | "django.template.context_processors.request", 41 | "django.contrib.auth.context_processors.auth", 42 | "django.contrib.messages.context_processors.messages", 43 | ], 44 | }, 45 | }, 46 | ] 47 | 48 | WSGI_APPLICATION = "testapp.wsgi.application" 49 | 50 | DATABASES = { 51 | "default": { 52 | "ENGINE": "django.db.backends.sqlite3", 53 | "NAME": os.path.join(BASE_DIR, "db.sqlite3"), 54 | } 55 | } 56 | 57 | DEFAULT_AUTO_FIELD = "django.db.models.AutoField" 58 | 59 | LANGUAGE_CODE = "en-us" 60 | TIME_ZONE = "UTC" 61 | USE_I18N = True 62 | USE_L10N = True 63 | USE_TZ = True 64 | 65 | STATIC_URL = "/static/" 66 | 67 | AUTHENTICATION_BACKENDS = [ 68 | "django.contrib.auth.backends.ModelBackend", 69 | "sp.backends.SAMLAuthenticationBackend", 70 | ] 71 | 72 | LOGIN_REDIRECT_URL = "home" 73 | LOGOUT_REDIRECT_URL = "home" 74 | -------------------------------------------------------------------------------- /testapp/management/commands/bootstrap.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | from django.core.management.base import BaseCommand 3 | 4 | from sp.models import IdP 5 | 6 | 7 | class Command(BaseCommand): 8 | help = 'Bootstraps the SP with a default "admin" user and a local test IdP.' 9 | 10 | def handle(self, *args, **options): 11 | User = get_user_model() 12 | if User.objects.count() == 0: 13 | print( 14 | 'Creating default "admin" account with password "letmein" ' 15 | "-- change this immediately!" 16 | ) 17 | User.objects.create_superuser( 18 | "admin", 19 | "admin@example.com", 20 | "letmein", 21 | first_name="Admin", 22 | last_name="User", 23 | ) 24 | admin = User.objects.filter(is_superuser=True).first() 25 | if IdP.objects.count() == 0: 26 | print('Creating "local" IdP for http://localhost:8000') 27 | idp = IdP.objects.create( 28 | name="Local SimpleSAML Provider", 29 | url_params={"idp_slug": "local"}, 30 | base_url="http://localhost:8000", 31 | contact_name=admin.get_full_name(), 32 | contact_email=admin.email, 33 | metadata_url="http://localhost:8080/simplesaml/saml2/idp/metadata.php", 34 | respect_expiration=True, 35 | logout_triggers_slo=True, 36 | ) 37 | idp.generate_certificate() 38 | # The local IdP sends an email address, but it isn't the nameid. Override it 39 | # to be our nameid, AND set the email field on User. 40 | idp.attributes.create( 41 | saml_attribute="email", mapped_name="email", is_nameid=True 42 | ) 43 | try: 44 | idp.import_metadata() 45 | except Exception: 46 | print( 47 | "Could not import IdP metadata; " 48 | "make sure your local IdP exposes {}".format(idp.metadata_url) 49 | ) 50 | 51 | print('Creating "stub" IdP at https://stubidp.sustainsys.com/Metadata') 52 | idp = IdP.objects.create( 53 | name="Sustainsys Stub", 54 | url_params={"idp_slug": "stub"}, 55 | base_url="http://localhost:8000", 56 | contact_name=admin.get_full_name(), 57 | contact_email=admin.email, 58 | metadata_url="https://stubidp.sustainsys.com/Metadata", 59 | logout_triggers_slo=True, 60 | require_attributes=False, 61 | ) 62 | idp.generate_certificate() 63 | try: 64 | idp.import_metadata() 65 | except Exception: 66 | print( 67 | "Could not import IdP metadata; " 68 | "make sure {} is available to download".format(idp.metadata_url) 69 | ) 70 | -------------------------------------------------------------------------------- /sp/backends.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from django.conf import settings 4 | from django.contrib.auth import get_user_model 5 | from django.contrib.auth.backends import ModelBackend 6 | 7 | from .models import IdPUser 8 | 9 | UserModel = get_user_model() 10 | 11 | 12 | class SAMLAuthenticationBackend(ModelBackend): 13 | def get_username(self, idp, saml): 14 | """ 15 | For users not already associated with the IdP, generate a username to either 16 | look up and associate, or to use when creating a new User. 17 | """ 18 | # Start with either the SAML nameid, or SAML attribute mapped to nameid. 19 | username = idp.get_nameid(saml) 20 | # Add IdP-specific prefix and suffix. 21 | username = idp.username_prefix + username + idp.username_suffix 22 | # Make sure the username is valid for Django's User model. 23 | username = re.sub(r"[^a-zA-Z0-9_@\+\.]", "-", username) 24 | # Make the username unique to the IdP, if SP_UNIQUE_USERNAMES is True. 25 | if getattr(settings, "SP_UNIQUE_USERNAMES", True): 26 | username += "-" + str(idp.pk) 27 | return username 28 | 29 | def authenticate(self, request, idp=None, saml=None): 30 | # The nameid (potentially mapped) to associate a User with an IdP. 31 | nameid = idp.get_nameid(saml) 32 | created = False 33 | 34 | try: 35 | # If this nameid is already associated with a User, our job is done. 36 | user = idp.users.get(nameid=nameid).user 37 | except IdPUser.DoesNotExist: 38 | # Otherwise, associate or create a user with the generated username, if the 39 | # IdP settings allow it. 40 | username = self.get_username(idp, saml) 41 | username_field = UserModel.USERNAME_FIELD 42 | if not idp.auth_case_sensitive: 43 | username_field += "__iexact" 44 | try: 45 | # If we find an existing User, and the IdP allows it, associate them 46 | # with this IdP. 47 | user = UserModel._default_manager.get(**{username_field: username}) 48 | if not idp.associate_users: 49 | return None 50 | idp.users.create(nameid=nameid, user=user) 51 | except UserModel.DoesNotExist: 52 | if not idp.create_users: 53 | return None 54 | # Create the User if the IdP allows it. 55 | user = UserModel(**{UserModel.USERNAME_FIELD: username}) 56 | user.set_unusable_password() 57 | created = True 58 | except UserModel.MultipleObjectsReturned: 59 | # This can happen with case-insensitive auth. 60 | return None 61 | 62 | user = self.update_user(request, idp, saml, user, created) 63 | 64 | if created: 65 | idp.users.create(nameid=nameid, user=user) 66 | 67 | return user 68 | 69 | def update_user(self, request, idp, saml, user, created): 70 | # By default just call through to IdP.update_user, but provide an easy place 71 | # to customize this behavior for subclasses. 72 | return idp.update_user(request, saml, user, created) 73 | -------------------------------------------------------------------------------- /sp/migrations/0002_auto_20191205_1758.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.7 on 2019-12-05 17:58 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("sp", "0001_initial"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterModelOptions( 14 | name="idpattribute", 15 | options={ 16 | "verbose_name": "attribute mapping", 17 | "verbose_name_plural": "attribute mappings", 18 | }, 19 | ), 20 | migrations.AddField( 21 | model_name="idp", 22 | name="auth_case_sensitive", 23 | field=models.BooleanField( 24 | default=True, verbose_name="NameID is case sensitive" 25 | ), 26 | ), 27 | migrations.AddField( 28 | model_name="idp", 29 | name="create_users", 30 | field=models.BooleanField( 31 | default=True, verbose_name="Create users that do not already exist" 32 | ), 33 | ), 34 | migrations.AddField( 35 | model_name="idp", 36 | name="entity_id", 37 | field=models.CharField( 38 | blank=True, 39 | help_text="Leave blank to automatically use the metadata URL.", 40 | max_length=200, 41 | verbose_name="Entity ID", 42 | ), 43 | ), 44 | migrations.AddField( 45 | model_name="idpattribute", 46 | name="always_update", 47 | field=models.BooleanField( 48 | default=False, 49 | help_text=( 50 | "Update this mapped user field on every successful authentication. " 51 | "By default, mapped fields are only set on user creation." 52 | ), 53 | verbose_name="Always Update", 54 | ), 55 | ), 56 | migrations.AlterField( 57 | model_name="idp", 58 | name="base_url", 59 | field=models.CharField( 60 | help_text=( 61 | "Root URL for the site, including http/https, no trailing slash." 62 | ), 63 | max_length=200, 64 | verbose_name="Base URL", 65 | ), 66 | ), 67 | migrations.CreateModel( 68 | name="IdPUserDefaultValue", 69 | fields=[ 70 | ( 71 | "id", 72 | models.AutoField( 73 | auto_created=True, 74 | primary_key=True, 75 | serialize=False, 76 | verbose_name="ID", 77 | ), 78 | ), 79 | ("field", models.CharField(max_length=200)), 80 | ("value", models.TextField()), 81 | ( 82 | "idp", 83 | models.ForeignKey( 84 | on_delete=django.db.models.deletion.CASCADE, 85 | related_name="user_defaults", 86 | to="sp.IdP", 87 | verbose_name="identity provider", 88 | ), 89 | ), 90 | ], 91 | options={ 92 | "verbose_name": "user default value", 93 | "verbose_name_plural": "user default values", 94 | "unique_together": {("idp", "field")}, 95 | }, 96 | ), 97 | ] 98 | -------------------------------------------------------------------------------- /sp/admin.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from django.contrib import admin 4 | 5 | from .models import IdP, IdPAttribute, IdPUserDefaultValue 6 | 7 | 8 | class IdPAttributeInline(admin.TabularInline): 9 | model = IdPAttribute 10 | extra = 0 11 | 12 | 13 | class IdPUserDefaultValueInline(admin.TabularInline): 14 | model = IdPUserDefaultValue 15 | extra = 0 16 | 17 | 18 | class IdPAdmin(admin.ModelAdmin): 19 | list_display = ( 20 | "name", 21 | "url_params", 22 | "last_import", 23 | "certificate_expires", 24 | "get_entity_id", 25 | "is_active", 26 | "sort_order", 27 | "last_login", 28 | ) 29 | list_filter = ("is_active",) 30 | list_editable = ("sort_order", "is_active") 31 | actions = ("import_metadata", "generate_certificates") 32 | inlines = (IdPUserDefaultValueInline, IdPAttributeInline) 33 | fieldsets = ( 34 | ( 35 | None, 36 | { 37 | "fields": ( 38 | "name", 39 | "url_params", 40 | "base_url", 41 | "entity_id", 42 | "notes", 43 | "is_active", 44 | "sort_order", 45 | ) 46 | }, 47 | ), 48 | ( 49 | "SP Settings", 50 | { 51 | "fields": ( 52 | "contact_name", 53 | "contact_email", 54 | "x509_certificate", 55 | "private_key", 56 | "certificate_expires", 57 | ) 58 | }, 59 | ), 60 | ( 61 | "IdP Metadata", 62 | { 63 | "fields": ( 64 | "metadata_url", 65 | "verify_metadata_cert", 66 | "metadata_xml", 67 | "lowercase_encoding", 68 | "last_import", 69 | ) 70 | }, 71 | ), 72 | ( 73 | "Logins", 74 | { 75 | "fields": ( 76 | "auth_case_sensitive", 77 | "create_users", 78 | "associate_users", 79 | "respect_expiration", 80 | "logout_triggers_slo", 81 | "login_redirect", 82 | "logout_redirect", 83 | "last_login", 84 | ) 85 | }, 86 | ), 87 | ( 88 | "Advanced", 89 | { 90 | "classes": ("collapse",), 91 | "fields": ( 92 | "username_prefix", 93 | "username_suffix", 94 | "state_timeout", 95 | "require_attributes", 96 | "authn_comparison", 97 | "authn_context", 98 | "logout_request_signed", 99 | "logout_response_signed", 100 | "authenticate_method", 101 | "login_method", 102 | "logout_method", 103 | "prepare_request_method", 104 | "update_user_method", 105 | ), 106 | }, 107 | ), 108 | ) 109 | readonly_fields = ("last_import", "last_login") 110 | 111 | def get_changeform_initial_data(self, request): 112 | return { 113 | "base_url": "{}://{}{}".format( 114 | request.scheme, 115 | request.get_host(), 116 | request.META["SCRIPT_NAME"].rstrip("/"), 117 | ) 118 | } 119 | 120 | def generate_certificates(self, request, queryset): 121 | for idp in queryset: 122 | idp.generate_certificate() 123 | 124 | def import_metadata(self, request, queryset): 125 | for idp in queryset: 126 | idp.import_metadata() 127 | 128 | def save_model(self, request, obj, form, change): 129 | super(IdPAdmin, self).save_model(request, obj, form, change) 130 | try: 131 | obj.import_metadata() 132 | except Exception: 133 | pass 134 | 135 | 136 | admin.site.register(IdP, IdPAdmin) 137 | -------------------------------------------------------------------------------- /sp/utils.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | import django 4 | from django.conf import settings 5 | from django.contrib import auth 6 | from django.core.exceptions import FieldDoesNotExist, ImproperlyConfigured 7 | from django.shortcuts import get_object_or_404 8 | from django.utils.module_loading import import_string 9 | 10 | from .models import IdP 11 | 12 | IDP_SESSION_KEY = "_idpid" 13 | NAMEID_SESSION_KEY = "_nameid" 14 | NAMEID_FORMAT_SESSION_KEY = "_nameidfmt" 15 | 16 | 17 | def authenticate(request, idp, saml): 18 | return auth.authenticate(request, idp=idp, saml=saml) 19 | 20 | 21 | def login(request, user, idp, saml): 22 | auth.login(request, user) 23 | # Store the authenticating IdP and actual (not mapped) SAML nameid in the session. 24 | set_session_idp(request, idp, saml.get_nameid(), saml.get_nameid_format()) 25 | if idp.respect_expiration: 26 | if ( 27 | django.VERSION[:2] < (4, 1) 28 | and settings.SESSION_SERIALIZER 29 | == "django.contrib.sessions.serializers.JSONSerializer" 30 | ): 31 | raise ImproperlyConfigured( 32 | "IdP-based session expiration is not supported using the " 33 | "JSONSerializer SESSION_SERIALIZER when using Django < 4.1." 34 | ) 35 | try: 36 | dt = datetime.datetime.fromtimestamp( 37 | saml.get_session_expiration(), tz=datetime.timezone.utc 38 | ) 39 | request.session.set_expiry(dt) 40 | except TypeError: 41 | pass 42 | 43 | 44 | def logout(request, idp): 45 | auth.logout(request) 46 | clear_session_idp(request) 47 | 48 | 49 | def prepare_request(request, idp): 50 | return { 51 | "https": "on" if request.is_secure() else "off", 52 | "http_host": request.get_host(), 53 | "script_name": request.path_info, 54 | "server_port": 443 if request.is_secure() else request.get_port(), 55 | "get_data": request.GET.copy(), 56 | "post_data": request.POST.copy(), 57 | "lowercase_urlencoding": idp.lowercase_encoding, 58 | } 59 | 60 | 61 | def update_user(request, idp, saml, user, created=None): 62 | # A dictionary of SAML attributes, mapped to field names via IdPAttribute. 63 | attrs = idp.mapped_attributes(saml) 64 | # The set of mapped attributes that should always be updated on the user. 65 | always_update = set( 66 | idp.attributes.filter(always_update=True).values_list("mapped_name", flat=True) 67 | ) 68 | # For users created by this backend, set initial user default values. 69 | if created: 70 | attrs.update( 71 | {default.field: [default.value] for default in idp.user_defaults.all()} 72 | ) 73 | # Keep track of which fields (if any) were updated. 74 | update_fields = [] 75 | for field, values in attrs.items(): 76 | if created or field in always_update: 77 | try: 78 | f = user._meta.get_field(field) 79 | # Only update if the field changed. This is a primitive check, but 80 | # will catch most cases. 81 | if values[0] != getattr(user, f.attname): 82 | setattr(user, f.attname, values[0]) 83 | update_fields.append(f.name) 84 | except FieldDoesNotExist: 85 | pass 86 | if created or update_fields: 87 | # Doing a full clean will make sure the values we set are of the correct 88 | # types before saving. 89 | user.full_clean(validate_unique=False) 90 | user.save() 91 | return user 92 | 93 | 94 | def get_request_idp(request, **kwargs): 95 | custom_loader = getattr(settings, "SP_IDP_LOADER", None) 96 | if custom_loader: 97 | return import_string(custom_loader)(request, **kwargs) 98 | else: 99 | return get_object_or_404(IdP, url_params=kwargs, is_active=True) 100 | 101 | 102 | def get_session_idp(request): 103 | return IdP.objects.filter(pk=request.session.get(IDP_SESSION_KEY)).first() 104 | 105 | 106 | def get_session_nameid(request): 107 | return request.session.get(NAMEID_SESSION_KEY) 108 | 109 | 110 | def get_session_nameid_format(request): 111 | return request.session.get(NAMEID_FORMAT_SESSION_KEY) 112 | 113 | 114 | def set_session_idp(request, idp, nameid, nameid_format=None): 115 | request.session[IDP_SESSION_KEY] = idp.pk 116 | request.session[NAMEID_SESSION_KEY] = nameid 117 | if nameid_format: 118 | request.session[NAMEID_FORMAT_SESSION_KEY] = nameid_format 119 | 120 | 121 | def clear_session_idp(request): 122 | for key in (IDP_SESSION_KEY, NAMEID_SESSION_KEY, NAMEID_FORMAT_SESSION_KEY): 123 | try: 124 | del request.session[key] 125 | except KeyError: 126 | pass 127 | -------------------------------------------------------------------------------- /sp/views.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import REDIRECT_FIELD_NAME 2 | from django.http import HttpResponse 3 | from django.http.response import HttpResponseBase 4 | from django.shortcuts import redirect, render 5 | from django.utils import timezone 6 | from django.views.decorators.csrf import csrf_exempt 7 | from django.views.decorators.http import require_POST 8 | from onelogin.saml2.auth import OneLogin_Saml2_Auth 9 | from onelogin.saml2.settings import OneLogin_Saml2_Settings 10 | 11 | from .utils import get_request_idp, get_session_nameid, get_session_nameid_format 12 | 13 | 14 | def metadata(request, **kwargs): 15 | idp = get_request_idp(request, **kwargs) 16 | saml_settings = OneLogin_Saml2_Settings( 17 | settings=idp.sp_settings, sp_validation_only=True 18 | ) 19 | return HttpResponse(saml_settings.get_sp_metadata(), content_type="text/xml") 20 | 21 | 22 | @csrf_exempt 23 | @require_POST 24 | def acs(request, **kwargs): 25 | idp = get_request_idp(request, **kwargs) 26 | state = request.POST.get("RelayState") 27 | saml = OneLogin_Saml2_Auth(idp.prepare_request(request), old_settings=idp.settings) 28 | saml.process_response() 29 | errors = saml.get_errors() 30 | if errors: 31 | return render( 32 | request, 33 | "sp/error.html", 34 | { 35 | "idp": idp, 36 | "state": state, 37 | "errors": errors, 38 | "reason": saml.get_last_error_reason(), 39 | }, 40 | status=500, 41 | ) 42 | else: 43 | if state and state.startswith("test:"): 44 | attrs = [] 45 | for saml_attr, value in saml.get_attributes().items(): 46 | attr, created = idp.attributes.get_or_create(saml_attribute=saml_attr) 47 | attrs.append((attr, "; ".join(value))) 48 | return render( 49 | request, 50 | "sp/test.html", 51 | { 52 | "idp": idp, 53 | "attrs": attrs, 54 | "nameid": saml.get_nameid(), 55 | "nameid_format": saml.get_nameid_format(), 56 | "redir": state[5:], 57 | }, 58 | ) 59 | elif state and state.startswith("verify:"): 60 | user = idp.authenticate(request, saml) 61 | if user == request.user: 62 | # TODO: add a hook here 63 | return redirect(idp.get_login_redirect(state[7:])) 64 | else: 65 | return render( 66 | request, 67 | "sp/unauth.html", 68 | { 69 | "nameid": idp.get_nameid(saml), 70 | "idp": idp, 71 | "verify": True, 72 | }, 73 | status=401, 74 | ) 75 | else: 76 | user = idp.authenticate(request, saml) 77 | if user: 78 | if isinstance(user, HttpResponseBase): 79 | return user 80 | else: 81 | idp.login(request, user, saml) 82 | idp.last_login = timezone.now() 83 | idp.save(update_fields=("last_login",)) 84 | return redirect(idp.get_login_redirect(state)) 85 | else: 86 | return render( 87 | request, 88 | "sp/unauth.html", 89 | { 90 | "nameid": idp.get_nameid(saml), 91 | "idp": idp, 92 | "verify": False, 93 | }, 94 | status=401, 95 | ) 96 | 97 | 98 | def slo(request, **kwargs): 99 | idp = get_request_idp(request, **kwargs) 100 | saml = OneLogin_Saml2_Auth(idp.prepare_request(request), old_settings=idp.settings) 101 | state = request.GET.get("RelayState") 102 | redir = saml.process_slo() 103 | errors = saml.get_errors() 104 | if errors: 105 | return render( 106 | request, 107 | "sp/error.html", 108 | { 109 | "idp": idp, 110 | "state": state, 111 | "errors": errors, 112 | "reason": saml.get_last_error_reason(), 113 | }, 114 | status=500, 115 | ) 116 | else: 117 | idp.logout(request) 118 | if not redir: 119 | redir = idp.get_logout_redirect(state) 120 | return redirect(redir) 121 | 122 | 123 | def login(request, test=False, verify=False, **kwargs): 124 | idp = get_request_idp(request, **kwargs) 125 | saml = OneLogin_Saml2_Auth(idp.prepare_request(request), old_settings=idp.settings) 126 | reauth = verify or "reauth" in request.GET 127 | redir = request.GET.get(REDIRECT_FIELD_NAME, "") 128 | # SAML only allows RelayState to be 80 characters, make them count. 129 | if test: 130 | state = "test:" + redir 131 | elif verify: 132 | state = "verify:" + redir 133 | else: 134 | state = redir 135 | # When verifying, we want to pass the (unmapped) SAML nameid, stored in the session. 136 | # TODO: do we actually want UPN here, or some other specified mapped field? At least 137 | # Auth0 is pre-populating the email field with nameid, which is not what we want. 138 | nameid = get_session_nameid(request) if verify else None 139 | return redirect(saml.login(state, force_authn=reauth, name_id_value_req=nameid)) 140 | 141 | 142 | def logout(request, **kwargs): 143 | idp = get_request_idp(request, **kwargs) 144 | redir = idp.get_logout_redirect(request.GET.get(REDIRECT_FIELD_NAME)) 145 | saml = OneLogin_Saml2_Auth(idp.prepare_request(request), old_settings=idp.settings) 146 | if saml.get_slo_url() and idp.logout_triggers_slo: 147 | # If the IdP supports SLO, send it a logout request (it will call our SLO). 148 | return redirect( 149 | saml.logout( 150 | redir, 151 | name_id=get_session_nameid(request), 152 | name_id_format=get_session_nameid_format(request), 153 | ) 154 | ) 155 | else: 156 | # Handle the logout "locally", i.e. log out via django.contrib.auth by default. 157 | idp.logout(request) 158 | return redirect(redir) 159 | -------------------------------------------------------------------------------- /sp/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.7 on 2019-11-13 15:49 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | initial = True 9 | 10 | dependencies = [] 11 | 12 | operations = [ 13 | migrations.CreateModel( 14 | name="IdP", 15 | fields=[ 16 | ( 17 | "id", 18 | models.AutoField( 19 | auto_created=True, 20 | primary_key=True, 21 | serialize=False, 22 | verbose_name="ID", 23 | ), 24 | ), 25 | ("name", models.CharField(max_length=200)), 26 | ("slug", models.CharField(max_length=100, unique=True)), 27 | ( 28 | "base_url", 29 | models.CharField( 30 | help_text=( 31 | "Root URL for the site, including http/https, " 32 | "no trailing slash." 33 | ), 34 | max_length=200, 35 | ), 36 | ), 37 | ("contact_name", models.CharField(max_length=100)), 38 | ("contact_email", models.EmailField(max_length=100)), 39 | ("x509_certificate", models.TextField(blank=True)), 40 | ("private_key", models.TextField(blank=True)), 41 | ("certificate_expires", models.DateTimeField(blank=True, null=True)), 42 | ( 43 | "metadata_url", 44 | models.URLField( 45 | blank=True, 46 | help_text="Leave this blank if entering metadata XML directly.", 47 | max_length=500, 48 | verbose_name="Metadata URL", 49 | ), 50 | ), 51 | ( 52 | "verify_metadata_cert", 53 | models.BooleanField( 54 | default=True, verbose_name="Verify metadata URL certificate" 55 | ), 56 | ), 57 | ( 58 | "metadata_xml", 59 | models.TextField( 60 | blank=True, 61 | help_text=( 62 | "Automatically loaded from the metadata URL, if specified. " 63 | "Otherwise input directly." 64 | ), 65 | verbose_name="Metadata XML", 66 | ), 67 | ), 68 | ( 69 | "lowercase_encoding", 70 | models.BooleanField( 71 | default=False, 72 | help_text="Check this if the identity provider is ADFS.", 73 | ), 74 | ), 75 | ( 76 | "saml_settings", 77 | models.TextField( 78 | blank=True, 79 | editable=False, 80 | help_text=( 81 | "Settings imported and used by the python-saml library." 82 | ), 83 | ), 84 | ), 85 | ("last_import", models.DateTimeField(blank=True, null=True)), 86 | ("notes", models.TextField(blank=True)), 87 | ( 88 | "respect_expiration", 89 | models.BooleanField( 90 | default=False, 91 | help_text=( 92 | "Expires the Django session based on the IdP session " 93 | "expiration." 94 | ), 95 | verbose_name="Respect IdP session expiration", 96 | ), 97 | ), 98 | ( 99 | "login_redirect", 100 | models.CharField( 101 | blank=True, 102 | help_text=( 103 | "URL name or path to redirect after a successful login." 104 | ), 105 | max_length=200, 106 | ), 107 | ), 108 | ( 109 | "last_login", 110 | models.DateTimeField(blank=True, default=None, null=True), 111 | ), 112 | ("is_active", models.BooleanField(default=True)), 113 | ( 114 | "authenticate_method", 115 | models.CharField(default="sp.utils.authenticate", max_length=200), 116 | ), 117 | ( 118 | "login_method", 119 | models.CharField(default="sp.utils.login", max_length=200), 120 | ), 121 | ], 122 | options={ 123 | "verbose_name": "identity provider", 124 | }, 125 | ), 126 | migrations.CreateModel( 127 | name="IdPAttribute", 128 | fields=[ 129 | ( 130 | "id", 131 | models.AutoField( 132 | auto_created=True, 133 | primary_key=True, 134 | serialize=False, 135 | verbose_name="ID", 136 | ), 137 | ), 138 | ("saml_attribute", models.CharField(max_length=200)), 139 | ("mapped_name", models.CharField(blank=True, max_length=200)), 140 | ( 141 | "is_nameid", 142 | models.BooleanField(default=False, verbose_name="Is NameID"), 143 | ), 144 | ( 145 | "idp", 146 | models.ForeignKey( 147 | on_delete=django.db.models.deletion.CASCADE, 148 | related_name="attributes", 149 | to="sp.IdP", 150 | verbose_name="identity provider", 151 | ), 152 | ), 153 | ], 154 | options={ 155 | "verbose_name": "identity provider attribute", 156 | "verbose_name_plural": "identity provider attributes", 157 | "unique_together": {("idp", "saml_attribute")}, 158 | }, 159 | ), 160 | ] 161 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Installation 2 | 3 | * `pip install django-saml-sp` 4 | * Add `sp` to your `INSTALLED_APPS` setting 5 | 6 | ## Local Test Application 7 | 8 | ### Start the local SimpleSAML IdP 9 | 10 | ``` 11 | docker run -it --rm -p 8080:8080 -p 8443:8443 \ 12 | -e SIMPLESAMLPHP_SP_ENTITY_ID=http://localhost:8000/sso/local/ \ 13 | -e SIMPLESAMLPHP_SP_ASSERTION_CONSUMER_SERVICE=http://localhost:8000/sso/local/acs/ \ 14 | -e SIMPLESAMLPHP_SP_SINGLE_LOGOUT_SERVICE=http://localhost:8000/sso/local/slo/ \ 15 | kristophjunge/test-saml-idp 16 | ``` 17 | 18 | ### Bootstrap and run the local SP test app 19 | 20 | ``` 21 | python manage.py migrate 22 | python manage.py bootstrap 23 | python manage.py runserver 24 | ``` 25 | 26 | The test SAML IdP defines the following user accounts you can use for testing: 27 | 28 | | UID | Username | Password | Group | Email | 29 | |---|---|---|---|---| 30 | | 1 | user1 | user1pass | group1 | user1@example.com | 31 | | 2 | user2 | user2pass | group2 | user2@example.com | 32 | 33 | 34 | ### Sustainsys Stub IdP 35 | 36 | The bootstrap command also creates a `stub` IdP which authenticates via 37 | https://stubidp.sustainsys.com. This is a good option if you don't want to run your own 38 | local identity provider for testing. 39 | 40 | 41 | ## Integration Guide 42 | 43 | ### Django Settings 44 | 45 | * `AUTHENTICATION_BACKENDS` - By default the Django authentication system is used to authenticate and log in users. Add `sp.backends.SAMLAuthenticationBackend` to your `AUTHENTICATION_BACKENDS` setting to authenticate using Django's `User` model. The user is looked up using `User.USERNAME_FIELD` matching the SAML `nameid`, and optionally created if it doesn't already exist. See the *Field Mapping* section below for how to map SAML attributes to `User` attributes. 46 | * `LOGIN_REDIRECT_URL` - This is the URL users will be redirected to by default after a successful login (or verification). Optional if you set `IdP.login_redirect` or specify a `next` parameter in your login URL. 47 | * `LOGOUT_REDIRECT_URL` - This is the URL users will be redirected to by default after a successful logout. Optional if you set `IdP.logout_redirect` or specify a `next` parameter in your logout URL. 48 | * `SESSION_SERIALIZER` - By default, Django uses `django.contrib.sessions.serializers.JSONSerializer`, which does not allow for setting specific expiration dates on sessions. If you want to use the `IdP.respect_expiration` flag to let the IdP dictate when the Django session should expire, you should change this to `django.contrib.sessions.serializers.PickleSerializer`. But if you do not plan on using that feature, leave the default. *Note: Django 4.1 forward now supports datetime session exipry using the default JSONSerializer.* 49 | 50 | ### SP Settings 51 | 52 | * `SP_IDP_LOADER` - Allow you to specify a custom method for the SP views to retrieve an `IdP` instance given a request and the URL path parameters. 53 | * `SP_AUTHENTICATE` - A custom authentication method to use for `IdP` instances that do not specify one. By default, `sp.utils.authenticate` is used (delegating to the auth backend). 54 | * `SP_LOGIN` - A custom login method to use for `IdP` instances that do not specify one. By default, `sp.utils.login` is used (again, delegating to the auth backend). 55 | * `SP_LOGOUT` - A custom logout method to use for `IdP` instances that do not specify one. By default, `sp.utils.logout` is used, which simply delegates to Django's `auth.logout`. 56 | * `SP_PREPARE_REQUEST` - A custom prepare_request method to use for `IdP` instances that do not specify one. By default, `sp.utils.prepare_request` is used. 57 | * `SP_UPDATE_USER` - A custom update_user method to use for `IdP` instances that do not specify one. By default, `sp.utils.update_user` is used, which updates user fields based on mapped SAML attributes when users are created, or when the attributes are set to always update. 58 | * `SP_UNIQUE_USERNAMES` - When `True` (the default), `SAMLAuthenticationBackend` will generate usernames unique to the `IdP` that authenticated them, both when associating existing users and creating new users. This prevents user accounts from being linked to multiple IDPs (and prevents spoofing if untrusted IDPs can be configured). 59 | 60 | ### URLs 61 | 62 | The application comes with a URLconf that can be included, using any path parameters you want. The `IdP` is fetched by matching any URL parameters to the `url_params` field (or by some custom means via `SP_IDP_LOADER` above). For example: 63 | 64 | ```python 65 | path("/sso//", include("sp.urls")) 66 | ``` 67 | 68 | Assuming the URL configuration above, and an `IdP` configured with `url_params={"prefix": "my", "idp_slug": "local"}`, the following URLs would be available: 69 | 70 | URL | Description 71 | --- | ----------- 72 | `/my/sso/local/` | The entity ID, and metadata URL. Visiting this will produce metadata XML you can give to the IdP administrator. 73 | `/my/sso/local/acs/` | The Assertion Consumer Service (ACS). This is what the IdP will POST to upon a successful login. 74 | `/my/sso/local/slo/` | The Single Logout Service (SLO). The IdP will redirect to this URL when logging out of all SSO services. 75 | `/my/sso/local/login/` | URL to trigger the login sequence for this IdP. Available programmatically as `idp.get_login_url()`. Takes a `next` parameter to redirect to after login. Also takes a `reauth` parameter to force the IdP to ask for credentials again (also see the verify URL below). 76 | `/my/sso/local/test/` | URL to trigger an IdP login and display a test page containing all the SAML attributes passed back. Available programmatically as `idp.get_test_url()`. Does not actually perform a Django user login. 77 | `/my/sso/local/verify/` | URL to trigger a verification sequence for this IdP. Available programmatically as `idp.get_verify_url()`. Does not perform a Django user login, but does check that the user authenticated by the IdP matches the current `request.user`. 78 | `/my/sso/local/logout/` | URL to trigger the logout sequence for this IdP. Available programmatically as `idp.get_logout_url()`. Takes a `next` parameter to redirect to after logout. 79 | 80 | You can also include `sp.urls` without any URL parameters (e.g. `path("sso/", include("sp.urls"))`) if only a single `IdP` is needed (it should have `url_params={}`). 81 | 82 | 83 | ### Configuring an identity provider (IdP) 84 | 85 | 1. Create an `IdP` model object, either via the Django admin or programmatically. If you have metadata from your IdP, you can enter the URL or XML now, but it is not required yet. 86 | 2. Generate a certificate to use for SAML requests between your SP and this IdP. You may use the built-in admin action for this by going to the Django admin page for Identity Providers, checking the row(s) you want, and selecting "Generate certificates" from the Action dropdown. If you already have a certificate you want to use, you can paste it into the appropriate fields. 87 | 3. Give your IdP administrator the Entity ID/Metadata URL and ACS URL, if they need to explicitly allow access or provide you attributes. 88 | 4. At this point, if you didn't in step 1, you'll need to enter either the IdP metadata URL, or metadata XML directly. Saving will automatically trigger an import of the IdP metadata, so you should see the Last Import date update if successful. There is also an "Import metadata" admin action to trigger this manually. 89 | 90 | Your IdP is now ready for testing. On the admin page for your IdP object, there is a "Test IdP" button in the upper right corner. You can also visit the `.../test/` URL (see above) manually to initiate a test. A successful test of the IdP will show a page containing the NameID and SAML attributes provided by the IdP. 91 | -------------------------------------------------------------------------------- /sp/models.py: -------------------------------------------------------------------------------- 1 | import collections 2 | import datetime 3 | import json 4 | from urllib.parse import urlparse 5 | 6 | from cryptography import x509 7 | from cryptography.hazmat.backends import default_backend 8 | from cryptography.hazmat.primitives import hashes, serialization 9 | from cryptography.hazmat.primitives.asymmetric import rsa 10 | from cryptography.x509.oid import NameOID 11 | from django.conf import settings 12 | from django.contrib.contenttypes.fields import GenericForeignKey 13 | from django.contrib.contenttypes.models import ContentType 14 | from django.db import models 15 | from django.urls import NoReverseMatch, reverse 16 | from django.utils import timezone 17 | from django.utils.module_loading import import_string 18 | from django.utils.translation import gettext_lazy as _ 19 | from onelogin.saml2.idp_metadata_parser import OneLogin_Saml2_IdPMetadataParser 20 | 21 | 22 | def _default_authn_context(): 23 | return ["urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport"] 24 | 25 | 26 | class IdP(models.Model): 27 | name = models.CharField(max_length=200) 28 | url_params = models.JSONField( 29 | _("URL Parameters"), 30 | default=dict, 31 | blank=True, 32 | help_text=_("Application-specific URL path parameters."), 33 | ) 34 | base_url = models.CharField( 35 | _("Base URL"), 36 | max_length=200, 37 | help_text=_("Root URL for the site, including http/https, no trailing slash."), 38 | ) 39 | entity_id = models.CharField( 40 | _("Entity ID"), 41 | max_length=200, 42 | blank=True, 43 | help_text=_("Leave blank to automatically use the metadata URL."), 44 | ) 45 | contact_name = models.CharField(max_length=100) 46 | contact_email = models.EmailField(max_length=100) 47 | x509_certificate = models.TextField(blank=True) 48 | private_key = models.TextField(blank=True) 49 | certificate_expires = models.DateTimeField(null=True, blank=True) 50 | metadata_url = models.URLField( 51 | "Metadata URL", 52 | max_length=500, 53 | blank=True, 54 | help_text=_("Leave this blank if entering metadata XML directly."), 55 | ) 56 | verify_metadata_cert = models.BooleanField( 57 | _("Verify metadata URL certificate"), default=True 58 | ) 59 | metadata_xml = models.TextField( 60 | _("Metadata XML"), 61 | blank=True, 62 | help_text=_( 63 | "Automatically loaded from the metadata URL, if specified. " 64 | "Otherwise input directly." 65 | ), 66 | ) 67 | lowercase_encoding = models.BooleanField( 68 | default=False, help_text=_("Check this if the identity provider is ADFS.") 69 | ) 70 | saml_settings = models.TextField( 71 | blank=True, 72 | help_text=_("Settings imported and used by the python-saml library."), 73 | editable=False, 74 | ) 75 | last_import = models.DateTimeField(null=True, blank=True) 76 | notes = models.TextField(blank=True) 77 | auth_case_sensitive = models.BooleanField( 78 | _("NameID is case sensitive"), default=True 79 | ) 80 | create_users = models.BooleanField( 81 | _("Create users that do not already exist"), default=True 82 | ) 83 | associate_users = models.BooleanField( 84 | _("Associate existing users with this IdP by username"), default=True 85 | ) 86 | username_prefix = models.CharField( 87 | help_text=_("Prefix for usernames generated by this IdP"), 88 | max_length=20, 89 | blank=True, 90 | ) 91 | username_suffix = models.CharField( 92 | help_text=_("Suffix for usernames generated by this IdP"), 93 | max_length=20, 94 | blank=True, 95 | ) 96 | respect_expiration = models.BooleanField( 97 | _("Respect IdP session expiration"), 98 | default=False, 99 | help_text=_("Expires the Django session based on the IdP session expiration."), 100 | ) 101 | logout_triggers_slo = models.BooleanField( 102 | _("Logout triggers SLO"), 103 | default=False, 104 | help_text=_("Whether logging out should trigger a SLO request to the IdP."), 105 | ) 106 | login_redirect = models.CharField( 107 | max_length=200, 108 | blank=True, 109 | help_text=_("URL name or path to redirect after a successful login."), 110 | ) 111 | logout_redirect = models.CharField( 112 | max_length=200, 113 | blank=True, 114 | help_text=_("URL name or path to redirect after logout."), 115 | ) 116 | last_login = models.DateTimeField(null=True, blank=True, default=None) 117 | is_active = models.BooleanField(default=True) 118 | authenticate_method = models.CharField(max_length=200, blank=True) 119 | login_method = models.CharField(max_length=200, blank=True) 120 | logout_method = models.CharField(max_length=200, blank=True) 121 | prepare_request_method = models.CharField(max_length=200, blank=True) 122 | update_user_method = models.CharField(max_length=200, blank=True) 123 | state_timeout = models.IntegerField( 124 | default=60, 125 | help_text=_("Time (in seconds) the SAML login request state is valid for."), 126 | ) 127 | require_attributes = models.BooleanField( 128 | default=True, 129 | help_text=_("Ensures the IdP provides attributes on responses."), 130 | ) 131 | authn_comparison = models.CharField( 132 | max_length=100, 133 | default="exact", 134 | help_text=_("The Comparison attribute on RequestedAuthnContext."), 135 | ) 136 | authn_context = models.JSONField( 137 | default=_default_authn_context, 138 | help_text=_( 139 | "true (urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport), " 140 | "false, or a list of AuthnContextClassRef names." 141 | ), 142 | ) 143 | logout_request_signed = models.BooleanField(default=False) 144 | logout_response_signed = models.BooleanField(default=False) 145 | sort_order = models.IntegerField(default=0) 146 | 147 | class Meta: 148 | verbose_name = _("identity provider") 149 | ordering = ("sort_order", "name") 150 | 151 | def __str__(self): 152 | return self.name 153 | 154 | def get_url(self, name, default="/"): 155 | try: 156 | return reverse(name, kwargs=self.url_params) 157 | except NoReverseMatch: 158 | return default 159 | 160 | def get_entity_id(self): 161 | if self.entity_id: 162 | return self.entity_id 163 | else: 164 | return self.base_url + self.get_absolute_url() 165 | 166 | get_entity_id.short_description = _("Entity ID") 167 | 168 | def get_acs(self): 169 | return self.base_url + self.get_url("sp-idp-acs") 170 | 171 | get_acs.short_description = _("ACS") 172 | 173 | def get_slo(self): 174 | return self.base_url + self.get_url("sp-idp-slo") 175 | 176 | get_slo.short_description = _("SLO") 177 | 178 | def get_absolute_url(self): 179 | return self.get_url("sp-idp-metadata") 180 | 181 | def get_login_url(self): 182 | return self.get_url("sp-idp-login") 183 | 184 | def get_test_url(self): 185 | return self.get_url("sp-idp-test") 186 | 187 | def get_verify_url(self): 188 | return self.get_url("sp-idp-verify") 189 | 190 | def get_logout_url(self): 191 | return self.get_url("sp-idp-logout") 192 | 193 | def prepare_request(self, request): 194 | method = self.prepare_request_method or getattr( 195 | settings, "SP_PREPARE_REQUEST", "sp.utils.prepare_request" 196 | ) 197 | return import_string(method)(request, self) 198 | 199 | @property 200 | def sp_settings(self): 201 | return { 202 | "strict": True, 203 | "sp": { 204 | "NameIDFormat": "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified", 205 | "entityId": self.get_entity_id(), 206 | "assertionConsumerService": { 207 | "url": self.get_acs(), 208 | "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST", 209 | }, 210 | "singleLogoutService": { 211 | "url": self.get_slo(), 212 | "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect", 213 | }, 214 | "x509cert": self.x509_certificate, 215 | "privateKey": self.private_key, 216 | }, 217 | "security": { 218 | "wantAttributeStatement": self.require_attributes, 219 | "metadataValidUntil": self.certificate_expires, 220 | "requestedAuthnContextComparison": self.authn_comparison, 221 | "requestedAuthnContext": self.authn_context, 222 | "logoutRequestSigned": self.logout_request_signed, 223 | "logoutResponseSigned": self.logout_response_signed, 224 | }, 225 | "contactPerson": { 226 | "technical": { 227 | "givenName": self.contact_name, 228 | "emailAddress": self.contact_email, 229 | } 230 | }, 231 | } 232 | 233 | @property 234 | def settings(self): 235 | settings_dict = json.loads(self.saml_settings) 236 | settings_dict.update(self.sp_settings) 237 | return settings_dict 238 | 239 | def generate_certificate(self): 240 | url_parts = urlparse(self.base_url) 241 | backend = default_backend() 242 | key = rsa.generate_private_key( 243 | public_exponent=65537, key_size=2048, backend=backend 244 | ) 245 | self.private_key = key.private_bytes( 246 | encoding=serialization.Encoding.PEM, 247 | format=serialization.PrivateFormat.PKCS8, 248 | encryption_algorithm=serialization.NoEncryption(), 249 | ).decode("ascii") 250 | name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, url_parts.netloc)]) 251 | basic_contraints = x509.BasicConstraints(ca=True, path_length=0) 252 | now = timezone.now() 253 | self.certificate_expires = now + datetime.timedelta(days=3650) 254 | cert = ( 255 | x509.CertificateBuilder() 256 | .subject_name(name) 257 | .issuer_name(name) 258 | .public_key(key.public_key()) 259 | .serial_number(x509.random_serial_number()) 260 | .not_valid_before(now) 261 | .not_valid_after(self.certificate_expires) 262 | .add_extension(basic_contraints, critical=False) 263 | .sign(key, hashes.SHA256(), backend) 264 | ) 265 | self.x509_certificate = cert.public_bytes(serialization.Encoding.PEM).decode( 266 | "ascii" 267 | ) 268 | self.save() 269 | 270 | def import_metadata(self): 271 | if self.metadata_url: 272 | self.metadata_xml = OneLogin_Saml2_IdPMetadataParser.get_metadata( 273 | self.metadata_url, validate_cert=self.verify_metadata_cert 274 | ).decode("utf-8") 275 | self.saml_settings = json.dumps( 276 | OneLogin_Saml2_IdPMetadataParser.parse(self.metadata_xml) 277 | ) 278 | self.last_import = timezone.now() 279 | self.save() 280 | 281 | def mapped_attributes(self, saml): 282 | attrs = collections.OrderedDict() 283 | for attr in self.attributes.exclude(mapped_name=""): 284 | value = saml.get_attribute(attr.saml_attribute) 285 | if value is not None: 286 | attrs[attr.mapped_name] = value 287 | return attrs 288 | 289 | def get_nameid(self, saml): 290 | nameid_attr = self.attributes.filter(is_nameid=True).first() 291 | if nameid_attr: 292 | return saml.get_attribute(nameid_attr.saml_attribute)[0] 293 | else: 294 | return saml.get_nameid() 295 | 296 | def get_login_redirect(self, redir=None): 297 | return redir or self.login_redirect or settings.LOGIN_REDIRECT_URL 298 | 299 | def get_logout_redirect(self, redir=None): 300 | return redir or self.logout_redirect or settings.LOGOUT_REDIRECT_URL 301 | 302 | def authenticate(self, request, saml): 303 | method = self.authenticate_method or getattr( 304 | settings, "SP_AUTHENTICATE", "sp.utils.authenticate" 305 | ) 306 | return import_string(method)(request, self, saml) 307 | 308 | def login(self, request, user, saml): 309 | method = self.login_method or getattr(settings, "SP_LOGIN", "sp.utils.login") 310 | return import_string(method)(request, user, self, saml) 311 | 312 | def logout(self, request): 313 | method = self.logout_method or getattr(settings, "SP_LOGOUT", "sp.utils.logout") 314 | return import_string(method)(request, self) 315 | 316 | def update_user(self, request, saml, user, created=None): 317 | method = self.update_user_method or getattr( 318 | settings, "SP_UPDATE_USER", "sp.utils.update_user" 319 | ) 320 | return ( 321 | import_string(method)(request, self, saml, user, created=created) 322 | if method 323 | else user 324 | ) 325 | 326 | 327 | class IdPUserDefaultValue(models.Model): 328 | idp = models.ForeignKey( 329 | IdP, 330 | verbose_name=_("identity provider"), 331 | related_name="user_defaults", 332 | on_delete=models.CASCADE, 333 | ) 334 | field = models.CharField(max_length=200) 335 | value = models.TextField() 336 | 337 | class Meta: 338 | verbose_name = _("user default value") 339 | verbose_name_plural = _("user default values") 340 | unique_together = [ 341 | ("idp", "field"), 342 | ] 343 | 344 | def __str__(self): 345 | return "{} -> {}".format(self.field, self.value) 346 | 347 | 348 | class IdPAttribute(models.Model): 349 | idp = models.ForeignKey( 350 | IdP, 351 | verbose_name=_("identity provider"), 352 | related_name="attributes", 353 | on_delete=models.CASCADE, 354 | ) 355 | saml_attribute = models.CharField(max_length=200) 356 | mapped_name = models.CharField(max_length=200, blank=True) 357 | is_nameid = models.BooleanField( 358 | _("Is NameID"), 359 | default=False, 360 | help_text=_( 361 | "Check if this should be the unique identifier of the SSO identity." 362 | ), 363 | ) 364 | always_update = models.BooleanField( 365 | _("Always Update"), 366 | default=False, 367 | help_text=_( 368 | "Update this mapped user field on every successful authentication. " 369 | "By default, mapped fields are only set on user creation." 370 | ), 371 | ) 372 | 373 | class Meta: 374 | verbose_name = _("attribute mapping") 375 | verbose_name_plural = _("attribute mappings") 376 | unique_together = [ 377 | ("idp", "saml_attribute"), 378 | ] 379 | 380 | def __str__(self): 381 | if self.mapped_name: 382 | return "{} -> {}".format(self.saml_attribute, self.mapped_name) 383 | else: 384 | return "{} (unmapped)".format(self.saml_attribute) 385 | 386 | 387 | class IdPUser(models.Model): 388 | idp = models.ForeignKey(IdP, related_name="users", on_delete=models.CASCADE) 389 | nameid = models.CharField(max_length=200, db_index=True) 390 | content_type = models.ForeignKey( 391 | ContentType, related_name="idp_users", on_delete=models.CASCADE 392 | ) 393 | user_id = models.CharField(max_length=100) 394 | 395 | user = GenericForeignKey("content_type", "user_id") 396 | 397 | class Meta: 398 | unique_together = [ 399 | ("idp", "nameid"), 400 | ("idp", "content_type", "user_id"), 401 | ] 402 | --------------------------------------------------------------------------------