From 56d872af15074ca14158dad82223388c6ad7a21d Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Fri, 8 Mar 2019 12:47:50 +0100 Subject: [PATCH 1/3] add PropertyMapping Model, add Subclass for SAML, test with AWS --- .../admin/templates/administration/base.html | 70 ++++++++------- .../administration/property_mapping/list.html | 52 +++++++++++ passbook/admin/urls.py | 13 ++- passbook/admin/views/property_mapping.py | 90 +++++++++++++++++++ passbook/core/auth/factors/password.py | 2 +- .../core/migrations/0017_propertymapping.py | 26 ++++++ .../0018_provider_property_mappings.py | 18 ++++ passbook/core/models.py | 20 ++++- passbook/core/templates/generic/delete.html | 4 +- passbook/saml_idp/base.py | 19 ++-- passbook/saml_idp/forms.py | 19 +++- .../migrations/0002_samlpropertymapping.py | 30 +++++++ passbook/saml_idp/models.py | 20 ++++- passbook/saml_idp/processors/aws.py | 10 +-- .../saml_idp/templates/saml/idp/login.html | 52 ++++------- .../templates/saml/xml/attributes.xml | 9 +- 16 files changed, 368 insertions(+), 86 deletions(-) create mode 100644 passbook/admin/templates/administration/property_mapping/list.html create mode 100644 passbook/admin/views/property_mapping.py create mode 100644 passbook/core/migrations/0017_propertymapping.py create mode 100644 passbook/core/migrations/0018_provider_property_mappings.py create mode 100644 passbook/saml_idp/migrations/0002_samlpropertymapping.py diff --git a/passbook/admin/templates/administration/base.html b/passbook/admin/templates/administration/base.html index fadc37d70..208108c0d 100644 --- a/passbook/admin/templates/administration/base.html +++ b/passbook/admin/templates/administration/base.html @@ -5,35 +5,45 @@ {% block nav_secondary %} {% endblock %} diff --git a/passbook/admin/templates/administration/property_mapping/list.html b/passbook/admin/templates/administration/property_mapping/list.html new file mode 100644 index 000000000..7bf8c2da2 --- /dev/null +++ b/passbook/admin/templates/administration/property_mapping/list.html @@ -0,0 +1,52 @@ +{% extends "administration/base.html" %} + +{% load i18n %} +{% load utils %} + +{% block title %} +{% title %} +{% endblock %} + +{% block content %} +
+

{% trans "Property Mappings" %}

