Merge branch '22-custom-property-mapping' into 'master'

Resolve "Custom Property Mapping"

Closes #22

See merge request BeryJu.org/passbook!8
This commit is contained in:
Jens Langhammer 2019-03-08 15:03:08 +00:00
commit 43a389e596
24 changed files with 541 additions and 86 deletions

View file

@ -5,35 +5,45 @@
{% block nav_secondary %} {% block nav_secondary %}
<ul class="nav navbar-nav navbar-persistent"> <ul class="nav navbar-nav navbar-persistent">
<li class="{% is_active 'passbook_admin:overview' %}"> <li class="{% is_active 'passbook_admin:overview' %}">
<a href="{% url 'passbook_admin:overview' %}">{% trans 'Overview' %}</a> <a href="{% url 'passbook_admin:overview' %}">{% trans 'Overview' %}</a>
</li> </li>
<li class="{% is_active 'passbook_admin:applications' 'passbook_admin:application-create' 'passbook_admin:application-update' 'passbook_admin:application-delete' %}"> <li
<a href="{% url 'passbook_admin:applications' %}">{% trans 'Applications' %}</a> class="{% is_active 'passbook_admin:applications' 'passbook_admin:application-create' 'passbook_admin:application-update' 'passbook_admin:application-delete' %}">
</li> <a href="{% url 'passbook_admin:applications' %}">{% trans 'Applications' %}</a>
<li class="{% is_active 'passbook_admin:sources' 'passbook_admin:source-create' 'passbook_admin:source-update' 'passbook_admin:source-delete' %}"> </li>
<a href="{% url 'passbook_admin:sources' %}">{% trans 'Sources' %}</a> <li
</li> class="{% is_active 'passbook_admin:sources' 'passbook_admin:source-create' 'passbook_admin:source-update' 'passbook_admin:source-delete' %}">
<li class="{% is_active 'passbook_admin:providers' 'passbook_admin:provider-create' 'passbook_admin:provider-update' 'passbook_admin:provider-delete' %}"> <a href="{% url 'passbook_admin:sources' %}">{% trans 'Sources' %}</a>
<a href="{% url 'passbook_admin:providers' %}">{% trans 'Providers' %}</a> </li>
</li> <li
<li class="{% is_active 'passbook_admin:factors' 'passbook_admin:factor-create' 'passbook_admin:factor-update' 'passbook_admin:factor-delete' %}"> class="{% is_active 'passbook_admin:providers' 'passbook_admin:provider-create' 'passbook_admin:provider-update' 'passbook_admin:provider-delete' %}">
<a href="{% url 'passbook_admin:factors' %}">{% trans 'Factors' %}</a> <a href="{% url 'passbook_admin:providers' %}">{% trans 'Providers' %}</a>
</li> </li>
<li class="{% is_active 'passbook_admin:policies' 'passbook_admin:policy-create' 'passbook_admin:policy-update' 'passbook_admin:policy-delete' 'passbook_admin:policy-test' %}"> <li
<a href="{% url 'passbook_admin:policies' %}">{% trans 'Policies' %}</a> class="{% is_active 'passbook_admin:property-mappings' 'passbook_admin:property-mapping-create' 'passbook_admin:property-mapping-update' 'passbook_admin:property-mapping-delete' %}">
</li> <a href="{% url 'passbook_admin:property-mappings' %}">{% trans 'Property Mappings' %}</a>
<li class="{% is_active 'passbook_admin:invitations' 'passbook_admin:invitation-create' 'passbook_admin:invitation-update' 'passbook_admin:invitation-delete' 'passbook_admin:invitation-test' %}"> </li>
<a href="{% url 'passbook_admin:invitations' %}">{% trans 'Invitations' %}</a> <li
</li> class="{% is_active 'passbook_admin:factors' 'passbook_admin:factor-create' 'passbook_admin:factor-update' 'passbook_admin:factor-delete' %}">
<li class="{% is_active 'passbook_admin:users' 'passbook_admin:user-update' 'passbook_admin:user-delete' %}"> <a href="{% url 'passbook_admin:factors' %}">{% trans 'Factors' %}</a>
<a href="{% url 'passbook_admin:users' %}">{% trans 'Users' %}</a> </li>
</li> <li
<li class="{% is_active 'passbook_admin:audit-log' %}"> class="{% is_active 'passbook_admin:policies' 'passbook_admin:policy-create' 'passbook_admin:policy-update' 'passbook_admin:policy-delete' 'passbook_admin:policy-test' %}">
<a href="{% url 'passbook_admin:audit-log' %}">{% trans 'Audit Log' %}</a> <a href="{% url 'passbook_admin:policies' %}">{% trans 'Policies' %}</a>
</li> </li>
<li class="{% is_active_app 'admin' %}"> <li
<a href="{% url 'admin:index' %}">{% trans 'Django' %}</a> class="{% is_active 'passbook_admin:invitations' 'passbook_admin:invitation-create' 'passbook_admin:invitation-update' 'passbook_admin:invitation-delete' 'passbook_admin:invitation-test' %}">
</li> <a href="{% url 'passbook_admin:invitations' %}">{% trans 'Invitations' %}</a>
</li>
<li class="{% is_active 'passbook_admin:users' 'passbook_admin:user-update' 'passbook_admin:user-delete' %}">
<a href="{% url 'passbook_admin:users' %}">{% trans 'Users' %}</a>
</li>
<li class="{% is_active 'passbook_admin:audit-log' %}">
<a href="{% url 'passbook_admin:audit-log' %}">{% trans 'Audit Log' %}</a>
</li>
<li class="{% is_active_app 'admin' %}">
<a href="{% url 'admin:index' %}">{% trans 'Django' %}</a>
</li>
</ul> </ul>
{% endblock %} {% endblock %}

