Merge branch 'master' into docs-flows

This commit is contained in:
Jens Langhammer 2020-06-02 15:20:24 +02:00
commit 23193314f1
17 changed files with 272 additions and 153 deletions

39
Pipfile.lock generated
View File

@ -18,10 +18,10 @@
"default": {
"amqp": {
"hashes": [
"sha256:6e649ca13a7df3faacdc8bbb280aa9a6602d22fd9d545336077e573a1f4ff3b8",
"sha256:77f1aef9410698d20eaeac5b73a87817365f457a507d82edf292e12cbb83b08d"
"sha256:24dbaff8ce4f30566bb88976b398e8c4e77637171af3af6f1b9650f48890e60b",
"sha256:bb68f8d2bced8f93ccfd07d96c689b716b3227720add971be980accfc2952139"
],
"version": "==2.5.2"
"version": "==2.6.0"
},
"asgiref": {
"hashes": [
@ -53,17 +53,18 @@
},
"boto3": {
"hashes": [
"sha256:c774003dc13d6de74b5e19d2b84d625da4456e64bd97f44baa1fcf40d808d29a"
"sha256:26f8564b46d009b8f4c6470a6d6cde147b282a197339c7e31cbb0fe9fd9e5f5d",
"sha256:f59d0bd230ed3a4b932c5c4e497a0e0ff3c93b46b7e8cde54efb6fe10c8266ba"
],
"index": "pypi",
"version": "==1.13.19"
"version": "==1.13.20"
},
"botocore": {
"hashes": [
"sha256:5cb537e7a4cf2d59a2a8dfbbc8e14ec3bc5b640eb81a1bf3bb0523c0a75e6b1b",
"sha256:7b8b1f082665c8670b9aa70143ee527c5d04939fe027a63ac5958359be20ccb0"
"sha256:990f3fc33dec746829740b1a9e1fe86183cdc96aedba6a632ccfcbae03e097cc",
"sha256:d4cc47ac989a7f1d2992ef7679fb423a7966f687becf623a291a555a2d7ce1c0"
],
"version": "==1.16.19"
"version": "==1.16.20"
},
"celery": {
"hashes": [
@ -363,11 +364,11 @@
},
"kombu": {
"hashes": [
"sha256:2d1cda774126a044d91a7ff5fa6d09edf99f46924ab332a810760fe6740e9b76",
"sha256:598e7e749d6ab54f646b74b2d2df67755dee13894f73ab02a2a9feb8870c7cb2"
"sha256:ab0afaa5388dd2979cbc439d3623b86a4f7a58d41f621096bef7767c37bc2505",
"sha256:aece08f48706743aaa1b9d607fee300559481eafcc5ee56451aa0ef867a3be07"
],
"index": "pypi",
"version": "==4.6.8"
"version": "==4.6.9"
},
"ldap3": {
"hashes": [
@ -687,10 +688,10 @@
},
"redis": {
"hashes": [
"sha256:2ef11f489003f151777c064c5dbc6653dfb9f3eade159bcadc524619fddc2242",
"sha256:6d65e84bc58091140081ee9d9c187aab0480097750fac44239307a3bdf0b1251"
"sha256:0e7e0cfca8660dea8b7d5cd8c4f6c5e29e11f31158c0b0ae91a397f00e5a05a2",
"sha256:432b788c4530cfe16d8d943a09d40ca6c16149727e4afe8c2c9d5580c59d9f24"
],
"version": "==3.5.2"
"version": "==3.5.3"
},
"requests": {
"hashes": [
@ -857,10 +858,10 @@
},
"autopep8": {
"hashes": [
"sha256:152fd8fe47d02082be86e05001ec23d6f420086db56b17fc883f3f965fb34954"
"sha256:60fd8c4341bab59963dafd5d2a566e94f547e660b9b396f772afe67d8481dbf0"
],
"index": "pypi",
"version": "==1.5.2"
"version": "==1.5.3"
},
"bandit": {
"hashes": [
@ -970,10 +971,10 @@
},
"gitpython": {
"hashes": [
"sha256:864a47472548f3ba716ca202e034c1900f197c0fb3a08f641c20c3cafd15ed94",
"sha256:da3b2cf819974789da34f95ac218ef99f515a928685db141327c09b73dd69c09"
"sha256:e107af4d873daed64648b4f4beb89f89f0cfbe3ef558fc7821ed2331c2f8da1a",
"sha256:ef1d60b01b5ce0040ad3ec20bc64f783362d41fa0822a2742d3586e1f49bb8ac"
],
"version": "==3.1.2"
"version": "==3.1.3"
},
"isort": {
"hashes": [

View File

@ -13,7 +13,6 @@ 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
@ -24,7 +23,6 @@ from passbook.lib.models import CreatedUpdatedModel
from passbook.policies.models import PolicyBindingModel
LOGGER = get_logger()
NATIVE_ENVIRONMENT = NativeEnvironment()
def default_token_duration():
@ -208,8 +206,11 @@ class PropertyMapping(models.Model):
self, user: Optional[User], request: Optional[HttpRequest], **kwargs
) -> Any:
"""Evaluate `self.expression` using `**kwargs` as Context."""
from passbook.policies.expression.evaluator import Evaluator
evaluator = Evaluator()
try:
expression = NATIVE_ENVIRONMENT.from_string(self.expression)
expression = evaluator.env.from_string(self.expression)
except TemplateSyntaxError as exc:
raise PropertyMappingExpressionException from exc
try:
@ -221,8 +222,11 @@ class PropertyMapping(models.Model):
raise PropertyMappingExpressionException from exc
def save(self, *args, **kwargs):
from passbook.policies.expression.evaluator import Evaluator
evaluator = Evaluator()
try:
NATIVE_ENVIRONMENT.from_string(self.expression)
evaluator.env.from_string(self.expression)
except TemplateSyntaxError as exc:
raise ValidationError("Expression Syntax Error") from exc
return super().save(*args, **kwargs)

View File

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

View File

@ -1,13 +1,15 @@
"""flow planner tests"""
from unittest.mock import MagicMock, 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
@ -81,3 +83,24 @@ class TestFlowPlanner(TestCase):
self.assertEqual(
TIME_NOW_MOCK.call_count, 2
) # When taking from cache, time is not measured
def test_planner_default_context(self):
"""Test planner with default_context"""
flow = Flow.objects.create(
name="test-default-context",
slug="test-default-context",
designation=FlowDesignation.AUTHENTICATION,
)
FlowStageBinding.objects.create(
flow=flow, stage=DummyStage.objects.create(name="dummy"), order=0
)
user = User.objects.create(username="test-user")
request = self.request_factory.get(
reverse("passbook_flows:flow-executor", kwargs={"flow_slug": flow.slug}),
)
request.user = user
planner = FlowPlanner(flow)
planner.plan(request, default_context={PLAN_CONTEXT_PENDING_USER: user})
key = cache_key(flow, user)
self.assertTrue(cache.get(key) is not None)

View File

@ -34,7 +34,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"""

View File

@ -1,22 +1,21 @@
"""passbook expression policy evaluator"""
import re
from typing import TYPE_CHECKING, Any, Dict, Optional
from typing import Any, Dict, List, Optional
from django.core.exceptions import ValidationError
from django.http import HttpRequest
from jinja2 import Undefined
from jinja2.exceptions import TemplateSyntaxError
from jinja2.nativetypes import NativeEnvironment
from requests import Session
from structlog import get_logger
from passbook.core.models import User
from passbook.flows.planner import PLAN_CONTEXT_SSO
from passbook.flows.views import SESSION_KEY_PLAN
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()
@ -25,12 +24,33 @@ class Evaluator:
_env: NativeEnvironment
_context: Dict[str, Any]
_messages: List[str]
def __init__(self):
self._env = NativeEnvironment()
self._env = NativeEnvironment(
extensions=["jinja2.ext.do"],
trim_blocks=True,
lstrip_blocks=True,
line_statement_prefix=">",
)
# 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
self._env.globals["pb_message"] = self.jinja2_func_message
self._context = {
"pb_is_group_member": Evaluator.jinja2_func_is_group_member,
"pb_user_by": Evaluator.jinja2_func_user_by,
"pb_logger": get_logger(),
"requests": Session(),
}
self._messages = []
@property
def env(self) -> NativeEnvironment:
"""Access to our custom NativeEnvironment"""
return self._env
@staticmethod
def jinja2_filter_regex_match(value: Any, regex: str) -> bool:
@ -43,56 +63,69 @@ class Evaluator:
return re.sub(regex, repl, value)
@staticmethod
def jinja2_func_is_group_member(user: "User", group_name: str) -> bool:
def jinja2_func_user_by(**filters) -> Optional[User]:
"""Get user by filters"""
users = User.objects.filter(**filters)
if users:
return users.first()
return None
@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"""
def jinja2_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
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)
self._context["request"] = request
if request.http_request:
kwargs["pb_client_ip"] = (
self.set_http_request(request.http_request)
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.http_request) or "255.255.255.255"
)
self._context["request"] = request
if SESSION_KEY_PLAN in request.http_request.session:
kwargs["pb_flow_plan"] = request.http_request.session[SESSION_KEY_PLAN]
return kwargs
self._context["pb_flow_plan"] = request.http_request.session[
SESSION_KEY_PLAN
]
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 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)
expression = self._env.from_string(expression_source.lstrip().rstrip())
except TemplateSyntaxError as exc:
return PolicyResult(False, str(exc))
try:
result: Optional[Any] = expression.render(
request=request, **self._get_expression_context(request)
)
result: Optional[Any] = expression.render(self._context)
except Exception as exc: # pylint: disable=broad-except
LOGGER.warning("Expression error", exc=exc)
return PolicyResult(False, str(exc))
else:
policy_result = PolicyResult(False)
policy_result.messages = tuple(self._messages)
if isinstance(result, Undefined):
LOGGER.warning(
"Expression policy returned undefined",
src=expression_source,
req=request,
req=self._context,
)
return PolicyResult(False)
if isinstance(result, (list, tuple)) and len(result) == 2:
return PolicyResult(*result)
policy_result.passing = False
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))
policy_result.passing = bool(result)
return policy_result
def validate(self, expression: str):
"""Validate expression's syntax, raise ValidationError if Syntax is invalid"""
@ -100,4 +133,4 @@ class Evaluator:
self._env.from_string(expression)
return True
except TemplateSyntaxError as exc:
raise ValidationError("Expression Syntax Error") from exc
raise ValidationError(f"Expression Syntax Error: {str(exc)}") from exc

View File

@ -16,7 +16,9 @@ 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 = Evaluator()
evaluator.set_policy_request(request)
return evaluator.evaluate(self.expression)
def save(self, *args, **kwargs):
Evaluator().validate(self.expression)

View File

@ -17,13 +17,15 @@ class TestEvaluator(TestCase):
"""test simple value expression"""
template = "True"
evaluator = Evaluator()
self.assertEqual(evaluator.evaluate(template, self.request).passing, True)
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'"
template = '{% do pb_message("some message") %}False'
evaluator = Evaluator()
result = evaluator.evaluate(template, self.request)
evaluator.set_policy_request(self.request)
result = evaluator.evaluate(template)
self.assertEqual(result.passing, False)
self.assertEqual(result.messages, ("some message",))
@ -31,7 +33,8 @@ class TestEvaluator(TestCase):
"""test invalid syntax"""
template = "{%"
evaluator = Evaluator()
result = evaluator.evaluate(template, self.request)
evaluator.set_policy_request(self.request)
result = evaluator.evaluate(template)
self.assertEqual(result.passing, False)
self.assertEqual(result.messages, ("tag name expected",))
@ -39,7 +42,8 @@ class TestEvaluator(TestCase):
"""test undefined result"""
template = "{{ foo.bar }}"
evaluator = Evaluator()
result = evaluator.evaluate(template, self.request)
evaluator.set_policy_request(self.request)
result = evaluator.evaluate(template)
self.assertEqual(result.passing, False)
self.assertEqual(result.messages, ("'foo' is undefined",))

View File

@ -39,4 +39,6 @@ class PolicyResult:
self.messages = messages
def __str__(self):
return f"<PolicyResult passing={self.passing}>"
if self.messages:
return f"PolicyResult passing={self.passing} messages={self.messages}"
return f"PolicyResult passing={self.passing}"

View File

@ -16,6 +16,8 @@ class IdentificationStageSerializer(ModelSerializer):
"name",
"user_fields",
"template",
"enrollment_flow",
"recovery_flow",
]

View File

@ -0,0 +1,41 @@
# Generated by Django 3.0.6 on 2020-05-30 22:04
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("passbook_flows", "0002_default_flows"),
("passbook_stages_identification", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="identificationstage",
name="enrollment_flow",
field=models.ForeignKey(
blank=True,
default=None,
help_text="Optional enrollment flow, which is linked at the bottom of the page.",
null=True,
on_delete=django.db.models.deletion.SET_DEFAULT,
related_name="+",
to="passbook_flows.Flow",
),
),
migrations.AddField(
model_name="identificationstage",
name="recovery_flow",
field=models.ForeignKey(
blank=True,
default=None,
help_text="Optional enrollment flow, which is linked at the bottom of the page.",
null=True,
on_delete=django.db.models.deletion.SET_DEFAULT,
related_name="+",
to="passbook_flows.Flow",
),
),
]

