*(minor): small refactor

This commit is contained in:
Langhammer, Jens
2019-10-07 16:33:48 +02:00
parent d21ec6c9a5
commit f2acc154cd
300 changed files with 1420 additions and 1788 deletions

View File

View File

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

View File

@ -0,0 +1,25 @@
"""passbook mod saml_idp app config"""
from importlib import import_module
from django.apps import AppConfig
from django.conf import settings
from structlog import get_logger
LOGGER = get_logger()
class PassbookProviderSAMLConfig(AppConfig):
"""passbook saml_idp app config"""
name = 'passbook.providers.saml'
label = 'passbook_providers_saml'
verbose_name = 'passbook Providers.SAML'
mountpoint = 'application/saml/'
def ready(self):
"""Load source_types from config file"""
for source_type in settings.PASSBOOK_PROVIDERS_SAML_PROCESSORS:
try:
import_module(source_type)
LOGGER.info("Loaded SAML Processor", processor_class=source_type)
except ImportError as exc:
LOGGER.debug(exc)

View File

@ -0,0 +1,324 @@
"""Basic SAML Processor"""
import time
import uuid
from defusedxml import ElementTree
from structlog import get_logger
from passbook.providers.saml import exceptions, utils, xml_render
MINUTES = 60
HOURS = 60 * MINUTES
def get_random_id():
"""Random hex id"""
# It is very important that these random IDs NOT start with a number.
random_id = '_' + uuid.uuid4().hex
return random_id
def get_time_string(delta=0):
"""Get Data formatted in SAML format"""
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(time.time() + delta))
# Design note: I've tried to make this easy to sub-class and override
# just the bits you need to override. I've made use of object properties,
# so that your sub-classes have access to all information: use wisely.
# Formatting note: These methods are alphabetized.
# pylint: disable=too-many-instance-attributes
class Processor:
"""Base SAML 2.0 AuthnRequest to Response Processor.
Sub-classes should provide Service Provider-specific functionality."""
is_idp_initiated = False
_audience = ''
_assertion_params = None
_assertion_xml = None
_assertion_id = None
_django_request = None
_relay_state = None
_request = None
_request_id = None
_request_xml = None
_request_params = None
_response_id = None
_response_xml = None
_response_params = None
_saml_request = None
_saml_response = None
_session_index = None
_subject = None
_subject_format = 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent'
_system_params = {}
@property
def dotted_path(self):
"""Return a dotted path to this class"""
return '{module}.{class_name}'.format(
module=self.__module__,
class_name=self.__class__.__name__)
def __init__(self, remote):
self.name = remote.name
self._remote = remote
self._logger = get_logger()
self._system_params['ISSUER'] = self._remote.issuer
self._logger.debug('processor configured')
def _build_assertion(self):
"""Builds _assertion_params."""
self._determine_assertion_id()
self._determine_audience()
self._determine_subject()
self._determine_session_index()
self._assertion_params = {
'ASSERTION_ID': self._assertion_id,
'ASSERTION_SIGNATURE': '', # it's unsigned
'AUDIENCE': self._audience,
'AUTH_INSTANT': get_time_string(),
'ISSUE_INSTANT': get_time_string(),
'NOT_BEFORE': get_time_string(-1 * HOURS), # TODO: Make these settings.
'NOT_ON_OR_AFTER': get_time_string(86400 * MINUTES),
'SESSION_INDEX': self._session_index,
'SESSION_NOT_ON_OR_AFTER': get_time_string(8 * HOURS),
'SP_NAME_QUALIFIER': self._audience,
'SUBJECT': self._subject,
'SUBJECT_FORMAT': self._subject_format,
}
self._assertion_params.update(self._system_params)
self._assertion_params.update(self._request_params)
def _build_response(self):
"""Builds _response_params."""
self._determine_response_id()
self._response_params = {
'ASSERTION': self._assertion_xml,
'ISSUE_INSTANT': get_time_string(),
'RESPONSE_ID': self._response_id,
'RESPONSE_SIGNATURE': '', # initially unsigned
}
self._response_params.update(self._system_params)
self._response_params.update(self._request_params)
def _decode_request(self):
"""Decodes _request_xml from _saml_request."""
self._request_xml = utils.decode_base64_and_inflate(self._saml_request).decode('utf-8')
self._logger.debug('SAML request decoded')
def _determine_assertion_id(self):
"""Determines the _assertion_id."""
self._assertion_id = get_random_id()
def _determine_audience(self):
"""Determines the _audience."""
self._audience = self._remote.audience
self._logger.info('determined audience')
def _determine_response_id(self):
"""Determines _response_id."""
self._response_id = get_random_id()
def _determine_session_index(self):
self._session_index = self._django_request.session.session_key
def _determine_subject(self):
"""Determines _subject and _subject_type for Assertion Subject."""
self._subject = self._django_request.user.email
def _encode_response(self):
"""Encodes _response_xml to _encoded_xml."""
self._saml_response = utils.nice64(str.encode(self._response_xml))
def _extract_saml_request(self):
"""Retrieves the _saml_request AuthnRequest from the _django_request."""
self._saml_request = self._django_request.session['SAMLRequest']
self._relay_state = self._django_request.session['RelayState']
def _format_assertion(self):
"""Formats _assertion_params as _assertion_xml."""
self._assertion_params['ATTRIBUTES'] = [
{
'FriendlyName': 'eduPersonPrincipalName',
'Name': 'urn:oid:1.3.6.1.4.1.5923.1.1.1.6',
'Value': self._django_request.user.email,
},
{
'FriendlyName': 'cn',
'Name': 'urn:oid:2.5.4.3',
'Value': self._django_request.user.name,
},
{
'FriendlyName': 'mail',
'Name': 'urn:oid:0.9.2342.19200300.100.1.3',
'Value': self._django_request.user.email,
},
{
'FriendlyName': 'displayName',
'Name': 'urn:oid:2.16.840.1.113730.3.1.241',
'Value': self._django_request.user.username,
},
]
from passbook.providers.saml.models import SAMLPropertyMapping
for mapping in self._remote.property_mappings.all().select_subclasses():
if isinstance(mapping, SAMLPropertyMapping):
mapping_payload = {
'Name': mapping.saml_name,
'ValueArray': [],
'FriendlyName': mapping.friendly_name
}
for value in mapping.values:
mapping_payload['ValueArray'].append(value.format(
user=self._django_request.user,
request=self._django_request
))
self._assertion_params['ATTRIBUTES'].append(mapping_payload)
self._assertion_xml = xml_render.get_assertion_xml(
'saml/xml/assertions/generic.xml', self._assertion_params, signed=True)
def _format_response(self):
"""Formats _response_params as _response_xml."""
assertion_id = self._assertion_params['ASSERTION_ID']
self._response_xml = xml_render.get_response_xml(self._response_params,
saml_provider=self._remote,
assertion_id=assertion_id)
def _get_django_response_params(self):
"""Returns a dictionary of parameters for the response template."""
return {
'acs_url': self._request_params['ACS_URL'],
'saml_response': self._saml_response,
'relay_state': self._relay_state,
'autosubmit': self._remote.application.skip_authorization,
}
def _parse_request(self):
"""Parses various parameters from _request_xml into _request_params."""
# Minimal test to verify that it's not binarily encoded still:
if not str(self._request_xml.strip()).startswith('<'):
raise Exception('RequestXML is not valid XML; '
'it may need to be decoded or decompressed.')
root = ElementTree.fromstring(self._request_xml)
params = {}
params['ACS_URL'] = root.attrib['AssertionConsumerServiceURL']
params['REQUEST_ID'] = root.attrib['ID']
params['DESTINATION'] = root.attrib.get('Destination', '')
params['PROVIDER_NAME'] = root.attrib.get('ProviderName', '')
self._request_params = params
def _reset(self, django_request, sp_config=None):
"""Initialize (and reset) object properties, so we don't risk carrying
over anything from the last authentication.
If provided, use sp_config throughout; otherwise, it will be set in
_validate_request(). """
self._assertion_params = sp_config
self._assertion_xml = sp_config
self._assertion_id = sp_config
self._django_request = django_request
self._relay_state = sp_config
self._request = sp_config
self._request_id = sp_config
self._request_xml = sp_config
self._request_params = sp_config
self._response_id = sp_config
self._response_xml = sp_config
self._response_params = sp_config
self._saml_request = sp_config
self._saml_response = sp_config
self._session_index = sp_config
self._subject = sp_config
self._subject_format = 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent'
self._system_params = {
'ISSUER': self._remote.issuer
}
def _validate_request(self):
"""
Validates the SAML request against the SP configuration of this
processor. Sub-classes should override this and raise a
`CannotHandleAssertion` exception if the validation fails.
Raises:
CannotHandleAssertion: if the ACS URL specified in the SAML request
doesn't match the one specified in the processor config.
"""
request_acs_url = self._request_params['ACS_URL']
if self._remote.acs_url != request_acs_url:
msg = ("couldn't find ACS url '{}' in SAML2IDP_REMOTES "
"setting.".format(request_acs_url))
self._logger.info(msg)
raise exceptions.CannotHandleAssertion(msg)
def _validate_user(self):
"""Validates the User. Sub-classes should override this and
throw an CannotHandleAssertion Exception if the validation does not succeed."""
def can_handle(self, request):
"""Returns true if this processor can handle this request."""
self._reset(request)
# Read the request.
try:
self._extract_saml_request()
except Exception as exc:
msg = "can't find SAML request in user session: %s" % exc
self._logger.info(msg)
raise exceptions.CannotHandleAssertion(msg)
try:
self._decode_request()
except Exception as exc:
msg = "can't decode SAML request: %s" % exc
self._logger.info(msg)
raise exceptions.CannotHandleAssertion(msg)
try:
self._parse_request()
except Exception as exc:
msg = "can't parse SAML request: %s" % exc
self._logger.info(msg)
raise exceptions.CannotHandleAssertion(msg)
self._validate_request()
return True
def generate_response(self):
"""Processes request and returns template variables suitable for a response."""
# Build the assertion and response.
# Only call can_handle if SP initiated Request, otherwise we have no Request
if not self.is_idp_initiated:
self.can_handle(self._django_request)
self._validate_user()
self._build_assertion()
self._format_assertion()
self._build_response()
self._format_response()
self._encode_response()
# Return proper template params.
return self._get_django_response_params()
def init_deep_link(self, request, url):
"""Initialize this Processor to make an IdP-initiated call to the SP's
deep-linked URL."""
self._reset(request)
acs_url = self._remote.acs_url
# NOTE: The following request params are made up. Some are blank,
# because they comes over in the AuthnRequest, but we don't have an
# AuthnRequest in this case:
# - Destination: Should be this IdP's SSO endpoint URL. Not used in the response?
# - ProviderName: According to the spec, this is optional.
self._request_params = {
'ACS_URL': acs_url,
'DESTINATION': '',
'PROVIDER_NAME': '',
}
self._relay_state = url

