Merge branch 'master' into e2e

This commit is contained in:
Jens Langhammer 2020-06-07 17:52:51 +02:00
commit aa440c17b7
131 changed files with 2589 additions and 918 deletions

View File

@ -62,6 +62,8 @@ jobs:
python-version: '3.8'
- name: Install pyright
run: npm install -g pyright
- name: Show pyright version
run: pyright --version
- name: Install dependencies
run: sudo pip install -U wheel pipenv && pipenv install --dev
- name: Lint with pyright

View File

@ -40,7 +40,6 @@ signxml = "*"
structlog = "*"
swagger-spec-validator = "*"
urllib3 = {extras = ["secure"],version = "*"}
jinja2 = "*"
[requires]
python_version = "3.8"

65
Pipfile.lock generated
View File

@ -18,10 +18,10 @@
"default": {
"amqp": {
"hashes": [
"sha256:6e649ca13a7df3faacdc8bbb280aa9a6602d22fd9d545336077e573a1f4ff3b8",
"sha256:77f1aef9410698d20eaeac5b73a87817365f457a507d82edf292e12cbb83b08d"
"sha256:24dbaff8ce4f30566bb88976b398e8c4e77637171af3af6f1b9650f48890e60b",
"sha256:bb68f8d2bced8f93ccfd07d96c689b716b3227720add971be980accfc2952139"
],
"version": "==2.5.2"
"version": "==2.6.0"
},
"asgiref": {
"hashes": [
@ -53,26 +53,26 @@
},
"boto3": {
"hashes": [
"sha256:1bdab4f87ff39d5aab59b0aae69965bf604fa5608984c673877f4c62c1f16240",
"sha256:2b4924ccc1603d562969b9f3c8c74ff4a1f3bdbafe857c990422c73d8e2e229e"
"sha256:bcaa88b2f81b88741c47da52f3414c876236700441df87b6198f860e6a200d6f",
"sha256:e974e7a3bbdbd6a73ffc07bea5fa0c0744a5a8b87dcca94702597176e3de465e"
],
"index": "pypi",
"version": "==1.13.18"
"version": "==1.13.23"
},
"botocore": {
"hashes": [
"sha256:93574cf95a64c71d35c12c93a23f6214cf2f4b461be3bda3a436381cbe126a84",
"sha256:e65eb27cae262a510e335bc0c0e286e9e42381b1da0aafaa79fa13c1d8d74a95"
"sha256:5831068c9b49b4c91b0733e0ec784a7733d8732359d73c67a07a0b0868433cae",
"sha256:7778957bdc9a25dd33bb4383ebd6d45a8570a2cbff03d1edf430fdacec2b7437"
],
"version": "==1.16.18"
"version": "==1.16.23"
},
"celery": {
"hashes": [
"sha256:108a0bf9018a871620936c33a3ee9f6336a89f8ef0a0f567a9001f4aa361415f",
"sha256:5b4b37e276033fe47575107a2775469f0b721646a08c96ec2c61531e4fe45f2a"
"sha256:9ae2e73b93cc7d6b48b56aaf49a68c91752d0ffd7dfdcc47f842ca79a6f13eae",
"sha256:c2037b6a8463da43b19969a0fc13f9023ceca6352b4dd51be01c66fbbb13647e"
],
"index": "pypi",
"version": "==4.4.2"
"version": "==4.4.4"
},
"certifi": {
"hashes": [
@ -169,11 +169,11 @@
},
"django": {
"hashes": [
"sha256:051ba55d42daa3eeda3944a8e4df2bc96d4c62f94316dea217248a22563c3621",
"sha256:9aaa6a09678e1b8f0d98a948c56482eac3e3dd2ddbfb8de70a868135ef3b5e01"
"sha256:5052b34b34b3425233c682e0e11d658fd6efd587d11335a0203d827224ada8f2",
"sha256:e1630333248c9b3d4e38f02093a26f1e07b271ca896d73097457996e0fae12e8"
],
"index": "pypi",
"version": "==3.0.6"
"version": "==3.0.7"
},
"django-cors-middleware": {
"hashes": [
@ -345,7 +345,6 @@
"sha256:c10142f819c2d22bdcd17548c46fa9b77cf4fda45097854c689666bf425e7484",
"sha256:c922560ac46888d47384de1dbdc3daaa2ea993af4b26a436dec31fa2c19ec668"
],
"index": "pypi",
"version": "==3.0.0a1"
},
"jmespath": {
@ -364,11 +363,11 @@
},
"kombu": {
"hashes": [
"sha256:2d1cda774126a044d91a7ff5fa6d09edf99f46924ab332a810760fe6740e9b76",
"sha256:598e7e749d6ab54f646b74b2d2df67755dee13894f73ab02a2a9feb8870c7cb2"
"sha256:437b9cdea193cc2ed0b8044c85fd0f126bb3615ca2f4d4a35b39de7cacfa3c1a",
"sha256:dc282bb277197d723bccda1a9ba30a27a28c9672d0ab93e9e51bb05a37bd29c3"
],
"index": "pypi",
"version": "==4.6.8"
"version": "==4.6.10"
},
"ldap3": {
"hashes": [
@ -688,10 +687,10 @@
},
"redis": {
"hashes": [
"sha256:2ef11f489003f151777c064c5dbc6653dfb9f3eade159bcadc524619fddc2242",
"sha256:6d65e84bc58091140081ee9d9c187aab0480097750fac44239307a3bdf0b1251"
"sha256:0e7e0cfca8660dea8b7d5cd8c4f6c5e29e11f31158c0b0ae91a397f00e5a05a2",
"sha256:432b788c4530cfe16d8d943a09d40ca6c16149727e4afe8c2c9d5580c59d9f24"
],
"version": "==3.5.2"
"version": "==3.5.3"
},
"requests": {
"hashes": [
@ -858,10 +857,10 @@
},
"autopep8": {
"hashes": [
"sha256:152fd8fe47d02082be86e05001ec23d6f420086db56b17fc883f3f965fb34954"
"sha256:60fd8c4341bab59963dafd5d2a566e94f547e660b9b396f772afe67d8481dbf0"
],
"index": "pypi",
"version": "==1.5.2"
"version": "==1.5.3"
},
"bandit": {
"hashes": [
@ -948,11 +947,11 @@
},
"django": {
"hashes": [
"sha256:051ba55d42daa3eeda3944a8e4df2bc96d4c62f94316dea217248a22563c3621",
"sha256:9aaa6a09678e1b8f0d98a948c56482eac3e3dd2ddbfb8de70a868135ef3b5e01"
"sha256:5052b34b34b3425233c682e0e11d658fd6efd587d11335a0203d827224ada8f2",
"sha256:e1630333248c9b3d4e38f02093a26f1e07b271ca896d73097457996e0fae12e8"
],
"index": "pypi",
"version": "==3.0.6"
"version": "==3.0.7"
},
"django-debug-toolbar": {
"hashes": [
@ -971,10 +970,10 @@
},
"gitpython": {
"hashes": [
"sha256:864a47472548f3ba716ca202e034c1900f197c0fb3a08f641c20c3cafd15ed94",
"sha256:da3b2cf819974789da34f95ac218ef99f515a928685db141327c09b73dd69c09"
"sha256:e107af4d873daed64648b4f4beb89f89f0cfbe3ef558fc7821ed2331c2f8da1a",
"sha256:ef1d60b01b5ce0040ad3ec20bc64f783362d41fa0822a2742d3586e1f49bb8ac"
],
"version": "==3.1.2"
"version": "==3.1.3"
},
"isort": {
"hashes": [
@ -1133,10 +1132,10 @@
},
"stevedore": {
"hashes": [
"sha256:18afaf1d623af5950cc0f7e75e70f917784c73b652a34a12d90b309451b5500b",
"sha256:a4e7dc759fb0f2e3e2f7d8ffe2358c19d45b9b8297f393ef1256858d82f69c9b"
"sha256:001e90cd704be6470d46cc9076434e2d0d566c1379187e7013eb296d3a6032d9",
"sha256:471c920412265cc809540ae6fb01f3f02aba89c79bbc7091372f4745a50f9691"
],
"version": "==1.32.0"
"version": "==2.0.0"
},
"toml": {
"hashes": [

55
docs/expressions/index.md Normal file
View File

@ -0,0 +1,55 @@
# Expressions
Expressions allow you to write custom Logic using Python code.
Expressions are used in different places throughout passbook, and can do different things.
!!! info
These functions/objects are available wherever expressions are used. For more specific information, see [Expression Policies](../policies/expression.md) and [Property Mappings](../property-mappings/expression.md)
## Global objects
- `pb_logger`: structlog BoundLogger. ([ref](https://www.structlog.org/en/stable/api.html#structlog.BoundLogger))
- `requests`: requests Session object. ([ref](https://requests.readthedocs.io/en/master/user/advanced/))
## Generally available functions
### `regex_match(value: Any, regex: str) -> bool`
Check if `value` matches Regular Expression `regex`.
Example:
```python
return regex_match(request.user.username, '.*admin.*')
```
### `regex_replace(value: Any, regex: str, repl: str) -> str`
Replace anything matching `regex` within `value` with `repl` and return it.
Example:
```python
user_email_local = regex_replace(request.user.email, '(.+)@.+', '')
```
### `pb_is_group_member(user: User, **group_filters) -> bool`
Check if `user` is member of a group matching `**group_filters`.
Example:
```python
return pb_is_group_member(request.user, name="test_group")
```
### `pb_user_by(**filters) -> Optional[User]`
Fetch a user matching `**filters`. Returns None if no user was found.
Example:
```python
other_user = pb_user_by(username="other_user")
```

View File

@ -15,6 +15,7 @@ The User object has the following attributes:
List all the User's Group Names
```jinja2
[{% for group in user.groups.all() %}'{{ group.name }}',{% endfor %}]
```python
for group in user.groups.all():
yield group.name
```

View File

@ -0,0 +1,27 @@
# Expression Policies
The passing of the policy is determined by the return value of the code. Use `return True` to pass a policy and `return False` to fail it.
### Available Functions
#### `pb_message(message: str)`
Add a message, visible by the end user. This can be used to show the reason why they were denied.
Example:
```python
pb_message("Access denied")
return False
```
### Context variables
- `request`: A PolicyRequest object, which has the following properties:
- `request.user`: The current User, which the Policy is applied against. ([ref](../expressions/reference/user-object.md))
- `request.http_request`: The Django HTTP Request. ([ref](https://docs.djangoproject.com/en/3.0/ref/request-response/#httprequest-objects))
- `request.obj`: A Django Model instance. This is only set if the Policy is ran against an object.
- `request.context`: A dictionary with dynamic data. This depends on the origin of the execution.
- `pb_is_sso_flow`: Boolean which is true if request was initiated by authenticating through an external Provider.
- `pb_client_ip`: Client's IP Address or '255.255.255.255' if no IP Address could be extracted.
- `pb_flow_plan`: Current Plan if Policy is called from the Flow Planner.

View File

@ -1,22 +0,0 @@
# Expression Policy
Expression Policies allows you to write custom Policy Logic using Jinja2 Templating language.
For a language reference, see [here](https://jinja.palletsprojects.com/en/2.11.x/templates/).
The following objects are passed into the variable:
- `request`: A PolicyRequest object, which has the following properties:
- `request.user`: The current User, which the Policy is applied against. ([ref](../../property-mappings/reference/user-object.md))
- `request.http_request`: The Django HTTP Request, as documented [here](https://docs.djangoproject.com/en/3.0/ref/request-response/#httprequest-objects).
- `request.obj`: A Django Model instance. This is only set if the Policy is ran against an object.
- `pb_flow_plan`: Current Plan if Policy is called while a flow is active.
- `pb_is_sso_flow`: Boolean which is true if request was initiated by authenticating through an external Provider.
- `pb_is_group_member(user, group_name)`: Function which checks if `user` is member of a Group with Name `gorup_name`.
- `pb_logger`: Standard Python Logger Object, which can be used to debug expressions.
- `pb_client_ip`: Client's IP Address.
There are also the following custom filters available:
- `regex_match(regex)`: Return True if value matches `regex`
- `regex_replace(regex, repl)`: Replace string matched by `regex` with `repl`

View File

@ -8,10 +8,6 @@ There are two different Kind of policies, a Standard Policy and a Password Polic
---
### Group-Membership Policy
This policy evaluates to True if the current user is a Member of the selected group.
### Reputation Policy
passbook keeps track of failed login attempts by Source IP and Attempted Username. These values are saved as scores. Each failed login decreases the Score for the Client IP as well as the targeted Username by one.
@ -20,11 +16,7 @@ This policy can be used to for example prompt Clients with a low score to pass a
## Expression Policy
See [Expression Policy](expression/index.md).
### Webhook Policy
This policy allows you to send an arbitrary HTTP Request to any URL. You can then use JSONPath to extract the result you need.
See [Expression Policy](expression.md).
## Password Policies

View File

@ -0,0 +1,9 @@
# Property Mapping Expressions
The property mapping should return a value that is expected by the Provider/Source. What types are supported, is documented in the individual Provider/Source. Returning `None` is always accepted, this simply skips this mapping.
### Context Variables
- `user`: The current user, this might be `None` if there is no contextual user. ([ref](../expressions/reference/user-object.md))
- `request`: The current request, this might be `None` if there is no contextual request. ([ref](https://docs.djangoproject.com/en/3.0/ref/request-response/#httprequest-objects))
- Arbitrary other arguments given by the provider, this is documented on the Provider/Source.

View File

@ -10,14 +10,17 @@ nav:
- Kubernetes: installation/kubernetes.md
- Sources: sources.md
- Providers: providers.md
- Expressions:
- Overview: expressions/index.md
- Reference:
- User Object: expressions/reference/user-object.md
- Property Mappings:
- Overview: property-mappings/index.md
- Reference:
- User Object: property-mappings/reference/user-object.md
- Expressions: property-mappings/expression.md
- Factors: factors.md
- Policies:
- Overview: policies/index.md
- Expression: policies/expression/index.md
- Expression: policies/expression.md
- Integrations:
- as Provider:
- Amazon Web Services: integrations/services/aws/index.md
@ -38,3 +41,11 @@ markdown_extensions:
- toc:
permalink: "¶"
- admonition
- codehilite
- pymdownx.betterem:
smart_enable: all
- pymdownx.inlinehilite
- pymdownx.magiclink
plugins:
- search

View File

@ -1,4 +1,17 @@
"""passbook core source form fields"""
SOURCE_FORM_FIELDS = ["name", "slug", "enabled"]
SOURCE_SERIALIZER_FIELDS = ["pk", "name", "slug", "enabled"]
SOURCE_FORM_FIELDS = [
"name",
"slug",
"enabled",
"authentication_flow",
"enrollment_flow",
]
SOURCE_SERIALIZER_FIELDS = [
"pk",
"name",
"slug",
"enabled",
"authentication_flow",
"enrollment_flow",
]

View File

@ -14,7 +14,7 @@
<link rel="stylesheet" href="{% static 'node_modules/codemirror/theme/monokai.css' %}">
<script src="{% static 'node_modules/codemirror/mode/xml/xml.js' %}"></script>
<script src="{% static 'node_modules/codemirror/mode/yaml/yaml.js' %}"></script>
<script src="{% static 'node_modules/codemirror/mode/jinja2/jinja2.js' %}"></script>
<script src="{% static 'node_modules/codemirror/mode/python/python.js' %}"></script>
{% endblock %}
{% block page_content %}

View File

@ -55,15 +55,26 @@
</div>
</div>
<div class="pf-c-card__body">
{% if factor_count < 1 %}
<i class="pficon-error-circle-o"></i> {{ factor_count }}
{% if stage_count < 1 %}
<i class="pficon-error-circle-o"></i> {{ stage_count }}
<p>{% trans 'No Stages configured. No Users will be able to login.' %}"></p>
{% else %}
<i class="pf-icon pf-icon-ok"></i> {{ factor_count }}
<i class="pf-icon pf-icon-ok"></i> {{ stage_count }}
{% endif %}
</div>
</a>
<a href="{% url 'passbook_admin:stages' %}" class="pf-c-card pf-m-hoverable pf-m-compact">
<div class="pf-c-card__head">
<div class="pf-c-card__head-main">
<i class="pf-icon pf-icon-topology"></i> {% trans 'Flows' %}
</div>
</div>
<div class="pf-c-card__body">
<i class="pf-icon pf-icon-ok"></i> {{ flow_count }}
</div>
</a>
<a href="{% url 'passbook_admin:policies' %}" class="pf-c-card pf-m-hoverable pf-m-compact">
<div class="pf-c-card__head">
<div class="pf-c-card__head-main">

View File

@ -29,7 +29,7 @@
{% for type, name in types.items %}
<li>
<a class="pf-c-dropdown__menu-item" href="{% url 'passbook_admin:provider-create' %}?type={{ type }}&back={{ request.get_full_path }}">
{{ name|verbose_name }}
{{ name|verbose_name }}<br>
<small>
{{ name|doc }}
</small>

View File

@ -5,14 +5,14 @@
{% block above_form %}
<h1>
{% blocktrans with type=form|form_verbose_name|title %}
{% blocktrans with type=form|form_verbose_name %}
Create {{ type }}
{% endblocktrans %}
</h1>
{% endblock %}
{% block action %}
{% blocktrans with type=form|form_verbose_name|title %}
{% blocktrans with type=form|form_verbose_name %}
Create {{ type }}
{% endblocktrans %}
{% endblock %}

View File

@ -15,7 +15,6 @@ class ApplicationSerializer(ModelSerializer):
"pk",
"name",
"slug",
"skip_authorization",
"provider",
"meta_launch_url",
"meta_icon_url",

View File

@ -17,7 +17,7 @@ class ProviderSerializer(ModelSerializer):
class Meta:
model = Provider
fields = ["pk", "property_mappings", "__type__"]
fields = ["pk", "authorization_flow", "property_mappings", "__type__"]
class ProviderViewSet(ReadOnlyModelViewSet):

View File

@ -0,0 +1,21 @@
"""Property Mapping Evaluator"""
from typing import Optional
from django.http import HttpRequest
from passbook.core.models import User
from passbook.lib.expression.evaluator import BaseEvaluator
class PropertyMappingEvaluator(BaseEvaluator):
"""Custom Evalautor that adds some different context variables."""
def set_context(
self, user: Optional[User], request: Optional[HttpRequest], **kwargs
):
"""Update context with context from PropertyMapping's evaluate"""
if user:
self._context["user"] = user
if request:
self._context["request"] = request
self._context.update(**kwargs)

View File

@ -19,7 +19,6 @@ class ApplicationForm(forms.ModelForm):
fields = [
"name",
"slug",
"skip_authorization",
"provider",
"meta_launch_url",
"meta_icon_url",

View File

@ -0,0 +1,52 @@
# Generated by Django 3.0.6 on 2020-05-23 11:33
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("passbook_flows", "0003_auto_20200523_1133"),
("passbook_core", "0001_initial"),
]
operations = [
migrations.RemoveField(model_name="application", name="skip_authorization",),
migrations.AddField(
model_name="source",
name="authentication_flow",
field=models.ForeignKey(
blank=True,
default=None,
help_text="Flow to use when authenticating existing users.",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="source_authentication",
to="passbook_flows.Flow",
),
),
migrations.AddField(
model_name="source",
name="enrollment_flow",
field=models.ForeignKey(
blank=True,
default=None,
help_text="Flow to use when enrolling new users.",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="source_enrollment",
to="passbook_flows.Flow",
),
),
migrations.AddField(
model_name="provider",
name="authorization_flow",
field=models.ForeignKey(
help_text="Flow used when authorizing this provider.",
on_delete=django.db.models.deletion.CASCADE,
related_name="provider_authorization",
to="passbook_flows.Flow",
),
),
]

View File

@ -0,0 +1,29 @@
# Generated by Django 3.0.6 on 2020-05-23 16:40
from django.apps.registry import Apps
from django.db import migrations
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
def create_default_user(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
# User = apps.get_model("passbook_core", "User")
from passbook.core.models import User
pbadmin = User.objects.create(
username="pbadmin", email="root@localhost", name="passbook Default Admin"
)
pbadmin.set_password("pbadmin") # noqa # nosec
pbadmin.is_superuser = True
pbadmin.is_staff = True
pbadmin.save()
class Migration(migrations.Migration):
dependencies = [
("passbook_core", "0002_auto_20200523_1133"),
]
operations = [
migrations.RunPython(create_default_user),
]

View File

@ -5,26 +5,22 @@ from uuid import uuid4
from django.contrib.auth.models import AbstractUser
from django.contrib.postgres.fields import JSONField
from django.core.exceptions import ValidationError
from django.db import models
from django.http import HttpRequest
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from guardian.mixins import GuardianUserMixin
from jinja2 import Undefined
from jinja2.exceptions import TemplateSyntaxError, UndefinedError
from jinja2.nativetypes import NativeEnvironment
from model_utils.managers import InheritanceManager
from structlog import get_logger
from passbook.core.exceptions import PropertyMappingExpressionException
from passbook.core.signals import password_changed
from passbook.core.types import UILoginButton, UIUserSettings
from passbook.flows.models import Flow
from passbook.lib.models import CreatedUpdatedModel
from passbook.policies.models import PolicyBindingModel
LOGGER = get_logger()
NATIVE_ENVIRONMENT = NativeEnvironment()
def default_token_duration():
@ -80,6 +76,13 @@ class User(GuardianUserMixin, AbstractUser):
class Provider(models.Model):
"""Application-independent Provider instance. For example SAML2 Remote, OAuth2 Application"""
authorization_flow = models.ForeignKey(
Flow,
on_delete=models.CASCADE,
help_text=_("Flow used when authorizing this provider."),
related_name="provider_authorization",
)
property_mappings = models.ManyToManyField(
"PropertyMapping", default=None, blank=True
)
@ -100,7 +103,6 @@ class Application(PolicyBindingModel):
name = models.TextField(help_text=_("Application's display Name."))
slug = models.SlugField(help_text=_("Internal application name, used in URLs."))
skip_authorization = models.BooleanField(default=False)
provider = models.OneToOneField(
"Provider", null=True, blank=True, default=None, on_delete=models.SET_DEFAULT
)
@ -133,6 +135,25 @@ class Source(PolicyBindingModel):
"PropertyMapping", default=None, blank=True
)
authentication_flow = models.ForeignKey(
Flow,
blank=True,
null=True,
default=None,
on_delete=models.SET_NULL,
help_text=_("Flow to use when authenticating existing users."),
related_name="source_authentication",
)
enrollment_flow = models.ForeignKey(
Flow,
blank=True,
null=True,
default=None,
on_delete=models.SET_NULL,
help_text=_("Flow to use when enrolling new users."),
related_name="source_enrollment",
)
form = "" # ModelForm-based class ued to create/edit instance
objects = InheritanceManager()
@ -208,24 +229,14 @@ class PropertyMapping(models.Model):
self, user: Optional[User], request: Optional[HttpRequest], **kwargs
) -> Any:
"""Evaluate `self.expression` using `**kwargs` as Context."""
try:
expression = NATIVE_ENVIRONMENT.from_string(self.expression)
except TemplateSyntaxError as exc:
raise PropertyMappingExpressionException from exc
try:
response = expression.render(user=user, request=request, **kwargs)
if isinstance(response, Undefined):
raise PropertyMappingExpressionException("Response was 'Undefined'")
return response
except UndefinedError as exc:
raise PropertyMappingExpressionException from exc
from passbook.core.expression import PropertyMappingEvaluator
def save(self, *args, **kwargs):
evaluator = PropertyMappingEvaluator()
evaluator.set_context(user, request, **kwargs)
try:
NATIVE_ENVIRONMENT.from_string(self.expression)
except TemplateSyntaxError as exc:
raise ValidationError("Expression Syntax Error") from exc
return super().save(*args, **kwargs)
return evaluator.evaluate(self.expression)
except (ValueError, SyntaxError) as exc:
raise PropertyMappingExpressionException from exc
def __str__(self):
return f"Property Mapping {self.name}"

View File

@ -1,31 +1,7 @@
"""passbook core signals"""
from django.core.cache import cache
from django.core.signals import Signal
from django.db.models.signals import post_save
from django.dispatch import receiver
from structlog import get_logger
LOGGER = get_logger()
user_signed_up = Signal(providing_args=["request", "user"])
invitation_created = Signal(providing_args=["request", "invitation"])
invitation_used = Signal(providing_args=["request", "invitation", "user"])
password_changed = Signal(providing_args=["user", "password"])
@receiver(post_save)
# pylint: disable=unused-argument
def invalidate_policy_cache(sender, instance, **_):
"""Invalidate Policy cache when policy is updated"""
from passbook.policies.models import Policy, PolicyBinding
from passbook.policies.process import cache_key
if isinstance(instance, Policy):
LOGGER.debug("Invalidating policy cache", policy=instance)
total = 0
for binding in PolicyBinding.objects.filter(policy=instance):
prefix = cache_key(binding) + "*"
keys = cache.keys(prefix)
total += len(keys)
cache.delete_many(keys)
LOGGER.debug("Deleted keys", len=total)

View File

@ -20,3 +20,40 @@
</form>
{% endblock %}
</div>
<footer class="pf-c-login__main-footer">
{% if config.login.subtext %}
<p>{{ config.login.subtext }}</p>
{% endif %}
<ul class="pf-c-login__main-footer-links">
{% for source in sources %}
<li class="pf-c-login__main-footer-links-item">
<a href="{{ source.url }}" class="pf-c-login__main-footer-links-item-link">
{% if source.icon_path %}
<img src="{% static source.icon_path %}" alt="{{ source.name }}">
{% elif source.icon_url %}
<img src="icon_url" alt="{{ source.name }}">
{% else %}
<i class="pf-icon pf-icon-arrow" title="{{ source.name }}"></i>
{% endif %}
</a>
</li>
{% endfor %}
</ul>
{% if enroll_url or recovery_url %}
<div class="pf-c-login__main-footer-band">
{% if enroll_url %}
<p class="pf-c-login__main-footer-band-item">
{% trans 'Need an account?' %}
<a href="{{ enroll_url }}">{% trans 'Sign up.' %}</a>
</p>
{% endif %}
{% if recovery_url %}
<p class="pf-c-login__main-footer-band-item">
<a href="{{ recovery_url }}">
{% trans 'Forgot username or password?' %}
</a>
</p>
{% endif %}
</div>
{% endif %}
</footer>

View File

@ -2,6 +2,13 @@
{% load i18n %}
{% csrf_token %}
{% if form.non_field_errors %}
<div class="pf-c-form__group has-error">
<p class="pf-c-form__helper-text pf-m-error">
{{ form.non_field_errors }}
</p>
</div>
{% endif %}
{% for field in form %}
<div class="pf-c-form__group {% if field.errors %} has-error {% endif %}">
{% if field.field.widget|fieldtype == 'RadioSelect' %}

View File

@ -28,6 +28,9 @@
</label>
<div class="pf-c-form__horizontal-group">
{{ field|css_class:"pf-c-form-control" }}
{% if field.help_text %}
<p class="pf-c-form__helper-text">{{ field.help_text|safe }}</p>
{% endif %}
</div>
{% elif field.field.widget|fieldtype == 'CheckboxInput' %}
<div class="pf-c-form__horizontal-group">
@ -36,7 +39,7 @@
<label class="pf-c-check__label" for="{{ field.name }}-{{ forloop.counter0 }}">{{ field.label }}</label>
</div>
{% if field.help_text %}
<p class="pf-c-form__helper-text">{{ field.help_text }}</p>
<p class="pf-c-form__helper-text">{{ field.help_text|safe }}</p>
{% endif %}
</div>
{% else %}
@ -49,7 +52,7 @@
<div class="c-form__horizontal-group">
{{ field|css_class:'pf-c-form-control' }}
{% if field.help_text %}
<p class="pf-c-form__helper-text">{{ field.help_text }}</p>
<p class="pf-c-form__helper-text">{{ field.help_text|safe }}</p>
{% endif %}
</div>
{% endif %}

View File

@ -17,7 +17,9 @@
<div class="pf-c-form__horizontal-group">
<div class="pf-c-form__actions">
<input class="pf-c-button pf-m-primary" type="submit" value="{% trans 'Update' %}" />
{% if unenrollment_enabled %}
<a class="pf-c-button pf-m-danger" href="{% url 'passbook_flows:default-unenrollment' %}?back={{ request.get_full_path }}">{% trans "Delete account" %}</a>
{% endif %}
</div>
</div>
</div>

View File

@ -35,6 +35,6 @@ class AccessMixin:
def user_has_access(self, application: Application, user: User) -> PolicyResult:
"""Check if user has access to application."""
LOGGER.debug("Checking permissions", user=user, application=application)
policy_engine = PolicyEngine(application.policies.all(), user, self.request)
policy_engine = PolicyEngine(application, user, self.request)
policy_engine.build()
return policy_engine.result

View File

@ -16,9 +16,7 @@ class OverviewView(LoginRequiredMixin, TemplateView):
def get_context_data(self, **kwargs):
kwargs["applications"] = []
for application in Application.objects.all().order_by("name"):
engine = PolicyEngine(
application.policies.all(), self.request.user, self.request
)
engine = PolicyEngine(application, self.request.user, self.request)
engine.build()
if engine.passing:
kwargs["applications"].append(application)

View File

@ -1,4 +1,6 @@
"""passbook core user views"""
from typing import Any, Dict
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.messages.views import SuccessMessageMixin
from django.urls import reverse_lazy
@ -6,6 +8,7 @@ from django.utils.translation import gettext as _
from django.views.generic import UpdateView
from passbook.core.forms.users import UserDetailForm
from passbook.flows.models import Flow, FlowDesignation
class UserSettingsView(SuccessMessageMixin, LoginRequiredMixin, UpdateView):
@ -19,3 +22,11 @@ class UserSettingsView(SuccessMessageMixin, LoginRequiredMixin, UpdateView):
def get_object(self):
return self.request.user
def get_context_data(self, **kwargs: Dict[str, Any]) -> Dict[str, Any]:
kwargs = super().get_context_data(**kwargs)
unenrollment_flow = Flow.with_policy(
self.request, designation=FlowDesignation.UNRENOLLMENT
)
kwargs["unenrollment_enabled"] = bool(unenrollment_flow)
return kwargs

View File

@ -36,11 +36,11 @@ class CertificateBuilder:
x509.Name(
[
x509.NameAttribute(
NameOID.COMMON_NAME, u"passbook Self-signed Certificate",
NameOID.COMMON_NAME, "passbook Self-signed Certificate",
),
x509.NameAttribute(NameOID.ORGANIZATION_NAME, u"passbook"),
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "passbook"),
x509.NameAttribute(
NameOID.ORGANIZATIONAL_UNIT_NAME, u"Self-signed"
NameOID.ORGANIZATIONAL_UNIT_NAME, "Self-signed"
),
]
)
@ -49,7 +49,7 @@ class CertificateBuilder:
x509.Name(
[
x509.NameAttribute(
NameOID.COMMON_NAME, u"passbook Self-signed Certificate",
NameOID.COMMON_NAME, "passbook Self-signed Certificate",
),
]
)

View File

@ -34,7 +34,6 @@ class CertificateKeyPairForm(forms.ModelForm):
password=None,
backend=default_backend(),
)
load_pem_x509_certificate(key_data.encode("utf-8"), default_backend())
except ValueError:
raise forms.ValidationError("Unable to load private key.")
return key_data

View File

@ -0,0 +1,26 @@
# Generated by Django 3.0.6 on 2020-05-23 23:07
from django.db import migrations
def create_self_signed(apps, schema_editor):
CertificateKeyPair = apps.get_model("passbook_crypto", "CertificateKeyPair")
db_alias = schema_editor.connection.alias
from passbook.crypto.builder import CertificateBuilder
builder = CertificateBuilder()
builder.build()
CertificateKeyPair.objects.using(db_alias).create(
name="passbook Self-signed Certificate",
certificate_data=builder.certificate,
key_data=builder.private_key,
)
class Migration(migrations.Migration):
dependencies = [
("passbook_crypto", "0001_initial"),
]
operations = [migrations.RunPython(create_self_signed)]

View File

@ -31,7 +31,7 @@ def create_default_authentication_flow(
if not IdentificationStage.objects.using(db_alias).exists():
IdentificationStage.objects.using(db_alias).create(
name="identification",
user_fields=[UserFields.E_MAIL],
user_fields=[UserFields.E_MAIL, UserFields.USERNAME],
template=Templates.DEFAULT_LOGIN,
)
@ -44,7 +44,7 @@ def create_default_authentication_flow(
UserLoginStage.objects.using(db_alias).create(name="authentication")
flow = Flow.objects.using(db_alias).create(
name="default-authentication-flow",
name="Welcome to passbook!",
slug="default-authentication-flow",
designation=FlowDesignation.AUTHENTICATION,
)

View File

@ -0,0 +1,29 @@
# Generated by Django 3.0.6 on 2020-05-23 11:33
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("passbook_flows", "0002_default_flows"),
]
operations = [
migrations.AlterField(
model_name="flow",
name="designation",
field=models.CharField(
choices=[
("authentication", "Authentication"),
("authorization", "Authorization"),
("invalidation", "Invalidation"),
("enrollment", "Enrollment"),
("unenrollment", "Unrenollment"),
("recovery", "Recovery"),
("password_change", "Password Change"),
],
max_length=100,
),
),
]

View File

@ -0,0 +1,131 @@
# Generated by Django 3.0.6 on 2020-05-23 15:47
from django.apps.registry import Apps
from django.db import migrations
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
from passbook.flows.models import FlowDesignation
from passbook.stages.prompt.models import FieldTypes
FLOW_POLICY_EXPRESSION = """{{ pb_is_sso_flow }}"""
PROMPT_POLICY_EXPRESSION = """
{% if pb_flow_plan.context.prompt_data.username %}
False
{% else %}
True
{% endif %}
"""
def create_default_source_enrollment_flow(
apps: Apps, schema_editor: BaseDatabaseSchemaEditor
):
Flow = apps.get_model("passbook_flows", "Flow")
FlowStageBinding = apps.get_model("passbook_flows", "FlowStageBinding")
PolicyBinding = apps.get_model("passbook_policies", "PolicyBinding")
ExpressionPolicy = apps.get_model(
"passbook_policies_expression", "ExpressionPolicy"
)
PromptStage = apps.get_model("passbook_stages_prompt", "PromptStage")
Prompt = apps.get_model("passbook_stages_prompt", "Prompt")
UserWriteStage = apps.get_model("passbook_stages_user_write", "UserWriteStage")
UserLoginStage = apps.get_model("passbook_stages_user_login", "UserLoginStage")
db_alias = schema_editor.connection.alias
# Create a policy that only allows this flow when doing an SSO Request
flow_policy = ExpressionPolicy.objects.create(
name="default-source-enrollment-if-sso", expression=FLOW_POLICY_EXPRESSION
)
# This creates a Flow used by sources to enroll users
# It makes sure that a username is set, and if not, prompts the user for a Username
flow = Flow.objects.create(
name="default-source-enrollment",
slug="default-source-enrollment",
designation=FlowDesignation.ENROLLMENT,
)
PolicyBinding.objects.create(policy=flow_policy, target=flow, order=0)
# PromptStage to ask user for their username
prompt_stage = PromptStage.objects.create(
name="default-source-enrollment-username-prompt",
)
prompt_stage.fields.add(
Prompt.objects.create(
field_key="username",
label="Username",
type=FieldTypes.TEXT,
required=True,
placeholder="Username",
)
)
# Policy to only trigger prompt when no username is given
prompt_policy = ExpressionPolicy.objects.create(
name="default-source-enrollment-if-username",
expression=PROMPT_POLICY_EXPRESSION,
)
# UserWrite stage to create the user, and login stage to log user in
user_write = UserWriteStage.objects.create(name="default-source-enrollment-write")
user_login = UserLoginStage.objects.create(name="default-source-enrollment-login")
binding = FlowStageBinding.objects.create(flow=flow, stage=prompt_stage, order=0)
PolicyBinding.objects.create(policy=prompt_policy, target=binding)
FlowStageBinding.objects.create(flow=flow, stage=user_write, order=1)
FlowStageBinding.objects.create(flow=flow, stage=user_login, order=2)
def create_default_source_authentication_flow(
apps: Apps, schema_editor: BaseDatabaseSchemaEditor
):
Flow = apps.get_model("passbook_flows", "Flow")
FlowStageBinding = apps.get_model("passbook_flows", "FlowStageBinding")
PolicyBinding = apps.get_model("passbook_policies", "PolicyBinding")
ExpressionPolicy = apps.get_model(
"passbook_policies_expression", "ExpressionPolicy"
)
UserLoginStage = apps.get_model("passbook_stages_user_login", "UserLoginStage")
db_alias = schema_editor.connection.alias
# Create a policy that only allows this flow when doing an SSO Request
flow_policy = ExpressionPolicy.objects.create(
name="default-source-authentication-if-sso", expression=FLOW_POLICY_EXPRESSION
)
# This creates a Flow used by sources to authenticate users
flow = Flow.objects.create(
name="default-source-authentication",
slug="default-source-authentication",
designation=FlowDesignation.AUTHENTICATION,
)
PolicyBinding.objects.create(policy=flow_policy, target=flow, order=0)
user_login = UserLoginStage.objects.create(
name="default-source-authentication-login"
)
FlowStageBinding.objects.create(flow=flow, stage=user_login, order=0)
class Migration(migrations.Migration):
dependencies = [
("passbook_flows", "0003_auto_20200523_1133"),
("passbook_policies", "0001_initial"),
("passbook_policies_expression", "0001_initial"),
("passbook_stages_prompt", "0001_initial"),
("passbook_stages_user_write", "0001_initial"),
("passbook_stages_user_login", "0001_initial"),
]
operations = [
migrations.RunPython(create_default_source_enrollment_flow),
migrations.RunPython(create_default_source_authentication_flow),
]

View File

@ -0,0 +1,44 @@
# Generated by Django 3.0.6 on 2020-05-24 11:34
from django.apps.registry import Apps
from django.db import migrations
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
from passbook.flows.models import FlowDesignation
def create_default_provider_authz_flow(
apps: Apps, schema_editor: BaseDatabaseSchemaEditor
):
Flow = apps.get_model("passbook_flows", "Flow")
FlowStageBinding = apps.get_model("passbook_flows", "FlowStageBinding")
ConsentStage = apps.get_model("passbook_stages_consent", "ConsentStage")
db_alias = schema_editor.connection.alias
# Empty flow for providers where no consent is needed
Flow.objects.create(
name="default-provider-authorization",
slug="default-provider-authorization",
designation=FlowDesignation.AUTHORIZATION,
)
# Flow with consent form to obtain user consent for authorization
flow = Flow.objects.create(
name="default-provider-authorization-consent",
slug="default-provider-authorization-consent",
designation=FlowDesignation.AUTHORIZATION,
)
stage = ConsentStage.objects.create(name="default-provider-authorization-consent")
FlowStageBinding.objects.create(flow=flow, stage=stage, order=0)
class Migration(migrations.Migration):
dependencies = [
("passbook_flows", "0004_source_flows"),
("passbook_stages_consent", "0001_initial"),
]
operations = [migrations.RunPython(create_default_provider_authz_flow)]

View File

@ -1,20 +1,26 @@
"""Flow models"""
from typing import Optional
from typing import Callable, Optional
from uuid import uuid4
from django.db import models
from django.http import HttpRequest
from django.utils.translation import gettext_lazy as _
from model_utils.managers import InheritanceManager
from structlog import get_logger
from passbook.core.types import UIUserSettings
from passbook.lib.utils.reflection import class_to_path
from passbook.policies.models import PolicyBindingModel
LOGGER = get_logger()
class FlowDesignation(models.TextChoices):
"""Designation of what a Flow should be used for. At a later point, this
should be replaced by a database entry."""
AUTHENTICATION = "authentication"
AUTHORIZATION = "authorization"
INVALIDATION = "invalidation"
ENROLLMENT = "enrollment"
UNRENOLLMENT = "unenrollment"
@ -44,6 +50,14 @@ class Stage(models.Model):
return f"Stage {self.name}"
def in_memory_stage(_type: Callable) -> Stage:
"""Creates an in-memory stage instance, based on a `_type` as view."""
class_path = class_to_path(_type)
stage = Stage()
stage.type = class_path
return stage
class Flow(PolicyBindingModel):
"""Flow describes how a series of Stages should be executed to authenticate/enroll/recover
a user. Additionally, policies can be applied, to specify which users
@ -62,10 +76,29 @@ class Flow(PolicyBindingModel):
PolicyBindingModel, parent_link=True, on_delete=models.CASCADE, related_name="+"
)
def related_flow(self, designation: str) -> Optional["Flow"]:
@staticmethod
def with_policy(request: HttpRequest, **flow_filter) -> Optional["Flow"]:
"""Get a Flow by `**flow_filter` and check if the request from `request` can access it."""
from passbook.policies.engine import PolicyEngine
flows = Flow.objects.filter(**flow_filter)
for flow in flows:
engine = PolicyEngine(flow, request.user, request)
engine.build()
result = engine.result
if result.passing:
LOGGER.debug("with_policy: flow passing", flow=flow)
return flow
LOGGER.warning(
"with_policy: flow not passing", flow=flow, messages=result.messages
)
LOGGER.debug("with_policy: no flow found", filters=flow_filter)
return None
def related_flow(self, designation: str, request: HttpRequest) -> Optional["Flow"]:
"""Get a related flow with `designation`. Currently this only queries
Flows by `designation`, but will eventually use `self` for related lookups."""
return Flow.objects.filter(designation=designation).first()
return Flow.with_policy(request, designation=designation)
def __str__(self) -> str:
return f"Flow {self.name} ({self.slug})"

View File

@ -11,12 +11,12 @@ from passbook.core.models import User
from passbook.flows.exceptions import EmptyFlowException, FlowNonApplicableException
from passbook.flows.models import Flow, Stage
from passbook.policies.engine import PolicyEngine
from passbook.policies.types import PolicyResult
LOGGER = get_logger()
PLAN_CONTEXT_PENDING_USER = "pending_user"
PLAN_CONTEXT_SSO = "is_sso"
PLAN_CONTEXT_APPLICATION = "application"
def cache_key(flow: Flow, user: Optional[User] = None) -> str:
@ -46,28 +46,21 @@ class FlowPlanner:
that should be applied."""
use_cache: bool
allow_empty_flows: bool
flow: Flow
def __init__(self, flow: Flow):
self.use_cache = True
self.allow_empty_flows = False
self.flow = flow
def _check_flow_root_policies(self, request: HttpRequest) -> PolicyResult:
engine = PolicyEngine(self.flow, request.user, request)
engine.build()
return engine.result
def plan(
self, request: HttpRequest, default_context: Optional[Dict[str, Any]] = None
) -> FlowPlan:
"""Check each of the flows' policies, check policies for each stage with PolicyBinding
and return ordered list"""
LOGGER.debug("f(plan): Starting planning process", flow=self.flow)
# First off, check the flow's direct policy bindings
# to make sure the user even has access to the flow
root_result = self._check_flow_root_policies(request)
if not root_result.passing:
raise FlowNonApplicableException(*root_result.messages)
# Bit of a workaround here, if there is a pending user set in the default context
# we use that user for our cache key
# to make sure they don't get the generic response
@ -75,16 +68,29 @@ class FlowPlanner:
user = default_context[PLAN_CONTEXT_PENDING_USER]
else:
user = request.user
# First off, check the flow's direct policy bindings
# to make sure the user even has access to the flow
engine = PolicyEngine(self.flow, user, request)
if default_context:
engine.request.context = default_context
engine.build()
result = engine.result
if not result.passing:
raise FlowNonApplicableException(result.messages)
# User is passing so far, check if we have a cached plan
cached_plan_key = cache_key(self.flow, user)
cached_plan = cache.get(cached_plan_key, None)
if cached_plan and self.use_cache:
LOGGER.debug(
"f(plan): Taking plan from cache", flow=self.flow, key=cached_plan_key
)
# Reset the context as this isn't factored into caching
cached_plan.context = default_context or {}
return cached_plan
LOGGER.debug("f(plan): building plan", flow=self.flow)
plan = self._build_plan(user, request, default_context)
cache.set(cache_key(self.flow, user), plan)
if not plan.stages:
if not plan.stages and not self.allow_empty_flows:
raise EmptyFlowException()
return plan

View File

@ -113,19 +113,18 @@ const updateMessages = () => {
});
});
};
const updateCard = (response) => {
if (!response.ok) {
console.log("well");
}
if (response.redirected && !response.url.endsWith(flowBodyUrl)) {
window.location = response.url;
} else {
response.text().then(text => {
flowBody.innerHTML = text;
const updateCard = (data) => {
switch (data.type) {
case "redirect":
window.location = data.to
break;
case "template":
flowBody.innerHTML = data.body;
updateMessages();
loadFormCode();
setFormSubmitHandlers();
});
default:
break;
}
};
const showSpinner = () => {
@ -139,10 +138,28 @@ const loadFormCode = () => {
document.head.appendChild(newScript);
});
};
const updateFormAction = (form) => {
for (let index = 0; index < form.elements.length; index++) {
const element = form.elements[index];
if (element.value === form.action) {
console.log("Found Form action URL in form elements, not changing form action.");
return false;
}
}
form.action = flowBodyUrl;
return true;
};
const checkAutosubmit = (form) => {
if ("autosubmit" in form.attributes) {
return form.submit();
}
};
const setFormSubmitHandlers = () => {
document.querySelectorAll("#flow-body form").forEach(form => {
console.log(`Checking for autosubmit attribute ${form}`);
checkAutosubmit(form);
console.log(`Setting action for form ${form}`);
form.action = flowBodyUrl;
updateFormAction(form);
console.log(`Adding handler for form ${form}`);
form.addEventListener('submit', (e) => {
e.preventDefault();
@ -150,19 +167,13 @@ const setFormSubmitHandlers = () => {
fetch(flowBodyUrl, {
method: 'post',
body: formData,
}).then((response) => {
showSpinner();
if (!response.url.endsWith(flowBodyUrl)) {
window.location = response.url;
} else {
updateCard(response);
}
}).then(response => response.json()).then(data => {
updateCard(data);
});
});
});
};
fetch(flowBodyUrl).then(updateCard);
fetch(flowBodyUrl).then(response => response.json()).then(data => updateCard(data));
</script>
{% endblock %}

View File

@ -1,17 +1,19 @@
"""flow planner tests"""
from unittest.mock import MagicMock, patch
from unittest.mock import MagicMock, PropertyMock, patch
from django.core.cache import cache
from django.shortcuts import reverse
from django.test import RequestFactory, TestCase
from guardian.shortcuts import get_anonymous_user
from passbook.core.models import User
from passbook.flows.exceptions import EmptyFlowException, FlowNonApplicableException
from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding
from passbook.flows.planner import FlowPlanner
from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlanner, cache_key
from passbook.policies.types import PolicyResult
from passbook.stages.dummy.models import DummyStage
POLICY_RESULT_MOCK = MagicMock(return_value=PolicyResult(False))
POLICY_RESULT_MOCK = PropertyMock(return_value=PolicyResult(False))
TIME_NOW_MOCK = MagicMock(return_value=3)
@ -38,8 +40,7 @@ class TestFlowPlanner(TestCase):
planner.plan(request)
@patch(
"passbook.flows.planner.FlowPlanner._check_flow_root_policies",
POLICY_RESULT_MOCK,
"passbook.policies.engine.PolicyEngine.result", POLICY_RESULT_MOCK,
)
def test_non_applicable_plan(self):
"""Test that empty plan raises exception"""
@ -81,3 +82,24 @@ class TestFlowPlanner(TestCase):
self.assertEqual(
TIME_NOW_MOCK.call_count, 2
) # When taking from cache, time is not measured
def test_planner_default_context(self):
"""Test planner with default_context"""
flow = Flow.objects.create(
name="test-default-context",
slug="test-default-context",
designation=FlowDesignation.AUTHENTICATION,
)
FlowStageBinding.objects.create(
flow=flow, stage=DummyStage.objects.create(name="dummy"), order=0
)
user = User.objects.create(username="test-user")
request = self.request_factory.get(
reverse("passbook_flows:flow-executor", kwargs={"flow_slug": flow.slug}),
)
request.user = user
planner = FlowPlanner(flow)
planner.plan(request, default_context={PLAN_CONTEXT_PENDING_USER: user})
key = cache_key(flow, user)
self.assertTrue(cache.get(key) is not None)

View File

@ -1,5 +1,5 @@
"""flow views tests"""
from unittest.mock import MagicMock, patch
from unittest.mock import MagicMock, PropertyMock, patch
from django.shortcuts import reverse
from django.test import Client, TestCase
@ -12,7 +12,7 @@ from passbook.lib.config import CONFIG
from passbook.policies.types import PolicyResult
from passbook.stages.dummy.models import DummyStage
POLICY_RESULT_MOCK = MagicMock(return_value=PolicyResult(False))
POLICY_RESULT_MOCK = PropertyMock(return_value=PolicyResult(False))
class TestFlowExecutor(TestCase):
@ -45,8 +45,7 @@ class TestFlowExecutor(TestCase):
self.assertEqual(cancel_mock.call_count, 1)
@patch(
"passbook.flows.planner.FlowPlanner._check_flow_root_policies",
POLICY_RESULT_MOCK,
"passbook.policies.engine.PolicyEngine.result", POLICY_RESULT_MOCK,
)
def test_invalid_non_applicable_flow(self):
"""Tests that a non-applicable flow returns the correct error message"""

View File

@ -1,8 +1,15 @@
"""passbook multi-stage authentication engine"""
from typing import Any, Dict, Optional
from django.http import HttpRequest, HttpResponse
from django.http import (
Http404,
HttpRequest,
HttpResponse,
HttpResponseRedirect,
JsonResponse,
)
from django.shortcuts import get_object_or_404, redirect, reverse
from django.template.response import TemplateResponse
from django.utils.decorators import method_decorator
from django.views.decorators.clickjacking import xframe_options_sameorigin
from django.views.generic import TemplateView, View
@ -34,7 +41,7 @@ class FlowExecutorView(View):
def setup(self, request: HttpRequest, flow_slug: str):
super().setup(request, flow_slug=flow_slug)
self.flow = get_object_or_404(Flow, slug=flow_slug)
self.flow = get_object_or_404(Flow.objects.select_related(), slug=flow_slug)
def handle_invalid_flow(self, exc: BaseException) -> HttpResponse:
"""When a flow is non-applicable check if user is on the correct domain"""
@ -81,6 +88,8 @@ class FlowExecutorView(View):
)
stage_cls = path_to_class(self.current_stage.type)
self.current_stage_view = stage_cls(self)
self.current_stage_view.args = self.args
self.current_stage_view.kwargs = self.kwargs
self.current_stage_view.request = request
return super().dispatch(request)
@ -91,7 +100,8 @@ class FlowExecutorView(View):
view_class=class_to_path(self.current_stage_view.__class__),
flow_slug=self.flow.slug,
)
return self.current_stage_view.get(request, *args, **kwargs)
stage_response = self.current_stage_view.get(request, *args, **kwargs)
return to_stage_response(request, stage_response)
def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
"""pass post request to current stage"""
@ -100,7 +110,8 @@ class FlowExecutorView(View):
view_class=class_to_path(self.current_stage_view.__class__),
flow_slug=self.flow.slug,
)
return self.current_stage_view.post(request, *args, **kwargs)
stage_response = self.current_stage_view.post(request, *args, **kwargs)
return to_stage_response(request, stage_response)
def _initiate_plan(self) -> FlowPlan:
planner = FlowPlanner(self.flow)
@ -164,7 +175,9 @@ class ToDefaultFlow(View):
designation: Optional[FlowDesignation] = None
def dispatch(self, request: HttpRequest) -> HttpResponse:
flow = get_object_or_404(Flow, designation=self.designation)
flow = Flow.with_policy(request, designation=self.designation)
if not flow:
raise Http404
# If user already has a pending plan, clear it so we don't have to later.
if SESSION_KEY_PLAN in self.request.session:
plan: FlowPlan = self.request.session[SESSION_KEY_PLAN]
@ -189,3 +202,22 @@ class FlowExecutorShellView(TemplateView):
kwargs["exec_url"] = reverse("passbook_flows:flow-executor", kwargs=self.kwargs)
kwargs["msg_url"] = reverse("passbook_api:messages-list")
return kwargs
def to_stage_response(request: HttpRequest, source: HttpResponse) -> HttpResponse:
"""Convert normal HttpResponse into JSON Response"""
if isinstance(source, HttpResponseRedirect) or source.status_code == 302:
redirect_url = source["Location"]
if request.path != redirect_url:
return JsonResponse({"type": "redirect", "to": redirect_url})
return source
if isinstance(source, TemplateResponse):
return JsonResponse(
{"type": "template", "body": source.render().content.decode("utf-8")}
)
# Check for actual HttpResponse (without isinstance as we dont want to check inheritance)
if source.__class__ == HttpResponse:
return JsonResponse(
{"type": "template", "body": source.content.decode("utf-8")}
)
return source

View File

View File

@ -0,0 +1,101 @@
"""passbook expression policy evaluator"""
import re
from textwrap import indent
from typing import Any, Dict, Iterable, Optional
from django.core.exceptions import ValidationError
from requests import Session
from structlog import get_logger
from passbook.core.models import User
LOGGER = get_logger()
class BaseEvaluator:
"""Validate and evaluate python-based expressions"""
# Globals that can be used by function
_globals: Dict[str, Any]
# Context passed as locals to exec()
_context: Dict[str, Any]
# Filename used for exec
_filename: str
def __init__(self):
# update passbook/policies/expression/templates/policy/expression/form.html
# update docs/policies/expression/index.md
self._globals = {
"regex_match": BaseEvaluator.expr_filter_regex_match,
"regex_replace": BaseEvaluator.expr_filter_regex_replace,
"pb_is_group_member": BaseEvaluator.expr_func_is_group_member,
"pb_user_by": BaseEvaluator.expr_func_user_by,
"pb_logger": get_logger(),
"requests": Session(),
}
self._context = {}
self._filename = "BaseEvalautor"
@staticmethod
def expr_filter_regex_match(value: Any, regex: str) -> bool:
"""Expression Filter to run re.search"""
return re.search(regex, value) is None
@staticmethod
def expr_filter_regex_replace(value: Any, regex: str, repl: str) -> str:
"""Expression Filter to run re.sub"""
return re.sub(regex, repl, value)
@staticmethod
def expr_func_user_by(**filters) -> Optional[User]:
"""Get user by filters"""
users = User.objects.filter(**filters)
if users:
return users.first()
return None
@staticmethod
def expr_func_is_group_member(user: User, **group_filters) -> bool:
"""Check if `user` is member of group with name `group_name`"""
return user.groups.filter(**group_filters).exists()
def wrap_expression(self, expression: str, params: Iterable[str]) -> str:
"""Wrap expression in a function, call it, and save the result as `result`"""
handler_signature = ",".join(params)
full_expression = f"def handler({handler_signature}):\n"
full_expression += indent(expression, " ")
full_expression += f"\nresult = handler({handler_signature})"
return full_expression
def evaluate(self, expression_source: str) -> Any:
"""Parse and evaluate expression. If the syntax is incorrect, a SyntaxError is raised.
If any exception is raised during execution, it is raised.
The result is returned without any type-checking."""
param_keys = self._context.keys()
ast_obj = compile(
self.wrap_expression(expression_source, param_keys), self._filename, "exec",
)
try:
_locals = self._context
# Yes this is an exec, yes it is potentially bad. Since we limit what variables are
# available here, and these policies can only be edited by admins, this is a risk
# we're willing to take.
# pylint: disable=exec-used
exec(ast_obj, self._globals, _locals) # nosec # noqa
result = _locals["result"]
except Exception as exc:
LOGGER.warning("Expression error", exc=exc)
raise
return result
def validate(self, expression: str) -> bool:
"""Validate expression's syntax, raise ValidationError if Syntax is invalid"""
param_keys = self._context.keys()
try:
compile(
self.wrap_expression(expression, param_keys), self._filename, "exec",
)
return True
except (ValueError, SyntaxError) as exc:
raise ValidationError(f"Expression Syntax Error: {str(exc)}") from exc

View File

@ -1,4 +1,6 @@
"""passbook policies app config"""
from importlib import import_module
from django.apps import AppConfig
@ -8,3 +10,7 @@ class PassbookPoliciesConfig(AppConfig):
name = "passbook.policies"
label = "passbook_policies"
verbose_name = "passbook Policies"
def ready(self):
"""Load source_types from config file"""
import_module("passbook.policies.signals")

View File

@ -73,16 +73,20 @@ class PolicyEngine:
"""Build task group"""
for binding in self._iter_bindings():
self._check_policy_type(binding.policy)
policy = binding.policy
cached_policy = cache.get(cache_key(binding, self.request.user), None)
key = cache_key(binding, self.request)
cached_policy = cache.get(key, None)
if cached_policy and self.use_cache:
LOGGER.debug("P_ENG: Taking result from cache", policy=policy)
LOGGER.debug(
"P_ENG: Taking result from cache",
policy=binding.policy,
cache_key=key,
)
self.__cached_policies.append(cached_policy)
continue
LOGGER.debug("P_ENG: Evaluating policy", policy=policy)
LOGGER.debug("P_ENG: Evaluating policy", policy=binding.policy)
our_end, task_end = Pipe(False)
task = PolicyProcess(binding, self.request, task_end)
LOGGER.debug("P_ENG: Starting Process", policy=policy)
LOGGER.debug("P_ENG: Starting Process", policy=binding.policy)
task.start()
self.__processes.append(
PolicyProcessInfo(process=task, connection=our_end, binding=binding)
@ -103,7 +107,9 @@ class PolicyEngine:
x.result for x in self.__processes if x.result
]
for result in process_results + self.__cached_policies:
LOGGER.debug("P_ENG: result", passing=result.passing)
LOGGER.debug(
"P_ENG: result", passing=result.passing, messages=result.messages
)
if result.messages:
messages += result.messages
if not result.passing:

View File

@ -1,103 +1,71 @@
"""passbook expression policy evaluator"""
import re
from typing import TYPE_CHECKING, Any, Dict, Optional
from typing import List
from django.core.exceptions import ValidationError
from jinja2 import Undefined
from jinja2.exceptions import TemplateSyntaxError
from jinja2.nativetypes import NativeEnvironment
from requests import Session
from django.http import HttpRequest
from structlog import get_logger
from passbook.flows.planner import PLAN_CONTEXT_SSO
from passbook.flows.views import SESSION_KEY_PLAN
from passbook.lib.expression.evaluator import BaseEvaluator
from passbook.lib.utils.http import get_client_ip
from passbook.policies.types import PolicyRequest, PolicyResult
if TYPE_CHECKING:
from passbook.core.models import User
LOGGER = get_logger()
class Evaluator:
"""Validate and evaluate jinja2-based expressions"""
class PolicyEvaluator(BaseEvaluator):
"""Validate and evaluate python-based expressions"""
_env: NativeEnvironment
_messages: List[str]
def __init__(self):
self._env = NativeEnvironment()
def __init__(self, policy_name: str):
super().__init__()
self._messages = []
self._context["pb_message"] = self.expr_func_message
self._filename = policy_name
def expr_func_message(self, message: str):
"""Wrapper to append to messages list, which is returned with PolicyResult"""
self._messages.append(message)
def set_policy_request(self, request: PolicyRequest):
"""Update context based on policy request (if http request is given, update that too)"""
# update passbook/policies/expression/templates/policy/expression/form.html
# update docs/policies/expression/index.md
self._env.filters["regex_match"] = Evaluator.jinja2_filter_regex_match
self._env.filters["regex_replace"] = Evaluator.jinja2_filter_regex_replace
@staticmethod
def jinja2_filter_regex_match(value: Any, regex: str) -> bool:
"""Jinja2 Filter to run re.search"""
return re.search(regex, value) is None
@staticmethod
def jinja2_filter_regex_replace(value: Any, regex: str, repl: str) -> str:
"""Jinja2 Filter to run re.sub"""
return re.sub(regex, repl, value)
@staticmethod
def jinja2_func_is_group_member(user: "User", group_name: str) -> bool:
"""Check if `user` is member of group with name `group_name`"""
return user.groups.filter(name=group_name).exists()
def _get_expression_context(
self, request: PolicyRequest, **kwargs
) -> Dict[str, Any]:
"""Return dictionary with additional global variables passed to expression"""
# update passbook/policies/expression/templates/policy/expression/form.html
# update docs/policies/expression/index.md
kwargs["pb_is_group_member"] = Evaluator.jinja2_func_is_group_member
kwargs["pb_logger"] = get_logger()
kwargs["requests"] = Session()
kwargs["pb_is_sso_flow"] = request.context.get(PLAN_CONTEXT_SSO, False)
self._context["pb_is_sso_flow"] = request.context.get(PLAN_CONTEXT_SSO, False)
if request.http_request:
kwargs["pb_client_ip"] = (
get_client_ip(request.http_request) or "255.255.255.255"
)
if SESSION_KEY_PLAN in request.http_request.session:
kwargs["pb_flow_plan"] = request.http_request.session[SESSION_KEY_PLAN]
return kwargs
self.set_http_request(request.http_request)
self._context["request"] = request
def evaluate(self, expression_source: str, request: PolicyRequest) -> PolicyResult:
"""Parse and evaluate expression.
If the Expression evaluates to a list with 2 items, the first is used as passing bool and
the second as messages.
If the Expression evaluates to a truthy-object, it is used as passing bool."""
def set_http_request(self, request: HttpRequest):
"""Update context based on http request"""
# update passbook/policies/expression/templates/policy/expression/form.html
# update docs/policies/expression/index.md
self._context["pb_client_ip"] = get_client_ip(request) or "255.255.255.255"
self._context["request"] = request
if SESSION_KEY_PLAN in request.session:
self._context["pb_flow_plan"] = request.session[SESSION_KEY_PLAN]
def evaluate(self, expression_source: str) -> PolicyResult:
"""Parse and evaluate expression. Policy is expected to return a truthy object.
Messages can be added using 'do pb_message()'."""
try:
expression = self._env.from_string(expression_source)
except TemplateSyntaxError as exc:
result = super().evaluate(expression_source)
except (ValueError, SyntaxError) as exc:
return PolicyResult(False, str(exc))
try:
result: Optional[Any] = expression.render(
request=request, **self._get_expression_context(request)
)
if isinstance(result, Undefined):
LOGGER.warning(
"Expression policy returned undefined",
src=expression_source,
req=request,
)
return PolicyResult(False)
if isinstance(result, (list, tuple)) and len(result) == 2:
return PolicyResult(*result)
if result:
return PolicyResult(bool(result))
return PolicyResult(False)
except Exception as exc: # pylint: disable=broad-except
LOGGER.warning("Expression error", exc=exc)
return PolicyResult(False, str(exc))
def validate(self, expression: str):
"""Validate expression's syntax, raise ValidationError if Syntax is invalid"""
try:
self._env.from_string(expression)
return True
except TemplateSyntaxError as exc:
raise ValidationError("Expression Syntax Error") from exc
else:
policy_result = PolicyResult(False)
policy_result.messages = tuple(self._messages)
if result is None:
LOGGER.warning(
"Expression policy returned None",
src=expression_source,
req=self._context,
)
policy_result.passing = False
if result:
policy_result.passing = bool(result)
return policy_result

View File

@ -3,7 +3,7 @@
from django import forms
from passbook.admin.fields import CodeMirrorWidget
from passbook.policies.expression.evaluator import Evaluator
from passbook.policies.expression.evaluator import PolicyEvaluator
from passbook.policies.expression.models import ExpressionPolicy
from passbook.policies.forms import GENERAL_FIELDS
@ -14,9 +14,9 @@ class ExpressionPolicyForm(forms.ModelForm):
template_name = "policy/expression/form.html"
def clean_expression(self):
"""Test Jinja2 Syntax"""
"""Test Syntax"""
expression = self.cleaned_data.get("expression")
Evaluator().validate(expression)
PolicyEvaluator(self.instance.name).validate(expression)
return expression
class Meta:
@ -27,5 +27,5 @@ class ExpressionPolicyForm(forms.ModelForm):
]
widgets = {
"name": forms.TextInput(),
"expression": CodeMirrorWidget(mode="jinja2"),
"expression": CodeMirrorWidget(mode="python"),
}

View File

@ -2,13 +2,13 @@
from django.db import models
from django.utils.translation import gettext as _
from passbook.policies.expression.evaluator import Evaluator
from passbook.policies.expression.evaluator import PolicyEvaluator
from passbook.policies.models import Policy
from passbook.policies.types import PolicyRequest, PolicyResult
class ExpressionPolicy(Policy):
"""Jinja2-based Expression policy that allows Admins to write their own logic"""
"""Implement custom logic using python."""
expression = models.TextField()
@ -16,10 +16,12 @@ class ExpressionPolicy(Policy):
def passes(self, request: PolicyRequest) -> PolicyResult:
"""Evaluate and render expression. Returns PolicyResult(false) on error."""
return Evaluator().evaluate(self.expression, request)
evaluator = PolicyEvaluator(self.name)
evaluator.set_policy_request(request)
return evaluator.evaluate(self.expression)
def save(self, *args, **kwargs):
Evaluator().validate(self.expression)
PolicyEvaluator(self.name).validate(self.expression)
return super().save(*args, **kwargs)
class Meta:

View File

@ -3,7 +3,7 @@ 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.expression.evaluator import PolicyEvaluator
from passbook.policies.types import PolicyRequest
@ -15,44 +15,48 @@ class TestEvaluator(TestCase):
def test_valid(self):
"""test simple value expression"""
template = "True"
evaluator = Evaluator()
self.assertEqual(evaluator.evaluate(template, self.request).passing, True)
template = "return True"
evaluator = PolicyEvaluator("test")
evaluator.set_policy_request(self.request)
self.assertEqual(evaluator.evaluate(template).passing, True)
def test_messages(self):
"""test expression with message return"""
template = "False, 'some message'"
evaluator = Evaluator()
result = evaluator.evaluate(template, self.request)
template = 'pb_message("some message");return False'
evaluator = PolicyEvaluator("test")
evaluator.set_policy_request(self.request)
result = evaluator.evaluate(template)
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)
template = ";"
evaluator = PolicyEvaluator("test")
evaluator.set_policy_request(self.request)
result = evaluator.evaluate(template)
self.assertEqual(result.passing, False)
self.assertEqual(result.messages, ("tag name expected",))
self.assertEqual(result.messages, ("invalid syntax (test, line 2)",))
def test_undefined(self):
"""test undefined result"""
template = "{{ foo.bar }}"
evaluator = Evaluator()
result = evaluator.evaluate(template, self.request)
evaluator = PolicyEvaluator("test")
evaluator.set_policy_request(self.request)
result = evaluator.evaluate(template)
self.assertEqual(result.passing, False)
self.assertEqual(result.messages, ("'foo' is undefined",))
self.assertEqual(result.messages, ("name 'foo' is not defined",))
def test_validate(self):
"""test validate"""
template = "True"
evaluator = Evaluator()
evaluator = PolicyEvaluator("test")
result = evaluator.validate(template)
self.assertEqual(result, True)
def test_validate_invalid(self):
"""test validate"""
template = "{%"
evaluator = Evaluator()
template = ";"
evaluator = PolicyEvaluator("test")
with self.assertRaises(ValidationError):
evaluator.validate(template)

View File

@ -6,7 +6,6 @@ from typing import Optional
from django.core.cache import cache
from structlog import get_logger
from passbook.core.models import User
from passbook.policies.exceptions import PolicyException
from passbook.policies.models import PolicyBinding
from passbook.policies.types import PolicyRequest, PolicyResult
@ -14,11 +13,13 @@ from passbook.policies.types import PolicyRequest, PolicyResult
LOGGER = get_logger()
def cache_key(binding: PolicyBinding, user: Optional[User] = None) -> str:
def cache_key(binding: PolicyBinding, request: PolicyRequest) -> str:
"""Generate Cache key for policy"""
prefix = f"policy_{binding.policy_binding_uuid.hex}_{binding.policy.pk.hex}"
if user:
prefix += f"#{user.pk}"
if request.http_request:
prefix += f"_{request.http_request.session.session_key}"
if request.user:
prefix += f"#{request.user.pk}"
return prefix
@ -38,6 +39,8 @@ class PolicyProcess(Process):
super().__init__()
self.binding = binding
self.request = request
if not isinstance(self.request, PolicyRequest):
raise ValueError(f"{self.request} is not a Policy Request.")
if connection:
self.connection = connection
@ -65,7 +68,7 @@ class PolicyProcess(Process):
passing=policy_result.passing,
user=self.request.user,
)
key = cache_key(self.binding, self.request.user)
key = cache_key(self.binding, self.request)
cache.set(key, policy_result)
LOGGER.debug("P_ENG(proc): Cached policy evaluation", key=key)
return policy_result

View File

@ -0,0 +1,26 @@
"""passbook policy signals"""
from django.core.cache import cache
from django.db.models.signals import post_save
from django.dispatch import receiver
from structlog import get_logger
LOGGER = get_logger()
@receiver(post_save)
# pylint: disable=unused-argument
def invalidate_policy_cache(sender, instance, **_):
"""Invalidate Policy cache when policy is updated"""
from passbook.policies.models import Policy, PolicyBinding
if isinstance(instance, Policy):
LOGGER.debug("Invalidating policy cache", policy=instance)
total = 0
for binding in PolicyBinding.objects.filter(policy=instance):
prefix = (
f"policy_{binding.policy_binding_uuid.hex}_{binding.policy.pk.hex}*"
)
keys = cache.keys(prefix)
total += len(keys)
cache.delete_many(keys)
LOGGER.debug("Deleted keys", len=total)

View File

@ -38,5 +38,10 @@ class PolicyResult:
self.passing = passing
self.messages = messages
def __repr__(self):
return self.__str__()
def __str__(self):
return f"<PolicyResult passing={self.passing}>"
if self.messages:
return f"PolicyResult passing={self.passing} messages={self.messages}"
return f"PolicyResult passing={self.passing}"

View File

@ -32,7 +32,7 @@ class ApplicationGatewayProviderForm(forms.ModelForm):
class Meta:
model = ApplicationGatewayProvider
fields = ["name", "internal_host", "external_host"]
fields = ["name", "authorization_flow", "internal_host", "external_host"]
widgets = {
"name": forms.TextInput(),
"internal_host": forms.TextInput(),

View File

@ -14,7 +14,8 @@ from passbook.lib.utils.template import render_to_string
class ApplicationGatewayProvider(Provider):
"""This provider uses oauth2_proxy with the OIDC Provider."""
"""Protect applications that don't support any of the other
Protocols by using a Reverse-Proxy."""
name = models.TextField()
internal_host = models.TextField()

View File

@ -1,21 +1,32 @@
"""passbook OAuth2 Provider Forms"""
from django import forms
from django.utils.translation import gettext_lazy as _
from passbook.flows.models import Flow, FlowDesignation
from passbook.providers.oauth.models import OAuth2Provider
class OAuth2ProviderForm(forms.ModelForm):
"""OAuth2 Provider form"""
authorization_flow = forms.ModelChoiceField(
queryset=Flow.objects.filter(designation=FlowDesignation.AUTHORIZATION)
)
class Meta:
model = OAuth2Provider
fields = [
"name",
"authorization_flow",
"redirect_uris",
"client_type",
"authorization_grant_type",
"client_id",
"client_secret",
]
labels = {
"client_id": _("Client ID"),
"redirect_uris": _("Redirect URIs"),
}

View File

@ -12,7 +12,8 @@ from passbook.lib.utils.template import render_to_string
class OAuth2Provider(Provider, AbstractApplication):
"""Associate an OAuth2 Application with a Product"""
"""Generic OAuth2 Provider for applications not using OpenID-Connect. This Provider
also supports the GitHub-pretend mode."""
form = "passbook.providers.oauth.forms.OAuth2ProviderForm"

View File

@ -24,6 +24,7 @@ OAUTH2_PROVIDER = {
"SCOPES": {
"openid": "Access OpenID Userinfo",
"openid:userinfo": "Access OpenID Userinfo",
"email": "Access OpenID E-Mail",
# 'write': 'Write scope',
# 'groups': 'Access to your groups',
"user:email": "GitHub Compatibility: User E-Mail",

View File

@ -6,17 +6,12 @@ from oauth2_provider import views
from passbook.providers.oauth.views import github, oauth2
oauth_urlpatterns = [
# Custom OAuth 2 Authorize View
# Custom OAuth2 Authorize View
path(
"authorize/",
oauth2.PassbookAuthorizationView.as_view(),
oauth2.AuthorizationFlowInitView.as_view(),
name="oauth2-authorize",
),
path(
"authorize/permission_denied/",
oauth2.OAuthPermissionDenied.as_view(),
name="oauth2-permission-denied",
),
# OAuth API
path("token/", views.TokenView.as_view(), name="token"),
path("revoke_token/", views.RevokeTokenView.as_view(), name="revoke-token"),
@ -26,7 +21,7 @@ oauth_urlpatterns = [
github_urlpatterns = [
path(
"login/oauth/authorize",
oauth2.PassbookAuthorizationView.as_view(),
oauth2.AuthorizationFlowInitView.as_view(),
name="github-authorize",
),
path(
@ -35,6 +30,7 @@ github_urlpatterns = [
name="github-access-token",
),
path("user", github.GitHubUserView.as_view(), name="github-user"),
path("user/teams", github.GitHubUserTeamsView.as_view(), name="github-user-teams"),
]
urlpatterns = [

View File

@ -1,21 +1,32 @@
"""passbook pretend GitHub Views"""
from django.http import JsonResponse
from django.core.exceptions import PermissionDenied
from django.http import HttpRequest, HttpResponse, JsonResponse
from django.shortcuts import get_object_or_404
from django.views import View
from oauth2_provider.models import AccessToken
from passbook.core.models import User
class GitHubUserView(View):
class GitHubPretendView(View):
"""Emulate GitHub's API Endpoints"""
def verify_access_token(self) -> User:
"""Verify access token manually since github uses /user?access_token=..."""
if "HTTP_AUTHORIZATION" in self.request.META:
full_token = self.request.META.get("HTTP_AUTHORIZATION")
_, token = full_token.split(" ")
elif "access_token" in self.request.GET:
token = self.request.GET.get("access_token", "")
else:
raise PermissionDenied("No access token passed.")
return get_object_or_404(AccessToken, token=token).user
class GitHubUserView(GitHubPretendView):
"""Emulate GitHub's /user API Endpoint"""
def verify_access_token(self):
"""Verify access token manually since github uses /user?access_token=..."""
token = get_object_or_404(
AccessToken, token=self.request.GET.get("access_token", "")
)
return token.user
def get(self, request):
def get(self, request: HttpRequest) -> HttpResponse:
"""Emulate GitHub's /user API Endpoint"""
user = self.verify_access_token()
return JsonResponse(
@ -65,3 +76,11 @@ class GitHubUserView(View):
},
}
)
class GitHubUserTeamsView(GitHubPretendView):
"""Emulate GitHub's /user/teams API Endpoint"""
def get(self, request: HttpRequest) -> HttpResponse:
"""Emulate GitHub's /user/teams API Endpoint"""
return JsonResponse([], safe=False)

View File

@ -1,76 +1,124 @@
"""passbook OAuth2 Views"""
from typing import Optional
from urllib.parse import urlencode
from django.contrib import messages
from django.forms import Form
from django.http import HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404, redirect, reverse
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
from django.shortcuts import get_object_or_404, redirect
from django.views import View
from oauth2_provider.exceptions import OAuthToolkitError
from oauth2_provider.views.base import AuthorizationView
from structlog import get_logger
from passbook.audit.models import Event, EventAction
from passbook.core.models import Application
from passbook.core.views.access import AccessMixin
from passbook.core.views.utils import PermissionDeniedView
from passbook.flows.models import in_memory_stage
from passbook.flows.planner import (
PLAN_CONTEXT_APPLICATION,
PLAN_CONTEXT_SSO,
FlowPlanner,
)
from passbook.flows.stage import StageView
from passbook.flows.views import SESSION_KEY_PLAN
from passbook.lib.utils.urls import redirect_with_qs
from passbook.providers.oauth.models import OAuth2Provider
LOGGER = get_logger()
PLAN_CONTEXT_CLIENT_ID = "client_id"
PLAN_CONTEXT_REDIRECT_URI = "redirect_uri"
PLAN_CONTEXT_RESPONSE_TYPE = "response_type"
PLAN_CONTEXT_STATE = "state"
class OAuthPermissionDenied(PermissionDeniedView):
"""Show permission denied view"""
PLAN_CONTEXT_CODE_CHALLENGE = "code_challenge"
PLAN_CONTEXT_CODE_CHALLENGE_METHOD = "code_challenge_method"
PLAN_CONTEXT_SCOPE = "scope"
PLAN_CONTEXT_NONCE = "nonce"
class PassbookAuthorizationView(AccessMixin, AuthorizationView):
"""Custom OAuth2 Authorization View which checks policies, etc"""
class AuthorizationFlowInitView(AccessMixin, View):
"""OAuth2 Flow initializer, checks access to application and starts flow"""
_application: Optional[Application] = None
def _inject_response_type(self):
"""Inject response_type into querystring if not set"""
LOGGER.debug("response_type not set, defaulting to 'code'")
querystring = urlencode(self.request.GET)
querystring += "&response_type=code"
return redirect(
reverse("passbook_providers_oauth:oauth2-ok-authorize") + "?" + querystring
)
def dispatch(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
"""Update OAuth2Provider's skip_authorization state"""
# Get client_id to get provider, so we can update skip_authorization field
# pylint: disable=unused-argument
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
"""Check access to application, start FlowPLanner, return to flow executor shell"""
client_id = request.GET.get("client_id")
provider = get_object_or_404(OAuth2Provider, client_id=client_id)
try:
application = self.provider_to_application(provider)
except Application.DoesNotExist:
return redirect("passbook_providers_oauth:oauth2-permission-denied")
# Update field here so oauth-toolkit does work for us
provider.skip_authorization = application.skip_authorization
provider.save()
self._application = application
# Check permissions
result = self.user_has_access(self._application, request.user)
result = self.user_has_access(application, request.user)
if not result.passing:
for policy_message in result.messages:
messages.error(request, policy_message)
return redirect("passbook_providers_oauth:oauth2-permission-denied")
# Some clients don't pass response_type, so we default to code
if "response_type" not in request.GET:
return self._inject_response_type()
actual_response = AuthorizationView.dispatch(self, request, *args, **kwargs)
if actual_response.status_code == 400:
LOGGER.debug("Bad request", redirect_uri=request.GET.get("redirect_uri"))
return actual_response
def form_valid(self, form: Form):
# User has clicked on "Authorize"
Event.new(
EventAction.AUTHORIZE_APPLICATION, authorized_application=self._application,
).from_http(self.request)
LOGGER.debug(
"User authorized Application",
user=self.request.user,
application=self._application,
# Regardless, we start the planner and return to it
planner = FlowPlanner(provider.authorization_flow)
# planner.use_cache = False
planner.allow_empty_flows = True
plan = planner.plan(
self.request,
{
PLAN_CONTEXT_SSO: True,
PLAN_CONTEXT_APPLICATION: application,
PLAN_CONTEXT_CLIENT_ID: client_id,
PLAN_CONTEXT_REDIRECT_URI: request.GET.get(PLAN_CONTEXT_REDIRECT_URI),
PLAN_CONTEXT_RESPONSE_TYPE: request.GET.get(PLAN_CONTEXT_RESPONSE_TYPE),
PLAN_CONTEXT_STATE: request.GET.get(PLAN_CONTEXT_STATE),
PLAN_CONTEXT_SCOPE: request.GET.get(PLAN_CONTEXT_SCOPE),
PLAN_CONTEXT_NONCE: request.GET.get(PLAN_CONTEXT_NONCE),
},
)
return AuthorizationView.form_valid(self, form)
plan.stages.append(in_memory_stage(OAuth2Stage))
self.request.session[SESSION_KEY_PLAN] = plan
return redirect_with_qs(
"passbook_flows:flow-executor-shell",
self.request.GET,
flow_slug=provider.authorization_flow.slug,
)
class OAuth2Stage(AuthorizationView, StageView):
"""OAuth2 Stage, dynamically injected into the plan"""
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
"""Last stage in flow, finalizes OAuth Response and redirects to Client"""
application: Application = self.executor.plan.context[PLAN_CONTEXT_APPLICATION]
provider: OAuth2Provider = application.provider
Event.new(
EventAction.AUTHORIZE_APPLICATION, authorized_application=application,
).from_http(self.request)
credentials = {
"client_id": self.executor.plan.context[PLAN_CONTEXT_CLIENT_ID],
"redirect_uri": self.executor.plan.context[PLAN_CONTEXT_REDIRECT_URI],
"response_type": self.executor.plan.context.get(
PLAN_CONTEXT_RESPONSE_TYPE, None
),
"state": self.executor.plan.context.get(PLAN_CONTEXT_STATE, None),
"nonce": self.executor.plan.context.get(PLAN_CONTEXT_NONCE, None),
}
if self.executor.plan.context.get(PLAN_CONTEXT_CODE_CHALLENGE, False):
credentials[PLAN_CONTEXT_CODE_CHALLENGE] = self.executor.plan.context.get(
PLAN_CONTEXT_CODE_CHALLENGE
)
if self.executor.plan.context.get(PLAN_CONTEXT_CODE_CHALLENGE_METHOD, False):
credentials[
PLAN_CONTEXT_CODE_CHALLENGE_METHOD
] = self.executor.plan.context.get(PLAN_CONTEXT_CODE_CHALLENGE_METHOD)
scopes = self.executor.plan.context.get(PLAN_CONTEXT_SCOPE)
try:
uri, _headers, _body, _status = self.create_authorization_response(
request=self.request,
scopes=scopes,
credentials=credentials,
allow=True,
)
LOGGER.debug("Success url for the request: {0}".format(uri))
except OAuthToolkitError as error:
return self.error_response(error, provider)
self.executor.stage_ok()
return HttpResponseRedirect(self.redirect(uri, provider).url)

View File

@ -1,6 +1,4 @@
"""passbook auth oidc provider app config"""
from importlib import import_module
from django.apps import AppConfig
from django.db.utils import InternalError, OperationalError, ProgrammingError
from django.urls import include, path
@ -15,6 +13,7 @@ class PassbookProviderOIDCConfig(AppConfig):
name = "passbook.providers.oidc"
label = "passbook_providers_oidc"
verbose_name = "passbook Providers.OIDC"
mountpoint = "application/oidc/"
def ready(self):
try:
@ -36,5 +35,3 @@ class PassbookProviderOIDCConfig(AppConfig):
include("oidc_provider.urls", namespace="oidc_provider"),
),
)
import_module("passbook.providers.oidc.signals")

View File

@ -10,6 +10,8 @@ from structlog import get_logger
from passbook.audit.models import Event, EventAction
from passbook.core.models import Application, Provider, User
from passbook.flows.planner import FlowPlan
from passbook.flows.views import SESSION_KEY_PLAN
from passbook.policies.engine import PolicyEngine
LOGGER = get_logger()
@ -46,7 +48,7 @@ def check_permissions(
LOGGER.debug(
"Checking permissions for application", user=user, application=application
)
policy_engine = PolicyEngine(application.policies.all(), user, request)
policy_engine = PolicyEngine(application, user, request)
policy_engine.build()
# Check permissions
@ -56,9 +58,10 @@ def check_permissions(
messages.error(request, policy_message)
return redirect("passbook_providers_oauth:oauth2-permission-denied")
plan: FlowPlan = request.session[SESSION_KEY_PLAN]
Event.new(
EventAction.AUTHORIZE_APPLICATION,
authorized_application=application,
skipped_authorization=False,
flow=plan.flow_pk,
).from_http(request)
return None

View File

@ -4,12 +4,17 @@ from django import forms
from oauth2_provider.generators import generate_client_id, generate_client_secret
from oidc_provider.models import Client
from passbook.flows.models import Flow, FlowDesignation
from passbook.providers.oidc.models import OpenIDProvider
class OIDCProviderForm(forms.ModelForm):
"""OpenID Client form"""
authorization_flow = forms.ModelChoiceField(
queryset=Flow.objects.filter(designation=FlowDesignation.AUTHORIZATION)
)
def __init__(self, *args, **kwargs):
# Correctly load data from 1:1 rel
if "instance" in kwargs and kwargs["instance"]:
@ -17,20 +22,35 @@ class OIDCProviderForm(forms.ModelForm):
super().__init__(*args, **kwargs)
self.fields["client_id"].initial = generate_client_id()
self.fields["client_secret"].initial = generate_client_secret()
try:
self.fields[
"authorization_flow"
].initial = self.instance.openidprovider.authorization_flow
# pylint: disable=no-member
except Client.openidprovider.RelatedObjectDoesNotExist:
pass
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
self.instance.require_consent = False # This is managed by passbook
response = super().save(*args, **kwargs)
# Check if openidprovider class instance exists
if not OpenIDProvider.objects.filter(oidc_client=self.instance).exists():
OpenIDProvider.objects.create(oidc_client=self.instance)
OpenIDProvider.objects.create(
oidc_client=self.instance,
authorization_flow=self.cleaned_data.get("authorization_flow"),
)
self.instance.openidprovider.authorization_flow = self.cleaned_data.get(
"authorization_flow"
)
self.instance.openidprovider.save()
return response
class Meta:
model = Client
fields = [
"name",
"authorization_flow",
"client_type",
"client_id",
"client_secret",

View File

@ -12,7 +12,7 @@ from passbook.lib.utils.template import render_to_string
class OpenIDProvider(Provider):
"""Proxy model for OIDC Client"""
"""OpenID Connect Provider for applications that support OIDC."""
# Since oidc_provider doesn't currently support swappable models
# (https://github.com/juanifioren/django-oidc-provider/pull/305)
@ -28,7 +28,7 @@ class OpenIDProvider(Provider):
return self.oidc_client.name
def __str__(self):
return "OpenID Connect Provider %s" % self.oidc_client.__str__()
return f"OpenID Connect Provider {self.oidc_client.__str__()}"
def html_setup_urls(self, request: HttpRequest) -> Optional[str]:
"""return template and context modal with URLs for authorize, token, openid-config, etc"""
@ -37,14 +37,14 @@ class OpenIDProvider(Provider):
{
"provider": self,
"authorize": request.build_absolute_uri(
reverse("oidc_provider:authorize")
reverse("passbook_providers_oidc:authorize")
),
"token": request.build_absolute_uri(reverse("oidc_provider:token")),
"userinfo": request.build_absolute_uri(
reverse("oidc_provider:userinfo")
),
"provider_info": request.build_absolute_uri(
reverse("oidc_provider:provider-info")
reverse("passbook_providers_oidc:provider-info")
),
},
)

View File

@ -1,15 +0,0 @@
"""OIDC Provider signals"""
from django.db.models.signals import post_save
from django.dispatch import receiver
from passbook.core.models import Application
from passbook.providers.oidc.models import OpenIDProvider
@receiver(post_save, sender=Application)
# pylint: disable=unused-argument
def on_application_save(sender, instance: Application, **_):
"""Synchronize application's skip_authorization with oidc_client's require_consent"""
if isinstance(instance.provider, OpenIDProvider):
instance.provider.oidc_client.require_consent = not instance.skip_authorization
instance.provider.oidc_client.save()

View File

@ -0,0 +1,13 @@
"""oidc provider URLs"""
from django.conf.urls import url
from passbook.providers.oidc.views import AuthorizationFlowInitView, ProviderInfoView
urlpatterns = [
url(r"^authorize/?$", AuthorizationFlowInitView.as_view(), name="authorize"),
url(
r"^\.well-known/openid-configuration/?$",
ProviderInfoView.as_view(),
name="provider-info",
),
]

View File

@ -0,0 +1,127 @@
"""passbook OIDC Views"""
from django.contrib import messages
from django.http import HttpRequest, HttpResponse, JsonResponse
from django.shortcuts import get_object_or_404, redirect, reverse
from django.views import View
from oidc_provider.lib.endpoints.authorize import AuthorizeEndpoint
from oidc_provider.lib.utils.common import get_issuer, get_site_url
from oidc_provider.models import ResponseType
from oidc_provider.views import AuthorizeView
from structlog import get_logger
from passbook.core.models import Application
from passbook.core.views.access import AccessMixin
from passbook.flows.models import in_memory_stage
from passbook.flows.planner import (
PLAN_CONTEXT_APPLICATION,
PLAN_CONTEXT_SSO,
FlowPlan,
FlowPlanner,
)
from passbook.flows.stage import StageView
from passbook.flows.views import SESSION_KEY_PLAN
from passbook.lib.utils.urls import redirect_with_qs
from passbook.providers.oidc.models import OpenIDProvider
LOGGER = get_logger()
PLAN_CONTEXT_PARAMS = "params"
class AuthorizationFlowInitView(AccessMixin, View):
"""OIDC Flow initializer, checks access to application and starts flow"""
# pylint: disable=unused-argument
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
"""Check access to application, start FlowPLanner, return to flow executor shell"""
client_id = request.GET.get("client_id")
provider = get_object_or_404(OpenIDProvider, oidc_client__client_id=client_id)
try:
application = self.provider_to_application(provider)
except Application.DoesNotExist:
return redirect("passbook_providers_oauth:oauth2-permission-denied")
# Check permissions
result = self.user_has_access(application, request.user)
if not result.passing:
for policy_message in result.messages:
messages.error(request, policy_message)
return redirect("passbook_providers_oauth:oauth2-permission-denied")
# Extract params so we can save them in the plan context
endpoint = AuthorizeEndpoint(request)
# Regardless, we start the planner and return to it
planner = FlowPlanner(provider.authorization_flow)
# planner.use_cache = False
planner.allow_empty_flows = True
plan = planner.plan(
self.request,
{
PLAN_CONTEXT_SSO: True,
PLAN_CONTEXT_APPLICATION: application,
PLAN_CONTEXT_PARAMS: endpoint.params,
},
)
plan.stages.append(in_memory_stage(OIDCStage))
self.request.session[SESSION_KEY_PLAN] = plan
return redirect_with_qs(
"passbook_flows:flow-executor-shell",
self.request.GET,
flow_slug=provider.authorization_flow.slug,
)
class FlowAuthorizeEndpoint(AuthorizeEndpoint):
"""Restore params from flow context"""
def _extract_params(self):
plan: FlowPlan = self.request.session[SESSION_KEY_PLAN]
self.params = plan.context[PLAN_CONTEXT_PARAMS]
class OIDCStage(AuthorizeView, StageView):
"""Finall stage, restores params from Flow."""
authorize_endpoint_class = FlowAuthorizeEndpoint
class ProviderInfoView(View):
"""Custom ProviderInfo View which shows our URLs instead"""
# pylint: disable=unused-argument
def get(self, request, *args, **kwargs):
"""Custom ProviderInfo View which shows our URLs instead"""
dic = dict()
site_url = get_site_url(request=request)
dic["issuer"] = get_issuer(site_url=site_url, request=request)
dic["authorization_endpoint"] = site_url + reverse(
"passbook_providers_oidc:authorize"
)
dic["token_endpoint"] = site_url + reverse("oidc_provider:token")
dic["userinfo_endpoint"] = site_url + reverse("oidc_provider:userinfo")
dic["end_session_endpoint"] = site_url + reverse("oidc_provider:end-session")
dic["introspection_endpoint"] = site_url + reverse(
"oidc_provider:token-introspection"
)
types_supported = [
response_type.value for response_type in ResponseType.objects.all()
]
dic["response_types_supported"] = types_supported
dic["jwks_uri"] = site_url + reverse("oidc_provider:jwks")
dic["id_token_signing_alg_values_supported"] = ["HS256", "RS256"]
# See: http://openid.net/specs/openid-connect-core-1_0.html#SubjectIDTypes
dic["subject_types_supported"] = ["public"]
dic["token_endpoint_auth_methods_supported"] = [
"client_secret_post",
"client_secret_basic",
]
response = JsonResponse(dic)
response["Access-Control-Allow-Origin"] = "*"
return response

View File

@ -4,6 +4,8 @@ from django import forms
from django.contrib.admin.widgets import FilteredSelectMultiple
from django.utils.translation import gettext as _
from passbook.core.expression import PropertyMappingEvaluator
from passbook.flows.models import Flow, FlowDesignation
from passbook.providers.saml.models import (
SAMLPropertyMapping,
SAMLProvider,
@ -14,6 +16,9 @@ from passbook.providers.saml.models import (
class SAMLProviderForm(forms.ModelForm):
"""SAML Provider form"""
authorization_flow = forms.ModelChoiceField(
queryset=Flow.objects.filter(designation=FlowDesignation.AUTHORIZATION)
)
processor_path = forms.ChoiceField(
choices=get_provider_choices(), label="Processor"
)
@ -23,10 +28,12 @@ class SAMLProviderForm(forms.ModelForm):
model = SAMLProvider
fields = [
"name",
"authorization_flow",
"processor_path",
"acs_url",
"audience",
"issuer",
"sp_binding",
"assertion_valid_not_before",
"assertion_valid_not_on_or_after",
"session_valid_not_on_or_after",
@ -52,6 +59,13 @@ class SAMLPropertyMappingForm(forms.ModelForm):
template_name = "saml/idp/property_mapping_form.html"
def clean_expression(self):
"""Test Syntax"""
expression = self.cleaned_data.get("expression")
evaluator = PropertyMappingEvaluator()
evaluator.validate(expression)
return expression
class Meta:
model = SAMLPropertyMapping

View File

@ -0,0 +1,63 @@
# Generated by Django 3.0.6 on 2020-05-23 19:32
from django.db import migrations
def create_default_property_mappings(apps, schema_editor):
"""Create default SAML Property Mappings"""
SAMLPropertyMapping = apps.get_model(
"passbook_providers_saml", "SAMLPropertyMapping"
)
db_alias = schema_editor.connection.alias
defaults = [
{
"FriendlyName": "eduPersonPrincipalName",
"Name": "urn:oid:1.3.6.1.4.1.5923.1.1.1.6",
"Expression": "return user.email",
},
{
"FriendlyName": "cn",
"Name": "urn:oid:2.5.4.3",
"Expression": "return user.name",
},
{
"FriendlyName": "mail",
"Name": "urn:oid:0.9.2342.19200300.100.1.3",
"Expression": "return user.email",
},
{
"FriendlyName": "displayName",
"Name": "urn:oid:2.16.840.1.113730.3.1.241",
"Expression": "return user.username",
},
{
"FriendlyName": "uid",
"Name": "urn:oid:0.9.2342.19200300.100.1.1",
"Expression": "return user.pk",
},
{
"FriendlyName": "member-of",
"Name": "member-of",
"Expression": "for group in user.groups.all():\n yield group.name",
},
]
for default in defaults:
SAMLPropertyMapping.objects.using(db_alias).get_or_create(
saml_name=default["Name"],
friendly_name=default["FriendlyName"],
expression=default["Expression"],
defaults={
"name": f"Autogenerated SAML Mapping: {default['FriendlyName']} -> {default['Expression']}"
},
)
class Migration(migrations.Migration):
dependencies = [
("passbook_providers_saml", "0001_initial"),
]
operations = [
migrations.RunPython(create_default_property_mappings),
]

View File

@ -0,0 +1,20 @@
# Generated by Django 3.0.6 on 2020-06-06 13:25
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("passbook_providers_saml", "0002_default_saml_property_mappings"),
]
operations = [
migrations.AddField(
model_name="samlprovider",
name="sp_binding",
field=models.TextField(
choices=[("redirect", "Redirect"), ("post", "Post")], default="redirect"
),
),
]

View File

@ -17,6 +17,13 @@ from passbook.providers.saml.utils.time import timedelta_string_validator
LOGGER = get_logger()
class SAMLBindings(models.TextChoices):
"""SAML Bindings supported by passbook"""
REDIRECT = "redirect"
POST = "post"
class SAMLProvider(Provider):
"""Model to save information about a Remote SAML Endpoint"""
@ -26,6 +33,9 @@ class SAMLProvider(Provider):
acs_url = models.URLField(verbose_name=_("ACS URL"))
audience = models.TextField(default="")
issuer = models.TextField(help_text=_("Also known as EntityID"))
sp_binding = models.TextField(
choices=SAMLBindings.choices, default=SAMLBindings.REDIRECT
)
assertion_valid_not_before = models.TextField(
default="minutes=-5",
@ -118,8 +128,8 @@ class SAMLProvider(Provider):
try:
# pylint: disable=no-member
return reverse(
"passbook_providers_saml:saml-metadata",
kwargs={"application": self.application.slug},
"passbook_providers_saml:metadata",
kwargs={"application_slug": self.application.slug},
)
except Provider.application.RelatedObjectDoesNotExist:
return None

View File

@ -1,4 +1,5 @@
"""Basic SAML Processor"""
from types import GeneratorType
from typing import TYPE_CHECKING, Dict, List, Union
from cryptography.exceptions import InvalidSignature
@ -21,7 +22,7 @@ if TYPE_CHECKING:
# pylint: disable=too-many-instance-attributes
class Processor:
"""Base SAML 2.0 AuthnRequest to Response Processor.
"""Base SAML 2.0 Auth-N-Request to Response Processor.
Sub-classes should provide Service Provider-specific functionality."""
is_idp_initiated = False
@ -111,6 +112,8 @@ class Processor:
request=self._http_request,
provider=self._remote,
)
if value is None:
continue
mapping_payload = {
"Name": mapping.saml_name,
"FriendlyName": mapping.friendly_name,
@ -119,6 +122,8 @@ class Processor:
# differently in the template
if isinstance(value, list):
mapping_payload["ValueArray"] = value
elif isinstance(value, GeneratorType):
mapping_payload["ValueArray"] = list(value)
else:
mapping_payload["Value"] = value
attributes.append(mapping_payload)

View File

@ -4,30 +4,26 @@
{% load i18n %}
{% block card_title %}
{% trans 'Redirecting...' %}
{% blocktrans with app=application.name %}
Redirecting to {{ app }}...
{% endblocktrans %}
{% endblock %}
{% block card %}
<form method="POST" action="{{ url }}">
<form method="POST" action="{{ url }}" autosubmit>
{% csrf_token %}
{% for key, value in attrs.items %}
<input type="hidden" name="{{ key }}" value="{{ value }}">
{% endfor %}
<div class="login-group">
<p>
{% blocktrans with user=user %}
You are logged in as {{ user }}.
{% endblocktrans %}
<a href="{% url 'passbook_flows:default-invalidation' %}">{% trans 'Not you?' %}</a>
</p>
<input class="btn btn-primary btn-block btn-lg" type="submit" value="{% trans 'Continue' %}" />
<div class="pf-c-form__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 class="pf-c-form__group pf-m-action">
<button class="pf-c-button pf-m-primary pf-m-block" type="submit">{% trans 'Continue' %}</button>
</div>
</form>
{% endblock %}
{% block scripts %}
{{ block.super }}
<script>
document.querySelector("form").submit();
</script>
{% endblock %}

View File

@ -1,26 +0,0 @@
{% extends "login/base.html" %}
{% load passbook_utils %}
{% load i18n %}
{% block card %}
<form method="POST" class="pf-c-form">
{% csrf_token %}
<div class="pf-c-form__group">
<h3>
{% blocktrans with provider=provider.application.name %}
You're about to sign into {{ provider }}
{% endblocktrans %}
</h3>
<p>
{% blocktrans with user=user %}
You are logged in as {{ user }}.
{% endblocktrans %}
<a href="{% url 'passbook_flows:default-invalidation' %}">{% trans 'Not you?' %}</a>
</p>
</div>
<div class="pf-c-form__group pf-m-action">
<input class="pf-c-button pf-m-primary pf-m-block" type="submit" value="{% trans 'Continue' %}" />
</div>
</form>
{% endblock %}

View File

@ -11,8 +11,7 @@
</md:KeyDescriptor>
{% endif %}
<md:NameIDFormat>{{ subject_format }}</md:NameIDFormat>
<md:SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="{{ slo_url }}"/>
<md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="{{ sso_post_url }}"/>
<md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="{{ sso_redirect_url }}"/>
<md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="{{ sso_binding_post }}"/>
<md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="{{ sso_binding_redirect }}"/>
</md:IDPSSODescriptor>
</md:EntityDescriptor>

View File

@ -4,31 +4,26 @@ from django.urls import path
from passbook.providers.saml import views
urlpatterns = [
# This view is used to initiate a Login-flow from the IDP
# SSO Bindings
path(
"<slug:application>/login/initiate/",
views.InitiateLoginView.as_view(),
name="saml-login-initiate",
),
# This view is the endpoint a SP would redirect to, and saves data into the session
# this is required as the process view which it redirects to might have to login first.
path(
"<slug:application>/login/", views.LoginBeginView.as_view(), name="saml-login"
"<slug:application_slug>/sso/binding/redirect/",
views.SAMLSSOBindingRedirectView.as_view(),
name="sso-redirect",
),
path(
"<slug:application>/login/authorize/",
views.AuthorizeView.as_view(),
name="saml-login-authorize",
"<slug:application_slug>/sso/binding/post/",
views.SAMLSSOBindingPOSTView.as_view(),
name="sso-post",
),
path("<slug:application>/logout/", views.LogoutView.as_view(), name="saml-logout"),
# SSO IdP Initiated
path(
"<slug:application>/logout/slo/",
views.SLOLogout.as_view(),
name="saml-logout-slo",
"<slug:application_slug>/sso/binding/init/",
views.SAMLSSOBindingInitView.as_view(),
name="sso-init",
),
path(
"<slug:application>/metadata/",
"<slug:application_slug>/metadata/",
views.DescriptorDownloadView.as_view(),
name="saml-metadata",
name="metadata",
),
]

View File

@ -1,15 +1,14 @@
"""passbook SAML IDP Views"""
from typing import Optional
from django.contrib.auth import logout
from django.contrib.auth.mixins import AccessMixin
from django.core.exceptions import ValidationError
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin
from django.core.exceptions import PermissionDenied
from django.core.validators import URLValidator
from django.http import HttpRequest, HttpResponse
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.html import mark_safe
from django.utils.http import urlencode
from django.utils.translation import gettext as _
from django.views import View
from django.views.decorators.csrf import csrf_exempt
@ -18,11 +17,20 @@ from structlog import get_logger
from passbook.audit.models import Event, EventAction
from passbook.core.models import Application, Provider
from passbook.flows.models import in_memory_stage
from passbook.flows.planner import (
PLAN_CONTEXT_APPLICATION,
PLAN_CONTEXT_SSO,
FlowPlanner,
)
from passbook.flows.stage import StageView
from passbook.flows.views import SESSION_KEY_PLAN
from passbook.lib.utils.template import render_to_string
from passbook.lib.utils.urls import redirect_with_qs
from passbook.lib.views import bad_request_message
from passbook.policies.engine import PolicyEngine
from passbook.providers.saml.exceptions import CannotHandleAssertion
from passbook.providers.saml.models import SAMLProvider
from passbook.providers.saml.models import SAMLBindings, SAMLProvider
from passbook.providers.saml.processors.types import SAMLResponseParams
LOGGER = get_logger()
@ -33,69 +41,82 @@ SESSION_KEY_RELAY_STATE = "RelayState"
SESSION_KEY_PARAMS = "SAMLParams"
class AccessRequiredView(AccessMixin, View):
"""Mixin class for Views using a provider instance"""
class SAMLAccessMixin:
"""SAML base access mixin, checks access to an application based on its policies"""
_provider: Optional[SAMLProvider] = None
@property
def provider(self) -> SAMLProvider:
"""Get provider instance"""
if not self._provider:
application = get_object_or_404(
Application, slug=self.kwargs["application"]
)
provider: SAMLProvider = get_object_or_404(
SAMLProvider, pk=application.provider_id
)
self._provider = provider
return self._provider
return self._provider
request: HttpRequest
application: Application
provider: SAMLProvider
def _has_access(self) -> bool:
"""Check if user has access to application"""
policy_engine = PolicyEngine(
self.provider.application.policies.all(), self.request.user, self.request
)
"""Check if user has access to application, add an error if not"""
policy_engine = PolicyEngine(self.application, self.request.user, self.request)
policy_engine.build()
passing = policy_engine.passing
result = policy_engine.result
LOGGER.debug(
"saml_has_access",
"SAMLFlowInit _has_access",
user=self.request.user,
app=self.provider.application,
passing=passing,
app=self.application,
result=result,
)
return passing
if not result.passing:
for message in result.messages:
messages.error(self.request, _(message))
return result.passing
def dispatch(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
if not request.user.is_authenticated:
return self.handle_no_permission()
class SAMLSSOView(LoginRequiredMixin, SAMLAccessMixin, View):
""""SAML SSO Base View, which plans a flow and injects our final stage.
Calls get/post handler."""
def dispatch(
self, request: HttpRequest, *args, application_slug: str, **kwargs
) -> HttpResponse:
self.application = get_object_or_404(Application, slug=application_slug)
self.provider: SAMLProvider = get_object_or_404(
SAMLProvider, pk=self.application.provider_id
)
if not self._has_access():
return render(
request,
"login/denied.html",
{"title": _("You don't have access to this application")},
raise PermissionDenied()
# Call the method handler, which checks the SAML Request
method_response = super().dispatch(request, *args, application_slug, **kwargs)
if method_response:
return method_response
# Regardless, we start the planner and return to it
planner = FlowPlanner(self.provider.authorization_flow)
planner.allow_empty_flows = True
plan = planner.plan(
self.request,
{PLAN_CONTEXT_SSO: True, PLAN_CONTEXT_APPLICATION: self.application},
)
plan.stages.append(in_memory_stage(SAMLFlowFinalView))
self.request.session[SESSION_KEY_PLAN] = plan
return redirect_with_qs(
"passbook_flows:flow-executor-shell",
self.request.GET,
flow_slug=self.provider.authorization_flow.slug,
)
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."""
class SAMLSSOBindingRedirectView(SAMLSSOView):
"""SAML Handler for SSO/Redirect bindings, which are sent via GET"""
def handler(self, source, application: str) -> HttpResponse:
"""Handle SAML Request whether its a POST or a Redirect binding"""
# pylint: disable=unused-argument
def get(
self, request: HttpRequest, application_slug: str
) -> Optional[HttpResponse]:
"""Handle REDIRECT bindings"""
# Store these values now, because Django's login cycle won't preserve them.
try:
self.request.session[SESSION_KEY_SAML_REQUEST] = source[
SESSION_KEY_SAML_REQUEST
]
except (KeyError, MultiValueDictKeyError):
if SESSION_KEY_SAML_REQUEST not in request.GET:
LOGGER.info("handle_saml_request: SAML payload missing")
return bad_request_message(
self.request, "The SAML request payload is missing."
)
self.request.session[SESSION_KEY_RELAY_STATE] = source.get(
self.request.session[SESSION_KEY_SAML_REQUEST] = request.GET[
SESSION_KEY_SAML_REQUEST
]
self.request.session[SESSION_KEY_RELAY_STATE] = request.GET.get(
SESSION_KEY_RELAY_STATE, ""
)
@ -105,104 +126,89 @@ class LoginBeginView(AccessRequiredView):
self.request.session[SESSION_KEY_PARAMS] = params
except CannotHandleAssertion as exc:
LOGGER.info(exc)
did_you_mean_link = self.request.build_absolute_uri(
reverse(
"passbook_providers_saml:saml-login-initiate",
kwargs={"application": application},
)
)
did_you_mean_message = (
f" Did you mean to go <a href='{did_you_mean_link}'>here</a>?"
)
return bad_request_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 dispatch(self, *args, **kwargs):
return super().dispatch(*args, **kwargs)
@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)
return bad_request_message(self.request, str(exc))
return None
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")
@method_decorator(csrf_exempt, name="dispatch")
class SAMLSSOBindingPOSTView(SAMLSSOView):
"""SAML Handler for SSO/POST bindings"""
# pylint: disable=unused-argument
def post(self, request: HttpRequest, application: str) -> HttpResponse:
"""Handle post request, return back to ACS"""
# User access gets checked in dispatch
def post(
self, request: HttpRequest, application_slug: str
) -> Optional[HttpResponse]:
"""Handle POST bindings"""
# Store these values now, because Django's login cycle won't preserve them.
if SESSION_KEY_SAML_REQUEST not in request.POST:
LOGGER.info("handle_saml_request: SAML payload missing")
return bad_request_message(
self.request, "The SAML request payload is missing."
)
# we get here when skip_authorization is True, and after the user accepted
# the authorization form
self.request.session[SESSION_KEY_SAML_REQUEST] = request.POST[
SESSION_KEY_SAML_REQUEST
]
self.request.session[SESSION_KEY_RELAY_STATE] = request.POST.get(
SESSION_KEY_RELAY_STATE, ""
)
try:
self.provider.processor.can_handle(self.request)
params = self.provider.processor.generate_response()
self.request.session[SESSION_KEY_PARAMS] = params
except CannotHandleAssertion as exc:
LOGGER.info(exc)
return bad_request_message(self.request, str(exc))
return None
class SAMLSSOBindingInitView(SAMLSSOView):
"""SAML Handler for for IdP Initiated login flows"""
# pylint: disable=unused-argument
def get(
self, request: HttpRequest, application_slug: str
) -> Optional[HttpResponse]:
"""Create saml params from scratch"""
LOGGER.debug(
"handle_saml_no_request: No SAML Request, using IdP-initiated flow."
)
self.provider.processor.is_idp_initiated = True
self.provider.processor.init_deep_link(self.request)
params = self.provider.processor.generate_response()
self.request.session[SESSION_KEY_PARAMS] = params
# This View doesn't have a URL on purpose, as its called by the FlowExecutor
class SAMLFlowFinalView(StageView):
"""View used by FlowExecutor after all stages have passed. Logs the authorization,
and redirects to the SP (if REDIRECT is configured) or shows and auto-submit for
(if POST is configured)."""
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
application: Application = self.executor.plan.context[PLAN_CONTEXT_APPLICATION]
provider: SAMLProvider = application.provider
# Log Application Authorization
Event.new(
EventAction.AUTHORIZE_APPLICATION,
authorized_application=self.provider.application,
skipped_authorization=self.provider.application.skip_authorization,
authorized_application=application,
flow=self.executor.plan.flow_pk,
).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)
if SESSION_KEY_PARAMS not in self.request.session:
return self.executor.stage_invalid()
response: SAMLResponseParams = self.request.session.pop(SESSION_KEY_PARAMS)
if provider.sp_binding == SAMLBindings.POST:
return render(
self.request,
"saml/idp/autosubmit_form.html",
{
"url": response.acs_url,
"application": application,
"attrs": {
"ACSUrl": response.acs_url,
SESSION_KEY_SAML_RESPONSE: response.saml_response,
@ -210,77 +216,41 @@ class AuthorizeView(AccessRequiredView):
},
},
)
if provider.sp_binding == SAMLBindings.REDIRECT:
querystring = urlencode(
{
SESSION_KEY_SAML_RESPONSE: response.saml_response,
SESSION_KEY_RELAY_STATE: response.relay_state,
}
)
return redirect(f"{response.acs_url}?{querystring}")
return bad_request_message(request, "Invalid sp_binding specified")
@method_decorator(csrf_exempt, name="dispatch")
class LogoutView(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)."""
# pylint: disable=unused-argument
def get(self, request: HttpRequest, application: str) -> HttpResponse:
"""Perform logout"""
logout(request)
redirect_url = request.GET.get("redirect_to", "")
try:
URL_VALIDATOR(redirect_url)
except ValidationError:
pass
else:
return redirect(redirect_url)
return render(request, "saml/idp/logged_out.html")
@method_decorator(csrf_exempt, name="dispatch")
class SLOLogout(AccessRequiredView):
"""Receives a SAML 2.0 LogoutRequest from a Service Provider,
logs out the user and returns a standard logged-out page."""
# pylint: disable=unused-argument
def post(self, request: HttpRequest, application: str) -> HttpResponse:
"""Perform logout"""
request.session[SESSION_KEY_SAML_REQUEST] = request.POST[
SESSION_KEY_SAML_REQUEST
]
# TODO: Parse SAML LogoutRequest from POST data, similar to login_process().
# TODO: Modify the base processor to handle logouts?
# TODO: Combine this with login_process(), since they are so very similar?
# TODO: Format a LogoutResponse and return it to the browser.
# XXX: For now, simply log out without validating the request.
logout(request)
return render(request, "saml/idp/logged_out.html")
class DescriptorDownloadView(AccessRequiredView):
class DescriptorDownloadView(LoginRequiredMixin, SAMLAccessMixin, View):
"""Replies with the XML Metadata IDSSODescriptor."""
@staticmethod
def get_metadata(request: HttpRequest, provider: SAMLProvider) -> str:
"""Return rendered XML Metadata"""
entity_id = provider.issuer
slo_url = request.build_absolute_uri(
saml_sso_binding_post = request.build_absolute_uri(
reverse(
"passbook_providers_saml:saml-logout",
kwargs={"application": provider.application.slug},
"passbook_providers_saml:sso-post",
kwargs={"application_slug": provider.application.slug},
)
)
sso_post_url = request.build_absolute_uri(
saml_sso_binding_redirect = request.build_absolute_uri(
reverse(
"passbook_providers_saml:saml-login",
kwargs={"application": provider.application.slug},
"passbook_providers_saml:sso-redirect",
kwargs={"application_slug": provider.application.slug},
)
)
subject_format = provider.processor.subject_format
ctx = {
"saml_sso_binding_post": saml_sso_binding_post,
"saml_sso_binding_redirect": saml_sso_binding_redirect,
"entity_id": entity_id,
"slo_url": slo_url,
# Currently, the same endpoint accepts POST and REDIRECT
"sso_post_url": sso_post_url,
"sso_redirect_url": sso_post_url,
"subject_format": subject_format,
}
if provider.signing_kp:
@ -289,9 +259,14 @@ class DescriptorDownloadView(AccessRequiredView):
).replace("\n", "")
return render_to_string("saml/xml/metadata.xml", ctx)
# pylint: disable=unused-argument
def get(self, request: HttpRequest, application: str) -> HttpResponse:
def get(self, request: HttpRequest, application_slug: str) -> HttpResponse:
"""Replies with the XML Metadata IDSSODescriptor."""
self.application = get_object_or_404(Application, slug=application_slug)
self.provider: SAMLProvider = get_object_or_404(
SAMLProvider, pk=self.application.provider_id
)
if not self._has_access():
raise PermissionDenied()
try:
metadata = DescriptorDownloadView.get_metadata(request, self.provider)
except Provider.application.RelatedObjectDoesNotExist: # pylint: disable=no-member

View File

@ -97,6 +97,7 @@ INSTALLED_APPS = [
"passbook.sources.oauth.apps.PassbookSourceOAuthConfig",
"passbook.sources.saml.apps.PassbookSourceSAMLConfig",
"passbook.stages.captcha.apps.PassbookStageCaptchaConfig",
"passbook.stages.consent.apps.PassbookStageConsentConfig",
"passbook.stages.dummy.apps.PassbookStageDummyConfig",
"passbook.stages.email.apps.PassbookStageEmailConfig",
"passbook.stages.prompt.apps.PassbookStagPromptConfig",

View File

@ -23,6 +23,7 @@ class LDAPSourceSerializer(ModelSerializer):
"group_object_filter",
"user_group_membership_field",
"object_uniqueness_field",
"sync_users",
"sync_groups",
"sync_parent_group",
"property_mappings",

View File

@ -16,26 +16,10 @@ LOGGER = get_logger()
class Connector:
"""Wrapper for ldap3 to easily manage user authentication and creation"""
_server: ldap3.Server
_connection = ldap3.Connection
_source: LDAPSource
def __init__(self, source: LDAPSource):
self._source = source
self._server = ldap3.Server(source.server_uri) # Implement URI parsing
def bind(self):
"""Bind using Source's Credentials"""
self._connection = ldap3.Connection(
self._server,
raise_exceptions=True,
user=self._source.bind_cn,
password=self._source.bind_password,
)
self._connection.bind()
if self._source.start_tls:
self._connection.start_tls()
@staticmethod
def encode_pass(password: str) -> bytes:
@ -45,19 +29,23 @@ class Connector:
@property
def base_dn_users(self) -> str:
"""Shortcut to get full base_dn for user lookups"""
return ",".join([self._source.additional_user_dn, self._source.base_dn])
if self._source.additional_user_dn:
return f"{self._source.additional_user_dn},{self._source.base_dn}"
return self._source.base_dn
@property
def base_dn_groups(self) -> str:
"""Shortcut to get full base_dn for group lookups"""
return ",".join([self._source.additional_group_dn, self._source.base_dn])
if self._source.additional_group_dn:
return f"{self._source.additional_group_dn},{self._source.base_dn}"
return self._source.base_dn
def sync_groups(self):
"""Iterate over all LDAP Groups and create passbook_core.Group instances"""
if not self._source.sync_groups:
LOGGER.debug("Group syncing is disabled for this Source")
LOGGER.warning("Group syncing is disabled for this Source")
return
groups = self._connection.extend.standard.paged_search(
groups = self._source.connection.extend.standard.paged_search(
search_base=self.base_dn_groups,
search_filter=self._source.group_object_filter,
search_scope=ldap3.SUBTREE,
@ -87,7 +75,10 @@ class Connector:
def sync_users(self):
"""Iterate over all LDAP Users and create passbook_core.User instances"""
users = self._connection.extend.standard.paged_search(
if not self._source.sync_users:
LOGGER.warning("User syncing is disabled for this Source")
return
users = self._source.connection.extend.standard.paged_search(
search_base=self.base_dn_users,
search_filter=self._source.user_object_filter,
search_scope=ldap3.SUBTREE,
@ -101,9 +92,9 @@ class Connector:
LOGGER.warning("Cannot find uniqueness Field in attributes")
continue
try:
defaults = self._build_object_properties(attributes)
user, created = User.objects.update_or_create(
attributes__ldap_uniq=uniq,
defaults=self._build_object_properties(attributes),
attributes__ldap_uniq=uniq, defaults=defaults,
)
except IntegrityError as exc:
LOGGER.warning("Failed to create user", exc=exc)
@ -123,7 +114,7 @@ class Connector:
def sync_membership(self):
"""Iterate over all Users and assign Groups using memberOf Field"""
users = self._connection.extend.standard.paged_search(
users = self._source.connection.extend.standard.paged_search(
search_base=self.base_dn_users,
search_filter=self._source.user_object_filter,
search_scope=ldap3.SUBTREE,
@ -173,9 +164,10 @@ class Connector:
continue
mapping: LDAPPropertyMapping
try:
properties[mapping.object_field] = mapping.evaluate(
user=None, request=None, ldap=attributes
)
value = mapping.evaluate(user=None, request=None, ldap=attributes)
if value is None:
continue
properties[mapping.object_field] = value
except PropertyMappingExpressionException as exc:
LOGGER.warning("Mapping failed to evaluate", exc=exc, mapping=mapping)
continue
@ -220,7 +212,7 @@ class Connector:
LOGGER.debug("Attempting Binding as user", user=user)
try:
temp_connection = ldap3.Connection(
self._server,
self._source.connection.server,
user=user.attributes.get("distinguishedName"),
password=password,
raise_exceptions=True,

View File

@ -5,6 +5,7 @@ from django.contrib.admin.widgets import FilteredSelectMultiple
from django.utils.translation import gettext_lazy as _
from passbook.admin.forms.source import SOURCE_FORM_FIELDS
from passbook.core.expression import PropertyMappingEvaluator
from passbook.sources.ldap.models import LDAPPropertyMapping, LDAPSource
@ -26,6 +27,7 @@ class LDAPSourceForm(forms.ModelForm):
"group_object_filter",
"user_group_membership_field",
"object_uniqueness_field",
"sync_users",
"sync_groups",
"sync_parent_group",
"property_mappings",
@ -51,6 +53,13 @@ class LDAPPropertyMappingForm(forms.ModelForm):
template_name = "ldap/property_mapping_form.html"
def clean_expression(self):
"""Test Syntax"""
expression = self.cleaned_data.get("expression")
evaluator = PropertyMappingEvaluator()
evaluator.validate(expression)
return expression
class Meta:
model = LDAPPropertyMapping

View File

@ -0,0 +1,18 @@
# Generated by Django 3.0.6 on 2020-05-23 19:17
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("passbook_sources_ldap", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="ldapsource",
name="sync_users",
field=models.BooleanField(default=True),
),
]

View File

@ -0,0 +1,35 @@
# Generated by Django 3.0.6 on 2020-05-23 19:30
from django.apps.registry import Apps
from django.db import migrations
def create_default_ad_property_mappings(apps: Apps, schema_editor):
LDAPPropertyMapping = apps.get_model("passbook_sources_ldap", "LDAPPropertyMapping")
mapping = {
"name": "return ldap.get('name')",
"first_name": "return ldap.get('givenName')",
"last_name": "return ldap.get('sn')",
"username": "return ldap.get('sAMAccountName')",
"email": "return ldap.get('mail')",
}
db_alias = schema_editor.connection.alias
for object_field, expression in mapping.items():
LDAPPropertyMapping.objects.using(db_alias).get_or_create(
expression=expression,
object_field=object_field,
defaults={
"name": f"Autogenerated LDAP Mapping: {expression} -> {object_field}"
},
)
class Migration(migrations.Migration):
dependencies = [
("passbook_sources_ldap", "0002_ldapsource_sync_users"),
]
operations = [
migrations.RunPython(create_default_ad_property_mappings),
]

View File

@ -0,0 +1,31 @@
# Generated by Django 3.0.6 on 2020-05-24 11:46
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("passbook_sources_ldap", "0003_default_ldap_property_mappings"),
]
operations = [
migrations.AlterField(
model_name="ldapsource",
name="additional_group_dn",
field=models.TextField(
blank=True,
help_text="Prepended to Base DN for Group-queries.",
verbose_name="Addition Group DN",
),
),
migrations.AlterField(
model_name="ldapsource",
name="additional_user_dn",
field=models.TextField(
blank=True,
help_text="Prepended to Base DN for User-queries.",
verbose_name="Addition User DN",
),
),
]

View File

@ -1,8 +1,10 @@
"""passbook LDAP Models"""
from typing import Optional
from django.core.validators import URLValidator
from django.db import models
from django.utils.translation import gettext_lazy as _
from ldap3 import Connection, Server
from passbook.core.models import Group, PropertyMapping, Source
@ -22,10 +24,12 @@ class LDAPSource(Source):
additional_user_dn = models.TextField(
help_text=_("Prepended to Base DN for User-queries."),
verbose_name=_("Addition User DN"),
blank=True,
)
additional_group_dn = models.TextField(
help_text=_("Prepended to Base DN for Group-queries."),
verbose_name=_("Addition Group DN"),
blank=True,
)
user_object_filter = models.TextField(
@ -43,6 +47,7 @@ class LDAPSource(Source):
default="objectSid", help_text=_("Field which contains a unique Identifier.")
)
sync_users = models.BooleanField(default=True)
sync_groups = models.BooleanField(default=True)
sync_parent_group = models.ForeignKey(
Group, blank=True, null=True, default=None, on_delete=models.SET_DEFAULT
@ -50,6 +55,25 @@ class LDAPSource(Source):
form = "passbook.sources.ldap.forms.LDAPSourceForm"
_connection: Optional[Connection]
@property
def connection(self) -> Connection:
"""Get a fully connected and bound LDAP Connection"""
if not self._connection:
server = Server(self.server_uri)
self._connection = Connection(
server,
raise_exceptions=True,
user=self.bind_cn,
password=self.bind_password,
)
self._connection.bind()
if self.start_tls:
self._connection.start_tls()
return self._connection
class Meta:
verbose_name = _("LDAP Source")

View File

@ -9,7 +9,6 @@ def sync_groups(source_pk: int):
"""Sync LDAP Groups on background worker"""
source = LDAPSource.objects.get(pk=source_pk)
connector = Connector(source)
connector.bind()
connector.sync_groups()
@ -18,7 +17,6 @@ def sync_users(source_pk: int):
"""Sync LDAP Users on background worker"""
source = LDAPSource.objects.get(pk=source_pk)
connector = Connector(source)
connector.bind()
connector.sync_users()
@ -27,7 +25,6 @@ def sync():
"""Sync all sources"""
for source in LDAPSource.objects.filter(enabled=True):
connector = Connector(source)
connector.bind()
connector.sync_users()
connector.sync_groups()
connector.sync_membership()

View File

@ -0,0 +1,75 @@
"""LDAP Source tests"""
from unittest.mock import PropertyMock, patch
from django.test import TestCase
from ldap3 import MOCK_SYNC, OFFLINE_AD_2012_R2, Connection, Server
from passbook.core.models import User
from passbook.sources.ldap.connector import Connector
from passbook.sources.ldap.models import LDAPPropertyMapping, LDAPSource
def _build_mock_connection() -> Connection:
"""Create mock connection"""
server = Server("my_fake_server", get_info=OFFLINE_AD_2012_R2)
_pass = "foo" # noqa # nosec
connection = Connection(
server,
user="cn=my_user,ou=test,o=lab",
password=_pass,
client_strategy=MOCK_SYNC,
)
connection.strategy.add_entry(
"cn=user0,ou=test,o=lab",
{
"userPassword": "test0000",
"sAMAccountName": "user0_sn",
"revision": 0,
"objectSid": "unique-test0000",
"objectCategory": "Person",
},
)
connection.strategy.add_entry(
"cn=user1,ou=test,o=lab",
{
"userPassword": "test1111",
"sAMAccountName": "user1_sn",
"revision": 0,
"objectSid": "unique-test1111",
"objectCategory": "Person",
},
)
connection.strategy.add_entry(
"cn=user2,ou=test,o=lab",
{
"userPassword": "test2222",
"sAMAccountName": "user2_sn",
"revision": 0,
"objectSid": "unique-test2222",
"objectCategory": "Person",
},
)
connection.bind()
return connection
LDAP_CONNECTION_PATCH = PropertyMock(return_value=_build_mock_connection())
class LDAPSourceTests(TestCase):
"""LDAP Source tests"""
def setUp(self):
self.source = LDAPSource.objects.create(
name="ldap", slug="ldap", base_dn="o=lab"
)
self.source.property_mappings.set(LDAPPropertyMapping.objects.all())
self.source.save()
@patch("passbook.sources.ldap.models.LDAPSource.connection", LDAP_CONNECTION_PATCH)
def test_sync_users(self):
"""Test user sync"""
connector = Connector(self.source)
connector.sync_users()
user = User.objects.filter(username="user2_sn")
self.assertTrue(user.exists())

View File

@ -1,6 +1,6 @@
"""OAuth Clients"""
import json
from typing import Dict, Optional
from typing import TYPE_CHECKING, Any, Dict, Optional
from urllib.parse import parse_qs, urlencode
from django.http import HttpRequest
@ -14,24 +14,29 @@ from structlog import get_logger
from passbook import __version__
LOGGER = get_logger()
if TYPE_CHECKING:
from passbook.sources.oauth.models import OAuthSource
class BaseOAuthClient:
"""Base OAuth Client"""
session: Session
source: "OAuthSource"
def __init__(self, source, token=""): # nosec
def __init__(self, source: "OAuthSource", token=""): # nosec
self.source = source
self.token = token
self.session = Session()
self.session.headers.update({"User-Agent": "passbook %s" % __version__})
def get_access_token(self, request, callback=None):
def get_access_token(
self, request: HttpRequest, callback=None
) -> Optional[Dict[str, Any]]:
"Fetch access token from callback request."
raise NotImplementedError("Defined in a sub-class") # pragma: no cover
def get_profile_info(self, token: Dict[str, str]):
def get_profile_info(self, token: Dict[str, str]) -> Optional[Dict[str, Any]]:
"Fetch user profile information."
try:
headers = {
@ -45,7 +50,7 @@ class BaseOAuthClient:
LOGGER.warning("Unable to fetch user profile", exc=exc)
return None
else:
return response.json() or response.text
return response.json()
def get_redirect_args(self, request, callback) -> Dict[str, str]:
"Get request parameters for redirect url."

View File

@ -3,6 +3,7 @@
from django import forms
from passbook.admin.forms.source import SOURCE_FORM_FIELDS
from passbook.flows.models import Flow, FlowDesignation
from passbook.sources.oauth.models import OAuthSource
from passbook.sources.oauth.types.manager import MANAGER
@ -10,6 +11,13 @@ from passbook.sources.oauth.types.manager import MANAGER
class OAuthSourceForm(forms.ModelForm):
"""OAuthSource Form"""
authentication_flow = forms.ModelChoiceField(
queryset=Flow.objects.filter(designation=FlowDesignation.AUTHENTICATION)
)
enrollment_flow = forms.ModelChoiceField(
queryset=Flow.objects.filter(designation=FlowDesignation.ENROLLMENT)
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if hasattr(self.Meta, "overrides"):

View File

@ -0,0 +1,38 @@
"""OAuth Source tests"""
from django.shortcuts import reverse
from django.test import Client, TestCase
from passbook.sources.oauth.models import OAuthSource
class OAuthSourceTests(TestCase):
"""OAuth Source tests"""
def setUp(self):
self.client = Client()
self.source = OAuthSource.objects.create(
name="test",
slug="test",
provider_type="openid-connect",
authorization_url="",
profile_url="",
consumer_key="",
)
def test_source_redirect(self):
"""test redirect view"""
self.client.get(
reverse(
"passbook_sources_oauth:oauth-client-login",
kwargs={"source_slug": self.source.slug},
)
)
def test_source_callback(self):
"""test callback view"""
self.client.get(
reverse(
"passbook_sources_oauth:oauth-client-callback",
kwargs={"source_slug": self.source.slug},
)
)

View File

@ -1,6 +1,9 @@
"""AzureAD OAuth2 Views"""
import uuid
from typing import Any, Dict
from passbook.core.models import User
from passbook.sources.oauth.models import OAuthSource, UserOAuthSourceConnection
from passbook.sources.oauth.types.manager import MANAGER, RequestKind
from passbook.sources.oauth.utils import user_get_or_create
from passbook.sources.oauth.views.core import OAuthCallback
@ -10,10 +13,15 @@ from passbook.sources.oauth.views.core import OAuthCallback
class AzureADOAuthCallback(OAuthCallback):
"""AzureAD OAuth2 Callback"""
def get_user_id(self, source, info):
return uuid.UUID(info.get("objectId")).int
def get_user_id(self, source: OAuthSource, info: Dict[str, Any]) -> str:
return str(uuid.UUID(info.get("objectId")).int)
def get_or_create_user(self, source, access, info):
def get_or_create_user(
self,
source: OAuthSource,
access: UserOAuthSourceConnection,
info: Dict[str, Any],
) -> User:
user_data = {
"username": info.get("displayName"),
"email": info.get("mail", None) or info.get("otherMails")[0],

View File

@ -54,7 +54,9 @@ class SourceTypeManager:
return OAuthCallback
if kind.value == RequestKind.redirect:
return OAuthRedirect
raise KeyError
raise KeyError(
f"Provider Type {source.provider_type} (type {kind.value}) not found."
)
MANAGER = SourceTypeManager()

View File

@ -21,8 +21,8 @@ class OpenIDConnectOAuthRedirect(OAuthRedirect):
class OpenIDConnectOAuth2Callback(OAuthCallback):
"""OpenIDConnect OAuth2 Callback"""
def get_user_id(self, source: OAuthSource, info: Dict[str, str]):
return info.get("sub")
def get_user_id(self, source: OAuthSource, info: Dict[str, str]) -> str:
return info.get("sub", "")
def get_or_create_user(self, source: OAuthSource, access, info: Dict[str, str]):
user_data = {

View File

@ -1,11 +1,11 @@
"""Core OAauth Views"""
from typing import Callable, Optional
from typing import Any, Callable, Dict, Optional
from django.conf import settings
from django.contrib import messages
from django.contrib.auth import authenticate
from django.contrib.auth.mixins import LoginRequiredMixin
from django.http import Http404
from django.http import Http404, HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils.translation import ugettext as _
@ -13,7 +13,8 @@ from django.views.generic import RedirectView, View
from structlog import get_logger
from passbook.audit.models import Event, EventAction
from passbook.flows.models import Flow, FlowDesignation
from passbook.core.models import User
from passbook.flows.models import Flow
from passbook.flows.planner import (
PLAN_CONTEXT_PENDING_USER,
PLAN_CONTEXT_SSO,
@ -21,7 +22,7 @@ from passbook.flows.planner import (
)
from passbook.flows.views import SESSION_KEY_PLAN
from passbook.lib.utils.urls import redirect_with_qs
from passbook.sources.oauth.clients import get_client
from passbook.sources.oauth.clients import BaseOAuthClient, get_client
from passbook.sources.oauth.models import OAuthSource, UserOAuthSourceConnection
from passbook.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND
@ -34,7 +35,7 @@ class OAuthClientMixin:
client_class: Optional[Callable] = None
def get_client(self, source):
def get_client(self, source: OAuthSource) -> BaseOAuthClient:
"Get instance of the OAuth client for this source."
if self.client_class is not None:
# pylint: disable=not-callable
@ -49,18 +50,18 @@ class OAuthRedirect(OAuthClientMixin, RedirectView):
params = None
# pylint: disable=unused-argument
def get_additional_parameters(self, source):
def get_additional_parameters(self, source: OAuthSource) -> Dict[str, Any]:
"Return additional redirect parameters for this source."
return self.params or {}
def get_callback_url(self, source):
def get_callback_url(self, source: OAuthSource) -> str:
"Return the callback url for this source."
return reverse(
"passbook_sources_oauth:oauth-client-callback",
kwargs={"source_slug": source.slug},
)
def get_redirect_url(self, **kwargs):
def get_redirect_url(self, **kwargs) -> str:
"Build redirect url for a given source."
slug = kwargs.get("source_slug", "")
try:
@ -84,7 +85,7 @@ class OAuthCallback(OAuthClientMixin, View):
source_id = None
source = None
def get(self, request, *_, **kwargs):
def get(self, request: HttpRequest, *_, **kwargs) -> HttpResponse:
"""View Get handler"""
slug = kwargs.get("source_slug", "")
try:
@ -143,38 +144,38 @@ class OAuthCallback(OAuthClientMixin, View):
return self.handle_existing_user(self.source, user, connection, info)
# pylint: disable=unused-argument
def get_callback_url(self, source):
def get_callback_url(self, source: OAuthSource) -> str:
"Return callback url if different than the current url."
return False
return ""
# pylint: disable=unused-argument
def get_error_redirect(self, source, reason):
def get_error_redirect(self, source: OAuthSource, reason: str) -> str:
"Return url to redirect on login failure."
return settings.LOGIN_URL
def get_or_create_user(self, source, access, info):
def get_or_create_user(
self,
source: OAuthSource,
access: UserOAuthSourceConnection,
info: Dict[str, Any],
) -> User:
"Create a shell auth.User."
raise NotImplementedError()
# pylint: disable=unused-argument
def get_user_id(self, source, info):
"Return unique identifier from the profile info."
id_key = self.source_id or "id"
result = info
try:
for key in id_key.split("."):
result = result[key]
return result
except KeyError:
def get_user_id(
self, source: UserOAuthSourceConnection, info: Dict[str, Any]
) -> Optional[str]:
"""Return unique identifier from the profile info."""
if "id" in info:
return info["id"]
return None
def handle_login(self, user, source, access):
def handle_login_flow(self, flow: Optional[Flow], user: User) -> HttpResponse:
"""Prepare Authentication Plan, redirect user FlowExecutor"""
user = authenticate(
source=access.source, identifier=access.identifier, request=self.request
)
if not flow:
raise Http404
# We run the Flow planner here so we can pass the Pending user in the context
flow = get_object_or_404(Flow, designation=FlowDesignation.AUTHENTICATION)
planner = FlowPlanner(flow)
plan = planner.plan(
self.request,
@ -186,11 +187,17 @@ class OAuthCallback(OAuthClientMixin, View):
)
self.request.session[SESSION_KEY_PLAN] = plan
return redirect_with_qs(
"passbook_flows:flow-executor", self.request.GET, flow_slug=flow.slug,
"passbook_flows:flow-executor-shell", self.request.GET, flow_slug=flow.slug,
)
# pylint: disable=unused-argument
def handle_existing_user(self, source, user, access, info):
def handle_existing_user(
self,
source: OAuthSource,
user: User,
access: UserOAuthSourceConnection,
info: Dict[str, Any],
) -> HttpResponse:
"Login user and redirect."
messages.success(
self.request,
@ -199,15 +206,23 @@ class OAuthCallback(OAuthClientMixin, View):
% {"source": self.source.name}
),
)
return self.handle_login(user, source, access)
user = authenticate(
source=access.source, identifier=access.identifier, request=self.request
)
return self.handle_login_flow(source.authentication_flow, user)
def handle_login_failure(self, source, reason):
def handle_login_failure(self, source: OAuthSource, reason: str) -> HttpResponse:
"Message user and redirect on error."
LOGGER.warning("Authentication Failure", reason=reason)
messages.error(self.request, _("Authentication Failed."))
return redirect(self.get_error_redirect(source, reason))
def handle_new_user(self, source, access, info):
def handle_new_user(
self,
source: OAuthSource,
access: UserOAuthSourceConnection,
info: Dict[str, Any],
) -> HttpResponse:
"Create a shell auth.User and redirect."
was_authenticated = False
if self.request.user.is_authenticated:
@ -244,7 +259,7 @@ class OAuthCallback(OAuthClientMixin, View):
% {"source": self.source.name}
),
)
return self.handle_login(user, source, access)
return self.handle_login_flow(source.enrollment_flow, user)
class DisconnectView(LoginRequiredMixin, View):

View File

@ -28,3 +28,4 @@ class SAMLSourceForm(forms.ModelForm):
"idp_url": forms.TextInput(),
"idp_logout_url": forms.TextInput(),
}
labels = {"signing_kp": _("Singing Keypair")}

View File

@ -0,0 +1,30 @@
# Generated by Django 3.0.6 on 2020-05-23 23:29
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("passbook_sources_saml", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="samlsource",
name="binding_type",
field=models.CharField(
choices=[("REDIRECT", "Redirect"), ("POST", "Post")],
default="REDIRECT",
max_length=100,
),
),
migrations.AlterField(
model_name="samlsource",
name="idp_url",
field=models.URLField(
help_text="URL that the initial SAML Request is sent to. Also known as a Binding.",
verbose_name="IDP URL",
),
),
]

View File

@ -8,6 +8,13 @@ from passbook.core.types import UILoginButton
from passbook.crypto.models import CertificateKeyPair
class SAMLBindingTypes(models.TextChoices):
"""SAML Binding types"""
Redirect = "REDIRECT"
POST = "POST"
class SAMLSource(Source):
"""SAML Source"""
@ -18,7 +25,18 @@ class SAMLSource(Source):
help_text=_("Also known as Entity ID. Defaults the Metadata URL."),
)
idp_url = models.URLField(verbose_name=_("IDP URL"))
idp_url = models.URLField(
verbose_name=_("IDP URL"),
help_text=_(
"URL that the initial SAML Request is sent to. Also known as a Binding."
),
)
binding_type = models.CharField(
max_length=100,
choices=SAMLBindingTypes.choices,
default=SAMLBindingTypes.Redirect,
)
idp_logout_url = models.URLField(
default=None, blank=True, null=True, verbose_name=_("IDP Logout URL")
)

View File

@ -2,21 +2,31 @@
from typing import TYPE_CHECKING, Optional
from defusedxml import ElementTree
from django.http import HttpRequest
from django.http import HttpRequest, HttpResponse
from signxml import XMLVerifier
from structlog import get_logger
from passbook.core.models import User
from passbook.flows.planner import (
PLAN_CONTEXT_PENDING_USER,
PLAN_CONTEXT_SSO,
FlowPlanner,
)
from passbook.flows.views import SESSION_KEY_PLAN
from passbook.lib.utils.urls import redirect_with_qs
from passbook.providers.saml.utils.encoding import decode_base64_and_inflate
from passbook.sources.saml.exceptions import (
MissingSAMLResponse,
UnsupportedNameIDFormat,
)
from passbook.sources.saml.models import SAMLSource
from passbook.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND
from passbook.stages.prompt.stage import PLAN_CONTEXT_PROMPT
LOGGER = get_logger()
if TYPE_CHECKING:
from xml.etree.ElementTree import Element # nosec
DEFAULT_BACKEND = "django.contrib.auth.backends.ModelBackend"
class Processor:
@ -46,7 +56,9 @@ class Processor:
def _verify_signed(self):
"""Verify SAML Response's Signature"""
verifier = XMLVerifier()
verifier.verify(self._root_xml, x509_cert=self._source.signing_kp.certificate)
verifier.verify(
self._root_xml, x509_cert=self._source.signing_kp.certificate_data
)
def _get_email(self) -> Optional[str]:
"""
@ -69,18 +81,32 @@ class Processor:
)
return name_id.text
def get_user(self) -> User:
"""
Gets info out of the response and locally logs in this user.
May create a local user account first.
Returns the user object that was created.
"""
def prepare_flow(self, request: HttpRequest) -> HttpResponse:
"""Prepare flow plan depending on whether or not the user exists"""
email = self._get_email()
try:
user = User.objects.get(email=email)
except User.DoesNotExist:
user = User.objects.create_user(username=email, email=email)
# TODO: Property Mappings
user.set_unusable_password()
user.save()
return user
matching_users = User.objects.filter(email=email)
if matching_users.exists():
# User exists already, switch to authentication flow
flow = self._source.authentication_flow
request.session[SESSION_KEY_PLAN] = FlowPlanner(flow).plan(
request,
{
# Data for authentication
PLAN_CONTEXT_PENDING_USER: matching_users.first(),
PLAN_CONTEXT_AUTHENTICATION_BACKEND: DEFAULT_BACKEND,
PLAN_CONTEXT_SSO: True,
},
)
else:
flow = self._source.enrollment_flow
request.session[SESSION_KEY_PLAN] = FlowPlanner(flow).plan(
request,
{
# Data for enrollment
PLAN_CONTEXT_PROMPT: {"username": email, "email": email},
PLAN_CONTEXT_SSO: True,
},
)
return redirect_with_qs(
"passbook_flows:flow-executor-shell", request.GET, flow_slug=flow.slug,
)

Some files were not shown because too many files have changed in this diff Show More