View File

@ -3,7 +3,7 @@ from django.contrib.postgres.fields import ArrayField
from django.db import models
from django.utils.translation import gettext_lazy as _
from passbook.flows.models import Stage
from passbook.flows.models import Flow, Stage
class UserFields(models.TextChoices):
@ -29,6 +29,29 @@ class IdentificationStage(Stage):
)
template = models.TextField(choices=Templates.choices)
enrollment_flow = models.ForeignKey(
Flow,
on_delete=models.SET_DEFAULT,
null=True,
blank=True,
related_name="+",
default=None,
help_text=_(
"Optional enrollment flow, which is linked at the bottom of the page."
),
)
recovery_flow = models.ForeignKey(
Flow,
on_delete=models.SET_DEFAULT,
null=True,
blank=True,
related_name="+",
default=None,
help_text=_(
"Optional enrollment flow, which is linked at the bottom of the page."
),
)
type = "passbook.stages.identification.stage.IdentificationStageView"
form = "passbook.stages.identification.forms.IdentificationStageForm"

View File

@ -10,7 +10,6 @@ from django.views.generic import FormView
from structlog import get_logger
from passbook.core.models import Source, User
from passbook.flows.models import FlowDesignation
from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER
from passbook.flows.stage import StageView
from passbook.stages.identification.forms import IdentificationForm
@ -34,18 +33,17 @@ class IdentificationStageView(FormView, StageView):
return [current_stage.template]
def get_context_data(self, **kwargs):
current_stage: IdentificationStage = self.executor.current_stage
# Check for related enrollment and recovery flow, add URL to view
enrollment_flow = self.executor.flow.related_flow(FlowDesignation.ENROLLMENT)
if enrollment_flow:
if current_stage.enrollment_flow:
kwargs["enroll_url"] = reverse(
"passbook_flows:flow-executor-shell",
kwargs={"flow_slug": enrollment_flow.slug},
kwargs={"flow_slug": current_stage.enrollment_flow.slug},
)
recovery_flow = self.executor.flow.related_flow(FlowDesignation.RECOVERY)
if recovery_flow:
if current_stage.recovery_flow:
kwargs["recovery_url"] = reverse(
"passbook_flows:flow-executor-shell",
kwargs={"flow_slug": recovery_flow.slug},
kwargs={"flow_slug": current_stage.recovery_flow.slug},
)
kwargs["primary_action"] = _("Log in")

