Compare commits

...

35 Commits

Author SHA1 Message Date
5fb1b8044c new release: 0.8.11-beta 2020-02-25 11:38:50 +01:00
b8daab4377 providers/saml: fix AccessRequiredView.dispatch not being called 2020-02-25 11:38:26 +01:00
c5b91bdae8 providers/saml: fix CannotHandleAssertion Error still being sent to sentry 2020-02-24 19:14:43 +01:00
39a208c55f providers/saml: fix wrong key being used for params 2020-02-24 17:48:03 +01:00
a5bfef9b6b providers/saml: fix leftover data in session, fix IdP initiated login
move can_handle calls to binding endpoints (/login/ and /login/initiate/), so that /login/authorize/ works either way, can clean up the session and audit
2020-02-24 17:34:52 +01:00
f1f4cbef9b lib/sentry: fix SentryIgnoredException not being ignored correctly 2020-02-24 17:01:31 +01:00
8388120b06 new release: 0.8.10-beta 2020-02-24 15:30:57 +01:00
2bf96828f1 root: fix logging.basicConfig being called by pyjwkest 2020-02-24 15:30:28 +01:00
22838e66fe providers/saml: fix users being able to authenticate without audit logs being created 2020-02-24 14:40:12 +01:00
484dd6de09 providers/oidc: add error template 2020-02-24 14:19:02 +01:00
b743736c26 lib/logging: fix typo 2020-02-24 14:10:58 +01:00
af91e2079b core: sort provider by pk when selection application provider 2020-02-24 14:10:51 +01:00
cad1c17f14 helm: fix inconsistent labels 2020-02-24 13:49:42 +01:00
120d32e4dc new release: 0.8.9-beta 2020-02-24 13:23:20 +01:00
238b489e07 root: add process ID to logging output 2020-02-24 13:20:32 +01:00
4daa70c894 core: fix saving of policy not correctly clearing it's cache 2020-02-24 13:15:52 +01:00
f8599438df ui: fix lists not being rendered correctly 2020-02-24 13:13:42 +01:00
155c9a4c3f ui: update remaining forms, completely remove jQuery 2020-02-24 13:13:28 +01:00
8433b5e583 ui: fix automatic slug generation 2020-02-24 12:40:16 +01:00
dc5ba144f1 ui: fix height of multiple select input 2020-02-24 12:40:06 +01:00
521a8b5356 ui: update more remaining templates 2020-02-23 22:49:56 +01:00
3453077d7b root: set SameSite to None when debugging 2020-02-23 22:49:33 +01:00
70ede8581a core: sort sources on login view 2020-02-23 20:19:01 +01:00
6e9d297f02 deploy: use new bootstrap command 2020-02-23 20:12:48 +01:00
6a7545fd43 lib: add bootstrap command 2020-02-23 19:52:41 +01:00
a8926cbd07 lib: add more errors to sentry ignore 2020-02-23 19:48:14 +01:00
64d7b009ab sources/oauth: fix invalid headers, fix invalid function signature 2020-02-23 19:42:57 +01:00
2b5fddb7bf policies: add unittests for evaluator 2020-02-23 15:54:26 +01:00
b99d23c119 all: remove dead code 2020-02-23 15:32:20 +01:00
03905b74ff admin: exclude anonymous user from listing 2020-02-23 15:27:28 +01:00
6b8a59cfbd admin: show prettified yaml 2020-02-23 15:27:11 +01:00
d6fdcd3ef9 ui: re-add automatic slug generation 2020-02-23 15:20:41 +01:00
53ebc551d2 ui: fix icon sizing on login 2020-02-23 15:13:18 +01:00
3d4f43d6e3 ui: show default icon for source without icon 2020-02-23 15:09:58 +01:00
074cde7cd5 audit: save model's name or string representation 2020-02-23 15:04:30 +01:00
52 changed files with 531 additions and 427 deletions

View File

@ -1,5 +1,5 @@
[bumpversion] [bumpversion]
current_version = 0.8.8-beta current_version = 0.8.11-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

@ -16,11 +16,11 @@ jobs:
- name: Building Docker Image - name: Building Docker Image
run: docker build run: docker build
--no-cache --no-cache
-t beryju/passbook:0.8.8-beta -t beryju/passbook:0.8.11-beta
-t beryju/passbook:latest -t beryju/passbook:latest
-f Dockerfile . -f Dockerfile .
- name: Push Docker Container to Registry (versioned) - name: Push Docker Container to Registry (versioned)
run: docker push beryju/passbook:0.8.8-beta run: docker push beryju/passbook:0.8.11-beta
- name: Push Docker Container to Registry (latest) - name: Push Docker Container to Registry (latest)
run: docker push beryju/passbook:latest run: docker push beryju/passbook:latest
build-gatekeeper: build-gatekeeper:
@ -37,11 +37,11 @@ jobs:
cd gatekeeper cd gatekeeper
docker build \ docker build \
--no-cache \ --no-cache \
-t beryju/passbook-gatekeeper:0.8.8-beta \ -t beryju/passbook-gatekeeper:0.8.11-beta \
-t beryju/passbook-gatekeeper:latest \ -t beryju/passbook-gatekeeper:latest \
-f Dockerfile . -f Dockerfile .
- name: Push Docker Container to Registry (versioned) - name: Push Docker Container to Registry (versioned)
run: docker push beryju/passbook-gatekeeper:0.8.8-beta run: docker push beryju/passbook-gatekeeper:0.8.11-beta
- name: Push Docker Container to Registry (latest) - name: Push Docker Container to Registry (latest)
run: docker push beryju/passbook-gatekeeper:latest run: docker push beryju/passbook-gatekeeper:latest
build-static: build-static:
@ -66,11 +66,11 @@ jobs:
run: docker build run: docker build
--no-cache --no-cache
--network=$(docker network ls | grep github | awk '{print $1}') --network=$(docker network ls | grep github | awk '{print $1}')
-t beryju/passbook-static:0.8.8-beta -t beryju/passbook-static:0.8.11-beta
-t beryju/passbook-static:latest -t beryju/passbook-static:latest
-f static.Dockerfile . -f static.Dockerfile .
- name: Push Docker Container to Registry (versioned) - name: Push Docker Container to Registry (versioned)
run: docker push beryju/passbook-static:0.8.8-beta run: docker push beryju/passbook-static:0.8.11-beta
- name: Push Docker Container to Registry (latest) - name: Push Docker Container to Registry (latest)
run: docker push beryju/passbook-static:latest run: docker push beryju/passbook-static:latest
test-release: test-release:

6
.isort.cfg Normal file
View File

@ -0,0 +1,6 @@
[settings]
multi_line_output=3
include_trailing_comma=True
force_grid_wrap=0
use_parentheses=True
line_length=88

View File

@ -7,7 +7,3 @@ const-rgx=[a-zA-Z0-9_]{1,40}$
ignored-modules=django-otp ignored-modules=django-otp
jobs=4 jobs=4
[SIMILARITIES]
# Minimum lines number of a similarity.
min-similarity-lines=20

View File

@ -23,6 +23,8 @@ services:
server: server:
image: beryju/passbook:${SERVER_TAG:-latest} image: beryju/passbook:${SERVER_TAG:-latest}
command: command:
- ./manage.py
- bootstrap
- uwsgi - uwsgi
- uwsgi.ini - uwsgi.ini
environment: environment:
@ -42,6 +44,8 @@ services:
worker: worker:
image: beryju/passbook:${SERVER_TAG:-latest} image: beryju/passbook:${SERVER_TAG:-latest}
command: command:
- ./manage.py
- bootstrap
- celery - celery
- worker - worker
- --autoscale=10,3 - --autoscale=10,3

View File

@ -1,6 +1,6 @@
apiVersion: v1 apiVersion: v1
appVersion: "0.8.8-beta" appVersion: "0.8.11-beta"
description: A Helm chart for passbook. description: A Helm chart for passbook.
name: passbook name: passbook
version: "0.8.8-beta" version: "0.8.11-beta"
icon: https://git.beryju.org/uploads/-/system/project/avatar/108/logo.png icon: https://git.beryju.org/uploads/-/system/project/avatar/108/logo.png

View File

@ -18,7 +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 k8s.passbook.io/component: web
spec: spec:
volumes: volumes:
- name: config-volume - name: config-volume
@ -27,9 +27,12 @@ spec:
initContainers: initContainers:
- name: passbook-database-migrations - name: passbook-database-migrations
image: "beryju/passbook:{{ .Values.image.tag }}" image: "beryju/passbook:{{ .Values.image.tag }}"
imagePullPolicy: Always
command: command:
- ./manage.py - ./manage.py
args: args:
- bootstrap
- ./manage.py
- migrate - migrate
volumeMounts: volumeMounts:
- mountPath: /etc/passbook - mountPath: /etc/passbook
@ -57,10 +60,12 @@ spec:
containers: containers:
- name: {{ .Chart.Name }} - name: {{ .Chart.Name }}
image: "beryju/passbook:{{ .Values.image.tag }}" image: "beryju/passbook:{{ .Values.image.tag }}"
imagePullPolicy: IfNotPresent imagePullPolicy: Always
command: command:
- uwsgi - ./manage.py
args: args:
- bootstrap
- uwsgi
- uwsgi.ini - uwsgi.ini
volumeMounts: volumeMounts:
- mountPath: /etc/passbook - mountPath: /etc/passbook

