Merge branch '10-saml-sp' into 'master'

Resolve "Add SAML SP"

Closes #10

See merge request BeryJu.org/passbook!31
This commit is contained in:
Jens Langhammer 2019-11-07 16:05:28 +00:00
commit afdac5f3f8
22 changed files with 610 additions and 0 deletions

View File

@ -78,6 +78,7 @@ INSTALLED_APPS = [
'passbook.audit.apps.PassbookAuditConfig', 'passbook.audit.apps.PassbookAuditConfig',
'passbook.recovery.apps.PassbookRecoveryConfig', 'passbook.recovery.apps.PassbookRecoveryConfig',
'passbook.sources.saml.apps.PassbookSourceSAMLConfig',
'passbook.sources.ldap.apps.PassbookSourceLDAPConfig', 'passbook.sources.ldap.apps.PassbookSourceLDAPConfig',
'passbook.sources.oauth.apps.PassbookSourceOAuthConfig', 'passbook.sources.oauth.apps.PassbookSourceOAuthConfig',

View File

View File

@ -0,0 +1,5 @@
"""SAML SP Admin"""
from passbook.lib.admin import admin_autoregister
admin_autoregister('passbook_sources_saml')

View File

@ -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

View File

@ -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/'

View File

@ -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(),
}

View File

@ -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',),
),
]

View File

@ -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',
),
]

View File

@ -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),
),
]

View File

@ -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"<a href=\"{metadata_url}\" class=\"btn btn-default btn-sm\">Metadata Download</a>"
class Meta:
verbose_name = _('SAML Source')
verbose_name_plural = _('SAML Sources')

View File

@ -0,0 +1,27 @@
{% extends "login/base.html" %}
{% load utils %}
{% load i18n %}
{% block title %}
{% title 'Authorize Application' %}
{% endblock %}
{% block card %}
<header class="login-pf-header">
<h1>{% trans 'Authorize Application' %}</h1>
</header>
<form method="POST" action="{{ request_url }}">
{% csrf_token %}
<input type="hidden" name="SAMLRequest" value="{{ request }}" />
<input type="hidden" name="RelayState" value="{{ token }}" />
<div class="login-group">
<h3>
{% blocktrans with remote=source.name %}
You're about to sign-in via {{ remote }}
{% endblocktrans %}
</h3>
<input class="btn btn-primary btn-block btn-lg" type="submit" value="{% trans 'Continue' %}" />
</div>
</form>
{% endblock %}

View File