View file

@ -0,0 +1,52 @@
{% extends "administration/base.html" %}
{% load i18n %}
{% load utils %}
{% block title %}
{% title %}
{% endblock %}
{% block content %}
<div class="container">
<h1><span class="fa fa-table"></span> {% trans "Property Mappings" %}</h1>
<span>{% trans "Property Mappings allow you expose provider-specific attributes." %}</span>
<hr>
<div class="dropdown">
<button class="btn btn-primary dropdown-toggle" type="button" id="createDropdown" data-toggle="dropdown">
{% trans 'Create...' %}
<span class="caret"></span>
</button>
<ul class="dropdown-menu" role="menu" aria-labelledby="createDropdown">
{% for type, name in types.items %}
<li role="presentation"><a role="menuitem" tabindex="-1"
href="{% url 'passbook_admin:property-mapping-create' %}?type={{ type }}&back={{ request.get_full_path }}">{{ name }}</a></li>
{% endfor %}
</ul>
</div>
<hr>
<table class="table table-striped table-bordered">
<thead>
<tr>
<th>{% trans 'Name' %}</th>
<th>{% trans 'Type' %}</th>
<th></th>
</tr>
</thead>
<tbody>
{% for property_mapping in object_list %}
<tr>
<td>{{ property_mapping.name }} ({{ property_mapping.slug }})</td>
<td>{{ property_mapping|verbose_name }}</td>
<td>
<a class="btn btn-default btn-sm"
href="{% url 'passbook_admin:property-mapping-update' pk=property_mapping.pk %}?back={{ request.get_full_path }}">{% trans 'Edit' %}</a>
<a class="btn btn-default btn-sm"
href="{% url 'passbook_admin:property-mapping-delete' pk=property_mapping.pk %}?back={{ request.get_full_path }}">{% trans 'Delete' %}</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View file

@ -3,6 +3,11 @@
{% load i18n %} {% load i18n %}
{% load utils %} {% load utils %}
{% block head %}
{{ block.super }}
{{ form.media.css }}
{% endblock %}
{% block content %} {% block content %}
<div class="container"> <div class="container">
{% block above_form %} {% block above_form %}
@ -16,3 +21,8 @@
</div> </div>
</div> </div>
{% endblock %} {% endblock %}
{% block scripts %}
{{ block.super }}
{{ form.media.js }}
{% endblock %}

