Merge branch 'master' into 23-groups

# Conflicts:
#	passbook/admin/templates/administration/base.html
This commit is contained in:
Jens Langhammer 2019-03-10 17:13:29 +01:00
commit 5f861189e4
59 changed files with 903 additions and 154 deletions

View File

@ -1,5 +1,5 @@
[bumpversion] [bumpversion]
current_version = 0.1.4-beta current_version = 0.1.10-beta
tag = True tag = True
commit = True commit = True
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)\-(?P<release>.*) parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)\-(?P<release>.*)

View File

@ -8,6 +8,7 @@ stages:
- test - test
- build - build
- docs - docs
- deploy
image: python:3.6 image: python:3.6
services: services:
- postgres:latest - postgres:latest
@ -53,7 +54,7 @@ package-docker:
before_script: before_script:
- echo "{\"auths\":{\"docker.$NEXUS_URL\":{\"auth\":\"$NEXUS_AUTH\"}}}" > /kaniko/.docker/config.json - echo "{\"auths\":{\"docker.$NEXUS_URL\":{\"auth\":\"$NEXUS_AUTH\"}}}" > /kaniko/.docker/config.json
script: 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 stage: build
only: only:
- tags - tags
@ -78,6 +79,7 @@ package-debian:
- virtualenv env - virtualenv env
- source env/bin/activate - source env/bin/activate
- pip3 install -U -r requirements.txt -r requirements-dev.txt - pip3 install -U -r requirements.txt -r requirements-dev.txt
- ./manage.py collectstatic --no-input
image: ubuntu:18.04 image: ubuntu:18.04
script: script:
- debuild -us -uc - debuild -us -uc
@ -112,3 +114,18 @@ package-debian:
# - mkdocs build # - mkdocs build
# - 'rsync -avh --delete web/* "beryjuorg@ory1-web-prod-1.ory1.beryju.org:passbook.beryju.org/"' # - '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/"' # - '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

34
debian/changelog vendored
View File

@ -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 <jens.langhammer@beryju.org> 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 <jens.langhammer@beryju.org> Fri, 08 Mar 2019 20:37:05 +0000
passbook (0.1.4) stable; urgency=medium passbook (0.1.4) stable; urgency=medium
* initial debian package release * initial debian package release

View File

@ -1,6 +1,6 @@
apiVersion: v1 apiVersion: v1
appVersion: "0.1.4-beta" appVersion: "0.1.10-beta"
description: A Helm chart for passbook. description: A Helm chart for passbook.
name: passbook name: passbook
version: "0.1.4-beta" version: "0.1.10-beta"
icon: https://passbook.beryju.org/images/logo.png icon: https://passbook.beryju.org/images/logo.png

View File