View File

@ -0,0 +1,9 @@
"""passbook SAML IDP Exceptions"""
class CannotHandleAssertion(Exception):
"""This processor does not handle this assertion."""
class UserNotAuthorized(Exception):
"""User not authorized for SAML 2.0 authentication."""

View File

@ -0,0 +1,59 @@
"""passbook SAML IDP Forms"""
from django import forms
from django.contrib.admin.widgets import FilteredSelectMultiple
from django.utils.translation import gettext as _
from passbook.lib.fields import DynamicArrayField
from passbook.providers.saml.models import (SAMLPropertyMapping, SAMLProvider,
get_provider_choices)
from passbook.providers.saml.utils import CertificateBuilder
class SAMLProviderForm(forms.ModelForm):
"""SAML Provider form"""
processor_path = forms.ChoiceField(choices=get_provider_choices(), label='Processor')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
builder = CertificateBuilder()
builder.build()
self.fields['signing_cert'].initial = builder.certificate
self.fields['signing_key'].initial = builder.private_key
class Meta:
model = SAMLProvider
fields = ['name', 'property_mappings', 'acs_url', 'audience', 'processor_path', 'issuer',
'assertion_valid_for', 'signing', 'signing_cert', 'signing_key', ]
labels = {
'acs_url': 'ACS URL',
'signing_cert': 'Singing Certificate',
}
widgets = {
'name': forms.TextInput(),
'audience': forms.TextInput(),
'issuer': forms.TextInput(),
'property_mappings': FilteredSelectMultiple(_('Property Mappings'), False)
}
class SAMLPropertyMappingForm(forms.ModelForm):
"""SAML Property Mapping form"""
class Meta:
model = SAMLPropertyMapping
fields = ['name', 'saml_name', 'friendly_name', 'values']
widgets = {
'name': forms.TextInput(),
'saml_name': forms.TextInput(),
'friendly_name': forms.TextInput(),
}
field_classes = {
'values': DynamicArrayField
}
help_texts = {
'values': 'String substitution uses a syntax like "{variable} test}".'
}