@ -0,0 +1,19 @@
{% extends "saml/sp/base.html" %}
{% block content %}
You are now logged out of this Service Provider.<br />
{% if idp_logout_url %}
You are still logged into your Identity Provider.
You should logout of your Identity Provider here:<br />
<a href="{{ idp_logout_url }}">{{ idp_logout_url }}</a>
{#XXX: Maybe this should happen as a redirect, rather than as javascript. #}
{% if autosubmit %}
<script language="javascript">
<!--
/* Automatically submit the form. */
document.location.href = '{{ idp_logout_url }}';
//-->
</script>
{% endif %}
{% endif %}
{% endblock content %}

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<samlp:AuthnRequest AssertionConsumerServiceURL="{{ ACS_URL }}"
Destination="{{ DESTINATION }}"
ID="{{ AUTHN_REQUEST_ID }}"
IssueInstant="{{ ISSUE_INSTANT }}"
ProtocolBinding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
Version="2.0"
xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol">
<saml:Issuer xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">{{ ISSUER }}</saml:Issuer>
{{ AUTHN_REQUEST_SIGNATURE }}
</samlp:AuthnRequest>

View File

@ -0,0 +1,9 @@
<ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
{{ SIGNED_INFO }}
<ds:SignatureValue>{{ RSA_SIGNATURE }}</ds:SignatureValue>
<ds:KeyInfo>
<ds:X509Data>
<ds:X509Certificate>{{ CERTIFICATE }}</ds:X509Certificate>
</ds:X509Data>
</ds:KeyInfo>
</ds:Signature>

View File

@ -0,0 +1,12 @@
<ds:SignedInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"></ds:CanonicalizationMethod>
<ds:SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"></ds:SignatureMethod>
<ds:Reference URI="#${REFERENCE_URI}">
<ds:Transforms>
<ds:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"></ds:Transform>
<ds:Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"></ds:Transform>
</ds:Transforms>
<ds:DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"></ds:DigestMethod>
<ds:DigestValue>{{ SUBJECT_DIGEST }}</ds:DigestValue>
</ds:Reference>
</ds:SignedInfo>

View File

@ -0,0 +1,70 @@
<md:EntityDescriptor
xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata"
xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
xmlns:ds="http://www.w3.org/2000/09/xmldsig#"
entityID="{{ entity_id }}">
<md:SPSSODescriptor
protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<md:KeyDescriptor use="signing">
<ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<ds:X509Data>
<ds:X509Certificate>{{ cert_public_key }}</ds:X509Certificate>
</ds:X509Data>
</ds:KeyInfo>
</md:KeyDescriptor>
<md:KeyDescriptor use="encryption">
<ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<ds:X509Data>
<ds:X509Certificate>{{ cert_public_key }}</ds:X509Certificate>
</ds:X509Data>
</ds:KeyInfo>
</md:KeyDescriptor>
<md:NameIDFormat>
urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress
</md:NameIDFormat>
<md:AssertionConsumerService isDefault="true" index="0"
Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
Location="{{ acs_url }}"/>
{% comment %}
<!-- Other bits that we might need. -->
<!-- Ref: saml-metadata-2.0-os.pdf, pg 10, section 2.3... -->
<md:NameIDFormat>
urn:oasis:names:tc:SAML:2.0:nameid-format:transient
</md:NameIDFormat>
<md:ArtifactResolutionService isDefault="true" index="0"
Binding="urn:oasis:names:tc:SAML:2.0:bindings:SOAP"
Location="https://sp.example.com/SAML2/ArtifactResolution"/>
<md:AssertionConsumerService index="1"
Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Artifact"
Location="https://sp.example.com/SAML2/Artifact"/>
<md:AttributeConsumingService isDefault="true" index="1">
<md:ServiceName xml:lang="en">
Service Provider Portal
</md:ServiceName>
<md:RequestedAttribute
NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri"
Name="urn:oid:1.3.6.1.4.1.5923.1.1.1.1"
FriendlyName="eduPersonAffiliation">
</md:RequestedAttribute>
</md:AttributeConsumingService>
{% endcomment %}
</md:SPSSODescriptor>
{% comment %}
<!-- #TODO: Add support for optional Organization section -->
{# if org #}
<md:Organization>
<md:OrganizationName xml:lang="en">{{ org.name }}</md:OrganizationName>
<md:OrganizationDisplayName xml:lang="en">{{ org.display_name }}</md:OrganizationDisplayName>
<md:OrganizationURL xml:lang="en">{{ org.url }}</md:OrganizationURL>
</md:Organization>
{# endif #}
<!-- #TODO: Add support for optional ContactPerson section(s) -->
{# for contact in contacts #}
<md:ContactPerson contactType="{{ contact.type }}">
<md:GivenName>{{ contact.given_name }}</md:GivenName>
<md:SurName>{{ contact.sur_name }}</md:SurName>
<md:EmailAddress>{{ contact.email }}</md:EmailAddress>
</md:ContactPerson>
{# endfor #}
{% endcomment %}
</md:EntityDescriptor>

View File

@ -0,0 +1,12 @@
"""saml sp urls"""
from django.urls import path
from passbook.sources.saml.views import (ACSView, InitiateView, MetadataView,
SLOView)
urlpatterns = [
path('<slug:source>/', InitiateView.as_view(), name='login'),
path('<slug:source>/acs/', ACSView.as_view(), name='acs'),
path('<slug:source>/slo/', SLOView.as_view(), name='slo'),
path('<slug:source>/metadata/', MetadataView.as_view(), name='metadata'),
]

View File

@ -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.:
<saml:Subject>
<saml:NameID Format="urn:oasis:names:tc:SAML:2.0:nameid-format:email"
SPNameQualifier=""
>email@example.com</saml:NameID>
"""
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

View File

@ -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,
})

View File

@ -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

View File

@ -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