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:
commit
afdac5f3f8
|
@ -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',
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,5 @@
|
||||||
|
"""SAML SP Admin"""
|
||||||
|
|
||||||
|
from passbook.lib.admin import admin_autoregister
|
||||||
|
|
||||||
|
admin_autoregister('passbook_sources_saml')
|
|
@ -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
|
|
@ -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/'
|
|
@ -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(),
|
||||||
|
}
|
|
@ -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',),
|
||||||
|
),
|
||||||
|
]
|
|
@ -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',
|
||||||
|
),
|
||||||
|
]
|
|
@ -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),
|
||||||
|
),
|
||||||
|
]
|
|
@ -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')
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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'),
|
||||||
|
]
|
|
@ -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
|
|
@ -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,
|
||||||
|
})
|
|
@ -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
|
|
@ -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
|
Reference in New Issue