View file

@ -2,8 +2,8 @@
from django.urls import include, path from django.urls import include, path
from passbook.admin.views import (applications, audit, factors, groups, from passbook.admin.views import (applications, audit, factors, groups,
invitations, overview, policy, providers, invitations, overview, policy,
sources, users) property_mapping, providers, sources, users)
urlpatterns = [ urlpatterns = [
path('', overview.AdministrationOverviewView.as_view(), name='overview'), path('', overview.AdministrationOverviewView.as_view(), name='overview'),
@ -43,6 +43,15 @@ urlpatterns = [
factors.FactorUpdateView.as_view(), name='factor-update'), factors.FactorUpdateView.as_view(), name='factor-update'),
path('factors/<uuid:pk>/delete/', path('factors/<uuid:pk>/delete/',
factors.FactorDeleteView.as_view(), name='factor-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/<uuid:pk>/update/',
property_mapping.PropertyMappingUpdateView.as_view(), name='property-mapping-update'),
path('property-mappings/<uuid:pk>/delete/',
property_mapping.PropertyMappingDeleteView.as_view(), name='property-mapping-delete'),
# Invitations # Invitations
path('invitations/', invitations.InvitationListView.as_view(), name='invitations'), path('invitations/', invitations.InvitationListView.as_view(), name='invitations'),
path('invitations/create/', path('invitations/create/',

View file

@ -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)

View file

@ -37,7 +37,7 @@ class PasswordFactor(FormView, AuthenticationFactor):
send_email.delay(self.pending_user.email, _('Forgotten password'), send_email.delay(self.pending_user.email, _('Forgotten password'),
'email/account_password_reset.html', { 'email/account_password_reset.html', {
'url': self.request.build_absolute_uri( 'url': self.request.build_absolute_uri(
reverse('passbook_core:passbook_core:auth-password-reset', reverse('passbook_core:auth-password-reset',
kwargs={ kwargs={
'nonce': nonce.uuid 'nonce': nonce.uuid
}) })

View file

@ -2,6 +2,7 @@
from django import forms from django import forms
from passbook.core.models import DummyFactor, PasswordFactor from passbook.core.models import DummyFactor, PasswordFactor
from passbook.lib.fields import DynamicArrayField
GENERAL_FIELDS = ['name', 'slug', 'order', 'policies', 'enabled'] GENERAL_FIELDS = ['name', 'slug', 'order', 'policies', 'enabled']
@ -16,6 +17,9 @@ class PasswordFactorForm(forms.ModelForm):
'name': forms.TextInput(), 'name': forms.TextInput(),
'order': forms.NumberInput(), 'order': forms.NumberInput(),
} }
field_classes = {
'backends': DynamicArrayField
}
class DummyFactorForm(forms.ModelForm): class DummyFactorForm(forms.ModelForm):
"""Form to create/edit Dummy Factor""" """Form to create/edit Dummy Factor"""

View file

@ -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',
},
),
]

View file

@ -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'),
),
]

View file

@ -60,6 +60,8 @@ class User(AbstractUser):
class Provider(models.Model): class Provider(models.Model):
"""Application-independent Provider instance. For example SAML2 Remote, OAuth2 Application""" """Application-independent Provider instance. For example SAML2 Remote, OAuth2 Application"""
property_mappings = models.ManyToManyField('PropertyMapping', default=None, blank=True)
objects = InheritanceManager() objects = InheritanceManager()
# This class defines no field for easier inheritance # This class defines no field for easier inheritance
@ -412,7 +414,7 @@ class Invitation(UUIDModel):
verbose_name_plural = _('Invitations') verbose_name_plural = _('Invitations')
class Nonce(UUIDModel): 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) expires = models.DateTimeField(default=default_nonce_duration)
user = models.ForeignKey('User', on_delete=models.CASCADE) user = models.ForeignKey('User', on_delete=models.CASCADE)
@ -424,3 +426,19 @@ class Nonce(UUIDModel):
verbose_name = _('Nonce') verbose_name = _('Nonce')
verbose_name_plural = _('Nonces') 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')

View file

@ -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;
}

