diff --git a/.gitignore b/.gitignore index 88aba2e..4b58fb3 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,5 @@ db.sqlite3 db.sqlite3-journal venv/ config.ini -/static/ \ No newline at end of file +/static/ +/certificates/ \ No newline at end of file diff --git a/config.dist.ini b/config.dist.ini index e9b85a8..3f43295 100644 --- a/config.dist.ini +++ b/config.dist.ini @@ -1,7 +1,9 @@ [App] Debug = 0 Hosts = ["kumidc.local"] +BaseURL = "https://kumidc.local/" # StaticDir = /var/www/html/kumidc/static +# CertificateDir = /etc/ssl/kumidc/ # [MySQL] # Database = kumidc diff --git a/core/management/commands/__init__.py b/core/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/management/commands/createsamlcert.py b/core/management/commands/createsamlcert.py new file mode 100644 index 0000000..6923b00 --- /dev/null +++ b/core/management/commands/createsamlcert.py @@ -0,0 +1,47 @@ +from django.core.management.base import BaseCommand +from django.conf import settings + +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives import serialization, hashes +from cryptography import x509 +from cryptography.x509.oid import NameOID + +from datetime import datetime, timedelta + + +class Command(BaseCommand): + help = 'Generates self-signed certificate for SAML IdP' + + def add_arguments(self, parser): + parser.add_argument('-f', '--force', action='store_true', help="Force re-creation of certificates if the files already exist") + parser.add_argument('--commonname', type=str, help="Common Name to use for certificate, default: KumiDC", default="KumiDC") + parser.add_argument('--country', type=str, help="Country Code to use for the certificate, default: US", default="US") + parser.add_argument('--state', type=str, help="State name to use for the certificate, default: New York", default="New York") + parser.add_argument('--locality', type=str, help="Locality name to use for the certificate, default: New York City", default="New York City") + parser.add_argument('--organization', type=str, help="Organization name to use for the certificate, default: KumiDC", default="KumiDC") + parser.add_argument('--validity-days', type=int, help="How many days the certificate should be \"valid\" for, default: 3650", default=3650) + + def handle(self, *args, **kwargs): + if (settings.CERTIFICATE_DIR / "saml.key").exists() or (settings.CERTIFICATE_DIR / "saml.crt").exists(): + if not kwargs["force"]: + print(f"Error: saml.crt and/or saml.key already in CERTIFICATE_DIR ({settings.CERTIFICATE_DIR}). Add --force to create new key pair.") + + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + + subject = issuer = x509.Name([ + x509.NameAttribute(NameOID.COMMON_NAME, kwargs["commonname"]), + x509.NameAttribute(NameOID.STATE_OR_PROVINCE_NAME, kwargs["state"]), + x509.NameAttribute(NameOID.COUNTRY_NAME, kwargs["country"]), + x509.NameAttribute(NameOID.LOCALITY_NAME, kwargs["locality"]), + x509.NameAttribute(NameOID.ORGANIZATION_NAME, kwargs["organization"]), + ]) + + cert = x509.CertificateBuilder().subject_name(subject).issuer_name(issuer).public_key(key.public_key()).serial_number(x509.random_serial_number()).not_valid_before(datetime.utcnow()).not_valid_after(datetime.utcnow() + timedelta(days=3650)).add_extension(x509.SubjectAlternativeName([x509.DNSName(name) for name in settings.ALLOWED_HOSTS]), critical=False).sign(key, hashes.SHA256()) + + settings.CERTIFICATE_DIR.mkdir(exist_ok=True) + + with open(settings.CERTIFICATE_DIR / "saml.key", "wb") as keyfile: + keyfile.write(key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.TraditionalOpenSSL, serialization.NoEncryption())) + + with open(settings.CERTIFICATE_DIR / "saml.crt", "wb") as certfile: + certfile.write(cert.public_bytes(serialization.Encoding.PEM)) \ No newline at end of file diff --git a/core/saml/__init__.py b/core/saml/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/saml/processors.py b/core/saml/processors.py new file mode 100644 index 0000000..c3040b3 --- /dev/null +++ b/core/saml/processors.py @@ -0,0 +1,6 @@ +from djangosaml2idp.processors import BaseProcessor + + +class SAMLProcessor(BaseProcessor): + def enable_multifactor(self, user): + return user.totpsecret.exists() and user.totpsecret.active \ No newline at end of file diff --git a/frontend/views/saml.py b/frontend/views/saml.py new file mode 100644 index 0000000..f30b26b --- /dev/null +++ b/frontend/views/saml.py @@ -0,0 +1,7 @@ +from djangosaml2idp.views import ProcessMultiFactorView + +from authentication.mixins.timeout import TimeoutMixin + + +class SAMLMultiFactorView(TimeoutMixin, ProcessMultiFactorView): + pass diff --git a/kumidc/settings.py b/kumidc/settings.py index 717ec32..69c25e4 100644 --- a/kumidc/settings.py +++ b/kumidc/settings.py @@ -1,9 +1,15 @@ from django.urls import reverse_lazy from pathlib import Path +from urllib.parse import urljoin import json +import saml2 + +from saml2.saml import NAMEID_FORMAT_EMAILADDRESS, NAMEID_FORMAT_UNSPECIFIED +from saml2.sigver import get_xmlsec_binary + from autosecretkey import AutoSecretKey @@ -16,7 +22,9 @@ SECRET_KEY = CONFIG_FILE.secret_key DEBUG = CONFIG_FILE.config.getboolean("App", "Debug", fallback=False) ALLOWED_HOSTS = json.loads(CONFIG_FILE.config["App"]["Hosts"]) +BASE_URL = CONFIG_FILE.config["App"]["BaseURL"] +CERTIFICATE_DIR = Path(CONFIG_FILE.config.get("App", "CertificateDir", fallback=BASE_DIR / "certificates")) # Application definition @@ -35,7 +43,9 @@ INSTALLED_APPS = [ 'core', 'authentication', 'frontend', + 'oidc_provider', + 'djangosaml2idp', ] MIDDLEWARE = [ @@ -148,6 +158,52 @@ OIDC_TEMPLATES = { 'authorize': 'frontend/oidc/authorize.html' } +# SAML Configuration + +SAML_IDP_CONFIG = { + 'debug' : DEBUG, + 'xmlsec_binary': get_xmlsec_binary(['/opt/local/bin', '/usr/bin']), + 'entityid': urljoin(BASE_URL, '/saml/metadata/'), + 'description': 'KumiDC', + + 'service': { + 'idp': { + 'name': 'KumiDC', + 'endpoints': { + 'single_sign_on_service': [ + #(urljoin(BASE_URL, '/saml/sso/post/'), saml2.BINDING_HTTP_POST), + (urljoin(BASE_URL, '/saml/sso/redirect/'), saml2.BINDING_HTTP_REDIRECT), + ], + "single_logout_service": [ + #(urljoin(BASE_URL, "/saml/slo/post/"), saml2.BINDING_HTTP_POST), + (urljoin(BASE_URL, "/saml/slo/redirect/"), saml2.BINDING_HTTP_REDIRECT) + ], + }, + 'name_id_format': [NAMEID_FORMAT_EMAILADDRESS, NAMEID_FORMAT_UNSPECIFIED], + 'sign_response': True, + 'sign_assertion': True, + 'want_authn_requests_signed': True, + }, + }, + + # Signing + 'key_file': str(CERTIFICATE_DIR / 'saml.key'), + 'cert_file': str(CERTIFICATE_DIR / 'saml.crt'), + + # Encryption + 'encryption_keypairs': [{ + 'key_file': str(CERTIFICATE_DIR / 'saml.key'), + 'cert_file': str(CERTIFICATE_DIR / 'saml.crt'), + }], + + 'valid_for': 365 * 24, +} + +SAML_IDP_SP_FIELD_DEFAULT_PROCESSOR = 'core.saml.processors.SAMLProcessor' +SAML_IDP_MULTIFACTOR_VIEW = "frontend.views.saml.SAMLMultiFactorView" + +SAML_AUTHN_SIGN_ALG = saml2.xmldsig.SIG_RSA_SHA256 +SAML_AUTHN_DIGEST_ALG = saml2.xmldsig.DIGEST_SHA256 # Session Timeouts diff --git a/kumidc/urls.py b/kumidc/urls.py index 751809b..48154e3 100644 --- a/kumidc/urls.py +++ b/kumidc/urls.py @@ -4,9 +4,12 @@ from django.views.generic import RedirectView urlpatterns = [ + re_path(r'^openid/', include('oidc_provider.urls', namespace='oidc_provider')), + re_path(r'^saml/', include('djangosaml2idp.urls', namespace="djangosaml2idp")), + path('admin/login/', RedirectView.as_view(url=reverse_lazy("auth:login"), query_string=True)), path('admin/', admin.site.urls), - re_path(r'^openid/', include('oidc_provider.urls', namespace='oidc_provider')), + path('auth/', include(("authentication.urls", "auth"))), path('', include(("frontend.urls", "frontend"))), ] diff --git a/requirements.txt b/requirements.txt index 7744132..64e7d5e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,10 +1,14 @@ Django<4 -git+https://github.com/juanifioren/django-oidc-provider +django-oidc-provider +djangosaml2idp dbsettings django-autosecretkey +git+https://github.com/IdentityPython/pysaml2 + +cryptography pyotp django-timezone-field django-phonenumber-field[phonenumbers]