+ {% trans "Property Mappings allow you expose provider-specific attributes." %} +
+ +
+ + + + + + + + + + {% for property_mapping in object_list %} + + + + + + {% endfor %} + +
{% trans 'Name' %}{% trans 'Type' %}
{{ property_mapping.name }} ({{ property_mapping.slug }}){{ property_mapping|verbose_name }} + {% trans 'Edit' %} + {% trans 'Delete' %} +
+
+{% endblock %} diff --git a/passbook/admin/urls.py b/passbook/admin/urls.py index e2d3a6236..fa4c9ec0a 100644 --- a/passbook/admin/urls.py +++ b/passbook/admin/urls.py @@ -2,8 +2,8 @@ from django.urls import include, path from passbook.admin.views import (applications, audit, factors, groups, - invitations, overview, policy, providers, - sources, users) + invitations, overview, policy, + property_mapping, providers, sources, users) urlpatterns = [ path('', overview.AdministrationOverviewView.as_view(), name='overview'), @@ -43,6 +43,15 @@ urlpatterns = [ factors.FactorUpdateView.as_view(), name='factor-update'), path('factors//delete/', factors.FactorDeleteView.as_view(), name='factor-delete'), + # Factors + path('property-mappings/', property_mapping.PropertyMappingListView.as_view(), + name='property-mappings'), + path('property-mappings/create/', + property_mapping.PropertyMappingCreateView.as_view(), name='property-mapping-create'), + path('property-mappings//update/', + property_mapping.PropertyMappingUpdateView.as_view(), name='property-mapping-update'), + path('property-mappings//delete/', + property_mapping.PropertyMappingDeleteView.as_view(), name='property-mapping-delete'), # Invitations path('invitations/', invitations.InvitationListView.as_view(), name='invitations'), path('invitations/create/', diff --git a/passbook/admin/views/property_mapping.py b/passbook/admin/views/property_mapping.py new file mode 100644 index 000000000..c4cf7fad3 --- /dev/null +++ b/passbook/admin/views/property_mapping.py @@ -0,0 +1,90 @@ +"""passbook PropertyMapping administration""" +from django.contrib import messages +from django.contrib.messages.views import SuccessMessageMixin +from django.http import Http404 +from django.urls import reverse_lazy +from django.utils.translation import ugettext as _ +from django.views.generic import CreateView, DeleteView, ListView, UpdateView + +from passbook.admin.mixins import AdminRequiredMixin +from passbook.core.models import PropertyMapping +from passbook.lib.utils.reflection import path_to_class + + +def all_subclasses(cls): + """Recursively return all subclassess of cls""" + return set(cls.__subclasses__()).union( + [s for c in cls.__subclasses__() for s in all_subclasses(c)]) + + +class PropertyMappingListView(AdminRequiredMixin, ListView): + """Show list of all property_mappings""" + + model = PropertyMapping + template_name = 'administration/property_mapping/list.html' + ordering = 'name' + + def get_context_data(self, **kwargs): + kwargs['types'] = { + x.__name__: x._meta.verbose_name for x in all_subclasses(PropertyMapping)} + return super().get_context_data(**kwargs) + + def get_queryset(self): + return super().get_queryset().select_subclasses() + + +class PropertyMappingCreateView(SuccessMessageMixin, AdminRequiredMixin, CreateView): + """Create new PropertyMapping""" + + template_name = 'generic/create.html' + success_url = reverse_lazy('passbook_admin:property-mappings') + success_message = _('Successfully created Property Mapping') + + def get_context_data(self, **kwargs): + kwargs = super().get_context_data(**kwargs) + property_mapping_type = self.request.GET.get('type') + model = next(x for x in all_subclasses(PropertyMapping) + if x.__name__ == property_mapping_type) + kwargs['type'] = model._meta.verbose_name + return kwargs + + def get_form_class(self): + property_mapping_type = self.request.GET.get('type') + model = next(x for x in all_subclasses(PropertyMapping) + if x.__name__ == property_mapping_type) + if not model: + raise Http404 + return path_to_class(model.form) + + +class PropertyMappingUpdateView(SuccessMessageMixin, AdminRequiredMixin, UpdateView): + """Update property_mapping""" + + model = PropertyMapping + template_name = 'generic/update.html' + success_url = reverse_lazy('passbook_admin:property-mappings') + success_message = _('Successfully updated Property Mapping') + + def get_form_class(self): + form_class_path = self.get_object().form + form_class = path_to_class(form_class_path) + return form_class + + def get_object(self, queryset=None): + return PropertyMapping.objects.filter(pk=self.kwargs.get('pk')).select_subclasses().first() + + +class PropertyMappingDeleteView(SuccessMessageMixin, AdminRequiredMixin, DeleteView): + """Delete property_mapping""" + + model = PropertyMapping + template_name = 'generic/delete.html' + success_url = reverse_lazy('passbook_admin:property-mappings') + success_message = _('Successfully deleted Property Mapping') + + def get_object(self, queryset=None): + return PropertyMapping.objects.filter(pk=self.kwargs.get('pk')).select_subclasses().first() + + def delete(self, request, *args, **kwargs): + messages.success(self.request, self.success_message) + return super().delete(request, *args, **kwargs) diff --git a/passbook/core/auth/factors/password.py b/passbook/core/auth/factors/password.py index 136759517..d8956d377 100644 --- a/passbook/core/auth/factors/password.py +++ b/passbook/core/auth/factors/password.py @@ -37,7 +37,7 @@ class PasswordFactor(FormView, AuthenticationFactor): send_email.delay(self.pending_user.email, _('Forgotten password'), 'email/account_password_reset.html', { 'url': self.request.build_absolute_uri( - reverse('passbook_core:passbook_core:auth-password-reset', + reverse('passbook_core:auth-password-reset', kwargs={ 'nonce': nonce.uuid }) diff --git a/passbook/core/migrations/0017_propertymapping.py b/passbook/core/migrations/0017_propertymapping.py new file mode 100644 index 000000000..c53c910c1 --- /dev/null +++ b/passbook/core/migrations/0017_propertymapping.py @@ -0,0 +1,26 @@ +# Generated by Django 2.1.7 on 2019-03-08 10:40 + +import uuid + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('passbook_core', '0016_auto_20190227_1355'), + ] + + operations = [ + migrations.CreateModel( + name='PropertyMapping', + fields=[ + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.TextField()), + ], + options={ + 'verbose_name': 'Property Mapping', + 'verbose_name_plural': 'Property Mappings', + }, + ), + ] diff --git a/passbook/core/migrations/0018_provider_property_mappings.py b/passbook/core/migrations/0018_provider_property_mappings.py new file mode 100644 index 000000000..a845d1c81 --- /dev/null +++ b/passbook/core/migrations/0018_provider_property_mappings.py @@ -0,0 +1,18 @@ +# Generated by Django 2.1.7 on 2019-03-08 10:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('passbook_core', '0017_propertymapping'), + ] + + operations = [ + migrations.AddField( + model_name='provider', + name='property_mappings', + field=models.ManyToManyField(blank=True, default=None, to='passbook_core.PropertyMapping'), + ), + ] diff --git a/passbook/core/models.py b/passbook/core/models.py index 2f6d526d8..7772a6d0f 100644 --- a/passbook/core/models.py +++ b/passbook/core/models.py @@ -60,6 +60,8 @@ class User(AbstractUser): class Provider(models.Model): """Application-independent Provider instance. For example SAML2 Remote, OAuth2 Application""" + property_mappings = models.ManyToManyField('PropertyMapping', default=None, blank=True) + objects = InheritanceManager() # This class defines no field for easier inheritance @@ -412,7 +414,7 @@ class Invitation(UUIDModel): verbose_name_plural = _('Invitations') class Nonce(UUIDModel): - """One-time link for password resets/signup-confirmations""" + """One-time link for password resets/sign-up-confirmations""" expires = models.DateTimeField(default=default_nonce_duration) user = models.ForeignKey('User', on_delete=models.CASCADE) @@ -424,3 +426,19 @@ class Nonce(UUIDModel): verbose_name = _('Nonce') verbose_name_plural = _('Nonces') + +class PropertyMapping(UUIDModel): + """User-defined key -> x mapping which can be used by providers to expose extra data.""" + + name = models.TextField() + + form = '' + objects = InheritanceManager() + + def __str__(self): + return "Property Mapping %s" % self.name + + class Meta: + + verbose_name = _('Property Mapping') + verbose_name_plural = _('Property Mappings') diff --git a/passbook/core/templates/generic/delete.html b/passbook/core/templates/generic/delete.html index 19e570e32..33a51dd0f 100644 --- a/passbook/core/templates/generic/delete.html +++ b/passbook/core/templates/generic/delete.html @@ -6,13 +6,13 @@ {% block content %}
{% block above_form %} -

{% blocktrans with object_type=object|fieldtype|title %}Delete {{ object_type }}{% endblocktrans %}

+

{% blocktrans with object_type=object|verbose_name %}Delete {{ object_type }}{% endblocktrans %}

{% endblock %}
{% csrf_token %}

- {% blocktrans with object_type=object|fieldtype|title name=object %} + {% blocktrans with object_type=object|verbose_name name=object %} Are you sure you want to delete {{ object_type }} "{{ object }}"? {% endblocktrans %}

diff --git a/passbook/saml_idp/base.py b/passbook/saml_idp/base.py index dcf03580f..eb9a921b1 100644 --- a/passbook/saml_idp/base.py +++ b/passbook/saml_idp/base.py @@ -6,7 +6,6 @@ from logging import getLogger from bs4 import BeautifulSoup -from passbook.lib.config import CONFIG from passbook.saml_idp import exceptions, utils, xml_render MINUTES = 60 @@ -52,9 +51,7 @@ class Processor: _session_index = None _subject = None _subject_format = 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent' - _system_params = { - 'ISSUER': CONFIG.y('saml_idp.issuer'), - } + _system_params = {} @property def dotted_path(self): @@ -67,7 +64,7 @@ class Processor: self.name = remote.name self._remote = remote self._logger = getLogger(__name__) - + self._system_params['ISSUER'] = self._remote.issuer self._logger.info('processor configured') def _build_assertion(self): @@ -170,6 +167,16 @@ class Processor: 'Value': self._django_request.user.username, }, ] + from passbook.saml_idp.models import SAMLPropertyMapping + for mapping in self._remote.property_mappings.all().select_subclasses(): + if isinstance(mapping, SAMLPropertyMapping): + mapping_payload = { + 'Name': mapping.saml_name, + 'ValueArray': mapping.values + } + if mapping.friendly_name: + mapping_payload['FriendlyName'] = mapping.friendly_name + 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) @@ -227,7 +234,7 @@ class Processor: self._subject = sp_config self._subject_format = 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent' self._system_params = { - 'ISSUER': CONFIG.y('saml_idp.issuer'), + 'ISSUER': self._remote.issuer } def _validate_request(self): diff --git a/passbook/saml_idp/forms.py b/passbook/saml_idp/forms.py index 99341ee5b..00f34bb98 100644 --- a/passbook/saml_idp/forms.py +++ b/passbook/saml_idp/forms.py @@ -2,7 +2,8 @@ from django import forms -from passbook.saml_idp.models import SAMLProvider, get_provider_choices +from passbook.saml_idp.models import (SAMLPropertyMapping, SAMLProvider, + get_provider_choices) from passbook.saml_idp.utils import CertificateBuilder @@ -21,7 +22,7 @@ class SAMLProviderForm(forms.ModelForm): class Meta: model = SAMLProvider - fields = ['name', 'acs_url', 'processor_path', 'issuer', + fields = ['name', 'property_mappings', 'acs_url', 'processor_path', 'issuer', 'assertion_valid_for', 'signing', 'signing_cert', 'signing_key', ] labels = { 'acs_url': 'ACS URL', @@ -31,3 +32,17 @@ class SAMLProviderForm(forms.ModelForm): 'name': forms.TextInput(), 'issuer': forms.TextInput(), } + + +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(), + } diff --git a/passbook/saml_idp/migrations/0002_samlpropertymapping.py b/passbook/saml_idp/migrations/0002_samlpropertymapping.py new file mode 100644 index 000000000..7fe8de720 --- /dev/null +++ b/passbook/saml_idp/migrations/0002_samlpropertymapping.py @@ -0,0 +1,30 @@ +# Generated by Django 2.1.7 on 2019-03-08 10:40 + +import django.contrib.postgres.fields +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('passbook_core', '0017_propertymapping'), + ('passbook_saml_idp', '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',), + ), + ] diff --git a/passbook/saml_idp/models.py b/passbook/saml_idp/models.py index 42589e574..4d9d2f400 100644 --- a/passbook/saml_idp/models.py +++ b/passbook/saml_idp/models.py @@ -1,10 +1,11 @@ """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 passbook.core.models import Provider +from passbook.core.models import PropertyMapping, Provider from passbook.lib.utils.reflection import class_to_path, path_to_class from passbook.saml_idp.base import Processor @@ -53,6 +54,23 @@ class SAMLProvider(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.saml_idp.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__()] diff --git a/passbook/saml_idp/processors/aws.py b/passbook/saml_idp/processors/aws.py index 2b3c05eca..44953b1fb 100644 --- a/passbook/saml_idp/processors/aws.py +++ b/passbook/saml_idp/processors/aws.py @@ -11,16 +11,12 @@ class AWSProcessor(Processor): def _format_assertion(self): """Formats _assertion_params as _assertion_xml.""" - self._assertion_params['ATTRIBUTES'] = [ + super()._format_assertion() + self._assertion_params['ATTRIBUTES'].append( { 'Name': 'https://aws.amazon.com/SAML/Attributes/RoleSessionName', 'Value': self._django_request.user.username, - }, - { - 'Name': 'https://aws.amazon.com/SAML/Attributes/Role', - # 'Value': 'arn:aws:iam::471432361072:saml-provider/passbook_dev, - # arn:aws:iam::471432361072:role/saml_role' } - ] + ) self._assertion_xml = xml_render.get_assertion_xml( 'saml/xml/assertions/generic.xml', self._assertion_params, signed=True) diff --git a/passbook/saml_idp/templates/saml/idp/login.html b/passbook/saml_idp/templates/saml/idp/login.html index a357a7b39..d8b929931 100644 --- a/passbook/saml_idp/templates/saml/idp/login.html +++ b/passbook/saml_idp/templates/saml/idp/login.html @@ -9,40 +9,26 @@ {% block card %} -> - {% csrf_token %} - - - - - -
{% endblock %} + +{% block scripts %} +{{ block.super }} +{{ form.media.js }} +{% endblock %} diff --git a/passbook/core/forms/factors.py b/passbook/core/forms/factors.py index 30a587546..11d0061c6 100644 --- a/passbook/core/forms/factors.py +++ b/passbook/core/forms/factors.py @@ -2,6 +2,7 @@ from django import forms from passbook.core.models import DummyFactor, PasswordFactor +from passbook.lib.fields import DynamicArrayField GENERAL_FIELDS = ['name', 'slug', 'order', 'policies', 'enabled'] @@ -16,6 +17,9 @@ class PasswordFactorForm(forms.ModelForm): 'name': forms.TextInput(), 'order': forms.NumberInput(), } + field_classes = { + 'backends': DynamicArrayField + } class DummyFactorForm(forms.ModelForm): """Form to create/edit Dummy Factor""" diff --git a/passbook/core/static/css/passbook.css b/passbook/core/static/css/passbook.css new file mode 100644 index 000000000..86711d8b1 --- /dev/null +++ b/passbook/core/static/css/passbook.css @@ -0,0 +1,23 @@ +.dynamic-array-widget .array-item { + display: flex; + align-items: center; + margin-bottom: 15px; +} + +.dynamic-array-widget .remove_sign { + width: 10px; + height: 2px; + background: #a41515; + border-radius: 1px; +} + +.dynamic-array-widget .remove { + height: 15px; + display: flex; + align-items: center; + margin-left: 5px; +} + +.dynamic-array-widget .remove:hover { + cursor: pointer; +} diff --git a/passbook/core/static/js/passbook.js b/passbook/core/static/js/passbook.js index a52cfe21e..cb89bbd27 100644 --- a/passbook/core/static/js/passbook.js +++ b/passbook/core/static/js/passbook.js @@ -16,3 +16,33 @@ const typeHandler = function (e) { $source.on('input', typeHandler) // register for oninput $source.on('propertychange', typeHandler) // for IE8 + +window.addEventListener('load', function () { + + function addRemoveEventListener(widgetElement) { + widgetElement.querySelectorAll('.array-remove').forEach(function (element) { + element.addEventListener('click', function () { + this.parentNode.parentNode.remove(); + }); + }); + } + + document.querySelectorAll('.dynamic-array-widget').forEach(function (widgetElement) { + + addRemoveEventListener(widgetElement); + + widgetElement.querySelector('.add-array-item').addEventListener('click', function () { + var first = widgetElement.querySelector('.array-item'); + var newElement = first.cloneNode(true); + var id_parts = newElement.querySelector('input').getAttribute('id').split('_'); + var id = id_parts.slice(0, -1).join('_') + '_' + String(parseInt(id_parts.slice(-1)[0]) + 1); + newElement.querySelector('input').setAttribute('id', id); + newElement.querySelector('input').value = ''; + + addRemoveEventListener(newElement); + first.parentElement.insertBefore(newElement, first.parentNode.lastChild); + }); + + }); + +}); diff --git a/passbook/core/templates/base/skeleton.html b/passbook/core/templates/base/skeleton.html index 69a60d503..dad228996 100644 --- a/passbook/core/templates/base/skeleton.html +++ b/passbook/core/templates/base/skeleton.html @@ -16,6 +16,7 @@ +