View File

@ -18,4 +18,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 k8s.passbook.io/component: web

View File

@ -18,7 +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 k8s.passbook.io/component: worker
spec: spec:
volumes: volumes:
- name: config-volume - name: config-volume
@ -29,8 +29,10 @@ spec:
image: "beryju/passbook:{{ .Values.image.tag }}" image: "beryju/passbook:{{ .Values.image.tag }}"
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
command: command:
- celery - ./manage.py
args: args:
- bootstrap
- celery
- worker - worker
- --autoscale=10,3 - --autoscale=10,3
- -E - -E

View File

@ -2,7 +2,7 @@
# This is a YAML-formatted file. # This is a YAML-formatted file.
# Declare variables to be passed into your templates. # Declare variables to be passed into your templates.
image: image:
tag: 0.8.8-beta tag: 0.8.11-beta
nameOverride: "" nameOverride: ""

View File

@ -1,2 +1,2 @@
"""passbook""" """passbook"""
__version__ = "0.8.8-beta" __version__ = "0.8.11-beta"

View File

@ -48,7 +48,7 @@ class YAMLField(forms.CharField):
def prepare_value(self, value): def prepare_value(self, value):
if isinstance(value, InvalidYAMLInput): if isinstance(value, InvalidYAMLInput):
return value return value
return yaml.dump(value, explicit_start=True) return yaml.dump(value, explicit_start=True, default_flow_style=False)
def has_changed(self, initial, data): def has_changed(self, initial, data):
if super().has_changed(initial, data): if super().has_changed(initial, data):

View File

@ -1,40 +0,0 @@
"""passbook form helpers"""
from django import forms
from passbook.admin.fields import YAMLField
class TagModelForm(forms.ModelForm):
"""Base form for models that have attributes"""
def __init__(self, *args, **kwargs):
# Check if we have an instance, load tags otherwise use an empty dict
instance = kwargs.get("instance", None)
tags = instance.tags if instance else {}
# Make sure all predefined tags exist in tags, and set default if they don't
predefined_tags = (
self._meta.model().get_predefined_tags() # pylint: disable=no-member
)
for key, value in predefined_tags.items():
if key not in tags:
tags[key] = value
# Format JSON
kwargs["initial"]["tags"] = tags
super().__init__(*args, **kwargs)
def clean_tags(self):
"""Make sure all required tags are set"""
if hasattr(self.instance, "get_required_keys") and hasattr(
self.instance, "tags"
):
for key in self.instance.get_required_keys():
if key not in self.cleaned_data.get("tags"):
raise forms.ValidationError("Tag %s missing." % key)
return self.cleaned_data.get("tags")
# pylint: disable=too-few-public-methods
class TagModelFormMeta:
"""Base Meta class that uses the YAMLField"""
field_classes = {"tags": YAMLField}

View File

@ -11,16 +11,22 @@
{% endblock %} {% endblock %}
{% block beneath_form %} {% block beneath_form %}
<p class="loading" style="display: none;"> <div class="pf-c-form__group pf-m-action" style="display: none;" id="loading">
<span class="spinner spinner-xs spinner-inline"></span> {% trans 'Processing, please wait...' %} <div class="pf-c-form__horizontal-group">
</p> <span class="pf-c-spinner" role="progressbar" aria-valuetext="Loading...">
<span class="pf-c-spinner__clipper"></span>
<span class="pf-c-spinner__lead-ball"></span>
<span class="pf-c-spinner__tail-ball"></span>
</span>
</div>
</div>
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
{{ block.super }} {{ block.super }}
<script> <script>
$('form').on('submit', function () { document.querySelector("form").addEventListener("submit", (e) => {
$('p.loading').show(); document.getElementById("loading").removeAttribute("style");
}) });
</script> </script>
{% endblock %} {% endblock %}

View File

@ -9,7 +9,11 @@ from django.shortcuts import redirect
from django.urls import reverse, reverse_lazy from django.urls import reverse, reverse_lazy
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from django.views.generic import DeleteView, DetailView, ListView, UpdateView from django.views.generic import DeleteView, DetailView, ListView, UpdateView
from guardian.mixins import PermissionListMixin, PermissionRequiredMixin from guardian.mixins import (
PermissionListMixin,
PermissionRequiredMixin,
get_anonymous_user,
)
from passbook.admin.forms.users import UserForm from passbook.admin.forms.users import UserForm
from passbook.core.models import Nonce, User from passbook.core.models import Nonce, User
@ -25,6 +29,9 @@ class UserListView(LoginRequiredMixin, PermissionListMixin, ListView):
paginate_by = 40 paginate_by = 40
template_name = "administration/user/list.html" template_name = "administration/user/list.html"
def get_queryset(self):
return super().get_queryset().exclude(pk=get_anonymous_user().pk)
class UserCreateView( class UserCreateView(
SuccessMessageMixin, SuccessMessageMixin,

View File

@ -33,11 +33,15 @@ def sanitize_dict(source: Dict[Any, Any]) -> Dict[Any, Any]:
source[key] = sanitize_dict(value) source[key] = sanitize_dict(value)
elif isinstance(value, models.Model): elif isinstance(value, models.Model):
model_content_type = ContentType.objects.get_for_model(value) model_content_type = ContentType.objects.get_for_model(value)
name = str(value)
if hasattr(value, "name"):
name = value.name
source[key] = sanitize_dict( source[key] = sanitize_dict(
{ {
"app": model_content_type.app_label, "app": model_content_type.app_label,
"name": model_content_type.model, "model_name": model_content_type.model,
"pk": value.pk, "pk": value.pk,
"name": name,
} }
) )
elif isinstance(value, UUID): elif isinstance(value, UUID):
@ -133,6 +137,7 @@ class Event(UUIDModel):
action=self.action, action=self.action,
context=self.context, context=self.context,
client_ip=self.client_ip, client_ip=self.client_ip,
user=self.user,
) )
return super().save(*args, **kwargs) return super().save(*args, **kwargs)

View File

@ -17,7 +17,7 @@ class PropertyMappingSerializer(ModelSerializer):
class Meta: class Meta:
model = PropertyMapping model = PropertyMapping
fields = ["pk", "name", "__type__"] fields = ["pk", "name", "expression", "__type__"]
class PropertyMappingViewSet(ReadOnlyModelViewSet): class PropertyMappingViewSet(ReadOnlyModelViewSet):

View File

@ -10,7 +10,8 @@ class ApplicationForm(forms.ModelForm):
"""Application Form""" """Application Form"""
provider = forms.ModelChoiceField( provider = forms.ModelChoiceField(
queryset=Provider.objects.all().select_subclasses(), required=False queryset=Provider.objects.all().order_by("pk").select_subclasses(),
required=False,
) )
class Meta: class Meta:

View File

@ -18,9 +18,11 @@ password_changed = Signal(providing_args=["user", "password"])
def invalidate_policy_cache(sender, instance, **_): def invalidate_policy_cache(sender, instance, **_):
"""Invalidate Policy cache when policy is updated""" """Invalidate Policy cache when policy is updated"""
from passbook.core.models import Policy from passbook.core.models import Policy
from passbook.policies.process import cache_key
if isinstance(instance, Policy): if isinstance(instance, Policy):
LOGGER.debug("Invalidating policy cache", policy=instance) LOGGER.debug("Invalidating policy cache", policy=instance)
keys = cache.keys("%s#*" % instance.pk) prefix = cache_key(instance) + "*"
keys = cache.keys(prefix)
cache.delete_many(keys) cache.delete_many(keys)
LOGGER.debug("Deleted %d keys", len(keys)) LOGGER.debug("Deleted %d keys", len(keys))

View File

@ -54,9 +54,10 @@
<a href="{{ source.url }}" class="pf-c-login__main-footer-links-item-link"> <a href="{{ source.url }}" class="pf-c-login__main-footer-links-item-link">
{% if source.icon_path %} {% if source.icon_path %}
<img src="{% static source.icon_path %}" alt="{{ source.name }}"> <img src="{% static source.icon_path %}" alt="{{ source.name }}">
{% endif %} {% elif source.icon_url %}
{% if source.icon_url %}
<img src="icon_url" alt="{{ source.name }}"> <img src="icon_url" alt="{{ source.name }}">
{% else %}
<i class="pf-icon pf-icon-arrow" title="{{ source.name }}"></i>
{% endif %} {% endif %}
</a> </a>
</li> </li>

View File

