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]
current_version = 0.1.4-beta
current_version = 0.1.10-beta
tag = True
commit = True
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)\-(?P<release>.*)

View file

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

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
* initial debian package release

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,2 +1,2 @@
"""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' %}">
<a href="{% url 'passbook_admin:providers' %}">{% trans 'Providers' %}</a>
</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
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>

View file

@ -5,3 +5,22 @@
{% block above_form %}
<h1>{% blocktrans with policy=policy %}Test policy {{ policy }}{% endblocktrans %}</h1>
{% 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>
<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>
<a class="btn btn-default btn-sm"
href="{% url 'passbook_core:overview' %}?__impersonate={{ user.pk }}">{% trans 'Impersonate' %}</a>
</td>
</tr>
{% endfor %}

View file

@ -3,6 +3,11 @@
{% load i18n %}
{% load utils %}
{% block head %}
{{ block.super }}
{{ form.media.css }}
{% endblock %}
{% block content %}
<div class="container">
{% block above_form %}
@ -14,5 +19,12 @@
<input type="submit" class="btn btn-primary" value="{% block action %}{% endblock %}" />
</form>
</div>
{% block beneath_form %}
{% endblock %}
</div>
{% endblock %}
{% block scripts %}
{{ block.super }}
{{ form.media.js }}
{% endblock %}

View file

@ -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/<uuid:pk>/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
path('invitations/', invitations.InvitationListView.as_view(), name='invitations'),
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.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:

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"""
__version__ = '0.1.4-beta'
__version__ = '0.1.10-beta'

View file

@ -1,2 +1,2 @@
"""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"""
__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):
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"""
__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'),
'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
})

View file

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

View file

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

View file

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

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):
"""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')

View file

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

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('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>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>
{% block title %}
{% title %}
{% endblock %}
</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 %}
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>
{% 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>
</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' %}">
<link rel="stylesheet" type="text/css" href="{% static 'css/passbook.css' %}">
<style>
.login-pf {
background-attachment: fixed;
scroll-behavior: smooth;
background-size: cover;
}
</style>
{% 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>

View file

@ -6,13 +6,13 @@
{% block content %}
<div class="container">
{% 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 %}
<div class="">
<form method="post" class="form-horizontal">
{% csrf_token %}
<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 }}"?
{% endblocktrans %}
</p>

View file

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

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"""
__version__ = '0.1.4-beta'
__version__ = '0.1.10-beta'

View file

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

View file

@ -1,2 +1,2 @@
"""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"""
__version__ = '0.1.4-beta'
__version__ = '0.1.10-beta'

View file

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

View file

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

View file

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

View file

@ -1,2 +1,2 @@
"""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 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):

View file

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

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"""
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__()]

View file

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

View file

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

View file

@ -1,7 +1,14 @@
<saml:AttributeStatement>
{% for attr in attributes %}
<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>
{% endfor %}
</saml:AttributeStatement>

View file

@ -2,13 +2,14 @@
from logging import getLogger
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.validators import URLValidator
from django.http import HttpResponse, HttpResponseBadRequest
from django.shortcuts import get_object_or_404, redirect, render, reverse
from django.utils.datastructures import MultiValueDictKeyError
from django.utils.decorators import method_decorator
from django.utils.translation import gettext as _
from django.views import View
from django.views.decorators.csrf import csrf_exempt
from signxml.util import strip_pem_header
@ -45,7 +46,7 @@ def render_xml(request, template, ctx):
return render(request, template, context=ctx, content_type="application/xml")
class ProviderMixin:
class AccessRequiredView(AccessMixin, View):
"""Mixin class for Views using a provider instance"""
_provider = None
@ -58,8 +59,24 @@ class ProviderMixin:
self._provider = get_object_or_404(SAMLProvider, pk=application.provider_id)
return self._provider
def _has_access(self):
"""Check if user has access to application"""
policy_engine = PolicyEngine(self.provider.application.policies.all())
policy_engine.for_user(self.request.user).with_request(self.request).build()
return policy_engine.passing
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
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"""
def get(self, request, acs_url, saml_response, relay_state):
@ -96,22 +113,15 @@ class RedirectToSPView(LoginRequiredMixin, View):
})
class LoginProcessView(ProviderMixin, LoginRequiredMixin, View):
class LoginProcessView(AccessRequiredView):
"""Processor-based login continuation.
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):
"""Handle get request, i.e. render form"""
LOGGER.debug("Request: %s", request)
# 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()
# Log Application Authorization
AuditEntry.create(
@ -134,7 +144,7 @@ class LoginProcessView(ProviderMixin, LoginRequiredMixin, View):
"""Handle post request, return back to ACS"""
LOGGER.debug("Request: %s", request)
# Check if user has access
if request.POST.get('ACSUrl', None) and self._has_access():
if request.POST.get('ACSUrl', None):
# User accepted request
AuditEntry.create(
action=AuditEntry.ACTION_AUTHORIZE_APPLICATION,
@ -153,7 +163,7 @@ class LoginProcessView(ProviderMixin, LoginRequiredMixin, View):
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
returns a standard logged-out page. (SalesForce and others use this method,
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')
class SLOLogout(CSRFExemptMixin, LoginRequiredMixin, View):
class SLOLogout(CSRFExemptMixin, AccessRequiredView):
"""Receives a SAML 2.0 LogoutRequest from a Service Provider,
logs out the user and returns a standard logged-out page."""
@ -190,7 +200,7 @@ class SLOLogout(CSRFExemptMixin, LoginRequiredMixin, View):
return render(request, 'saml/idp/logged_out.html')
class DescriptorDownloadView(ProviderMixin, View):
class DescriptorDownloadView(AccessRequiredView):
"""Replies with the XML Metadata IDSSODescriptor."""
def get(self, request, application):
@ -214,10 +224,10 @@ class DescriptorDownloadView(ProviderMixin, View):
return response
class InitiateLoginView(ProviderMixin, LoginRequiredMixin, View):
class InitiateLoginView(AccessRequiredView):
"""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."""
self.provider.processor.init_deep_link(request, '')
return _generate_response(request, self.provider)