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 %}
+
+
+{% 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