From 135adc0f67595d2f3d12259352ca3893e4183f04 Mon Sep 17 00:00:00 2001 From: Art Lukyanchyk <artiom.lukyanchyk@hs-hannover.de> Date: Wed, 2 Aug 2017 16:47:52 +0200 Subject: [PATCH] RC1. --- .dev-config/nginx-https-config.sh | 80 ++++++++ README.md | 54 +++++ requirements.txt | 4 + setup.py | 41 ++++ ssoauth/__init__.py | 10 + ssoauth/app_settings/__init__.py | 98 +++++++++ ssoauth/app_settings/defaults.py | 74 +++++++ ssoauth/apps.py | 30 +++ ssoauth/auth_utils.py | 144 +++++++++++++ ssoauth/checks.py | 94 +++++++++ ssoauth/management/__init__.py | 0 ssoauth/management/commands/__init__.py | 0 .../commands/create_compat_groups.py | 40 ++++ ssoauth/management/commands/group_mapping.py | 118 +++++++++++ ssoauth/migrations/0001_initial.py | 37 ++++ ssoauth/migrations/__init__.py | 0 ssoauth/models.py | 22 ++ ssoauth/sso_utils.py | 71 +++++++ ssoauth/templates/ssoauth/dev.html | 62 ++++++ ssoauth/urls.py | 11 + ssoauth/views.py | 193 ++++++++++++++++++ 21 files changed, 1183 insertions(+) create mode 100755 .dev-config/nginx-https-config.sh create mode 100644 README.md create mode 100644 requirements.txt create mode 100644 setup.py create mode 100644 ssoauth/__init__.py create mode 100644 ssoauth/app_settings/__init__.py create mode 100644 ssoauth/app_settings/defaults.py create mode 100644 ssoauth/apps.py create mode 100644 ssoauth/auth_utils.py create mode 100644 ssoauth/checks.py create mode 100644 ssoauth/management/__init__.py create mode 100644 ssoauth/management/commands/__init__.py create mode 100644 ssoauth/management/commands/create_compat_groups.py create mode 100644 ssoauth/management/commands/group_mapping.py create mode 100644 ssoauth/migrations/0001_initial.py create mode 100644 ssoauth/migrations/__init__.py create mode 100644 ssoauth/models.py create mode 100644 ssoauth/sso_utils.py create mode 100644 ssoauth/templates/ssoauth/dev.html create mode 100644 ssoauth/urls.py create mode 100644 ssoauth/views.py diff --git a/.dev-config/nginx-https-config.sh b/.dev-config/nginx-https-config.sh new file mode 100755 index 0000000..c555c13 --- /dev/null +++ b/.dev-config/nginx-https-config.sh @@ -0,0 +1,80 @@ +#!/bin/bash + +# Django usually listens on port 8000 without SSL. +# Some of our projects however require SSL. +# Run this script to set up nginx SSL proxy for Django. + + +if [[ "$EUID" != 0 ]]; then + echo "Need to be run as root." + exit 1 +fi + +SITES_AVAILABLE="/etc/nginx/sites-available" +SITES_ENABLED="/etc/nginx/sites-enabled" +CONFIG_FILENAME="443-ssl-to-8000" + +CERT_PUB="/etc/ssl/certs/ssl-cert-snakeoil.pem" +CERT_KEY="/etc/ssl/private/ssl-cert-snakeoil.key" + +set -e # die on error +set -u # die if some var not set + +if [[ ! -d "$SITES_AVAILABLE" ]] || [[ ! -d "$SITES_AVAILABLE" ]]; then + echo "Is nginx installed?" + exit 1 +fi + +if [[ ! -e "$CERT_PUB" ]] || [[ ! -e "$CERT_KEY" ]]; then + echo "No snakeoil certs?" + exit 1 +fi + +echo "Seting up site config..." + +sudo cat > "$SITES_AVAILABLE/$CONFIG_FILENAME" << EOF + +upstream localhost8000 { + server localhost:8000; +} + +server { + listen 443; + ssl on; + + ssl_certificate $CERT_PUB; + ssl_certificate_key $CERT_KEY; + ssl_protocols TLSv1 TLSv1.1 TLSv1.2; + ssl_ciphers "ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA:ECDHE-RSA-AES128-SHA:DHE-RSA-AES256-SHA256:DHE-RSA-AES128-SHA256:DHE-RSA-AES256-SHA:DHE-RSA-AES128-SHA:ECDHE-RSA-DES-CBC3-SHA:EDH-RSA-DES-CBC3-SHA:AES256-GCM-SHA384:AES128-GCM-SHA256:AES256-SHA256:AES128-SHA256:AES256-SHA:AES128-SHA:DES-CBC3-SHA:HIGH:!aNULL:!eNULL:!EXPORT:!DES:!MD5:!PSK:!RC4"; + + location / { + proxy_pass http://localhost8000; + proxy_set_header Host \$host; + proxy_set_header X-Forwarded-For \$remote_addr; + proxy_set_header X-Forwarded-Proto "https"; + proxy_set_header REMOTE-USER \$remote_user; + } +} + +EOF + +set +e # stop dying on errors now + +sudo ln -f -s "$SITES_AVAILABLE/$CONFIG_FILENAME" "$SITES_ENABLED/" + +echo "Restarting nginx..." + +sudo systemctl restart nginx + +if [[ "`systemctl is-active nginx`" == "active" ]]; then + echo "http://localhost:8000 with SSL is here: https://localhost" +else + echo "Oops. Seems like nginx is dead now." + echo + systemctl status nginx | cat + echo +fi + +exit + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..246d027 --- /dev/null +++ b/README.md @@ -0,0 +1,54 @@ + + +#### Minimal Intro: +- [SSO](https://lmddgtfy.net/?q=SSO): Single Sign On +- SP: Service Provider (your client) +- IdP: Identity Provider (server, Shibboleth) +- Metadata/Meta: an XML that describes SP or IdP (or another entity) +- SAML/SAML2: It's just another XML-based enterprise-grade standard that will make you cry blood + + +#### Integrating the app into your Django project. +- Binary dependencies: `sudo apt install libxml2-dev libxslt1-dev xmlsec1 libxmlsec1-dev pkg-config` +- Python dependencies: see `requirements.txt` or `setup.py` +- Add the app into `INSTALLED_APPS` +- Include the app's `urls.py` into the project `urls.py` `urlpatterns` +- Add to the project settings + - ```python + SP_HOST = "141.71.foo.bar" # your SP host or IP address + IDP_META_URL = "https://idp-test.it.hs-hannover.de/idp/shibboleth" # development + # IDP_META_URL = "https://idp.hs-hannover.de/idp/shibboleth" # production + ``` +- generate a new key pair: + - create `cert` directory: + - inside of project `settings` directory if it's a package + - next to project `settings.py` file if it's a module + - `openssl req -new -x509 -days 3650 -nodes -out sp.pem -keyout sp.key` + - `./cert/sp.key` put the private key here + - `./cert/sp.pem` put the certificate here, signing is optional + +#### Development setup + - `sudo apt-get install nginx` + - run `.dev-config/nginx-https-config.sh` + - in case you already have port 443 busy, use your force to solve the issue :) + + +#### Integrating your project into the existing SSO infrastructure +- **Ask somebody who knows more. Seriously.** +- **Try on the test IdP before you brick the production!** +- Grab your meta + - Run your project. + - Find meta of your SP (relative path `/saml2/meta` or view name `sso-saml2-meta`) + - **if using Firefox:** use "show source code" option or you will get invalid XML +- configure the IdP: + - `SSH` to the IdP and locate the Shibboleth directory, most likely `/opt/shibboleth-idp/` + - put your meta into a new file in `./metadata/` and give it a nice verbose name + - edit `./conf/metadata-providers.xml` + - create a new `<MetadataProvider .../>` element, your best guess is to copy an existing line that belongs to some existing Django project + - `id` should be unique + - `metadataFile` should point on your new metadata + - edit `./conf/attribute-filter.xml` + - add a new `<Rule .../>` element inside of `<AttributeFilterPolicy id="releaseToDjango">` + - `value` (looks like URI) should be the `entityID` of your SP (you find it in your meta) + - `systemctl restart tomcat8 & tail -fn0 /opt/shibboleth-idp/logs/idp-warn.log` (you now have some time to grab you a coffee) + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a1431f1 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +Django>=1.11,<2.0 +python3-saml + + diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..bb91b10 --- /dev/null +++ b/setup.py @@ -0,0 +1,41 @@ +import os +from setuptools import setup, find_packages +from pip.req import parse_requirements + +README = open(os.path.join(os.path.dirname(__file__), "README.md")).read() + +# automagic +requirements_parsed = parse_requirements(open(os.path.join(os.path.dirname(__file__), "requirements.txt"))) +requirements_list = [str(r.req) for r in requirements_parsed] + + +# allow setup.py to be run from any path +os.chdir(os.path.normpath(os.path.join(os.path.abspath(__file__), os.pardir))) + + + +setup( + name='django-ssoauth', + version='1.0', + packages=find_packages(), + include_package_data=True, + license='BSD', + description='SSO using SAML2', + long_description=README, + url='https://lab.it.hs-hannover.de/django/ssoauth', + author='Art Lukyanchyk', + author_email='artiom.lukyanchyk@hs-hannover.de', + zip_safe=False, + install_requires=requirements_list, + classifiers=[ + 'Environment :: Web Environment', + 'Framework :: Django', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: BSD License', + 'Operating System :: OS Independent', + 'Programming Language :: Python', + 'Programming Language :: Python :: 3.4', + 'Topic :: Internet :: WWW/HTTP', + 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', + ], +) diff --git a/ssoauth/__init__.py b/ssoauth/__init__.py new file mode 100644 index 0000000..4e93af4 --- /dev/null +++ b/ssoauth/__init__.py @@ -0,0 +1,10 @@ +import logging + +from . import checks # As for Django 1.11 it still doesn't auto-import checks >.< +assert checks + + +logger = logging.getLogger("ssoauth") + +default_app_config = "ssoauth.apps.SSOAuthConfig" + diff --git a/ssoauth/app_settings/__init__.py b/ssoauth/app_settings/__init__.py new file mode 100644 index 0000000..f8b66c8 --- /dev/null +++ b/ssoauth/app_settings/__init__.py @@ -0,0 +1,98 @@ +from onelogin.saml2 import settings as onelogin_settings +from .defaults import * +from django import conf + + +# merge defaults with customized user settings + +for setting_name in [k for k in globals().keys() if k.isupper()]: + for name in ["SSOAUTH_" + setting_name, setting_name]: + try: + globals()[setting_name] = getattr(conf.settings, name) + continue + except (KeyError, AttributeError): + pass # not set + + +# checks + +assert SP_HOST and SP_PORT, "Need SP_HOST and SP_PORT configured in settings." +assert not SP_HOST.startswith(("127.", "::1", "localhost",)), "Too many localhosts around. Need a real hostname or IP address." +assert not SP_HOST.lower().startswith(("http:", "https:",)), "Need host name without a protocol." + + +# helpers + +_SET_ON_RUNTIME = None and "doesn't make sense to change it" + + +def get_project_settings_path(): + module = os.environ.get(conf.ENVIRONMENT_VARIABLE) + path = os.path.abspath(os.path.join(*module.split("."))) + if not os.path.isdir(path): + # module, not a package + path = os.path.dirname(path) + return path + + +def read_key(path): + path = path.format(project_settings=get_project_settings_path()) + try: + with open(path, "r") as f: + return f.read() + except FileNotFoundError: + raise FileNotFoundError("SSO requires a key pair. Missing: {path}".format(path=path)) + + +# template for OneLogin toolkit settings + +ONELOGIN_SETTINGS_TEMPLATE = { + # Don't change it (you cannot in fact) + # If you really need to adjust something here, please use ONELOGIN_OVERRIDES instead + "strict": True, + "debug": conf.settings.DEBUG, + "sp": { + "entityId": _SET_ON_RUNTIME or str(), + "assertionConsumerService": { + "url": _SET_ON_RUNTIME or str(), + }, + "x509cert": read_key(SP_CERT), + "privateKey": read_key(SP_KEY), + "NameIDFormat": onelogin_settings.OneLogin_Saml2_Constants.NAMEID_PERSISTENT, # otherwise Shibboleth shows warnings + }, + "idp": { + "entityId": IDP_META_URL, + "x509certMulti": { + "signing": _SET_ON_RUNTIME or dict(), + "encryption": _SET_ON_RUNTIME or dict(), + }, + "singleSignOnService": { + "url": _SET_ON_RUNTIME or str(), + "binding": onelogin_settings.OneLogin_Saml2_Constants.BINDING_HTTP_REDIRECT, + }, + "singleLogoutService": { + "url": _SET_ON_RUNTIME or str(), + "binding": onelogin_settings.OneLogin_Saml2_Constants.BINDING_HTTP_REDIRECT, + } + }, + "security": { + "nameIdEncrypted": False, + "authnRequestsSigned": True, + "logoutRequestSigned": True, + "logoutResponseSigned": False, + "signMetadata": True, + "wantMessagesSigned": True, + "wantAssertionsSigned": True, + "wantNameId": True, + "wantNameIdEncrypted": False, + "wantAssertionsEncrypted": True, + "signatureAlgorithm": "http://www.w3.org/2001/04/xmldsig-more#rsa-sha512", + "metadataCacheDuration": SP_METADATA_LIFETIME, + }, + "contactPerson": SP_CONTACTS, + "organization": SP_ORGANIZATION, +} + +ONELOGIN_SETTINGS_TEMPLATE.update(ONELOGIN_OVERRIDES) + +ONELOGIN_SETTINGS = _SET_ON_RUNTIME diff --git a/ssoauth/app_settings/defaults.py b/ssoauth/app_settings/defaults.py new file mode 100644 index 0000000..e4f2b19 --- /dev/null +++ b/ssoauth/app_settings/defaults.py @@ -0,0 +1,74 @@ +import os + + +""" +If you want to change something: + - optionally prefix the setting with "SSOAUTH_" + - put it into your project settings +""" + + +""" +Settings you want to change: +""" + +# host and port, not what Django thinks, but what nginx serves +SP_HOST = None # e.g. "141.71.113.1" +SP_PORT = 443 + +IDP_META_URL = "https://idp.hs-hannover.de/idp/shibboleth" # test is "https://idp-test.it.hs-hannover.de/idp/shibboleth" + + +""" +Settings you DON'T want to change (in fact, you want to avoid even thinking about them): +""" + +IDP_REQUIRED = True # die on start if cannot find IDP or parse its meta + +SP_KEY = "{project_settings}/cert/sp.key" +SP_CERT = "{project_settings}/cert/sp.pem" + +SP_METADATA_LIFETIME = "P20Y" # "P7D"-like (https://www.w3.org/TR/xmlschema-2/#duration) + +# if you really really need to add/modify something in OneLogin settings, add it to ONELOGIN_OVERRIDES +ONELOGIN_OVERRIDES = {} # e.g.: ONELOGIN_OVERRIDES = { "strict": False } + +PROJECT_NAME = os.environ.get('DJANGO_SETTINGS_MODULE').split('.')[0] + +SU_GROUP_NAME = "{0}_superusers".format(PROJECT_NAME) +SU_PERM_NAME = "superuser" +STAFF_GROUP_NAME = "{0}_staff".format(PROJECT_NAME) +STAFF_PERM_NAME = "staff" + + +""" +Not settings... just... Eww. +""" + +SP_CONTACTS = { + "technical": { + "givenName": "Service Desk", + "emailAddress": "mailto:support-it@hs-hannover.de", + }, + "support": { + "givenName": "Service Desk", + "emailAddress": "mailto:support-it@hs-hannover.de", + }, + "administrative": { + "givenName": "Service Desk", + "emailAddress": "mailto:support-it@hs-hannover.de", + }, +} + +SP_ORGANIZATION = { + "de": { + "name": "Hochschule Hannover", + "displayname": "Hochschule Hannover", + "url": "https://www.hs-hannover.de", + }, + "en": { + "name": "Hannover University of Applied Sciences and Arts", + "displayname": "Hannover University of Applied Sciences and Arts", + "url": "https://www.hs-hannover.de", + }, +} diff --git a/ssoauth/apps.py b/ssoauth/apps.py new file mode 100644 index 0000000..e543e5e --- /dev/null +++ b/ssoauth/apps.py @@ -0,0 +1,30 @@ +from django.apps import AppConfig +from django.core import management +from django.db.models.signals import post_migrate +from . import logger +from . import sso_utils +from . import app_settings + + +class SSOAuthConfig(AppConfig): + name = 'ssoauth' + + def ready(self, *args, **kwargs): + super().ready(*args, **kwargs) + # OneLogin settings stuff + try: + app_settings.ONELOGIN_SETTINGS = sso_utils.create_onelogin_settings(app_settings.ONELOGIN_SETTINGS_TEMPLATE) + except Exception as e: + if app_settings.IDP_REQUIRED: + raise e + else: + logger.error("SSO will not work. {ec}: {e}".format(ec=e.__class__.__name__, e=str(e),)) + # default groups + post_migrate.connect(self.post_migrate_callback, sender=self) + # ... + logger.warning("Need to ensure user.has_usable_password is False") + + @staticmethod + def post_migrate_callback(*args, **kwargs): + management.call_command("create_compat_groups") + diff --git a/ssoauth/auth_utils.py b/ssoauth/auth_utils.py new file mode 100644 index 0000000..a55fed6 --- /dev/null +++ b/ssoauth/auth_utils.py @@ -0,0 +1,144 @@ +from django.db import transaction +from django.db.models import Q +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group +from uuid import UUID +from . import models +from . import logger +from . import app_settings +import functools +import re + + +EXTRACT_RDN = re.compile(r"^\w+=(\w+),.+$", re.IGNORECASE) # RDN is in the first regex group + + +def _validate_username(username): + assert isinstance(username, str) + assert username.islower() + + +def get_user(uuid=None, username=None): + """ This helper just gets a user instance. """ + assert bool(uuid) ^ bool(username), "Need uuid OR username" + if uuid: + assert isinstance(uuid, UUID), "Not an UUID instance" + return models.UserMapping.objects.get(uuid=uuid).user + elif username: + assert isinstance(username, str), "Seriously?" + return get_user_model().objects.get(username__iexact=username.lower()) + + +@transaction.atomic() +def get_or_create_user(uuid, username): + """ + Returns a user. + Is able to process many weird cases like changed username or re-created user in DS. + If it dies with an exception, with 90% probability the data state is very bad. + """ + + def get_user_by_uuid(uuid, username): + try: + user = get_user(uuid=uuid) + if user.username != username: + # have to do it because users might be renamed + logger.warning("Usernames mismatch. Changing from {0} to {1}.".format(user.username, username)) + user.username = username + user.save() + return user + except models.UserMapping.DoesNotExist: + return None + + def get_user_by_username(uuid, username): + try: + user = get_user(username=username) + except get_user_model().DoesNotExist: + return None + try: + meta = models.UserMapping.objects.get(user=user) + logger.warning("User UUID changed. Assuming re-created in DS. Automagically fixing...") + meta.uuid = uuid + meta.save() + except models.UserMapping.DoesNotExist: + logger.Error("User {username} lost their meta. Auto-fixing and hoping for the best.".format(**locals())) + models.UserMapping.objects.create(user=user, uuid=uuid) + return user + + def create_user(uuid, username): + _validate_username(username) + user = get_user_model().objects.create(username=username, is_staff=False) + models.UserMapping.objects.create(user=user, uuid=uuid) + logger.info("Created user: {username} {uuid}".format(**locals())) + return user + + # checks and casts + if isinstance(uuid, str): + uuid = UUID(uuid) + assert isinstance(uuid, UUID) and isinstance(username, str), "Bad arguments" + username = username.lower() + # get or create + user = get_user_by_uuid(uuid, username) # best case scenario + if not user: + user = get_user_by_username(uuid, username) # worst case scenario + if not user: + user = create_user(uuid, username) # create if not present + # sanity check + if user and user.user_permissions.all().count(): + logger.error("Who assigned permissions directly to user {user}?! Removing: {perms}".format( + user=user, perms=", ".join(str(p) for p in user.user_permissions))) + user.user_permissions.clear() + return user + + +def update_user_data(user, surname=None, forename=None, email=None): + """ Updates User instance with data received from SSO. """ + assert isinstance(user, get_user_model()) + user.first_name = forename or str() + user.last_name = surname or str() + user.email = email or None + user.save() + + +def extract_rdn(dn): + """ Extracts group name from the DN. """ + match = EXTRACT_RDN.search(dn) + assert match, "Received something weird instead of a DN: {group_dn}".format(**locals()) + return match.group(1) + + +def set_user_groups(user, group_dn_list): + """ Updates groups for the user. """ + # using Q to create ignore-case DN lookup since DS is case insensitive + q_list = [Q(sso_mapping__dn__iexact=dn) for dn in group_dn_list] + if q_list: + q_combined = functools.reduce(lambda q1, q2: q1 | q2, q_list) + groups = list(Group.objects.filter(q_combined)) + else: + groups = list() + # modify user groups + if set(user.groups.all()) != set(groups): + user.groups.set(groups) + assert set(user.groups.all()) == set(groups) # dunno how relation.set() behaves, better safe than sorry + logger.info("Groups for {user} are updated to: {groups}".format(user=user, groups=", ".join(g.name for g in groups))) + logger.debug("User {user} has {g_n} group(s) based on {dn_n} DN(s): {g_names}".format( + user=user, g_n=len(groups), g_names=", ".join(str(g) for g in groups), dn_n=len(group_dn_list))) + + +def set_user_compat_flags(user): + is_active = True + user.is_staff = False + user.is_superuser = False + try: + group_su = Group.objects.get(name=app_settings.SU_GROUP_NAME) + group_staff = Group.objects.get(name=app_settings.STAFF_GROUP_NAME) + if group_su in user.groups.all(): + logger.info("User {user} is superuser.".format(**locals())) + user.is_staff = True + user.is_superuser = True + if group_staff in user.groups.all(): + logger.info("User {user} has admin access.".format(**locals())) + user.is_staff = True + except Group.DoesNotExist: + logger.error("No compat groups. Migrate or run management command create_compat_groups.") + user.save() + diff --git a/ssoauth/checks.py b/ssoauth/checks.py new file mode 100644 index 0000000..2b18c7e --- /dev/null +++ b/ssoauth/checks.py @@ -0,0 +1,94 @@ +from django.core.checks import Tags, Error, Warning, register +from django.contrib.auth import get_user_model +from django.db.utils import OperationalError, ProgrammingError +from django import conf +from django import urls + + +def _get_abstract_user(): + """ Cannot import when loading the module. """ + from django.contrib.auth.models import AbstractUser + return AbstractUser + + +def _ignore_db_errors(function): + """ Needed for checks that read data. """ + def wrapper(*args, **kwargs): + try: + return function(*args, **kwargs) + except (OperationalError, ProgrammingError,) as e: + return [Warning("Could not perform a check. Not migrated yet?", obj=function.__name__)] + return wrapper + + +@register(Tags.security) +@_ignore_db_errors +def no_passwords_stored(app_configs, **kwargs): + errors = list() + user_model = get_user_model() + users_with_password = user_model.objects.exclude(password__isnull=True).exclude(password="") + if users_with_password: + errors.append(Error( + "Some users have their password stored in the database: {}".format(", ".join(u.username for u in users_with_password)), + obj=user_model + )) + for user in users_with_password: + user.password = str() + user.save() + return errors + + +@register(Tags.compatibility) +def compatible_user_model(app_configs, **kwargs): + errors = list() + if not issubclass(get_user_model(), _get_abstract_user()): + errors.append(Error( + "{} is probably incompatible with ssoauth.".format(get_user_model()), + obj=get_user_model(), + )) + return errors + + +@register(Tags.security) +@_ignore_db_errors +def no_direct_user_permissions(app_configs, **kwargs): + errors = list() + qs_bad_users = get_user_model().objects.filter(user_permissions__isnull=False) + if qs_bad_users.count() is not 0: + errors.append(Error( + "Detected directly assigned permissions. Truncate the User<->Permission table. Investigate the reason. " + + "Bad users: {0}".format(", ".join(u.username for u in qs_bad_users)), + obj=get_user_model(), + )) + return errors + + +@register(Tags.urls) +def auth_urls_configured(app_configs, **kwargs): + errors = list() + + def _url_from_setting(setting_name): + # get url based on django setting name, handles most of cases of something going wrong + try: + setting_value = getattr(conf.settings, setting_name, None) # setting exists + assert urls.resolve(setting_value) # page exists + return setting_value + except Exception as e: + errors.append(Warning("{ec}: {e}".format(ec=e.__class__.__name__, e=str(e)), obj=conf.settings,)) + return None + + # login + login_url_needed = urls.reverse("sso-login") + login_url_current = _url_from_setting("LOGIN_URL") + if login_url_current != login_url_needed: + errors.append(Warning( + "LOGIN_URL must point on {login_url_needed}, not on {login_url_current}. Add the following to your project settings: " + "LOGIN_URL = urls.reverse_lazy(\"sso-login\")".format(**locals()), + obj=conf.settings, + )) + # default landing and logout pages + for setting_name in ("LOGIN_REDIRECT_URL", "LOGOUT_REDIRECT_URL",): + if not _url_from_setting(setting_name): + errors.append(Warning("{setting_name} is not found or invalid.".format(**locals()), obj=conf.settings,)) + return errors + diff --git a/ssoauth/management/__init__.py b/ssoauth/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ssoauth/management/commands/__init__.py b/ssoauth/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ssoauth/management/commands/create_compat_groups.py b/ssoauth/management/commands/create_compat_groups.py new file mode 100644 index 0000000..62822d6 --- /dev/null +++ b/ssoauth/management/commands/create_compat_groups.py @@ -0,0 +1,40 @@ +from django.core.management.base import BaseCommand, CommandError +from django.contrib.auth.models import Group, Permission +from django.contrib.contenttypes.models import ContentType +from django.contrib.auth import get_user_model +from ... import app_settings +from ... import logger + + +class Command(BaseCommand): + help = "Creates groups required for django.contrib.auth compatibility." + requires_migrations_checks = True + requires_system_checks = True + + def handle(self, *args, **options): + try: + self.ensure_group_exists(app_settings.SU_GROUP_NAME, [app_settings.SU_PERM_NAME]) + self.ensure_group_exists(app_settings.STAFF_GROUP_NAME, [app_settings.STAFF_PERM_NAME]) + except Exception as e: + raise CommandError("Could not ensure that compatibility groups and permissions exist. {0}".format(str(e))) + + @staticmethod + def ensure_group_exists(group_name, permission_names=list()): + """ + Ensures the group with the given permissions exists. + Creates the group and/or permissions if needed. + Adds permissions to the group if needed (but never removes any). + """ + group, created = Group.objects.get_or_create(name=group_name) + if created: + logger.info("Created group '{}'".format(group)) + for permission_name in permission_names: + permission, created = Permission.objects.get_or_create( + codename=permission_name, + content_type_id=ContentType.objects.get_for_model(get_user_model()).id, + ) + if created: + logger.info("Created permission '{}'".format(permission.codename)) + if permission not in group.permissions.all(): + group.permissions.add(permission) + logger.info("Added permission '{1}' to group '{0}'".format(group, permission.codename)) diff --git a/ssoauth/management/commands/group_mapping.py b/ssoauth/management/commands/group_mapping.py new file mode 100644 index 0000000..5dcb6b0 --- /dev/null +++ b/ssoauth/management/commands/group_mapping.py @@ -0,0 +1,118 @@ +from django.core.management.base import BaseCommand, CommandError +from django.contrib.auth.models import Group, Permission +from ... models import GroupMapping +from django.contrib.contenttypes.models import ContentType +from django.contrib.auth import get_user_model +from ... import app_settings +from ... import logger + + +class Command(BaseCommand): + help = "Modify group mapping. You can also do it from your project admin." + requires_migrations_checks = True + requires_system_checks = True + + def add_arguments(self, parser): + parser.add_argument( + "action", + action="store", + choices=["list", "add", "del", "?"], + help="action", + ) + parser.add_argument( + "name", + action="store", + default=None, + nargs="?", + help="local group name (in this project)", + ) + parser.add_argument( + "dn", + action="store", + default=None, + nargs="?", + help="group DN in a directory as returned by Shibboleth", + ) + + def handle(self, *args, **options): + action, name, dn = options["action"], options["name"], options["dn"] + if action == "list": + self.print_mapping() + elif action == "add": + if not name or not dn: + return logger.error("Need group name and DN.") + self.add_mapping(name, dn) + self.print_mapping() + elif action == "del": + if not name: + return logger.error("Need group name.") + self.delete_mapping(name) + else: + self.print_help(str(), str()) + + def print_mapping(self): + groups = Group.objects.all() + mapped = groups.filter(sso_mapping__isnull=False) + unmapped = groups.filter(sso_mapping__isnull=True) + logger.info("There {g} groups, {m} mapped and {u} unmapped:".format(g=len(groups), m=len(mapped), u=len(unmapped))) + for group in groups.order_by("-sso_mapping", "name"): + name = group.name + try: + dn = group.sso_mapping.dn + except GroupMapping.DoesNotExist: + dn = None + logger.info(" {name:<50} {dn}".format(**locals())) + + def delete_mapping(self, group_name): + mapping = self._get_mapping_for(group_name) + if mapping: + logger.info("Deleting {mapping}.".format(**locals())) + mapping.delete() + else: + logger.error("There is no mapping for {group_name}.".format(**locals())) + + def add_mapping(self, group_name, dn): + # group + try: + group = Group.objects.get(name__iexact=group_name) + logger.info("Existing group {group} will be used.".format(**locals())) + except Group.DoesNotExist: + logger.info("Group {group_name} will be created.".format(**locals())) + group = Group.objects.create(name=group_name) + # mapping + existing = self._get_mapping_for(group_name) + if existing: + logger.warning("Removing existing mapping {existing}".format(**locals())) + existing.delete() + new = GroupMapping.objects.create(group=group, dn=dn) + logger.info("Created {new}.".format(**locals())) + + def _get_mapping_for(self, group_name): + try: + return GroupMapping.objects.get(group__name__iexact=group_name) + except GroupMapping.DoesNotExist: + return None + + + # raise CommandError("Could not ensure that compatibility groups and permissions exist. {0}".format(str(e))) + + # @staticmethod + # def ensure_group_exists(group_name, permission_names=list()): + # """ + # Ensures the group with the given permissions exists. + # Creates the group and/or permissions if needed. + # Adds permissions to the group if needed (but never removes any). + # """ + # group, created = Group.objects.get_or_create(name=group_name) + # if created: + # logger.info("Created group '{}'".format(group)) + # for permission_name in permission_names: + # permission, created = Permission.objects.get_or_create( + # codename=permission_name, + # content_type_id=ContentType.objects.get_for_model(get_user_model()).id, + # ) + # if created: + # logger.info("Created permission '{}'".format(permission.codename)) + # if permission not in group.permissions.all(): + # group.permissions.add(permission) + # logger.info("Added permission '{1}' to group '{0}'".format(group, permission.codename)) diff --git a/ssoauth/migrations/0001_initial.py b/ssoauth/migrations/0001_initial.py new file mode 100644 index 0000000..5ddfc7a --- /dev/null +++ b/ssoauth/migrations/0001_initial.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.3 on 2017-08-14 16:16 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0008_alter_user_username_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='GroupMapping', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('dn', models.CharField(max_length=1000)), + ('created_on', models.DateField(auto_now_add=True)), + ('group', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='sso_mapping', to='auth.Group')), + ], + ), + migrations.CreateModel( + name='UserMapping', + fields=[ + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, related_name='sso_mapping', serialize=False, to=settings.AUTH_USER_MODEL)), + ('uuid', models.UUIDField()), + ('anonymized', models.BooleanField(default=False)), + ('imported_on', models.DateField(auto_now_add=True)), + ], + ), + ] diff --git a/ssoauth/migrations/__init__.py b/ssoauth/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ssoauth/models.py b/ssoauth/models.py new file mode 100644 index 0000000..30b0f66 --- /dev/null +++ b/ssoauth/models.py @@ -0,0 +1,22 @@ +from django.db import models +from django import conf + + +class UserMapping(models.Model): + user = models.OneToOneField(conf.settings.AUTH_USER_MODEL, primary_key=True, on_delete=models.CASCADE, related_name="sso_mapping") + uuid = models.UUIDField(null=False) + anonymized = models.BooleanField(null=False, default=False) + imported_on = models.DateField(null=False, auto_now_add=True) + + def __str__(self): + return "{user} <-> {uuid}".format(user=self.user, uuid=self.uuid) + + +class GroupMapping(models.Model): + group = models.OneToOneField("auth.Group", null=False, on_delete=models.CASCADE, related_name="sso_mapping") + dn = models.CharField(max_length=1000, null=False, blank=False) + created_on = models.DateField(null=False, auto_now_add=True) + + def __str__(self): + return "{group} <-> \"{dn}\"".format(group=self.group, dn=self.dn) + diff --git a/ssoauth/sso_utils.py b/ssoauth/sso_utils.py new file mode 100644 index 0000000..ffa1c53 --- /dev/null +++ b/ssoauth/sso_utils.py @@ -0,0 +1,71 @@ +import urllib.request +from xml.etree import ElementTree +from . import logger +from . import app_settings +from django import urls +from onelogin.saml2.settings import OneLogin_Saml2_Settings +from copy import copy + + +def get_idp_runtime_info(meta_url): + """ Grab data directly from the running IDP instance """ + # fetch the meta + try: + io = urllib.request.urlopen(meta_url, timeout=10) + except Exception as e: + logger.error("Could not download IDP meta.") + raise e + # parse the xml + try: + idp_meta = ElementTree.parse(io) + except Exception as e: + logger.error("Could not parse IDP meta.") + raise e + # namespaces + namespaces = { + "ds": "http://www.w3.org/2000/09/xmldsig#", + "saml2": "urn:oasis:names:tc:SAML:2.0:metadata", + } + for prefix, uri in namespaces.items(): + ElementTree.register_namespace(prefix, uri) + # get the required data + signing = idp_meta.findall(".//*[@use='signing']//ds:X509Certificate", namespaces) + encryption = idp_meta.findall(".//*[@use='encryption']//ds:X509Certificate", namespaces) + bindings_sso_redirect = idp_meta.findall(".//saml2:SingleSignOnService[@Binding='urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect']", namespaces) + bindings_slo_redirect = idp_meta.findall(".//saml2:SingleLogoutService[@Binding='urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect']", namespaces) + # log and check + logger.debug("From the IDP metadata received {s} signing certs, {e} encryption certs, {sso} SSO bindings, {slo} SLO bindings.".format( + s=len(signing), e=len(encryption), sso=len(bindings_sso_redirect), slo=len(bindings_slo_redirect), + )) + assert signing and encryption, "Could not find certificates in IDP meta." + assert bindings_sso_redirect is not None, "Could not find SSO HTTP-Redirect binding." + # pack the received data + return { + "certificates": { + "signing": [e.text for e in signing], + "encryption": [e.text for e in encryption] + }, + "bindings": { + "sso_redirect": bindings_sso_redirect[0].get("Location"), + "slo_redirect": bindings_slo_redirect[0].get("Location") if bindings_slo_redirect else None, + } + } + + +def create_onelogin_settings(template): + """ This function is intended to be run only once, on app startup. Raises exceptions. """ + template = copy(template) + # SP settings + this_host = "https://{host}".format(host=app_settings.SP_HOST) + if app_settings.SP_PORT != 443: + this_host += ":{}".format(app_settings.SP_PORT) + template["sp"]["entityId"] = this_host + urls.reverse("sso-saml2-meta") + template["sp"]["assertionConsumerService"]["url"] = this_host + urls.reverse("sso-saml2-acs") + # IDP settings + idp_info = get_idp_runtime_info(app_settings.IDP_META_URL) + template["idp"]["x509certMulti"]["signing"] = idp_info["certificates"]["signing"] + template["idp"]["x509certMulti"]["encryption"] = idp_info["certificates"]["encryption"] + template["idp"]["singleSignOnService"]["url"] = idp_info["bindings"]["sso_redirect"] + template["idp"]["singleLogoutService"]["url"] = idp_info["bindings"]["slo_redirect"] + return OneLogin_Saml2_Settings(settings=template, sp_validation_only=True) + diff --git a/ssoauth/templates/ssoauth/dev.html b/ssoauth/templates/ssoauth/dev.html new file mode 100644 index 0000000..67d678d --- /dev/null +++ b/ssoauth/templates/ssoauth/dev.html @@ -0,0 +1,62 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <title>Debug info</title> + <link rel="stylesheet" href="//fonts.googleapis.com/css?family=Roboto:300,300italic,700,700italic"> + <link rel="stylesheet" href="//cdn.rawgit.com/milligram/milligram/master/dist/milligram.min.css"> + <style> + .hidden {display: none !important;} + .wrapper>.container { padding-bottom: 2rem; padding-top: 2rem; } + </style> +</head> +<body> + <main class="wrapper"> + + <section class="container"> + <h2 class="title">Actions</h2> + <form action="" method="post"> + {% csrf_token %} + <div class="row"> + <div class="column"> + <h4 class="title">Log In</h4> + <input type="text" name="username" id="id_username" placeholder="username" autofocus> + <p><i>Log in as any user</i></p> + </div> + <div class="column"> + <h4 class="title">Toggle Groups</h4> + <select name="toggle_group" class="disabled" id="id_toggle_group" onchange="this.form.submit()"> + {% for value, name in form.fields.toggle_group.choices %} + <option value="{{ value }}">{% if value %}{{ name }}{% endif %}</option> + {% endfor %} + </select> + <p><i>Add or remove groups for the logged in user</i></p> + </div> + <div class="column"> + <h4 class="title">Production Actions</h4> + <a class="button button-outline button-black" href="{% url "sso-login" %}">SSO Log in</a> + <a class="button button-outline button-black" href="{% url "sso-logout" %}">Log out</a> + <p><i>These actions are used in production</i></p> + </div> + </div> + <input class="hidden" type="submit"> + </form> + </section> + + {% for table_name, table_contents in tables %} + <br/> + <section class="container"> + <h2 class="title">{{ table_name }}</h2> + <table class="pure-table"> + {% for k, v in table_contents.items %} + <tr><td>{{ k }}</td><td>{{ v }}</td> + {% empty %} + <tr><td>(empty)</td></tr> + {% endfor %} + </table> + </section> + <br/> + {% endfor %} + </main> +</body> +</html> diff --git a/ssoauth/urls.py b/ssoauth/urls.py new file mode 100644 index 0000000..a291deb --- /dev/null +++ b/ssoauth/urls.py @@ -0,0 +1,11 @@ +from django.conf.urls import url +from . import views + +urlpatterns = ( + url(r"^login/?$", views.LogInView.as_view(), name="sso-login"), + url(r"^logout/?$", views.LogOutView.as_view(), name="sso-logout"), + url(r"^saml2/acs/?$", views.ACSAuthNView.as_view(), name="sso-saml2-acs"), + url(r"^saml2/meta/?$", views.MetadataView.as_view(), name="sso-saml2-meta"), + url(r"^d(?:ev)?/?$", views.DevView.as_view(), name="sso-dev"), +) + diff --git a/ssoauth/views.py b/ssoauth/views.py new file mode 100644 index 0000000..1ee0733 --- /dev/null +++ b/ssoauth/views.py @@ -0,0 +1,193 @@ +from django.views.generic import View, FormView +from django import http +from django import urls +from django.utils.decorators import method_decorator +from django.views.decorators.csrf import csrf_exempt +from django.contrib import auth as contrib_auth +from django.contrib.auth import models as contrib_auth_models +from django import conf +from django.core import exceptions +from django import forms +from . import logger +from . import app_settings +from . import auth_utils +from onelogin.saml2.utils import OneLogin_Saml2_Utils +from onelogin.saml2.auth import OneLogin_Saml2_Auth +from collections import OrderedDict + + +ATTRIBUTE_MAPPING = dict( + # https://commons.lbl.gov/display/IDMgmt/Attribute+Definitions + username="urn:oid:0.9.2342.19200300.100.1.1", + email="urn:oid:0.9.2342.19200300.100.1.3", + forename="urn:oid:2.5.4.42", + surname="urn:oid:2.5.4.4", + uuid="UUID", + idm_groups="IDMGroups", +) + + +class SAMLMixin: + """ Merges Django runtime info and settings for OneLogin toolkit """ + + @staticmethod + def get_onelogin_request_data(request): + return { + # since nginx is in the way, the settings cannot be fetched from the request + 'https': 'on', # nginx should do it and django will never have a clue + 'http_host': app_settings.SP_HOST, # nginx might change it + 'server_port': app_settings.SP_PORT, # nginx will change it + 'script_name': request.META['PATH_INFO'], + 'get_data': request.GET.copy(), + 'post_data': request.POST.copy(), + } + + @classmethod + def get_onelogin_auth(cls, request): + return OneLogin_Saml2_Auth( + request_data=cls.get_onelogin_request_data(request), + old_settings=app_settings.ONELOGIN_SETTINGS + ) + + +class LogInView(SAMLMixin, View): + + def get(self, request, *args, **kwargs): + next_url = "{host}{relative}".format( + host=OneLogin_Saml2_Utils.get_self_url(self.get_onelogin_request_data(request)), + relative=urls.reverse("sso-saml2-meta"), + ) + auth = self.get_onelogin_auth(request) + login = auth.login(return_to=next_url) + return http.HttpResponseRedirect(login) + + +class LogOutView(View): + + def get(self, request, *args, **kwargs): + contrib_auth.logout(request) + logger.warning("Don't know what to do after logging out in Django.") + return http.HttpResponseRedirect(urls.reverse("sso-dev")) + + +@method_decorator(csrf_exempt, "dispatch") +class ACSAuthNView(SAMLMixin, View): + """ + This is NOT a universal ACS. It can only consume artifacts with an AuthN statement. + It's how OneLogin toolkit works, cannot easily detect/process other statements here, so I don't even try. + """ + + def post(self, request, *args, **kwargs): + auth = self.get_onelogin_auth(request) + auth.process_response() + if auth.is_authenticated(): + self.log_in_user(request, auth) + if conf.settings.DEBUG: + request.session["DEBUG_SAML2_ATTRS"] = auth.get_attributes() + return http.HttpResponseRedirect(urls.reverse("sso-dev")) + else: + logger.error("Not authenticated. Errors: {0}".format(auth.get_errors())) + raise exceptions.PermissionDenied() + + def log_in_user(self, request, auth): + + def get_attr(attribute_name, nullable=False, multivalued=False): + attribute_id = ATTRIBUTE_MAPPING[attribute_name] + values = auth.get_attributes().get(attribute_id, list()) + assert nullable or values, "Haven't received any {0}".format(attribute_name) + if multivalued: + return values + else: + assert len(values) <= 1, "Received too many {0}: {1}".format(attribute_name, values) + return values[0] if values else None + + logger.debug("Synchronizing user using SAML2 data: {}".format(auth.get_attributes())) + # get the user + user = auth_utils.get_or_create_user( + uuid=get_attr("uuid"), + username=get_attr("username"), + ) + # update user data + auth_utils.update_user_data( + user=user, + surname=get_attr("surname", nullable=True), + forename=get_attr("forename", nullable=True), + email=get_attr("email", nullable=True), + ) + auth_utils.set_user_groups( + user=user, + group_dn_list=get_attr("idm_groups", nullable=True, multivalued=True) or list() + ) + auth_utils.set_user_compat_flags(user=user) + request.user = user + contrib_auth.login(request, user) + logger.debug("Logged in {user}".format(**locals())) + + +class MetadataView(SAMLMixin, View): + + def get(self, request, *args, **kwargs): + meta = app_settings.ONELOGIN_SETTINGS.get_sp_metadata() + errors = app_settings.ONELOGIN_SETTINGS.validate_metadata(meta) + if errors: + for e in errors: + logger.error(e) + return http.HttpResponseServerError() + return http.HttpResponse(content_type="text/xml", content=meta) + + +class DevView(FormView): + + class DevForm(forms.Form): + username = forms.CharField(required=False) + toggle_group = forms.ModelChoiceField( + queryset=contrib_auth_models.Group.objects.all(), + required=False, + ) + + template_name = "ssoauth/dev.html" + form_class = DevForm + + def __init__(self, *args, **kwargs): + if not conf.settings.DEBUG and False: + raise exceptions.PermissionDenied() + else: + super().__init__(*args, **kwargs) + + def get_context_data(self, **kwargs): + assert conf.settings.DEBUG + context = super().get_context_data(**kwargs) + user = self.request.user + groups = list(user.groups.all()) if user.is_authenticated else list() + context["tables"] =[ + ["User", OrderedDict([ + ["user", "{0} ({1})".format(self.request.user.username, self.request.user.__class__.__name__)], + ["groups", ", ".join(str(g) for g in groups)], + ])], + ["SAML2 Attributes", self.request.session.get("DEBUG_SAML2_ATTRS")], + ["Session", dict(self.request.session)], + ] + return context + + def form_valid(self, form): + assert conf.settings.DEBUG + username = form.cleaned_data["username"] + toggle_group = form.cleaned_data["toggle_group"] + if username: + try: + user = auth_utils.get_user(username=username) + except exceptions.ObjectDoesNotExist: + import uuid + user = auth_utils.get_or_create_user(username=username, uuid=uuid.uuid4()) + self.request.user = user + contrib_auth.login(request=self.request, user=user) + elif toggle_group: + if self.request.user.is_authenticated: + if toggle_group in self.request.user.groups.all(): + self.request.user.groups.remove(toggle_group) + else: + self.request.user.groups.add(toggle_group) + else: + logger.warning("Too anonymous to join groups.") + return http.HttpResponseRedirect(urls.reverse("sso-dev")) + -- GitLab