View file

@ -16,3 +16,33 @@ const typeHandler = function (e) {
$source.on('input', typeHandler) // register for oninput $source.on('input', typeHandler) // register for oninput
$source.on('propertychange', typeHandler) // for IE8 $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);
});
});
});

View file

@ -16,6 +16,7 @@
<link rel="shortcut icon" type="image/png" href="{% static 'img/logo.png' %}"> <link rel="shortcut icon" type="image/png" href="{% static 'img/logo.png' %}">
<link rel="stylesheet" type="text/css" href="{% static 'css/patternfly.min.css' %}"> <link rel="stylesheet" type="text/css" href="{% static 'css/patternfly.min.css' %}">
<link rel="stylesheet" type="text/css" href="{% static 'css/patternfly-additions.min.css' %}"> <link rel="stylesheet" type="text/css" href="{% static 'css/patternfly-additions.min.css' %}">
<link rel="stylesheet" type="text/css" href="{% static 'css/passbook.css' %}">
<style> <style>
.login-pf { .login-pf {
background-attachment: fixed; background-attachment: fixed;

View file

@ -6,13 +6,13 @@
{% block content %} {% block content %}
<div class="container"> <div class="container">
{% block above_form %} {% block above_form %}
<h1>{% blocktrans with object_type=object|fieldtype|title %}Delete {{ object_type }}{% endblocktrans %}</h1> <h1>{% blocktrans with object_type=object|verbose_name %}Delete {{ object_type }}{% endblocktrans %}</h1>
{% endblock %} {% endblock %}
<div class=""> <div class="">
<form method="post" class="form-horizontal"> <form method="post" class="form-horizontal">
{% csrf_token %} {% csrf_token %}
<p> <p>
{% 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 }}"? Are you sure you want to delete {{ object_type }} "{{ object }}"?
{% endblocktrans %} {% endblocktrans %}
</p> </p>

View file

@ -0,0 +1,44 @@
"""passbook lib fields"""
from itertools import chain
from django import forms
from django.contrib.postgres.utils import prefix_validation_error
from passbook.lib.widgets import DynamicArrayWidget
class DynamicArrayField(forms.Field):
"""Show array field as a dynamic amount of textboxes"""
default_error_messages = {"item_invalid": "Item %(nth)s in the array did not validate: "}
def __init__(self, base_field, **kwargs):
self.base_field = base_field
self.max_length = kwargs.pop("max_length", None)
kwargs.setdefault("widget", DynamicArrayWidget)
super().__init__(**kwargs)
def clean(self, value):
cleaned_data = []
errors = []
value = [x for x in value if x]
for index, item in enumerate(value):
try:
cleaned_data.append(self.base_field.clean(item))
except forms.ValidationError as error:
errors.append(
prefix_validation_error(
error, self.error_messages["item_invalid"],
code="item_invalid", params={"nth": index}
)
)
if errors:
raise forms.ValidationError(list(chain.from_iterable(errors)))
if not cleaned_data and self.required:
raise forms.ValidationError(self.error_messages["required"])
return cleaned_data
def has_changed(self, initial, data):
if not data and not initial:
return False
return super().has_changed(initial, data)

View file

@ -0,0 +1,17 @@
{% load utils %}
{% spaceless %}
<div class="dynamic-array-widget">
{% for widget in widget.subwidgets %}
<div class="array-item input-group">
{% include widget.template_name %}
<div class="input-group-btn">
<button class="array-remove btn btn-danger" type="button">
<span class="pficon-delete"></span>
</button>
</div>
</div>
{% endfor %}
<div><button type="button" class="add-array-item btn btn-default">Add another</button></div>
</div>
{% endspaceless %}

36
passbook/lib/widgets.py Normal file
View file

@ -0,0 +1,36 @@
"""Dynamic array widget"""
from django import forms
class DynamicArrayWidget(forms.TextInput):
"""Dynamic array widget"""
template_name = "lib/arrayfield.html"
def get_context(self, name, value, attrs):
value = value or [""]
context = super().get_context(name, value, attrs)
final_attrs = context["widget"]["attrs"]
id_ = context["widget"]["attrs"].get("id")
subwidgets = []
for index, item in enumerate(context["widget"]["value"]):
widget_attrs = final_attrs.copy()
if id_:
widget_attrs["id"] = "{id_}_{index}".format(id_=id_, index=index)
widget = forms.TextInput()
widget.is_required = self.is_required
subwidgets.append(widget.get_context(name, item, widget_attrs)["widget"])
context["widget"]["subwidgets"] = subwidgets
return context
def value_from_datadict(self, data, files, name):
try:
getter = data.getlist
return [value for value in getter(name) if value]
except AttributeError:
return data.get(name)
def format_value(self, value):
return value or []

View file

@ -6,7 +6,6 @@ from logging import getLogger
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from passbook.lib.config import CONFIG
from passbook.saml_idp import exceptions, utils, xml_render from passbook.saml_idp import exceptions, utils, xml_render
MINUTES = 60 MINUTES = 60
@ -52,9 +51,7 @@ class Processor:
_session_index = None _session_index = None
_subject = None _subject = None
_subject_format = 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent' _subject_format = 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent'
_system_params = { _system_params = {}
'ISSUER': CONFIG.y('saml_idp.issuer'),
}
@property @property
def dotted_path(self): def dotted_path(self):
@ -67,7 +64,7 @@ class Processor:
self.name = remote.name self.name = remote.name
self._remote = remote self._remote = remote
self._logger = getLogger(__name__) self._logger = getLogger(__name__)
self._system_params['ISSUER'] = self._remote.issuer
self._logger.info('processor configured') self._logger.info('processor configured')
def _build_assertion(self): def _build_assertion(self):
@ -170,6 +167,20 @@ class Processor:
'Value': self._django_request.user.username, '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': [],
'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( self._assertion_xml = xml_render.get_assertion_xml(
'saml/xml/assertions/generic.xml', self._assertion_params, signed=True) 'saml/xml/assertions/generic.xml', self._assertion_params, signed=True)
@ -227,7 +238,7 @@ class Processor:
self._subject = sp_config self._subject = sp_config
self._subject_format = 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent' self._subject_format = 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent'
self._system_params = { self._system_params = {
'ISSUER': CONFIG.y('saml_idp.issuer'), 'ISSUER': self._remote.issuer
} }
def _validate_request(self): def _validate_request(self):