View File

@ -1,38 +1,12 @@
{% extends 'base/skeleton.html' %}
{% load static %}
{% load i18n %}
{% load static %}
{% block body %}
<div class="pf-c-background-image">
<svg xmlns="http://www.w3.org/2000/svg" class="pf-c-background-image__filter" width="0" height="0">
<filter id="image_overlay">
<feColorMatrix type="matrix" values="1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 0 0 0 1 0"></feColorMatrix>
<feComponentTransfer color-interpolation-filters="sRGB" result="duotone">
<feFuncR type="table" tableValues="0.086274509803922 0.43921568627451"></feFuncR>
<feFuncG type="table" tableValues="0.086274509803922 0.43921568627451"></feFuncG>
<feFuncB type="table" tableValues="0.086274509803922 0.43921568627451"></feFuncB>
<feFuncA type="table" tableValues="0 1"></feFuncA>
</feComponentTransfer>
</filter>
</svg>
</div>
{% include 'partials/messages.html' %}
<div class="pf-c-login">
<div class="pf-c-login__container">
<header class="pf-c-login__header">
<img class="pf-c-brand" src="{% static 'passbook/logo.svg' %}" style="height: 60px;"
alt="passbook icon" />
<img class="pf-c-brand" src="{% static 'passbook/brand.svg' %}" style="height: 60px;"
alt="passbook branding" />
</header>
<main class="pf-c-login__main">
<header class="pf-c-login__main-header">
<header class="pf-c-login__main-header">
<h1 class="pf-c-title pf-m-3xl">
{% trans 'Trouble Logging In?' %}
</h1>
</header>
<div class="pf-c-login__main-body">
</header>
<div class="pf-c-login__main-body">
{% block card %}
<form method="POST" class="pf-c-form">
{% block above_form %}
@ -47,26 +21,9 @@
</div>
</form>
{% endblock %}
</div>
<footer class="pf-c-login__main-footer">
</div>
<footer class="pf-c-login__main-footer">
{% if config.login.subtext %}
<p>{{ config.login.subtext }}</p>
{% endif %}
</footer>
</main>
<footer class="pf-c-login__footer">
<ul class="pf-c-list pf-m-inline">
<li>
<a href="https://beryju.github.io/passbook/">{% trans 'Documentation' %}</a>
</li>
{% config 'passbook.footer_links' as footer_links %}
{% for link in footer_links %}
<li>
<a href="{{ link.href }}">{{ link.name }}</a>
</li>
{% endfor %}
</ul>
</footer>
</div>
</div>
{% endblock %}
</footer>

