diff --git a/README.md b/README.md index 0fbd6f0..e4ea9de 100755 --- a/README.md +++ b/README.md @@ -1,2 +1,7 @@ # RuG website template with SAML2 login + +# CENTOS dependencies + + yum install libxml2-devel libxslt-devel python34-devel xmlsec1-devel libmcrypt libmcrypt-devel xmlsec1-openssl + diff --git a/rugwebsite/__pycache__/urls.cpython-35.pyc b/rugwebsite/__pycache__/urls.cpython-35.pyc index e7f3361..c839a1a 100644 Binary files a/rugwebsite/__pycache__/urls.cpython-35.pyc and b/rugwebsite/__pycache__/urls.cpython-35.pyc differ diff --git a/rugwebsite/management/commands/init-saml2-settings.py b/rugwebsite/management/commands/init-saml2-settings.py new file mode 100644 index 0000000..cf14b81 --- /dev/null +++ b/rugwebsite/management/commands/init-saml2-settings.py @@ -0,0 +1,83 @@ +from django.core.management.base import BaseCommand, CommandError +import datetime +import os.path +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives import hashes, serialization +from cryptography import x509 +from cryptography.x509.oid import NameOID + +try: + import urllib # python 2 +except: + import urllib.request as urllib # python 3 + + +class Command(BaseCommand): + help = 'Create SAML2 settings, they are printed to standard out and it is up to de developer or deployer to add' \ + 'them to the django settings.' + + def add_arguments(self, parser): + parser.add_argument('--country', required=True, nargs=1, type=str, dest='country', help='For example: US, NL, DE, etc') + parser.add_argument('--city', required=True, nargs=1, type=str, dest='city', help='For example: London or Groningen') + parser.add_argument('--state', required=True, nargs=1, type=str, dest='state', help='For example: State or province, for example California or Groningen.') + parser.add_argument('--organisation', required=True, nargs=1, type=str, dest='organisation', help='Typically \'University of Groningen\'') + parser.add_argument('--organisation-unit', required=True, nargs=1, type=str, dest='organisation-unit', help='For example: \'Research and Innovation\' Support or \'Faculty of smart people\'') + parser.add_argument('--common-name', required=True, nargs=1, type=str, dest='common-name') + parser.add_argument('--alternative', nargs='*', type=str, dest='alternatives') + parser.add_argument('--support-name', required=True, nargs=1, type=str, dest='support_name') + parser.add_argument('--support-email', required=True, nargs=1, type=str, dest='support_email') + parser.add_argument('--technical-name', required=True, nargs=1, type=str, dest='technical_name') + parser.add_argument('--technical-email', required=True, nargs=1, type=str, dest='technical_email') + parser.add_argument('--entity-id', required=True, nargs=1, type=str, dest='entity_id', help='Used as an identifyer of your service, typically the url to your service: www.rug.nl/yourservice') + parser.add_argument('--base-url', required=True, nargs=1, type=str, dest='base_url', help='The base url of your service, the route sso/saml/ should exist.') + parser.add_argument('--expires-after-days', nargs='?', type=int, default=10 * 365, dest='expires') + + def handle(self, *args, **options): + key = rsa.generate_private_key(public_exponent=65537, key_size=2048, backend = default_backend()) + subject = issuer = x509.Name([ + x509.NameAttribute(NameOID.COUNTRY_NAME, options['country'][0]), + x509.NameAttribute(NameOID.STATE_OR_PROVINCE_NAME, options['state'][0]), + x509.NameAttribute(NameOID.LOCALITY_NAME, options['city'][0]), + x509.NameAttribute(NameOID.ORGANIZATION_NAME, options['organisation'][0]), + x509.NameAttribute(NameOID.ORGANIZATIONAL_UNIT_NAME, options['organisation-unit'][0]), + x509.NameAttribute(NameOID.COMMON_NAME, options['common-name'][0]), + ]) + cert = ( + x509.CertificateBuilder() + .subject_name(subject) + .issuer_name(issuer) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(datetime.datetime.utcnow()) + .not_valid_after(datetime.datetime.utcnow() + datetime.timedelta(days=options['expires'])) + .add_extension(x509.SubjectAlternativeName([ + x509.DNSName(alternative) + for alternative in options['alternatives'] + ] if options['alternatives'] is not None else []), critical = False) + .sign(key, hashes.SHA256(), default_backend()) + ) + settings_template = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'settings_template.py') + + key_text = key.private_bytes(encoding=serialization.Encoding.PEM, + format = serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm = serialization.NoEncryption()).decode('utf-8') + x509_text = cert.public_bytes(encoding=serialization.Encoding.PEM).decode('utf-8') + + assert ( + key_text.startswith('-----BEGIN RSA PRIVATE KEY-----\n') and + key_text.endswith('\n-----END RSA PRIVATE KEY-----\n')), "Private key starts and ends improperly, should " \ + "be -----BEGIN RSA PRIVATE KEY----- and " \ + "-----END RSA PRIVATE KEY-----" + assert ( + x509_text.startswith('-----BEGIN CERTIFICATE-----\n') and + x509_text.endswith('\n-----END CERTIFICATE-----\n')), "Certificate starts and ends improperly, should " \ + "be -----BEGIN CERTIFICATE----- and " \ + "-----END CERTIFICATE-----" + key_text = key_text[len('-----BEGIN RSA PRIVATE KEY-----\n'):-len('\n-----END RSA PRIVATE KEY-----\n')] + x509_text = x509_text[len('-----BEGIN CERTIFICATE-----\n'):-len('\n-----END CERTIFICATE-----\n')] + + + with open(settings_template, 'r') as f: + print(f.read().format(private_key=key_text , x509=x509_text, **options)) + diff --git a/rugwebsite/management/commands/initsaml.py b/rugwebsite/management/commands/initsaml.py deleted file mode 100644 index c1abd63..0000000 --- a/rugwebsite/management/commands/initsaml.py +++ /dev/null @@ -1,19 +0,0 @@ -from django.core.management.base import BaseCommand, CommandError -from django.conf import settings - -try: - import urllib # python 2 -except: - import urllib.request as urllib # python 3 - - -class Command(BaseCommand): - help = 'Initialize saml2 authentication files' - - def add_arguments(self, parser): - pass - - def handle(self, *args, **options): - - urllib.urlretrieve(settings.SAML_PROVIDER_METADATA_URL, os.path.join()) - diff --git a/rugwebsite/management/commands/settings_template.py b/rugwebsite/management/commands/settings_template.py new file mode 100644 index 0000000..3f912c6 --- /dev/null +++ b/rugwebsite/management/commands/settings_template.py @@ -0,0 +1,131 @@ + + +AUTHENTICATION_BACKENDS = [ + 'django_saml2_pro_auth.auth.Backend' +] + +# identifier for SAML to the service provider. +ENTITY_ID = '{entity_id}' + +# Important to make sure redirects and such work properly +BASE_URL = '{base_url}' + +# This support information is used for the SAML2 service provider contact information +TECHNICAL_NAME = '{technical_name}' +TECHNICAL_EMAIL = '{technical_email}' +SUPPORT_NAME = '{support_name}' +SUPPORT_EMAIL = '{support_email}' + +ORGANISATION = '{organisation}' + + +SAML_ROUTE = BASE_URL + 'sso/saml/' +# redirection after successful SAML2 login +SAML_REDIRECT = BASE_URL + '/' + +# Mapping used to move the SAML2 attributes to the django-auth user database +SAML_USERS_MAP = [{{ + "RuG": {{ + "email": dict(key="urn:mace:dir:attribute-def:mail", index=0), + "username": dict(key="urn:mace:dir:attribute-def:uid", index=0), + "first_name": dict(key="urn:mace:dir:attribute-def:gn", index=0), + "last_name": dict(key="urn:mace:dir:attribute-def:sn", index=0), + }} +}}] + +#Private key stripped from the ---BEGIN ... and ---END ... part +PRIVATE_KEY = """{private_key}""" + +# Idem for the certificate +X509 = """{x509}""" + +# RuG metadata url, should not change unless you want another service provider. +SAML_PROVIDER_METADATA_URL = 'https://tst-idp.id.rug.nl/nidp/saml2/metadata' + +#Code to get the RuG identity provider certificate +import sys +from onelogin.saml2.xml_utils import OneLogin_Saml2_XML +if sys.version_info[0] == 2: + import urllib # python 2 +else: + assert sys.version_info[0] == 3 + import urllib.request as urllib # python 3 + +with urllib.urlopen(SAML_PROVIDER_METADATA_URL) as u: + RUG_PROVIDER_METADATA = u.read() + RUG_PROVIDER_X509CERT = OneLogin_Saml2_XML.query( + OneLogin_Saml2_XML.to_etree(RUG_PROVIDER_METADATA), + '/md:EntityDescriptor/ds:Signature/ds:KeyInfo/ds:X509Data/ds:X509Certificate' + ) + + assert len(RUG_PROVIDER_X509CERT) > 0, "Excepted a X509 RUG Provider Certificate" + assert len(RUG_PROVIDER_X509CERT) == 1, "Excepted no more than 1 X509 RUG Provider Certificate" + RUG_PROVIDER_X509CERT = RUG_PROVIDER_X509CERT[0].text.strip() + + +# Construction of the service provider metadata. +SAML_PROVIDERS = [{{ + "RuG": {{ + "strict": True, + "debug": True, + "custom_base_path": "", + "sp": {{ + "entityId": ENTITY_ID, + "assertionConsumerService": {{ + "url": BASE_URL + "/sso/saml/?provider=RuG&acs", + "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" + }}, + "singleLogoutService": {{ + "url": BASE_URL + "/sso/saml/?provider=RuG", + "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" + }}, + "NameIDFormat": "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified", + "x509cert": X509, + "privateKey": PRIVATE_KEY, + }}, + "idp": {{ + "entityId": "https://tst-idp.id.rug.nl/nidp/saml2/metadata", + "singleSignOnService": {{ + "url": "https://tst-idp.id.rug.nl/nidp/saml2/sso", + "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" + }}, + "singleLogoutService": {{ + "url": "https://tst-idp.id.rug.nl/nidp/saml2/spslo", + "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" + }}, + "x509cert": RUG_PROVIDER_X509CERT, + }}, + "organization": {{ + "en-US": {{ + "name": ORGANISATION, + "displayname": ORGANISATION, + "url": BASE_URL + }} + }}, + "contact_person": {{ + "technical": {{ + "given_name": TECHNICAL_NAME, + "email_address": TECHNICAL_EMAIL + }}, + "support": {{ + "given_name": SUPPORT_NAME, + "email_address": SUPPORT_EMAIL + }} + }}, + "security": {{ + "requestedAuthnContext": False, + "name_id_encrypted": False, + "authn_requests_signed": True, + "logout_requests_signed": False, + "logout_response_signed": False, + "sign_metadata": False, + "want_messages_signed": False, + "want_assertions_signed": True, + "want_name_id": True, + "want_name_id_encrypted": False, + "want_assertions_encrypted": True, + "signature_algorithm": "http://www.w3.org/2000/09/xmldsig#rsa-sha1", + "digest_algorithm": "http://www.w3.org/2000/09/xmldsig#rsa-sha1", + }} + }} +}}] diff --git a/rugwebsite/settings/default.py b/rugwebsite/settings/default.py index 6d916b0..6f6c685 100755 --- a/rugwebsite/settings/default.py +++ b/rugwebsite/settings/default.py @@ -61,8 +61,6 @@ USE_TZ = True STATIC_URL = '/static/' STATIC_ROOT = os.path.join(BASE_DIR, 'static') -LOGOUT_URL = '/logout/' -LOGIN_URL = '/login/' AUTHENTICATION_BACKENDS = [ @@ -76,7 +74,7 @@ SAML_USERS_MAP = [{ "RuG": { "email": dict(key="urn:mace:dir:attribute-def:mail", index=0), "username": dict(key="urn:mace:dir:attribute-def:uid", index=0), - "first_name": dict(key="urn:mace:dir:attribute-def:givenName", index=0), + "first_name": dict(key="urn:mace:dir:attribute-def:gn", index=0), "last_name": dict(key="urn:mace:dir:attribute-def:sn", index=0), } }] diff --git a/setup.py b/setup.py index 3a4282e..24f9212 100644 --- a/setup.py +++ b/setup.py @@ -27,6 +27,8 @@ setup( include_package_data=True, install_requires=[ + 'pyOpenSSL>=17.4.0', + 'cryptography>=2.1.3,<3' 'django>=1.11.7,<1.12', 'django-bootstrap4>=0.0.4,<0.1', 'django-saml2-pro-auth>=0.0.2,<0.1'