*(minor): small refactor
This commit is contained in:
0
passbook/providers/saml/__init__.py
Normal file
0
passbook/providers/saml/__init__.py
Normal file
5
passbook/providers/saml/admin.py
Normal file
5
passbook/providers/saml/admin.py
Normal file
@ -0,0 +1,5 @@
|
||||
"""SAML IDP Admin"""
|
||||
|
||||
from passbook.lib.admin import admin_autoregister
|
||||
|
||||
admin_autoregister('passbook_providers_saml')
|
||||
25
passbook/providers/saml/apps.py
Normal file
25
passbook/providers/saml/apps.py
Normal 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)
|
||||
324
passbook/providers/saml/base.py
Normal file
324
passbook/providers/saml/base.py
Normal 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
|
||||
9
passbook/providers/saml/exceptions.py
Normal file
9
passbook/providers/saml/exceptions.py
Normal 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."""
|
||||
59
passbook/providers/saml/forms.py
Normal file
59
passbook/providers/saml/forms.py
Normal 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}".'
|
||||
}
|
||||
51
passbook/providers/saml/migrations/0001_initial.py
Normal file
51
passbook/providers/saml/migrations/0001_initial.py
Normal 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',),
|
||||
),
|
||||
]
|
||||
0
passbook/providers/saml/migrations/__init__.py
Normal file
0
passbook/providers/saml/migrations/__init__.py
Normal file
83
passbook/providers/saml/models.py
Normal file
83
passbook/providers/saml/models.py
Normal 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__()]
|
||||
0
passbook/providers/saml/processors/__init__.py
Normal file
0
passbook/providers/saml/processors/__init__.py
Normal file
7
passbook/providers/saml/processors/generic.py
Normal file
7
passbook/providers/saml/processors/generic.py
Normal 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."""
|
||||
15
passbook/providers/saml/processors/salesforce.py
Normal file
15
passbook/providers/saml/processors/salesforce.py
Normal 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)
|
||||
6
passbook/providers/saml/settings.py
Normal file
6
passbook/providers/saml/settings.py
Normal file
@ -0,0 +1,6 @@
|
||||
"""saml provider settings"""
|
||||
|
||||
PASSBOOK_PROVIDERS_SAML_PROCESSORS = [
|
||||
'passbook.providers.saml.processors.generic',
|
||||
'passbook.providers.saml.processors.salesforce',
|
||||
]
|
||||
@ -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 %}
|
||||
@ -0,0 +1,5 @@
|
||||
{% extends "saml/idp/base.html" %}
|
||||
{% load i18n %}
|
||||
{% block content %}
|
||||
{% trans "You have successfully logged out of the Identity Provider." %}
|
||||
{% endblock %}
|
||||
34
passbook/providers/saml/templates/saml/idp/login.html
Normal file
34
passbook/providers/saml/templates/saml/idp/login.html
Normal 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 %}
|
||||
47
passbook/providers/saml/templates/saml/idp/settings.html
Normal file
47
passbook/providers/saml/templates/saml/idp/settings.html
Normal 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 %}
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
14
passbook/providers/saml/templates/saml/xml/attributes.xml
Normal file
14
passbook/providers/saml/templates/saml/xml/attributes.xml
Normal 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>
|
||||
40
passbook/providers/saml/templates/saml/xml/metadata.xml
Normal file
40
passbook/providers/saml/templates/saml/xml/metadata.xml
Normal 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>
|
||||
14
passbook/providers/saml/templates/saml/xml/response.xml
Normal file
14
passbook/providers/saml/templates/saml/xml/response.xml
Normal 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>
|
||||
1
passbook/providers/saml/templates/saml/xml/signature.xml
Normal file
1
passbook/providers/saml/templates/saml/xml/signature.xml
Normal file
@ -0,0 +1 @@
|
||||
<ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#" Id="placeholder"></ds:Signature>
|
||||
8
passbook/providers/saml/templates/saml/xml/subject.xml
Normal file
8
passbook/providers/saml/templates/saml/xml/subject.xml
Normal 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>
|
||||
17
passbook/providers/saml/urls.py
Normal file
17
passbook/providers/saml/urls.py
Normal 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'),
|
||||
]
|
||||
88
passbook/providers/saml/utils.py
Normal file
88
passbook/providers/saml/utils.py
Normal 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')
|
||||
234
passbook/providers/saml/views.py
Normal file
234
passbook/providers/saml/views.py
Normal 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)
|
||||
93
passbook/providers/saml/xml_render.py
Normal file
93
passbook/providers/saml/xml_render.py
Normal 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
|
||||
29
passbook/providers/saml/xml_signing.py
Normal file
29
passbook/providers/saml/xml_signing.py
Normal 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', {})
|
||||
Reference in New Issue
Block a user