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

View File

@ -13,7 +13,6 @@ from django.utils.translation import gettext_lazy as _
from guardian.mixins import GuardianUserMixin from guardian.mixins import GuardianUserMixin
from jinja2 import Undefined from jinja2 import Undefined
from jinja2.exceptions import TemplateSyntaxError, UndefinedError from jinja2.exceptions import TemplateSyntaxError, UndefinedError
from jinja2.nativetypes import NativeEnvironment
from model_utils.managers import InheritanceManager from model_utils.managers import InheritanceManager
from structlog import get_logger from structlog import get_logger
@ -24,7 +23,6 @@ from passbook.lib.models import CreatedUpdatedModel
from passbook.policies.models import PolicyBindingModel from passbook.policies.models import PolicyBindingModel
LOGGER = get_logger() LOGGER = get_logger()
NATIVE_ENVIRONMENT = NativeEnvironment()
def default_token_duration(): def default_token_duration():
@ -208,8 +206,11 @@ class PropertyMapping(models.Model):
self, user: Optional[User], request: Optional[HttpRequest], **kwargs self, user: Optional[User], request: Optional[HttpRequest], **kwargs
) -> Any: ) -> Any:
"""Evaluate `self.expression` using `**kwargs` as Context.""" """Evaluate `self.expression` using `**kwargs` as Context."""
from passbook.policies.expression.evaluator import Evaluator
evaluator = Evaluator()
try: try:
expression = NATIVE_ENVIRONMENT.from_string(self.expression) expression = evaluator.env.from_string(self.expression)
except TemplateSyntaxError as exc: except TemplateSyntaxError as exc:
raise PropertyMappingExpressionException from exc raise PropertyMappingExpressionException from exc
try: try:
@ -221,8 +222,11 @@ class PropertyMapping(models.Model):
raise PropertyMappingExpressionException from exc raise PropertyMappingExpressionException from exc
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
from passbook.policies.expression.evaluator import Evaluator
evaluator = Evaluator()
try: try:
NATIVE_ENVIRONMENT.from_string(self.expression) evaluator.env.from_string(self.expression)
except TemplateSyntaxError as exc: except TemplateSyntaxError as exc:
raise ValidationError("Expression Syntax Error") from exc raise ValidationError("Expression Syntax Error") from exc
return super().save(*args, **kwargs) return super().save(*args, **kwargs)

View File

@ -2,6 +2,13 @@
{% load i18n %} {% load i18n %}
{% csrf_token %} {% 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 %} {% for field in form %}
<div class="pf-c-form__group {% if field.errors %} has-error {% endif %}"> <div class="pf-c-form__group {% if field.errors %} has-error {% endif %}">
{% if field.field.widget|fieldtype == 'RadioSelect' %} {% if field.field.widget|fieldtype == 'RadioSelect' %}

View File