@ -4,25 +4,16 @@
{% load i18n %} {% load i18n %}
{% load utils %} {% load utils %}
{% block head %}
{{ block.super }}
<style>
.pf-icon {
font-size: 48px;
text-align: center;
}
</style>
{% endblock %}
{% block card %} {% block card %}
<header class="login-pf-header"> <form method="POST" class="pf-c-form">
<h1>{% trans title %}</h1>
</header>
<form method="POST">
{% csrf_token %} {% csrf_token %}
{% include 'partials/form.html' %} {% include 'partials/form.html' %}
<span class="pf-icon pficon-error-circle-o btn-block"></span> <div class="pf-c-form__group">
Access denied <p>
<i class="pf-icon pf-icon-error-circle-o"></i>
{% trans 'Access denied' %}
</p>
</div>
{% if 'back' in request.GET %} {% if 'back' in request.GET %}
<a href="{% back %}" class="btn btn-primary btn-block btn-lg">{% trans 'Back' %}</a> <a href="{% back %}" class="btn btn-primary btn-block btn-lg">{% trans 'Back' %}</a>
{% endif %} {% endif %}

View File

@ -44,13 +44,13 @@ class LoginView(UserPassesTestMixin, FormView):
kwargs["primary_action"] = _("Log in") kwargs["primary_action"] = _("Log in")
kwargs["show_sign_up_notice"] = CONFIG.y("passbook.sign_up.enabled") kwargs["show_sign_up_notice"] = CONFIG.y("passbook.sign_up.enabled")
kwargs["sources"] = [] kwargs["sources"] = []
sources = Source.objects.filter(enabled=True).select_subclasses() sources = (
Source.objects.filter(enabled=True).order_by("name").select_subclasses()
)
for source in sources: for source in sources:
ui_login_button = source.ui_login_button ui_login_button = source.ui_login_button
if ui_login_button: if ui_login_button:
kwargs["sources"].append(ui_login_button) kwargs["sources"].append(ui_login_button)
# if kwargs["sources"]:
# self.template_name = "login/with_sources.html"
return super().get_context_data(**kwargs) return super().get_context_data(**kwargs)
def get_user(self, uid_value) -> Optional[User]: def get_user(self, uid_value) -> Optional[User]:
@ -231,7 +231,6 @@ class PasswordResetView(View):
login(request, nonce.user) login(request, nonce.user)
nonce.delete() nonce.delete()
messages.success( messages.success(
request, request, _(("Temporarily authenticated, please change your password")),
_(("Temporarily authenticated with Nonce, " "please change your password")),
) )
return redirect("passbook_core:user-change-password") return redirect("passbook_core:user-change-password")

View File