@ -18,6 +18,7 @@ spec:
labels: labels:
app.kubernetes.io/name: {{ include "passbook.name" . }} app.kubernetes.io/name: {{ include "passbook.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }} app.kubernetes.io/instance: {{ .Release.Name }}
passbook.io/component: web
spec: spec:
volumes: volumes:
- name: config-volume - name: config-volume

View File

@ -17,3 +17,4 @@ spec:
selector: selector:
app.kubernetes.io/name: {{ include "passbook.name" . }} app.kubernetes.io/name: {{ include "passbook.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }} app.kubernetes.io/instance: {{ .Release.Name }}
passbook.io/component: web

View File

@ -18,6 +18,7 @@ spec:
labels: labels:
app.kubernetes.io/name: {{ include "passbook.name" . }} app.kubernetes.io/name: {{ include "passbook.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }} app.kubernetes.io/instance: {{ .Release.Name }}
passbook.io/component: worker
spec: spec:
volumes: volumes:
- name: config-volume - name: config-volume

View File

@ -5,7 +5,7 @@
replicaCount: 1 replicaCount: 1
image: image:
tag: 0.1.4-beta tag: 0.1.10-beta
nameOverride: "" nameOverride: ""

View File

@ -1,2 +1,2 @@
"""passbook""" """passbook"""
__version__ = '0.1.4-beta' __version__ = '0.1.10-beta'

View File

@ -1,2 +1,2 @@
"""passbook admin""" """passbook admin"""
__version__ = '0.1.4-beta' __version__ = '0.1.10-beta'

View File

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

View File

@ -0,0 +1,5 @@
"""passbook admin settings"""
MIDDLEWARE = [
'passbook.admin.middleware.impersonate',
]

View File

@ -20,6 +20,10 @@
class="{% is_active 'passbook_admin:providers' 'passbook_admin:provider-create' 'passbook_admin:provider-update' 'passbook_admin:provider-delete' %}"> class="{% is_active 'passbook_admin:providers' 'passbook_admin:provider-create' 'passbook_admin:provider-update' 'passbook_admin:provider-delete' %}">
<a href="{% url 'passbook_admin:providers' %}">{% trans 'Providers' %}</a> <a href="{% url 'passbook_admin:providers' %}">{% trans 'Providers' %}</a>
</li> </li>
<li
class="{% is_active 'passbook_admin:property-mappings' 'passbook_admin:property-mapping-create' 'passbook_admin:property-mapping-update' 'passbook_admin:property-mapping-delete' %}">
<a href="{% url 'passbook_admin:property-mappings' %}">{% trans 'Property Mappings' %}</a>
</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:factors' 'passbook_admin:factor-create' 'passbook_admin:factor-update' 'passbook_admin:factor-delete' %}">
<a href="{% url 'passbook_admin:factors' %}">{% trans 'Factors' %}</a> <a href="{% url 'passbook_admin:factors' %}">{% trans 'Factors' %}</a>

View File

@ -5,3 +5,22 @@
{% block above_form %} {% block above_form %}
<h1>{% blocktrans with policy=policy %}Test policy {{ policy }}{% endblocktrans %}</h1> <h1>{% blocktrans with policy=policy %}Test policy {{ policy }}{% endblocktrans %}</h1>
{% endblock %} {% endblock %}
{% block action %}
{% trans 'Test' %}
{% endblock %}
{% block beneath_form %}
<p class="loading" style="display: none;">
<span class="spinner spinner-xs spinner-inline"></span> {% trans 'Processing, please wait...' %}
</p>
{% endblock %}
{% block scripts %}
{{ block.super }}
<script>
$('form').on('submit', function () {
$('p.loading').show();
})
</script>
{% 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

@ -31,6 +31,8 @@
href="{% url 'passbook_admin:user-delete' pk=user.pk %}?back={{ request.get_full_path }}">{% trans 'Delete' %}</a> href="{% url 'passbook_admin:user-delete' pk=user.pk %}?back={{ request.get_full_path }}">{% trans 'Delete' %}</a>
<a class="btn btn-default btn-sm" <a class="btn btn-default btn-sm"
href="{% url 'passbook_admin:user-password-reset' pk=user.pk %}?back={{ request.get_full_path }}">{% trans 'Reset Password' %}</a> href="{% url 'passbook_admin:user-password-reset' pk=user.pk %}?back={{ request.get_full_path }}">{% trans 'Reset Password' %}</a>
<a class="btn btn-default btn-sm"
href="{% url 'passbook_core:overview' %}?__impersonate={{ user.pk }}">{% trans 'Impersonate' %}</a>
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}

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 %}
@ -14,5 +19,12 @@
<input type="submit" class="btn btn-primary" value="{% block action %}{% endblock %}" /> <input type="submit" class="btn btn-primary" value="{% block action %}{% endblock %}" />
</form> </form>
</div> </div>
{% block beneath_form %}
{% endblock %}
</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

@ -11,6 +11,7 @@ from django.views.generic.detail import DetailView
from passbook.admin.forms.policies import PolicyTestForm from passbook.admin.forms.policies import PolicyTestForm
from passbook.admin.mixins import AdminRequiredMixin from passbook.admin.mixins import AdminRequiredMixin
from passbook.core.models import Policy from passbook.core.models import Policy
from passbook.core.policies import PolicyEngine
from passbook.lib.utils.reflection import path_to_class from passbook.lib.utils.reflection import path_to_class
@ -100,7 +101,9 @@ class PolicyTestView(AdminRequiredMixin, DetailView, FormView):
def form_valid(self, form): def form_valid(self, form):
policy = self.get_object() policy = self.get_object()
user = form.cleaned_data.get('user') 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: if result:
messages.success(self.request, _('User successfully passed policy.')) messages.success(self.request, _('User successfully passed policy.'))
else: else:

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

@ -1,2 +1,2 @@
"""passbook api""" """passbook api"""
__version__ = '0.1.4-beta' __version__ = '0.1.10-beta'

View File

@ -1,2 +1,2 @@
"""passbook audit Header""" """passbook audit Header"""
__version__ = '0.1.4-beta' __version__ = '0.1.10-beta'

View File

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

View File

@ -1,2 +1,2 @@
"""passbook captcha_factor Header""" """passbook captcha_factor Header"""
__version__ = '0.1.4-beta' __version__ = '0.1.10-beta'

View File

@ -13,3 +13,10 @@ class CaptchaFactor(FormView, AuthenticationFactor):
def form_valid(self, form): def form_valid(self, form):
return self.authenticator.user_ok() 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

View File

@ -1,2 +1,2 @@
"""passbook core""" """passbook core"""
__version__ = '0.1.4-beta' __version__ = '0.1.10-beta'

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

@ -39,35 +39,41 @@ class AuthenticationView(UserPassesTestMixin, View):
# Allow only not authenticated users to login # Allow only not authenticated users to login
def test_func(self): 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): def handle_no_permission(self):
# Function from UserPassesTestMixin # Function from UserPassesTestMixin
if 'next' in self.request.GET: if 'next' in self.request.GET:
return redirect(self.request.GET.get('next')) 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): 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) # Extract pending user from session (only remember uid)
if AuthenticationView.SESSION_PENDING_USER in request.session: self.pending_user = get_object_or_404(
self.pending_user = get_object_or_404( User, id=self.request.session[AuthenticationView.SESSION_PENDING_USER])
User, id=self.request.session[AuthenticationView.SESSION_PENDING_USER]) self.pending_factors = self.get_pending_factors()
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))
# Read and instantiate factor from session # Read and instantiate factor from session
factor_uuid, factor_class = None, None factor_uuid, factor_class = None, None
if AuthenticationView.SESSION_FACTOR not in request.session: if AuthenticationView.SESSION_FACTOR not in request.session:
@ -107,11 +113,11 @@ class AuthenticationView(UserPassesTestMixin, View):
next_factor = None next_factor = None
if self.pending_factors: if self.pending_factors:
next_factor = self.pending_factors.pop() next_factor = self.pending_factors.pop()
# Save updated pening_factor list to session
self.request.session[AuthenticationView.SESSION_PENDING_FACTORS] = \ self.request.session[AuthenticationView.SESSION_PENDING_FACTORS] = \
self.pending_factors self.pending_factors
self.request.session[AuthenticationView.SESSION_FACTOR] = next_factor self.request.session[AuthenticationView.SESSION_FACTOR] = next_factor
LOGGER.debug("Rendering Factor is %s", 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) return _redirect_with_qs('passbook_core:auth-process', self.request.GET)
# User passed all factors # User passed all factors
LOGGER.debug("User passed all factors, logging in") LOGGER.debug("User passed all factors, logging in")
@ -126,7 +132,6 @@ class AuthenticationView(UserPassesTestMixin, View):
def _user_passed(self): def _user_passed(self):
"""User Successfully passed all factors""" """User Successfully passed all factors"""
# user = authenticate(request=self.request, )
backend = self.request.session[AuthenticationView.SESSION_USER_BACKEND] backend = self.request.session[AuthenticationView.SESSION_USER_BACKEND]
login(self.request, self.pending_user, backend=backend) login(self.request, self.pending_user, backend=backend)
LOGGER.debug("Logged in user %s", self.pending_user) LOGGER.debug("Logged in user %s", self.pending_user)

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

