diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 5d06175a4..29c810a10 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.1.4-beta +current_version = 0.1.10-beta tag = True commit = True parse = (?P\d+)\.(?P\d+)\.(?P\d+)\-(?P.*) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index bae12ea9b..01729e100 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -8,6 +8,7 @@ stages: - test - build - docs + - deploy image: python:3.6 services: - postgres:latest @@ -53,7 +54,7 @@ package-docker: before_script: - echo "{\"auths\":{\"docker.$NEXUS_URL\":{\"auth\":\"$NEXUS_AUTH\"}}}" > /kaniko/.docker/config.json script: - - /kaniko/executor --context $CI_PROJECT_DIR --dockerfile $CI_PROJECT_DIR/Dockerfile --destination docker.pkg.beryju.org/passbook:latest --destination docker.pkg.beryju.org/passbook:0.1.4-beta + - /kaniko/executor --context $CI_PROJECT_DIR --dockerfile $CI_PROJECT_DIR/Dockerfile --destination docker.pkg.beryju.org/passbook:latest --destination docker.pkg.beryju.org/passbook:0.1.10-beta stage: build only: - tags @@ -78,6 +79,7 @@ package-debian: - virtualenv env - source env/bin/activate - pip3 install -U -r requirements.txt -r requirements-dev.txt + - ./manage.py collectstatic --no-input image: ubuntu:18.04 script: - debuild -us -uc @@ -112,3 +114,18 @@ package-debian: # - mkdocs build # - 'rsync -avh --delete web/* "beryjuorg@ory1-web-prod-1.ory1.beryju.org:passbook.beryju.org/"' # - 'rsync -avh --delete site/* "beryjuorg@ory1-web-prod-1.ory1.beryju.org:passbook.beryju.org/docs/"' + +deploy: + environment: + name: production + url: https://passbook-prod.default.k8s.beryju.org/ + stage: deploy + only: + - tags + - /^version/.*$/ + script: + - curl https://raw.githubusercontent.com/helm/helm/master/scripts/get | bash + - helm init --client-only + - helm repo add beryju.org https://pkg.beryju.org/repository/helm/ + - helm repo update + - helm upgrade passbook-prod beryju.org/passbook --devel diff --git a/debian/changelog b/debian/changelog index fe430b972..679515b61 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,37 @@ +passbook (0.1.10) stable; urgency=high + + * bump version: 0.1.7-beta -> 0.1.8-beta + * consistently using PolicyEngine + * add more Verbosity to PolicyEngine, rewrite SAML Authorisation check + * slightly refactor Factor View, add more unittests + * add impersonation middleware, add to templates + * bump version: 0.1.8-beta -> 0.1.9-beta + * fix k8s service routing http traffic to workers + * Fix button on policy test page + * better show loading state when testing a policy + + -- Jens Langhammer Sun, 10 Mar 2019 14:52:40 +0000 + +passbook (0.1.7) stable; urgency=medium + + * bump version: 0.1.3-beta -> 0.1.4-beta + * implicitly add kubernetes-healthcheck-host in helm configmap + * fix debian build (again) + * add PropertyMapping Model, add Subclass for SAML, test with AWS + * add custom DynamicArrayField to better handle arrays + * format data before inserting it + * bump version: 0.1.4-beta -> 0.1.5-beta + * fix static files missing for debian package + * fix password not getting set on user import + * remove audit's login attempt + * add passing property to PolicyEngine + * fix captcha factor not loading keys from Factor class + * bump version: 0.1.5-beta -> 0.1.6-beta + * fix MATCH_EXACT not working as intended + * Improve access control for saml + + -- Jens Langhammer Fri, 08 Mar 2019 20:37:05 +0000 + passbook (0.1.4) stable; urgency=medium * initial debian package release diff --git a/helm/passbook/Chart.yaml b/helm/passbook/Chart.yaml index 3dbead71d..5b133024d 100644 --- a/helm/passbook/Chart.yaml +++ b/helm/passbook/Chart.yaml @@ -1,6 +1,6 @@ apiVersion: v1 -appVersion: "0.1.4-beta" +appVersion: "0.1.10-beta" description: A Helm chart for passbook. name: passbook -version: "0.1.4-beta" +version: "0.1.10-beta" icon: https://passbook.beryju.org/images/logo.png diff --git a/helm/passbook/templates/passbook-web-deployment.yaml b/helm/passbook/templates/passbook-web-deployment.yaml index 78830221d..2308300ca 100644 --- a/helm/passbook/templates/passbook-web-deployment.yaml +++ b/helm/passbook/templates/passbook-web-deployment.yaml @@ -18,6 +18,7 @@ spec: labels: app.kubernetes.io/name: {{ include "passbook.name" . }} app.kubernetes.io/instance: {{ .Release.Name }} + passbook.io/component: web spec: volumes: - name: config-volume diff --git a/helm/passbook/templates/passbook-web-service.yaml b/helm/passbook/templates/passbook-web-service.yaml index 1cbb65de6..9406e6c67 100644 --- a/helm/passbook/templates/passbook-web-service.yaml +++ b/helm/passbook/templates/passbook-web-service.yaml @@ -17,3 +17,4 @@ spec: selector: app.kubernetes.io/name: {{ include "passbook.name" . }} app.kubernetes.io/instance: {{ .Release.Name }} + passbook.io/component: web diff --git a/helm/passbook/templates/passbook-worker-deployment.yaml b/helm/passbook/templates/passbook-worker-deployment.yaml index f4b6c8513..79cd11002 100644 --- a/helm/passbook/templates/passbook-worker-deployment.yaml +++ b/helm/passbook/templates/passbook-worker-deployment.yaml @@ -18,6 +18,7 @@ spec: labels: app.kubernetes.io/name: {{ include "passbook.name" . }} app.kubernetes.io/instance: {{ .Release.Name }} + passbook.io/component: worker spec: volumes: - name: config-volume diff --git a/helm/passbook/values.yaml b/helm/passbook/values.yaml index 273f77488..cf75920f8 100644 --- a/helm/passbook/values.yaml +++ b/helm/passbook/values.yaml @@ -5,7 +5,7 @@ replicaCount: 1 image: - tag: 0.1.4-beta + tag: 0.1.10-beta nameOverride: "" diff --git a/passbook/__init__.py b/passbook/__init__.py index 56b2dca4e..700e02bb3 100644 --- a/passbook/__init__.py +++ b/passbook/__init__.py @@ -1,2 +1,2 @@ """passbook""" -__version__ = '0.1.4-beta' +__version__ = '0.1.10-beta' diff --git a/passbook/admin/__init__.py b/passbook/admin/__init__.py index d812363c3..f772a6422 100644 --- a/passbook/admin/__init__.py +++ b/passbook/admin/__init__.py @@ -1,2 +1,2 @@ """passbook admin""" -__version__ = '0.1.4-beta' +__version__ = '0.1.10-beta' diff --git a/passbook/admin/middleware.py b/passbook/admin/middleware.py new file mode 100644 index 000000000..1c4d1c42a --- /dev/null +++ b/passbook/admin/middleware.py @@ -0,0 +1,25 @@ +"""passbook admin Middleware to impersonate users""" + +from passbook.core.models import User + + +def impersonate(get_response): + """Middleware to impersonate users""" + + def middleware(request): + """Middleware to impersonate users""" + + # User is superuser and has __impersonate ID set + if request.user.is_superuser and "__impersonate" in request.GET: + request.session['impersonate_id'] = request.GET["__impersonate"] + # user wants to stop impersonation + elif "__unimpersonate" in request.GET and 'impersonate_id' in request.session: + del request.session['impersonate_id'] + + # Actually impersonate user + if request.user.is_superuser and 'impersonate_id' in request.session: + request.user = User.objects.get(pk=request.session['impersonate_id']) + + response = get_response(request) + return response + return middleware diff --git a/passbook/admin/settings.py b/passbook/admin/settings.py new file mode 100644 index 000000000..14f5e9421 --- /dev/null +++ b/passbook/admin/settings.py @@ -0,0 +1,5 @@ +"""passbook admin settings""" + +MIDDLEWARE = [ + 'passbook.admin.middleware.impersonate', +] diff --git a/passbook/admin/templates/administration/base.html b/passbook/admin/templates/administration/base.html index d33970b78..46a0aabcb 100644 --- a/passbook/admin/templates/administration/base.html +++ b/passbook/admin/templates/administration/base.html @@ -20,6 +20,10 @@ class="{% is_active 'passbook_admin:providers' 'passbook_admin:provider-create' 'passbook_admin:provider-update' 'passbook_admin:provider-delete' %}"> {% trans 'Providers' %} +
  • + {% trans 'Property Mappings' %} +
  • {% trans 'Factors' %} diff --git a/passbook/admin/templates/administration/policy/test.html b/passbook/admin/templates/administration/policy/test.html index 6a5ddcbb1..932e19705 100644 --- a/passbook/admin/templates/administration/policy/test.html +++ b/passbook/admin/templates/administration/policy/test.html @@ -5,3 +5,22 @@ {% block above_form %}

    {% blocktrans with policy=policy %}Test policy {{ policy }}{% endblocktrans %}

    {% endblock %} + +{% block action %} +{% trans 'Test' %} +{% endblock %} + +{% block beneath_form %} + +{% endblock %} + +{% block scripts %} +{{ block.super }} + +{% 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/templates/administration/user/list.html b/passbook/admin/templates/administration/user/list.html index fe641038c..726936b1e 100644 --- a/passbook/admin/templates/administration/user/list.html +++ b/passbook/admin/templates/administration/user/list.html @@ -31,6 +31,8 @@ href="{% url 'passbook_admin:user-delete' pk=user.pk %}?back={{ request.get_full_path }}">{% trans 'Delete' %} {% trans 'Reset Password' %} + {% trans 'Impersonate' %} {% endfor %} diff --git a/passbook/admin/templates/generic/form.html b/passbook/admin/templates/generic/form.html index 40a2b7568..88bbec856 100644 --- a/passbook/admin/templates/generic/form.html +++ b/passbook/admin/templates/generic/form.html @@ -3,6 +3,11 @@ {% load i18n %} {% load utils %} +{% block head %} +{{ block.super }} +{{ form.media.css }} +{% endblock %} + {% block content %}
    {% block above_form %} @@ -14,5 +19,12 @@
    + {% block beneath_form %} + {% endblock %} {% endblock %} + +{% block scripts %} +{{ block.super }} +{{ form.media.js }} +{% endblock %} diff --git a/passbook/admin/urls.py b/passbook/admin/urls.py index deb4472bd..3563a0c9b 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/policy.py b/passbook/admin/views/policy.py index 51dc4bd0c..355ea916f 100644 --- a/passbook/admin/views/policy.py +++ b/passbook/admin/views/policy.py @@ -11,6 +11,7 @@ from django.views.generic.detail import DetailView from passbook.admin.forms.policies import PolicyTestForm from passbook.admin.mixins import AdminRequiredMixin from passbook.core.models import Policy +from passbook.core.policies import PolicyEngine from passbook.lib.utils.reflection import path_to_class @@ -100,7 +101,9 @@ class PolicyTestView(AdminRequiredMixin, DetailView, FormView): def form_valid(self, form): policy = self.get_object() user = form.cleaned_data.get('user') - result = policy.passes(user) + policy_engine = PolicyEngine([policy]) + policy_engine.for_user(user).with_request(self.request).build() + result = policy_engine.passing if result: messages.success(self.request, _('User successfully passed policy.')) else: 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/api/__init__.py b/passbook/api/__init__.py index 895c549c7..0c5b0717a 100644 --- a/passbook/api/__init__.py +++ b/passbook/api/__init__.py @@ -1,2 +1,2 @@ """passbook api""" -__version__ = '0.1.4-beta' +__version__ = '0.1.10-beta' diff --git a/passbook/audit/__init__.py b/passbook/audit/__init__.py index 017272cfb..ee9db2ecc 100644 --- a/passbook/audit/__init__.py +++ b/passbook/audit/__init__.py @@ -1,2 +1,2 @@ """passbook audit Header""" -__version__ = '0.1.4-beta' +__version__ = '0.1.10-beta' diff --git a/passbook/audit/migrations/0004_delete_loginattempt.py b/passbook/audit/migrations/0004_delete_loginattempt.py new file mode 100644 index 000000000..96ca0e8dc --- /dev/null +++ b/passbook/audit/migrations/0004_delete_loginattempt.py @@ -0,0 +1,16 @@ +# Generated by Django 2.1.7 on 2019-03-08 14:53 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('passbook_audit', '0003_auto_20190221_1240'), + ] + + operations = [ + migrations.DeleteModel( + name='LoginAttempt', + ), + ] diff --git a/passbook/captcha_factor/__init__.py b/passbook/captcha_factor/__init__.py index 0f462f20c..814a2c720 100644 --- a/passbook/captcha_factor/__init__.py +++ b/passbook/captcha_factor/__init__.py @@ -1,2 +1,2 @@ """passbook captcha_factor Header""" -__version__ = '0.1.4-beta' +__version__ = '0.1.10-beta' diff --git a/passbook/captcha_factor/factor.py b/passbook/captcha_factor/factor.py index a16222995..9ce564cff 100644 --- a/passbook/captcha_factor/factor.py +++ b/passbook/captcha_factor/factor.py @@ -13,3 +13,10 @@ class CaptchaFactor(FormView, AuthenticationFactor): def form_valid(self, form): return self.authenticator.user_ok() + + def get_form(self, form_class=None): + form = CaptchaForm(**self.get_form_kwargs()) + form.fields['captcha'].public_key = '6Lfi1w8TAAAAAELH-YiWp0OFItmMzvjGmw2xkvUN' + form.fields['captcha'].private_key = '6Lfi1w8TAAAAAMQI3f86tGMvd1QkcqqVQyBWI23D' + form.fields['captcha'].widget.attrs["data-sitekey"] = form.fields['captcha'].public_key + return form diff --git a/passbook/core/__init__.py b/passbook/core/__init__.py index 0f73d3567..34384aea8 100644 --- a/passbook/core/__init__.py +++ b/passbook/core/__init__.py @@ -1,2 +1,2 @@ """passbook core""" -__version__ = '0.1.4-beta' +__version__ = '0.1.10-beta' 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/auth/view.py b/passbook/core/auth/view.py index 80f7bd835..710967b14 100644 --- a/passbook/core/auth/view.py +++ b/passbook/core/auth/view.py @@ -39,35 +39,41 @@ class AuthenticationView(UserPassesTestMixin, View): # Allow only not authenticated users to login def test_func(self): - return self.request.user.is_authenticated is False + return AuthenticationView.SESSION_PENDING_USER in self.request.session def handle_no_permission(self): # Function from UserPassesTestMixin if 'next' in self.request.GET: return redirect(self.request.GET.get('next')) - return _redirect_with_qs('passbook_core:overview', self.request.GET) + if self.request.user.is_authenticated: + return _redirect_with_qs('passbook_core:overview', self.request.GET) + return _redirect_with_qs('passbook_core:auth-login', self.request.GET) + + def get_pending_factors(self): + """Loading pending factors from Database or load from session variable""" + # Write pending factors to session + if AuthenticationView.SESSION_PENDING_FACTORS in self.request.session: + return self.request.session[AuthenticationView.SESSION_PENDING_FACTORS] + # Get an initial list of factors which are currently enabled + # and apply to the current user. We check policies here and block the request + _all_factors = Factor.objects.filter(enabled=True).order_by('order').select_subclasses() + pending_factors = [] + for factor in _all_factors: + policy_engine = PolicyEngine(factor.policies.all()) + policy_engine.for_user(self.pending_user).with_request(self.request).build() + if policy_engine.passing: + pending_factors.append((factor.uuid.hex, factor.type)) + return pending_factors def dispatch(self, request, *args, **kwargs): + # Check if user passes test (i.e. SESSION_PENDING_USER is set) + user_test_result = self.get_test_func()() + if not user_test_result: + return self.handle_no_permission() # Extract pending user from session (only remember uid) - if AuthenticationView.SESSION_PENDING_USER in request.session: - self.pending_user = get_object_or_404( - User, id=self.request.session[AuthenticationView.SESSION_PENDING_USER]) - else: - # No Pending user, redirect to login screen - return _redirect_with_qs('passbook_core:auth-login', request.GET) - # Write pending factors to session - if AuthenticationView.SESSION_PENDING_FACTORS in request.session: - self.pending_factors = request.session[AuthenticationView.SESSION_PENDING_FACTORS] - else: - # Get an initial list of factors which are currently enabled - # and apply to the current user. We check policies here and block the request - _all_factors = Factor.objects.filter(enabled=True).order_by('order').select_subclasses() - self.pending_factors = [] - for factor in _all_factors: - policy_engine = PolicyEngine(factor.policies.all()) - policy_engine.for_user(self.pending_user).with_request(request).build() - if policy_engine.result[0]: - self.pending_factors.append((factor.uuid.hex, factor.type)) + self.pending_user = get_object_or_404( + User, id=self.request.session[AuthenticationView.SESSION_PENDING_USER]) + self.pending_factors = self.get_pending_factors() # Read and instantiate factor from session factor_uuid, factor_class = None, None if AuthenticationView.SESSION_FACTOR not in request.session: @@ -107,11 +113,11 @@ class AuthenticationView(UserPassesTestMixin, View): next_factor = None if self.pending_factors: next_factor = self.pending_factors.pop() + # Save updated pening_factor list to session self.request.session[AuthenticationView.SESSION_PENDING_FACTORS] = \ self.pending_factors self.request.session[AuthenticationView.SESSION_FACTOR] = next_factor LOGGER.debug("Rendering Factor is %s", next_factor) - # return _redirect_with_qs('passbook_core:auth-process', kwargs={'factor': next_factor}) return _redirect_with_qs('passbook_core:auth-process', self.request.GET) # User passed all factors LOGGER.debug("User passed all factors, logging in") @@ -126,7 +132,6 @@ class AuthenticationView(UserPassesTestMixin, View): def _user_passed(self): """User Successfully passed all factors""" - # user = authenticate(request=self.request, ) backend = self.request.session[AuthenticationView.SESSION_USER_BACKEND] login(self.request, self.pending_user, backend=backend) LOGGER.debug("Logged in user %s", self.pending_user) 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/management/commands/import_users.py b/passbook/core/management/commands/import_users.py index fc694ef96..c0b1a829b 100644 --- a/passbook/core/management/commands/import_users.py +++ b/passbook/core/management/commands/import_users.py @@ -37,7 +37,8 @@ class Command(BaseCommand): User.objects.create( username=user.get('username'), email=user.get('email'), - name=user.get('name')) + name=user.get('name'), + password=user.get('password')) LOGGER.debug('Created User %s', user.get('username')) except ValidationError as exc: LOGGER.warning('User %s caused %r, skipping', user.get('username'), exc) 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 25608b67a..c60f580b3 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 @@ -286,6 +288,8 @@ class FieldMatcherPolicy(Policy): if self.match_action == FieldMatcherPolicy.MATCH_REGEXP: pattern = re.compile(self.value) passes = bool(pattern.match(user_field_value)) + if self.match_action == FieldMatcherPolicy.MATCH_EXACT: + passes = user_field_value == self.value LOGGER.debug("User got '%r'", passes) return passes @@ -412,7 +416,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 +428,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/policies.py b/passbook/core/policies.py index 37050a2d0..917b1a039 100644 --- a/passbook/core/policies.py +++ b/passbook/core/policies.py @@ -54,6 +54,8 @@ class PolicyEngine: def build(self): """Build task group""" + if not self._user: + raise ValueError("User not set.") signatures = [] kwargs = { '__password__': getattr(self._user, '__password__', None), @@ -74,8 +76,14 @@ class PolicyEngine: for policy_action, policy_result, policy_message in self._group.get(): passing = (policy_action == Policy.ACTION_ALLOW and policy_result) or \ (policy_action == Policy.ACTION_DENY and not policy_result) + LOGGER.debug('Action=%s, Result=%r => %r', policy_action, policy_result, passing) if policy_message: messages.append(policy_message) if not passing: return False, messages return True, messages + + @property + def passing(self): + """Only get true/false if user passes""" + return self.result[0] 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..dba86a0b8 100644 --- a/passbook/core/templates/base/skeleton.html +++ b/passbook/core/templates/base/skeleton.html @@ -4,39 +4,51 @@ - - - - - {% block title %} - {% title %} - {% endblock %} - - - - - - - {% block head %} + + + + + + {% block title %} + {% title %} {% endblock %} - </head> - <body {% if is_login %} class="login-pf" {% endif %}> - {% block body %} - {% endblock %} - <script src="{% static 'js/jquery.min.js' %}"></script> - <script src="{% static 'js/bootstrap.min.js' %}"></script> - <script src="{% static 'js/patternfly.min.js' %}"></script> - <script src="{% static 'js/passbook.js' %}"></script> - {% block scripts %} - {% endblock %} - <div class="modals"> - {% include 'partials/about_modal.html' %} - </div> - </body> + + + + + + + + {% block head %} + {% endblock %} + + + + {% if 'impersonate_id' in request.session %} +
    + + {% blocktrans with user=user %}You're currently impersonating {{ user }}.{% endblocktrans %} + {% trans 'Stop impersonation' %} + +
    + {% endif %} + {% block body %} + {% endblock %} + + + + + {% block scripts %} + {% endblock %} +
    + {% include 'partials/about_modal.html' %} +
    + + 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/core/templatetags/passbook_user_settings.py b/passbook/core/templatetags/passbook_user_settings.py index f6e11df6c..8e529d68c 100644 --- a/passbook/core/templatetags/passbook_user_settings.py +++ b/passbook/core/templatetags/passbook_user_settings.py @@ -17,6 +17,6 @@ def user_factors(context): _link = factor.has_user_settings() policy_engine = PolicyEngine(factor.policies.all()) policy_engine.for_user(user).with_request(context.get('request')).build() - if policy_engine.result[0] and _link: + if policy_engine.passing and _link: matching_factors.append(_link) return matching_factors diff --git a/passbook/core/tests/test_auth_view.py b/passbook/core/tests/test_auth_view.py new file mode 100644 index 000000000..573c214c8 --- /dev/null +++ b/passbook/core/tests/test_auth_view.py @@ -0,0 +1,130 @@ +"""passbook Core Authentication Test""" +import string +from random import SystemRandom + +from django.contrib.auth.models import AnonymousUser +from django.contrib.sessions.middleware import SessionMiddleware +from django.test import RequestFactory, TestCase +from django.urls import reverse + +from passbook.core.auth.view import AuthenticationView +from passbook.core.models import DummyFactor, PasswordFactor, User + + +class TestFactorAuthentication(TestCase): + """passbook Core Authentication Test""" + + def setUp(self): + super().setUp() + self.password = ''.join(SystemRandom().choice( + string.ascii_uppercase + string.digits) for _ in range(8)) + self.factor, _ = PasswordFactor.objects.get_or_create(name='password', + slug='password', + backends=[]) + self.user = User.objects.create_user(username='test', + email='test@test.test', + password=self.password) + + def test_unauthenticated_raw(self): + """test direct call to AuthenticationView""" + response = self.client.get(reverse('passbook_core:auth-process')) + # Response should be 302 since no pending user is set + self.assertEqual(response.status_code, 302) + self.assertEqual(response.url, reverse('passbook_core:auth-login')) + + def test_unauthenticated_prepared(self): + """test direct call but with pending_uesr in session""" + request = RequestFactory().get(reverse('passbook_core:auth-process')) + request.user = AnonymousUser() + request.session = {} + request.session[AuthenticationView.SESSION_PENDING_USER] = self.user.pk + + response = AuthenticationView.as_view()(request) + self.assertEqual(response.status_code, 200) + + def test_no_factors(self): + """Test with all factors disabled""" + self.factor.enabled = False + self.factor.save() + request = RequestFactory().get(reverse('passbook_core:auth-process')) + request.user = AnonymousUser() + request.session = {} + request.session[AuthenticationView.SESSION_PENDING_USER] = self.user.pk + + response = AuthenticationView.as_view()(request) + self.assertEqual(response.status_code, 302) + self.assertEqual(response.url, reverse('passbook_core:auth-denied')) + self.factor.enabled = True + self.factor.save() + + def test_authenticated(self): + """Test with already logged in user""" + self.client.force_login(self.user) + response = self.client.get(reverse('passbook_core:auth-process')) + # Response should be 302 since no pending user is set + self.assertEqual(response.status_code, 302) + self.assertEqual(response.url, reverse('passbook_core:overview')) + self.client.logout() + + def test_unauthenticated_post(self): + """Test post request as unauthenticated user""" + request = RequestFactory().post(reverse('passbook_core:auth-process'), data={ + 'password': self.password + }) + request.user = AnonymousUser() + middleware = SessionMiddleware() + middleware.process_request(request) + request.session.save() + request.session[AuthenticationView.SESSION_PENDING_USER] = self.user.pk + + response = AuthenticationView.as_view()(request) + self.assertEqual(response.status_code, 302) + self.assertEqual(response.url, reverse('passbook_core:overview')) + self.client.logout() + + def test_unauthenticated_post_invalid(self): + """Test post request as unauthenticated user""" + request = RequestFactory().post(reverse('passbook_core:auth-process'), data={ + 'password': self.password + 'a' + }) + request.user = AnonymousUser() + middleware = SessionMiddleware() + middleware.process_request(request) + request.session.save() + request.session[AuthenticationView.SESSION_PENDING_USER] = self.user.pk + + response = AuthenticationView.as_view()(request) + self.assertEqual(response.status_code, 200) + self.client.logout() + + def test_multifactor(self): + """Test view with multiple active factors""" + DummyFactor.objects.get_or_create(name='dummy', + slug='dummy', + order=1) + request = RequestFactory().post(reverse('passbook_core:auth-process'), data={ + 'password': self.password + }) + request.user = AnonymousUser() + middleware = SessionMiddleware() + middleware.process_request(request) + request.session.save() + request.session[AuthenticationView.SESSION_PENDING_USER] = self.user.pk + + response = AuthenticationView.as_view()(request) + session_copy = request.session.items() + self.assertEqual(response.status_code, 302) + # Verify view redirects to itself after auth + self.assertEqual(response.url, reverse('passbook_core:auth-process')) + + # Run another request with same session which should result in a logged in user + request = RequestFactory().post(reverse('passbook_core:auth-process')) + request.user = AnonymousUser() + middleware = SessionMiddleware() + middleware.process_request(request) + for key, value in session_copy: + request.session[key] = value + request.session.save() + response = AuthenticationView.as_view()(request) + self.assertEqual(response.status_code, 302) + self.assertEqual(response.url, reverse('passbook_core:overview')) diff --git a/passbook/hibp_policy/__init__.py b/passbook/hibp_policy/__init__.py index 5b2a27243..0630e1101 100644 --- a/passbook/hibp_policy/__init__.py +++ b/passbook/hibp_policy/__init__.py @@ -1,2 +1,2 @@ """passbook hibp_policy""" -__version__ = '0.1.4-beta' +__version__ = '0.1.10-beta' diff --git a/passbook/ldap/__init__.py b/passbook/ldap/__init__.py index 86e8af36c..f23eb47ea 100644 --- a/passbook/ldap/__init__.py +++ b/passbook/ldap/__init__.py @@ -1,2 +1,2 @@ """Passbook ldap app Header""" -__version__ = '0.1.4-beta' +__version__ = '0.1.10-beta' diff --git a/passbook/lib/__init__.py b/passbook/lib/__init__.py index 4477dc335..755c6777f 100644 --- a/passbook/lib/__init__.py +++ b/passbook/lib/__init__.py @@ -1,2 +1,2 @@ """passbook lib""" -__version__ = '0.1.4-beta' +__version__ = '0.1.10-beta' diff --git a/passbook/lib/fields.py b/passbook/lib/fields.py index e69de29bb..d2ef82f87 100644 --- a/passbook/lib/fields.py +++ b/passbook/lib/fields.py @@ -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) diff --git a/passbook/lib/templates/lib/arrayfield.html b/passbook/lib/templates/lib/arrayfield.html new file mode 100644 index 000000000..85705402c --- /dev/null +++ b/passbook/lib/templates/lib/arrayfield.html @@ -0,0 +1,17 @@ +{% load utils %} + +{% spaceless %} +
    + {% for widget in widget.subwidgets %} +
    + {% include widget.template_name %} +
    + +
    +
    + {% endfor %} +
    +
    +{% endspaceless %} diff --git a/passbook/lib/widgets.py b/passbook/lib/widgets.py new file mode 100644 index 000000000..5012a1ff7 --- /dev/null +++ b/passbook/lib/widgets.py @@ -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 [] diff --git a/passbook/oauth_client/__init__.py b/passbook/oauth_client/__init__.py index b8c244612..6a32e8af1 100644 --- a/passbook/oauth_client/__init__.py +++ b/passbook/oauth_client/__init__.py @@ -1,2 +1,2 @@ """passbook oauth_client Header""" -__version__ = '0.1.4-beta' +__version__ = '0.1.10-beta' diff --git a/passbook/oauth_provider/__init__.py b/passbook/oauth_provider/__init__.py index 5a8c9b340..a2a618929 100644 --- a/passbook/oauth_provider/__init__.py +++ b/passbook/oauth_provider/__init__.py @@ -1,2 +1,2 @@ """passbook oauth_provider Header""" -__version__ = '0.1.4-beta' +__version__ = '0.1.10-beta' diff --git a/passbook/otp/__init__.py b/passbook/otp/__init__.py index ce49b71da..09b157720 100644 --- a/passbook/otp/__init__.py +++ b/passbook/otp/__init__.py @@ -1,2 +1,2 @@ """passbook otp Header""" -__version__ = '0.1.4-beta' +__version__ = '0.1.10-beta' diff --git a/passbook/password_expiry_policy/__init__.py b/passbook/password_expiry_policy/__init__.py index 2cf7bc4b4..d63ba506e 100644 --- a/passbook/password_expiry_policy/__init__.py +++ b/passbook/password_expiry_policy/__init__.py @@ -1,2 +1,2 @@ """passbook password_expiry""" -__version__ = '0.1.4-beta' +__version__ = '0.1.10-beta' diff --git a/passbook/saml_idp/__init__.py b/passbook/saml_idp/__init__.py index a1453f884..907a72ce9 100644 --- a/passbook/saml_idp/__init__.py +++ b/passbook/saml_idp/__init__.py @@ -1,2 +1,2 @@ """passbook saml_idp Header""" -__version__ = '0.1.4-beta' +__version__ = '0.1.10-beta' diff --git a/passbook/saml_idp/base.py b/passbook/saml_idp/base.py index dcf03580f..c9b7a5d05 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,20 @@ 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': [], + '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) @@ -227,7 +238,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..e54bd6306 100644 --- a/passbook/saml_idp/forms.py +++ b/passbook/saml_idp/forms.py @@ -2,7 +2,9 @@ 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 @@ -21,7 +23,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 +33,20 @@ 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(), + } + field_classes = { + 'values': DynamicArrayField + } 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..13fc082b3 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 %} - - - - - -