View File

@ -0,0 +1,51 @@
# Generated by Django 2.2.6 on 2019-10-07 14:07
import django.contrib.postgres.fields
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('passbook_core', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='SAMLPropertyMapping',
fields=[
('propertymapping_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='passbook_core.PropertyMapping')),
('saml_name', models.TextField()),
('friendly_name', models.TextField(blank=True, default=None, null=True)),
('values', django.contrib.postgres.fields.ArrayField(base_field=models.TextField(), size=None)),
],
options={
'verbose_name': 'SAML Property Mapping',
'verbose_name_plural': 'SAML Property Mappings',
},
bases=('passbook_core.propertymapping',),
),
migrations.CreateModel(
name='SAMLProvider',
fields=[
('provider_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='passbook_core.Provider')),
('name', models.TextField()),
('acs_url', models.URLField()),
('audience', models.TextField(default='')),
('processor_path', models.CharField(max_length=255)),
('issuer', models.TextField()),
('assertion_valid_for', models.IntegerField(default=86400)),
('signing', models.BooleanField(default=True)),
('signing_cert', models.TextField()),
('signing_key', models.TextField()),
],
options={
'verbose_name': 'SAML Provider',
'verbose_name_plural': 'SAML Providers',
},
bases=('passbook_core.provider',),
),
]

View File

@ -0,0 +1,83 @@
"""passbook saml_idp Models"""
from django.contrib.postgres.fields import ArrayField
from django.db import models
from django.shortcuts import reverse
from django.utils.translation import gettext as _
from structlog import get_logger
from passbook.core.models import PropertyMapping, Provider
from passbook.lib.utils.reflection import class_to_path, path_to_class
from passbook.providers.saml.base import Processor
LOGGER = get_logger()
class SAMLProvider(Provider):
"""Model to save information about a Remote SAML Endpoint"""
name = models.TextField()
acs_url = models.URLField()
audience = models.TextField(default='')
processor_path = models.CharField(max_length=255, choices=[])
issuer = models.TextField()
assertion_valid_for = models.IntegerField(default=86400)
signing = models.BooleanField(default=True)
signing_cert = models.TextField()
signing_key = models.TextField()
form = 'passbook.providers.saml.forms.SAMLProviderForm'
_processor = None
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._meta.get_field('processor_path').choices = get_provider_choices()
@property
def processor(self):
"""Return selected processor as instance"""
if not self._processor:
try:
self._processor = path_to_class(self.processor_path)(self)
except ModuleNotFoundError as exc:
LOGGER.warning(exc)
self._processor = None
return self._processor
def __str__(self):
return "SAML Provider %s" % self.name
def link_download_metadata(self):
"""Get link to download XML metadata for admin interface"""
try:
# pylint: disable=no-member
return reverse('passbook_saml_idp:saml-metadata',
kwargs={'application': self.application.slug})
except Provider.application.RelatedObjectDoesNotExist:
return None
class Meta:
verbose_name = _('SAML Provider')
verbose_name_plural = _('SAML Providers')
class SAMLPropertyMapping(PropertyMapping):
"""SAML Property mapping, allowing Name/FriendlyName mapping to a list of strings"""
saml_name = models.TextField()
friendly_name = models.TextField(default=None, blank=True, null=True)
values = ArrayField(models.TextField())
form = 'passbook.providers.saml.forms.SAMLPropertyMappingForm'
def __str__(self):
return "SAML Property Mapping %s" % self.saml_name
class Meta:
verbose_name = _('SAML Property Mapping')
verbose_name_plural = _('SAML Property Mappings')
def get_provider_choices():
"""Return tuple of class_path, class name of all providers."""
return [(class_to_path(x), x.__name__) for x in Processor.__subclasses__()]

View File

@ -0,0 +1,7 @@
"""Generic Processor"""
from passbook.providers.saml.base import Processor
class GenericProcessor(Processor):
"""Generic Response Handler Processor for testing against django-saml2-sp."""

View File

@ -0,0 +1,15 @@
"""Salesforce Processor"""
from passbook.providers.saml.base import Processor
from passbook.providers.saml.xml_render import get_assertion_xml
class SalesForceProcessor(Processor):
"""SalesForce.com-specific SAML 2.0 AuthnRequest to Response Handler Processor."""
def _determine_audience(self):
self._audience = 'IAMShowcase'
def _format_assertion(self):
self._assertion_xml = get_assertion_xml(
'saml/xml/assertions/salesforce.xml', self._assertion_params, signed=True)

View File

@ -0,0 +1,6 @@
"""saml provider settings"""
PASSBOOK_PROVIDERS_SAML_PROCESSORS = [
'passbook.providers.saml.processors.generic',
'passbook.providers.saml.processors.salesforce',
]

View File

@ -0,0 +1,5 @@
{% extends "saml/idp/base.html" %}
{% load i18n %}
{% block content %}
{% trans "You have logged in, but your user account is not enabled for SAML 2.0." %}
{% endblock %}

View File

@ -0,0 +1,5 @@
{% extends "saml/idp/base.html" %}
{% load i18n %}
{% block content %}
{% trans "You have successfully logged out of the Identity Provider." %}
{% endblock %}

View File

@ -0,0 +1,34 @@
{% 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="{{ acs_url }}">
{% csrf_token %}
<input type="hidden" name="ACSUrl" value="{{ acs_url }}">
<input type="hidden" name="RelayState" value="{{ relay_state }}" />
<input type="hidden" name="SAMLResponse" value="{{ saml_response }}" />
<div class="login-group">
<h3>
{% blocktrans with remote=remote.application.name %}
You're about to sign into {{ remote }}
{% endblocktrans %}
</h3>
<p>
{% blocktrans with user=user %}
You are logged in as {{ user }}.
{% endblocktrans %}
<a href="{% url 'passbook_core:auth-logout' %}">{% trans 'Not you?' %}</a>
</p>
<input class="btn btn-primary btn-block btn-lg" type="submit" value="{% trans 'Continue' %}" />
</div>
</form>
{% endblock %}

View File

@ -0,0 +1,47 @@
{% extends "_admin/module_default.html" %}
{% load i18n %}
{% load utils %}
{% block title %}
{% title "Overview" %}
{% endblock %}
{% block module_content %}
<h2><clr-icon shape="application" size="32"></clr-icon>{% trans 'SAML2 IDP' %}</h2>
<div class="row">
<div class="col-md-12">
<div class="card">
<div class="card-header">
<h2><clr-icon shape="settings" size="32"></clr-icon>{% trans 'Settings' %}</h2>
</div>
<form role="form" method="POST">
<div class="card-block">
{% include 'partials/form.html' with form=form %}
</div>
<div class="card-footer">
<button type="submit" class="btn btn-primary">{% trans 'Update' %}</button>
</div>
</form>
</div>
</div>
</div>
<div class="row">
<div class="col-md-12">
<div class="card">
<div class="card-header">
<h2><clr-icon shape="bank" size="32"></clr-icon>{% trans 'Metadata' %}</h2>
</div>
<div class="card-block">
<p>{% trans 'Cert Fingerprint (SHA1):' %} <pre>{{ fingerprint }}</pre></p>
<section class="form-block">
<pre lang="xml" >{{ metadata }}</pre>
</section>
</div>
<div class="card-footer">
<a href="{% url 'passbook_saml_idp:saml-metadata' %}" class="btn btn-primary"><clr-icon shape="download"></clr-icon>{% trans 'Download Metadata' %}</a>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,19 @@
<saml:Assertion xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
ID="{{ ASSERTION_ID }}"
IssueInstant="{{ ISSUE_INSTANT }}"
Version="2.0">
<saml:Issuer xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">{{ ISSUER }}</saml:Issuer>
{% include 'saml/xml/signature.xml' %}
{{ SUBJECT_STATEMENT }}
<saml:Conditions NotBefore="{{ NOT_BEFORE }}" NotOnOrAfter="{{ NOT_ON_OR_AFTER }}">
<saml:AudienceRestriction>
<saml:Audience>{{ AUDIENCE }}</saml:Audience>
</saml:AudienceRestriction>
</saml:Conditions>
<saml:AuthnStatement AuthnInstant="{{ NOT_BEFORE }}" SessionIndex="{{ ASSERTION_ID }}">
<saml:AuthnContext>
<saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport</saml:AuthnContextClassRef>
</saml:AuthnContext>
</saml:AuthnStatement>
{{ ATTRIBUTE_STATEMENT }}
</saml:Assertion>

View File

@ -0,0 +1,15 @@
<saml:Assertion xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
ID="{{ ASSERTION_ID }}"
IssueInstant="{{ ISSUE_INSTANT }}"
Version="2.0">
<saml:Issuer>{{ ISSUER }}</saml:Issuer>
{% include 'saml/xml/signature.xml' %}
{% include 'saml/xml/subject.xml' %}
<saml:Conditions NotBefore="{{ NOT_BEFORE }}" NotOnOrAfter="{{ NOT_ON_OR_AFTER }}" />
<saml:AuthnStatement AuthnInstant="{{ AUTH_INSTANT }}">
<saml:AuthnContext>
<saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:Password</saml:AuthnContextClassRef>
</saml:AuthnContext>
</saml:AuthnStatement>
{{ ATTRIBUTE_STATEMENT }}
</saml:Assertion>

View File

@ -0,0 +1,19 @@
<saml:Assertion xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
ID="{{ ASSERTION_ID }}"
IssueInstant="{{ ISSUE_INSTANT }}"
Version="2.0">
<saml:Issuer xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">{{ ISSUER }}</saml:Issuer>
{{ ASSERTION_SIGNATURE|safe }}
{% include 'saml/xml/subject.xml' %}
<saml:Conditions NotBefore="{{ NOT_BEFORE }}" NotOnOrAfter="{{ NOT_ON_OR_AFTER }}">
<saml:AudienceRestriction>
<saml:Audience>{{ AUDIENCE }}</saml:Audience>
</saml:AudienceRestriction>
</saml:Conditions>
<saml:AuthnStatement AuthnInstant="{{ AUTH_INSTANT }}">
<saml:AuthnContext>
<saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:Password</saml:AuthnContextClassRef>
</saml:AuthnContext>
</saml:AuthnStatement>
{{ ATTRIBUTE_STATEMENT|safe }}
</saml:Assertion>

View File

@ -0,0 +1,14 @@
<saml:AttributeStatement>
{% for attr in attributes %}
<saml:Attribute {% if attr.FriendlyName %}FriendlyName="{{ attr.FriendlyName }}" {% endif %}Name="{{ attr.Name }}">
{% if attr.Value %}
<saml:AttributeValue>{{ attr.Value }}</saml:AttributeValue>
{% endif %}
{% if attr.ValueArray %}
{% for value in attr.ValueArray %}
<saml:AttributeValue>{{ value }}</saml:AttributeValue>
{% endfor %}
{% endif %}
</saml:Attribute>
{% endfor %}
</saml:AttributeStatement>

View File

@ -0,0 +1,40 @@
<?xml version="1.0"?>
<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" entityID="{{ entity_id }}">
<md:IDPSSODescriptor 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:SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="{{ slo_url }}"/>
<md:NameIDFormat>urn:oasis:names:tc:SAML:2.0:nameid-format:persistent</md:NameIDFormat>
<md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="{{ sso_url }}"/>
</md:IDPSSODescriptor>
{% 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,14 @@
<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
xmlns:ds="http://www.w3.org/2000/09/xmldsig#"
Destination="{{ ACS_URL }}"
ID="{{ RESPONSE_ID }}"
{{ IN_RESPONSE_TO|safe }}
IssueInstant="{{ ISSUE_INSTANT }}"
Version="2.0">
<saml:Issuer xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">{{ ISSUER }}</saml:Issuer>
{{ ASSERTION_SIGNATURE }}
<samlp:Status>
<samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success" />
</samlp:Status>
{{ ASSERTION }}
</samlp:Response>

View File

@ -0,0 +1 @@
<ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#" Id="placeholder"></ds:Signature>

View File

@ -0,0 +1,8 @@
<saml:Subject>
<saml:NameID Format="{{ SUBJECT_FORMAT }}">
{{ SUBJECT }}
</saml:NameID>
<saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
<saml:SubjectConfirmationData {{ IN_RESPONSE_TO|safe }} NotOnOrAfter="{{ NOT_ON_OR_AFTER }}" Recipient="{{ ACS_URL }}" />
</saml:SubjectConfirmation>
</saml:Subject>

View File

@ -0,0 +1,17 @@
"""passbook SAML IDP URLs"""
from django.urls import path
from passbook.providers.saml import views
urlpatterns = [
path('<slug:application>/login/',
views.LoginBeginView.as_view(), name="saml-login"),
path('<slug:application>/login/initiate/',
views.InitiateLoginView.as_view(), name="saml-login-initiate"),
path('<slug:application>/login/process/',
views.LoginProcessView.as_view(), name='saml-login-process'),
path('<slug:application>/logout/', views.LogoutView.as_view(), name="saml-logout"),
path('<slug:application>/logout/slo/', views.SLOLogout.as_view(), name="saml-logout-slo"),
path('<slug:application>/metadata/',
views.DescriptorDownloadView.as_view(), name='saml-metadata'),
]

View File

@ -0,0 +1,88 @@
"""Wrappers to de/encode and de/inflate strings"""
import base64
import datetime
import uuid
import zlib
from cryptography import x509
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.x509.oid import NameOID
def decode_base64_and_inflate(b64string):
"""Base64 decode and ZLib decompress b64string"""
decoded_data = base64.b64decode(b64string)
return zlib.decompress(decoded_data, -15)
def deflate_and_base64_encode(string_val):
"""Base64 and ZLib Compress b64string"""
zlibbed_str = zlib.compress(string_val)
compressed_string = zlibbed_str[2:-4]
return base64.b64encode(compressed_string)
def nice64(src):
""" Returns src base64-encoded and formatted nicely for our XML. """
return base64.b64encode(src).decode('utf-8').replace('\n', '')
class CertificateBuilder:
"""Build self-signed certificates"""
__public_key = None
__private_key = None
__builder = None
__certificate = None
def __init__(self):
self.__public_key = None
self.__private_key = None
self.__builder = None
self.__certificate = None
def build(self):
"""Build self-signed certificate"""
one_day = datetime.timedelta(1, 0, 0)
self.__private_key = rsa.generate_private_key(
public_exponent=65537,
key_size=2048,
backend=default_backend()
)
self.__public_key = self.__private_key.public_key()
self.__builder = \
x509.CertificateBuilder(). \
subject_name(x509.Name([
x509.NameAttribute(NameOID.COMMON_NAME, u'passbook Self-signed SAML Certificate'),
x509.NameAttribute(NameOID.ORGANIZATION_NAME, u'passbook'),
x509.NameAttribute(NameOID.ORGANIZATIONAL_UNIT_NAME, u'Self-signed'),
])). \
issuer_name(x509.Name([
x509.NameAttribute(NameOID.COMMON_NAME, u'passbook Self-signed SAML Certificate'),
])). \
not_valid_before(datetime.datetime.today() - one_day). \
not_valid_after(datetime.datetime.today() + datetime.timedelta(days=365)). \
serial_number(int(uuid.uuid4())). \
public_key(self.__public_key)
self.__certificate = self.__builder.sign(
private_key=self.__private_key, algorithm=hashes.SHA256(),
backend=default_backend()
)
@property
def private_key(self):
"""Return private key in PEM format"""
return self.__private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption(),
).decode('utf-8')
@property
def certificate(self):
"""Return certificate in PEM format"""
return self.__certificate.public_bytes(
encoding=serialization.Encoding.PEM,
).decode('utf-8')

View File

@ -0,0 +1,234 @@
"""passbook SAML IDP Views"""
from django.contrib.auth import logout
from django.contrib.auth.mixins import AccessMixin
from django.core.exceptions import ValidationError
from django.core.validators import URLValidator
from django.http import HttpResponse, HttpResponseBadRequest
from django.shortcuts import get_object_or_404, redirect, render, reverse
from django.utils.datastructures import MultiValueDictKeyError
from django.utils.decorators import method_decorator
from django.utils.translation import gettext as _
from django.views import View
from django.views.decorators.csrf import csrf_exempt
from signxml.util import strip_pem_header
from structlog import get_logger
from passbook.audit.models import AuditEntry
from passbook.core.models import Application
from passbook.lib.mixins import CSRFExemptMixin
from passbook.lib.utils.template import render_to_string
from passbook.policies.engine import PolicyEngine
from passbook.providers.saml import exceptions
from passbook.providers.saml.models import SAMLProvider
LOGGER = get_logger()
URL_VALIDATOR = URLValidator(schemes=('http', 'https'))
def _generate_response(request, provider: SAMLProvider):
"""Generate a SAML response using processor_instance and return it in the proper Django
response."""
try:
provider.processor.init_deep_link(request, '')
ctx = provider.processor.generate_response()
ctx['remote'] = provider
ctx['is_login'] = True
except exceptions.UserNotAuthorized:
return render(request, 'saml/idp/invalid_user.html')
return render(request, 'saml/idp/login.html', ctx)
def render_xml(request, template, ctx):
"""Render template with content_type application/xml"""
return render(request, template, context=ctx, content_type="application/xml")
class AccessRequiredView(AccessMixin, View):
"""Mixin class for Views using a provider instance"""
_provider = None
@property
def provider(self):
"""Get provider instance"""
if not self._provider:
application = get_object_or_404(Application, slug=self.kwargs['application'])
self._provider = get_object_or_404(SAMLProvider, pk=application.provider_id)
return self._provider
def _has_access(self):
"""Check if user has access to application"""
policy_engine = PolicyEngine(self.provider.application.policies.all())
policy_engine.for_user(self.request.user).with_request(self.request).build()
return policy_engine.passing
def dispatch(self, request, *args, **kwargs):
if not request.user.is_authenticated:
return self.handle_no_permission()
if not self._has_access():
return render(request, 'login/denied.html', {
'title': _("You don't have access to this application"),
'is_login': True
})
return super().dispatch(request, *args, **kwargs)
class LoginBeginView(AccessRequiredView):
"""Receives a SAML 2.0 AuthnRequest from a Service Provider and
stores it in the session prior to enforcing login."""
@method_decorator(csrf_exempt)
def dispatch(self, request, application):
if request.method == 'POST':
source = request.POST
else:
source = request.GET
# Store these values now, because Django's login cycle won't preserve them.
try:
request.session['SAMLRequest'] = source['SAMLRequest']
except (KeyError, MultiValueDictKeyError):
return HttpResponseBadRequest('the SAML request payload is missing')
request.session['RelayState'] = source.get('RelayState', '')
return redirect(reverse('passbook_saml_idp:saml-login-process', kwargs={
'application': application
}))
class RedirectToSPView(AccessRequiredView):
"""Return autosubmit form"""
def get(self, request, acs_url, saml_response, relay_state):
"""Return autosubmit form"""
return render(request, 'core/autosubmit_form.html', {
'url': acs_url,
'attrs': {
'SAMLResponse': saml_response,
'RelayState': relay_state
}
})
class LoginProcessView(AccessRequiredView):
"""Processor-based login continuation.
Presents a SAML 2.0 Assertion for POSTing back to the Service Provider."""
def get(self, request, application):
"""Handle get request, i.e. render form"""
LOGGER.debug("Request: %s", request)
# Check if user has access
if self.provider.application.skip_authorization:
ctx = self.provider.processor.generate_response()
# Log Application Authorization
AuditEntry.create(
action=AuditEntry.ACTION_AUTHORIZE_APPLICATION,
request=request,
app=self.provider.application.name,
skipped_authorization=True)
return RedirectToSPView.as_view()(
request=request,
acs_url=ctx['acs_url'],
saml_response=ctx['saml_response'],
relay_state=ctx['relay_state'])
try:
full_res = _generate_response(request, self.provider)
return full_res
except exceptions.CannotHandleAssertion as exc:
LOGGER.debug(exc)
def post(self, request, application):
"""Handle post request, return back to ACS"""
LOGGER.debug("Request: %s", request)
# Check if user has access
if request.POST.get('ACSUrl', None):
# User accepted request
AuditEntry.create(
action=AuditEntry.ACTION_AUTHORIZE_APPLICATION,
request=request,
app=self.provider.application.name,
skipped_authorization=False)
return RedirectToSPView.as_view()(
request=request,
acs_url=request.POST.get('ACSUrl'),
saml_response=request.POST.get('SAMLResponse'),
relay_state=request.POST.get('RelayState'))
try:
full_res = _generate_response(request, self.provider)
return full_res
except exceptions.CannotHandleAssertion as exc:
LOGGER.debug(exc)
class LogoutView(CSRFExemptMixin, AccessRequiredView):
"""Allows a non-SAML 2.0 URL to log out the user and
returns a standard logged-out page. (SalesForce and others use this method,
though it's technically not SAML 2.0)."""
def get(self, request, application):
"""Perform logout"""
logout(request)
redirect_url = request.GET.get('redirect_to', '')
try:
URL_VALIDATOR(redirect_url)
except ValidationError:
pass
else:
return redirect(redirect_url)
return render(request, 'saml/idp/logged_out.html')
class SLOLogout(CSRFExemptMixin, AccessRequiredView):
"""Receives a SAML 2.0 LogoutRequest from a Service Provider,
logs out the user and returns a standard logged-out page."""
def post(self, request, application):
"""Perform logout"""
request.session['SAMLRequest'] = request.POST['SAMLRequest']
# TODO: Parse SAML LogoutRequest from POST data, similar to login_process().
# TODO: Modify the base processor to handle logouts?
# TODO: Combine this with login_process(), since they are so very similar?
# TODO: Format a LogoutResponse and return it to the browser.
# XXX: For now, simply log out without validating the request.
logout(request)
return render(request, 'saml/idp/logged_out.html')
class DescriptorDownloadView(AccessRequiredView):
"""Replies with the XML Metadata IDSSODescriptor."""
def get(self, request, application):
"""Replies with the XML Metadata IDSSODescriptor."""
entity_id = self.provider.issuer
slo_url = request.build_absolute_uri(reverse('passbook_saml_idp:saml-logout', kwargs={
'application': application
}))
sso_url = request.build_absolute_uri(reverse('passbook_saml_idp:saml-login', kwargs={
'application': application
}))
pubkey = strip_pem_header(self.provider.signing_cert.replace('\r', '')).replace('\n', '')
ctx = {
'entity_id': entity_id,
'cert_public_key': pubkey,
'slo_url': slo_url,
'sso_url': sso_url
}
metadata = render_to_string('saml/xml/metadata.xml', ctx)
response = HttpResponse(metadata, content_type='application/xml')
response['Content-Disposition'] = ('attachment; filename="'
'%s_passbook_meta.xml"' % self.provider.name)
return response
class InitiateLoginView(AccessRequiredView):
"""IdP-initiated Login"""
def get(self, request, application):
"""Initiates an IdP-initiated link to a simple SP resource/target URL."""
self.provider.processor.init_deep_link(request, '')
self.provider.processor.is_idp_initiated = True
return _generate_response(request, self.provider)

View File

@ -0,0 +1,93 @@
"""Functions for creating XML output."""
from __future__ import annotations
from typing import TYPE_CHECKING
from structlog import get_logger
from passbook.lib.utils.template import render_to_string
from passbook.providers.saml.xml_signing import (get_signature_xml,
sign_with_signxml)
if TYPE_CHECKING:
from passbook.providers.saml.models import SAMLProvider
LOGGER = get_logger()
def _get_attribute_statement(params):
"""Inserts AttributeStatement, if we have any attributes.
Modifies the params dict.
PRE-REQ: params['SUBJECT'] has already been created (usually by a call to
_get_subject()."""
attributes = params.get('ATTRIBUTES', [])
if not attributes:
params['ATTRIBUTE_STATEMENT'] = ''
return
# Build complete AttributeStatement.
params['ATTRIBUTE_STATEMENT'] = render_to_string('saml/xml/attributes.xml', {
'attributes': attributes})
def _get_in_response_to(params):
"""Insert InResponseTo if we have a RequestID.
Modifies the params dict."""
# NOTE: I don't like this. We're mixing templating logic here, but the
# current design requires this; maybe refactor using better templates, or
# just bite the bullet and use elementtree to produce the XML; see comments
# in xml_templates about Canonical XML.
request_id = params.get('REQUEST_ID', None)
if request_id:
params['IN_RESPONSE_TO'] = 'InResponseTo="%s" ' % request_id
else:
params['IN_RESPONSE_TO'] = ''
def _get_subject(params):
"""Insert Subject. Modifies the params dict."""
params['SUBJECT_STATEMENT'] = render_to_string('saml/xml/subject.xml', params)
def get_assertion_xml(template, parameters, signed=False):
"""Get XML for Assertion"""
# Reset signature.
params = {}
params.update(parameters)
params['ASSERTION_SIGNATURE'] = ''
_get_in_response_to(params)
_get_subject(params) # must come before _get_attribute_statement()
_get_attribute_statement(params)
unsigned = render_to_string(template, params)
# LOGGER.debug('Unsigned: %s', unsigned)
if not signed:
return unsigned
# Sign it.
signature_xml = get_signature_xml()
params['ASSERTION_SIGNATURE'] = signature_xml
return render_to_string(template, params)
def get_response_xml(parameters, saml_provider: SAMLProvider, assertion_id=''):
"""Returns XML for response, with signatures, if signed is True."""
# Reset signatures.
params = {}
params.update(parameters)
params['RESPONSE_SIGNATURE'] = ''
_get_in_response_to(params)
raw_response = render_to_string('saml/xml/response.xml', params)
# LOGGER.debug('Unsigned: %s', unsigned)
if not saml_provider.signing:
return raw_response
signature_xml = get_signature_xml()
params['RESPONSE_SIGNATURE'] = signature_xml
# LOGGER.debug("Raw response: %s", raw_response)
signed = sign_with_signxml(
saml_provider.signing_key, raw_response, saml_provider.signing_cert,
reference_uri=assertion_id)
return signed

View File

@ -0,0 +1,29 @@
"""Signing code goes here."""
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import serialization
from lxml import etree # nosec
from signxml import XMLSigner, XMLVerifier
from structlog import get_logger
from passbook.lib.utils.template import render_to_string
LOGGER = get_logger()
def sign_with_signxml(private_key, data, cert, reference_uri=None):
"""Sign Data with signxml"""
key = serialization.load_pem_private_key(
str.encode('\n'.join([x.strip() for x in private_key.split('\n')])),
password=None, backend=default_backend())
# defused XML is not used here because it messes up XML namespaces
# Data is trusted, so lxml is ok
root = etree.fromstring(data) # nosec
signer = XMLSigner(c14n_algorithm='http://www.w3.org/2001/10/xml-exc-c14n#')
signed = signer.sign(root, key=key, cert=[cert], reference_uri=reference_uri)
XMLVerifier().verify(signed, x509_cert=cert)
return etree.tostring(signed).decode('utf-8') # nosec
def get_signature_xml():
"""Returns XML Signature for subject."""
return render_to_string('saml/xml/signature.xml', {})