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.recovery.apps.PassbookRecoveryConfig',
|
||||
|
||||
'passbook.sources.saml.apps.PassbookSourceSAMLConfig',
|
||||
'passbook.sources.ldap.apps.PassbookSourceLDAPConfig',
|
||||
'passbook.sources.oauth.apps.PassbookSourceOAuthConfig',
|
||||
|
||||
|
|
0
passbook/sources/saml/__init__.py
Normal file
0
passbook/sources/saml/__init__.py
Normal file
5
passbook/sources/saml/admin.py
Normal file
5
passbook/sources/saml/admin.py
Normal file
|
@ -0,0 +1,5 @@
|
|||
"""SAML SP Admin"""
|
||||
|
||||
from passbook.lib.admin import admin_autoregister
|
||||
|
||||
admin_autoregister('passbook_sources_saml')
|
21
passbook/sources/saml/api.py
Normal file
21
passbook/sources/saml/api.py
Normal 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
|
12
passbook/sources/saml/apps.py
Normal file
12
passbook/sources/saml/apps.py
Normal 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/'
|
32
passbook/sources/saml/forms.py
Normal file
32
passbook/sources/saml/forms.py
Normal 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(),
|
||||
}
|
33
passbook/sources/saml/migrations/0001_initial.py
Normal file
33
passbook/sources/saml/migrations/0001_initial.py
Normal 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',),
|
||||
),
|
||||
]
|
25
passbook/sources/saml/migrations/0002_auto_20191107_1505.py
Normal file
25
passbook/sources/saml/migrations/0002_auto_20191107_1505.py
Normal 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',
|
||||
),
|
||||
]
|
22
passbook/sources/saml/migrations/0003_auto_20191107_1550.py
Normal file
22
passbook/sources/saml/migrations/0003_auto_20191107_1550.py
Normal 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),
|
||||
),
|
||||
]
|
0
passbook/sources/saml/migrations/__init__.py
Normal file
0
passbook/sources/saml/migrations/__init__.py
Normal file
35
passbook/sources/saml/models.py
Normal file
35
passbook/sources/saml/models.py
Normal 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')
|
27
passbook/sources/saml/templates/saml/sp/login.html
Normal file
27
passbook/sources/saml/templates/saml/sp/login.html
Normal 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 %}
|
|
@ -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>
|
12
passbook/sources/saml/templates/saml/sp/xml/signed_info.xml
Normal file
12
passbook/sources/saml/templates/saml/sp/xml/signed_info.xml
Normal 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>
|
|
@ -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>
|
12
passbook/sources/saml/urls.py
Normal file
12
passbook/sources/saml/urls.py
Normal 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'),
|
||||
]
|
84
passbook/sources/saml/utils.py
Normal file
84
passbook/sources/saml/utils.py
Normal 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
|
86
passbook/sources/saml/views.py
Normal file
86
passbook/sources/saml/views.py
Normal 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,
|
||||
})
|
28
passbook/sources/saml/xml_render.py
Normal file
28
passbook/sources/saml/xml_render.py
Normal 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
|
66
passbook/sources/saml/xml_signing.py
Normal file
66
passbook/sources/saml/xml_signing.py
Normal 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
|
Reference in a new issue