diff --git a/passbook/root/settings.py b/passbook/root/settings.py index 8ae90be0c..0b2607832 100644 --- a/passbook/root/settings.py +++ b/passbook/root/settings.py @@ -78,6 +78,7 @@ INSTALLED_APPS = [ 'passbook.audit.apps.PassbookAuditConfig', 'passbook.recovery.apps.PassbookRecoveryConfig', + 'passbook.sources.saml.apps.PassbookSourceSAMLConfig', 'passbook.sources.ldap.apps.PassbookSourceLDAPConfig', 'passbook.sources.oauth.apps.PassbookSourceOAuthConfig', diff --git a/passbook/sources/saml/__init__.py b/passbook/sources/saml/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/passbook/sources/saml/admin.py b/passbook/sources/saml/admin.py new file mode 100644 index 000000000..56bfaeba3 --- /dev/null +++ b/passbook/sources/saml/admin.py @@ -0,0 +1,5 @@ +"""SAML SP Admin""" + +from passbook.lib.admin import admin_autoregister + +admin_autoregister('passbook_sources_saml') diff --git a/passbook/sources/saml/api.py b/passbook/sources/saml/api.py new file mode 100644 index 000000000..7af165d0b --- /dev/null +++ b/passbook/sources/saml/api.py @@ -0,0 +1,21 @@ +"""SAMLSource API Views""" +from rest_framework.serializers import ModelSerializer +from rest_framework.viewsets import ModelViewSet + +from passbook.sources.saml.models import SAMLSource + + +class SAMLSourceSerializer(ModelSerializer): + """SAMLSource Serializer""" + + class Meta: + + model = SAMLSource + fields = ['pk', 'entity_id', 'idp_url', 'idp_logout_url', 'auto_logout', 'signing_cert'] + + +class SAMLSourceViewSet(ModelViewSet): + """SAMLSource Viewset""" + + queryset = SAMLSource.objects.all() + serializer_class = SAMLSourceSerializer diff --git a/passbook/sources/saml/apps.py b/passbook/sources/saml/apps.py new file mode 100644 index 000000000..b58fd73fe --- /dev/null +++ b/passbook/sources/saml/apps.py @@ -0,0 +1,12 @@ +"""Passbook SAML app config""" + +from django.apps import AppConfig + + +class PassbookSourceSAMLConfig(AppConfig): + """passbook saml_idp app config""" + + name = 'passbook.sources.saml' + label = 'passbook_sources_saml' + verbose_name = 'passbook Sources.SAML' + mountpoint = 'source/saml/' diff --git a/passbook/sources/saml/forms.py b/passbook/sources/saml/forms.py new file mode 100644 index 000000000..59997e5b8 --- /dev/null +++ b/passbook/sources/saml/forms.py @@ -0,0 +1,32 @@ +"""passbook SAML SP Forms""" + +from django import forms + +from passbook.providers.saml.utils import CertificateBuilder +from passbook.sources.saml.models import SAMLSource + + +class SAMLSourceForm(forms.ModelForm): + """SAML Provider form""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + builder = CertificateBuilder() + builder.build() + self.fields['signing_cert'].initial = builder.certificate + + class Meta: + + model = SAMLSource + fields = ['name', 'entity_id', 'idp_url', 'idp_logout_url', 'auto_logout', 'signing_cert'] + labels = { + 'entity_id': 'Entity ID', + 'idp_url': 'IDP URL', + 'idp_logout_url': 'IDP Logout URL', + } + widgets = { + 'name': forms.TextInput(), + 'entity_id': forms.TextInput(), + 'idp_url': forms.TextInput(), + 'idp_logout_url': forms.TextInput(), + } diff --git a/passbook/sources/saml/migrations/0001_initial.py b/passbook/sources/saml/migrations/0001_initial.py new file mode 100644 index 000000000..380234206 --- /dev/null +++ b/passbook/sources/saml/migrations/0001_initial.py @@ -0,0 +1,33 @@ +# Generated by Django 2.2.6 on 2019-11-07 13:54 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('passbook_core', '0005_merge_20191025_2022'), + ] + + operations = [ + migrations.CreateModel( + name='SAMLSource', + fields=[ + ('source_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='passbook_core.Source')), + ('acs_url', models.URLField()), + ('slo_url', models.URLField()), + ('entity_id', models.TextField(blank=True, default=None)), + ('idp_url', models.URLField()), + ('auto_logout', models.BooleanField(default=False)), + ('signing_cert', models.TextField()), + ('signing_key', models.TextField()), + ], + options={ + 'abstract': False, + }, + bases=('passbook_core.source',), + ), + ] diff --git a/passbook/sources/saml/migrations/0002_auto_20191107_1505.py b/passbook/sources/saml/migrations/0002_auto_20191107_1505.py new file mode 100644 index 000000000..294c44b40 --- /dev/null +++ b/passbook/sources/saml/migrations/0002_auto_20191107_1505.py @@ -0,0 +1,25 @@ +# Generated by Django 2.2.6 on 2019-11-07 15:05 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('passbook_sources_saml', '0001_initial'), + ] + + operations = [ + migrations.AlterModelOptions( + name='samlsource', + options={'verbose_name': 'SAML Source', 'verbose_name_plural': 'SAML Sources'}, + ), + migrations.RemoveField( + model_name='samlsource', + name='acs_url', + ), + migrations.RemoveField( + model_name='samlsource', + name='slo_url', + ), + ] diff --git a/passbook/sources/saml/migrations/0003_auto_20191107_1550.py b/passbook/sources/saml/migrations/0003_auto_20191107_1550.py new file mode 100644 index 000000000..10d065cb7 --- /dev/null +++ b/passbook/sources/saml/migrations/0003_auto_20191107_1550.py @@ -0,0 +1,22 @@ +# Generated by Django 2.2.6 on 2019-11-07 15:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('passbook_sources_saml', '0002_auto_20191107_1505'), + ] + + operations = [ + migrations.RemoveField( + model_name='samlsource', + name='signing_key', + ), + migrations.AddField( + model_name='samlsource', + name='idp_logout_url', + field=models.URLField(blank=True, default=None, null=True), + ), + ] diff --git a/passbook/sources/saml/migrations/__init__.py b/passbook/sources/saml/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/passbook/sources/saml/models.py b/passbook/sources/saml/models.py new file mode 100644 index 000000000..f28a32bf0 --- /dev/null +++ b/passbook/sources/saml/models.py @@ -0,0 +1,35 @@ +"""saml sp models""" +from django.db import models +from django.urls import reverse_lazy +from django.utils.translation import gettext as _ + +from passbook.core.models import Source + + +class SAMLSource(Source): + """SAML2 Source""" + + entity_id = models.TextField(blank=True, default=None) + idp_url = models.URLField() + idp_logout_url = models.URLField(default=None, blank=True, null=True) + auto_logout = models.BooleanField(default=False) + signing_cert = models.TextField() + + form = 'passbook.sources.saml.forms.SAMLSourceForm' + + @property + def login_button(self): + url = reverse_lazy('passbook_sources_saml:login', kwargs={'source': self.slug}) + return url, '', self.name + + @property + def additional_info(self): + metadata_url = reverse_lazy('passbook_sources_saml:metadata', kwargs={ + 'source': self + }) + return f"Metadata Download" + + class Meta: + + verbose_name = _('SAML Source') + verbose_name_plural = _('SAML Sources') diff --git a/passbook/sources/saml/templates/saml/sp/login.html b/passbook/sources/saml/templates/saml/sp/login.html new file mode 100644 index 000000000..dbca2d946 --- /dev/null +++ b/passbook/sources/saml/templates/saml/sp/login.html @@ -0,0 +1,27 @@ +{% extends "login/base.html" %} + +{% load utils %} +{% load i18n %} + +{% block title %} +{% title 'Authorize Application' %} +{% endblock %} + +{% block card %} +
+

{% trans 'Authorize Application' %}

+
+
+ {% csrf_token %} + + +
+

+ {% blocktrans with remote=source.name %} + You're about to sign-in via {{ remote }} + {% endblocktrans %} +

+ +
+
+{% endblock %} diff --git a/passbook/sources/saml/templates/saml/sp/sso_single_logout.html b/passbook/sources/saml/templates/saml/sp/sso_single_logout.html new file mode 100644 index 000000000..8d3ab945c --- /dev/null +++ b/passbook/sources/saml/templates/saml/sp/sso_single_logout.html @@ -0,0 +1,19 @@ +{% extends "saml/sp/base.html" %} + +{% block content %} +You are now logged out of this Service Provider.
+{% if idp_logout_url %} +You are still logged into your Identity Provider. +You should logout of your Identity Provider here:
+{{ idp_logout_url }} +{#XXX: Maybe this should happen as a redirect, rather than as javascript. #} +{% if autosubmit %} + +{% endif %} +{% endif %} +{% endblock content %} diff --git a/passbook/sources/saml/templates/saml/sp/xml/authn_request.xml b/passbook/sources/saml/templates/saml/sp/xml/authn_request.xml new file mode 100644 index 000000000..724a77753 --- /dev/null +++ b/passbook/sources/saml/templates/saml/sp/xml/authn_request.xml @@ -0,0 +1,11 @@ + + + {{ ISSUER }} + {{ AUTHN_REQUEST_SIGNATURE }} + diff --git a/passbook/sources/saml/templates/saml/sp/xml/signature.xml b/passbook/sources/saml/templates/saml/sp/xml/signature.xml new file mode 100644 index 000000000..8da07dddc --- /dev/null +++ b/passbook/sources/saml/templates/saml/sp/xml/signature.xml @@ -0,0 +1,9 @@ + + {{ SIGNED_INFO }} + {{ RSA_SIGNATURE }} + + + {{ CERTIFICATE }} + + + diff --git a/passbook/sources/saml/templates/saml/sp/xml/signed_info.xml b/passbook/sources/saml/templates/saml/sp/xml/signed_info.xml new file mode 100644 index 000000000..d57858fe6 --- /dev/null +++ b/passbook/sources/saml/templates/saml/sp/xml/signed_info.xml @@ -0,0 +1,12 @@ + + + + + + + + + + {{ SUBJECT_DIGEST }} + + diff --git a/passbook/sources/saml/templates/saml/sp/xml/spssodescriptor.xml b/passbook/sources/saml/templates/saml/sp/xml/spssodescriptor.xml new file mode 100644 index 000000000..e990f5a91 --- /dev/null +++ b/passbook/sources/saml/templates/saml/sp/xml/spssodescriptor.xml @@ -0,0 +1,70 @@ + + + + + + {{ cert_public_key }} + + + + + + + {{ cert_public_key }} + + + + + urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress + + +{% comment %} + + + + urn:oasis:names:tc:SAML:2.0:nameid-format:transient + + + + + + Service Provider Portal + + + + +{% endcomment %} + +{% comment %} + +{# if org #} + + {{ org.name }} + {{ org.display_name }} + {{ org.url }} + +{# endif #} + +{# for contact in contacts #} + + {{ contact.given_name }} + {{ contact.sur_name }} + {{ contact.email }} + +{# endfor #} +{% endcomment %} + diff --git a/passbook/sources/saml/urls.py b/passbook/sources/saml/urls.py new file mode 100644 index 000000000..affc03b9a --- /dev/null +++ b/passbook/sources/saml/urls.py @@ -0,0 +1,12 @@ +"""saml sp urls""" +from django.urls import path + +from passbook.sources.saml.views import (ACSView, InitiateView, MetadataView, + SLOView) + +urlpatterns = [ + path('/', InitiateView.as_view(), name='login'), + path('/acs/', ACSView.as_view(), name='acs'), + path('/slo/', SLOView.as_view(), name='slo'), + path('/metadata/', MetadataView.as_view(), name='metadata'), +] diff --git a/passbook/sources/saml/utils.py b/passbook/sources/saml/utils.py new file mode 100644 index 000000000..0d90e574b --- /dev/null +++ b/passbook/sources/saml/utils.py @@ -0,0 +1,84 @@ +"""saml sp helpers""" +from django.http import HttpRequest +from django.shortcuts import reverse + +from passbook.core.models import User +from passbook.sources.saml.models import SAMLSource + + +def get_entity_id(request: HttpRequest, source: SAMLSource): + """Get Source's entity ID, falling back to our Metadata URL if none is set""" + entity_id = source.entity_id + if entity_id is None: + return build_full_url('metadata', request, source) + return entity_id + + +def build_full_url(view: str, request: HttpRequest, source: SAMLSource) -> str: + """Build Full ACS URL to be used in IDP""" + return request.build_absolute_uri( + reverse(f"passbook_sources_saml:{view}", kwargs={ + 'source': source.slug + })) + + +def _get_email_from_response(root): + """ + Returns the email out of the response. + + At present, response must pass the email address as the Subject, eg.: + + + email@example.com + """ + assertion = root.find("{urn:oasis:names:tc:SAML:2.0:assertion}Assertion") + subject = assertion.find("{urn:oasis:names:tc:SAML:2.0:assertion}Subject") + name_id = subject.find("{urn:oasis:names:tc:SAML:2.0:assertion}NameID") + return name_id.text + + +def _get_attributes_from_response(root): + """ + Returns the SAML Attributes (if any) that are present in the response. + + NOTE: Technically, attribute values could be any XML structure. + But for now, just assume a single string value. + """ + flat_attributes = {} + assertion = root.find("{urn:oasis:names:tc:SAML:2.0:assertion}Assertion") + attributes = assertion.find("{urn:oasis:names:tc:SAML:2.0:assertion}AttributeStatement") + for attribute in attributes.getchildren(): + name = attribute.attrib.get('Name') + children = attribute.getchildren() + if not children: + # Ignore empty-valued attributes. (I think these are not allowed.) + continue + if len(children) == 1: + #See NOTE: + flat_attributes[name] = children[0].text + else: + # It has multiple values. + for child in children: + #See NOTE: + flat_attributes.setdefault(name, []).append(child.text) + return flat_attributes + + +def _get_user_from_response(root): + """ + Gets info out of the response and locally logs in this user. + May create a local user account first. + Returns the user object that was created. + """ + email = _get_email_from_response(root) + try: + user = User.objects.get(email=email) + except User.DoesNotExist: + user = User.objects.create_user( + username=email, + email=email) + user.set_unusable_password() + user.save() + return user diff --git a/passbook/sources/saml/views.py b/passbook/sources/saml/views.py new file mode 100644 index 000000000..bb88414db --- /dev/null +++ b/passbook/sources/saml/views.py @@ -0,0 +1,86 @@ +"""saml sp views""" +import base64 + +from defusedxml import ElementTree +from django.contrib.auth import login, logout +from django.http import HttpRequest, HttpResponse +from django.shortcuts import get_object_or_404, redirect, render, reverse +from django.utils.decorators import method_decorator +from django.views import View +from django.views.decorators.csrf import csrf_exempt + +from passbook.providers.saml.base import get_random_id, get_time_string +from passbook.providers.saml.utils import nice64 +from passbook.providers.saml.views import render_xml +from passbook.sources.saml.models import SAMLSource +from passbook.sources.saml.utils import (_get_user_from_response, + build_full_url, get_entity_id) +from passbook.sources.saml.xml_render import get_authnrequest_xml + + +class InitiateView(View): + """Get the Form with SAML Request, which sends us to the IDP""" + + def get(self, request: HttpRequest, source: str) -> HttpResponse: + """Replies with an XHTML SSO Request.""" + source: SAMLSource = get_object_or_404(SAMLSource, slug=source) + sso_destination = request.GET.get('next', None) + request.session['sso_destination'] = sso_destination + parameters = { + 'ACS_URL': build_full_url('acs', request, source), + 'DESTINATION': source.idp_url, + 'AUTHN_REQUEST_ID': get_random_id(), + 'ISSUE_INSTANT': get_time_string(), + 'ISSUER': get_entity_id(request, source), + } + authn_req = get_authnrequest_xml(parameters, signed=False) + _request = nice64(str.encode(authn_req)) + return render(request, 'saml/sp/login.html', { + 'request_url': source.idp_url, + 'request': _request, + 'token': sso_destination, + 'source': source + }) + + +@method_decorator(csrf_exempt, name='dispatch') +class ACSView(View): + """AssertionConsumerService, consume assertion and log user in""" + + def post(self, request: HttpRequest, source: str) -> HttpResponse: + """Handles a POSTed SSO Assertion and logs the user in.""" + # sso_session = request.POST.get('RelayState', None) + data = request.POST.get('SAMLResponse', None) + response = base64.b64decode(data) + root = ElementTree.fromstring(response) + user = _get_user_from_response(root) + # attributes = _get_attributes_from_response(root) + login(request, user, backend='django.contrib.auth.backends.ModelBackend') + return redirect(reverse('passbook_core:overview')) + + +class SLOView(View): + """Single-Logout-View""" + + def dispatch(self, request: HttpRequest, source: str) -> HttpResponse: + """Replies with an XHTML SSO Request.""" + source: SAMLSource = get_object_or_404(SAMLSource, slug=source) + logout(request) + return render(request, 'saml/sp/sso_single_logout.html', { + 'idp_logout_url': source.idp_logout_url, + 'autosubmit': source.auto_logout, + }) + + +class MetadataView(View): + """Return XML Metadata for IDP""" + + def dispatch(self, request: HttpRequest, source: str) -> HttpResponse: + """Replies with the XML Metadata SPSSODescriptor.""" + source: SAMLSource = get_object_or_404(SAMLSource, slug=source) + entity_id = get_entity_id(request, source) + return render_xml(request, 'saml/sp/xml/spssodescriptor.xml', { + 'acs_url': build_full_url('acs', request, source), + 'entity_id': entity_id, + 'cert_public_key': source.signing_cert, + }) diff --git a/passbook/sources/saml/xml_render.py b/passbook/sources/saml/xml_render.py new file mode 100644 index 000000000..b438cfec3 --- /dev/null +++ b/passbook/sources/saml/xml_render.py @@ -0,0 +1,28 @@ +"""Functions for creating XML output.""" +from structlog import get_logger + +from passbook.lib.utils.template import render_to_string +from passbook.providers.saml.xml_signing import get_signature_xml + +LOGGER = get_logger() + + +def get_authnrequest_xml(parameters, signed=False): + """Get AuthN Request XML""" + # Reset signature. + params = {} + params.update(parameters) + params['AUTHN_REQUEST_SIGNATURE'] = '' + + unsigned = render_to_string('saml/sp/xml/authn_request.xml', params) + LOGGER.debug('AuthN Request', unsigned=unsigned) + if not signed: + return unsigned + + # Sign it. + signature_xml = get_signature_xml() + params['AUTHN_REQUEST_SIGNATURE'] = signature_xml + signed = render_to_string('saml/sp/xml/authn_request.xml', params) + + LOGGER.debug('AuthN Request', signed=signed) + return signed diff --git a/passbook/sources/saml/xml_signing.py b/passbook/sources/saml/xml_signing.py new file mode 100644 index 000000000..f6556d21a --- /dev/null +++ b/passbook/sources/saml/xml_signing.py @@ -0,0 +1,66 @@ +#XXX: Use svn:externals to get the same version as in saml2idp??? +""" +Signing code goes here. +""" +# # python: +# import hashlib +# import string + +from structlog import get_logger + +# other libraries: +# this app: +# from passbook.providers.saml.utils import nice64 +# from passbook.sources.saml.xml_templates import SIGNATURE, SIGNED_INFO + +LOGGER = get_logger() + +# def get_signature_xml(subject, reference_uri): +# """ +# Returns XML Signature for subject. +# """ +# private_key_file = saml2sp_settings.SAML2SP_PRIVATE_KEY_FILE +# certificate_file = saml2sp_settings.SAML2SP_CERTIFICATE_FILE +# LOGGER.debug('get_signature_xml - Begin.') +# LOGGER.debug('Using private key file: ' + private_key_file) +# LOGGER.debug('Using certificate file: ' + certificate_file) +# LOGGER.debug('Subject: ' + subject) + +# # Hash the subject. +# subject_hash = hashlib.sha1() +# subject_hash.update(subject) +# subject_digest = nice64(subject_hash.digest()) +# LOGGER.debug('Subject digest: ' + subject_digest) + +# # Create signed_info. +# signed_info = string.Template(SIGNED_INFO).substitute({ +# 'REFERENCE_URI': reference_uri, +# 'SUBJECT_DIGEST': subject_digest, +# }) +# LOGGER.debug('SignedInfo XML: ' + signed_info) + +# # # "Digest" the signed_info. +# # info_hash = hashlib.sha1() +# # info_hash.update(signed_info) +# # info_digest = info_hash.digest() +# # LOGGER.debug('Info digest: ' + nice64(info_digest)) + +# # RSA-sign the signed_info. +# private_key = M2Crypto.EVP.load_key(private_key_file) +# private_key.sign_init() +# private_key.sign_update(signed_info) +# rsa_signature = nice64(private_key.sign_final()) +# LOGGER.debug('RSA Signature: ' + rsa_signature) + +# # Load the certificate. +# cert_data = load_cert_data(certificate_file) + +# # Put the signed_info and rsa_signature into the XML signature. +# signed_info_short = signed_info.replace('xmlns:ds="http://www.w3.org/2000/09/xmldsig#"', '') +# signature_xml = string.Template(SIGNATURE).substitute({ +# 'RSA_SIGNATURE': rsa_signature, +# 'SIGNED_INFO': signed_info_short, +# 'CERTIFICATE': cert_data, +# }) +# LOGGER.debug('Signature XML: ' + signature_xml) +# return signature_xml