@ -37,7 +37,8 @@ class Command(BaseCommand):
User.objects.create( User.objects.create(
username=user.get('username'), username=user.get('username'),
email=user.get('email'), email=user.get('email'),
name=user.get('name')) name=user.get('name'),
password=user.get('password'))
LOGGER.debug('Created User %s', user.get('username')) LOGGER.debug('Created User %s', user.get('username'))
except ValidationError as exc: except ValidationError as exc:
LOGGER.warning('User %s caused %r, skipping', user.get('username'), exc) LOGGER.warning('User %s caused %r, skipping', user.get('username'), exc)

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
@ -286,6 +288,8 @@ class FieldMatcherPolicy(Policy):
if self.match_action == FieldMatcherPolicy.MATCH_REGEXP: if self.match_action == FieldMatcherPolicy.MATCH_REGEXP:
pattern = re.compile(self.value) pattern = re.compile(self.value)
passes = bool(pattern.match(user_field_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) LOGGER.debug("User got '%r'", passes)
return passes return passes
@ -412,7 +416,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 +428,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

@ -54,6 +54,8 @@ class PolicyEngine:
def build(self): def build(self):
"""Build task group""" """Build task group"""
if not self._user:
raise ValueError("User not set.")
signatures = [] signatures = []
kwargs = { kwargs = {
'__password__': getattr(self._user, '__password__', None), '__password__': getattr(self._user, '__password__', None),
@ -74,8 +76,14 @@ class PolicyEngine:
for policy_action, policy_result, policy_message in self._group.get(): for policy_action, policy_result, policy_message in self._group.get():
passing = (policy_action == Policy.ACTION_ALLOW and policy_result) or \ passing = (policy_action == Policy.ACTION_ALLOW and policy_result) or \
(policy_action == Policy.ACTION_DENY and not policy_result) (policy_action == Policy.ACTION_DENY and not policy_result)
LOGGER.debug('Action=%s, Result=%r => %r', policy_action, policy_result, passing)
if policy_message: if policy_message:
messages.append(policy_message) messages.append(policy_message)
if not passing: if not passing:
return False, messages return False, messages
return True, messages return True, messages
@property
def passing(self):
"""Only get true/false if user passes"""
return self.result[0]

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

@ -4,39 +4,51 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head>
<meta charset="UTF-8"> <head>
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta charset="UTF-8">
<title> <meta name="viewport" content="width=device-width, initial-scale=1">
{% block title %} <title>
{% title %} {% block title %}
{% endblock %} {% title %}
</title>
<link rel="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-additions.min.css' %}">
<style>
.login-pf {
background-attachment: fixed;
scroll-behavior: smooth;
background-size: cover;
}
</style>
{% block head %}
{% endblock %} {% endblock %}
</head> </title>
<body {% if is_login %} class="login-pf" {% endif %}> <link rel="icon" type="image/png" href="{% static 'img/logo.png' %}">
{% block body %} <link rel="shortcut icon" type="image/png" href="{% static 'img/logo.png' %}">
{% endblock %} <link rel="stylesheet" type="text/css" href="{% static 'css/patternfly.min.css' %}">
<script src="{% static 'js/jquery.min.js' %}"></script> <link rel="stylesheet" type="text/css" href="{% static 'css/patternfly-additions.min.css' %}">
<script src="{% static 'js/bootstrap.min.js' %}"></script> <link rel="stylesheet" type="text/css" href="{% static 'css/passbook.css' %}">
<script src="{% static 'js/patternfly.min.js' %}"></script> <style>
<script src="{% static 'js/passbook.js' %}"></script> .login-pf {
{% block scripts %} background-attachment: fixed;
{% endblock %} scroll-behavior: smooth;
<div class="modals"> background-size: cover;
{% include 'partials/about_modal.html' %} }
</div> </style>
</body> {% block head %}
{% endblock %}
</head>
<body {% if is_login %} class="login-pf" {% endif %}>
{% if 'impersonate_id' in request.session %}
<div class="experimental-pf-bar">
<span id="experimentalBar" class="experimental-pf-text">
{% blocktrans with user=user %}You're currently impersonating {{ user }}.{% endblocktrans %}
<a href="?__unimpersonate=True" id="acceptMessage">{% trans 'Stop impersonation' %}</a>
</span>
</div>
{% 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>
</html> </html>

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

@ -17,6 +17,6 @@ def user_factors(context):
_link = factor.has_user_settings() _link = factor.has_user_settings()
policy_engine = PolicyEngine(factor.policies.all()) policy_engine = PolicyEngine(factor.policies.all())
policy_engine.for_user(user).with_request(context.get('request')).build() 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) matching_factors.append(_link)
return matching_factors return matching_factors

View File

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

View File

@ -1,2 +1,2 @@
"""passbook hibp_policy""" """passbook hibp_policy"""
__version__ = '0.1.4-beta' __version__ = '0.1.10-beta'

View File

@ -1,2 +1,2 @@
"""Passbook ldap app Header""" """Passbook ldap app Header"""
__version__ = '0.1.4-beta' __version__ = '0.1.10-beta'

View File

@ -1,2 +1,2 @@
"""passbook lib""" """passbook lib"""
__version__ = '0.1.4-beta' __version__ = '0.1.10-beta'

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

@ -1,2 +1,2 @@
"""passbook oauth_client Header""" """passbook oauth_client Header"""
__version__ = '0.1.4-beta' __version__ = '0.1.10-beta'

View File

@ -1,2 +1,2 @@
"""passbook oauth_provider Header""" """passbook oauth_provider Header"""
__version__ = '0.1.4-beta' __version__ = '0.1.10-beta'

View File

@ -1,2 +1,2 @@
"""passbook otp Header""" """passbook otp Header"""
__version__ = '0.1.4-beta' __version__ = '0.1.10-beta'

View File

@ -1,2 +1,2 @@
"""passbook password_expiry""" """passbook password_expiry"""
__version__ = '0.1.4-beta' __version__ = '0.1.10-beta'

View File

@ -1,2 +1,2 @@
"""passbook saml_idp Header""" """passbook saml_idp Header"""
__version__ = '0.1.4-beta' __version__ = '0.1.10-beta'

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.application.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>

View File

@ -2,13 +2,14 @@
from logging import getLogger from logging import getLogger
from django.contrib.auth import logout from django.contrib.auth import logout
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import AccessMixin
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import URLValidator from django.core.validators import URLValidator
from django.http import HttpResponse, HttpResponseBadRequest from django.http import HttpResponse, HttpResponseBadRequest
from django.shortcuts import get_object_or_404, redirect, render, reverse from django.shortcuts import get_object_or_404, redirect, render, reverse
from django.utils.datastructures import MultiValueDictKeyError from django.utils.datastructures import MultiValueDictKeyError
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.utils.translation import gettext as _
from django.views import View from django.views import View
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from signxml.util import strip_pem_header from signxml.util import strip_pem_header
@ -45,7 +46,7 @@ def render_xml(request, template, ctx):
return render(request, template, context=ctx, content_type="application/xml") return render(request, template, context=ctx, content_type="application/xml")
class ProviderMixin: class AccessRequiredView(AccessMixin, View):
"""Mixin class for Views using a provider instance""" """Mixin class for Views using a provider instance"""
_provider = None _provider = None
@ -58,8 +59,24 @@ class ProviderMixin:
self._provider = get_object_or_404(SAMLProvider, pk=application.provider_id) self._provider = get_object_or_404(SAMLProvider, pk=application.provider_id)
return self._provider return self._provider
def _has_access(self):
"""Check if user has access to application"""
policy_engine = PolicyEngine(self.provider.application.policies.all())
policy_engine.for_user(self.request.user).with_request(self.request).build()
return policy_engine.passing
class LoginBeginView(LoginRequiredMixin, View): def dispatch(self, request, *args, **kwargs):
if not request.user.is_authenticated:
return self.handle_no_permission()
if not self._has_access():
return render(request, 'login/denied.html', {
'title': _("You don't have access to this application"),
'is_login': True
})
return super().dispatch(request, *args, **kwargs)
class LoginBeginView(AccessRequiredView):
"""Receives a SAML 2.0 AuthnRequest from a Service Provider and """Receives a SAML 2.0 AuthnRequest from a Service Provider and
stores it in the session prior to enforcing login.""" stores it in the session prior to enforcing login."""
@ -82,7 +99,7 @@ class LoginBeginView(LoginRequiredMixin, View):
})) }))
class RedirectToSPView(LoginRequiredMixin, View): class RedirectToSPView(AccessRequiredView):
"""Return autosubmit form""" """Return autosubmit form"""
def get(self, request, acs_url, saml_response, relay_state): def get(self, request, acs_url, saml_response, relay_state):
@ -96,22 +113,15 @@ class RedirectToSPView(LoginRequiredMixin, View):
}) })
class LoginProcessView(AccessRequiredView):
class LoginProcessView(ProviderMixin, LoginRequiredMixin, View):
"""Processor-based login continuation. """Processor-based login continuation.
Presents a SAML 2.0 Assertion for POSTing back to the Service Provider.""" Presents a SAML 2.0 Assertion for POSTing back to the Service Provider."""
def _has_access(self):
"""Check if user has access to application"""
policy_engine = PolicyEngine(self.provider.application.policies.all())
policy_engine.for_user(self.request.user).with_request(self.request).build()
return policy_engine.result
def get(self, request, application): def get(self, request, application):
"""Handle get request, i.e. render form""" """Handle get request, i.e. render form"""
LOGGER.debug("Request: %s", request) LOGGER.debug("Request: %s", request)
# Check if user has access # Check if user has access
if self.provider.application.skip_authorization and self._has_access(): if self.provider.application.skip_authorization:
ctx = self.provider.processor.generate_response() ctx = self.provider.processor.generate_response()
# Log Application Authorization # Log Application Authorization
AuditEntry.create( AuditEntry.create(
@ -134,7 +144,7 @@ class LoginProcessView(ProviderMixin, LoginRequiredMixin, View):
"""Handle post request, return back to ACS""" """Handle post request, return back to ACS"""
LOGGER.debug("Request: %s", request) LOGGER.debug("Request: %s", request)
# Check if user has access # Check if user has access
if request.POST.get('ACSUrl', None) and self._has_access(): if request.POST.get('ACSUrl', None):
# User accepted request # User accepted request
AuditEntry.create( AuditEntry.create(
action=AuditEntry.ACTION_AUTHORIZE_APPLICATION, action=AuditEntry.ACTION_AUTHORIZE_APPLICATION,
@ -153,7 +163,7 @@ class LoginProcessView(ProviderMixin, LoginRequiredMixin, View):
LOGGER.debug(exc) LOGGER.debug(exc)
class LogoutView(CSRFExemptMixin, LoginRequiredMixin, View): class LogoutView(CSRFExemptMixin, AccessRequiredView):
"""Allows a non-SAML 2.0 URL to log out the user and """Allows a non-SAML 2.0 URL to log out the user and
returns a standard logged-out page. (SalesForce and others use this method, returns a standard logged-out page. (SalesForce and others use this method,
though it's technically not SAML 2.0).""" though it's technically not SAML 2.0)."""
@ -174,7 +184,7 @@ class LogoutView(CSRFExemptMixin, LoginRequiredMixin, View):
return render(request, 'saml/idp/logged_out.html') return render(request, 'saml/idp/logged_out.html')
class SLOLogout(CSRFExemptMixin, LoginRequiredMixin, View): class SLOLogout(CSRFExemptMixin, AccessRequiredView):
"""Receives a SAML 2.0 LogoutRequest from a Service Provider, """Receives a SAML 2.0 LogoutRequest from a Service Provider,
logs out the user and returns a standard logged-out page.""" logs out the user and returns a standard logged-out page."""
@ -190,7 +200,7 @@ class SLOLogout(CSRFExemptMixin, LoginRequiredMixin, View):
return render(request, 'saml/idp/logged_out.html') return render(request, 'saml/idp/logged_out.html')
class DescriptorDownloadView(ProviderMixin, View): class DescriptorDownloadView(AccessRequiredView):
"""Replies with the XML Metadata IDSSODescriptor.""" """Replies with the XML Metadata IDSSODescriptor."""
def get(self, request, application): def get(self, request, application):
@ -214,10 +224,10 @@ class DescriptorDownloadView(ProviderMixin, View):
return response return response
class InitiateLoginView(ProviderMixin, LoginRequiredMixin, View): class InitiateLoginView(AccessRequiredView):
"""IdP-initiated Login""" """IdP-initiated Login"""
def dispatch(self, request, application): def get(self, request, application):
"""Initiates an IdP-initiated link to a simple SP resource/target URL.""" """Initiates an IdP-initiated link to a simple SP resource/target URL."""
self.provider.processor.init_deep_link(request, '') self.provider.processor.init_deep_link(request, '')
return _generate_response(request, self.provider) return _generate_response(request, self.provider)