@ -1,48 +0,0 @@
"""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)

9
passbook/lib/logging.py Normal file
View File

@ -0,0 +1,9 @@
"""logging helpers"""
from os import getpid
# pylint: disable=unused-argument
def add_process_id(logger, method_name, event_dict):
"""Add the current process ID"""
event_dict["pid"] = getpid()
return event_dict

View File

View File

@ -0,0 +1,66 @@
"""passbook management command to bootstrap"""
from argparse import REMAINDER
from subprocess import Popen # nosec
# pylint: disable=redefined-builtin
from sys import exit, stderr, stdin, stdout
from time import sleep
from django.core.management.base import BaseCommand
from django.db import connection
from django.db.utils import OperationalError
from django_redis import get_redis_connection
from redis.exceptions import ConnectionError as RedisConnectionError
from structlog import get_logger
LOGGER = get_logger()
class Command(BaseCommand):
"""Bootstrap passbook, ensure Database and Cache are
reachable, and directories are writeable"""
help = """Bootstrap passbook, ensure Database and Cache are
reachable, and directories are writeable"""
def add_arguments(self, parser):
parser.add_argument("command", nargs=REMAINDER)
def check_database(self) -> bool:
"""Return true if database is reachable, false otherwise"""
try:
connection.cursor()
LOGGER.info("Database reachable")
return True
except OperationalError:
LOGGER.info("Database unreachable")
return False
def check_cache(self) -> bool:
"""Return true if cache is reachable, false otherwise"""
try:
con = get_redis_connection("default")
con.ping()
LOGGER.info("Cache reachable")
return True
except RedisConnectionError:
LOGGER.info("Cache unreachable")
return False
def handle(self, *args, **options):
LOGGER.info("passbook bootstrapping...")
should_check = True
while should_check:
should_check = not (self.check_database() and self.check_cache())
sleep(1)
LOGGER.info("Dependencies are up, starting command...")
proc = Popen(
args=options.get("command"), stdout=stdout, stderr=stderr, stdin=stdin
) # nosec
try:
proc.wait()
exit(proc.returncode)
except KeyboardInterrupt:
LOGGER.info("Killing process")
proc.kill()
exit(254)

View File

@ -1,29 +1,28 @@
"""passbook sentry integration""" """passbook sentry integration"""
from billiard.exceptions import WorkerLostError
from botocore.client import ClientError
from django.core.exceptions import DisallowedHost, ValidationError
from django.db import InternalError, OperationalError, ProgrammingError
from django_redis.exceptions import ConnectionInterrupted
from redis.exceptions import RedisError
from rest_framework.exceptions import APIException
from structlog import get_logger from structlog import get_logger
LOGGER = get_logger() LOGGER = get_logger()
class SentryIgnoredException(Exception): class SentryIgnoredException(Exception):
"""Base Class for all errors that are supressed, and not sent to sentry.""" """Base Class for all errors that are suppressed, and not sent to sentry."""
def before_send(event, hint): def before_send(event, hint):
"""Check if error is database error, and ignore if so""" """Check if error is database error, and ignore if so"""
from django_redis.exceptions import ConnectionInterrupted
from django.db import OperationalError, InternalError
from django.core.exceptions import ValidationError
from rest_framework.exceptions import APIException
from billiard.exceptions import WorkerLostError
from django.core.exceptions import DisallowedHost
from botocore.client import ClientError
from redis.exceptions import RedisError
ignored_classes = ( ignored_classes = (
OperationalError, OperationalError,
InternalError,
ProgrammingError,
ConnectionInterrupted, ConnectionInterrupted,
APIException, APIException,
InternalError,
ConnectionResetError, ConnectionResetError,
WorkerLostError, WorkerLostError,
DisallowedHost, DisallowedHost,

View File

@ -19,7 +19,7 @@ LOGGER = get_logger()
class Evaluator: class Evaluator:
"""Validate and evaulate jinja2-based expressions""" """Validate and evaluate jinja2-based expressions"""
_env: NativeEnvironment _env: NativeEnvironment
@ -51,11 +51,12 @@ class Evaluator:
"""Return dictionary with additional global variables passed to expression""" """Return dictionary with additional global variables passed to expression"""
# update passbook/policies/expression/templates/policy/expression/form.html # update passbook/policies/expression/templates/policy/expression/form.html
# update docs/policies/expression/index.md # update docs/policies/expression/index.md
kwargs["pb_is_group_member"] = Evaluator.jinja2_func_is_group_member
kwargs["pb_logger"] = get_logger()
if request.http_request:
kwargs["pb_is_sso_flow"] = request.http_request.session.get( kwargs["pb_is_sso_flow"] = request.http_request.session.get(
AuthenticationView.SESSION_IS_SSO_LOGIN, False AuthenticationView.SESSION_IS_SSO_LOGIN, False
) )
kwargs["pb_is_group_member"] = Evaluator.jinja2_func_is_group_member
kwargs["pb_logger"] = get_logger()
kwargs["pb_client_ip"] = ( kwargs["pb_client_ip"] = (
get_client_ip(request.http_request) or "255.255.255.255" get_client_ip(request.http_request) or "255.255.255.255"
) )
@ -81,7 +82,7 @@ class Evaluator:
req=request, req=request,
) )
return PolicyResult(False) return PolicyResult(False)
if isinstance(result, list) and len(result) == 2: if isinstance(result, (list, tuple)) and len(result) == 2:
return PolicyResult(*result) return PolicyResult(*result)
if result: if result:
return PolicyResult(result) return PolicyResult(result)

View File

@ -9,7 +9,7 @@
<p> <p>
Expression using <a href="https://jinja.palletsprojects.com/en/2.11.x/templates/">Jinja</a>. Following variables are available: Expression using <a href="https://jinja.palletsprojects.com/en/2.11.x/templates/">Jinja</a>. Following variables are available:
</p> </p>
<ul> <ul class="pf-c-list">
<li><code>request.user</code>: Passbook User Object (<a href="https://beryju.github.io/passbook/property-mappings/reference/user-object/">Reference</a>)</li> <li><code>request.user</code>: Passbook User Object (<a href="https://beryju.github.io/passbook/property-mappings/reference/user-object/">Reference</a>)</li>
<li><code>request.http_request</code>: Django HTTP Request Object (<a href="https://docs.djangoproject.com/en/3.0/ref/request-response/#httprequest-objects">Reference</a>) </li> <li><code>request.http_request</code>: Django HTTP Request Object (<a href="https://docs.djangoproject.com/en/3.0/ref/request-response/#httprequest-objects">Reference</a>) </li>
<li><code>request.obj</code>: Model the Policy is run against. </li> <li><code>request.obj</code>: Model the Policy is run against. </li>
@ -19,7 +19,7 @@
<li><code>pb_client_ip</code>: Client's IP Address.</li> <li><code>pb_client_ip</code>: Client's IP Address.</li>
</ul> </ul>
<p>Custom Filters:</p> <p>Custom Filters:</p>
<ul> <ul class="pf-c-list">
<li><code>regex_match(regex)</code>: Checks if value matches <code>regex</code></li> <li><code>regex_match(regex)</code>: Checks if value matches <code>regex</code></li>
<li><code>regex_replace(regex, repl)</code>: Replace string matched by <code>regex</code> with <code>repl</code></li> <li><code>regex_replace(regex, repl)</code>: Replace string matched by <code>regex</code> with <code>repl</code></li>
</ul> </ul>

View File

@ -0,0 +1,58 @@
"""evaluator tests"""
from django.core.exceptions import ValidationError
from django.test import TestCase
from guardian.shortcuts import get_anonymous_user
from passbook.policies.expression.evaluator import Evaluator
from passbook.policies.types import PolicyRequest
class TestEvaluator(TestCase):
"""Evaluator tests"""
def setUp(self):
self.request = PolicyRequest(user=get_anonymous_user())
def test_valid(self):
"""test simple value expression"""
template = "True"
evaluator = Evaluator()
self.assertEqual(evaluator.evaluate(template, self.request).passing, True)
def test_messages(self):
"""test expression with message return"""
template = "False, 'some message'"
evaluator = Evaluator()
result = evaluator.evaluate(template, self.request)
self.assertEqual(result.passing, False)
self.assertEqual(result.messages, ("some message",))
def test_invalid_syntax(self):
"""test invalid syntax"""
template = "{%"
evaluator = Evaluator()
result = evaluator.evaluate(template, self.request)
self.assertEqual(result.passing, False)
self.assertEqual(result.messages, ("tag name expected",))
def test_undefined(self):
"""test undefined result"""
template = "{{ foo.bar }}"
evaluator = Evaluator()
result = evaluator.evaluate(template, self.request)
self.assertEqual(result.passing, False)
self.assertEqual(result.messages, ("'foo' is undefined",))
def test_validate(self):
"""test validate"""
template = "True"
evaluator = Evaluator()
result = evaluator.validate(template)
self.assertEqual(result, True)
def test_validate_invalid(self):
"""test validate"""
template = "{%"
evaluator = Evaluator()
with self.assertRaises(ValidationError):
evaluator.validate(template)

View File

@ -5,16 +5,19 @@ from multiprocessing.connection import Connection
from django.core.cache import cache from django.core.cache import cache
from structlog import get_logger from structlog import get_logger
from passbook.core.models import Policy from passbook.core.models import Policy, User
from passbook.policies.exceptions import PolicyException from passbook.policies.exceptions import PolicyException
from passbook.policies.types import PolicyRequest, PolicyResult from passbook.policies.types import PolicyRequest, PolicyResult
LOGGER = get_logger() LOGGER = get_logger()
def cache_key(policy, user): def cache_key(policy: Policy, user: User = None) -> str:
"""Generate Cache key for policy""" """Generate Cache key for policy"""
return f"policy_{policy.pk}#{user.pk}" prefix = f"policy_{policy.pk}"
if user:
prefix += f"#{user.pk}"
return prefix
class PolicyProcess(Process): class PolicyProcess(Process):
@ -33,7 +36,7 @@ class PolicyProcess(Process):
def run(self): def run(self):
"""Task wrapper to run policy checking""" """Task wrapper to run policy checking"""
LOGGER.debug( LOGGER.debug(
"Running policy", "P_ENG(proc): Running policy",
policy=self.policy, policy=self.policy,
user=self.request.user, user=self.request.user,
process="PolicyProcess", process="PolicyProcess",
@ -41,13 +44,13 @@ class PolicyProcess(Process):
try: try:
policy_result = self.policy.passes(self.request) policy_result = self.policy.passes(self.request)
except PolicyException as exc: except PolicyException as exc:
LOGGER.debug(exc) LOGGER.debug("P_ENG(proc): error", exc=exc)
policy_result = PolicyResult(False, str(exc)) policy_result = PolicyResult(False, str(exc))
# Invert result if policy.negate is set # Invert result if policy.negate is set
if self.policy.negate: if self.policy.negate:
policy_result.passing = not policy_result.passing policy_result.passing = not policy_result.passing
LOGGER.debug( LOGGER.debug(
"Got result", "P_ENG(proc): Finished",
policy=self.policy, policy=self.policy,
result=policy_result, result=policy_result,
process="PolicyProcess", process="PolicyProcess",
@ -56,5 +59,5 @@ class PolicyProcess(Process):
) )
key = cache_key(self.policy, self.request.user) key = cache_key(self.policy, self.request.user)
cache.set(key, policy_result) cache.set(key, policy_result)
LOGGER.debug("Cached policy evaluation", key=key) LOGGER.debug("P_ENG(proc): Cached policy evaluation", key=key)
self.connection.send(policy_result) self.connection.send(policy_result)

View File

@ -1,7 +1,7 @@
"""policy structures""" """policy structures"""
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING, Tuple from typing import TYPE_CHECKING, Optional, Tuple
from django.db.models import Model from django.db.models import Model
from django.http import HttpRequest from django.http import HttpRequest
@ -14,11 +14,13 @@ class PolicyRequest:
"""Data-class to hold policy request data""" """Data-class to hold policy request data"""
user: User user: User
http_request: HttpRequest http_request: Optional[HttpRequest]
obj: Model obj: Optional[Model]
def __init__(self, user: User): def __init__(self, user: User):
self.user = user self.user = user
self.http_request = None
self.obj = None
def __str__(self): def __str__(self):
return f"<PolicyRequest user={self.user}>" return f"<PolicyRequest user={self.user}>"

View File

@ -3,15 +3,12 @@
{% load utils %} {% load utils %}
{% load i18n %} {% load i18n %}
{% block title %} {% block card_title %}
{% title 'Authorize Application' %} {% title 'Authorize Application' %}
{% endblock %} {% endblock %}
{% block card %} {% block card %}
<header class="login-pf-header"> <form method="POST" class="pf-c-form">
<h1>{% trans 'Authorize Application' %}</h1>
</header>
<form method="POST">
{% csrf_token %} {% csrf_token %}
{% if not error %} {% if not error %}
{% csrf_token %} {% csrf_token %}
@ -20,32 +17,40 @@
{{ field }} {{ field }}
{% endif %} {% endif %}
{% endfor %} {% endfor %}
<div class="form-group"> <div class="pf-c-form__group">
<p class="subtitle"> <p class="subtitle">
{% blocktrans with remote=application.name %} {% blocktrans with remote=application.name %}
You're about to sign into {{ remote }} You're about to sign into {{ remote }}.
{% endblocktrans %} {% endblocktrans %}
</p> </p>
<p>{% trans "Application requires following permissions" %}</p> <p>{% trans "Application requires following permissions" %}</p>
<ul> <ul class="pf-c-list">
{% for scope in scopes_descriptions %} {% for scope in scopes_descriptions %}
<li>{{ scope }}</li> <li>{{ scope }}</li>
{% endfor %} {% endfor %}
</ul> </ul>
{{ form.errors }} {{ form.errors }}
{{ form.non_field_errors }} {{ form.non_field_errors }}
</div>
<div class="pf-c-form__group">
<p> <p>
{% blocktrans with user=user %} {% blocktrans with user=user %}
You are logged in as {{ user }}. Not you? You are logged in as {{ user }}. Not you?
{% endblocktrans %} {% endblocktrans %}
<a href="{% url 'passbook_core:auth-logout' %}">{% trans 'Logout' %}</a> <a href="{% url 'passbook_core:auth-logout' %}">{% trans 'Logout' %}</a>
</p> </p>
<div class="form-group">
<input type="submit" class="btn btn-success btn-disabled btn-lg click-spinner" name="allow" value="{% trans 'Continue' %}">
<a href="{% back %}" class="btn btn-default btn-lg">{% trans "Cancel" %}</a>
</div> </div>
<div class="form-group spinner-hidden hidden"> <div class="pf-c-form__group pf-m-action">
<div class="spinner"></div> <input type="submit" class="pf-c-button pf-m-primary" name="allow" value="{% trans 'Continue' %}">
<a href="{% back %}" class="pf-c-button pf-m-secondary">{% trans "Cancel" %}</a>
</div>
<div class="pf-c-form__group" style="display: none;" id="loading">
<div class="pf-c-form__horizontal-group">
<span class="pf-c-spinner" role="progressbar" aria-valuetext="Loading...">
<span class="pf-c-spinner__clipper"></span>
<span class="pf-c-spinner__lead-ball"></span>
<span class="pf-c-spinner__tail-ball"></span>
</span>
</div> </div>
</div> </div>
{% else %} {% else %}
@ -61,9 +66,8 @@
{% block scripts %} {% block scripts %}
<script> <script>
$('.click-spinner').on('click', function (e) { document.querySelector("form").addEventListener("submit", (e) => {
$('.spinner-hidden').removeClass('hidden'); document.getElementById("loading").removeAttribute("style");
$(e.target).addClass('disabled'); });
})
</script> </script>
{% endblock %} {% endblock %}

View File

@ -19,6 +19,8 @@ class OIDCProviderForm(forms.ModelForm):
self.fields["client_secret"].initial = generate_client_secret() self.fields["client_secret"].initial = generate_client_secret()
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
self.instance.reuse_consent = False # This is managed by passbook
self.instance.require_consent = True # This is managed by passbook
response = super().save(*args, **kwargs) response = super().save(*args, **kwargs)
# Check if openidprovider class instance exists # Check if openidprovider class instance exists
if not OpenIDProvider.objects.filter(oidc_client=self.instance).exists(): if not OpenIDProvider.objects.filter(oidc_client=self.instance).exists():

View File

@ -3,15 +3,12 @@
{% load utils %} {% load utils %}
{% load i18n %} {% load i18n %}
{% block title %} {% block card_title %}
{% title 'Authorize Application' %} {% trans 'Authorize Application' %}
{% endblock %} {% endblock %}
{% block card %} {% block card %}
<header class="login-pf-header"> <form method="POST" class="pf-c-form">
<h1>{% trans 'Authorize Application' %}</h1>
</header>
<form method="POST">
{% csrf_token %} {% csrf_token %}
{% if not error %} {% if not error %}
{% csrf_token %} {% csrf_token %}
@ -20,14 +17,14 @@
{{ field }} {{ field }}
{% endif %} {% endif %}
{% endfor %} {% endfor %}
<div class="form-group"> <div class="pf-c-form__group">
<p class="subtitle"> <p class="subtitle">
{% blocktrans with remote=client.name %} {% blocktrans with remote=client.name %}
You're about to sign into {{ remote }} You're about to sign into {{ remote }}.
{% endblocktrans %} {% endblocktrans %}
</p> </p>
<p>{% trans "Application requires following permissions" %}</p> <p>{% trans "Application requires following permissions" %}</p>
<ul> <ul class="pf-c-list">
{% for scope in scopes %} {% for scope in scopes %}
<li>{{ scope.name }}</li> <li>{{ scope.name }}</li>
{% endfor %} {% endfor %}
@ -35,18 +32,26 @@
{{ hidden_inputs }} {{ hidden_inputs }}
{{ form.errors }} {{ form.errors }}
{{ form.non_field_errors }} {{ form.non_field_errors }}
</div>
<div class="pf-c-form__group">
<p> <p>
{% blocktrans with user=user %} {% blocktrans with user=user %}
You are logged in as {{ user }}. Not you? You are logged in as {{ user }}. Not you?
{% endblocktrans %} {% endblocktrans %}
<a href="{% url 'passbook_core:auth-logout' %}">{% trans 'Logout' %}</a> <a href="{% url 'passbook_core:auth-logout' %}">{% trans 'Logout' %}</a>
</p> </p>
<div class="form-group">
<input type="submit" class="btn btn-success btn-disabled btn-lg click-spinner" name="allow" value="{% trans 'Continue' %}">
<a href="{% back %}" class="btn btn-default btn-lg">{% trans "Cancel" %}</a>
</div> </div>
<div class="form-group spinner-hidden hidden"> <div class="pf-c-form__group pf-m-action">
<div class="spinner"></div> <input type="submit" class="pf-c-button pf-m-primary" name="allow" value="{% trans 'Continue' %}">
<a href="{% back %}" class="pf-c-button pf-m-secondary">{% trans "Cancel" %}</a>
</div>
<div class="pf-c-form__group" style="display: none;" id="loading">
<div class="pf-c-form__horizontal-group">
<span class="pf-c-spinner" role="progressbar" aria-valuetext="Loading...">
<span class="pf-c-spinner__clipper"></span>
<span class="pf-c-spinner__lead-ball"></span>
<span class="pf-c-spinner__tail-ball"></span>
</span>
</div> </div>
</div> </div>
{% else %} {% else %}
@ -62,9 +67,8 @@
{% block scripts %} {% block scripts %}
<script> <script>
$('.click-spinner').on('click', function (e) { document.querySelector("form").addEventListener("submit", (e) => {
$('.spinner-hidden').removeClass('hidden'); document.getElementById("loading").removeAttribute("style");
$(e.target).addClass('disabled'); });
})
</script> </script>
{% endblock %} {% endblock %}

View File

@ -0,0 +1,18 @@
{% extends 'login/base.html' %}
{% load static %}
{% load i18n %}
{% load utils %}
{% block card_title %}
{% trans error %}
{% endblock %}
{% block card %}
<form>
<h3>{% trans description %}</h3>
{% if 'back' in request.GET %}
<a href="{% back %}" class="btn btn-primary btn-block btn-lg">{% trans 'Back' %}</a>
{% endif %}
</form>
{% endblock %}

View File

@ -184,7 +184,7 @@ class Processor:
try: try:
self._extract_saml_request() self._extract_saml_request()
except KeyError: except KeyError:
raise CannotHandleAssertion(f"Couldn't find SAML request in user session:") raise CannotHandleAssertion(f"Couldn't find SAML request in user session")
try: try:
self._decode_and_parse_request() self._decode_and_parse_request()

View File

@ -3,23 +3,17 @@
{% load utils %} {% load utils %}
{% load i18n %} {% load i18n %}
{% block title %} {% block card_title %}
{% title 'Redirecting...' %} {% title 'Redirecting...' %}
{% endblock %} {% endblock %}
{% block card %} {% block card %}
<header class="login-pf-header">
<h1>{% trans 'Redirecting...' %}</h1>
</header>
<form method="POST" action="{{ url }}"> <form method="POST" action="{{ url }}">
{% csrf_token %} {% csrf_token %}
{% for key, value in attrs.items %} {% for key, value in attrs.items %}
<input type="hidden" name="{{ key }}" value="{{ value }}"> <input type="hidden" name="{{ key }}" value="{{ value }}">
{% endfor %} {% endfor %}
<div class="login-group"> <div class="login-group">
<h3>
{% trans "Redirecting..." %}
</h3>
<p> <p>
{% blocktrans with user=user %} {% blocktrans with user=user %}
You are logged in as {{ user }}. You are logged in as {{ user }}.
@ -34,6 +28,6 @@
{% block scripts %} {% block scripts %}
{{ block.super }} {{ block.super }}
<script> <script>
$('form').submit(); document.querySelector("form").submit();
</script> </script>
{% endblock %} {% endblock %}

View File

@ -4,11 +4,8 @@
{% load i18n %} {% load i18n %}
{% block card %} {% block card %}
<form method="POST" class="pf-c-form" action="{{ saml_params.acs_url }}"> <form method="POST" class="pf-c-form">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="ACSUrl" value="{{ saml_params.acs_url }}">
<input type="hidden" name="RelayState" value="{{ saml_params.relay_state }}" />
<input type="hidden" name="SAMLResponse" value="{{ saml_params.saml_response }}" />
<div class="pf-c-form__group"> <div class="pf-c-form__group">
<h3> <h3>
{% blocktrans with provider=provider.application.name %} {% blocktrans with provider=provider.application.name %}

View File

@ -8,7 +8,7 @@
<div class="c-form__horizontal-group"> <div class="c-form__horizontal-group">
<p> <p>
Expression using <a href="https://jinja.palletsprojects.com/en/2.11.x/templates/">Jinja</a>. Following variables are available: Expression using <a href="https://jinja.palletsprojects.com/en/2.11.x/templates/">Jinja</a>. Following variables are available:
<ul> <ul class="pf-c-list">
<li><code>user</code>: Passbook User Object (<a href="https://beryju.github.io/passbook/reference/property-mappings/user-object/">Reference</a>)</li> <li><code>user</code>: Passbook User Object (<a href="https://beryju.github.io/passbook/reference/property-mappings/user-object/">Reference</a>)</li>
<li><code>request</code>: Django HTTP Request Object (<a href="https://docs.djangoproject.com/en/3.0/ref/request-response/#httprequest-objects">Reference</a>) </li> <li><code>request</code>: Django HTTP Request Object (<a href="https://docs.djangoproject.com/en/3.0/ref/request-response/#httprequest-objects">Reference</a>) </li>
<li><code>provider</code>: Passbook SAML Provider Object (<a href="https://github.com/BeryJu/passbook/blob/master/passbook/providers/saml/models.py#L16">Reference</a>) </li> <li><code>provider</code>: Passbook SAML Provider Object (<a href="https://github.com/BeryJu/passbook/blob/master/passbook/providers/saml/models.py#L16">Reference</a>) </li>

View File

@ -1,7 +1,5 @@
<saml:Subject> <saml:Subject>
<saml:NameID Format="{{ SUBJECT_FORMAT }}"> <saml:NameID Format="{{ SUBJECT_FORMAT }}">{{ SUBJECT }}</saml:NameID>
{{ SUBJECT }}
</saml:NameID>
<saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer"> <saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
<saml:SubjectConfirmationData {{ IN_RESPONSE_TO|safe }} NotOnOrAfter="{{ NOT_ON_OR_AFTER }}" Recipient="{{ ACS_URL }}" /> <saml:SubjectConfirmationData {{ IN_RESPONSE_TO|safe }} NotOnOrAfter="{{ NOT_ON_OR_AFTER }}" Recipient="{{ ACS_URL }}" />
</saml:SubjectConfirmation> </saml:SubjectConfirmation>

View File

@ -16,9 +16,9 @@ urlpatterns = [
"<slug:application>/login/", views.LoginBeginView.as_view(), name="saml-login" "<slug:application>/login/", views.LoginBeginView.as_view(), name="saml-login"
), ),
path( path(
"<slug:application>/login/process/", "<slug:application>/login/authorize/",
views.LoginProcessView.as_view(), views.AuthorizeView.as_view(),
name="saml-login-process", name="saml-login-authorize",
), ),
path("<slug:application>/logout/", views.LogoutView.as_view(), name="saml-logout"), path("<slug:application>/logout/", views.LogoutView.as_view(), name="saml-logout"),
path( path(

View File

@ -21,12 +21,16 @@ from passbook.core.models import Application, Provider
from passbook.lib.utils.template import render_to_string from passbook.lib.utils.template import render_to_string
from passbook.lib.views import bad_request_message from passbook.lib.views import bad_request_message
from passbook.policies.engine import PolicyEngine from passbook.policies.engine import PolicyEngine
from passbook.providers.saml import exceptions from passbook.providers.saml.exceptions import CannotHandleAssertion
from passbook.providers.saml.models import SAMLProvider from passbook.providers.saml.models import SAMLProvider
from passbook.providers.saml.processors.types import SAMLResponseParams from passbook.providers.saml.processors.types import SAMLResponseParams
LOGGER = get_logger() LOGGER = get_logger()
URL_VALIDATOR = URLValidator(schemes=("http", "https")) URL_VALIDATOR = URLValidator(schemes=("http", "https"))
SESSION_KEY_SAML_REQUEST = "SAMLRequest"
SESSION_KEY_SAML_RESPONSE = "SAMLResponse"
SESSION_KEY_RELAY_STATE = "RelayState"
SESSION_KEY_PARAMS = "SAMLParams"
class AccessRequiredView(AccessMixin, View): class AccessRequiredView(AccessMixin, View):
@ -50,14 +54,18 @@ class AccessRequiredView(AccessMixin, View):
def _has_access(self) -> bool: def _has_access(self) -> bool:
"""Check if user has access to application""" """Check if user has access to application"""
LOGGER.debug(
"_has_access", user=self.request.user, app=self.provider.application
)
policy_engine = PolicyEngine( policy_engine = PolicyEngine(
self.provider.application.policies.all(), self.request.user, self.request self.provider.application.policies.all(), self.request.user, self.request
) )
policy_engine.build() policy_engine.build()
return policy_engine.passing passing = policy_engine.passing
LOGGER.debug(
"saml_has_access",
user=self.request.user,
app=self.provider.application,
passing=passing,
)
return passing
def dispatch(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: def dispatch(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
if not request.user.is_authenticated: if not request.user.is_authenticated:
@ -75,80 +83,29 @@ 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."""
@method_decorator(csrf_exempt) def handler(self, source, application: str) -> HttpResponse:
def dispatch(self, request: HttpRequest, application: str) -> HttpResponse: """Handle SAML Request whether its a POST or a Redirect binding"""
if request.method == "POST":
source = request.POST
else:
source = request.GET
# Store these values now, because Django's login cycle won't preserve them. # Store these values now, because Django's login cycle won't preserve them.
try: try:
request.session["SAMLRequest"] = source["SAMLRequest"] self.request.session[SESSION_KEY_SAML_REQUEST] = source[
SESSION_KEY_SAML_REQUEST
]
except (KeyError, MultiValueDictKeyError): except (KeyError, MultiValueDictKeyError):
return bad_request_message(request, "The SAML request payload is missing.") return bad_request_message(
self.request, "The SAML request payload is missing."
request.session["RelayState"] = source.get("RelayState", "")
return redirect(
reverse(
"passbook_providers_saml:saml-login-process",
kwargs={"application": application},
)
) )
self.request.session[SESSION_KEY_RELAY_STATE] = source.get(
class LoginProcessView(AccessRequiredView): SESSION_KEY_RELAY_STATE, ""
"""Processor-based login continuation.
Presents a SAML 2.0 Assertion for POSTing back to the Service Provider."""
def handle_redirect(
self, params: SAMLResponseParams, skipped_authorization: bool
) -> HttpResponse:
"""Handle direct redirect to SP"""
# Log Application Authorization
Event.new(
EventAction.AUTHORIZE_APPLICATION,
authorized_application=self.provider.application,
skipped_authorization=skipped_authorization,
).from_http(self.request)
return render(
self.request,
"saml/idp/autosubmit_form.html",
{
"url": params.acs_url,
"attrs": {
"SAMLResponse": params.saml_response,
"RelayState": params.relay_state,
},
},
) )
def get(self, request: HttpRequest, application: str) -> HttpResponse:
"""Handle get request, i.e. render form"""
# User access gets checked in dispatch
# Otherwise we generate the IdP initiated session
try: try:
# application.skip_authorization is set so we directly redirect the user self.provider.processor.can_handle(self.request)
if self.provider.application.skip_authorization:
return self.post(request, application)
self.provider.processor.init_deep_link(request)
params = self.provider.processor.generate_response() params = self.provider.processor.generate_response()
self.request.session[SESSION_KEY_PARAMS] = params
return render( except CannotHandleAssertion as exc:
request, LOGGER.info(exc)
"saml/idp/login.html", did_you_mean_link = self.request.build_absolute_uri(
{
"saml_params": params,
"provider": self.provider,
"title": "Authorize Application",
},
)
except exceptions.CannotHandleAssertion as exc:
LOGGER.error(exc)
did_you_mean_link = request.build_absolute_uri(
reverse( reverse(
"passbook_providers_saml:saml-login-initiate", "passbook_providers_saml:saml-login-initiate",
kwargs={"application": application}, kwargs={"application": application},
@ -158,19 +115,97 @@ class LoginProcessView(AccessRequiredView):
f" Did you mean to go <a href='{did_you_mean_link}'>here</a>?" f" Did you mean to go <a href='{did_you_mean_link}'>here</a>?"
) )
return bad_request_message( return bad_request_message(
request, mark_safe(str(exc) + did_you_mean_message) self.request, mark_safe(str(exc) + did_you_mean_message)
) )
return redirect(
reverse(
"passbook_providers_saml:saml-login-authorize",
kwargs={"application": application},
)
)
@method_decorator(csrf_exempt)
def get(self, request: HttpRequest, application: str) -> HttpResponse:
"""Handle REDIRECT bindings"""
return self.handler(request.GET, application)
@method_decorator(csrf_exempt)
def post(self, request: HttpRequest, application: str) -> HttpResponse:
"""Handle POST Bindings"""
return self.handler(request.POST, application)
class InitiateLoginView(AccessRequiredView):
"""IdP-initiated Login"""
def get(self, request: HttpRequest, application: str) -> HttpResponse:
"""Initiates an IdP-initiated link to a simple SP resource/target URL."""
self.provider.processor.is_idp_initiated = True
self.provider.processor.init_deep_link(request)
params = self.provider.processor.generate_response()
request.session[SESSION_KEY_PARAMS] = params
return redirect(
reverse(
"passbook_providers_saml:saml-login-authorize",
kwargs={"application": application},
)
)
class AuthorizeView(AccessRequiredView):
"""Ask the user for authorization to continue to the SP.
Presents a SAML 2.0 Assertion for POSTing back to the Service Provider."""
def get(self, request: HttpRequest, application: str) -> HttpResponse:
"""Handle get request, i.e. render form"""
# User access gets checked in dispatch
# Otherwise we generate the IdP initiated session
try:
# application.skip_authorization is set so we directly redirect the user
if self.provider.application.skip_authorization:
LOGGER.debug("skipping authz", application=self.provider.application)
return self.post(request, application)
return render(
request,
"saml/idp/login.html",
{"provider": self.provider, "title": "Authorize Application",},
)
except KeyError:
return bad_request_message(request, "Missing SAML Payload")
# pylint: disable=unused-argument # pylint: disable=unused-argument
def post(self, request: HttpRequest, application: str) -> HttpResponse: def post(self, request: HttpRequest, application: str) -> HttpResponse:
"""Handle post request, return back to ACS""" """Handle post request, return back to ACS"""
# User access gets checked in dispatch # User access gets checked in dispatch
# we get here when skip_authorization is False, and after the user accepted # we get here when skip_authorization is True, and after the user accepted
# the authorization form # the authorization form
self.provider.processor.can_handle(request) # Log Application Authorization
saml_params = self.provider.processor.generate_response() Event.new(
return self.handle_redirect(saml_params, True) EventAction.AUTHORIZE_APPLICATION,
authorized_application=self.provider.application,
skipped_authorization=self.provider.application.skip_authorization,
).from_http(self.request)
self.request.session.pop(SESSION_KEY_SAML_REQUEST, None)
self.request.session.pop(SESSION_KEY_SAML_RESPONSE, None)
self.request.session.pop(SESSION_KEY_RELAY_STATE, None)
response: SAMLResponseParams = self.request.session.pop(SESSION_KEY_PARAMS)
return render(
self.request,
"saml/idp/autosubmit_form.html",
{
"url": response.acs_url,
"attrs": {
"ACSUrl": response.acs_url,
SESSION_KEY_SAML_RESPONSE: response.saml_response,
SESSION_KEY_RELAY_STATE: response.relay_state,
},
},
)
@method_decorator(csrf_exempt, name="dispatch") @method_decorator(csrf_exempt, name="dispatch")
@ -204,7 +239,9 @@ class SLOLogout(AccessRequiredView):
# pylint: disable=unused-argument # pylint: disable=unused-argument
def post(self, request: HttpRequest, application: str) -> HttpResponse: def post(self, request: HttpRequest, application: str) -> HttpResponse:
"""Perform logout""" """Perform logout"""
request.session["SAMLRequest"] = request.POST["SAMLRequest"] request.session[SESSION_KEY_SAML_REQUEST] = request.POST[
SESSION_KEY_SAML_REQUEST
]
# TODO: Parse SAML LogoutRequest from POST data, similar to login_process(). # TODO: Parse SAML LogoutRequest from POST data, similar to login_process().
# TODO: Modify the base processor to handle logouts? # TODO: Modify the base processor to handle logouts?
# TODO: Combine this with login_process(), since they are so very similar? # TODO: Combine this with login_process(), since they are so very similar?
@ -259,54 +296,7 @@ class DescriptorDownloadView(AccessRequiredView):
) )
else: else:
response = HttpResponse(metadata, content_type="application/xml") response = HttpResponse(metadata, content_type="application/xml")
response["Content-Disposition"] = ( response[
'attachment; filename="' '%s_passbook_meta.xml"' % self.provider.name "Content-Disposition"
) ] = f'attachment; filename="{self.provider.name}_passbook_meta.xml"'
return response return response
class InitiateLoginView(AccessRequiredView):
"""IdP-initiated Login"""
def handle_redirect(
self, params: SAMLResponseParams, skipped_authorization: bool
) -> HttpResponse:
"""Handle direct redirect to SP"""
# Log Application Authorization
Event.new(
EventAction.AUTHORIZE_APPLICATION,
authorized_application=self.provider.application,
skipped_authorization=skipped_authorization,
).from_http(self.request)
return render(
self.request,
"saml/idp/autosubmit_form.html",
{
"url": params.acs_url,
"attrs": {
"SAMLResponse": params.saml_response,
"RelayState": params.relay_state,
},
},
)
# pylint: disable=unused-argument
def get(self, request: HttpRequest, application: str) -> HttpResponse:
"""Initiates an IdP-initiated link to a simple SP resource/target URL."""
self.provider.processor.is_idp_initiated = True
self.provider.processor.init_deep_link(request)
params = self.provider.processor.generate_response()
# IdP-initiated Login Flow
if self.provider.application.skip_authorization:
return self.handle_redirect(params, True)
return render(
request,
"saml/idp/login.html",
{
"saml_params": params,
"provider": self.provider,
"title": "Authorize Application",
},
)