View file

@ -2,7 +2,9 @@
from django import forms from django import forms
from passbook.saml_idp.models import SAMLProvider, get_provider_choices from passbook.lib.fields import DynamicArrayField
from passbook.saml_idp.models import (SAMLPropertyMapping, SAMLProvider,
get_provider_choices)
from passbook.saml_idp.utils import CertificateBuilder from passbook.saml_idp.utils import CertificateBuilder
@ -21,7 +23,7 @@ class SAMLProviderForm(forms.ModelForm):
class Meta: class Meta:
model = SAMLProvider 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', ] 'assertion_valid_for', 'signing', 'signing_cert', 'signing_key', ]
labels = { labels = {
'acs_url': 'ACS URL', 'acs_url': 'ACS URL',
@ -31,3 +33,20 @@ class SAMLProviderForm(forms.ModelForm):
'name': forms.TextInput(), 'name': forms.TextInput(),
'issuer': 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(),
}
field_classes = {
'values': DynamicArrayField
}

View file

@ -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',),
),
]

View file

@ -1,10 +1,11 @@
"""passbook saml_idp Models""" """passbook saml_idp Models"""
from django.contrib.postgres.fields import ArrayField
from django.db import models from django.db import models
from django.shortcuts import reverse from django.shortcuts import reverse
from django.utils.translation import gettext as _ 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.lib.utils.reflection import class_to_path, path_to_class
from passbook.saml_idp.base import Processor from passbook.saml_idp.base import Processor
@ -53,6 +54,23 @@ class SAMLProvider(Provider):
verbose_name_plural = _('SAML Providers') 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(): def get_provider_choices():
"""Return tuple of class_path, class name of all providers.""" """Return tuple of class_path, class name of all providers."""
return [(class_to_path(x), x.__name__) for x in Processor.__subclasses__()] return [(class_to_path(x), x.__name__) for x in Processor.__subclasses__()]

