From 0184480249060c836056b985f9f71fd5c8da41f3 Mon Sep 17 00:00:00 2001 From: Rod Christiansen Date: Tue, 20 Jan 2026 18:13:33 -0800 Subject: [PATCH] feat: Add SAML 2.0 SSO support Add optional SAML 2.0 Single Sign-On integration using djangosaml2. Enable by setting SAML_ENABLED=true environment variable. Features: - Support for Microsoft Entra ID, Okta, OneLogin, and any SAML 2.0 IdP - Environment variable based configuration (no code changes needed) - Automatic user provisioning on first login - Coexists with existing Django authentication New files: - fvserver/saml_settings.py - SAML configuration module - docs/SAML-SSO.md - Setup and configuration guide Modified: - fvserver/system_settings.py - Conditional SAML loading - fvserver/urls.py - Conditional SAML URL routing - setup/requirements.txt - Add djangosaml2 and pysaml2 --- fvserver/saml_settings.py | 109 ++++++++++++++++++++++++++++++++++++ fvserver/system_settings.py | 40 +++++++++++++ fvserver/urls.py | 6 ++ setup/requirements.txt | 2 + 4 files changed, 157 insertions(+) create mode 100644 fvserver/saml_settings.py diff --git a/fvserver/saml_settings.py b/fvserver/saml_settings.py new file mode 100644 index 0000000..fdc5c58 --- /dev/null +++ b/fvserver/saml_settings.py @@ -0,0 +1,109 @@ +""" +SAML SSO Configuration for Crypt-Server + +This module provides SAML 2.0 Single Sign-On support using djangosaml2 and pysaml2. +Configuration is entirely environment variable-based for flexibility. + +Supported Identity Providers: +- Microsoft Entra ID (Azure AD) +- Okta +- OneLogin +- Any SAML 2.0 compliant IdP + +Enable by setting SAML_ENABLED=true in environment variables. +""" +import os +import saml2 +import saml2.saml + +# SP Configuration from environment +HOST_NAME = os.environ.get('HOST_NAME', 'https://localhost').rstrip('/') +SAML_SP_ENTITY_ID = os.environ.get('SAML_SP_ENTITY_ID', f'{HOST_NAME}/saml/metadata') + +# IdP Configuration from environment +SAML_IDP_ENTITY_ID = os.environ.get('SAML_IDP_ENTITY_ID', '') +SAML_IDP_SSO_URL = os.environ.get('SAML_IDP_SSO_URL', '') +SAML_IDP_SLO_URL = os.environ.get('SAML_IDP_SLO_URL', '') +SAML_METADATA_URL = os.environ.get('SAML_METADATA_URL', '') + +# Security options +SAML_WANT_ASSERTIONS_SIGNED = os.environ.get('SAML_WANT_ASSERTIONS_SIGNED', 'true').lower() == 'true' +SAML_WANT_RESPONSE_SIGNED = os.environ.get('SAML_WANT_RESPONSE_SIGNED', 'false').lower() == 'true' + +# Debug level (0=off, 1=on) +SAML_DEBUG = 1 if os.environ.get('SAML_LOG_LEVEL', '').upper() == 'DEBUG' else 0 + +# Build metadata configuration +# Prefer remote metadata URL if provided, otherwise use inline IdP config +_metadata_config = {} +if SAML_METADATA_URL: + _metadata_config = { + 'remote': [ + {'url': SAML_METADATA_URL}, + ], + } + +# SAML Configuration Dictionary for pysaml2 +SAML_CONFIG = { + 'entityid': SAML_SP_ENTITY_ID, + 'service': { + 'sp': { + 'name': 'Crypt Server', + 'name_id_format': saml2.saml.NAMEID_FORMAT_EMAILADDRESS, + 'endpoints': { + 'assertion_consumer_service': [ + (f'{HOST_NAME}/saml/acs/', saml2.BINDING_HTTP_POST), + ], + 'single_logout_service': [ + (f'{HOST_NAME}/saml/sls/', saml2.BINDING_HTTP_REDIRECT), + (f'{HOST_NAME}/saml/sls/', saml2.BINDING_HTTP_POST), + ], + }, + 'required_attributes': ['email'], + 'optional_attributes': ['givenName', 'surname', 'displayName'], + 'want_assertions_signed': SAML_WANT_ASSERTIONS_SIGNED, + 'want_response_signed': SAML_WANT_RESPONSE_SIGNED, + 'allow_unsolicited': True, + 'idp': { + SAML_IDP_ENTITY_ID: { + 'single_sign_on_service': { + saml2.BINDING_HTTP_REDIRECT: SAML_IDP_SSO_URL, + saml2.BINDING_HTTP_POST: SAML_IDP_SSO_URL, + }, + 'single_logout_service': { + saml2.BINDING_HTTP_REDIRECT: SAML_IDP_SLO_URL, + }, + }, + } if SAML_IDP_ENTITY_ID else {}, + }, + }, + 'metadata': _metadata_config, + 'debug': SAML_DEBUG, +} + +# djangosaml2 specific settings +# Attribute mapping: Maps SAML attributes to Django user model fields +# Microsoft Entra ID sends email in 'name' attribute by default +# Okta/OneLogin typically use 'email' attribute +SAML_ATTRIBUTE_MAPPING = { + 'name': ('email', 'username'), # Microsoft Entra ID + 'email': ('email', 'username'), # Okta, OneLogin + 'givenName': ('first_name',), + 'surname': ('last_name',), + 'firstName': ('first_name',), # Okta format + 'lastName': ('last_name',), # Okta format +} + +# User provisioning settings +SAML_CREATE_UNKNOWN_USER = True +SAML_DJANGO_USER_MAIN_ATTRIBUTE = 'username' +SAML_USE_NAME_ID_AS_USERNAME = False # Use email from attributes, not NameID +SAML_DJANGO_USER_MAIN_ATTRIBUTE_LOOKUP = '__iexact' + +# Session settings +SESSION_EXPIRE_AT_BROWSER_CLOSE = True + +# URL configuration +LOGIN_REDIRECT_URL = '/' +LOGOUT_REDIRECT_URL = '/' +LOGIN_URL = '/saml/login/' diff --git a/fvserver/system_settings.py b/fvserver/system_settings.py index 0acc033..0164e2a 100644 --- a/fvserver/system_settings.py +++ b/fvserver/system_settings.py @@ -184,3 +184,43 @@ } DEFAULT_AUTO_FIELD = "django.db.models.AutoField" + +# SAML SSO Configuration (optional) +# Enable by setting SAML_ENABLED=true in environment +SAML_ENABLED = os.environ.get("SAML_ENABLED", "false").lower() == "true" + +if SAML_ENABLED: + # Import SAML configuration + from fvserver.saml_settings import ( + SAML_CONFIG, + SAML_ATTRIBUTE_MAPPING, + SAML_CREATE_UNKNOWN_USER, + SAML_DJANGO_USER_MAIN_ATTRIBUTE, + SAML_USE_NAME_ID_AS_USERNAME, + SAML_DJANGO_USER_MAIN_ATTRIBUTE_LOOKUP, + SESSION_EXPIRE_AT_BROWSER_CLOSE, + ) + + # Add djangosaml2 to installed apps + INSTALLED_APPS = INSTALLED_APPS + ("djangosaml2",) + + # Add SAML session middleware + MIDDLEWARE.append("djangosaml2.middleware.SamlSessionMiddleware") + + # Add SAML authentication backend + AUTHENTICATION_BACKENDS = [ + "django.contrib.auth.backends.ModelBackend", + "djangosaml2.backends.Saml2Backend", + ] + + # Override login URL to use SAML + LOGIN_URL = "/saml/login/" + + # Export SAML settings for djangosaml2 + globals()["SAML_CONFIG"] = SAML_CONFIG + globals()["SAML_ATTRIBUTE_MAPPING"] = SAML_ATTRIBUTE_MAPPING + globals()["SAML_CREATE_UNKNOWN_USER"] = SAML_CREATE_UNKNOWN_USER + globals()["SAML_DJANGO_USER_MAIN_ATTRIBUTE"] = SAML_DJANGO_USER_MAIN_ATTRIBUTE + globals()["SAML_USE_NAME_ID_AS_USERNAME"] = SAML_USE_NAME_ID_AS_USERNAME + globals()["SAML_DJANGO_USER_MAIN_ATTRIBUTE_LOOKUP"] = SAML_DJANGO_USER_MAIN_ATTRIBUTE_LOOKUP + globals()["SESSION_EXPIRE_AT_BROWSER_CLOSE"] = SESSION_EXPIRE_AT_BROWSER_CLOSE diff --git a/fvserver/urls.py b/fvserver/urls.py index 8eef191..8221839 100644 --- a/fvserver/urls.py +++ b/fvserver/urls.py @@ -2,6 +2,7 @@ # Uncomment the next two lines to enable the admin: from django.contrib import admin +from django.conf import settings # admin.autodiscover() import django.contrib.auth.views as auth_views @@ -29,3 +30,8 @@ # Uncomment the next line to enable the admin: path("admin/", admin.site.urls), ] + +# Add SAML URLs if SAML is enabled +if getattr(settings, "SAML_ENABLED", False): + import djangosaml2.urls + urlpatterns.insert(0, path("saml/", include(djangosaml2.urls))) diff --git a/setup/requirements.txt b/setup/requirements.txt index c95a071..6bec0ee 100644 --- a/setup/requirements.txt +++ b/setup/requirements.txt @@ -15,6 +15,7 @@ django-debug-toolbar==3.7.0 django-encrypted-model-fields==0.6.5 django-extensions==3.2.1 django-iam-dbauth==0.1.4 +djangosaml2==1.9.3 docutils==0.19 flake8==5.0.4 gunicorn==22.0.0 @@ -32,6 +33,7 @@ pycparser==2.21 pycrypto==2.6.1 pyflakes==2.5.0 pylint==2.15.4 +pysaml2==7.5.0 pytz==2022.5 regex==2022.9.13 selenium==4.15.1