View File

@ -22,6 +22,7 @@ from sentry_sdk.integrations.django import DjangoIntegration
from passbook import __version__ from passbook import __version__
from passbook.lib.config import CONFIG from passbook.lib.config import CONFIG
from passbook.lib.logging import add_process_id
from passbook.lib.sentry import before_send from passbook.lib.sentry import before_send
LOGGER = structlog.get_logger() LOGGER = structlog.get_logger()
@ -53,6 +54,7 @@ if DEBUG:
CSRF_COOKIE_NAME = "passbook_csrf_debug" CSRF_COOKIE_NAME = "passbook_csrf_debug"
LANGUAGE_COOKIE_NAME = "passbook_language_debug" LANGUAGE_COOKIE_NAME = "passbook_language_debug"
SESSION_COOKIE_NAME = "passbook_session_debug" SESSION_COOKIE_NAME = "passbook_session_debug"
SESSION_COOKIE_SAMESITE = None
else: else:
CSRF_COOKIE_NAME = "passbook_csrf" CSRF_COOKIE_NAME = "passbook_csrf"
LANGUAGE_COOKIE_NAME = "passbook_language" LANGUAGE_COOKIE_NAME = "passbook_language"
@ -278,6 +280,7 @@ structlog.configure_once(
processors=[ processors=[
structlog.stdlib.add_log_level, structlog.stdlib.add_log_level,
structlog.stdlib.add_logger_name, structlog.stdlib.add_logger_name,
add_process_id,
structlog.stdlib.PositionalArgumentsFormatter(), structlog.stdlib.PositionalArgumentsFormatter(),
structlog.processors.TimeStamper(), structlog.processors.TimeStamper(),
structlog.processors.StackInfoRenderer(), structlog.processors.StackInfoRenderer(),
@ -314,7 +317,7 @@ LOGGING = {
}, },
"handlers": { "handlers": {
"console": { "console": {
"level": DEBUG, "level": "DEBUG",
"class": "logging.StreamHandler", "class": "logging.StreamHandler",
"formatter": "colored" if DEBUG else "plain", "formatter": "colored" if DEBUG else "plain",
}, },
@ -322,6 +325,7 @@ LOGGING = {
"loggers": {}, "loggers": {},
} }
_LOGGING_HANDLER_MAP = { _LOGGING_HANDLER_MAP = {
"": "DEBUG",
"passbook": "DEBUG", "passbook": "DEBUG",
"django": "WARNING", "django": "WARNING",
"celery": "WARNING", "celery": "WARNING",
@ -334,7 +338,7 @@ for handler_name, level in _LOGGING_HANDLER_MAP.items():
LOGGING["loggers"][handler_name] = { LOGGING["loggers"][handler_name] = {
"handlers": ["console"], "handlers": ["console"],
"level": level, "level": level,
"propagate": True, "propagate": False,
} }
TEST = False TEST = False

View File

@ -8,7 +8,7 @@
<div class="c-form__horizontal-group"> <div class="c-form__horizontal-group">
<p> <p>
Expression using <a href="https://jinja.palletsprojects.com/en/2.11.x/templates/">Jinja</a>. Following variables are available: Expression using <a href="https://jinja.palletsprojects.com/en/2.11.x/templates/">Jinja</a>. Following variables are available:
<ul> <ul class="pf-c-list">
<li><code>ldap</code>: A Dictionary of all values retrieved from LDAP.</li> <li><code>ldap</code>: A Dictionary of all values retrieved from LDAP.</li>
</ul> </ul>
</p> </p>

View File

@ -1,8 +1,9 @@
"""OAuth Clients""" """OAuth Clients"""
import json import json
from typing import Dict from typing import Dict, Optional
from urllib.parse import parse_qs, urlencode from urllib.parse import parse_qs, urlencode
from django.http import HttpRequest
from django.utils.crypto import constant_time_compare, get_random_string from django.utils.crypto import constant_time_compare, get_random_string
from django.utils.encoding import force_text from django.utils.encoding import force_text
from requests import Session from requests import Session
@ -18,30 +19,26 @@ LOGGER = get_logger()
class BaseOAuthClient: class BaseOAuthClient:
"""Base OAuth Client""" """Base OAuth Client"""
_session: Session = None session: Session = None
def __init__(self, source, token=""): # nosec def __init__(self, source, token=""): # nosec
self.source = source self.source = source
self.token = token self.token = token
self._session = Session() self.session = Session()
self._session.headers.update({"User-Agent": "passbook %s" % __version__}) self.session.headers.update({"User-Agent": "passbook %s" % __version__})
def get_access_token(self, request, callback=None): def get_access_token(self, request, callback=None):
"Fetch access token from callback request." "Fetch access token from callback request."
raise NotImplementedError("Defined in a sub-class") # pragma: no cover raise NotImplementedError("Defined in a sub-class") # pragma: no cover
def get_profile_info(self, raw_token): def get_profile_info(self, token: Dict[str, str]):
"Fetch user profile information." "Fetch user profile information."
try: try:
token = json.loads(raw_token)
headers = { headers = {
"Authorization": f"{token['token_type']} {token['access_token']}" "Authorization": f"{token['token_type']} {token['access_token']}"
} }
response = self.request( response = self.session.request(
"get", "get", self.source.profile_url, headers=headers,
self.source.profile_url,
token=token["access_token"],
headers=headers,
) )
response.raise_for_status() response.raise_for_status()
except RequestException as exc: except RequestException as exc:
@ -67,10 +64,6 @@ class BaseOAuthClient:
"Parse token and secret from raw token response." "Parse token and secret from raw token response."
raise NotImplementedError("Defined in a sub-class") # pragma: no cover raise NotImplementedError("Defined in a sub-class") # pragma: no cover
def request(self, method, url, **kwargs):
"Build remote url request."
return self._session.request(method, url, **kwargs)
@property @property
def session_key(self): def session_key(self):
"""Return Session Key""" """Return Session Key"""
@ -80,36 +73,48 @@ class BaseOAuthClient:
class OAuthClient(BaseOAuthClient): class OAuthClient(BaseOAuthClient):
"""OAuth1 Client""" """OAuth1 Client"""
def get_access_token(self, request, callback=None): _default_headers = {
"Accept": "application/json",
}
def get_access_token(
self, request: HttpRequest, callback=None
) -> Optional[Dict[str, str]]:
"Fetch access token from callback request." "Fetch access token from callback request."
raw_token = request.session.get(self.session_key, None) raw_token = request.session.get(self.session_key, None)
verifier = request.GET.get("oauth_verifier", None) verifier = request.GET.get("oauth_verifier", None)
if raw_token is not None and verifier is not None: if raw_token is not None and verifier is not None:
data = {"oauth_verifier": verifier} data = {
"oauth_verifier": verifier,
"oauth_callback": callback,
"token": raw_token,
}
callback = request.build_absolute_uri(callback or request.path) callback = request.build_absolute_uri(callback or request.path)
callback = force_text(callback) callback = force_text(callback)
try: try:
response = self.request( response = self.session.request(
"post", "post",
self.source.access_token_url, self.source.access_token_url,
token=raw_token,
data=data, data=data,
oauth_callback=callback, headers=self._default_headers,
) )
response.raise_for_status() response.raise_for_status()
except RequestException as exc: except RequestException as exc:
LOGGER.warning("Unable to fetch access token", exc=exc) LOGGER.warning("Unable to fetch access token", exc=exc)
return None return None
else: else:
return response.text return response.json()
return None return None
def get_request_token(self, request, callback): def get_request_token(self, request, callback):
"Fetch the OAuth request token. Only required for OAuth 1.0." "Fetch the OAuth request token. Only required for OAuth 1.0."
callback = force_text(request.build_absolute_uri(callback)) callback = force_text(request.build_absolute_uri(callback))
try: try:
response = self.request( response = self.session.request(
"post", self.source.request_token_url, oauth_callback=callback "post",
self.source.request_token_url,
data={"oauth_callback": callback},
headers=self._default_headers,
) )
response.raise_for_status() response.raise_for_status()
except RequestException as exc: except RequestException as exc:
@ -154,7 +159,7 @@ class OAuthClient(BaseOAuthClient):
callback_uri=callback, callback_uri=callback,
) )
kwargs["auth"] = oauth kwargs["auth"] = oauth
return super(OAuthClient, self).request(method, url, **kwargs) return super(OAuthClient, self).session.request(method, url, **kwargs)
@property @property
def session_key(self): def session_key(self):
@ -164,6 +169,10 @@ class OAuthClient(BaseOAuthClient):
class OAuth2Client(BaseOAuthClient): class OAuth2Client(BaseOAuthClient):
"""OAuth2 Client""" """OAuth2 Client"""
_default_headers = {
"Accept": "application/json",
}
# pylint: disable=unused-argument # pylint: disable=unused-argument
def check_application_state(self, request, callback): def check_application_state(self, request, callback):
"Check optional state parameter." "Check optional state parameter."
@ -197,15 +206,19 @@ class OAuth2Client(BaseOAuthClient):
LOGGER.warning("No code returned by the source") LOGGER.warning("No code returned by the source")
return None return None
try: try:
response = self.request( response = self.session.request(
"post", self.source.access_token_url, data=args, **request_kwargs "post",
self.source.access_token_url,
data=args,
headers=self._default_headers,
**request_kwargs,
) )
response.raise_for_status() response.raise_for_status()
except RequestException as exc: except RequestException as exc:
LOGGER.warning("Unable to fetch access token", exc=exc) LOGGER.warning("Unable to fetch access token", exc=exc)
return None return None
else: else:
return response.text return response.json()
# pylint: disable=unused-argument # pylint: disable=unused-argument
def get_application_state(self, request, callback): def get_application_state(self, request, callback):
@ -247,7 +260,7 @@ class OAuth2Client(BaseOAuthClient):
params = kwargs.get("params", {}) params = kwargs.get("params", {})
params["access_token"] = token params["access_token"] = token
kwargs["params"] = params kwargs["params"] = params
return super(OAuth2Client, self).request(method, url, **kwargs) return super(OAuth2Client, self).session.request(method, url, **kwargs)
@property @property
def session_key(self): def session_key(self):

View File

@ -116,7 +116,7 @@ class AzureADOAuthSourceForm(OAuthSourceForm):
class Meta(OAuthSourceForm.Meta): class Meta(OAuthSourceForm.Meta):
overrides = { overrides = {
"provider_type": "azure_ad", "provider_type": "azure-ad",
"request_token_url": "", "request_token_url": "",
"authorization_url": "https://login.microsoftonline.com/common/oauth2/authorize", "authorization_url": "https://login.microsoftonline.com/common/oauth2/authorize",
"access_token_url": "https://login.microsoftonline.com/common/oauth2/token", "access_token_url": "https://login.microsoftonline.com/common/oauth2/token",

View File

@ -89,13 +89,15 @@ class OAuthCallback(OAuthClientMixin, View):
client = self.get_client(self.source) client = self.get_client(self.source)
callback = self.get_callback_url(self.source) callback = self.get_callback_url(self.source)
# Fetch access token # Fetch access token
raw_token = client.get_access_token(self.request, callback=callback) token = client.get_access_token(self.request, callback=callback)
if raw_token is None: if token is None:
return self.handle_login_failure( return self.handle_login_failure(
self.source, "Could not retrieve token." self.source, "Could not retrieve token."
) )
if "error" in token:
return self.handle_login_failure(self.source, token["error"])
# Fetch profile info # Fetch profile info
info = client.get_profile_info(raw_token) info = client.get_profile_info(token)
if info is None: if info is None:
return self.handle_login_failure( return self.handle_login_failure(
self.source, "Could not retrieve profile." self.source, "Could not retrieve profile."
@ -105,7 +107,7 @@ class OAuthCallback(OAuthClientMixin, View):
return self.handle_login_failure(self.source, "Could not determine id.") return self.handle_login_failure(self.source, "Could not determine id.")
# Get or create access record # Get or create access record
defaults = { defaults = {
"access_token": raw_token, "access_token": token.get("access_token"),
} }
existing = UserOAuthSourceConnection.objects.filter( existing = UserOAuthSourceConnection.objects.filter(
source=self.source, identifier=identifier source=self.source, identifier=identifier
@ -113,13 +115,15 @@ class OAuthCallback(OAuthClientMixin, View):
if existing.exists(): if existing.exists():
connection = existing.first() connection = existing.first()
connection.access_token = raw_token connection.access_token = token.get("access_token")
UserOAuthSourceConnection.objects.filter(pk=connection.pk).update( UserOAuthSourceConnection.objects.filter(pk=connection.pk).update(
**defaults **defaults
) )
else: else:
connection = UserOAuthSourceConnection( connection = UserOAuthSourceConnection(
source=self.source, identifier=identifier, access_token=raw_token source=self.source,
identifier=identifier,
access_token=token.get("access_token"),
) )
user = authenticate( user = authenticate(
source=self.source, identifier=identifier, request=request source=self.source, identifier=identifier, request=request

View File

@ -7,33 +7,18 @@
z-index: 0; z-index: 0;
} }
@@ -1,204 +0,0 @@ /* login page's icons */
.navbar-brand-name { .pf-c-login__main-footer-links-item-link img {
height: 35px; fill: var(--pf-c-login__main-footer-links-item-link-svg--Fill);
width: 100%;
max-width: var(--pf-c-login__main-footer-links-item-link-svg--Width);
height: 100%;
max-height: var(--pf-c-login__main-footer-links-item-link-svg--Height);
} }
.dynamic-array-widget .array-item { /* fix multiple selects height */
display: flex; select[multiple] {
align-items: center; height: initial;
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;
} }
/* Selector */ /* Selector */

View File

@ -36,3 +36,19 @@ document.querySelectorAll(".codemirror").forEach((cm) => {
autoRefresh: true, autoRefresh: true,
}); });
}); });
// Automatic slug fields
const convertToSlug = (text) => {
return text
.toLowerCase()
.replace(/[^\w ]+/g, '')
.replace(/ +/g, '-');
};
document.querySelectorAll("input[name=name]").forEach((input) => {
input.addEventListener("input", (e) => {
const form = e.target.closest("form");
const slugField = form.querySelector("input[name=slug]");
slugField.value = convertToSlug(e.target.value);
});
});