@ -1,13 +1,15 @@
"""flow planner tests""" """flow planner tests"""
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from django.core.cache import cache
from django.shortcuts import reverse from django.shortcuts import reverse
from django.test import RequestFactory, TestCase from django.test import RequestFactory, TestCase
from guardian.shortcuts import get_anonymous_user from guardian.shortcuts import get_anonymous_user
from passbook.core.models import User
from passbook.flows.exceptions import EmptyFlowException, FlowNonApplicableException from passbook.flows.exceptions import EmptyFlowException, FlowNonApplicableException
from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding 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.policies.types import PolicyResult
from passbook.stages.dummy.models import DummyStage from passbook.stages.dummy.models import DummyStage
@ -81,3 +83,24 @@ class TestFlowPlanner(TestCase):
self.assertEqual( self.assertEqual(
TIME_NOW_MOCK.call_count, 2 TIME_NOW_MOCK.call_count, 2
) # When taking from cache, time is not measured ) # 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): def setup(self, request: HttpRequest, flow_slug: str):
super().setup(request, flow_slug=flow_slug) 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: def handle_invalid_flow(self, exc: BaseException) -> HttpResponse:
"""When a flow is non-applicable check if user is on the correct domain""" """When a flow is non-applicable check if user is on the correct domain"""

View File

@ -1,22 +1,21 @@
"""passbook expression policy evaluator""" """passbook expression policy evaluator"""
import re 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.core.exceptions import ValidationError
from django.http import HttpRequest
from jinja2 import Undefined from jinja2 import Undefined
from jinja2.exceptions import TemplateSyntaxError from jinja2.exceptions import TemplateSyntaxError
from jinja2.nativetypes import NativeEnvironment from jinja2.nativetypes import NativeEnvironment
from requests import Session from requests import Session
from structlog import get_logger from structlog import get_logger
from passbook.core.models import User
from passbook.flows.planner import PLAN_CONTEXT_SSO from passbook.flows.planner import PLAN_CONTEXT_SSO
from passbook.flows.views import SESSION_KEY_PLAN from passbook.flows.views import SESSION_KEY_PLAN
from passbook.lib.utils.http import get_client_ip from passbook.lib.utils.http import get_client_ip
from passbook.policies.types import PolicyRequest, PolicyResult from passbook.policies.types import PolicyRequest, PolicyResult
if TYPE_CHECKING:
from passbook.core.models import User
LOGGER = get_logger() LOGGER = get_logger()
@ -25,12 +24,33 @@ class Evaluator:
_env: NativeEnvironment _env: NativeEnvironment
_context: Dict[str, Any]
_messages: List[str]
def __init__(self): 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 passbook/policies/expression/templates/policy/expression/form.html
# update docs/policies/expression/index.md # update docs/policies/expression/index.md
self._env.filters["regex_match"] = Evaluator.jinja2_filter_regex_match self._env.filters["regex_match"] = Evaluator.jinja2_filter_regex_match
self._env.filters["regex_replace"] = Evaluator.jinja2_filter_regex_replace 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 @staticmethod
def jinja2_filter_regex_match(value: Any, regex: str) -> bool: def jinja2_filter_regex_match(value: Any, regex: str) -> bool:
@ -43,56 +63,69 @@ class Evaluator:
return re.sub(regex, repl, value) return re.sub(regex, repl, value)
@staticmethod @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`""" """Check if `user` is member of group with name `group_name`"""
return user.groups.filter(name=group_name).exists() return user.groups.filter(name=group_name).exists()
def _get_expression_context( def jinja2_func_message(self, message: str):
self, request: PolicyRequest, **kwargs """Wrapper to append to messages list, which is returned with PolicyResult"""
) -> Dict[str, Any]: self._messages.append(message)
"""Return dictionary with additional global variables passed to expression"""
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 passbook/policies/expression/templates/policy/expression/form.html
# update docs/policies/expression/index.md # update docs/policies/expression/index.md
kwargs["pb_is_group_member"] = Evaluator.jinja2_func_is_group_member self._context["pb_is_sso_flow"] = request.context.get(PLAN_CONTEXT_SSO, False)
kwargs["pb_logger"] = get_logger() self._context["request"] = request
kwargs["requests"] = Session()
kwargs["pb_is_sso_flow"] = request.context.get(PLAN_CONTEXT_SSO, False)
if request.http_request: if request.http_request:
kwargs["pb_client_ip"] = ( self.set_http_request(request.http_request)
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
def evaluate(self, expression_source: str, request: PolicyRequest) -> PolicyResult: def set_http_request(self, request: HttpRequest):
"""Parse and evaluate expression. """Update context based on http request"""
If the Expression evaluates to a list with 2 items, the first is used as passing bool and # update passbook/policies/expression/templates/policy/expression/form.html
the second as messages. # update docs/policies/expression/index.md
If the Expression evaluates to a truthy-object, it is used as passing bool.""" 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:
self._context["pb_flow_plan"] = request.http_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: try:
expression = self._env.from_string(expression_source) expression = self._env.from_string(expression_source.lstrip().rstrip())
except TemplateSyntaxError as exc: except TemplateSyntaxError as exc:
return PolicyResult(False, str(exc)) return PolicyResult(False, str(exc))
try: try:
result: Optional[Any] = expression.render( result: Optional[Any] = expression.render(self._context)
request=request, **self._get_expression_context(request) 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): if isinstance(result, Undefined):
LOGGER.warning( LOGGER.warning(
"Expression policy returned undefined", "Expression policy returned undefined",
src=expression_source, src=expression_source,
req=request, req=self._context,
) )
return PolicyResult(False) policy_result.passing = False
if isinstance(result, (list, tuple)) and len(result) == 2:
return PolicyResult(*result)
if result: if result:
return PolicyResult(bool(result)) policy_result.passing = bool(result)
return PolicyResult(False) return policy_result
except Exception as exc: # pylint: disable=broad-except
LOGGER.warning("Expression error", exc=exc)
return PolicyResult(False, str(exc))
def validate(self, expression: str): def validate(self, expression: str):
"""Validate expression's syntax, raise ValidationError if Syntax is invalid""" """Validate expression's syntax, raise ValidationError if Syntax is invalid"""
@ -100,4 +133,4 @@ class Evaluator:
self._env.from_string(expression) self._env.from_string(expression)
return True return True
except TemplateSyntaxError as exc: 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: def passes(self, request: PolicyRequest) -> PolicyResult:
"""Evaluate and render expression. Returns PolicyResult(false) on error.""" """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): def save(self, *args, **kwargs):
Evaluator().validate(self.expression) Evaluator().validate(self.expression)

View File

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

View File

@ -39,4 +39,6 @@ class PolicyResult:
self.messages = messages self.messages = messages
def __str__(self): 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", "name",
"user_fields", "user_fields",
"template", "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.db import models
from django.utils.translation import gettext_lazy as _ 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): class UserFields(models.TextChoices):
@ -29,6 +29,29 @@ class IdentificationStage(Stage):
) )
template = models.TextField(choices=Templates.choices) 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" type = "passbook.stages.identification.stage.IdentificationStageView"
form = "passbook.stages.identification.forms.IdentificationStageForm" form = "passbook.stages.identification.forms.IdentificationStageForm"

View File

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

View File

@ -1,72 +1,29 @@
{% extends 'base/skeleton.html' %}
{% load static %}
{% load i18n %} {% load i18n %}
{% load static %}
{% block body %} <header class="pf-c-login__main-header">
<div class="pf-c-background-image"> <h1 class="pf-c-title pf-m-3xl">
<svg xmlns="http://www.w3.org/2000/svg" class="pf-c-background-image__filter" width="0" height="0"> {% trans 'Trouble Logging In?' %}
<filter id="image_overlay"> </h1>
<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> </header>
<feComponentTransfer color-interpolation-filters="sRGB" result="duotone"> <div class="pf-c-login__main-body">
<feFuncR type="table" tableValues="0.086274509803922 0.43921568627451"></feFuncR> {% block card %}
<feFuncG type="table" tableValues="0.086274509803922 0.43921568627451"></feFuncG> <form method="POST" class="pf-c-form">
<feFuncB type="table" tableValues="0.086274509803922 0.43921568627451"></feFuncB> {% block above_form %}
<feFuncA type="table" tableValues="0 1"></feFuncA> {% endblock %}
</feComponentTransfer>
</filter> {% include 'partials/form.html' %}
</svg>
{% block beneath_form %}
{% endblock %}
<div class="pf-c-form__group pf-m-action">
<button class="pf-c-button pf-m-primary pf-m-block" type="submit">{% trans primary_action %}</button>
</div>
</form>
{% endblock %}
</div> </div>
{% include 'partials/messages.html' %} <footer class="pf-c-login__main-footer">
<div class="pf-c-login"> {% if config.login.subtext %}
<div class="pf-c-login__container"> <p>{{ config.login.subtext }}</p>
<header class="pf-c-login__header"> {% endif %}
<img class="pf-c-brand" src="{% static 'passbook/logo.svg' %}" style="height: 60px;" </footer>
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">
<h1 class="pf-c-title pf-m-3xl">
{% trans 'Trouble Logging In?' %}
</h1>
</header>
<div class="pf-c-login__main-body">
{% block card %}
<form method="POST" class="pf-c-form">
{% block above_form %}
{% endblock %}
{% include 'partials/form.html' %}
{% block beneath_form %}
{% endblock %}
<div class="pf-c-form__group pf-m-action">
<button class="pf-c-button pf-m-primary pf-m-block" type="submit">{% trans primary_action %}</button>
</div>
</form>
{% endblock %}
</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 %}

View File

@ -85,15 +85,19 @@ class TestIdentificationStage(TestCase):
slug="unique-enrollment-string", slug="unique-enrollment-string",
designation=FlowDesignation.ENROLLMENT, designation=FlowDesignation.ENROLLMENT,
) )
self.stage.enrollment_flow = flow
self.stage.save()
FlowStageBinding.objects.create( FlowStageBinding.objects.create(
flow=flow, stage=self.stage, order=0, flow=flow, stage=self.stage, order=0,
) )
response = self.client.get( 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.assertEqual(response.status_code, 200)
self.assertIn(flow.name, response.rendered_content) self.assertIn(flow.slug, response.rendered_content)
def test_recovery_flow(self): def test_recovery_flow(self):
"""Test that recovery flow is linked correctly""" """Test that recovery flow is linked correctly"""
@ -102,12 +106,16 @@ class TestIdentificationStage(TestCase):
slug="unique-recovery-string", slug="unique-recovery-string",
designation=FlowDesignation.RECOVERY, designation=FlowDesignation.RECOVERY,
) )
self.stage.recovery_flow = flow
self.stage.save()
FlowStageBinding.objects.create( FlowStageBinding.objects.create(
flow=flow, stage=self.stage, order=0, flow=flow, stage=self.stage, order=0,
) )
response = self.client.get( 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.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() engine.build()
result = engine.result result = engine.result
if not result.passing: if not result.passing:
raise forms.ValidationError(result.messages) raise forms.ValidationError(list(result.messages))

View File

@ -5919,6 +5919,20 @@ definitions:
enum: enum:
- stages/identification/login.html - stages/identification/login.html
- stages/identification/recovery.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: InvitationStage:
required: required:
- name - name