View File

@ -85,15 +85,19 @@ class TestIdentificationStage(TestCase):
slug="unique-enrollment-string",
designation=FlowDesignation.ENROLLMENT,
)
self.stage.enrollment_flow = flow
self.stage.save()
FlowStageBinding.objects.create(
flow=flow, stage=self.stage, order=0,
)
response = self.client.get(
reverse("passbook_flows:flow-executor", kwargs={"flow_slug": flow.slug}),
reverse(
"passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
),
)
self.assertEqual(response.status_code, 200)
self.assertIn(flow.name, response.rendered_content)
self.assertIn(flow.slug, response.rendered_content)
def test_recovery_flow(self):
"""Test that recovery flow is linked correctly"""
@ -102,12 +106,16 @@ class TestIdentificationStage(TestCase):
slug="unique-recovery-string",
designation=FlowDesignation.RECOVERY,
)
self.stage.recovery_flow = flow
self.stage.save()
FlowStageBinding.objects.create(
flow=flow, stage=self.stage, order=0,
)
response = self.client.get(
reverse("passbook_flows:flow-executor", kwargs={"flow_slug": flow.slug}),
reverse(
"passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
),
)
self.assertEqual(response.status_code, 200)
self.assertIn(flow.name, response.rendered_content)
self.assertIn(flow.slug, response.rendered_content)

View File

@ -64,4 +64,4 @@ class PromptForm(forms.Form):
engine.build()
result = engine.result
if not result.passing:
raise forms.ValidationError(result.messages)
raise forms.ValidationError(list(result.messages))

View File

@ -5919,6 +5919,20 @@ definitions:
enum:
- stages/identification/login.html
- stages/identification/recovery.html
enrollment_flow:
title: Enrollment flow
description: Optional enrollment flow, which is linked at the bottom of the
page.
type: string
format: uuid
x-nullable: true
recovery_flow:
title: Recovery flow
description: Optional enrollment flow, which is linked at the bottom of the
page.
type: string
format: uuid
x-nullable: true
InvitationStage:
required:
- name