+ {% blocktrans with username=user.username %}
+ Hi {{ username }},
+ {% endblocktrans %}
+
+
+ {% blocktrans %}
+ You recently requested to change your password for you passbook account. Use the button below to set a new password.
+ {% endblocktrans %}
+
+ {% blocktrans with expires=expires|naturaltime %}
+ If you did not request a password change, please ignore this E-Mail. The link above is valid for {{ expires }}.
+ {% endblocktrans %}
+
+
+
+{% endblock %}
diff --git a/passbook/stages/email/templates/stages/email/waiting_message.html b/passbook/stages/email/templates/stages/email/waiting_message.html
new file mode 100644
index 000000000..4a4ad8b1d
--- /dev/null
+++ b/passbook/stages/email/templates/stages/email/waiting_message.html
@@ -0,0 +1 @@
+check your emails mate
diff --git a/passbook/stages/email/templatetags/__init__.py b/passbook/stages/email/templatetags/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/passbook/stages/email/templatetags/passbook_stages_email.py b/passbook/stages/email/templatetags/passbook_stages_email.py
new file mode 100644
index 000000000..d39668eff
--- /dev/null
+++ b/passbook/stages/email/templatetags/passbook_stages_email.py
@@ -0,0 +1,30 @@
+"""passbook core inlining template tags"""
+import os
+from pathlib import Path
+from typing import Optional
+
+from django import template
+from django.contrib.staticfiles import finders
+
+register = template.Library()
+
+
+@register.simple_tag()
+def inline_static_ascii(path: str) -> Optional[str]:
+ """Inline static asset. Doesn't check file contents, plain text is assumed"""
+ result = finders.find(path)
+ if os.path.exists(result):
+ with open(result) as _file:
+ return _file.read()
+ return None
+
+
+@register.simple_tag()
+def inline_static_binary(path: str) -> Optional[str]:
+ """Inline static asset. Uses file extension for base64 block"""
+ result = finders.find(path)
+ suffix = Path(path).suffix
+ if os.path.exists(result):
+ with open(result) as _file:
+ return f"data:image/{suffix};base64," + _file.read()
+ return None
diff --git a/passbook/stages/email/utils.py b/passbook/stages/email/utils.py
index a94f4c5d6..e46f27d7d 100644
--- a/passbook/stages/email/utils.py
+++ b/passbook/stages/email/utils.py
@@ -8,34 +8,10 @@ class TemplateEmailMessage(EmailMultiAlternatives):
"""Wrapper around EmailMultiAlternatives with integrated template rendering"""
# pylint: disable=too-many-arguments
- def __init__(
- self,
- subject="",
- body=None,
- from_email=None,
- to=None,
- bcc=None,
- connection=None,
- attachments=None,
- headers=None,
- cc=None,
- reply_to=None,
- template_name=None,
- template_context=None,
- ):
+ def __init__(self, template_name=None, template_context=None, **kwargs):
html_content = render_to_string(template_name, template_context)
- if not body:
- body = strip_tags(html_content)
- super().__init__(
- subject=subject,
- body=body,
- from_email=from_email,
- to=to,
- bcc=bcc,
- connection=connection,
- attachments=attachments,
- headers=headers,
- cc=cc,
- reply_to=reply_to,
- )
+ if "body" not in kwargs:
+ kwargs["body"] = strip_tags(html_content)
+ super().__init__(**kwargs)
+ self.content_subtype = "html"
self.attach_alternative(html_content, "text/html")
From 206cf4967d045a483804d52b4a8747ce8fc3ae47 Mon Sep 17 00:00:00 2001
From: Jens Langhammer
Date: Sun, 10 May 2020 20:17:10 +0200
Subject: [PATCH 47/80] stages/identification: add more templates
---
passbook/stages/email/stage.py | 20 +++---
passbook/stages/email/utils.py | 1 -
.../migrations/0004_auto_20200510_1648.py | 23 +++++++
passbook/stages/identification/models.py | 3 +-
.../stages/identification/login.html | 1 +
.../stages/identification/recovery.html | 68 +++++++++++++++++++
6 files changed, 105 insertions(+), 11 deletions(-)
create mode 100644 passbook/stages/identification/migrations/0004_auto_20200510_1648.py
create mode 100644 passbook/stages/identification/templates/stages/identification/login.html
create mode 100644 passbook/stages/identification/templates/stages/identification/recovery.html
diff --git a/passbook/stages/email/stage.py b/passbook/stages/email/stage.py
index 76cb97726..10c2a0891 100644
--- a/passbook/stages/email/stage.py
+++ b/passbook/stages/email/stage.py
@@ -1,10 +1,10 @@
"""passbook multi-stage authentication engine"""
from datetime import timedelta
-from urllib.parse import quote
from django.contrib import messages
from django.http import HttpRequest
from django.shortcuts import reverse
+from django.utils.http import urlencode
from django.utils.timezone import now
from django.utils.translation import gettext as _
from structlog import get_logger
@@ -23,6 +23,15 @@ class EmailStageView(AuthenticationStage):
template_name = "stages/email/waiting_message.html"
+ def get_full_url(self, **kwargs) -> str:
+ """Get full URL to be used in template"""
+ base_url = reverse(
+ "passbook_flows:flow-executor",
+ kwargs={"flow_slug": self.executor.flow.slug},
+ )
+ relative_url = f"{base_url}?{urlencode(kwargs)}"
+ return self.request.build_absolute_uri(relative_url)
+
def get(self, request, *args, **kwargs):
# TODO: Form to make sure email is only sent once
pending_user = self.executor.plan.context[PLAN_CONTEXT_PENDING_USER]
@@ -37,14 +46,7 @@ class EmailStageView(AuthenticationStage):
template_name="stages/email/for_email/password_reset.html",
to=[pending_user.email],
template_context={
- "url": self.request.build_absolute_uri(
- reverse(
- "passbook_flows:flow-executor",
- kwargs={"flow_slug": self.executor.flow.slug},
- )
- + "?token="
- + quote(nonce.uuid.hex)
- ),
+ "url": self.get_full_url(token=nonce.pk.hex),
"user": pending_user,
"expires": nonce.expires,
},
diff --git a/passbook/stages/email/utils.py b/passbook/stages/email/utils.py
index e46f27d7d..b26b9eab6 100644
--- a/passbook/stages/email/utils.py
+++ b/passbook/stages/email/utils.py
@@ -7,7 +7,6 @@ from django.utils.html import strip_tags
class TemplateEmailMessage(EmailMultiAlternatives):
"""Wrapper around EmailMultiAlternatives with integrated template rendering"""
- # pylint: disable=too-many-arguments
def __init__(self, template_name=None, template_context=None, **kwargs):
html_content = render_to_string(template_name, template_context)
if "body" not in kwargs:
diff --git a/passbook/stages/identification/migrations/0004_auto_20200510_1648.py b/passbook/stages/identification/migrations/0004_auto_20200510_1648.py
new file mode 100644
index 000000000..9c081b969
--- /dev/null
+++ b/passbook/stages/identification/migrations/0004_auto_20200510_1648.py
@@ -0,0 +1,23 @@
+# Generated by Django 3.0.5 on 2020-05-10 16:48
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("passbook_stages_identification", "0003_auto_20200509_2025"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="identificationstage",
+ name="template",
+ field=models.TextField(
+ choices=[
+ ("stages/identification/login.html", "Default Login"),
+ ("stages/identification/recovery.html", "Default Recovery"),
+ ]
+ ),
+ ),
+ ]
diff --git a/passbook/stages/identification/models.py b/passbook/stages/identification/models.py
index 40616d0ac..d43b6b15c 100644
--- a/passbook/stages/identification/models.py
+++ b/passbook/stages/identification/models.py
@@ -16,7 +16,8 @@ class UserFields(models.TextChoices):
class Templates(models.TextChoices):
"""Templates to be used for the stage"""
- DEFAULT_LOGIN = "login/form.html"
+ DEFAULT_LOGIN = "stages/identification/login.html"
+ DEFAULT_RECOVERY = "stages/identification/recovery.html"
class IdentificationStage(Stage):
diff --git a/passbook/stages/identification/templates/stages/identification/login.html b/passbook/stages/identification/templates/stages/identification/login.html
new file mode 100644
index 000000000..640765f1e
--- /dev/null
+++ b/passbook/stages/identification/templates/stages/identification/login.html
@@ -0,0 +1 @@
+{% extends 'login/form.html' %}
diff --git a/passbook/stages/identification/templates/stages/identification/recovery.html b/passbook/stages/identification/templates/stages/identification/recovery.html
new file mode 100644
index 000000000..1cf4c4838
--- /dev/null
+++ b/passbook/stages/identification/templates/stages/identification/recovery.html
@@ -0,0 +1,68 @@
+{% extends 'base/skeleton.html' %}
+
+{% load static %}
+{% load i18n %}
+
+{% block body %}
+
diff --git a/passbook/providers/oidc/templates/oidc_provider/authorize.html b/passbook/providers/oidc/templates/oidc_provider/authorize.html
index 6438d8a92..5a1fa1780 100644
--- a/passbook/providers/oidc/templates/oidc_provider/authorize.html
+++ b/passbook/providers/oidc/templates/oidc_provider/authorize.html
@@ -38,7 +38,7 @@
{% blocktrans with user=user %}
You are logged in as {{ user }}. Not you?
{% endblocktrans %}
- {% trans 'Logout' %}
+ {% trans 'Logout' %}
diff --git a/passbook/providers/saml/templates/saml/idp/autosubmit_form.html b/passbook/providers/saml/templates/saml/idp/autosubmit_form.html
index 54a198770..b43f243db 100644
--- a/passbook/providers/saml/templates/saml/idp/autosubmit_form.html
+++ b/passbook/providers/saml/templates/saml/idp/autosubmit_form.html
@@ -18,7 +18,7 @@
{% blocktrans with user=user %}
You are logged in as {{ user }}.
{% endblocktrans %}
- {% trans 'Not you?' %}
+ {% trans 'Not you?' %}
diff --git a/passbook/providers/saml/templates/saml/idp/login.html b/passbook/providers/saml/templates/saml/idp/login.html
index 1126d5bda..d4257cab7 100644
--- a/passbook/providers/saml/templates/saml/idp/login.html
+++ b/passbook/providers/saml/templates/saml/idp/login.html
@@ -16,7 +16,7 @@
{% blocktrans with user=user %}
You are logged in as {{ user }}.
{% endblocktrans %}
- {% trans 'Not you?' %}
+ {% trans 'Not you?' %}
diff --git a/passbook/root/urls.py b/passbook/root/urls.py
index 73adb5531..a418d097c 100644
--- a/passbook/root/urls.py
+++ b/passbook/root/urls.py
@@ -12,6 +12,9 @@ from passbook.root.monitoring import MetricsView
LOGGER = get_logger()
admin.autodiscover()
admin.site.login = RedirectView.as_view(pattern_name="passbook_flows:default-auth")
+admin.site.logout = RedirectView.as_view(
+ pattern_name="passbook_flows:default-invalidate"
+)
handler400 = error.BadRequestView.as_view()
handler403 = error.ForbiddenView.as_view()
From 10cb412532ea878f0d8cb3b9c32ef2e9108d463b Mon Sep 17 00:00:00 2001
From: Jens Langhammer
Date: Mon, 11 May 2020 09:08:15 +0200
Subject: [PATCH 56/80] flows: fix linting of migrations
---
.../flows/migrations/0004_auto_20200510_2310.py | 17 +++++++++++++----
1 file changed, 13 insertions(+), 4 deletions(-)
diff --git a/passbook/flows/migrations/0004_auto_20200510_2310.py b/passbook/flows/migrations/0004_auto_20200510_2310.py
index 9b013bb5e..61a3cac3a 100644
--- a/passbook/flows/migrations/0004_auto_20200510_2310.py
+++ b/passbook/flows/migrations/0004_auto_20200510_2310.py
@@ -6,13 +6,22 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
- ('passbook_flows', '0003_auto_20200509_1258'),
+ ("passbook_flows", "0003_auto_20200509_1258"),
]
operations = [
migrations.AlterField(
- model_name='flow',
- name='designation',
- field=models.CharField(choices=[('authentication', 'Authentication'), ('enrollment', 'Enrollment'), ('recovery', 'Recovery'), ('password_change', 'Password Change'), ('invalidation', 'Invalidation')], max_length=100),
+ model_name="flow",
+ name="designation",
+ field=models.CharField(
+ choices=[
+ ("authentication", "Authentication"),
+ ("enrollment", "Enrollment"),
+ ("recovery", "Recovery"),
+ ("password_change", "Password Change"),
+ ("invalidation", "Invalidation"),
+ ],
+ max_length=100,
+ ),
),
]
From 6fd19c0a379ae04f83ccecba1dc05cb5efbe29b8 Mon Sep 17 00:00:00 2001
From: Jens Langhammer
Date: Mon, 11 May 2020 11:39:58 +0200
Subject: [PATCH 57/80] flows: add caching of plan, add planner unittests
---
passbook/flows/planner.py | 25 +++++++--
passbook/flows/tests/__init__.py | 0
passbook/flows/tests/test_planner.py | 82 ++++++++++++++++++++++++++++
3 files changed, 103 insertions(+), 4 deletions(-)
create mode 100644 passbook/flows/tests/__init__.py
create mode 100644 passbook/flows/tests/test_planner.py
diff --git a/passbook/flows/planner.py b/passbook/flows/planner.py
index 5ed272888..a2e654bf7 100644
--- a/passbook/flows/planner.py
+++ b/passbook/flows/planner.py
@@ -1,11 +1,13 @@
"""Flows Planner"""
from dataclasses import dataclass, field
from time import time
-from typing import Any, Dict, List, Tuple
+from typing import Any, Dict, List, Optional, Tuple
+from django.core.cache import cache
from django.http import HttpRequest
from structlog import get_logger
+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
@@ -16,6 +18,14 @@ PLAN_CONTEXT_PENDING_USER = "pending_user"
PLAN_CONTEXT_SSO = "is_sso"
+def cache_key(flow: Flow, user: Optional[User] = None) -> str:
+ """Generate Cache key for flow"""
+ prefix = f"flow_{flow.pk}"
+ if user:
+ prefix += f"#{user.pk}"
+ return prefix
+
+
@dataclass
class FlowPlan:
"""This data-class is the output of a FlowPlanner. It holds a flat list
@@ -34,9 +44,11 @@ class FlowPlanner:
"""Execute all policies to plan out a flat list of all Stages
that should be applied."""
+ use_cache: bool
flow: Flow
def __init__(self, flow: Flow):
+ self.use_cache = True
self.flow = flow
def _check_flow_root_policies(self, request: HttpRequest) -> Tuple[bool, List[str]]:
@@ -48,13 +60,17 @@ class FlowPlanner:
"""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)
- start_time = time()
- plan = FlowPlan(flow_pk=self.flow.pk.hex)
# First off, check the flow's direct policy bindings
# to make sure the user even has access to the flow
root_passing, root_passing_messages = self._check_flow_root_policies(request)
if not root_passing:
raise FlowNonApplicableException(root_passing_messages)
+ cached_plan = cache.get(cache_key(self.flow, request.user), None)
+ if cached_plan and self.use_cache:
+ LOGGER.debug("f(plan): Taking plan from cache", flow=self.flow)
+ return cached_plan
+ start_time = time()
+ plan = FlowPlan(flow_pk=self.flow.pk.hex)
# Check Flow policies
for stage in (
self.flow.stages.order_by("flowstagebinding__order")
@@ -66,7 +82,7 @@ class FlowPlanner:
engine.build()
passing, _ = engine.result
if passing:
- LOGGER.debug("f(plan): Stage passing", stage=stage)
+ LOGGER.debug("f(plan): Stage passing", stage=stage, flow=self.flow)
plan.stages.append(stage)
end_time = time()
LOGGER.debug(
@@ -74,6 +90,7 @@ class FlowPlanner:
flow=self.flow,
duration_s=end_time - start_time,
)
+ cache.set(cache_key(self.flow, request.user), plan)
if not plan.stages:
raise EmptyFlowException()
return plan
diff --git a/passbook/flows/tests/__init__.py b/passbook/flows/tests/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/passbook/flows/tests/test_planner.py b/passbook/flows/tests/test_planner.py
new file mode 100644
index 000000000..79c0b6bdb
--- /dev/null
+++ b/passbook/flows/tests/test_planner.py
@@ -0,0 +1,82 @@
+"""flow planner tests"""
+from unittest.mock import MagicMock, patch
+
+from django.shortcuts import reverse
+from django.test import RequestFactory, TestCase
+from guardian.shortcuts import get_anonymous_user
+
+from passbook.flows.exceptions import EmptyFlowException, FlowNonApplicableException
+from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding
+from passbook.flows.planner import FlowPlanner
+from passbook.stages.dummy.models import DummyStage
+
+POLICY_RESULT_MOCK = MagicMock(return_value=(False, [""],))
+TIME_NOW_MOCK = MagicMock(return_value=3)
+
+
+class TestFlowPlanner(TestCase):
+ """Test planner logic"""
+
+ def setUp(self):
+ self.request_factory = RequestFactory()
+
+ def test_empty_plan(self):
+ """Test that empty plan raises exception"""
+ flow = Flow.objects.create(
+ name="test-empty",
+ slug="test-empty",
+ designation=FlowDesignation.AUTHENTICATION,
+ )
+ request = self.request_factory.get(
+ reverse("passbook_flows:flow-executor", kwargs={"flow_slug": flow.slug}),
+ )
+ request.user = get_anonymous_user()
+
+ with self.assertRaises(EmptyFlowException):
+ planner = FlowPlanner(flow)
+ planner.plan(request)
+
+ @patch(
+ "passbook.flows.planner.FlowPlanner._check_flow_root_policies",
+ POLICY_RESULT_MOCK,
+ )
+ def test_non_applicable_plan(self):
+ """Test that empty plan raises exception"""
+ flow = Flow.objects.create(
+ name="test-empty",
+ slug="test-empty",
+ designation=FlowDesignation.AUTHENTICATION,
+ )
+ request = self.request_factory.get(
+ reverse("passbook_flows:flow-executor", kwargs={"flow_slug": flow.slug}),
+ )
+ request.user = get_anonymous_user()
+
+ with self.assertRaises(FlowNonApplicableException):
+ planner = FlowPlanner(flow)
+ planner.plan(request)
+
+ @patch("passbook.flows.planner.time", TIME_NOW_MOCK)
+ def test_planner_cache(self):
+ """Test planner cache"""
+ flow = Flow.objects.create(
+ name="test-cache",
+ slug="test-cache",
+ designation=FlowDesignation.AUTHENTICATION,
+ )
+ FlowStageBinding.objects.create(
+ flow=flow, stage=DummyStage.objects.create(name="dummy"), order=0
+ )
+ request = self.request_factory.get(
+ reverse("passbook_flows:flow-executor", kwargs={"flow_slug": flow.slug}),
+ )
+ request.user = get_anonymous_user()
+
+ planner = FlowPlanner(flow)
+ planner.plan(request)
+ self.assertEqual(TIME_NOW_MOCK.call_count, 2) # Start and end
+ planner = FlowPlanner(flow)
+ planner.plan(request)
+ self.assertEqual(
+ TIME_NOW_MOCK.call_count, 2
+ ) # When taking from cache, time is not measured
From fc9f86cccce4d7c2fb0052633c7ef21aceec6709 Mon Sep 17 00:00:00 2001
From: Jens Langhammer
Date: Mon, 11 May 2020 14:08:04 +0200
Subject: [PATCH 58/80] lib: use TemplateResponse for bad_request_message
---
passbook/core/tests/test_views_utils.py | 2 +-
passbook/lib/views.py | 11 ++++++-----
2 files changed, 7 insertions(+), 6 deletions(-)
diff --git a/passbook/core/tests/test_views_utils.py b/passbook/core/tests/test_views_utils.py
index 3d0083425..e9c8d6af9 100644
--- a/passbook/core/tests/test_views_utils.py
+++ b/passbook/core/tests/test_views_utils.py
@@ -27,7 +27,7 @@ class TestUtilViews(TestCase):
request = self.factory.get("something")
response = LoadingView.as_view(target_url="somestring")(request)
response.render()
- self.assertIn("somestring", response.content.decode("utf-8"))
+ self.assertIn("somestring", response.rendered_content)
def test_permission_denied_view(self):
"""Test PermissionDeniedView"""
diff --git a/passbook/lib/views.py b/passbook/lib/views.py
index cb537b826..c816b70fe 100644
--- a/passbook/lib/views.py
+++ b/passbook/lib/views.py
@@ -1,6 +1,7 @@
"""passbook helper views"""
-from django.http import HttpRequest, HttpResponse
-from django.shortcuts import render
+from django.http import HttpRequest
+from django.template.response import TemplateResponse
+from django.utils.translation import gettext_lazy as _
from django.views.generic import CreateView
from guardian.shortcuts import assign_perm
@@ -25,11 +26,11 @@ class CreateAssignPermView(CreateView):
return response
-def bad_request_message(request: HttpRequest, message: str) -> HttpResponse:
+def bad_request_message(request: HttpRequest, message: str) -> TemplateResponse:
"""Return generic error page with message, with status code set to 400"""
- return render(
+ return TemplateResponse(
request,
"error/generic.html",
- {"message": message, "card_title": "Bad Request",},
+ {"message": message, "card_title": _("Bad Request")},
status=400,
)
From 9814d3be0305b2e5f3faf482605ed79e1ba0ac2e Mon Sep 17 00:00:00 2001
From: Jens Langhammer
Date: Mon, 11 May 2020 15:01:14 +0200
Subject: [PATCH 59/80] flows: add Planner and Executor unittests
---
passbook/flows/tests/test_misc.py | 24 ++++
passbook/flows/tests/test_views.py | 146 ++++++++++++++++++++++
passbook/flows/tests/test_views_helper.py | 39 ++++++
passbook/flows/views.py | 22 ++--
passbook/lib/utils/urls.py | 17 ++-
passbook/recovery/tests.py | 4 +-
6 files changed, 239 insertions(+), 13 deletions(-)
create mode 100644 passbook/flows/tests/test_misc.py
create mode 100644 passbook/flows/tests/test_views.py
create mode 100644 passbook/flows/tests/test_views_helper.py
diff --git a/passbook/flows/tests/test_misc.py b/passbook/flows/tests/test_misc.py
new file mode 100644
index 000000000..2fb773ee1
--- /dev/null
+++ b/passbook/flows/tests/test_misc.py
@@ -0,0 +1,24 @@
+"""miscellaneous flow tests"""
+from django.test import TestCase
+
+from passbook.flows.api import StageSerializer, StageViewSet
+from passbook.flows.models import Stage
+from passbook.stages.dummy.models import DummyStage
+
+
+class TestFlowsMisc(TestCase):
+ """miscellaneous tests"""
+
+ def test_models(self):
+ """Test that ui_user_settings returns none"""
+ self.assertIsNone(Stage().ui_user_settings)
+
+ def test_api_serializer(self):
+ """Test that stage serializer returns the correct type"""
+ obj = DummyStage()
+ self.assertEqual(StageSerializer().get_type(obj), "dummy")
+
+ def test_api_viewset(self):
+ """Test that stage serializer returns the correct type"""
+ dummy = DummyStage.objects.create()
+ self.assertIn(dummy, StageViewSet().get_queryset())
diff --git a/passbook/flows/tests/test_views.py b/passbook/flows/tests/test_views.py
new file mode 100644
index 000000000..081ba0f00
--- /dev/null
+++ b/passbook/flows/tests/test_views.py
@@ -0,0 +1,146 @@
+"""flow views tests"""
+from unittest.mock import MagicMock, patch
+
+from django.shortcuts import reverse
+from django.test import Client, TestCase
+
+from passbook.flows.exceptions import EmptyFlowException, FlowNonApplicableException
+from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding
+from passbook.flows.planner import FlowPlan
+from passbook.flows.views import NEXT_ARG_NAME, SESSION_KEY_PLAN
+from passbook.lib.config import CONFIG
+from passbook.stages.dummy.models import DummyStage
+
+POLICY_RESULT_MOCK = MagicMock(return_value=(False, [""],))
+
+
+class TestFlowExecutor(TestCase):
+ """Test views logic"""
+
+ def setUp(self):
+ self.client = Client()
+
+ def test_invalid_domain(self):
+ """Check that an invalid domain triggers the correct message"""
+ flow = Flow.objects.create(
+ name="test-empty",
+ slug="test-empty",
+ designation=FlowDesignation.AUTHENTICATION,
+ )
+ wrong_domain = CONFIG.y("domain") + "-invalid:8000"
+ response = self.client.get(
+ reverse("passbook_flows:flow-executor", kwargs={"flow_slug": flow.slug}),
+ HTTP_HOST=wrong_domain,
+ )
+ self.assertEqual(response.status_code, 400)
+ self.assertIn("match", response.rendered_content)
+ self.assertIn(CONFIG.y("domain"), response.rendered_content)
+ self.assertIn(wrong_domain.split(":")[0], response.rendered_content)
+
+ def test_existing_plan_diff_flow(self):
+ """Check that a plan for a different flow cancels the current plan"""
+ flow = Flow.objects.create(
+ name="test-existing-plan-diff",
+ slug="test-existing-plan-diff",
+ designation=FlowDesignation.AUTHENTICATION,
+ )
+ stage = DummyStage.objects.create(name="dummy")
+ plan = FlowPlan(flow_pk=flow.pk.hex + "a", stages=[stage])
+ session = self.client.session
+ session[SESSION_KEY_PLAN] = plan
+ session.save()
+
+ cancel_mock = MagicMock()
+ with patch("passbook.flows.views.FlowExecutorView.cancel", cancel_mock):
+ response = self.client.get(
+ reverse(
+ "passbook_flows:flow-executor", kwargs={"flow_slug": flow.slug}
+ ),
+ )
+ self.assertEqual(response.status_code, 400)
+ self.assertEqual(cancel_mock.call_count, 1)
+
+ @patch(
+ "passbook.flows.planner.FlowPlanner._check_flow_root_policies",
+ POLICY_RESULT_MOCK,
+ )
+ def test_invalid_non_applicable_flow(self):
+ """Tests that a non-applicable flow returns the correct error message"""
+ flow = Flow.objects.create(
+ name="test-non-applicable",
+ slug="test-non-applicable",
+ designation=FlowDesignation.AUTHENTICATION,
+ )
+
+ CONFIG.update_from_dict({"domain": "testserver"})
+ response = self.client.get(
+ reverse("passbook_flows:flow-executor", kwargs={"flow_slug": flow.slug}),
+ )
+ self.assertEqual(response.status_code, 400)
+ self.assertInHTML(FlowNonApplicableException.__doc__, response.rendered_content)
+
+ def test_invalid_empty_flow(self):
+ """Tests that an empty flow returns the correct error message"""
+ flow = Flow.objects.create(
+ name="test-empty",
+ slug="test-empty",
+ designation=FlowDesignation.AUTHENTICATION,
+ )
+
+ CONFIG.update_from_dict({"domain": "testserver"})
+ response = self.client.get(
+ reverse("passbook_flows:flow-executor", kwargs={"flow_slug": flow.slug}),
+ )
+ self.assertEqual(response.status_code, 400)
+ self.assertInHTML(EmptyFlowException.__doc__, response.rendered_content)
+
+ def test_invalid_flow_redirect(self):
+ """Tests that an invalid flow still redirects"""
+ flow = Flow.objects.create(
+ name="test-empty",
+ slug="test-empty",
+ designation=FlowDesignation.AUTHENTICATION,
+ )
+
+ CONFIG.update_from_dict({"domain": "testserver"})
+ dest = "/unique-string"
+ response = self.client.get(
+ reverse("passbook_flows:flow-executor", kwargs={"flow_slug": flow.slug})
+ + f"?{NEXT_ARG_NAME}={dest}"
+ )
+ self.assertEqual(response.status_code, 302)
+ self.assertEqual(response.url, dest)
+
+ def test_multi_stage_flow(self):
+ """Test a full flow with multiple stages"""
+ flow = Flow.objects.create(
+ name="test-full",
+ slug="test-full",
+ designation=FlowDesignation.AUTHENTICATION,
+ )
+ FlowStageBinding.objects.create(
+ flow=flow, stage=DummyStage.objects.create(name="dummy1"), order=0
+ )
+ FlowStageBinding.objects.create(
+ flow=flow, stage=DummyStage.objects.create(name="dummy2"), order=1
+ )
+
+ exec_url = reverse(
+ "passbook_flows:flow-executor", kwargs={"flow_slug": flow.slug}
+ )
+ # First Request, start planning, renders form
+ response = self.client.get(exec_url)
+ self.assertEqual(response.status_code, 200)
+ # Check that two stages are in plan
+ session = self.client.session
+ plan: FlowPlan = session[SESSION_KEY_PLAN]
+ self.assertEqual(len(plan.stages), 2)
+ # Second request, submit form, one stage left
+ response = self.client.post(exec_url)
+ # Second request redirects to the same URL
+ self.assertEqual(response.status_code, 302)
+ self.assertEqual(response.url, exec_url)
+ # Check that two stages are in plan
+ session = self.client.session
+ plan: FlowPlan = session[SESSION_KEY_PLAN]
+ self.assertEqual(len(plan.stages), 1)
diff --git a/passbook/flows/tests/test_views_helper.py b/passbook/flows/tests/test_views_helper.py
new file mode 100644
index 000000000..7336cfc07
--- /dev/null
+++ b/passbook/flows/tests/test_views_helper.py
@@ -0,0 +1,39 @@
+"""flow views tests"""
+from django.shortcuts import reverse
+from django.test import Client, TestCase
+
+from passbook.flows.models import Flow, FlowDesignation
+from passbook.flows.planner import FlowPlan
+from passbook.flows.views import SESSION_KEY_PLAN
+
+
+class TestHelperView(TestCase):
+ """Test helper views logic"""
+
+ def setUp(self):
+ self.client = Client()
+
+ def test_default_view(self):
+ """Test that ToDefaultFlow returns the expected URL"""
+ flow = Flow.objects.filter(designation=FlowDesignation.INVALIDATION,).first()
+ response = self.client.get(reverse("passbook_flows:default-invalidation"),)
+ expected_url = reverse(
+ "passbook_flows:flow-executor", kwargs={"flow_slug": flow.slug}
+ )
+ self.assertEqual(response.status_code, 302)
+ self.assertEqual(response.url, expected_url)
+
+ def test_default_view_invalid_plan(self):
+ """Test that ToDefaultFlow returns the expected URL (with an invalid plan)"""
+ flow = Flow.objects.filter(designation=FlowDesignation.INVALIDATION,).first()
+ plan = FlowPlan(flow_pk=flow.pk.hex + "aa", stages=[])
+ session = self.client.session
+ session[SESSION_KEY_PLAN] = plan
+ session.save()
+
+ response = self.client.get(reverse("passbook_flows:default-invalidation"),)
+ expected_url = reverse(
+ "passbook_flows:flow-executor", kwargs={"flow_slug": flow.slug}
+ )
+ self.assertEqual(response.status_code, 302)
+ self.assertEqual(response.url, expected_url)
diff --git a/passbook/flows/views.py b/passbook/flows/views.py
index d687c8800..43ca4e609 100644
--- a/passbook/flows/views.py
+++ b/passbook/flows/views.py
@@ -12,7 +12,7 @@ from passbook.flows.models import Flow, FlowDesignation, Stage
from passbook.flows.planner import FlowPlan, FlowPlanner
from passbook.lib.config import CONFIG
from passbook.lib.utils.reflection import class_to_path, path_to_class
-from passbook.lib.utils.urls import is_url_absolute, redirect_with_qs
+from passbook.lib.utils.urls import redirect_with_qs
from passbook.lib.views import bad_request_message
LOGGER = get_logger()
@@ -59,7 +59,8 @@ class FlowExecutorView(View):
incorrect_domain_message = self._check_config_domain()
if incorrect_domain_message:
return incorrect_domain_message
- return bad_request_message(self.request, str(exc))
+ message = exc.__doc__ if exc.__doc__ else str(exc)
+ return bad_request_message(self.request, message)
def dispatch(self, request: HttpRequest, flow_slug: str) -> HttpResponse:
# Early check if theres an active Plan for the current session
@@ -128,10 +129,8 @@ class FlowExecutorView(View):
def _flow_done(self) -> HttpResponse:
"""User Successfully passed all stages"""
self.cancel()
- next_param = self.request.GET.get(NEXT_ARG_NAME, None)
- if next_param and not is_url_absolute(next_param):
- return redirect(next_param)
- return redirect_with_qs("passbook_core:overview")
+ next_param = self.request.GET.get(NEXT_ARG_NAME, "passbook_core:overview")
+ return redirect_with_qs(next_param)
def stage_ok(self) -> HttpResponse:
"""Callback called by stages upon successful completion.
@@ -183,9 +182,16 @@ class ToDefaultFlow(View):
designation: Optional[FlowDesignation] = None
def dispatch(self, request: HttpRequest) -> HttpResponse:
- if SESSION_KEY_PLAN in self.request.session:
- del self.request.session[SESSION_KEY_PLAN]
flow = get_object_or_404(Flow, designation=self.designation)
+ # 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]
+ if plan.flow_pk != flow.pk.hex:
+ LOGGER.warning(
+ "f(def): Found existing plan for other flow, deleteing plan",
+ flow_slug=flow.slug,
+ )
+ del self.request.session[SESSION_KEY_PLAN]
# TODO: Get Flow depending on subdomain?
return redirect_with_qs(
"passbook_flows:flow-executor", request.GET, flow_slug=flow.slug
diff --git a/passbook/lib/utils/urls.py b/passbook/lib/utils/urls.py
index 30cb25603..29f0e9eaf 100644
--- a/passbook/lib/utils/urls.py
+++ b/passbook/lib/utils/urls.py
@@ -3,7 +3,11 @@ from urllib.parse import urlparse
from django.http import HttpResponse
from django.shortcuts import redirect, reverse
+from django.urls import NoReverseMatch
from django.utils.http import urlencode
+from structlog import get_logger
+
+LOGGER = get_logger()
def is_url_absolute(url):
@@ -13,7 +17,12 @@ def is_url_absolute(url):
def redirect_with_qs(view: str, get_query_set=None, **kwargs) -> HttpResponse:
"""Wrapper to redirect whilst keeping GET Parameters"""
- target = reverse(view, kwargs=kwargs)
- if get_query_set:
- target += "?" + urlencode(get_query_set.items())
- return redirect(target)
+ try:
+ target = reverse(view, kwargs=kwargs)
+ except NoReverseMatch:
+ LOGGER.debug("redirect target is not a valid view", view=view)
+ raise
+ else:
+ if get_query_set:
+ target += "?" + urlencode(get_query_set.items())
+ return redirect(target)
diff --git a/passbook/recovery/tests.py b/passbook/recovery/tests.py
index bb2c19b68..3080b176e 100644
--- a/passbook/recovery/tests.py
+++ b/passbook/recovery/tests.py
@@ -6,6 +6,7 @@ from django.shortcuts import reverse
from django.test import TestCase
from passbook.core.models import Nonce, User
+from passbook.lib.config import CONFIG
class TestRecovery(TestCase):
@@ -16,10 +17,11 @@ class TestRecovery(TestCase):
def test_create_key(self):
"""Test creation of a new key"""
+ CONFIG.update_from_dict({"domain": "testserver"})
out = StringIO()
self.assertEqual(len(Nonce.objects.all()), 0)
call_command("create_recovery_key", "1", self.user.username, stdout=out)
- self.assertIn("https://localhost/recovery/use-nonce/", out.getvalue())
+ self.assertIn("https://testserver/recovery/use-nonce/", out.getvalue())
self.assertEqual(len(Nonce.objects.all()), 1)
def test_recovery_view(self):
From d49c58f326ef5327749794dbbd5bb99f903bcd3e Mon Sep 17 00:00:00 2001
From: Jens Langhammer
Date: Mon, 11 May 2020 21:27:46 +0200
Subject: [PATCH 60/80] flows: fix linting
---
passbook/flows/tests/test_views.py | 6 ++----
1 file changed, 2 insertions(+), 4 deletions(-)
diff --git a/passbook/flows/tests/test_views.py b/passbook/flows/tests/test_views.py
index 081ba0f00..c775eb38c 100644
--- a/passbook/flows/tests/test_views.py
+++ b/passbook/flows/tests/test_views.py
@@ -104,10 +104,8 @@ class TestFlowExecutor(TestCase):
CONFIG.update_from_dict({"domain": "testserver"})
dest = "/unique-string"
- response = self.client.get(
- reverse("passbook_flows:flow-executor", kwargs={"flow_slug": flow.slug})
- + f"?{NEXT_ARG_NAME}={dest}"
- )
+ url = reverse("passbook_flows:flow-executor", kwargs={"flow_slug": flow.slug})
+ response = self.client.get(url + f"?{NEXT_ARG_NAME}={dest}")
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, dest)
From 7500e622f6aa2bceabbad2b4d1ded0ed4b8c7d01 Mon Sep 17 00:00:00 2001
From: Jens Langhammer
Date: Mon, 11 May 2020 21:58:02 +0200
Subject: [PATCH 61/80] stages/invitation: start extracting invitation from
core
---
passbook/admin/views/invitations.py | 10 +--
passbook/admin/views/overview.py | 3 +-
passbook/api/v2/urls.py | 5 +-
passbook/core/forms/invitations.py | 38 ---------
.../core/migrations/0014_delete_invitation.py | 14 ++++
passbook/core/models.py | 25 ------
passbook/lib/utils/urls.py | 2 +
passbook/root/settings.py | 1 +
passbook/stages/invitation/__init__.py | 0
.../invitation/api.py} | 24 +++++-
passbook/stages/invitation/apps.py | 10 +++
passbook/stages/invitation/forms.py | 33 ++++++++
.../invitation/migrations/0001_initial.py | 72 ++++++++++++++++
...nstage_continue_flow_without_invitation.py | 21 +++++
.../stages/invitation/migrations/__init__.py | 0
passbook/stages/invitation/models.py | 50 +++++++++++
passbook/stages/invitation/stage.py | 26 ++++++
passbook/stages/invitation/tests.py | 83 +++++++++++++++++++
18 files changed, 344 insertions(+), 73 deletions(-)
delete mode 100644 passbook/core/forms/invitations.py
create mode 100644 passbook/core/migrations/0014_delete_invitation.py
create mode 100644 passbook/stages/invitation/__init__.py
rename passbook/{core/api/invitations.py => stages/invitation/api.py} (50%)
create mode 100644 passbook/stages/invitation/apps.py
create mode 100644 passbook/stages/invitation/forms.py
create mode 100644 passbook/stages/invitation/migrations/0001_initial.py
create mode 100644 passbook/stages/invitation/migrations/0002_invitationstage_continue_flow_without_invitation.py
create mode 100644 passbook/stages/invitation/migrations/__init__.py
create mode 100644 passbook/stages/invitation/models.py
create mode 100644 passbook/stages/invitation/stage.py
create mode 100644 passbook/stages/invitation/tests.py
diff --git a/passbook/admin/views/invitations.py b/passbook/admin/views/invitations.py
index 59383d840..dd00bee83 100644
--- a/passbook/admin/views/invitations.py
+++ b/passbook/admin/views/invitations.py
@@ -11,17 +11,17 @@ from django.utils.translation import ugettext as _
from django.views.generic import DeleteView, ListView
from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
-from passbook.core.forms.invitations import InvitationForm
-from passbook.core.models import Invitation
from passbook.core.signals import invitation_created
from passbook.lib.views import CreateAssignPermView
+from passbook.stages.invitation.forms import InvitationForm
+from passbook.stages.invitation.models import Invitation
class InvitationListView(LoginRequiredMixin, PermissionListMixin, ListView):
"""Show list of all invitations"""
model = Invitation
- permission_required = "passbook_core.view_invitation"
+ permission_required = "passbook_stages_invitation.view_invitation"
template_name = "administration/invitation/list.html"
paginate_by = 10
ordering = "-expires"
@@ -37,7 +37,7 @@ class InvitationCreateView(
model = Invitation
form_class = InvitationForm
- permission_required = "passbook_core.add_invitation"
+ permission_required = "passbook_stages_invitation.add_invitation"
template_name = "generic/create.html"
success_url = reverse_lazy("passbook_admin:invitations")
@@ -61,7 +61,7 @@ class InvitationDeleteView(
"""Delete invitation"""
model = Invitation
- permission_required = "passbook_core.delete_invitation"
+ permission_required = "passbook_stages_invitation.delete_invitation"
template_name = "generic/delete.html"
success_url = reverse_lazy("passbook_admin:invitations")
diff --git a/passbook/admin/views/overview.py b/passbook/admin/views/overview.py
index c263007dc..41b1ffaa4 100644
--- a/passbook/admin/views/overview.py
+++ b/passbook/admin/views/overview.py
@@ -5,9 +5,10 @@ from django.views.generic import TemplateView
from passbook import __version__
from passbook.admin.mixins import AdminRequiredMixin
-from passbook.core.models import Application, Invitation, Policy, Provider, Source, User
+from passbook.core.models import Application, Policy, Provider, Source, User
from passbook.flows.models import Flow, Stage
from passbook.root.celery import CELERY_APP
+from passbook.stages.invitation.models import Invitation
class AdministrationOverviewView(AdminRequiredMixin, TemplateView):
diff --git a/passbook/api/v2/urls.py b/passbook/api/v2/urls.py
index 84dc92d77..402f5eddb 100644
--- a/passbook/api/v2/urls.py
+++ b/passbook/api/v2/urls.py
@@ -11,7 +11,6 @@ from passbook.api.permissions import CustomObjectPermissions
from passbook.audit.api import EventViewSet
from passbook.core.api.applications import ApplicationViewSet
from passbook.core.api.groups import GroupViewSet
-from passbook.core.api.invitations import InvitationViewSet
from passbook.core.api.policies import PolicyViewSet
from passbook.core.api.propertymappings import PropertyMappingViewSet
from passbook.core.api.providers import ProviderViewSet
@@ -34,6 +33,7 @@ from passbook.sources.oauth.api import OAuthSourceViewSet
from passbook.stages.captcha.api import CaptchaStageViewSet
from passbook.stages.email.api import EmailStageViewSet
from passbook.stages.identification.api import IdentificationStageViewSet
+from passbook.stages.invitation.api import InvitationStageViewSet, InvitationViewSet
from passbook.stages.otp.api import OTPStageViewSet
from passbook.stages.password.api import PasswordStageViewSet
from passbook.stages.prompt.api import PromptStageViewSet, PromptViewSet
@@ -51,7 +51,6 @@ for _passbook_app in get_apps():
LOGGER.debug("Mounted API URLs", app_name=_passbook_app.name)
router.register("core/applications", ApplicationViewSet)
-router.register("core/invitations", InvitationViewSet)
router.register("core/groups", GroupViewSet)
router.register("core/users", UserViewSet)
@@ -83,6 +82,8 @@ router.register("stages/all", StageViewSet)
router.register("stages/captcha", CaptchaStageViewSet)
router.register("stages/email", EmailStageViewSet)
router.register("stages/identification", IdentificationStageViewSet)
+router.register("stages/invitation", InvitationStageViewSet)
+router.register("stages/invitation/invitations", InvitationViewSet)
router.register("stages/otp", OTPStageViewSet)
router.register("stages/password", PasswordStageViewSet)
router.register("stages/prompt", PromptStageViewSet)
diff --git a/passbook/core/forms/invitations.py b/passbook/core/forms/invitations.py
deleted file mode 100644
index be64ce302..000000000
--- a/passbook/core/forms/invitations.py
+++ /dev/null
@@ -1,38 +0,0 @@
-"""passbook core invitation form"""
-
-from django import forms
-from django.core.exceptions import ValidationError
-from django.utils.translation import gettext as _
-
-from passbook.core.models import Invitation, User
-
-
-class InvitationForm(forms.ModelForm):
- """InvitationForm"""
-
- def clean_fixed_username(self):
- """Check if username is already used"""
- username = self.cleaned_data.get("fixed_username")
- if User.objects.filter(username=username).exists():
- raise ValidationError(_("Username is already in use."))
- return username
-
- def clean_fixed_email(self):
- """Check if email is already used"""
- email = self.cleaned_data.get("fixed_email")
- if User.objects.filter(email=email).exists():
- raise ValidationError(_("E-Mail is already in use."))
- return email
-
- class Meta:
-
- model = Invitation
- fields = ["expires", "fixed_username", "fixed_email", "needs_confirmation"]
- labels = {
- "fixed_username": "Force user's username (optional)",
- "fixed_email": "Force user's email (optional)",
- }
- widgets = {
- "fixed_username": forms.TextInput(),
- "fixed_email": forms.TextInput(),
- }
diff --git a/passbook/core/migrations/0014_delete_invitation.py b/passbook/core/migrations/0014_delete_invitation.py
new file mode 100644
index 000000000..2f3e67b70
--- /dev/null
+++ b/passbook/core/migrations/0014_delete_invitation.py
@@ -0,0 +1,14 @@
+# Generated by Django 3.0.5 on 2020-05-11 19:57
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("passbook_core", "0013_delete_debugpolicy"),
+ ]
+
+ operations = [
+ migrations.DeleteModel(name="Invitation",),
+ ]
diff --git a/passbook/core/models.py b/passbook/core/models.py
index 4894829fd..6c0a6d5ea 100644
--- a/passbook/core/models.py
+++ b/passbook/core/models.py
@@ -8,7 +8,6 @@ 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.urls import reverse_lazy
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from django_prometheus.models import ExportModelOperationsMixin
@@ -196,30 +195,6 @@ class Policy(ExportModelOperationsMixin("policy"), UUIDModel, CreatedUpdatedMode
raise PolicyException()
-class Invitation(ExportModelOperationsMixin("invitation"), UUIDModel):
- """Single-use invitation link"""
-
- created_by = models.ForeignKey("User", on_delete=models.CASCADE)
- expires = models.DateTimeField(default=None, blank=True, null=True)
- fixed_username = models.TextField(blank=True, default=None)
- fixed_email = models.TextField(blank=True, default=None)
- needs_confirmation = models.BooleanField(default=True)
-
- @property
- def link(self):
- """Get link to use invitation"""
- qs = f"?invitation={self.uuid.hex}"
- return reverse_lazy("passbook_flows:default-enrollment") + qs
-
- def __str__(self):
- return f"Invitation {self.uuid.hex} created by {self.created_by}"
-
- class Meta:
-
- verbose_name = _("Invitation")
- verbose_name_plural = _("Invitations")
-
-
class Nonce(ExportModelOperationsMixin("nonce"), UUIDModel):
"""One-time link for password resets/sign-up-confirmations"""
diff --git a/passbook/lib/utils/urls.py b/passbook/lib/utils/urls.py
index 29f0e9eaf..450b98485 100644
--- a/passbook/lib/utils/urls.py
+++ b/passbook/lib/utils/urls.py
@@ -20,6 +20,8 @@ def redirect_with_qs(view: str, get_query_set=None, **kwargs) -> HttpResponse:
try:
target = reverse(view, kwargs=kwargs)
except NoReverseMatch:
+ if not is_url_absolute(view):
+ return redirect(view)
LOGGER.debug("redirect target is not a valid view", view=view)
raise
else:
diff --git a/passbook/root/settings.py b/passbook/root/settings.py
index 7abd08760..cd19685d8 100644
--- a/passbook/root/settings.py
+++ b/passbook/root/settings.py
@@ -108,6 +108,7 @@ INSTALLED_APPS = [
"passbook.stages.email.apps.PassbookStageEmailConfig",
"passbook.stages.prompt.apps.PassbookStagPromptConfig",
"passbook.stages.identification.apps.PassbookStageIdentificationConfig",
+ "passbook.stages.invitation.apps.PassbookStageUserInvitationConfig",
"passbook.stages.user_login.apps.PassbookStageUserLoginConfig",
"passbook.stages.user_logout.apps.PassbookStageUserLogoutConfig",
"passbook.stages.user_write.apps.PassbookStageUserWriteConfig",
diff --git a/passbook/stages/invitation/__init__.py b/passbook/stages/invitation/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/passbook/core/api/invitations.py b/passbook/stages/invitation/api.py
similarity index 50%
rename from passbook/core/api/invitations.py
rename to passbook/stages/invitation/api.py
index c6e451f62..c4218db6f 100644
--- a/passbook/core/api/invitations.py
+++ b/passbook/stages/invitation/api.py
@@ -1,8 +1,28 @@
-"""Invitation API Views"""
+"""Invitation Stage API Views"""
from rest_framework.serializers import ModelSerializer
from rest_framework.viewsets import ModelViewSet
-from passbook.core.models import Invitation
+from passbook.stages.invitation.models import Invitation, InvitationStage
+
+
+class InvitationStageSerializer(ModelSerializer):
+ """InvitationStage Serializer"""
+
+ class Meta:
+
+ model = InvitationStage
+ fields = [
+ "pk",
+ "name",
+ "continue_flow_without_invitation",
+ ]
+
+
+class InvitationStageViewSet(ModelViewSet):
+ """InvitationStage Viewset"""
+
+ queryset = InvitationStage.objects.all()
+ serializer_class = InvitationStageSerializer
class InvitationSerializer(ModelSerializer):
diff --git a/passbook/stages/invitation/apps.py b/passbook/stages/invitation/apps.py
new file mode 100644
index 000000000..0b0eddd0b
--- /dev/null
+++ b/passbook/stages/invitation/apps.py
@@ -0,0 +1,10 @@
+"""passbook invitation stage app config"""
+from django.apps import AppConfig
+
+
+class PassbookStageUserInvitationConfig(AppConfig):
+ """passbook invitation stage config"""
+
+ name = "passbook.stages.invitation"
+ label = "passbook_stages_invitation"
+ verbose_name = "passbook Stages.User Invitation"
diff --git a/passbook/stages/invitation/forms.py b/passbook/stages/invitation/forms.py
new file mode 100644
index 000000000..a6a34d17e
--- /dev/null
+++ b/passbook/stages/invitation/forms.py
@@ -0,0 +1,33 @@
+"""passbook flows invitation forms"""
+from django import forms
+from django.utils.translation import gettext as _
+
+from passbook.stages.invitation.models import Invitation, InvitationStage
+
+
+class InvitationStageForm(forms.ModelForm):
+ """Form to create/edit InvitationStage instances"""
+
+ class Meta:
+
+ model = InvitationStage
+ fields = ["name", "continue_flow_without_invitation"]
+ widgets = {
+ "name": forms.TextInput(),
+ }
+
+
+class InvitationForm(forms.ModelForm):
+ """InvitationForm"""
+
+ class Meta:
+
+ model = Invitation
+ fields = ["expires", "fixed_data"]
+ labels = {
+ "fixed_data": _("Optional fixed data to enforce on user enrollment."),
+ }
+ widgets = {
+ "fixed_username": forms.TextInput(),
+ "fixed_email": forms.TextInput(),
+ }
diff --git a/passbook/stages/invitation/migrations/0001_initial.py b/passbook/stages/invitation/migrations/0001_initial.py
new file mode 100644
index 000000000..31c10f124
--- /dev/null
+++ b/passbook/stages/invitation/migrations/0001_initial.py
@@ -0,0 +1,72 @@
+# Generated by Django 3.0.5 on 2020-05-11 19:09
+
+import uuid
+
+import django.contrib.postgres.fields.jsonb
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ("passbook_flows", "0004_auto_20200510_2310"),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="InvitationStage",
+ fields=[
+ (
+ "stage_ptr",
+ models.OneToOneField(
+ auto_created=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ parent_link=True,
+ primary_key=True,
+ serialize=False,
+ to="passbook_flows.Stage",
+ ),
+ ),
+ ],
+ options={
+ "verbose_name": "Invitation Stage",
+ "verbose_name_plural": "Invitation Stages",
+ },
+ bases=("passbook_flows.stage",),
+ ),
+ migrations.CreateModel(
+ name="Invitation",
+ fields=[
+ (
+ "uuid",
+ models.UUIDField(
+ default=uuid.uuid4,
+ editable=False,
+ primary_key=True,
+ serialize=False,
+ ),
+ ),
+ ("expires", models.DateTimeField(blank=True, default=None, null=True)),
+ (
+ "fixed_data",
+ django.contrib.postgres.fields.jsonb.JSONField(default=dict),
+ ),
+ (
+ "created_by",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ ],
+ options={
+ "verbose_name": "Invitation",
+ "verbose_name_plural": "Invitations",
+ },
+ ),
+ ]
diff --git a/passbook/stages/invitation/migrations/0002_invitationstage_continue_flow_without_invitation.py b/passbook/stages/invitation/migrations/0002_invitationstage_continue_flow_without_invitation.py
new file mode 100644
index 000000000..2772f6c7b
--- /dev/null
+++ b/passbook/stages/invitation/migrations/0002_invitationstage_continue_flow_without_invitation.py
@@ -0,0 +1,21 @@
+# Generated by Django 3.0.5 on 2020-05-11 19:46
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("passbook_stages_invitation", "0001_initial"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="invitationstage",
+ name="continue_flow_without_invitation",
+ field=models.BooleanField(
+ default=False,
+ help_text="If this flag is set, this Stage will jump to the next Stage when no Invitation is given. By default this Stage will cancel the Flow when no invitation is given.",
+ ),
+ ),
+ ]
diff --git a/passbook/stages/invitation/migrations/__init__.py b/passbook/stages/invitation/migrations/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/passbook/stages/invitation/models.py b/passbook/stages/invitation/models.py
new file mode 100644
index 000000000..fed3ee8b0
--- /dev/null
+++ b/passbook/stages/invitation/models.py
@@ -0,0 +1,50 @@
+"""invitation stage models"""
+from django.contrib.postgres.fields import JSONField
+from django.db import models
+from django.utils.translation import gettext_lazy as _
+
+from passbook.core.models import User
+from passbook.flows.models import Stage
+from passbook.lib.models import UUIDModel
+
+
+class InvitationStage(Stage):
+ """Invitation stage, to enroll themselves with enforced parameters"""
+
+ continue_flow_without_invitation = models.BooleanField(
+ default=False,
+ help_text=_(
+ (
+ "If this flag is set, this Stage will jump to the next Stage when "
+ "no Invitation is given. By default this Stage will cancel the "
+ "Flow when no invitation is given."
+ )
+ ),
+ )
+
+ type = "passbook.stages.invitation.stage.InvitationStageView"
+ form = "passbook.stages.invitation.forms.InvitationStageForm"
+
+ def __str__(self):
+ return f"Invitation Stage {self.name}"
+
+ class Meta:
+
+ verbose_name = _("Invitation Stage")
+ verbose_name_plural = _("Invitation Stages")
+
+
+class Invitation(UUIDModel):
+ """Single-use invitation link"""
+
+ created_by = models.ForeignKey(User, on_delete=models.CASCADE)
+ expires = models.DateTimeField(default=None, blank=True, null=True)
+ fixed_data = JSONField(default=dict)
+
+ def __str__(self):
+ return f"Invitation {self.uuid.hex} created by {self.created_by}"
+
+ class Meta:
+
+ verbose_name = _("Invitation")
+ verbose_name_plural = _("Invitations")
diff --git a/passbook/stages/invitation/stage.py b/passbook/stages/invitation/stage.py
new file mode 100644
index 000000000..e4ca3a5ef
--- /dev/null
+++ b/passbook/stages/invitation/stage.py
@@ -0,0 +1,26 @@
+"""invitation stage logic"""
+from django.http import HttpRequest, HttpResponse
+from django.shortcuts import get_object_or_404
+
+from passbook.flows.stage import AuthenticationStage
+from passbook.stages.invitation.models import Invitation, InvitationStage
+from passbook.stages.prompt.stage import PLAN_CONTEXT_PROMPT
+
+INVITATION_TOKEN_KEY = "token"
+
+
+class InvitationStageView(AuthenticationStage):
+ """Finalise Authentication flow by logging the user in"""
+
+ def get(self, request: HttpRequest) -> HttpResponse:
+ stage: InvitationStage = self.executor.current_stage
+ if INVITATION_TOKEN_KEY not in request.GET:
+ # No Invitation was given, raise error or continue
+ if stage.continue_flow_without_invitation:
+ return self.executor.stage_ok()
+ return self.executor.stage_invalid()
+
+ token = request.GET[INVITATION_TOKEN_KEY]
+ invite: Invitation = get_object_or_404(Invitation, pk=token)
+ self.executor.plan.context[PLAN_CONTEXT_PROMPT] = invite.fixed_data
+ return self.executor.stage_ok()
diff --git a/passbook/stages/invitation/tests.py b/passbook/stages/invitation/tests.py
new file mode 100644
index 000000000..59b988d2b
--- /dev/null
+++ b/passbook/stages/invitation/tests.py
@@ -0,0 +1,83 @@
+"""login tests"""
+from django.shortcuts import reverse
+from django.test import Client, TestCase
+
+from passbook.core.models import User
+from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding
+from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan
+from passbook.flows.views import SESSION_KEY_PLAN
+from passbook.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND
+from passbook.stages.user_login.forms import UserLoginStageForm
+from passbook.stages.user_login.models import UserLoginStage
+
+
+class TestUserLoginStage(TestCase):
+ """Login tests"""
+
+ def setUp(self):
+ super().setUp()
+ self.user = User.objects.create(username="unittest", email="test@beryju.org")
+ self.client = Client()
+
+ self.flow = Flow.objects.create(
+ name="test-login",
+ slug="test-login",
+ designation=FlowDesignation.AUTHENTICATION,
+ )
+ self.stage = UserLoginStage.objects.create(name="login")
+ FlowStageBinding.objects.create(flow=self.flow, stage=self.stage, order=2)
+
+ def test_valid_password(self):
+ """Test with a valid pending user and backend"""
+ plan = FlowPlan(flow_pk=self.flow.pk.hex, stages=[self.stage])
+ plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
+ plan.context[
+ PLAN_CONTEXT_AUTHENTICATION_BACKEND
+ ] = "django.contrib.auth.backends.ModelBackend"
+ session = self.client.session
+ session[SESSION_KEY_PLAN] = plan
+ session.save()
+
+ response = self.client.get(
+ reverse(
+ "passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
+ )
+ )
+ self.assertEqual(response.status_code, 302)
+ self.assertEqual(response.url, reverse("passbook_core:overview"))
+
+ def test_without_user(self):
+ """Test a plan without any pending user, resulting in a denied"""
+ plan = FlowPlan(flow_pk=self.flow.pk.hex, stages=[self.stage])
+ session = self.client.session
+ session[SESSION_KEY_PLAN] = plan
+ session.save()
+
+ response = self.client.get(
+ reverse(
+ "passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
+ )
+ )
+ self.assertEqual(response.status_code, 302)
+ self.assertEqual(response.url, reverse("passbook_flows:denied"))
+
+ def test_without_backend(self):
+ """Test a plan with pending user, without backend, resulting in a denied"""
+ plan = FlowPlan(flow_pk=self.flow.pk.hex, stages=[self.stage])
+ plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
+ session = self.client.session
+ session[SESSION_KEY_PLAN] = plan
+ session.save()
+
+ response = self.client.get(
+ reverse(
+ "passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
+ )
+ )
+ self.assertEqual(response.status_code, 302)
+ self.assertEqual(response.url, reverse("passbook_flows:denied"))
+
+ def test_form(self):
+ """Test Form"""
+ data = {"name": "test"}
+ self.assertEqual(UserLoginStageForm(data).is_valid(), True)
From 137e90355bbd1fd447f07a9142baceb4fb1167fc Mon Sep 17 00:00:00 2001
From: Jens Langhammer
Date: Tue, 12 May 2020 14:49:47 +0200
Subject: [PATCH 62/80] flows: default-auth -> default-authentication
---
passbook/admin/views/overview.py | 2 +-
passbook/core/templates/login/form_with_user.html | 2 +-
passbook/flows/urls.py | 4 ++--
passbook/root/settings.py | 2 +-
passbook/root/urls.py | 2 +-
5 files changed, 6 insertions(+), 6 deletions(-)
diff --git a/passbook/admin/views/overview.py b/passbook/admin/views/overview.py
index 41b1ffaa4..1b1b0a88b 100644
--- a/passbook/admin/views/overview.py
+++ b/passbook/admin/views/overview.py
@@ -20,7 +20,7 @@ class AdministrationOverviewView(AdminRequiredMixin, TemplateView):
"""Handle post (clear cache from modal)"""
if "clear" in self.request.POST:
cache.clear()
- return redirect(reverse("passbook_flows:default-auth"))
+ return redirect(reverse("passbook_flows:default-authentication"))
return self.get(*args, **kwargs)
def get_context_data(self, **kwargs):
diff --git a/passbook/core/templates/login/form_with_user.html b/passbook/core/templates/login/form_with_user.html
index b1ac95753..f1b418b65 100644
--- a/passbook/core/templates/login/form_with_user.html
+++ b/passbook/core/templates/login/form_with_user.html
@@ -37,7 +37,7 @@
{{ user.username }}