diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3b76df232..dbb0e4d62 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/Pipfile b/Pipfile index 5230760fc..5a6c688d9 100644 --- a/Pipfile +++ b/Pipfile @@ -40,7 +40,6 @@ signxml = "*" structlog = "*" swagger-spec-validator = "*" urllib3 = {extras = ["secure"],version = "*"} -jinja2 = "*" [requires] python_version = "3.8" diff --git a/Pipfile.lock b/Pipfile.lock index 3f4fd3420..f09bc6204 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -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": [ diff --git a/docs/expressions/index.md b/docs/expressions/index.md new file mode 100644 index 000000000..4355dfca3 --- /dev/null +++ b/docs/expressions/index.md @@ -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") +``` diff --git a/docs/property-mappings/reference/user-object.md b/docs/expressions/reference/user-object.md similarity index 84% rename from docs/property-mappings/reference/user-object.md rename to docs/expressions/reference/user-object.md index 8cc35c162..5cdb0780a 100644 --- a/docs/property-mappings/reference/user-object.md +++ b/docs/expressions/reference/user-object.md @@ -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 ``` diff --git a/docs/policies/expression.md b/docs/policies/expression.md new file mode 100644 index 000000000..e3d0812cb --- /dev/null +++ b/docs/policies/expression.md @@ -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. diff --git a/docs/policies/expression/index.md b/docs/policies/expression/index.md deleted file mode 100644 index 2bf6b2fb0..000000000 --- a/docs/policies/expression/index.md +++ /dev/null @@ -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` diff --git a/docs/policies/index.md b/docs/policies/index.md index b413a7794..362f43d54 100644 --- a/docs/policies/index.md +++ b/docs/policies/index.md @@ -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 diff --git a/docs/property-mappings/expression.md b/docs/property-mappings/expression.md new file mode 100644 index 000000000..a25ee2aad --- /dev/null +++ b/docs/property-mappings/expression.md @@ -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. diff --git a/docs/property-mappings/index.md b/docs/property-mappings/index.md index 0eebcb0d7..7e7e21e17 100644 --- a/docs/property-mappings/index.md +++ b/docs/property-mappings/index.md @@ -12,10 +12,10 @@ You can find examples [here](integrations/) LDAP Property Mappings are used when you define a LDAP Source. These Mappings define which LDAP Property maps to which passbook Property. By default, these mappings are created: -- Autogenerated LDAP Mapping: givenName -> first_name -- Autogenerated LDAP Mapping: mail -> email -- Autogenerated LDAP Mapping: name -> name -- Autogenerated LDAP Mapping: sAMAccountName -> username -- Autogenerated LDAP Mapping: sn -> last_name +- Autogenerated LDAP Mapping: givenName -> first_name +- Autogenerated LDAP Mapping: mail -> email +- Autogenerated LDAP Mapping: name -> name +- Autogenerated LDAP Mapping: sAMAccountName -> username +- Autogenerated LDAP Mapping: sn -> last_name These are configured for the most common LDAP Setups. diff --git a/mkdocs.yml b/mkdocs.yml index cb31d86a1..2fd537455 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -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 diff --git a/passbook/admin/forms/source.py b/passbook/admin/forms/source.py index b18d7157a..2207dec1c 100644 --- a/passbook/admin/forms/source.py +++ b/passbook/admin/forms/source.py @@ -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", +] diff --git a/passbook/admin/templates/administration/base.html b/passbook/admin/templates/administration/base.html index 7c4ccc5f7..864c526c5 100644 --- a/passbook/admin/templates/administration/base.html +++ b/passbook/admin/templates/administration/base.html @@ -14,7 +14,7 @@ - + {% endblock %} {% block page_content %} diff --git a/passbook/admin/templates/administration/overview.html b/passbook/admin/templates/administration/overview.html index d053f8c3d..21dcb8906 100644 --- a/passbook/admin/templates/administration/overview.html +++ b/passbook/admin/templates/administration/overview.html @@ -55,15 +55,26 @@
- {% if factor_count < 1 %} - {{ factor_count }} + {% if stage_count < 1 %} + {{ stage_count }}

{% trans 'No Stages configured. No Users will be able to login.' %}">

{% else %} - {{ factor_count }} + {{ stage_count }} {% endif %}
+ +
+
+ {% trans 'Flows' %} +
+
+
+ {{ flow_count }} +
+
+
diff --git a/passbook/admin/templates/administration/provider/list.html b/passbook/admin/templates/administration/provider/list.html index 9ae3a7f20..5a804bfd6 100644 --- a/passbook/admin/templates/administration/provider/list.html +++ b/passbook/admin/templates/administration/provider/list.html @@ -29,7 +29,7 @@ {% for type, name in types.items %}
  • - {{ name|verbose_name }} + {{ name|verbose_name }}
    {{ name|doc }} diff --git a/passbook/admin/templates/generic/create.html b/passbook/admin/templates/generic/create.html index 3094c2da7..640ab3916 100644 --- a/passbook/admin/templates/generic/create.html +++ b/passbook/admin/templates/generic/create.html @@ -5,14 +5,14 @@ {% block above_form %}

    - {% blocktrans with type=form|form_verbose_name|title %} + {% blocktrans with type=form|form_verbose_name %} Create {{ type }} {% endblocktrans %}

    {% endblock %} {% block action %} -{% blocktrans with type=form|form_verbose_name|title %} +{% blocktrans with type=form|form_verbose_name %} Create {{ type }} {% endblocktrans %} {% endblock %} diff --git a/passbook/core/api/applications.py b/passbook/core/api/applications.py index f0b4c69e2..f2bcb5e9c 100644 --- a/passbook/core/api/applications.py +++ b/passbook/core/api/applications.py @@ -15,7 +15,6 @@ class ApplicationSerializer(ModelSerializer): "pk", "name", "slug", - "skip_authorization", "provider", "meta_launch_url", "meta_icon_url", diff --git a/passbook/core/api/providers.py b/passbook/core/api/providers.py index 2b54a5807..c2830c06e 100644 --- a/passbook/core/api/providers.py +++ b/passbook/core/api/providers.py @@ -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): diff --git a/passbook/core/expression.py b/passbook/core/expression.py new file mode 100644 index 000000000..37a397ca0 --- /dev/null +++ b/passbook/core/expression.py @@ -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) diff --git a/passbook/core/forms/applications.py b/passbook/core/forms/applications.py index e63eeb06d..4eb071719 100644 --- a/passbook/core/forms/applications.py +++ b/passbook/core/forms/applications.py @@ -19,7 +19,6 @@ class ApplicationForm(forms.ModelForm): fields = [ "name", "slug", - "skip_authorization", "provider", "meta_launch_url", "meta_icon_url", diff --git a/passbook/core/migrations/0002_auto_20200523_1133.py b/passbook/core/migrations/0002_auto_20200523_1133.py new file mode 100644 index 000000000..393a9be75 --- /dev/null +++ b/passbook/core/migrations/0002_auto_20200523_1133.py @@ -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", + ), + ), + ] diff --git a/passbook/core/migrations/0003_default_user.py b/passbook/core/migrations/0003_default_user.py new file mode 100644 index 000000000..e1d52f3b4 --- /dev/null +++ b/passbook/core/migrations/0003_default_user.py @@ -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), + ] diff --git a/passbook/core/models.py b/passbook/core/models.py index 08ad322dc..2c8a8930e 100644 --- a/passbook/core/models.py +++ b/passbook/core/models.py @@ -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}" diff --git a/passbook/core/signals.py b/passbook/core/signals.py index 01299f90e..74b6b49f1 100644 --- a/passbook/core/signals.py +++ b/passbook/core/signals.py @@ -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) diff --git a/passbook/core/templates/login/base.html b/passbook/core/templates/login/base.html index b7234adaa..5c8000887 100644 --- a/passbook/core/templates/login/base.html +++ b/passbook/core/templates/login/base.html @@ -20,3 +20,40 @@ {% endblock %}
  • + diff --git a/passbook/core/templates/partials/form.html b/passbook/core/templates/partials/form.html index 83a6e8ae3..efd249d08 100644 --- a/passbook/core/templates/partials/form.html +++ b/passbook/core/templates/partials/form.html @@ -2,6 +2,13 @@ {% load i18n %} {% csrf_token %} +{% if form.non_field_errors %} +
    +

    + {{ form.non_field_errors }} +

    +
    +{% endif %} {% for field in form %}
    {% if field.field.widget|fieldtype == 'RadioSelect' %} diff --git a/passbook/core/templates/partials/form_horizontal.html b/passbook/core/templates/partials/form_horizontal.html index facce648e..1e2836e6e 100644 --- a/passbook/core/templates/partials/form_horizontal.html +++ b/passbook/core/templates/partials/form_horizontal.html @@ -28,6 +28,9 @@
    {{ field|css_class:"pf-c-form-control" }} + {% if field.help_text %} +

    {{ field.help_text|safe }}

    + {% endif %}
    {% elif field.field.widget|fieldtype == 'CheckboxInput' %}
    @@ -36,7 +39,7 @@
    {% if field.help_text %} -

    {{ field.help_text }}

    +

    {{ field.help_text|safe }}

    {% endif %}
    {% else %} @@ -49,7 +52,7 @@
    {{ field|css_class:'pf-c-form-control' }} {% if field.help_text %} -

    {{ field.help_text }}

    +

    {{ field.help_text|safe }}

    {% endif %}
    {% endif %} diff --git a/passbook/core/templates/user/settings.html b/passbook/core/templates/user/settings.html index edbcc4928..5e752f935 100644 --- a/passbook/core/templates/user/settings.html +++ b/passbook/core/templates/user/settings.html @@ -17,7 +17,9 @@
    + {% if unenrollment_enabled %} {% trans "Delete account" %} + {% endif %}
    diff --git a/passbook/core/views/access.py b/passbook/core/views/access.py index c2e07dd19..287a12f80 100644 --- a/passbook/core/views/access.py +++ b/passbook/core/views/access.py @@ -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 diff --git a/passbook/core/views/overview.py b/passbook/core/views/overview.py index 6185dce05..ef5186ac6 100644 --- a/passbook/core/views/overview.py +++ b/passbook/core/views/overview.py @@ -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) diff --git a/passbook/core/views/user.py b/passbook/core/views/user.py index bb575ee8e..4bc02b27f 100644 --- a/passbook/core/views/user.py +++ b/passbook/core/views/user.py @@ -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 diff --git a/passbook/crypto/builder.py b/passbook/crypto/builder.py index 67545f5bb..47722a463 100644 --- a/passbook/crypto/builder.py +++ b/passbook/crypto/builder.py @@ -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", ), ] ) diff --git a/passbook/crypto/forms.py b/passbook/crypto/forms.py index babf25919..79d5f7100 100644 --- a/passbook/crypto/forms.py +++ b/passbook/crypto/forms.py @@ -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 diff --git a/passbook/crypto/migrations/0002_create_self_signed_kp.py b/passbook/crypto/migrations/0002_create_self_signed_kp.py new file mode 100644 index 000000000..66239b816 --- /dev/null +++ b/passbook/crypto/migrations/0002_create_self_signed_kp.py @@ -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)] diff --git a/passbook/flows/migrations/0002_default_flows.py b/passbook/flows/migrations/0002_default_flows.py index 21d61d338..5977cc8ee 100644 --- a/passbook/flows/migrations/0002_default_flows.py +++ b/passbook/flows/migrations/0002_default_flows.py @@ -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, ) diff --git a/passbook/flows/migrations/0003_auto_20200523_1133.py b/passbook/flows/migrations/0003_auto_20200523_1133.py new file mode 100644 index 000000000..951835239 --- /dev/null +++ b/passbook/flows/migrations/0003_auto_20200523_1133.py @@ -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, + ), + ), + ] diff --git a/passbook/flows/migrations/0004_source_flows.py b/passbook/flows/migrations/0004_source_flows.py new file mode 100644 index 000000000..97db472d6 --- /dev/null +++ b/passbook/flows/migrations/0004_source_flows.py @@ -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), + ] diff --git a/passbook/flows/migrations/0005_provider_flows.py b/passbook/flows/migrations/0005_provider_flows.py new file mode 100644 index 000000000..a197d0532 --- /dev/null +++ b/passbook/flows/migrations/0005_provider_flows.py @@ -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)] diff --git a/passbook/flows/models.py b/passbook/flows/models.py index de0147c99..d049a6885 100644 --- a/passbook/flows/models.py +++ b/passbook/flows/models.py @@ -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})" diff --git a/passbook/flows/planner.py b/passbook/flows/planner.py index e44e378ee..ca8a81c5e 100644 --- a/passbook/flows/planner.py +++ b/passbook/flows/planner.py @@ -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 diff --git a/passbook/flows/templates/flows/shell.html b/passbook/flows/templates/flows/shell.html index be6b22724..08190994d 100644 --- a/passbook/flows/templates/flows/shell.html +++ b/passbook/flows/templates/flows/shell.html @@ -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)); {% endblock %} diff --git a/passbook/flows/tests/test_planner.py b/passbook/flows/tests/test_planner.py index 25f2bd1a7..af6ae98fa 100644 --- a/passbook/flows/tests/test_planner.py +++ b/passbook/flows/tests/test_planner.py @@ -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) diff --git a/passbook/flows/tests/test_views.py b/passbook/flows/tests/test_views.py index e6a2ad20c..cacbe2004 100644 --- a/passbook/flows/tests/test_views.py +++ b/passbook/flows/tests/test_views.py @@ -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""" diff --git a/passbook/flows/views.py b/passbook/flows/views.py index 37380a90c..3613a7017 100644 --- a/passbook/flows/views.py +++ b/passbook/flows/views.py @@ -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 diff --git a/passbook/lib/expression/__init__.py b/passbook/lib/expression/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/passbook/lib/expression/evaluator.py b/passbook/lib/expression/evaluator.py new file mode 100644 index 000000000..3a252f08b --- /dev/null +++ b/passbook/lib/expression/evaluator.py @@ -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 diff --git a/passbook/policies/apps.py b/passbook/policies/apps.py index 5795355b6..946f84609 100644 --- a/passbook/policies/apps.py +++ b/passbook/policies/apps.py @@ -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") diff --git a/passbook/policies/engine.py b/passbook/policies/engine.py index 143ad6473..5db4f8cfc 100644 --- a/passbook/policies/engine.py +++ b/passbook/policies/engine.py @@ -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: diff --git a/passbook/policies/expression/evaluator.py b/passbook/policies/expression/evaluator.py index b2120bb74..825c23ebf 100644 --- a/passbook/policies/expression/evaluator.py +++ b/passbook/policies/expression/evaluator.py @@ -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 diff --git a/passbook/policies/expression/forms.py b/passbook/policies/expression/forms.py index edc7a3545..23d2bc47f 100644 --- a/passbook/policies/expression/forms.py +++ b/passbook/policies/expression/forms.py @@ -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"), } diff --git a/passbook/policies/expression/models.py b/passbook/policies/expression/models.py index edf9b629b..103be2b69 100644 --- a/passbook/policies/expression/models.py +++ b/passbook/policies/expression/models.py @@ -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: diff --git a/passbook/policies/expression/tests/test_evaluator.py b/passbook/policies/expression/tests/test_evaluator.py index ca22e86ec..0526d6c9c 100644 --- a/passbook/policies/expression/tests/test_evaluator.py +++ b/passbook/policies/expression/tests/test_evaluator.py @@ -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) diff --git a/passbook/policies/process.py b/passbook/policies/process.py index 1fb906c9f..f4af4819e 100644 --- a/passbook/policies/process.py +++ b/passbook/policies/process.py @@ -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 diff --git a/passbook/policies/signals.py b/passbook/policies/signals.py new file mode 100644 index 000000000..82e0b3d94 --- /dev/null +++ b/passbook/policies/signals.py @@ -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) diff --git a/passbook/policies/types.py b/passbook/policies/types.py index 1fd65e63b..29ab69146 100644 --- a/passbook/policies/types.py +++ b/passbook/policies/types.py @@ -38,5 +38,10 @@ class PolicyResult: self.passing = passing self.messages = messages + def __repr__(self): + return self.__str__() + def __str__(self): - return f"" + if self.messages: + return f"PolicyResult passing={self.passing} messages={self.messages}" + return f"PolicyResult passing={self.passing}" diff --git a/passbook/providers/app_gw/forms.py b/passbook/providers/app_gw/forms.py index ad4c7fc60..6dddc8927 100644 --- a/passbook/providers/app_gw/forms.py +++ b/passbook/providers/app_gw/forms.py @@ -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(), diff --git a/passbook/providers/app_gw/models.py b/passbook/providers/app_gw/models.py index 664c75f8b..b85f759ee 100644 --- a/passbook/providers/app_gw/models.py +++ b/passbook/providers/app_gw/models.py @@ -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() diff --git a/passbook/providers/oauth/forms.py b/passbook/providers/oauth/forms.py index 9c4acc4e0..3d3a7517e 100644 --- a/passbook/providers/oauth/forms.py +++ b/passbook/providers/oauth/forms.py @@ -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"), + } diff --git a/passbook/providers/oauth/models.py b/passbook/providers/oauth/models.py index b99ebaccf..c42d3bcb4 100644 --- a/passbook/providers/oauth/models.py +++ b/passbook/providers/oauth/models.py @@ -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" diff --git a/passbook/providers/oauth/settings.py b/passbook/providers/oauth/settings.py index b8bd2e1a0..bd04dd43e 100644 --- a/passbook/providers/oauth/settings.py +++ b/passbook/providers/oauth/settings.py @@ -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", diff --git a/passbook/providers/oauth/urls.py b/passbook/providers/oauth/urls.py index a8b68f603..4da8b3a07 100644 --- a/passbook/providers/oauth/urls.py +++ b/passbook/providers/oauth/urls.py @@ -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 = [ diff --git a/passbook/providers/oauth/views/github.py b/passbook/providers/oauth/views/github.py index b52446ee9..11adb9e0a 100644 --- a/passbook/providers/oauth/views/github.py +++ b/passbook/providers/oauth/views/github.py @@ -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) diff --git a/passbook/providers/oauth/views/oauth2.py b/passbook/providers/oauth/views/oauth2.py index 208dd99ac..301d3e432 100644 --- a/passbook/providers/oauth/views/oauth2.py +++ b/passbook/providers/oauth/views/oauth2.py @@ -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) diff --git a/passbook/providers/oidc/apps.py b/passbook/providers/oidc/apps.py index ee4f50191..363366e17 100644 --- a/passbook/providers/oidc/apps.py +++ b/passbook/providers/oidc/apps.py @@ -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") diff --git a/passbook/providers/oidc/auth.py b/passbook/providers/oidc/auth.py index 91a0b9dcf..0050463bf 100644 --- a/passbook/providers/oidc/auth.py +++ b/passbook/providers/oidc/auth.py @@ -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 diff --git a/passbook/providers/oidc/forms.py b/passbook/providers/oidc/forms.py index 857adeef6..46e56d16b 100644 --- a/passbook/providers/oidc/forms.py +++ b/passbook/providers/oidc/forms.py @@ -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", diff --git a/passbook/providers/oidc/models.py b/passbook/providers/oidc/models.py index 16c6d9d34..e0f3feaf0 100644 --- a/passbook/providers/oidc/models.py +++ b/passbook/providers/oidc/models.py @@ -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") ), }, ) diff --git a/passbook/providers/oidc/signals.py b/passbook/providers/oidc/signals.py deleted file mode 100644 index a74550388..000000000 --- a/passbook/providers/oidc/signals.py +++ /dev/null @@ -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() diff --git a/passbook/providers/oidc/urls.py b/passbook/providers/oidc/urls.py new file mode 100644 index 000000000..a7f317709 --- /dev/null +++ b/passbook/providers/oidc/urls.py @@ -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", + ), +] diff --git a/passbook/providers/oidc/views.py b/passbook/providers/oidc/views.py new file mode 100644 index 000000000..cd81f9c59 --- /dev/null +++ b/passbook/providers/oidc/views.py @@ -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 diff --git a/passbook/providers/saml/forms.py b/passbook/providers/saml/forms.py index 76a56d66a..864e36a74 100644 --- a/passbook/providers/saml/forms.py +++ b/passbook/providers/saml/forms.py @@ -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 diff --git a/passbook/providers/saml/migrations/0002_default_saml_property_mappings.py b/passbook/providers/saml/migrations/0002_default_saml_property_mappings.py new file mode 100644 index 000000000..9f1f13357 --- /dev/null +++ b/passbook/providers/saml/migrations/0002_default_saml_property_mappings.py @@ -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), + ] diff --git a/passbook/providers/saml/migrations/0003_samlprovider_sp_binding.py b/passbook/providers/saml/migrations/0003_samlprovider_sp_binding.py new file mode 100644 index 000000000..20ffbbf06 --- /dev/null +++ b/passbook/providers/saml/migrations/0003_samlprovider_sp_binding.py @@ -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" + ), + ), + ] diff --git a/passbook/providers/saml/models.py b/passbook/providers/saml/models.py index 4472df8c7..26767fbbb 100644 --- a/passbook/providers/saml/models.py +++ b/passbook/providers/saml/models.py @@ -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 diff --git a/passbook/providers/saml/processors/base.py b/passbook/providers/saml/processors/base.py index db65e37ff..966f687de 100644 --- a/passbook/providers/saml/processors/base.py +++ b/passbook/providers/saml/processors/base.py @@ -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) diff --git a/passbook/providers/saml/templates/saml/idp/autosubmit_form.html b/passbook/providers/saml/templates/saml/idp/autosubmit_form.html index dff5c2dba..6202797b1 100644 --- a/passbook/providers/saml/templates/saml/idp/autosubmit_form.html +++ b/passbook/providers/saml/templates/saml/idp/autosubmit_form.html @@ -4,30 +4,26 @@ {% load i18n %} {% block card_title %} -{% trans 'Redirecting...' %} +{% blocktrans with app=application.name %} +Redirecting to {{ app }}... +{% endblocktrans %} {% endblock %} {% block card %} -
    + {% csrf_token %} {% for key, value in attrs.items %} {% endfor %} -