View file

@ -11,16 +11,12 @@ class AWSProcessor(Processor):
def _format_assertion(self): def _format_assertion(self):
"""Formats _assertion_params as _assertion_xml.""" """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', 'Name': 'https://aws.amazon.com/SAML/Attributes/RoleSessionName',
'Value': self._django_request.user.username, '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( self._assertion_xml = xml_render.get_assertion_xml(
'saml/xml/assertions/generic.xml', self._assertion_params, signed=True) 'saml/xml/assertions/generic.xml', self._assertion_params, signed=True)

View file

@ -9,40 +9,26 @@
{% block card %} {% block card %}
<header class="login-pf-header"> <header class="login-pf-header">
<h1>{% trans 'Authorize Application' %}</h1> <h1>{% trans 'Authorize Application' %}</h1>
</header> </header>
<form method="POST" action="{{ acs_url }}">> <form method="POST" action="{{ acs_url }}">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="ACSUrl" value="{{ acs_url }}"> <input type="hidden" name="ACSUrl" value="{{ acs_url }}">
<input type="hidden" name="RelayState" value="{{ relay_state }}" /> <input type="hidden" name="RelayState" value="{{ relay_state }}" />
<input type="hidden" name="SAMLResponse" value="{{ saml_response }}" /> <input type="hidden" name="SAMLResponse" value="{{ saml_response }}" />
<label class="title"> <div class="login-group">
<clr-icon shape="passbook" class="is-info" size="48"></clr-icon> <h3>
{% config 'passbook.branding' %} {% blocktrans with remote=remote.name %}
</label> You're about to sign into {{ remote }}
<label class="subtitle"> {% endblocktrans %}
{% trans 'SSO - Authorize External Source' %} </h3>
</label> <p>
<div class="login-group"> {% blocktrans with user=user %}
<p class="subtitle"> You are logged in as {{ user }}.
{% blocktrans with remote=remote.name %} {% endblocktrans %}
You're about to sign into {{ remote }} <a href="{% url 'passbook_core:auth-logout' %}">{% trans 'Not you?' %}</a>
{% endblocktrans %} </p>
</p> <input class="btn btn-primary btn-block btn-lg" type="submit" value="{% trans 'Continue' %}" />
<p>
{% blocktrans with user=user %}
You are logged in as {{ user }}. Not you?
{% endblocktrans %}
<a href="{% url 'passbook_core:auth-logout' %}">{% trans 'Logout' %}</a>
</p>
<div class="row">
<div class="col-md-6">
<input class="btn btn-success btn-block" type="submit" value="{% trans "Continue" %}" />
</div>
<div class="col-md-6">
<a href="{% url 'passbook_core:overview' %}" class="btn btn-outline btn-block">{% trans "Cancel" %}</a>
</div>
</div> </div>
</div>
</form> </form>
{% endblock %} {% endblock %}

View file

@ -1,7 +1,14 @@
<saml:AttributeStatement> <saml:AttributeStatement>
{% for attr in attributes %} {% for attr in attributes %}
<saml:Attribute {% if attr.FriendlyName %}FriendlyName="{{ attr.FriendlyName }}" {% endif %}Name="{{ attr.Name }}"> <saml:Attribute {% if attr.FriendlyName %}FriendlyName="{{ attr.FriendlyName }}" {% endif %}Name="{{ attr.Name }}">
<saml:AttributeValue>{{ attr.Value }}</saml:AttributeValue> {% 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> </saml:Attribute>
{% endfor %} {% endfor %}
</saml:AttributeStatement> </saml:AttributeStatement>