From 8dd05d54313d314e22aef6747b1f5b20f22ac295 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Thu, 28 May 2020 21:56:18 +0200 Subject: [PATCH 01/64] Squashed commit of the following: commit 270739a45a14e9d994f95d805c9ee8be205bd40c Author: Jens Langhammer Date: Thu May 28 21:50:43 2020 +0200 admin: fix policy testing form not showing the correct result commit df8995deed1137cc95136786d6961624ccd73191 Author: Jens L Date: Thu May 28 21:45:54 2020 +0200 policies/*: remove Policy.negate, order, timeout (#39) policies: rewrite engine to use PolicyBinding for order/negate/timeout policies: rewrite engine to use PolicyResult instead of tuple commit fdfc6472d2eddfa93ddb408a926f14f58a592cc6 Author: Jens Langhammer Date: Thu May 28 10:36:10 2020 +0200 admin: fixup some urls commit bc495828e7965e58864027269f39f991eccd417e Author: dependabot-preview[bot] <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Thu May 28 09:39:28 2020 +0200 build(deps): bump django-redis from 4.11.0 to 4.12.1 (#38) Bumps [django-redis](https://github.com/jazzband/django-redis) from 4.11.0 to 4.12.1. - [Release notes](https://github.com/jazzband/django-redis/releases) - [Changelog](https://github.com/jazzband/django-redis/blob/master/CHANGES.rst) - [Commits](https://github.com/jazzband/django-redis/compare/4.11.0...4.12.1) Signed-off-by: dependabot-preview[bot] Co-authored-by: dependabot-preview[bot] <27856297+dependabot-preview[bot]@users.noreply.github.com> commit fa138a273f0882e5badd742094c862ad6b3cf6e4 Author: dependabot-preview[bot] <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Thu May 28 08:59:19 2020 +0200 build(deps): bump boto3 from 1.13.17 to 1.13.18 (#37) Bumps [boto3](https://github.com/boto/boto3) from 1.13.17 to 1.13.18. - [Release notes](https://github.com/boto/boto3/releases) - [Changelog](https://github.com/boto/boto3/blob/develop/CHANGELOG.rst) - [Commits](https://github.com/boto/boto3/compare/1.13.17...1.13.18) Signed-off-by: dependabot-preview[bot] Co-authored-by: dependabot-preview[bot] <27856297+dependabot-preview[bot]@users.noreply.github.com> --- Pipfile.lock | 17 +-- passbook/admin/forms/policies.py | 2 + .../admin/templates/administration/base.html | 2 +- .../templates/administration/stage/list.html | 4 +- .../administration/stage_prompt/list.html | 21 +++- passbook/admin/views/policies.py | 51 +++++---- passbook/core/signals.py | 13 ++- passbook/core/views/access.py | 7 +- passbook/flows/forms.py | 3 - passbook/flows/planner.py | 18 ++-- passbook/flows/templates/flows/shell.html | 1 - passbook/flows/tests/test_planner.py | 3 +- passbook/flows/tests/test_views.py | 3 +- passbook/lib/models.py | 25 +++++ passbook/policies/api.py | 2 +- passbook/policies/engine.py | 56 ++++++---- passbook/policies/expression/evaluator.py | 5 +- passbook/policies/forms.py | 11 +- .../migrations/0002_auto_20200528_1647.py | 58 ++++++++++ passbook/policies/models.py | 31 ++++-- passbook/policies/process.py | 40 ++++--- passbook/policies/tests/test_engine.py | 65 ++++++++---- passbook/providers/oauth/views/oauth2.py | 6 +- passbook/providers/oidc/auth.py | 8 +- passbook/stages/prompt/forms.py | 8 +- passbook/stages/prompt/models.py | 2 +- passbook/stages/prompt/tests.py | 4 +- swagger.yaml | 100 ++---------------- 28 files changed, 321 insertions(+), 245 deletions(-) create mode 100644 passbook/policies/migrations/0002_auto_20200528_1647.py diff --git a/Pipfile.lock b/Pipfile.lock index d8411c1ad..3f4fd3420 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -53,17 +53,18 @@ }, "boto3": { "hashes": [ - "sha256:009d0b483513e4c8639895c2b8dc451b41c9b863116d0234506b8b88b30a3d1b" + "sha256:1bdab4f87ff39d5aab59b0aae69965bf604fa5608984c673877f4c62c1f16240", + "sha256:2b4924ccc1603d562969b9f3c8c74ff4a1f3bdbafe857c990422c73d8e2e229e" ], "index": "pypi", - "version": "==1.13.17" + "version": "==1.13.18" }, "botocore": { "hashes": [ - "sha256:cca04cd4bdb092a727772c38808f97e15f07dc609f6fbd7d7ba09c7734058794", - "sha256:f9627c718d480225cbfeeeb7b4a694b9cea3cae67940a4b673770cfaca328a81" + "sha256:93574cf95a64c71d35c12c93a23f6214cf2f4b461be3bda3a436381cbe126a84", + "sha256:e65eb27cae262a510e335bc0c0e286e9e42381b1da0aafaa79fa13c1d8d74a95" ], - "version": "==1.16.17" + "version": "==1.16.18" }, "celery": { "hashes": [ @@ -253,11 +254,11 @@ }, "django-redis": { "hashes": [ - "sha256:a5b1e3ffd3198735e6c529d9bdf38ca3fcb3155515249b98dc4d966b8ddf9d2b", - "sha256:e1aad4cc5bd743d8d0b13d5cae0cef5410eaace33e83bff5fc3a139ad8db50b4" + "sha256:1133b26b75baa3664164c3f44b9d5d133d1b8de45d94d79f38d1adc5b1d502e5", + "sha256:306589c7021e6468b2656edc89f62b8ba67e8d5a1c8877e2688042263daa7a63" ], "index": "pypi", - "version": "==4.11.0" + "version": "==4.12.1" }, "django-rest-framework": { "hashes": [ diff --git a/passbook/admin/forms/policies.py b/passbook/admin/forms/policies.py index 9bca84a98..9751260d0 100644 --- a/passbook/admin/forms/policies.py +++ b/passbook/admin/forms/policies.py @@ -1,6 +1,7 @@ """passbook administration forms""" from django import forms +from passbook.admin.fields import CodeMirrorWidget, YAMLField from passbook.core.models import User @@ -8,3 +9,4 @@ class PolicyTestForm(forms.Form): """Form to test policies against user""" user = forms.ModelChoiceField(queryset=User.objects.all()) + context = YAMLField(widget=CodeMirrorWidget(), required=False, initial=dict) diff --git a/passbook/admin/templates/administration/base.html b/passbook/admin/templates/administration/base.html index 85ab74d15..7c4ccc5f7 100644 --- a/passbook/admin/templates/administration/base.html +++ b/passbook/admin/templates/administration/base.html @@ -81,7 +81,7 @@
  • - {% trans 'Stage Prompts' %} + {% trans 'Prompts' %}
  • diff --git a/passbook/admin/templates/administration/stage/list.html b/passbook/admin/templates/administration/stage/list.html index 5098825c4..624f5374e 100644 --- a/passbook/admin/templates/administration/stage/list.html +++ b/passbook/admin/templates/administration/stage/list.html @@ -66,8 +66,8 @@ - {% trans 'Edit' %} - {% trans 'Delete' %} + {% trans 'Edit' %} + {% trans 'Delete' %} {% get_links stage as links %} {% for name, href in links.items %} {% trans name %} diff --git a/passbook/admin/templates/administration/stage_prompt/list.html b/passbook/admin/templates/administration/stage_prompt/list.html index 117f221c2..5c13689b4 100644 --- a/passbook/admin/templates/administration/stage_prompt/list.html +++ b/passbook/admin/templates/administration/stage_prompt/list.html @@ -26,7 +26,9 @@ - + + + @@ -37,19 +39,30 @@ + + @@ -56,9 +56,9 @@ diff --git a/passbook/stages/email/models.py b/passbook/stages/email/models.py index bcdec174e..ddfe0f45a 100644 --- a/passbook/stages/email/models.py +++ b/passbook/stages/email/models.py @@ -21,7 +21,7 @@ class EmailTemplates(models.TextChoices): class EmailStage(Stage): - """email stage""" + """Email-based verification.""" host = models.TextField(default="localhost") port = models.IntegerField(default=25) diff --git a/passbook/stages/email/templates/stages/email/for_email/account_confirm.html b/passbook/stages/email/templates/stages/email/for_email/account_confirmation.html similarity index 92% rename from passbook/stages/email/templates/stages/email/for_email/account_confirm.html rename to passbook/stages/email/templates/stages/email/for_email/account_confirmation.html index 4c38ccf38..598601226 100644 --- a/passbook/stages/email/templates/stages/email/for_email/account_confirm.html +++ b/passbook/stages/email/templates/stages/email/for_email/account_confirmation.html @@ -1,6 +1,6 @@ -{% extends 'email/base.html' %} +{% extends 'stages/email/for_email/base.html' %} -{% load inline %} +{% load passbook_stages_email %} {% load i18n %} {% block content %} diff --git a/passbook/stages/email/templates/stages/email/for_email/generic_email.html b/passbook/stages/email/templates/stages/email/for_email/generic_email.html index 915e16398..8246f93b6 100644 --- a/passbook/stages/email/templates/stages/email/for_email/generic_email.html +++ b/passbook/stages/email/templates/stages/email/for_email/generic_email.html @@ -1,4 +1,4 @@ -{% extends "email/base.html" %} +{% extends "stages/email/for_email/base.html" %} {% block content %} From de1be2df889d31abb25e2d1850527c8394c918e1 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Sun, 21 Jun 2020 20:46:38 +0200 Subject: [PATCH 53/64] flows: save entire GET params from shell executor --- e2e/test_provider_oidc.py | 4 +++- e2e/utils.py | 2 +- passbook/flows/views.py | 11 ++++------- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/e2e/test_provider_oidc.py b/e2e/test_provider_oidc.py index f6c75d959..3c44b9544 100644 --- a/e2e/test_provider_oidc.py +++ b/e2e/test_provider_oidc.py @@ -222,7 +222,9 @@ class TestProviderOIDC(SeleniumTestCase): self.driver.find_element(By.CSS_SELECTOR, "[type=submit]").click() self.wait.until( - ec.presence_of_element_located((By.XPATH, "//a[contains(@href, '/profile')]")) + ec.presence_of_element_located( + (By.XPATH, "//a[contains(@href, '/profile')]") + ) ) self.driver.find_element(By.XPATH, "//a[contains(@href, '/profile')]").click() self.assertEqual( diff --git a/e2e/utils.py b/e2e/utils.py index fa6876796..68fc787ec 100644 --- a/e2e/utils.py +++ b/e2e/utils.py @@ -1,10 +1,10 @@ """passbook e2e testing utilities""" -from time import time from functools import lru_cache from glob import glob from importlib.util import module_from_spec, spec_from_file_location from inspect import getmembers, isfunction from os import makedirs +from time import time from Cryptodome.PublicKey import RSA from django.apps import apps diff --git a/passbook/flows/views.py b/passbook/flows/views.py index eea803b43..0182afa53 100644 --- a/passbook/flows/views.py +++ b/passbook/flows/views.py @@ -27,7 +27,7 @@ LOGGER = get_logger() # Argument used to redirect user after login NEXT_ARG_NAME = "next" SESSION_KEY_PLAN = "passbook_flows_plan" -SESSION_KEY_NEXT = "passbook_flows_shell_next" +SESSION_KEY_GET = "passbook_flows_get" @method_decorator(xframe_options_sameorigin, name="dispatch") @@ -129,8 +129,8 @@ class FlowExecutorView(View): """User Successfully passed all stages""" self.cancel() # Since this is wrapped by the ExecutorShell, the next argument is saved in the session - next_param = self.request.session.get( - SESSION_KEY_NEXT, "passbook_core:overview" + next_param = self.request.session.get(SESSION_KEY_GET, {}).get( + NEXT_ARG_NAME, "passbook_core:overview" ) return redirect_with_qs(next_param) @@ -214,10 +214,7 @@ class FlowExecutorShellView(TemplateView): def get_context_data(self, **kwargs) -> Dict[str, Any]: kwargs["exec_url"] = reverse("passbook_flows:flow-executor", kwargs=self.kwargs) kwargs["msg_url"] = reverse("passbook_api:messages-list") - if NEXT_ARG_NAME in self.request.GET: - next_arg = self.request.GET[NEXT_ARG_NAME] - LOGGER.debug("f(exec/shell): Saved next param", next=next_arg) - self.request.session[SESSION_KEY_NEXT] = next_arg + self.request.session[SESSION_KEY_GET] = self.request.GET return kwargs From 491e507d49e0d982c87a0239bc4eb9c04450ec96 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Sun, 21 Jun 2020 20:46:48 +0200 Subject: [PATCH 54/64] stages/email: check saved get params for token --- passbook/stages/email/stage.py | 55 +++++++++++++------ .../stages/email/waiting_message.html | 2 +- passbook/stages/email/tests.py | 14 ++++- 3 files changed, 49 insertions(+), 22 deletions(-) diff --git a/passbook/stages/email/stage.py b/passbook/stages/email/stage.py index a93ab563e..58f2ab03d 100644 --- a/passbook/stages/email/stage.py +++ b/passbook/stages/email/stage.py @@ -13,12 +13,15 @@ from structlog import get_logger from passbook.core.models import Token from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER from passbook.flows.stage import StageView +from passbook.flows.views import SESSION_KEY_GET from passbook.stages.email.forms import EmailStageSendForm +from passbook.stages.email.models import EmailStage from passbook.stages.email.tasks import send_mails from passbook.stages.email.utils import TemplateEmailMessage LOGGER = get_logger() QS_KEY_TOKEN = "token" +PLAN_CONTEXT_EMAIL_SENT = "email_sent" class EmailStageView(FormView, StageView): @@ -30,34 +33,25 @@ class EmailStageView(FormView, StageView): def get_full_url(self, **kwargs) -> str: """Get full URL to be used in template""" base_url = reverse( - "passbook_flows:flow-executor", + "passbook_flows:flow-executor-shell", 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: HttpRequest, *args, **kwargs) -> HttpResponse: - if QS_KEY_TOKEN in request.GET: - token = get_object_or_404(Token, pk=request.GET[QS_KEY_TOKEN]) - self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] = token.user - token.delete() - messages.success(request, _("Successfully verified Email.")) - return self.executor.stage_ok() - return super().get(request, *args, **kwargs) - - def form_invalid(self, form: EmailStageSendForm) -> HttpResponse: - if PLAN_CONTEXT_PENDING_USER not in self.executor.plan.context: - messages.error(self.request, _("No pending user.")) - return super().form_invalid(form) + def send_email(self): + """Helper function that sends the actual email. Implies that you've + already checked that there is a pending user.""" pending_user = self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] + current_stage: EmailStage = self.executor.current_stage valid_delta = timedelta( - minutes=self.executor.current_stage.token_expiry + 1 + minutes=current_stage.token_expiry + 1 ) # + 1 because django timesince always rounds down token = Token.objects.create(user=pending_user, expires=now() + valid_delta) # Send mail to user message = TemplateEmailMessage( - subject=_("passbook - Password Recovery"), - template_name=self.executor.current_stage.template, + subject=_(current_stage.subject), + template_name=current_stage.template, to=[pending_user.email], template_context={ "url": self.get_full_url(**{QS_KEY_TOKEN: token.pk.hex}), @@ -65,7 +59,32 @@ class EmailStageView(FormView, StageView): "expires": token.expires, }, ) - send_mails(self.executor.current_stage, message) + send_mails(current_stage, message) + + def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: + # Check if the user came back from the email link to verify + if QS_KEY_TOKEN in request.session.get(SESSION_KEY_GET, {}): + token = get_object_or_404( + Token, pk=request.session[SESSION_KEY_GET][QS_KEY_TOKEN] + ) + self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] = token.user + token.delete() + messages.success(request, _("Successfully verified Email.")) + return self.executor.stage_ok() + if PLAN_CONTEXT_PENDING_USER not in self.executor.plan.context: + messages.error(self.request, _("No pending user.")) + return self.executor.stage_invalid() + # Check if we've already sent the initial e-mail + if PLAN_CONTEXT_EMAIL_SENT not in self.executor.plan.context: + self.send_email() + self.executor.plan.context[PLAN_CONTEXT_EMAIL_SENT] = True + return super().get(request, *args, **kwargs) + + def form_invalid(self, form: EmailStageSendForm) -> HttpResponse: + if PLAN_CONTEXT_PENDING_USER not in self.executor.plan.context: + messages.error(self.request, _("No pending user.")) + return super().form_invalid(form) + self.send_email() # We can't call stage_ok yet, as we're still waiting # for the user to click the link in the email return super().form_invalid(form) diff --git a/passbook/stages/email/templates/stages/email/waiting_message.html b/passbook/stages/email/templates/stages/email/waiting_message.html index 98f961333..5f1762624 100644 --- a/passbook/stages/email/templates/stages/email/waiting_message.html +++ b/passbook/stages/email/templates/stages/email/waiting_message.html @@ -15,7 +15,7 @@ {% block beneath_form %} {% endblock %}
    - +
    {% endblock %} diff --git a/passbook/stages/email/tests.py b/passbook/stages/email/tests.py index 43d7e2e7c..657cd1258 100644 --- a/passbook/stages/email/tests.py +++ b/passbook/stages/email/tests.py @@ -83,7 +83,7 @@ class TestEmailStage(TestCase): response = self.client.post(url) self.assertEqual(response.status_code, 200) self.assertEqual(len(mail.outbox), 1) - self.assertEqual(mail.outbox[0].subject, "passbook - Password Recovery") + self.assertEqual(mail.outbox[0].subject, "passbook") def test_token(self): """Test with token""" @@ -97,12 +97,20 @@ class TestEmailStage(TestCase): session.save() with patch("passbook.flows.views.FlowExecutorView.cancel", MagicMock()): + # Call the executor shell to preseed the session url = reverse( - "passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug} + "passbook_flows:flow-executor-shell", + kwargs={"flow_slug": self.flow.slug}, ) token = Token.objects.get(user=self.user) url += f"?{QS_KEY_TOKEN}={token.pk.hex}" - response = self.client.get(url) + self.client.get(url) + # Call the actual executor to get the JSON Response + response = self.client.get( + reverse( + "passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug} + ) + ) self.assertEqual(response.status_code, 200) self.assertJSONEqual( From 52f138d402183e51d6baa8ac677a242801bc138f Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Tue, 23 Jun 2020 21:49:27 +0200 Subject: [PATCH 55/64] sources/saml: improve error handing of invalid signatures --- passbook/sources/saml/forms.py | 1 + passbook/sources/saml/processors/base.py | 5 +++-- passbook/sources/saml/views.py | 3 +++ 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/passbook/sources/saml/forms.py b/passbook/sources/saml/forms.py index 3aa5258d3..e79b32ff0 100644 --- a/passbook/sources/saml/forms.py +++ b/passbook/sources/saml/forms.py @@ -16,6 +16,7 @@ class SAMLSourceForm(forms.ModelForm): model = SAMLSource fields = SOURCE_FORM_FIELDS + [ "issuer", + "binding_type", "idp_url", "idp_logout_url", "auto_logout", diff --git a/passbook/sources/saml/processors/base.py b/passbook/sources/saml/processors/base.py index 285a77c5a..f0cf7ff94 100644 --- a/passbook/sources/saml/processors/base.py +++ b/passbook/sources/saml/processors/base.py @@ -68,8 +68,9 @@ class Processor: email@example.com + SPNameQualifier=""> + email@example.com + """ assertion = self._root.find("{urn:oasis:names:tc:SAML:2.0:assertion}Assertion") subject = assertion.find("{urn:oasis:names:tc:SAML:2.0:assertion}Subject") diff --git a/passbook/sources/saml/views.py b/passbook/sources/saml/views.py index d23524e81..18c0912c6 100644 --- a/passbook/sources/saml/views.py +++ b/passbook/sources/saml/views.py @@ -6,6 +6,7 @@ from django.utils.decorators import method_decorator from django.utils.http import urlencode from django.views import View from django.views.decorators.csrf import csrf_exempt +from signxml import InvalidSignature from signxml.util import strip_pem_header from passbook.lib.views import bad_request_message @@ -71,6 +72,8 @@ class ACSView(View): processor.parse(request) except MissingSAMLResponse as exc: return bad_request_message(request, str(exc)) + except InvalidSignature as exc: + return bad_request_message(request, str(exc)) try: return processor.prepare_flow(request) From db6cb5ad51db9f84b445dbc0d4811881bb0ccb4b Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Tue, 23 Jun 2020 21:49:43 +0200 Subject: [PATCH 56/64] core: make generic error template work with shell executor --- passbook/core/templates/error/generic.html | 66 +++++++++++++++++----- 1 file changed, 52 insertions(+), 14 deletions(-) diff --git a/passbook/core/templates/error/generic.html b/passbook/core/templates/error/generic.html index 24595d1c8..7bb8ee16e 100644 --- a/passbook/core/templates/error/generic.html +++ b/passbook/core/templates/error/generic.html @@ -1,20 +1,58 @@ -{% extends 'login/base.html' %} +{% extends 'base/skeleton.html' %} {% load static %} {% load i18n %} {% load passbook_utils %} -{% block card_title %} -{% trans 'Bad Request' %} -{% endblock %} - -{% block card %} -
    - {% if message %} -

    {% trans message %}

    - {% endif %} - {% if 'back' in request.GET %} - {% trans 'Back' %} - {% endif %} - +{% block body %} +
    + + + + + + + + + + + +
    + {% endblock %} From c0d8aa2303e2f173a5f5c98148dd44969a3c1c73 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Wed, 24 Jun 2020 13:12:34 +0200 Subject: [PATCH 57/64] sources/saml: fix SAMLRequest not being encoded properly for Redirect bindings --- passbook/providers/saml/utils/encoding.py | 2 +- passbook/sources/saml/templates/saml/sp/login.html | 2 +- passbook/sources/saml/views.py | 14 ++++++++------ 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/passbook/providers/saml/utils/encoding.py b/passbook/providers/saml/utils/encoding.py index 9aac5246a..3445024b4 100644 --- a/passbook/providers/saml/utils/encoding.py +++ b/passbook/providers/saml/utils/encoding.py @@ -20,5 +20,5 @@ def deflate_and_base64_encode(inflated: bytes, encoding="utf-8"): def nice64(src): - """ Returns src base64-encoded and formatted nicely for our XML. """ + """Returns src base64-encoded and formatted nicely for our XML. """ return base64.b64encode(src).decode("utf-8").replace("\n", "") diff --git a/passbook/sources/saml/templates/saml/sp/login.html b/passbook/sources/saml/templates/saml/sp/login.html index 4a47376ac..89dcfa19b 100644 --- a/passbook/sources/saml/templates/saml/sp/login.html +++ b/passbook/sources/saml/templates/saml/sp/login.html @@ -14,7 +14,7 @@
    {% csrf_token %} - +
    {% trans 'Name' %}{% trans 'Field' %}{% trans 'Label' %}{% trans 'Type' %} {% trans 'Flows' %}
    {{ prompt.field_key }}
    - {{ prompt|verbose_name }}
    +
    + {{ prompt.label }} +
    +
    +
    + {{ prompt.type }} +
    +
      {% for flow in prompt.flow_set.all %}
    • {{ flow.slug }}
    • + {% empty %} +
    • -
    • {% endfor %}
    - {% trans 'Edit' %} - {% trans 'Delete' %} + {% trans 'Edit' %} + {% trans 'Delete' %} {% get_links prompt as links %} {% for name, href in links.items %} {% trans name %} diff --git a/passbook/admin/views/policies.py b/passbook/admin/views/policies.py index e8a009bd2..76196e70e 100644 --- a/passbook/admin/views/policies.py +++ b/passbook/admin/views/policies.py @@ -1,11 +1,15 @@ """passbook Policy administration""" +from typing import Any, Dict + from django.contrib import messages from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import ( PermissionRequiredMixin as DjangoPermissionRequiredMixin, ) from django.contrib.messages.views import SuccessMessageMixin -from django.http import Http404 +from django.db.models import QuerySet +from django.forms import Form +from django.http import Http404, HttpRequest, HttpResponse from django.urls import reverse_lazy from django.utils.translation import ugettext as _ from django.views.generic import DeleteView, FormView, ListView, UpdateView @@ -15,8 +19,8 @@ from guardian.mixins import PermissionListMixin, PermissionRequiredMixin from passbook.admin.forms.policies import PolicyTestForm from passbook.lib.utils.reflection import all_subclasses, path_to_class from passbook.lib.views import CreateAssignPermView -from passbook.policies.engine import PolicyEngine -from passbook.policies.models import Policy +from passbook.policies.models import Policy, PolicyBinding +from passbook.policies.process import PolicyProcess, PolicyRequest class PolicyListView(LoginRequiredMixin, PermissionListMixin, ListView): @@ -25,14 +29,14 @@ class PolicyListView(LoginRequiredMixin, PermissionListMixin, ListView): model = Policy permission_required = "passbook_policies.view_policy" paginate_by = 10 - ordering = "order" + ordering = "name" template_name = "administration/policy/list.html" - def get_context_data(self, **kwargs): + def get_context_data(self, **kwargs: Any) -> Dict[str, Any]: kwargs["types"] = {x.__name__: x for x in all_subclasses(Policy)} return super().get_context_data(**kwargs) - def get_queryset(self): + def get_queryset(self) -> QuerySet: return super().get_queryset().select_subclasses() @@ -51,14 +55,14 @@ class PolicyCreateView( success_url = reverse_lazy("passbook_admin:policies") success_message = _("Successfully created Policy") - def get_context_data(self, **kwargs): + def get_context_data(self, **kwargs: Any) -> Dict[str, Any]: kwargs = super().get_context_data(**kwargs) form_cls = self.get_form_class() if hasattr(form_cls, "template_name"): kwargs["base_template"] = form_cls.template_name return kwargs - def get_form_class(self): + def get_form_class(self) -> Form: policy_type = self.request.GET.get("type") try: model = next(x for x in all_subclasses(Policy) if x.__name__ == policy_type) @@ -79,19 +83,19 @@ class PolicyUpdateView( success_url = reverse_lazy("passbook_admin:policies") success_message = _("Successfully updated Policy") - def get_context_data(self, **kwargs): + def get_context_data(self, **kwargs: Any) -> Dict[str, Any]: kwargs = super().get_context_data(**kwargs) form_cls = self.get_form_class() if hasattr(form_cls, "template_name"): kwargs["base_template"] = form_cls.template_name return kwargs - def get_form_class(self): + def get_form_class(self) -> Form: form_class_path = self.get_object().form form_class = path_to_class(form_class_path) return form_class - def get_object(self, queryset=None): + def get_object(self, queryset=None) -> Policy: return ( Policy.objects.filter(pk=self.kwargs.get("pk")).select_subclasses().first() ) @@ -109,12 +113,12 @@ class PolicyDeleteView( success_url = reverse_lazy("passbook_admin:policies") success_message = _("Successfully deleted Policy") - def get_object(self, queryset=None): + def get_object(self, queryset=None) -> Policy: return ( Policy.objects.filter(pk=self.kwargs.get("pk")).select_subclasses().first() ) - def delete(self, request, *args, **kwargs): + def delete(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: messages.success(self.request, self.success_message) return super().delete(request, *args, **kwargs) @@ -128,27 +132,30 @@ class PolicyTestView(LoginRequiredMixin, DetailView, PermissionRequiredMixin, Fo template_name = "administration/policy/test.html" object = None - def get_object(self, queryset=None): + def get_object(self, queryset=None) -> QuerySet: return ( Policy.objects.filter(pk=self.kwargs.get("pk")).select_subclasses().first() ) - def get_context_data(self, **kwargs): + def get_context_data(self, **kwargs: Any) -> Dict[str, Any]: kwargs["policy"] = self.get_object() return super().get_context_data(**kwargs) - def post(self, *args, **kwargs): + def post(self, *args, **kwargs) -> HttpResponse: self.object = self.get_object() return super().post(*args, **kwargs) - def form_valid(self, form): + def form_valid(self, form: PolicyTestForm) -> HttpResponse: policy = self.get_object() user = form.cleaned_data.get("user") - policy_engine = PolicyEngine([policy], user, self.request) - policy_engine.use_cache = False - policy_engine.build() - result = policy_engine.passing - if result: + + p_request = PolicyRequest(user) + p_request.http_request = self.request + p_request.context = form.cleaned_data + + proc = PolicyProcess(PolicyBinding(policy=policy), p_request, None) + result = proc.execute() + if result.passing: messages.success(self.request, _("User successfully passed policy.")) else: messages.error(self.request, _("User didn't pass policy.")) diff --git a/passbook/core/signals.py b/passbook/core/signals.py index c3a50b4cc..01299f90e 100644 --- a/passbook/core/signals.py +++ b/passbook/core/signals.py @@ -17,12 +17,15 @@ password_changed = Signal(providing_args=["user", "password"]) # pylint: disable=unused-argument def invalidate_policy_cache(sender, instance, **_): """Invalidate Policy cache when policy is updated""" - from passbook.policies.models import Policy + from passbook.policies.models import Policy, PolicyBinding from passbook.policies.process import cache_key if isinstance(instance, Policy): LOGGER.debug("Invalidating policy cache", policy=instance) - prefix = cache_key(instance) + "*" - keys = cache.keys(prefix) - cache.delete_many(keys) - LOGGER.debug("Deleted %d keys", len(keys)) + total = 0 + for binding in PolicyBinding.objects.filter(policy=instance): + prefix = cache_key(binding) + "*" + keys = cache.keys(prefix) + total += len(keys) + cache.delete_many(keys) + LOGGER.debug("Deleted keys", len=total) diff --git a/passbook/core/views/access.py b/passbook/core/views/access.py index ff3888645..c2e07dd19 100644 --- a/passbook/core/views/access.py +++ b/passbook/core/views/access.py @@ -1,6 +1,4 @@ """passbook access helper classes""" -from typing import List, Tuple - from django.contrib import messages from django.http import HttpRequest from django.utils.translation import gettext as _ @@ -8,6 +6,7 @@ from structlog import get_logger from passbook.core.models import Application, Provider, User from passbook.policies.engine import PolicyEngine +from passbook.policies.types import PolicyResult LOGGER = get_logger() @@ -33,9 +32,7 @@ class AccessMixin: ) raise exc - def user_has_access( - self, application: Application, user: User - ) -> Tuple[bool, List[str]]: + def user_has_access(self, application: Application, user: User) -> PolicyResult: """Check if user has access to application.""" LOGGER.debug("Checking permissions", user=user, application=application) policy_engine = PolicyEngine(application.policies.all(), user, self.request) diff --git a/passbook/flows/forms.py b/passbook/flows/forms.py index bd7d018b1..6f695050f 100644 --- a/passbook/flows/forms.py +++ b/passbook/flows/forms.py @@ -1,7 +1,6 @@ """Flow and Stage forms""" from django import forms -from django.contrib.admin.widgets import FilteredSelectMultiple from django.utils.translation import gettext_lazy as _ from passbook.flows.models import Flow, FlowStageBinding @@ -17,7 +16,6 @@ class FlowForm(forms.ModelForm): "name", "slug", "designation", - "stages", ] help_texts = { "name": _("Shown as the Title in Flow pages."), @@ -31,7 +29,6 @@ class FlowForm(forms.ModelForm): } widgets = { "name": forms.TextInput(), - "stages": FilteredSelectMultiple(_("stages"), False), } diff --git a/passbook/flows/planner.py b/passbook/flows/planner.py index a2d0dc05f..f03f8e6f9 100644 --- a/passbook/flows/planner.py +++ b/passbook/flows/planner.py @@ -1,7 +1,7 @@ """Flows Planner""" from dataclasses import dataclass, field from time import time -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, List, Optional from django.core.cache import cache from django.http import HttpRequest @@ -11,6 +11,7 @@ from passbook.core.models import User from passbook.flows.exceptions import EmptyFlowException, FlowNonApplicableException from passbook.flows.models import Flow, Stage from passbook.policies.engine import PolicyEngine +from passbook.policies.types import PolicyResult LOGGER = get_logger() @@ -51,8 +52,8 @@ class FlowPlanner: self.use_cache = True self.flow = flow - def _check_flow_root_policies(self, request: HttpRequest) -> Tuple[bool, List[str]]: - engine = PolicyEngine(self.flow.policies.all(), request.user, request) + def _check_flow_root_policies(self, request: HttpRequest) -> PolicyResult: + engine = PolicyEngine(self.flow, request.user, request) engine.build() return engine.result @@ -64,9 +65,9 @@ class FlowPlanner: LOGGER.debug("f(plan): Starting planning process", flow=self.flow) # First off, check the flow's direct policy bindings # to make sure the user even has access to the flow - root_passing, root_passing_messages = self._check_flow_root_policies(request) - if not root_passing: - raise FlowNonApplicableException(root_passing_messages) + root_result = self._check_flow_root_policies(request) + if not root_result.passing: + raise FlowNonApplicableException(*root_result.messages) # Bit of a workaround here, if there is a pending user set in the default context # we use that user for our cache key # to make sure they don't get the generic response @@ -106,11 +107,10 @@ class FlowPlanner: .select_related() ): binding = stage.flowstagebinding_set.get(flow__pk=self.flow.pk) - engine = PolicyEngine(binding.policies.all(), user, request) + engine = PolicyEngine(binding, user, request) engine.request.context = plan.context engine.build() - passing, _ = engine.result - if passing: + if engine.passing: LOGGER.debug("f(plan): Stage passing", stage=stage, flow=self.flow) plan.stages.append(stage) end_time = time() diff --git a/passbook/flows/templates/flows/shell.html b/passbook/flows/templates/flows/shell.html index 046f3c685..aa57f7a5d 100644 --- a/passbook/flows/templates/flows/shell.html +++ b/passbook/flows/templates/flows/shell.html @@ -142,7 +142,6 @@ const loadFormCode = () => { const setFormSubmitHandlers = () => { document.querySelectorAll("#flow-body form").forEach(form => { console.log(`Setting action for form ${form}`); - // debugger; form.action = flowBodyUrl; console.log(`Adding handler for form ${form}`); form.addEventListener('submit', (e) => { diff --git a/passbook/flows/tests/test_planner.py b/passbook/flows/tests/test_planner.py index 79c0b6bdb..25f2bd1a7 100644 --- a/passbook/flows/tests/test_planner.py +++ b/passbook/flows/tests/test_planner.py @@ -8,9 +8,10 @@ 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.policies.types import PolicyResult from passbook.stages.dummy.models import DummyStage -POLICY_RESULT_MOCK = MagicMock(return_value=(False, [""],)) +POLICY_RESULT_MOCK = MagicMock(return_value=PolicyResult(False)) TIME_NOW_MOCK = MagicMock(return_value=3) diff --git a/passbook/flows/tests/test_views.py b/passbook/flows/tests/test_views.py index f9979e4d9..e6a2ad20c 100644 --- a/passbook/flows/tests/test_views.py +++ b/passbook/flows/tests/test_views.py @@ -9,9 +9,10 @@ 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.policies.types import PolicyResult from passbook.stages.dummy.models import DummyStage -POLICY_RESULT_MOCK = MagicMock(return_value=(False, [""],)) +POLICY_RESULT_MOCK = MagicMock(return_value=PolicyResult(False)) class TestFlowExecutor(TestCase): diff --git a/passbook/lib/models.py b/passbook/lib/models.py index d6036dd0e..0966dd8ac 100644 --- a/passbook/lib/models.py +++ b/passbook/lib/models.py @@ -1,5 +1,6 @@ """Generic models""" from django.db import models +from model_utils.managers import InheritanceManager class CreatedUpdatedModel(models.Model): @@ -10,3 +11,27 @@ class CreatedUpdatedModel(models.Model): class Meta: abstract = True + + +class InheritanceAutoManager(InheritanceManager): + """Object manager which automatically selects the subclass""" + + def get_queryset(self): + return super().get_queryset().select_subclasses() + + +class InheritanceForwardManyToOneDescriptor( + models.fields.related.ForwardManyToOneDescriptor +): + """Forward ManyToOne Descriptor that selects subclass. Requires InheritanceAutoManager.""" + + def get_queryset(self, **hints): + return self.field.remote_field.model.objects.db_manager( + hints=hints + ).select_subclasses() + + +class InheritanceForeignKey(models.ForeignKey): + """Custom ForeignKey that uses InheritanceForwardManyToOneDescriptor""" + + forward_related_accessor_class = InheritanceForwardManyToOneDescriptor diff --git a/passbook/policies/api.py b/passbook/policies/api.py index ebae9a3a0..27fd129ac 100644 --- a/passbook/policies/api.py +++ b/passbook/policies/api.py @@ -12,7 +12,7 @@ class PolicyBindingSerializer(ModelSerializer): class Meta: model = PolicyBinding - fields = ["policy", "target", "enabled", "order"] + fields = ["policy", "target", "enabled", "order", "timeout"] class PolicyBindingViewSet(ModelViewSet): diff --git a/passbook/policies/engine.py b/passbook/policies/engine.py index 24aaae59f..143ad6473 100644 --- a/passbook/policies/engine.py +++ b/passbook/policies/engine.py @@ -1,14 +1,14 @@ """passbook policy engine""" from multiprocessing import Pipe, set_start_method from multiprocessing.connection import Connection -from typing import List, Optional, Tuple +from typing import List, Optional from django.core.cache import cache from django.http import HttpRequest from structlog import get_logger from passbook.core.models import User -from passbook.policies.models import Policy +from passbook.policies.models import Policy, PolicyBinding, PolicyBindingModel from passbook.policies.process import PolicyProcess, cache_key from passbook.policies.types import PolicyRequest, PolicyResult @@ -24,12 +24,14 @@ class PolicyProcessInfo: process: PolicyProcess connection: Connection result: Optional[PolicyResult] - policy: Policy + binding: PolicyBinding - def __init__(self, process: PolicyProcess, connection: Connection, policy: Policy): + def __init__( + self, process: PolicyProcess, connection: Connection, binding: PolicyBinding + ): self.process = process self.connection = connection - self.policy = policy + self.binding = binding self.result = None @@ -37,54 +39,64 @@ class PolicyEngine: """Orchestrate policy checking, launch tasks and return result""" use_cache: bool = True - policies: List[Policy] = [] request: PolicyRequest + __pbm: PolicyBindingModel __cached_policies: List[PolicyResult] __processes: List[PolicyProcessInfo] - def __init__(self, policies, user: User, request: HttpRequest = None): - self.policies = policies + def __init__( + self, pbm: PolicyBindingModel, user: User, request: HttpRequest = None + ): + if not isinstance(pbm, PolicyBindingModel): + raise ValueError(f"{pbm} is not instance of PolicyBindingModel") + self.__pbm = pbm self.request = PolicyRequest(user) if request: self.request.http_request = request self.__cached_policies = [] self.__processes = [] - def _select_subclasses(self) -> List[Policy]: + def _iter_bindings(self) -> List[PolicyBinding]: """Make sure all Policies are their respective classes""" - return ( - Policy.objects.filter(pk__in=[x.pk for x in self.policies]) - .select_subclasses() - .order_by("order") + return PolicyBinding.objects.filter(target=self.__pbm, enabled=True).order_by( + "order" ) + def _check_policy_type(self, policy: Policy): + """Check policy type, make sure it's not the root class as that has no logic implemented""" + # policy_type = type(policy) + if policy.__class__ == Policy: + raise TypeError(f"Policy '{policy}' is root type") + def build(self) -> "PolicyEngine": """Build task group""" - for policy in self._select_subclasses(): - cached_policy = cache.get(cache_key(policy, self.request.user), None) + for binding in self._iter_bindings(): + self._check_policy_type(binding.policy) + policy = binding.policy + cached_policy = cache.get(cache_key(binding, self.request.user), None) if cached_policy and self.use_cache: LOGGER.debug("P_ENG: Taking result from cache", policy=policy) self.__cached_policies.append(cached_policy) continue LOGGER.debug("P_ENG: Evaluating policy", policy=policy) our_end, task_end = Pipe(False) - task = PolicyProcess(policy, self.request, task_end) + task = PolicyProcess(binding, self.request, task_end) LOGGER.debug("P_ENG: Starting Process", policy=policy) task.start() self.__processes.append( - PolicyProcessInfo(process=task, connection=our_end, policy=policy) + PolicyProcessInfo(process=task, connection=our_end, binding=binding) ) # If all policies are cached, we have an empty list here. for proc_info in self.__processes: - proc_info.process.join(proc_info.policy.timeout) + proc_info.process.join(proc_info.binding.timeout) # Only call .recv() if no result is saved, otherwise we just deadlock here if not proc_info.result: proc_info.result = proc_info.connection.recv() return self @property - def result(self) -> Tuple[bool, List[str]]: + def result(self) -> PolicyResult: """Get policy-checking result""" messages: List[str] = [] process_results: List[PolicyResult] = [ @@ -95,10 +107,10 @@ class PolicyEngine: if result.messages: messages += result.messages if not result.passing: - return False, messages - return True, messages + return PolicyResult(False, *messages) + return PolicyResult(True, *messages) @property def passing(self) -> bool: """Only get true/false if user passes""" - return self.result[0] + return self.result.passing diff --git a/passbook/policies/expression/evaluator.py b/passbook/policies/expression/evaluator.py index 2b31f4671..b2120bb74 100644 --- a/passbook/policies/expression/evaluator.py +++ b/passbook/policies/expression/evaluator.py @@ -4,7 +4,7 @@ from typing import TYPE_CHECKING, Any, Dict, Optional from django.core.exceptions import ValidationError from jinja2 import Undefined -from jinja2.exceptions import TemplateSyntaxError, UndefinedError +from jinja2.exceptions import TemplateSyntaxError from jinja2.nativetypes import NativeEnvironment from requests import Session from structlog import get_logger @@ -90,7 +90,8 @@ class Evaluator: if result: return PolicyResult(bool(result)) return PolicyResult(False) - except UndefinedError as exc: + except Exception as exc: # pylint: disable=broad-except + LOGGER.warning("Expression error", exc=exc) return PolicyResult(False, str(exc)) def validate(self, expression: str): diff --git a/passbook/policies/forms.py b/passbook/policies/forms.py index 8bcdbb9e0..0378e2c58 100644 --- a/passbook/policies/forms.py +++ b/passbook/policies/forms.py @@ -3,8 +3,8 @@ from django import forms from passbook.policies.models import PolicyBinding, PolicyBindingModel -GENERAL_FIELDS = ["name", "negate", "order", "timeout"] -GENERAL_SERIALIZER_FIELDS = ["pk", "name", "negate", "order", "timeout"] +GENERAL_FIELDS = ["name"] +GENERAL_SERIALIZER_FIELDS = ["pk", "name"] class PolicyBindingForm(forms.ModelForm): @@ -18,9 +18,4 @@ class PolicyBindingForm(forms.ModelForm): class Meta: model = PolicyBinding - fields = [ - "enabled", - "policy", - "target", - "order", - ] + fields = ["enabled", "policy", "target", "order", "timeout"] diff --git a/passbook/policies/migrations/0002_auto_20200528_1647.py b/passbook/policies/migrations/0002_auto_20200528_1647.py new file mode 100644 index 000000000..b43a2f732 --- /dev/null +++ b/passbook/policies/migrations/0002_auto_20200528_1647.py @@ -0,0 +1,58 @@ +# Generated by Django 3.0.6 on 2020-05-28 16:47 + +import django.db.models.deletion +from django.db import migrations, models + +import passbook.lib.models + + +class Migration(migrations.Migration): + + dependencies = [ + ("passbook_policies", "0001_initial"), + ] + + operations = [ + migrations.AlterModelOptions( + name="policy", + options={ + "base_manager_name": "objects", + "verbose_name": "Policy", + "verbose_name_plural": "Policies", + }, + ), + migrations.RemoveField(model_name="policy", name="negate",), + migrations.RemoveField(model_name="policy", name="order",), + migrations.RemoveField(model_name="policy", name="timeout",), + migrations.AddField( + model_name="policybinding", + name="negate", + field=models.BooleanField( + default=False, + help_text="Negates the outcome of the policy. Messages are unaffected.", + ), + ), + migrations.AddField( + model_name="policybinding", + name="timeout", + field=models.IntegerField( + default=30, + help_text="Timeout after which Policy execution is terminated.", + ), + ), + migrations.AlterField( + model_name="policybinding", name="order", field=models.IntegerField(), + ), + migrations.AlterField( + model_name="policybinding", + name="policy", + field=passbook.lib.models.InheritanceForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to="passbook_policies.Policy", + ), + ), + migrations.AlterUniqueTogether( + name="policybinding", unique_together={("policy", "target", "order")}, + ), + ] diff --git a/passbook/policies/models.py b/passbook/policies/models.py index b47dc9ecd..826938f51 100644 --- a/passbook/policies/models.py +++ b/passbook/policies/models.py @@ -5,7 +5,11 @@ from django.db import models from django.utils.translation import gettext_lazy as _ from model_utils.managers import InheritanceManager -from passbook.lib.models import CreatedUpdatedModel +from passbook.lib.models import ( + CreatedUpdatedModel, + InheritanceAutoManager, + InheritanceForeignKey, +) from passbook.policies.exceptions import PolicyException from passbook.policies.types import PolicyRequest, PolicyResult @@ -22,7 +26,6 @@ class PolicyBindingModel(models.Model): objects = InheritanceManager() class Meta: - verbose_name = _("Policy Binding Model") verbose_name_plural = _("Policy Binding Models") @@ -36,13 +39,19 @@ class PolicyBinding(models.Model): enabled = models.BooleanField(default=True) - policy = models.ForeignKey("Policy", on_delete=models.CASCADE, related_name="+") + policy = InheritanceForeignKey("Policy", on_delete=models.CASCADE, related_name="+") target = models.ForeignKey( PolicyBindingModel, on_delete=models.CASCADE, related_name="+" ) + negate = models.BooleanField( + default=False, + help_text=_("Negates the outcome of the policy. Messages are unaffected."), + ) + timeout = models.IntegerField( + default=30, help_text=_("Timeout after which Policy execution is terminated.") + ) - # default value and non-unique for compatibility - order = models.IntegerField(default=0) + order = models.IntegerField() def __str__(self) -> str: return f"PolicyBinding policy={self.policy} target={self.target} order={self.order}" @@ -51,6 +60,7 @@ class PolicyBinding(models.Model): verbose_name = _("Policy Binding") verbose_name_plural = _("Policy Bindings") + unique_together = ("policy", "target", "order") class Policy(CreatedUpdatedModel): @@ -60,11 +70,8 @@ class Policy(CreatedUpdatedModel): policy_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4) name = models.TextField(blank=True, null=True) - negate = models.BooleanField(default=False) - order = models.IntegerField(default=0) - timeout = models.IntegerField(default=30) - objects = InheritanceManager() + objects = InheritanceAutoManager() def __str__(self): return f"Policy {self.name}" @@ -72,3 +79,9 @@ class Policy(CreatedUpdatedModel): def passes(self, request: PolicyRequest) -> PolicyResult: """Check if user instance passes this policy""" raise PolicyException() + + class Meta: + base_manager_name = "objects" + + verbose_name = _("Policy") + verbose_name_plural = _("Policies") diff --git a/passbook/policies/process.py b/passbook/policies/process.py index f6c2a1265..1fb906c9f 100644 --- a/passbook/policies/process.py +++ b/passbook/policies/process.py @@ -8,15 +8,15 @@ from structlog import get_logger from passbook.core.models import User from passbook.policies.exceptions import PolicyException -from passbook.policies.models import Policy +from passbook.policies.models import PolicyBinding from passbook.policies.types import PolicyRequest, PolicyResult LOGGER = get_logger() -def cache_key(policy: Policy, user: Optional[User] = None) -> str: +def cache_key(binding: PolicyBinding, user: Optional[User] = None) -> str: """Generate Cache key for policy""" - prefix = f"policy_{policy.pk}" + prefix = f"policy_{binding.policy_binding_uuid.hex}_{binding.policy.pk.hex}" if user: prefix += f"#{user.pk}" return prefix @@ -26,40 +26,50 @@ class PolicyProcess(Process): """Evaluate a single policy within a seprate process""" connection: Connection - policy: Policy + binding: PolicyBinding request: PolicyRequest - def __init__(self, policy: Policy, request: PolicyRequest, connection: Connection): + def __init__( + self, + binding: PolicyBinding, + request: PolicyRequest, + connection: Optional[Connection], + ): super().__init__() - self.policy = policy + self.binding = binding self.request = request - self.connection = connection + if connection: + self.connection = connection - def run(self): - """Task wrapper to run policy checking""" + def execute(self) -> PolicyResult: + """Run actual policy, returns result""" LOGGER.debug( "P_ENG(proc): Running policy", - policy=self.policy, + policy=self.binding.policy, user=self.request.user, process="PolicyProcess", ) try: - policy_result = self.policy.passes(self.request) + policy_result = self.binding.policy.passes(self.request) except PolicyException as exc: LOGGER.debug("P_ENG(proc): error", exc=exc) policy_result = PolicyResult(False, str(exc)) # Invert result if policy.negate is set - if self.policy.negate: + if self.binding.negate: policy_result.passing = not policy_result.passing LOGGER.debug( "P_ENG(proc): Finished", - policy=self.policy, + policy=self.binding.policy, result=policy_result, process="PolicyProcess", passing=policy_result.passing, user=self.request.user, ) - key = cache_key(self.policy, self.request.user) + key = cache_key(self.binding, self.request.user) cache.set(key, policy_result) LOGGER.debug("P_ENG(proc): Cached policy evaluation", key=key) - self.connection.send(policy_result) + return policy_result + + def run(self): + """Task wrapper to run policy checking""" + self.connection.send(self.execute()) diff --git a/passbook/policies/tests/test_engine.py b/passbook/policies/tests/test_engine.py index 0f4c63a83..05537e432 100644 --- a/passbook/policies/tests/test_engine.py +++ b/passbook/policies/tests/test_engine.py @@ -5,7 +5,8 @@ from django.test import TestCase from passbook.core.models import User from passbook.policies.dummy.models import DummyPolicy from passbook.policies.engine import PolicyEngine -from passbook.policies.models import Policy +from passbook.policies.expression.models import ExpressionPolicy +from passbook.policies.models import Policy, PolicyBinding, PolicyBindingModel class PolicyTestEngine(TestCase): @@ -20,40 +21,64 @@ class PolicyTestEngine(TestCase): self.policy_true = DummyPolicy.objects.create( result=True, wait_min=0, wait_max=1 ) - self.policy_negate = DummyPolicy.objects.create( - negate=True, result=True, wait_min=0, wait_max=1 + self.policy_wrong_type = Policy.objects.create(name="wrong_type") + self.policy_raises = ExpressionPolicy.objects.create( + name="raises", expression="{{ 0/0 }}" ) - self.policy_raises = Policy.objects.create(name="raises") def test_engine_empty(self): """Ensure empty policy list passes""" - engine = PolicyEngine([], self.user) - self.assertEqual(engine.build().passing, True) + pbm = PolicyBindingModel.objects.create() + engine = PolicyEngine(pbm, self.user) + result = engine.build().result + self.assertEqual(result.passing, True) + self.assertEqual(result.messages, ()) def test_engine(self): """Ensure all policies passes (Mix of false and true -> false)""" - engine = PolicyEngine( - DummyPolicy.objects.filter(negate__exact=False), self.user - ) - self.assertEqual(engine.build().passing, False) + pbm = PolicyBindingModel.objects.create() + PolicyBinding.objects.create(target=pbm, policy=self.policy_false, order=0) + PolicyBinding.objects.create(target=pbm, policy=self.policy_true, order=1) + engine = PolicyEngine(pbm, self.user) + result = engine.build().result + self.assertEqual(result.passing, False) + self.assertEqual(result.messages, ("dummy",)) def test_engine_negate(self): """Test negate flag""" - engine = PolicyEngine(DummyPolicy.objects.filter(negate__exact=True), self.user) - self.assertEqual(engine.build().passing, False) + pbm = PolicyBindingModel.objects.create() + PolicyBinding.objects.create( + target=pbm, policy=self.policy_true, negate=True, order=0 + ) + engine = PolicyEngine(pbm, self.user) + result = engine.build().result + self.assertEqual(result.passing, False) + self.assertEqual(result.messages, ("dummy",)) def test_engine_policy_error(self): - """Test negate flag""" - engine = PolicyEngine(Policy.objects.filter(name="raises"), self.user) - self.assertEqual(engine.build().passing, False) + """Test policy raising an error flag""" + pbm = PolicyBindingModel.objects.create() + PolicyBinding.objects.create(target=pbm, policy=self.policy_raises, order=0) + engine = PolicyEngine(pbm, self.user) + result = engine.build().result + self.assertEqual(result.passing, False) + self.assertEqual(result.messages, ("division by zero",)) + + def test_engine_policy_type(self): + """Test invalid policy type""" + pbm = PolicyBindingModel.objects.create() + PolicyBinding.objects.create(target=pbm, policy=self.policy_wrong_type, order=0) + with self.assertRaises(TypeError): + engine = PolicyEngine(pbm, self.user) + engine.build() def test_engine_cache(self): """Ensure empty policy list passes""" - engine = PolicyEngine( - DummyPolicy.objects.filter(negate__exact=False), self.user - ) + pbm = PolicyBindingModel.objects.create() + PolicyBinding.objects.create(target=pbm, policy=self.policy_false, order=0) + engine = PolicyEngine(pbm, self.user) self.assertEqual(len(cache.keys("policy_*")), 0) self.assertEqual(engine.build().passing, False) - self.assertEqual(len(cache.keys("policy_*")), 2) + self.assertEqual(len(cache.keys("policy_*")), 1) self.assertEqual(engine.build().passing, False) - self.assertEqual(len(cache.keys("policy_*")), 2) + self.assertEqual(len(cache.keys("policy_*")), 1) diff --git a/passbook/providers/oauth/views/oauth2.py b/passbook/providers/oauth/views/oauth2.py index 96a0eaaa2..208dd99ac 100644 --- a/passbook/providers/oauth/views/oauth2.py +++ b/passbook/providers/oauth/views/oauth2.py @@ -50,9 +50,9 @@ class PassbookAuthorizationView(AccessMixin, AuthorizationView): provider.save() self._application = application # Check permissions - passing, policy_messages = self.user_has_access(self._application, request.user) - if not passing: - for policy_message in policy_messages: + result = self.user_has_access(self._application, request.user) + if not result.passing: + for policy_message in result.messages: messages.error(request, policy_message) return redirect("passbook_providers_oauth:oauth2-permission-denied") # Some clients don't pass response_type, so we default to code diff --git a/passbook/providers/oidc/auth.py b/passbook/providers/oidc/auth.py index 334607afa..91a0b9dcf 100644 --- a/passbook/providers/oidc/auth.py +++ b/passbook/providers/oidc/auth.py @@ -18,7 +18,7 @@ LOGGER = get_logger() def client_related_provider(client: Client) -> Optional[Provider]: """Lookup related Application from Client""" # because oidc_provider is also used by app_gw, we can't be - # sure an OpenIDPRovider instance exists. hence we look through all related models + # sure an OpenIDProvider instance exists. hence we look through all related models # and choose the one that inherits from Provider, which is guaranteed to # have the application property collector = Collector(using="default") @@ -50,9 +50,9 @@ def check_permissions( policy_engine.build() # Check permissions - passing, policy_messages = policy_engine.result - if not passing: - for policy_message in policy_messages: + result = policy_engine.result + if not result.passing: + for policy_message in result.messages: messages.error(request, policy_message) return redirect("passbook_providers_oauth:oauth2-permission-denied") diff --git a/passbook/stages/prompt/forms.py b/passbook/stages/prompt/forms.py index 14830f36d..d59517e00 100644 --- a/passbook/stages/prompt/forms.py +++ b/passbook/stages/prompt/forms.py @@ -55,9 +55,9 @@ class PromptForm(forms.Form): def clean(self): cleaned_data = super().clean() user = self.plan.context.get(PLAN_CONTEXT_PENDING_USER, get_anonymous_user()) - engine = PolicyEngine(self.stage.policies.all(), user) + engine = PolicyEngine(self.stage, user) engine.request.context = cleaned_data engine.build() - passing, messages = engine.result - if not passing: - raise forms.ValidationError(messages) + result = engine.result + if not result.passing: + raise forms.ValidationError(result.messages) diff --git a/passbook/stages/prompt/models.py b/passbook/stages/prompt/models.py index 503d8d0f3..908d121b7 100644 --- a/passbook/stages/prompt/models.py +++ b/passbook/stages/prompt/models.py @@ -74,7 +74,7 @@ class Prompt(models.Model): return super().save(*args, **kwargs) def __str__(self): - return f"Prompt '{self.field_key}' type={self.type}'" + return f"Prompt '{self.field_key}' type={self.type}" class Meta: diff --git a/passbook/stages/prompt/tests.py b/passbook/stages/prompt/tests.py index d4d2e108d..ee63c8566 100644 --- a/passbook/stages/prompt/tests.py +++ b/passbook/stages/prompt/tests.py @@ -139,7 +139,7 @@ class TestPromptStage(TestCase): expr_policy = ExpressionPolicy.objects.create( name="validate-form", expression=expr ) - PolicyBinding.objects.create(policy=expr_policy, target=self.stage) + PolicyBinding.objects.create(policy=expr_policy, target=self.stage, order=0) form = PromptForm(stage=self.stage, plan=plan, data=self.prompt_data) self.assertEqual(form.is_valid(), True) return form @@ -151,7 +151,7 @@ class TestPromptStage(TestCase): expr_policy = ExpressionPolicy.objects.create( name="validate-form", expression=expr ) - PolicyBinding.objects.create(policy=expr_policy, target=self.stage) + PolicyBinding.objects.create(policy=expr_policy, target=self.stage, order=0) form = PromptForm(stage=self.stage, plan=plan, data=self.prompt_data) self.assertEqual(form.is_valid(), False) return form diff --git a/swagger.yaml b/swagger.yaml index ce498dd6f..54beb80bb 100755 --- a/swagger.yaml +++ b/swagger.yaml @@ -837,7 +837,7 @@ paths: parameters: - name: policy_uuid in: path - description: A UUID string identifying this policy. + description: A UUID string identifying this Policy. required: true type: string format: uuid @@ -5079,19 +5079,6 @@ definitions: title: Name type: string x-nullable: true - negate: - title: Negate - type: boolean - order: - title: Order - type: integer - maximum: 2147483647 - minimum: -2147483648 - timeout: - title: Timeout - type: integer - maximum: 2147483647 - minimum: -2147483648 __type__: title: 'type ' type: string @@ -5100,6 +5087,7 @@ definitions: required: - policy - target + - order type: object properties: policy: @@ -5118,6 +5106,12 @@ definitions: type: integer maximum: 2147483647 minimum: -2147483648 + timeout: + title: Timeout + description: Timeout after which Policy execution is terminated. + type: integer + maximum: 2147483647 + minimum: -2147483648 DummyPolicy: type: object properties: @@ -5130,19 +5124,6 @@ definitions: title: Name type: string x-nullable: true - negate: - title: Negate - type: boolean - order: - title: Order - type: integer - maximum: 2147483647 - minimum: -2147483648 - timeout: - title: Timeout - type: integer - maximum: 2147483647 - minimum: -2147483648 result: title: Result type: boolean @@ -5170,19 +5151,6 @@ definitions: title: Name type: string x-nullable: true - negate: - title: Negate - type: boolean - order: - title: Order - type: integer - maximum: 2147483647 - minimum: -2147483648 - timeout: - title: Timeout - type: integer - maximum: 2147483647 - minimum: -2147483648 expression: title: Expression type: string @@ -5199,19 +5167,6 @@ definitions: title: Name type: string x-nullable: true - negate: - title: Negate - type: boolean - order: - title: Order - type: integer - maximum: 2147483647 - minimum: -2147483648 - timeout: - title: Timeout - type: integer - maximum: 2147483647 - minimum: -2147483648 allowed_count: title: Allowed count type: integer @@ -5231,19 +5186,6 @@ definitions: title: Name type: string x-nullable: true - negate: - title: Negate - type: boolean - order: - title: Order - type: integer - maximum: 2147483647 - minimum: -2147483648 - timeout: - title: Timeout - type: integer - maximum: 2147483647 - minimum: -2147483648 amount_uppercase: title: Amount uppercase type: integer @@ -5286,19 +5228,6 @@ definitions: title: Name type: string x-nullable: true - negate: - title: Negate - type: boolean - order: - title: Order - type: integer - maximum: 2147483647 - minimum: -2147483648 - timeout: - title: Timeout - type: integer - maximum: 2147483647 - minimum: -2147483648 days: title: Days type: integer @@ -5319,19 +5248,6 @@ definitions: title: Name type: string x-nullable: true - negate: - title: Negate - type: boolean - order: - title: Order - type: integer - maximum: 2147483647 - minimum: -2147483648 - timeout: - title: Timeout - type: integer - maximum: 2147483647 - minimum: -2147483648 check_ip: title: Check ip type: boolean From 0302a95dd729323881ec7d95c79da5b3b444d818 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Thu, 28 May 2020 22:44:59 +0200 Subject: [PATCH 02/64] Squashed commit of the following: commit fe6bfb162063d65daf509aa2184f9cf700206ce8 Author: Jens Langhammer Date: Thu May 28 22:44:42 2020 +0200 stages/identification: fix wrong link --- .../identification/templates/stages/identification/login.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/passbook/stages/identification/templates/stages/identification/login.html b/passbook/stages/identification/templates/stages/identification/login.html index ee20656fb..d95ebd675 100644 --- a/passbook/stages/identification/templates/stages/identification/login.html +++ b/passbook/stages/identification/templates/stages/identification/login.html @@ -43,7 +43,7 @@ {% if enroll_url %} {% endif %} {% if recovery_url %} From 27728abe999d82cc5ef2e7fc6c7b304258f8c130 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Fri, 29 May 2020 00:45:56 +0200 Subject: [PATCH 03/64] e2e: start implementing e2e tests --- e2e/__init__.py | 0 e2e/docker-compose.yml | 14 +++ e2e/test_enroll_2_step.py | 109 ++++++++++++++++++ e2e/test_login_default.py | 54 +++++++++ .../stages/identification/login.html | 4 +- 5 files changed, 179 insertions(+), 2 deletions(-) create mode 100644 e2e/__init__.py create mode 100644 e2e/docker-compose.yml create mode 100644 e2e/test_enroll_2_step.py create mode 100644 e2e/test_login_default.py diff --git a/e2e/__init__.py b/e2e/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml new file mode 100644 index 000000000..2519ac3fa --- /dev/null +++ b/e2e/docker-compose.yml @@ -0,0 +1,14 @@ +version: '3.7' + +services: + hub: + image: dosel/zalenium + command: start + ports: + - 4444:4444 + environment: + PULL_SELENIUM_IMAGE: 'true' + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - /tmp/videos:/home/seluser/videos + privileged: true diff --git a/e2e/test_enroll_2_step.py b/e2e/test_enroll_2_step.py new file mode 100644 index 000000000..1a05d058b --- /dev/null +++ b/e2e/test_enroll_2_step.py @@ -0,0 +1,109 @@ +"""Test 2-step enroll flow""" +from django.contrib.staticfiles.testing import StaticLiveServerTestCase +from selenium import webdriver +from selenium.webdriver.common.by import By +from selenium.webdriver.common.desired_capabilities import DesiredCapabilities + +from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding +from passbook.policies.expression.models import ExpressionPolicy +from passbook.policies.models import PolicyBinding +from passbook.stages.prompt.models import FieldTypes, Prompt, PromptStage +from passbook.stages.user_login.models import UserLoginStage +from passbook.stages.user_write.models import UserWriteStage + + +class TestEnroll2Step(StaticLiveServerTestCase): + """Test 2-step enroll flow""" + + host = "0.0.0.0" + port = 8001 + + def setUp(self): + self.driver = webdriver.Remote( + command_executor="http://localhost:4444/wd/hub", + desired_capabilities=DesiredCapabilities.CHROME, + ) + self.driver.implicitly_wait(2) + + def tearDown(self): + super().tearDown() + self.driver.quit() + + def test_enroll_2_step(self): + """Test 2-step enroll flow""" + # First stage fields + username_prompt = Prompt.objects.create( + field_key="username", label="Username", order=0, type=FieldTypes.TEXT + ) + password = Prompt.objects.create( + field_key="password", label="Password", order=1, type=FieldTypes.PASSWORD + ) + password_repeat = Prompt.objects.create( + field_key="password_repeat", + label="Password (repeat)", + order=2, + type=FieldTypes.PASSWORD, + ) + + # Second stage fields + name_field = Prompt.objects.create( + field_key="name", label="Name", order=0, type=FieldTypes.TEXT + ) + email = Prompt.objects.create( + field_key="email", label="E-Mail", order=1, type=FieldTypes.EMAIL + ) + + # Stages + first_stage = PromptStage.objects.create(name="prompt-stage-first") + first_stage.fields.set([username_prompt, password, password_repeat]) + first_stage.save() + second_stage = PromptStage.objects.create(name="prompt-stage-second") + second_stage.fields.set([name_field, email]) + second_stage.save() + user_write = UserWriteStage.objects.create(name="enroll-user-write") + user_login = UserLoginStage.objects.create(name="enroll-user-login") + + # Password checking policy + password_policy = ExpressionPolicy.objects.create( + name="policy-enrollment-password-equals", + expression="{{ request.context.password == request.context.password_repeat }}", + ) + PolicyBinding.objects.create( + target=first_stage, policy=password_policy, order=0 + ) + + flow = Flow.objects.create( + name="default-enrollment-flow", + slug="default-enrollment-flow", + designation=FlowDesignation.ENROLLMENT, + ) + + FlowStageBinding.objects.create(flow=flow, stage=first_stage, order=0) + FlowStageBinding.objects.create(flow=flow, stage=second_stage, order=1) + FlowStageBinding.objects.create(flow=flow, stage=user_write, order=2) + FlowStageBinding.objects.create(flow=flow, stage=user_login, order=3) + self.driver.get(f"http://host.docker.internal:{self.port}") + self.driver.find_element(By.CSS_SELECTOR, "[role=enroll]").click() + self.driver.find_element(By.ID, "id_username").send_keys("foo") + self.driver.find_element(By.ID, "id_password").send_keys("pbadmin") + self.driver.find_element(By.ID, "id_password_repeat").send_keys("pbadmin") + self.driver.find_element(By.CSS_SELECTOR, ".pf-c-button").click() + self.driver.find_element(By.ID, "id_name").send_keys("some name") + self.driver.find_element(By.ID, "id_email").send_keys("foo@bar.baz") + self.driver.find_element(By.CSS_SELECTOR, ".pf-c-button").click() + self.driver.find_element(By.LINK_TEXT, "foo").click() + self.assertEqual( + self.driver.find_element(By.XPATH, "//a[contains(@href, '/-/user/')]").text, + "foo", + ) + self.assertEqual( + self.driver.find_element(By.ID, "id_username").get_attribute("value"), "foo" + ) + self.assertEqual( + self.driver.find_element(By.ID, "id_name").get_attribute("value"), + "some name", + ) + self.assertEqual( + self.driver.find_element(By.ID, "id_email").get_attribute("value"), + "foo@bar.baz", + ) diff --git a/e2e/test_login_default.py b/e2e/test_login_default.py new file mode 100644 index 000000000..e21e3ed27 --- /dev/null +++ b/e2e/test_login_default.py @@ -0,0 +1,54 @@ +"""test default login flow""" +import string +from random import SystemRandom + +from django.contrib.staticfiles.testing import StaticLiveServerTestCase +from django.core.management import call_command +from selenium import webdriver +from selenium.webdriver.common.by import By +from selenium.webdriver.common.desired_capabilities import DesiredCapabilities +from selenium.webdriver.common.keys import Keys + +from passbook.core.models import User + + +class TestLogin(StaticLiveServerTestCase): + """test default login flow""" + + host = "0.0.0.0" + port = 8000 + + def setUp(self): + self.driver = webdriver.Remote( + command_executor="http://localhost:4444/wd/hub", + desired_capabilities=DesiredCapabilities.CHROME, + ) + self.driver.implicitly_wait(2) + self.password = "".join( + SystemRandom().choice(string.ascii_uppercase + string.digits) + for _ in range(8) + ) + User.objects.create_superuser( + username="pbadmin", email="admin@example.tld", password=self.password + ) + call_command("migrate", "--fake", "passbook_flows", "0001_initial") + call_command("migrate", "passbook_flows", "0002_default_flows") + + def tearDown(self): + super().tearDown() + self.driver.quit() + + def test_login(self): + """test default login flow""" + self.driver.get( + f"http://host.docker.internal:{self.port}/flows/default-authentication-flow/?next=%2F" + ) + self.driver.find_element(By.ID, "id_uid_field").click() + self.driver.find_element(By.ID, "id_uid_field").send_keys("admin@example.tld") + self.driver.find_element(By.ID, "id_uid_field").send_keys(Keys.ENTER) + self.driver.find_element(By.ID, "id_password").send_keys(self.password) + self.driver.find_element(By.ID, "id_password").send_keys(Keys.ENTER) + self.assertEqual( + self.driver.find_element(By.XPATH, "//a[contains(@href, '/-/user/')]").text, + "pbadmin", + ) diff --git a/passbook/stages/identification/templates/stages/identification/login.html b/passbook/stages/identification/templates/stages/identification/login.html index d95ebd675..95079ae13 100644 --- a/passbook/stages/identification/templates/stages/identification/login.html +++ b/passbook/stages/identification/templates/stages/identification/login.html @@ -43,12 +43,12 @@ {% if enroll_url %} {% endif %} {% if recovery_url %} {% endif %} From fc2eb003ea9a8ef9f81524ecc2ed61c46b665651 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Sun, 7 Jun 2020 19:30:56 +0200 Subject: [PATCH 04/64] e2e: add apply_default_data to load data from migrations after tables have been truncated --- .github/workflows/ci.yml | 17 +++++++++ .github/workflows/release.yml | 1 - .github/workflows/tag.yml | 1 - docker.env.yml | 9 +++++ docs/installation/docker-compose.md | 6 ---- e2e/docker-compose.yml | 18 ++++++++++ e2e/test_enroll_2_step.py | 20 +++++++---- e2e/test_login_default.py | 30 +++++----------- e2e/utils.py | 35 +++++++++++++++++++ .../flows/migrations/0004_source_flows.py | 13 +++---- scripts/coverage.sh | 2 +- 11 files changed, 105 insertions(+), 47 deletions(-) create mode 100644 docker.env.yml create mode 100644 e2e/utils.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dbb0e4d62..25ff1759e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -130,6 +130,23 @@ jobs: - uses: codecov/codecov-action@v1 with: token: ${{ secrets.CODECOV_TOKEN }} + e2e: + needs: + - pylint + - black + - prospector + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v1 + - name: Setup test containers + run: | + cd e2e + docker-compose pull + docker-compose up -d + docker-compose exec passbook pip install -r /app/requirements-dev.txt + - name: Run e2e tests + run: | + docker-compose exec passbook ./manage.py test e2e # Build build-server: needs: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 95d620a74..9b3847b68 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -82,7 +82,6 @@ jobs: - uses: actions/checkout@v1 - name: Run test suite in final docker images run: | - export PASSBOOK_DOMAIN=localhost docker-compose pull docker-compose up --no-start docker-compose start postgresql redis diff --git a/.github/workflows/tag.yml b/.github/workflows/tag.yml index a65546dfb..a352ef5b6 100644 --- a/.github/workflows/tag.yml +++ b/.github/workflows/tag.yml @@ -13,7 +13,6 @@ jobs: - uses: actions/checkout@master - name: Pre-release test run: | - export PASSBOOK_DOMAIN=localhost docker-compose pull docker build \ --no-cache \ diff --git a/docker.env.yml b/docker.env.yml new file mode 100644 index 000000000..f8961f7ef --- /dev/null +++ b/docker.env.yml @@ -0,0 +1,9 @@ +debug: true +postgresql: + user: postgres + host: postgresql + +redis: + host: redis + +log_level: debug diff --git a/docs/installation/docker-compose.md b/docs/installation/docker-compose.md index 76b4a6a27..4b34428ce 100644 --- a/docs/installation/docker-compose.md +++ b/docs/installation/docker-compose.md @@ -11,12 +11,6 @@ This installation Method is for test-setups and small-scale productive setups. Download the latest `docker-compose.yml` from [here](https://raw.githubusercontent.com/BeryJu/passbook/master/docker-compose.yml). Place it in a directory of your choice. -passbook needs to know it's primary URL to create links in E-Mails and set cookies, so you have to run the following command: - -``` -export PASSBOOK_DOMAIN=domain.tld # this can be any domain or IP, it just needs to point to passbook. -``` - The compose file references the current latest version, which can be overridden with the `SERVER_TAG` Environment variable. If you plan to use this setup for production, it is also advised to change the PostgreSQL Password by setting `PG_PASS` to a password of your choice. diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml index 2519ac3fa..6158270e1 100644 --- a/e2e/docker-compose.yml +++ b/e2e/docker-compose.yml @@ -12,3 +12,21 @@ services: - /var/run/docker.sock:/var/run/docker.sock - /tmp/videos:/home/seluser/videos privileged: true + postgresql: + image: postgres:11 + restart: always + environment: + POSTGRES_HOST_AUTH_METHOD: trust + POSTGRES_DB: passbook + redis: + image: redis + restart: always + passbook: + image: beryju/passbook + command: /bin/bash -c "sleep infinity" + volumes: + - ../:/testing + environment: + PASSBOOK_ENV: docker + user: root + working_dir: /testing diff --git a/e2e/test_enroll_2_step.py b/e2e/test_enroll_2_step.py index 1a05d058b..ad64cc97b 100644 --- a/e2e/test_enroll_2_step.py +++ b/e2e/test_enroll_2_step.py @@ -10,20 +10,21 @@ from passbook.policies.models import PolicyBinding from passbook.stages.prompt.models import FieldTypes, Prompt, PromptStage from passbook.stages.user_login.models import UserLoginStage from passbook.stages.user_write.models import UserWriteStage - +from passbook.stages.identification.models import IdentificationStage +from e2e.utils import apply_default_data class TestEnroll2Step(StaticLiveServerTestCase): """Test 2-step enroll flow""" - host = "0.0.0.0" - port = 8001 + host = "passbook" def setUp(self): self.driver = webdriver.Remote( - command_executor="http://localhost:4444/wd/hub", + command_executor="http://hub:4444/wd/hub", desired_capabilities=DesiredCapabilities.CHROME, ) - self.driver.implicitly_wait(2) + self.driver.implicitly_wait(5) + apply_default_data() def tearDown(self): super().tearDown() @@ -66,7 +67,7 @@ class TestEnroll2Step(StaticLiveServerTestCase): # Password checking policy password_policy = ExpressionPolicy.objects.create( name="policy-enrollment-password-equals", - expression="{{ request.context.password == request.context.password_repeat }}", + expression="return request.context['password'] == request.context['password_repeat']", ) PolicyBinding.objects.create( target=first_stage, policy=password_policy, order=0 @@ -78,11 +79,16 @@ class TestEnroll2Step(StaticLiveServerTestCase): designation=FlowDesignation.ENROLLMENT, ) + # Attach enrollment flow to identification stage + ident_stage: IdentificationStage = IdentificationStage.objects.first() + ident_stage.enrollment_flow = flow + ident_stage.save() + FlowStageBinding.objects.create(flow=flow, stage=first_stage, order=0) FlowStageBinding.objects.create(flow=flow, stage=second_stage, order=1) FlowStageBinding.objects.create(flow=flow, stage=user_write, order=2) FlowStageBinding.objects.create(flow=flow, stage=user_login, order=3) - self.driver.get(f"http://host.docker.internal:{self.port}") + self.driver.get(self.live_server_url) self.driver.find_element(By.CSS_SELECTOR, "[role=enroll]").click() self.driver.find_element(By.ID, "id_username").send_keys("foo") self.driver.find_element(By.ID, "id_password").send_keys("pbadmin") diff --git a/e2e/test_login_default.py b/e2e/test_login_default.py index e21e3ed27..d989ff907 100644 --- a/e2e/test_login_default.py +++ b/e2e/test_login_default.py @@ -1,38 +1,24 @@ """test default login flow""" -import string -from random import SystemRandom - from django.contrib.staticfiles.testing import StaticLiveServerTestCase -from django.core.management import call_command from selenium import webdriver from selenium.webdriver.common.by import By from selenium.webdriver.common.desired_capabilities import DesiredCapabilities from selenium.webdriver.common.keys import Keys - -from passbook.core.models import User +from e2e.utils import apply_default_data class TestLogin(StaticLiveServerTestCase): """test default login flow""" - host = "0.0.0.0" - port = 8000 + host = "passbook" def setUp(self): self.driver = webdriver.Remote( - command_executor="http://localhost:4444/wd/hub", + command_executor="http://hub:4444/wd/hub", desired_capabilities=DesiredCapabilities.CHROME, ) - self.driver.implicitly_wait(2) - self.password = "".join( - SystemRandom().choice(string.ascii_uppercase + string.digits) - for _ in range(8) - ) - User.objects.create_superuser( - username="pbadmin", email="admin@example.tld", password=self.password - ) - call_command("migrate", "--fake", "passbook_flows", "0001_initial") - call_command("migrate", "passbook_flows", "0002_default_flows") + self.driver.implicitly_wait(5) + apply_default_data() def tearDown(self): super().tearDown() @@ -41,12 +27,12 @@ class TestLogin(StaticLiveServerTestCase): def test_login(self): """test default login flow""" self.driver.get( - f"http://host.docker.internal:{self.port}/flows/default-authentication-flow/?next=%2F" + f"{self.live_server_url}/flows/default-authentication-flow/" ) self.driver.find_element(By.ID, "id_uid_field").click() - self.driver.find_element(By.ID, "id_uid_field").send_keys("admin@example.tld") + self.driver.find_element(By.ID, "id_uid_field").send_keys("pbadmin") self.driver.find_element(By.ID, "id_uid_field").send_keys(Keys.ENTER) - self.driver.find_element(By.ID, "id_password").send_keys(self.password) + self.driver.find_element(By.ID, "id_password").send_keys("pbadmin") self.driver.find_element(By.ID, "id_password").send_keys(Keys.ENTER) self.assertEqual( self.driver.find_element(By.XPATH, "//a[contains(@href, '/-/user/')]").text, diff --git a/e2e/utils.py b/e2e/utils.py new file mode 100644 index 000000000..880d85de9 --- /dev/null +++ b/e2e/utils.py @@ -0,0 +1,35 @@ +"""passbook e2e testing utilities""" + +from glob import glob +from inspect import getmembers, isfunction +from importlib.util import spec_from_file_location, module_from_spec +from django.apps import apps +from django.db import connection, transaction +from django.db.utils import IntegrityError + +def apply_default_data(): + """apply objects created by migrations after tables have been truncated""" + # Find all migration files + # load all functions + migration_files = glob("**/migrations/*.py", recursive=True) + matches = [] + for migration in migration_files: + with open(migration, 'r+') as migration_file: + # Check if they have a `RunPython` + if "RunPython" in migration_file.read(): + matches.append(migration) + + with connection.schema_editor() as schema_editor: + for match in matches: + # Load module from file path + spec = spec_from_file_location("", match) + migration_module = module_from_spec(spec) + # pyright: reportGeneralTypeIssues=false + spec.loader.exec_module(migration_module) + # Call all functions from module + for _, func in getmembers(migration_module, isfunction): + with transaction.atomic(): + try: + func(apps, schema_editor) + except IntegrityError: + pass diff --git a/passbook/flows/migrations/0004_source_flows.py b/passbook/flows/migrations/0004_source_flows.py index 97db472d6..112ff34a3 100644 --- a/passbook/flows/migrations/0004_source_flows.py +++ b/passbook/flows/migrations/0004_source_flows.py @@ -7,15 +7,10 @@ from django.db.backends.base.schema import BaseDatabaseSchemaEditor from passbook.flows.models import FlowDesignation from passbook.stages.prompt.models import FieldTypes -FLOW_POLICY_EXPRESSION = """{{ pb_is_sso_flow }}""" - -PROMPT_POLICY_EXPRESSION = """ -{% if pb_flow_plan.context.prompt_data.username %} -False -{% else %} -True -{% endif %} -""" +FLOW_POLICY_EXPRESSION = """return pb_is_sso_flow""" +PROMPT_POLICY_EXPRESSION = ( + """return 'username' in pb_flow_plan.context['prompt_data']""" +) def create_default_source_enrollment_flow( diff --git a/scripts/coverage.sh b/scripts/coverage.sh index c95d47574..eee3c35b6 100755 --- a/scripts/coverage.sh +++ b/scripts/coverage.sh @@ -1,5 +1,5 @@ #!/bin/bash -xe -coverage run --concurrency=multiprocessing manage.py test --failfast +coverage run --concurrency=multiprocessing manage.py test passbook --failfast coverage combine coverage html coverage report From 16c6e298013d19fc6821cb2d6c844dc6b2a65fa3 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Sun, 7 Jun 2020 19:44:28 +0200 Subject: [PATCH 05/64] root: add missing selenium --- Pipfile | 1 + Pipfile.lock | 169 +++++++++++++++++++++++++++++++++++++++++---------- e2e/utils.py | 1 + 3 files changed, 139 insertions(+), 32 deletions(-) diff --git a/Pipfile b/Pipfile index 5a6c688d9..69ed42c56 100644 --- a/Pipfile +++ b/Pipfile @@ -55,6 +55,7 @@ pylint = "*" pylint-django = "*" unittest-xml-reporting = "*" black = "*" +selenium = "*" [pipenv] allow_prereleases = true diff --git a/Pipfile.lock b/Pipfile.lock index f09bc6204..e46301756 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "c9232d99b062ee14eda43d176481f16da78ce53f912c844db3f33f1b3e8a47b7" + "sha256": "1243af11f030c4bb870928e3a36f9dcce7a170ce9c56689031ebe768de3ed3fa" }, "pipfile-spec": 6, "requires": { @@ -61,10 +61,10 @@ }, "botocore": { "hashes": [ - "sha256:5831068c9b49b4c91b0733e0ec784a7733d8732359d73c67a07a0b0868433cae", - "sha256:7778957bdc9a25dd33bb4383ebd6d45a8570a2cbff03d1edf430fdacec2b7437" + "sha256:17bc71415186efb86a25dd674f78064cdd85139485967d5a0741c7b83d62cf5b", + "sha256:e44b11b1c47c06b0f6524b0ff1fa1cae5ddea4eb06f359e4a9730e8e881b397a" ], - "version": "==1.16.23" + "version": "==1.16.24" }, "celery": { "hashes": [ @@ -76,10 +76,10 @@ }, "certifi": { "hashes": [ - "sha256:1d987a998c75633c40847cc966fcf5904906c920a7f17ef374f5aa4282abd304", - "sha256:51fcb31174be6e6664c5f69e3e1691a2d72a1a12e90f872cbdb1567eb47b6519" + "sha256:5ad7e9a056d25ffa5082862e36f119f7f7cec6457fa07ee2f8c339814b80c9b1", + "sha256:9cd41137dc19af6a5e03b630eefe7d1f458d964d406342dd3edf625839b944cc" ], - "version": "==2020.4.5.1" + "version": "==2020.4.5.2" }, "cffi": { "hashes": [ @@ -328,10 +328,10 @@ }, "inflection": { "hashes": [ - "sha256:32a5c3341d9583ec319548b9015b7fbdf8c429cbcb575d326c33ae3a0e90d52c", - "sha256:9a15d3598f01220e93f2207c432cfede50daff53137ce660fb8be838ef1ca6cc" + "sha256:88b101b2668a1d81d6d72d4c2018e53bc6c7fc544c987849da1c7f77545c3bc9", + "sha256:f576e85132d34f5bf7df5183c2c6f94cfb32e528f53065345cf71329ba0b8924" ], - "version": "==0.4.0" + "version": "==0.5.0" }, "itypes": { "hashes": [ @@ -893,6 +893,46 @@ "index": "pypi", "version": "==0.6.0" }, + "certifi": { + "hashes": [ + "sha256:5ad7e9a056d25ffa5082862e36f119f7f7cec6457fa07ee2f8c339814b80c9b1", + "sha256:9cd41137dc19af6a5e03b630eefe7d1f458d964d406342dd3edf625839b944cc" + ], + "version": "==2020.4.5.2" + }, + "cffi": { + "hashes": [ + "sha256:001bf3242a1bb04d985d63e138230802c6c8d4db3668fb545fb5005ddf5bb5ff", + "sha256:00789914be39dffba161cfc5be31b55775de5ba2235fe49aa28c148236c4e06b", + "sha256:028a579fc9aed3af38f4892bdcc7390508adabc30c6af4a6e4f611b0c680e6ac", + "sha256:14491a910663bf9f13ddf2bc8f60562d6bc5315c1f09c704937ef17293fb85b0", + "sha256:1cae98a7054b5c9391eb3249b86e0e99ab1e02bb0cc0575da191aedadbdf4384", + "sha256:2089ed025da3919d2e75a4d963d008330c96751127dd6f73c8dc0c65041b4c26", + "sha256:2d384f4a127a15ba701207f7639d94106693b6cd64173d6c8988e2c25f3ac2b6", + "sha256:337d448e5a725bba2d8293c48d9353fc68d0e9e4088d62a9571def317797522b", + "sha256:399aed636c7d3749bbed55bc907c3288cb43c65c4389964ad5ff849b6370603e", + "sha256:3b911c2dbd4f423b4c4fcca138cadde747abdb20d196c4a48708b8a2d32b16dd", + "sha256:3d311bcc4a41408cf5854f06ef2c5cab88f9fded37a3b95936c9879c1640d4c2", + "sha256:62ae9af2d069ea2698bf536dcfe1e4eed9090211dbaafeeedf5cb6c41b352f66", + "sha256:66e41db66b47d0d8672d8ed2708ba91b2f2524ece3dee48b5dfb36be8c2f21dc", + "sha256:675686925a9fb403edba0114db74e741d8181683dcf216be697d208857e04ca8", + "sha256:7e63cbcf2429a8dbfe48dcc2322d5f2220b77b2e17b7ba023d6166d84655da55", + "sha256:8a6c688fefb4e1cd56feb6c511984a6c4f7ec7d2a1ff31a10254f3c817054ae4", + "sha256:8c0ffc886aea5df6a1762d0019e9cb05f825d0eec1f520c51be9d198701daee5", + "sha256:95cd16d3dee553f882540c1ffe331d085c9e629499ceadfbda4d4fde635f4b7d", + "sha256:99f748a7e71ff382613b4e1acc0ac83bf7ad167fb3802e35e90d9763daba4d78", + "sha256:b8c78301cefcf5fd914aad35d3c04c2b21ce8629b5e4f4e45ae6812e461910fa", + "sha256:c420917b188a5582a56d8b93bdd8e0f6eca08c84ff623a4c16e809152cd35793", + "sha256:c43866529f2f06fe0edc6246eb4faa34f03fe88b64a0a9a942561c8e22f4b71f", + "sha256:cab50b8c2250b46fe738c77dbd25ce017d5e6fb35d3407606e7a4180656a5a6a", + "sha256:cef128cb4d5e0b3493f058f10ce32365972c554572ff821e175dbc6f8ff6924f", + "sha256:cf16e3cf6c0a5fdd9bc10c21687e19d29ad1fe863372b5543deaec1039581a30", + "sha256:e56c744aa6ff427a607763346e4170629caf7e48ead6921745986db3692f987f", + "sha256:e577934fc5f8779c554639376beeaa5657d54349096ef24abe8c74c5d9c117c3", + "sha256:f2b0fa0c01d8a0c7483afd9f31d7ecf2d71760ca24499c8697aeb5ca37dc090c" + ], + "version": "==1.14.0" + }, "click": { "hashes": [ "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a", @@ -945,6 +985,30 @@ "index": "pypi", "version": "==5.1" }, + "cryptography": { + "hashes": [ + "sha256:091d31c42f444c6f519485ed528d8b451d1a0c7bf30e8ca583a0cac44b8a0df6", + "sha256:18452582a3c85b96014b45686af264563e3e5d99d226589f057ace56196ec78b", + "sha256:1dfa985f62b137909496e7fc182dac687206d8d089dd03eaeb28ae16eec8e7d5", + "sha256:1e4014639d3d73fbc5ceff206049c5a9a849cefd106a49fa7aaaa25cc0ce35cf", + "sha256:22e91636a51170df0ae4dcbd250d318fd28c9f491c4e50b625a49964b24fe46e", + "sha256:3b3eba865ea2754738616f87292b7f29448aec342a7c720956f8083d252bf28b", + "sha256:651448cd2e3a6bc2bb76c3663785133c40d5e1a8c1a9c5429e4354201c6024ae", + "sha256:726086c17f94747cedbee6efa77e99ae170caebeb1116353c6cf0ab67ea6829b", + "sha256:844a76bc04472e5135b909da6aed84360f522ff5dfa47f93e3dd2a0b84a89fa0", + "sha256:88c881dd5a147e08d1bdcf2315c04972381d026cdb803325c03fe2b4a8ed858b", + "sha256:96c080ae7118c10fcbe6229ab43eb8b090fccd31a09ef55f83f690d1ef619a1d", + "sha256:a0c30272fb4ddda5f5ffc1089d7405b7a71b0b0f51993cb4e5dbb4590b2fc229", + "sha256:bb1f0281887d89617b4c68e8db9a2c42b9efebf2702a3c5bf70599421a8623e3", + "sha256:c447cf087cf2dbddc1add6987bbe2f767ed5317adb2d08af940db517dd704365", + "sha256:c4fd17d92e9d55b84707f4fd09992081ba872d1a0c610c109c18e062e06a2e55", + "sha256:d0d5aeaedd29be304848f1c5059074a740fa9f6f26b84c5b63e8b29e73dfc270", + "sha256:daf54a4b07d67ad437ff239c8a4080cfd1cc7213df57d33c97de7b4738048d5e", + "sha256:e993468c859d084d5579e2ebee101de8f5a27ce8e2159959b6673b418fd8c785", + "sha256:f118a95c7480f5be0df8afeb9a11bd199aa20afab7a96bcf20409b411a3a85f0" + ], + "version": "==2.9.2" + }, "django": { "hashes": [ "sha256:5052b34b34b3425233c682e0e11d658fd6efd587d11335a0203d827224ada8f2", @@ -975,6 +1039,13 @@ ], "version": "==3.1.3" }, + "idna": { + "hashes": [ + "sha256:7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb", + "sha256:a068a21ceac8a4d63dbfd964670474107f541babbd2250d61922f029858365fa" + ], + "version": "==2.9" + }, "isort": { "hashes": [ "sha256:54da7e92468955c4fceacd0c86bd0ec997b0e1ee80d97f67c35a78b719dccab1", @@ -1036,6 +1107,13 @@ ], "version": "==2.6.0" }, + "pycparser": { + "hashes": [ + "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0", + "sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705" + ], + "version": "==2.20" + }, "pylint": { "hashes": [ "sha256:b95e31850f3af163c2283ed40432f053acbc8fc6eba6a069cb518d9dbf71848c", @@ -1059,6 +1137,13 @@ ], "version": "==0.6" }, + "pyopenssl": { + "hashes": [ + "sha256:621880965a720b8ece2f1b2f54ea2071966ab00e2970ad2ce11d596102063504", + "sha256:9a24494b2602aaf402be5c9e30a0b82d4a5c67528fe8fb475e3f3bc00dd69507" + ], + "version": "==19.1.0" + }, "pytz": { "hashes": [ "sha256:a494d53b6d39c3c6e44c3bec237336e14305e4f29bbf800b599253057fbb79ed", @@ -1085,29 +1170,37 @@ }, "regex": { "hashes": [ - "sha256:1386e75c9d1574f6aa2e4eb5355374c8e55f9aac97e224a8a5a6abded0f9c927", - "sha256:27ff7325b297fb6e5ebb70d10437592433601c423f5acf86e5bc1ee2919b9561", - "sha256:329ba35d711e3428db6b45a53b1b13a0a8ba07cbbcf10bbed291a7da45f106c3", - "sha256:3a9394197664e35566242686d84dfd264c07b20f93514e2e09d3c2b3ffdf78fe", - "sha256:51f17abbe973c7673a61863516bdc9c0ef467407a940f39501e786a07406699c", - "sha256:579ea215c81d18da550b62ff97ee187b99f1b135fd894a13451e00986a080cad", - "sha256:70c14743320a68c5dac7fc5a0f685be63bc2024b062fe2aaccc4acc3d01b14a1", - "sha256:7e61be8a2900897803c293247ef87366d5df86bf701083b6c43119c7c6c99108", - "sha256:8044d1c085d49673aadb3d7dc20ef5cb5b030c7a4fa253a593dda2eab3059929", - "sha256:89d76ce33d3266173f5be80bd4efcbd5196cafc34100fdab814f9b228dee0fa4", - "sha256:99568f00f7bf820c620f01721485cad230f3fb28f57d8fbf4a7967ec2e446994", - "sha256:a7c37f048ec3920783abab99f8f4036561a174f1314302ccfa4e9ad31cb00eb4", - "sha256:c2062c7d470751b648f1cacc3f54460aebfc261285f14bc6da49c6943bd48bdd", - "sha256:c9bce6e006fbe771a02bda468ec40ffccbf954803b470a0345ad39c603402577", - "sha256:ce367d21f33e23a84fb83a641b3834dd7dd8e9318ad8ff677fbfae5915a239f7", - "sha256:ce450ffbfec93821ab1fea94779a8440e10cf63819be6e176eb1973a6017aff5", - "sha256:ce5cc53aa9fbbf6712e92c7cf268274eaff30f6bd12a0754e8133d85a8fb0f5f", - "sha256:d466967ac8e45244b9dfe302bbe5e3337f8dc4dec8d7d10f5e950d83b140d33a", - "sha256:d881c2e657c51d89f02ae4c21d9adbef76b8325fe4d5cf0e9ad62f850f3a98fd", - "sha256:e565569fc28e3ba3e475ec344d87ed3cd8ba2d575335359749298a0899fe122e", - "sha256:ea55b80eb0d1c3f1d8d784264a6764f931e172480a2f1868f2536444c5f01e01" + "sha256:150125da109fccdcc8fec3b0b386b2a5d6ca7cff076f8b622486d1ca868b0c10", + "sha256:163bc0805e46acfa098dfc8c0b07f371577d505f603e48afc425ff475cdac3a5", + "sha256:20c513893ff80bdbe4b4ce11ea2e93d49481f05b270595d82af69ffc402010a6", + "sha256:21fc17cb868c4264f0813f992f46f9ae6fc8c309d4741091de4153bd1f6a6176", + "sha256:2c928bc8e0c453d73dffa3193a6e37ee752ea36df0dd4601e21024d98274dfad", + "sha256:2d9beca70e36f9c60d679e108c5fe49f3d4da79d13a13f91e5e759443bd954f9", + "sha256:5735f26cacdb50b3d6d35ebf8fdeb504bd8b381e2d079d2d9f12ce534fc14ecd", + "sha256:6edc5c190248d3b612f2cca45448cf8ebc3621d41afcd1c5708853cbb1dbb3b3", + "sha256:7606dba82435429641efe4fbc580574942f89cf2b9c5c1f8bc1eab2bacbf7e8b", + "sha256:8d1ee3796795e609ef7a3a5a35eaf4728038d986aa12c06b3fd1b92ee81911f4", + "sha256:8d9bb2d90e23c51aacbc58c1a11320f49b335cd67a91986cdbebcc3e843e4de8", + "sha256:97d414c41f19fd2362e493810caa8445c05e0a2d63a14081c972aad66284a8d2", + "sha256:9e37502817225ee99d91d8418f5119e98c380b00e772d06915690c05290f32ee", + "sha256:af7209b2fcc79ee2b0ad4ea080d70bb748450ec4f282cc9e864861e469b1072e", + "sha256:c0849b0864ff451f04c8afb5fc28e9ed592262e03debdd227cf0f53e04a55dcd", + "sha256:c4ac9215650688e78dea29b46adbdafb7b85058eebe92ef6ea848e14466c915f", + "sha256:dcda6d4e1bbfc939b177c237aee41c9678eaaf71df482688f8986e8251e12345", + "sha256:dd8501b8d9ea1aba53c4bc7d47bc72933f9b4213d534cf400f16c1431f51c8ba", + "sha256:ec0e509ed1877ff1cbc6f0864689bb60384a303502c4d72d9a635f8a4676fd3f", + "sha256:f6c8c3f56fef719180464855346e6e80971b86dfd9e5a0e356664b5baca53072", + "sha256:ffd4f80602490a309064cf2b203e220d581c51660e01055c64bf5da450485ee6" ], - "version": "==2020.5.14" + "version": "==2020.6.7" + }, + "selenium": { + "hashes": [ + "sha256:5f5489a0c5fe2f09cc6bc3f32a0d53441ab36882c987269f2afe805979633ac1", + "sha256:a9779ddc69cf03b75d94062c5e948f763919cf3341c77272f94cd05e6b4c7b32" + ], + "index": "pypi", + "version": "==4.0.0a6.post2" }, "six": { "hashes": [ @@ -1178,6 +1271,18 @@ "index": "pypi", "version": "==3.0.2" }, + "urllib3": { + "extras": [ + "secure" + ], + "hashes": [ + "sha256:3018294ebefce6572a474f0604c2021e33b3fd8006ecd11d62107a5d2a963527", + "sha256:88206b0eb87e6d677d424843ac5209e3fb9d0190d0ee169599165ec25e9d9115" + ], + "index": "pypi", + "markers": null, + "version": "==1.25.9" + }, "wrapt": { "hashes": [ "sha256:b62ffa81fb85f4332a4f609cab4ac40709470da05643a082ec1eb88e6d9b97d7" diff --git a/e2e/utils.py b/e2e/utils.py index 880d85de9..d10aaa5b2 100644 --- a/e2e/utils.py +++ b/e2e/utils.py @@ -7,6 +7,7 @@ from django.apps import apps from django.db import connection, transaction from django.db.utils import IntegrityError + def apply_default_data(): """apply objects created by migrations after tables have been truncated""" # Find all migration files From 2291ae98c301aacd4f219430e8a16e75bbd46c9e Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Sun, 7 Jun 2020 19:50:01 +0200 Subject: [PATCH 06/64] e2e: fix lint error --- e2e/test_enroll_2_step.py | 1 + 1 file changed, 1 insertion(+) diff --git a/e2e/test_enroll_2_step.py b/e2e/test_enroll_2_step.py index ad64cc97b..3a99fe1f8 100644 --- a/e2e/test_enroll_2_step.py +++ b/e2e/test_enroll_2_step.py @@ -13,6 +13,7 @@ from passbook.stages.user_write.models import UserWriteStage from passbook.stages.identification.models import IdentificationStage from e2e.utils import apply_default_data + class TestEnroll2Step(StaticLiveServerTestCase): """Test 2-step enroll flow""" From 0ca7579d194043b8f775abcfb964e2f35248dbd4 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Sun, 7 Jun 2020 19:57:01 +0200 Subject: [PATCH 07/64] ci: attempt to fix e2e not a tty error --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 25ff1759e..0f01592ed 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -143,10 +143,10 @@ jobs: cd e2e docker-compose pull docker-compose up -d - docker-compose exec passbook pip install -r /app/requirements-dev.txt + docker-compose exec passbook bash -c "pip install -r /app/requirements-dev.txt" - name: Run e2e tests run: | - docker-compose exec passbook ./manage.py test e2e + docker-compose exec passbook bash -c "./manage.py test e2e" # Build build-server: needs: From b1b3a23d1e11b8f513b3ea3c0027040c0fb82bdf Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Mon, 8 Jun 2020 10:21:22 +0200 Subject: [PATCH 08/64] ci: docker-compose without TTY --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0f01592ed..988fc705c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -143,10 +143,10 @@ jobs: cd e2e docker-compose pull docker-compose up -d - docker-compose exec passbook bash -c "pip install -r /app/requirements-dev.txt" + docker-compose exec -T passbook bash -c "pip install -r /app/requirements-dev.txt" - name: Run e2e tests run: | - docker-compose exec passbook bash -c "./manage.py test e2e" + docker-compose exec -T passbook bash -c "./manage.py test e2e" # Build build-server: needs: From 56198e503b50c552dd627a4c983cb0f623f5ad1f Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Mon, 8 Jun 2020 10:50:31 +0200 Subject: [PATCH 09/64] ci: Run e2e tests in one stage --- .github/workflows/ci.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 988fc705c..65beba0da 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -138,14 +138,12 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v1 - - name: Setup test containers + - name: Setup test containers and run tests run: | cd e2e docker-compose pull docker-compose up -d docker-compose exec -T passbook bash -c "pip install -r /app/requirements-dev.txt" - - name: Run e2e tests - run: | docker-compose exec -T passbook bash -c "./manage.py test e2e" # Build build-server: From 8859806d64f493ad66b86857c0625164bb13bc0a Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Mon, 8 Jun 2020 10:57:48 +0200 Subject: [PATCH 10/64] ci: fix missing selenium --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 65beba0da..369b3d679 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -144,6 +144,8 @@ jobs: docker-compose pull docker-compose up -d docker-compose exec -T passbook bash -c "pip install -r /app/requirements-dev.txt" + # This is temporary as we don't have selenium as a dependency yet + docker-compose exec -T passbook bash -c "pip install selenium" docker-compose exec -T passbook bash -c "./manage.py test e2e" # Build build-server: From 3b6e414d0fbedd4d5f9eeb69828941355b304269 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Mon, 8 Jun 2020 10:58:44 +0200 Subject: [PATCH 11/64] ci: use docker-compose pull -q --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/tag.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 369b3d679..41a2c77e0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -141,7 +141,7 @@ jobs: - name: Setup test containers and run tests run: | cd e2e - docker-compose pull + docker-compose pull -q docker-compose up -d docker-compose exec -T passbook bash -c "pip install -r /app/requirements-dev.txt" # This is temporary as we don't have selenium as a dependency yet diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9b3847b68..63d80ac51 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -82,7 +82,7 @@ jobs: - uses: actions/checkout@v1 - name: Run test suite in final docker images run: | - docker-compose pull + docker-compose pull -q docker-compose up --no-start docker-compose start postgresql redis docker-compose run -u root server bash -c "pip install --no-cache -r requirements-dev.txt && ./manage.py test" diff --git a/.github/workflows/tag.yml b/.github/workflows/tag.yml index a352ef5b6..8497e400e 100644 --- a/.github/workflows/tag.yml +++ b/.github/workflows/tag.yml @@ -13,7 +13,7 @@ jobs: - uses: actions/checkout@master - name: Pre-release test run: | - docker-compose pull + docker-compose pull -q docker build \ --no-cache \ -t beryju/passbook:latest \ From a4a7ecd4931fc089516f909a9cafb79b95646ae2 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Mon, 8 Jun 2020 11:21:14 +0200 Subject: [PATCH 12/64] e2e: use normal selenium grid --- e2e/docker-compose.yml | 35 +++++++++++++++++++++++++---------- 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml index 6158270e1..504b85b56 100644 --- a/e2e/docker-compose.yml +++ b/e2e/docker-compose.yml @@ -1,17 +1,32 @@ version: '3.7' services: - hub: - image: dosel/zalenium - command: start - ports: - - 4444:4444 - environment: - PULL_SELENIUM_IMAGE: 'true' + # hub: + # image: dosel/zalenium + # command: start + # ports: + # - 4444:4444 + # environment: + # PULL_SELENIUM_IMAGE: 'true' + # volumes: + # - /var/run/docker.sock:/var/run/docker.sock + # - /tmp/videos:/home/seluser/videos + # privileged: true + chrome: + image: selenium/node-chrome:3.14.0-gallium volumes: - - /var/run/docker.sock:/var/run/docker.sock - - /tmp/videos:/home/seluser/videos - privileged: true + - /dev/shm:/dev/shm + depends_on: + - hub + environment: + HUB_HOST: hub + + hub: + image: selenium/hub:3.14.0-gallium + ports: + - "4444:4444" + + postgresql: image: postgres:11 restart: always From 0963b68f4ea1c1d7eeb54940d15d525476ef4bb5 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Mon, 8 Jun 2020 11:23:18 +0200 Subject: [PATCH 13/64] e2e: use separate network --- e2e/docker-compose.yml | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml index 504b85b56..9a8d71f93 100644 --- a/e2e/docker-compose.yml +++ b/e2e/docker-compose.yml @@ -20,12 +20,15 @@ services: - hub environment: HUB_HOST: hub + networks: + - e2e hub: image: selenium/hub:3.14.0-gallium ports: - "4444:4444" - + networks: + - e2e postgresql: image: postgres:11 @@ -33,9 +36,13 @@ services: environment: POSTGRES_HOST_AUTH_METHOD: trust POSTGRES_DB: passbook + networks: + - e2e redis: image: redis restart: always + networks: + - e2e passbook: image: beryju/passbook command: /bin/bash -c "sleep infinity" @@ -45,3 +52,8 @@ services: PASSBOOK_ENV: docker user: root working_dir: /testing + networks: + - e2e + +networks: + e2e: From 8c6a4a4968d31cfc0c3c48400d7adebbd67332fb Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Fri, 19 Jun 2020 18:19:20 +0200 Subject: [PATCH 14/64] e2e: test against standalone chrome instance, start implementing oidc provider test --- e2e/docker-compose.yml | 47 ++----------------- e2e/requirements.txt | 2 + e2e/setup.sh | 20 ++++++++ e2e/test_enroll_2_step.py | 4 +- e2e/test_login_default.py | 4 +- passbook/policies/expression/evaluator.py | 2 +- passbook/providers/oidc/views.py | 3 +- .../stages/email/waiting_message.html | 2 +- 8 files changed, 32 insertions(+), 52 deletions(-) create mode 100644 e2e/requirements.txt create mode 100755 e2e/setup.sh diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml index 9a8d71f93..1786d5c5e 100644 --- a/e2e/docker-compose.yml +++ b/e2e/docker-compose.yml @@ -1,34 +1,11 @@ version: '3.7' services: - # hub: - # image: dosel/zalenium - # command: start - # ports: - # - 4444:4444 - # environment: - # PULL_SELENIUM_IMAGE: 'true' - # volumes: - # - /var/run/docker.sock:/var/run/docker.sock - # - /tmp/videos:/home/seluser/videos - # privileged: true chrome: - image: selenium/node-chrome:3.14.0-gallium + image: selenium/standalone-chrome-debug:3.141.59-20200525 volumes: - /dev/shm:/dev/shm - depends_on: - - hub - environment: - HUB_HOST: hub - networks: - - e2e - - hub: - image: selenium/hub:3.14.0-gallium - ports: - - "4444:4444" - networks: - - e2e + network_mode: host postgresql: image: postgres:11 @@ -36,24 +13,8 @@ services: environment: POSTGRES_HOST_AUTH_METHOD: trust POSTGRES_DB: passbook - networks: - - e2e + network_mode: host redis: image: redis restart: always - networks: - - e2e - passbook: - image: beryju/passbook - command: /bin/bash -c "sleep infinity" - volumes: - - ../:/testing - environment: - PASSBOOK_ENV: docker - user: root - working_dir: /testing - networks: - - e2e - -networks: - e2e: + network_mode: host diff --git a/e2e/requirements.txt b/e2e/requirements.txt new file mode 100644 index 000000000..c87e8eb53 --- /dev/null +++ b/e2e/requirements.txt @@ -0,0 +1,2 @@ +selenium +docker diff --git a/e2e/setup.sh b/e2e/setup.sh new file mode 100755 index 000000000..7859a76e9 --- /dev/null +++ b/e2e/setup.sh @@ -0,0 +1,20 @@ +#!/bin/bash -x +sudo apt update +sudo apt install -y python3.8 python3-pip apt-transport-https ca-certificates curl gnupg-agent software-properties-common +curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add - +sudo add-apt-repository \ + "deb [arch=amd64] https://download.docker.com/linux/ubuntu \ + $(lsb_release -cs) \ + stable" +sudo apt-get install -y docker-ce docker-ce-cli containerd.io +sudo usermod -a -G docker ubuntu +sudo curl -L "https://github.com/docker/compose/releases/download/1.26.0/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose +sudo chmod +x /usr/local/bin/docker-compose +sudo pip3 install pipenv + +cd e2e +sudo docker-compose up -d +cd .. +pipenv sync --dev +pipenv run pip install -r e2e/requirements.txt +pipenv shell diff --git a/e2e/test_enroll_2_step.py b/e2e/test_enroll_2_step.py index 3a99fe1f8..b8f83cd33 100644 --- a/e2e/test_enroll_2_step.py +++ b/e2e/test_enroll_2_step.py @@ -17,11 +17,9 @@ from e2e.utils import apply_default_data class TestEnroll2Step(StaticLiveServerTestCase): """Test 2-step enroll flow""" - host = "passbook" - def setUp(self): self.driver = webdriver.Remote( - command_executor="http://hub:4444/wd/hub", + command_executor="http://localhost:4444/wd/hub", desired_capabilities=DesiredCapabilities.CHROME, ) self.driver.implicitly_wait(5) diff --git a/e2e/test_login_default.py b/e2e/test_login_default.py index d989ff907..f4db6a5b8 100644 --- a/e2e/test_login_default.py +++ b/e2e/test_login_default.py @@ -10,11 +10,9 @@ from e2e.utils import apply_default_data class TestLogin(StaticLiveServerTestCase): """test default login flow""" - host = "passbook" - def setUp(self): self.driver = webdriver.Remote( - command_executor="http://hub:4444/wd/hub", + command_executor="http://localhost:4444/wd/hub", desired_capabilities=DesiredCapabilities.CHROME, ) self.driver.implicitly_wait(5) diff --git a/passbook/policies/expression/evaluator.py b/passbook/policies/expression/evaluator.py index 825c23ebf..968e29834 100644 --- a/passbook/policies/expression/evaluator.py +++ b/passbook/policies/expression/evaluator.py @@ -22,7 +22,7 @@ class PolicyEvaluator(BaseEvaluator): super().__init__() self._messages = [] self._context["pb_message"] = self.expr_func_message - self._filename = policy_name + self._filename = policy_name or "PolicyEvaluator" def expr_func_message(self, message: str): """Wrapper to append to messages list, which is returned with PolicyResult""" diff --git a/passbook/providers/oidc/views.py b/passbook/providers/oidc/views.py index cd81f9c59..2d9e5bb89 100644 --- a/passbook/providers/oidc/views.py +++ b/passbook/providers/oidc/views.py @@ -1,5 +1,6 @@ """passbook OIDC Views""" from django.contrib import messages +from django.contrib.auth.mixins import LoginRequiredMixin from django.http import HttpRequest, HttpResponse, JsonResponse from django.shortcuts import get_object_or_404, redirect, reverse from django.views import View @@ -28,7 +29,7 @@ LOGGER = get_logger() PLAN_CONTEXT_PARAMS = "params" -class AuthorizationFlowInitView(AccessMixin, View): +class AuthorizationFlowInitView(AccessMixin, LoginRequiredMixin, View): """OIDC Flow initializer, checks access to application and starts flow""" # pylint: disable=unused-argument diff --git a/passbook/stages/email/templates/stages/email/waiting_message.html b/passbook/stages/email/templates/stages/email/waiting_message.html index a1c0a2a64..98f961333 100644 --- a/passbook/stages/email/templates/stages/email/waiting_message.html +++ b/passbook/stages/email/templates/stages/email/waiting_message.html @@ -15,7 +15,7 @@ {% block beneath_form %} {% endblock %}
    - +
    {% endblock %} From 73e715817864a3c27febc7fda0e2f4bd96ea7c4a Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Fri, 19 Jun 2020 19:34:27 +0200 Subject: [PATCH 15/64] e2e: add OIDC Provider test against grafana, more formatting, minor bug fixes --- e2e/passbook.side | 300 ++++++++++++++++++++++++++++++ e2e/test_enroll_2_step.py | 4 +- e2e/test_login_default.py | 5 +- e2e/test_provider_oidc.py | 176 ++++++++++++++++++ e2e/utils.py | 16 +- manage.py | 1 + passbook/flows/planner.py | 6 +- passbook/flows/views.py | 8 +- passbook/providers/oidc/claims.py | 2 +- passbook/providers/oidc/views.py | 2 +- scripts/pre-commit.sh | 6 +- 11 files changed, 512 insertions(+), 14 deletions(-) create mode 100644 e2e/passbook.side create mode 100644 e2e/test_provider_oidc.py diff --git a/e2e/passbook.side b/e2e/passbook.side new file mode 100644 index 000000000..af2abbb3c --- /dev/null +++ b/e2e/passbook.side @@ -0,0 +1,300 @@ +{ + "id": "7d9b2407-1520-4c04-b040-68e8ada9aecc", + "version": "2.0", + "name": "passbook", + "url": "http://localhost:8000", + "tests": [{ + "id": "94b39863-74ec-4b7d-98c5-2b380b6d2c55", + "name": "passbook login simple", + "commands": [{ + "id": "e60e4382-4f96-44c3-ba06-5e18609c9c2b", + "comment": "", + "command": "open", + "target": "/flows/default-authentication-flow/?next=%2F", + "targets": [], + "value": "" + }, { + "id": "b2652f24-931e-45b0-b01d-2f0ac0f74db8", + "comment": "", + "command": "click", + "target": "id=id_uid_field", + "targets": [ + ["id=id_uid_field", "id"], + ["name=uid_field", "name"], + ["css=#id_uid_field", "css:finder"], + ["xpath=//input[@id='id_uid_field']", "xpath:attributes"], + ["xpath=//main[@id='flow-body']/div/form/div/input", "xpath:idRelative"], + ["xpath=//div/input", "xpath:position"] + ], + "value": "" + }, { + "id": "f1930f8a-984a-4076-a925-20937bb2f8d3", + "comment": "", + "command": "type", + "target": "id=id_uid_field", + "targets": [ + ["id=id_uid_field", "id"], + ["name=uid_field", "name"], + ["css=#id_uid_field", "css:finder"], + ["xpath=//input[@id='id_uid_field']", "xpath:attributes"], + ["xpath=//main[@id='flow-body']/div/form/div/input", "xpath:idRelative"], + ["xpath=//div/input", "xpath:position"] + ], + "value": "admin@example.tld" + }, { + "id": "0b568ee3-1bed-4821-a3bc-f6b960dbed9d", + "comment": "", + "command": "sendKeys", + "target": "id=id_uid_field", + "targets": [ + ["id=id_uid_field", "id"], + ["name=uid_field", "name"], + ["css=#id_uid_field", "css:finder"], + ["xpath=//input[@id='id_uid_field']", "xpath:attributes"], + ["xpath=//main[@id='flow-body']/div/form/div/input", "xpath:idRelative"], + ["xpath=//div/input", "xpath:position"] + ], + "value": "${KEY_ENTER}" + }, { + "id": "6d98e479-2825-484d-996a-ccf350d2761f", + "comment": "", + "command": "type", + "target": "id=id_password", + "targets": [ + ["id=id_password", "id"], + ["name=password", "name"], + ["css=#id_password", "css:finder"], + ["xpath=//input[@id='id_password']", "xpath:attributes"], + ["xpath=//main[@id='flow-body']/div/form/div[2]/input", "xpath:idRelative"], + ["xpath=//div[2]/input", "xpath:position"] + ], + "value": "pbadmin" + }, { + "id": "6f7abec6-ff44-4eb5-ae23-520c1c29a706", + "comment": "", + "command": "sendKeys", + "target": "id=id_password", + "targets": [ + ["id=id_password", "id"], + ["name=password", "name"], + ["css=#id_password", "css:finder"], + ["xpath=//input[@id='id_password']", "xpath:attributes"], + ["xpath=//main[@id='flow-body']/div/form/div[2]/input", "xpath:idRelative"], + ["xpath=//div[2]/input", "xpath:position"] + ], + "value": "${KEY_ENTER}" + }, { + "id": "04c5876f-1405-4077-a98b-e911f09113d7", + "comment": "", + "command": "assertText", + "target": "xpath=//a[contains(@href, '/-/user/')]", + "targets": [ + ["linkText=pbadmin", "linkText"], + ["css=.pf-c-page__header-tools-group:nth-child(2) > .pf-c-button", "css:finder"], + ["xpath=//a[contains(text(),'pbadmin')]", "xpath:link"], + ["xpath=//div[@id='page-default-nav-example']/header/div[3]/div[2]/a", "xpath:idRelative"], + ["xpath=//a[contains(@href, '/-/user/')]", "xpath:href"], + ["xpath=//div[2]/a", "xpath:position"], + ["xpath=//a[contains(.,'pbadmin')]", "xpath:innerText"] + ], + "value": "pbadmin" + }] + }, { + "id": "61948b3c-3012-4f97-aa52-bc8f34fec333", + "name": "passbook enroll simple", + "commands": [{ + "id": "0f4884b3-4891-41bc-956d-1fa433e892e9", + "comment": "", + "command": "open", + "target": "/flows/default-authentication-flow/?next=%2F", + "targets": [], + "value": "" + }, { + "id": "84d3861f-a60c-4650-8689-535f82b39577", + "comment": "", + "command": "click", + "target": "linkText=Sign up.", + "targets": [ + ["linkText=Sign up.", "linkText"], + ["css=.pf-c-login__main-footer-band-item > a", "css:finder"], + ["xpath=//a[contains(text(),'Sign up.')]", "xpath:link"], + ["xpath=//main[@id='flow-body']/footer/div/p/a", "xpath:idRelative"], + ["xpath=//a[contains(@href, '/flows/default-enrollment-flow/')]", "xpath:href"], + ["xpath=//a", "xpath:position"], + ["xpath=//a[contains(.,'Sign up.')]", "xpath:innerText"] + ], + "value": "" + }, { + "id": "a32435ca-d84a-41e7-a915-fcbbc5f88341", + "comment": "", + "command": "type", + "target": "id=id_username", + "targets": [ + ["id=id_username", "id"], + ["name=username", "name"], + ["css=#id_username", "css:finder"], + ["xpath=//input[@id='id_username']", "xpath:attributes"], + ["xpath=//main[@id='flow-body']/div/form/div/input", "xpath:idRelative"], + ["xpath=//div/input", "xpath:position"] + ], + "value": "foo" + }, { + "id": "3b5dcf53-8297-46c5-88b7-11c2eb25f34f", + "comment": "", + "command": "type", + "target": "id=id_password", + "targets": [ + ["id=id_password", "id"], + ["name=password", "name"], + ["css=#id_password", "css:finder"], + ["xpath=//input[@id='id_password']", "xpath:attributes"], + ["xpath=//main[@id='flow-body']/div/form/div[2]/input", "xpath:idRelative"], + ["xpath=//div[2]/input", "xpath:position"] + ], + "value": "pbadmin" + }, { + "id": "e948d61c-dae6-4994-b56f-ff130892b342", + "comment": "", + "command": "type", + "target": "id=id_password_repeat", + "targets": [ + ["id=id_password_repeat", "id"], + ["name=password_repeat", "name"], + ["css=#id_password_repeat", "css:finder"], + ["xpath=//input[@id='id_password_repeat']", "xpath:attributes"], + ["xpath=//main[@id='flow-body']/div/form/div[3]/input", "xpath:idRelative"], + ["xpath=//div[3]/input", "xpath:position"] + ], + "value": "pbadmin" + }, { + "id": "e7527bfc-ec74-4d96-86f0-5a3a55a59025", + "comment": "", + "command": "click", + "target": "css=.pf-c-button", + "targets": [ + ["css=.pf-c-button", "css:finder"], + ["xpath=//button[@type='submit']", "xpath:attributes"], + ["xpath=//main[@id='flow-body']/div/form/div[4]/button", "xpath:idRelative"], + ["xpath=//button", "xpath:position"], + ["xpath=//button[contains(.,'Continue')]", "xpath:innerText"] + ], + "value": "" + }, { + "id": "434b842c-a659-4ff5-aca8-06a6a3489597", + "comment": "", + "command": "type", + "target": "id=id_name", + "targets": [ + ["id=id_name", "id"], + ["name=name", "name"], + ["css=#id_name", "css:finder"], + ["xpath=//input[@id='id_name']", "xpath:attributes"], + ["xpath=//main[@id='flow-body']/div/form/div/input", "xpath:idRelative"], + ["xpath=//div/input", "xpath:position"] + ], + "value": "some name" + }, { + "id": "cbc43a1b-2cfe-46e2-85bc-476fb32c6cb1", + "comment": "", + "command": "type", + "target": "id=id_email", + "targets": [ + ["id=id_email", "id"], + ["name=email", "name"], + ["css=#id_email", "css:finder"], + ["xpath=//input[@id='id_email']", "xpath:attributes"], + ["xpath=//main[@id='flow-body']/div/form/div[2]/input", "xpath:idRelative"], + ["xpath=//div[2]/input", "xpath:position"] + ], + "value": "foo@bar.baz" + }, { + "id": "e74389a0-228b-4312-9677-e9add6358de3", + "comment": "", + "command": "click", + "target": "css=.pf-c-button", + "targets": [ + ["css=.pf-c-button", "css:finder"], + ["xpath=//button[@type='submit']", "xpath:attributes"], + ["xpath=//main[@id='flow-body']/div/form/div[3]/button", "xpath:idRelative"], + ["xpath=//button", "xpath:position"], + ["xpath=//button[contains(.,'Continue')]", "xpath:innerText"] + ], + "value": "" + }, { + "id": "3e22f9c2-5ebd-49c2-81b1-340fa0435bbc", + "comment": "", + "command": "click", + "target": "linkText=foo", + "targets": [ + ["linkText=foo", "linkText"], + ["css=.pf-c-page__header-tools-group:nth-child(2) > .pf-c-button", "css:finder"], + ["xpath=//a[contains(text(),'foo')]", "xpath:link"], + ["xpath=//div[@id='page-default-nav-example']/header/div[3]/div[2]/a", "xpath:idRelative"], + ["xpath=//a[contains(@href, '/-/user/')]", "xpath:href"], + ["xpath=//div[2]/a", "xpath:position"], + ["xpath=//a[contains(.,'foo')]", "xpath:innerText"] + ], + "value": "" + }, { + "id": "60124cfd-f11c-4d7f-8b01-bef54c8cbd73", + "comment": "", + "command": "assertText", + "target": "xpath=//a[contains(@href, '/-/user/')]", + "targets": [ + ["linkText=foo", "linkText"], + ["css=.pf-c-page__header-tools-group:nth-child(2) > .pf-c-button", "css:finder"], + ["xpath=//a[contains(text(),'foo')]", "xpath:link"], + ["xpath=//div[@id='page-default-nav-example']/header/div[3]/div[2]/a", "xpath:idRelative"], + ["xpath=//a[contains(@href, '/-/user/')]", "xpath:href"], + ["xpath=//div[2]/a", "xpath:position"], + ["xpath=//a[contains(.,'foo')]", "xpath:innerText"] + ], + "value": "foo" + }, { + "id": "429ee61b-9991-4919-8131-55f8e1bd9a0d", + "comment": "", + "command": "assertValue", + "target": "id=id_username", + "targets": [], + "value": "foo" + }, { + "id": "f6c50760-52ed-4c1d-b232-30f8afe144eb", + "comment": "", + "command": "assertText", + "target": "id=id_name", + "targets": [ + ["id=id_name", "id"], + ["name=name", "name"], + ["css=#id_name", "css:finder"], + ["xpath=//input[@id='id_name']", "xpath:attributes"], + ["xpath=//main[@id='main-content']/section/div/div/div/div[2]/form/div[2]/div/input", "xpath:idRelative"], + ["xpath=//div[2]/div/input", "xpath:position"] + ], + "value": "some name" + }, { + "id": "b26905b5-89b5-4b41-abf5-a9f848f08622", + "comment": "", + "command": "assertText", + "target": "id=id_email", + "targets": [ + ["id=id_email", "id"], + ["name=email", "name"], + ["css=#id_email", "css:finder"], + ["xpath=//input[@id='id_email']", "xpath:attributes"], + ["xpath=//main[@id='main-content']/section/div/div/div/div[2]/form/div[3]/div/input", "xpath:idRelative"], + ["xpath=//div[3]/div/input", "xpath:position"] + ], + "value": "foo@bar.baz" + }] + }], + "suites": [{ + "id": "495657fb-3f5e-4431-877c-4d0b248c0841", + "name": "Default Suite", + "persistSession": false, + "parallel": false, + "timeout": 300, + "tests": ["94b39863-74ec-4b7d-98c5-2b380b6d2c55"] + }], + "urls": ["http://localhost:8000/"], + "plugins": [] +} \ No newline at end of file diff --git a/e2e/test_enroll_2_step.py b/e2e/test_enroll_2_step.py index b8f83cd33..d517d0144 100644 --- a/e2e/test_enroll_2_step.py +++ b/e2e/test_enroll_2_step.py @@ -4,14 +4,14 @@ from selenium import webdriver from selenium.webdriver.common.by import By from selenium.webdriver.common.desired_capabilities import DesiredCapabilities +from e2e.utils import apply_default_data from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding from passbook.policies.expression.models import ExpressionPolicy from passbook.policies.models import PolicyBinding +from passbook.stages.identification.models import IdentificationStage from passbook.stages.prompt.models import FieldTypes, Prompt, PromptStage from passbook.stages.user_login.models import UserLoginStage from passbook.stages.user_write.models import UserWriteStage -from passbook.stages.identification.models import IdentificationStage -from e2e.utils import apply_default_data class TestEnroll2Step(StaticLiveServerTestCase): diff --git a/e2e/test_login_default.py b/e2e/test_login_default.py index f4db6a5b8..c8bf5f445 100644 --- a/e2e/test_login_default.py +++ b/e2e/test_login_default.py @@ -4,6 +4,7 @@ from selenium import webdriver from selenium.webdriver.common.by import By from selenium.webdriver.common.desired_capabilities import DesiredCapabilities from selenium.webdriver.common.keys import Keys + from e2e.utils import apply_default_data @@ -24,9 +25,7 @@ class TestLogin(StaticLiveServerTestCase): def test_login(self): """test default login flow""" - self.driver.get( - f"{self.live_server_url}/flows/default-authentication-flow/" - ) + self.driver.get(f"{self.live_server_url}/flows/default-authentication-flow/") self.driver.find_element(By.ID, "id_uid_field").click() self.driver.find_element(By.ID, "id_uid_field").send_keys("pbadmin") self.driver.find_element(By.ID, "id_uid_field").send_keys(Keys.ENTER) diff --git a/e2e/test_provider_oidc.py b/e2e/test_provider_oidc.py new file mode 100644 index 000000000..df966b137 --- /dev/null +++ b/e2e/test_provider_oidc.py @@ -0,0 +1,176 @@ +"""test OpenID Provider flow""" +from time import sleep + +from django.contrib.staticfiles.testing import StaticLiveServerTestCase +from oauth2_provider.generators import generate_client_id, generate_client_secret +from oidc_provider.models import Client, ResponseType +from selenium import webdriver +from selenium.webdriver.common.by import By +from selenium.webdriver.common.desired_capabilities import DesiredCapabilities +from selenium.webdriver.common.keys import Keys + +from docker import DockerClient, from_env +from docker.models.containers import Container +from docker.types import Healthcheck +from e2e.utils import apply_default_data, ensure_rsa_key +from passbook.core.models import Application +from passbook.flows.models import Flow +from passbook.providers.oidc.models import OpenIDProvider + + +class TestProviderOIDC(StaticLiveServerTestCase): + """test OpenID Provider flow""" + + def setUp(self): + self.driver = webdriver.Remote( + command_executor="http://localhost:4444/wd/hub", + desired_capabilities=DesiredCapabilities.CHROME, + ) + self.driver.implicitly_wait(30) + apply_default_data() + self.client_id = generate_client_id() + self.client_secret = generate_client_secret() + self.container = self.setup_client() + + def setup_client(self) -> Container: + """Setup client grafana container which we test OIDC against""" + client: DockerClient = from_env() + container = client.containers.run( + image="grafana/grafana:latest", + detach=True, + name="passbook-e2e-grafana-client", + network_mode="host", + auto_remove=True, + healthcheck=Healthcheck( + test=["CMD", "wget", "--spider", "http://localhost:3000"], + interval=5 * 100 * 1000000, + start_period=1 * 100 * 1000000, + ), + environment={ + "GF_AUTH_GENERIC_OAUTH_ENABLED": "true", + "GF_AUTH_GENERIC_OAUTH_CLIENT_ID": self.client_id, + "GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET": self.client_secret, + "GF_AUTH_GENERIC_OAUTH_SCOPES": "openid email profile", + "GF_AUTH_GENERIC_OAUTH_AUTH_URL": ( + f"{self.live_server_url}/application/oidc/authorize" + ), + "GF_AUTH_GENERIC_OAUTH_TOKEN_URL": ( + f"{self.live_server_url}/application/oidc/token" + ), + "GF_AUTH_GENERIC_OAUTH_API_URL": ( + f"{self.live_server_url}/application/oidc/userinfo" + ), + "GF_LOG_LEVEL": "debug", + }, + ) + while True: + container.reload() + status = container.attrs.get("State", {}).get("Health", {}).get("Status") + if status == "healthy": + return container + sleep(1) + + def tearDown(self): + super().tearDown() + self.driver.quit() + self.container.kill() + + def test_redirect_uri_error(self): + """test OpenID Provider flow (invalid redirect URI, check error message)""" + # Bootstrap all needed objects + authorization_flow = Flow.objects.get(slug="default-provider-authorization") + client = Client.objects.create( + name="grafana", + client_type="confidential", + client_id=self.client_id, + client_secret=self.client_secret, + _redirect_uris="http://localhost:3000/", + _scope="openid userinfo", + ) + # At least one of these objects must exist + ensure_rsa_key() + # This response_code object might exist or not, depending on the order the tests are run + rp_type, _ = ResponseType.objects.get_or_create(value="code") + client.response_types.set([rp_type]) + client.save() + provider = OpenIDProvider.objects.create( + oidc_client=client, authorization_flow=authorization_flow, + ) + Application.objects.create( + name="Grafana", slug="grafana", provider=provider, + ) + + self.driver.get(f"http://localhost:3000") + self.driver.find_element(By.CLASS_NAME, "btn-service--oauth").click() + self.driver.find_element(By.ID, "id_uid_field").click() + self.driver.find_element(By.ID, "id_uid_field").send_keys("pbadmin") + self.driver.find_element(By.ID, "id_uid_field").send_keys(Keys.ENTER) + self.driver.find_element(By.ID, "id_password").send_keys("pbadmin") + self.driver.find_element(By.ID, "id_password").send_keys(Keys.ENTER) + sleep(2) + self.assertEqual( + self.driver.find_element(By.CLASS_NAME, "pf-c-title").text, + "Redirect URI Error", + ) + + def test_authorization_no_consent(self): + """test OpenID Provider flow (default authorization flow without consent)""" + # Bootstrap all needed objects + authorization_flow = Flow.objects.get(slug="default-provider-authorization") + client = Client.objects.create( + name="grafana", + client_type="confidential", + client_id=self.client_id, + client_secret=self.client_secret, + _redirect_uris="http://localhost:3000/login/generic_oauth", + _scope="openid profile email", + reuse_consent=False, + require_consent=False, + ) + # At least one of these objects must exist + ensure_rsa_key() + # This response_code object might exist or not, depending on the order the tests are run + rp_type, _ = ResponseType.objects.get_or_create(value="code") + client.response_types.set([rp_type]) + client.save() + provider = OpenIDProvider.objects.create( + oidc_client=client, authorization_flow=authorization_flow, + ) + Application.objects.create( + name="Grafana", slug="grafana", provider=provider, + ) + + self.driver.get("http://localhost:3000") + self.driver.find_element(By.CLASS_NAME, "btn-service--oauth").click() + self.driver.find_element(By.ID, "id_uid_field").click() + self.driver.find_element(By.ID, "id_uid_field").send_keys("pbadmin") + self.driver.find_element(By.ID, "id_uid_field").send_keys(Keys.ENTER) + self.driver.find_element(By.ID, "id_password").send_keys("pbadmin") + self.driver.find_element(By.ID, "id_password").send_keys(Keys.ENTER) + self.driver.find_element(By.XPATH, "//a[contains(@href, '/profile')]").click() + # sleep() + self.assertEqual( + self.driver.find_element(By.CLASS_NAME, "page-header__title").text, + "passbook Default Admin", + ) + self.assertEqual( + self.driver.find_element( + By.XPATH, + "/html/body/grafana-app/div/div/div/react-profile-wrapper/form[1]/div[1]/div/input", + ).get_attribute("value"), + "passbook Default Admin", + ) + self.assertEqual( + self.driver.find_element( + By.XPATH, + "/html/body/grafana-app/div/div/div/react-profile-wrapper/form[1]/div[2]/div/input", + ).get_attribute("value"), + "root@localhost", + ) + self.assertEqual( + self.driver.find_element( + By.XPATH, + "/html/body/grafana-app/div/div/div/react-profile-wrapper/form[1]/div[3]/div/input", + ).get_attribute("value"), + "root@localhost", + ) diff --git a/e2e/utils.py b/e2e/utils.py index d10aaa5b2..5434e7da5 100644 --- a/e2e/utils.py +++ b/e2e/utils.py @@ -1,8 +1,10 @@ """passbook e2e testing utilities""" from glob import glob +from importlib.util import module_from_spec, spec_from_file_location from inspect import getmembers, isfunction -from importlib.util import spec_from_file_location, module_from_spec + +from Cryptodome.PublicKey import RSA from django.apps import apps from django.db import connection, transaction from django.db.utils import IntegrityError @@ -15,7 +17,7 @@ def apply_default_data(): migration_files = glob("**/migrations/*.py", recursive=True) matches = [] for migration in migration_files: - with open(migration, 'r+') as migration_file: + with open(migration, "r+") as migration_file: # Check if they have a `RunPython` if "RunPython" in migration_file.read(): matches.append(migration) @@ -34,3 +36,13 @@ def apply_default_data(): func(apps, schema_editor) except IntegrityError: pass + + +def ensure_rsa_key(): + """Ensure that at least one RSAKey Object exists, create one if none exist""" + from oidc_provider.models import RSAKey + + if not RSAKey.objects.exists(): + key = RSA.generate(2048) + rsakey = RSAKey(key=key.exportKey("PEM").decode("utf8")) + rsakey.save() diff --git a/manage.py b/manage.py index 5a70f4ada..8b16cf9a3 100755 --- a/manage.py +++ b/manage.py @@ -2,6 +2,7 @@ """Django manage.py""" import os import sys + from defusedxml import defuse_stdlib defuse_stdlib() diff --git a/passbook/flows/planner.py b/passbook/flows/planner.py index 86acdb630..f91bc08da 100644 --- a/passbook/flows/planner.py +++ b/passbook/flows/planner.py @@ -39,6 +39,11 @@ class FlowPlan: context: Dict[str, Any] = field(default_factory=dict) markers: List[StageMarker] = field(default_factory=list) + def append(self, stage: Stage, marker: Optional[StageMarker] = None): + """Append `stage` to all stages, optionall with stage marker""" + self.stages.append(stage) + self.markers.append(marker or StageMarker()) + def next(self) -> Optional[Stage]: """Return next pending stage from the bottom of the list""" if not self.has_stages: @@ -54,7 +59,6 @@ class FlowPlan: self.markers.remove(marker) if not self.has_stages: return None - # pylint: disable=not-callable return self.next() return marked_stage diff --git a/passbook/flows/views.py b/passbook/flows/views.py index affbe4536..1625e2c01 100644 --- a/passbook/flows/views.py +++ b/passbook/flows/views.py @@ -27,6 +27,7 @@ LOGGER = get_logger() # Argument used to redirect user after login NEXT_ARG_NAME = "next" SESSION_KEY_PLAN = "passbook_flows_plan" +SESSION_KEY_NEXT = "passbook_flows_shell_next" @method_decorator(xframe_options_sameorigin, name="dispatch") @@ -127,7 +128,10 @@ class FlowExecutorView(View): def _flow_done(self) -> HttpResponse: """User Successfully passed all stages""" self.cancel() - next_param = self.request.GET.get(NEXT_ARG_NAME, "passbook_core:overview") + # Since this is wrapped by the ExecutorShell, the next argument is saved in the session + next_param = self.request.session.get( + SESSION_KEY_NEXT, "passbook_core:overview" + ) return redirect_with_qs(next_param) def stage_ok(self) -> HttpResponse: @@ -210,6 +214,8 @@ class FlowExecutorShellView(TemplateView): def get_context_data(self, **kwargs) -> Dict[str, Any]: kwargs["exec_url"] = reverse("passbook_flows:flow-executor", kwargs=self.kwargs) kwargs["msg_url"] = reverse("passbook_api:messages-list") + if NEXT_ARG_NAME in self.request.GET: + self.request.session[SESSION_KEY_NEXT] = self.request.GET[NEXT_ARG_NAME] return kwargs diff --git a/passbook/providers/oidc/claims.py b/passbook/providers/oidc/claims.py index 8ad905a22..f53c90f33 100644 --- a/passbook/providers/oidc/claims.py +++ b/passbook/providers/oidc/claims.py @@ -10,5 +10,5 @@ def userinfo(claims: Dict[str, Any], user: User) -> Dict[str, Any]: claims["given_name"] = user.name claims["family_name"] = user.name claims["email"] = user.email - + claims["preferred_username"] = user.username return claims diff --git a/passbook/providers/oidc/views.py b/passbook/providers/oidc/views.py index 2d9e5bb89..abc0039ca 100644 --- a/passbook/providers/oidc/views.py +++ b/passbook/providers/oidc/views.py @@ -61,7 +61,7 @@ class AuthorizationFlowInitView(AccessMixin, LoginRequiredMixin, View): PLAN_CONTEXT_PARAMS: endpoint.params, }, ) - plan.stages.append(in_memory_stage(OIDCStage)) + plan.append(in_memory_stage(OIDCStage)) self.request.session[SESSION_KEY_PLAN] = plan return redirect_with_qs( "passbook_flows:flow-executor-shell", diff --git a/scripts/pre-commit.sh b/scripts/pre-commit.sh index 1cf64708d..e8335a1a9 100755 --- a/scripts/pre-commit.sh +++ b/scripts/pre-commit.sh @@ -1,9 +1,9 @@ #!/bin/bash -xe -isort -rc passbook +isort -rc . pyright -black passbook +black . ./manage.py generate_swagger -o swagger.yaml -f yaml scripts/coverage.sh -bandit -r passbook +bandit -r . pylint passbook prospector From b83aa44c4fb7a1dc21f8cac23e43d535f0a015ff Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Fri, 19 Jun 2020 19:45:46 +0200 Subject: [PATCH 16/64] ci: run full coverage with e2e in one step so we get full coverage percentage --- .github/workflows/ci.yml | 27 +++++++++------------------ 1 file changed, 9 insertions(+), 18 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9289325d6..b21c8b0e7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -122,7 +122,15 @@ jobs: with: python-version: '3.8' - name: Install dependencies - run: sudo pip install -U wheel pipenv && pipenv install --dev + run: | + sudo pip install -U wheel pipenv + pipenv install --dev + pipenv run pip install -r e2e/requirements.txt + - name: Prepare Chrome node + run: | + cd e2e + docker-compose pull -q chrome + docker-compose up -d chrome - name: Run coverage run: pipenv run ./scripts/coverage.sh - name: Create XML Report @@ -130,23 +138,6 @@ jobs: - uses: codecov/codecov-action@v1 with: token: ${{ secrets.CODECOV_TOKEN }} - e2e: - needs: - - pylint - - black - - prospector - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v1 - - name: Setup test containers and run tests - run: | - cd e2e - docker-compose pull -q - docker-compose up -d - docker-compose exec -T passbook bash -c "pip install -r /app/requirements-dev.txt" - # This is temporary as we don't have selenium as a dependency yet - docker-compose exec -T passbook bash -c "pip install selenium" - docker-compose exec -T passbook bash -c "./manage.py test e2e" # Build build-server: needs: From fa5c2bd85cfa83d2e907d4c2d59a1c998b67abf4 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Fri, 19 Jun 2020 20:19:18 +0200 Subject: [PATCH 17/64] stages/consent: add FlowPlan context variable for template name --- passbook/stages/consent/stage.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/passbook/stages/consent/stage.py b/passbook/stages/consent/stage.py index e925f222a..fc9afdf14 100644 --- a/passbook/stages/consent/stage.py +++ b/passbook/stages/consent/stage.py @@ -1,25 +1,30 @@ """passbook consent stage""" -from typing import Any, Dict +from typing import List, Dict, Any from django.views.generic import FormView from passbook.flows.stage import StageView -from passbook.lib.utils.template import render_to_string from passbook.stages.consent.forms import ConsentForm +PLAN_CONTEXT_CONSENT_TEMPLATE = "consent_template" + class ConsentStage(FormView, StageView): """Simple consent checker.""" - body_template_name: str - form_class = ConsentForm def get_context_data(self, **kwargs: Dict[str, Any]) -> Dict[str, Any]: kwargs = super().get_context_data(**kwargs) - if self.body_template_name: - kwargs["body"] = render_to_string(self.body_template_name, kwargs) + kwargs['current_stage'] = self.executor.current_stage + kwargs['context'] = self.executor.plan.context return kwargs + def get_template_names(self) -> List[str]: + if PLAN_CONTEXT_CONSENT_TEMPLATE in self.executor.plan.context: + template_name = self.executor.plan.context[PLAN_CONTEXT_CONSENT_TEMPLATE] + return [template_name] + return super().get_template_names() + def form_valid(self, form): return self.executor.stage_ok() From 3a40e50fa0aa470860bf4387434600473125b2eb Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Fri, 19 Jun 2020 20:19:31 +0200 Subject: [PATCH 18/64] providers/oidc: add template for consent --- .../templates/providers/oidc/consent.html | 20 +++++++++++++++++++ passbook/providers/oidc/views.py | 5 ++++- 2 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 passbook/providers/oidc/templates/providers/oidc/consent.html diff --git a/passbook/providers/oidc/templates/providers/oidc/consent.html b/passbook/providers/oidc/templates/providers/oidc/consent.html new file mode 100644 index 000000000..83ad45123 --- /dev/null +++ b/passbook/providers/oidc/templates/providers/oidc/consent.html @@ -0,0 +1,20 @@ +{% extends 'login/form_with_user.html' %} + +{% load i18n %} + +{% block beneath_form %} +
    +

    + {% blocktrans with name=context.application.name %} + You're about to sign into {{ name }}. + {% endblocktrans %} +

    +

    {% trans "Application requires following permissions" %}

    +
      + {% for scope in context.scopes %} +
    • {{ scope.name }}
    • + {% endfor %} +
    + {{ hidden_inputs }} +
    +{% endblock %} diff --git a/passbook/providers/oidc/views.py b/passbook/providers/oidc/views.py index abc0039ca..1cddbe524 100644 --- a/passbook/providers/oidc/views.py +++ b/passbook/providers/oidc/views.py @@ -1,4 +1,5 @@ """passbook OIDC Views""" +from passbook.stages.consent.stage import PLAN_CONTEXT_CONSENT_TEMPLATE from django.contrib import messages from django.contrib.auth.mixins import LoginRequiredMixin from django.http import HttpRequest, HttpResponse, JsonResponse @@ -27,7 +28,7 @@ from passbook.providers.oidc.models import OpenIDProvider LOGGER = get_logger() PLAN_CONTEXT_PARAMS = "params" - +PLAN_CONTEXT_SCOPES = "scopes" class AuthorizationFlowInitView(AccessMixin, LoginRequiredMixin, View): """OIDC Flow initializer, checks access to application and starts flow""" @@ -59,6 +60,8 @@ class AuthorizationFlowInitView(AccessMixin, LoginRequiredMixin, View): PLAN_CONTEXT_SSO: True, PLAN_CONTEXT_APPLICATION: application, PLAN_CONTEXT_PARAMS: endpoint.params, + PLAN_CONTEXT_SCOPES: endpoint.get_scopes_information(), + PLAN_CONTEXT_CONSENT_TEMPLATE: "providers/oidc/consent.html" }, ) plan.append(in_memory_stage(OIDCStage)) From 01f004cec6a1972bdad611c73e36a8e173ff6ad5 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Fri, 19 Jun 2020 20:25:45 +0200 Subject: [PATCH 19/64] root: move all e2e dependencies into pipfile --- .github/workflows/ci.yml | 1 - Pipfile | 1 + Pipfile.lock | 32 +++++++++++++++++++++++++++++++- e2e/requirements.txt | 2 -- e2e/setup.sh | 1 - 5 files changed, 32 insertions(+), 5 deletions(-) delete mode 100644 e2e/requirements.txt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b21c8b0e7..31f0eed4b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -125,7 +125,6 @@ jobs: run: | sudo pip install -U wheel pipenv pipenv install --dev - pipenv run pip install -r e2e/requirements.txt - name: Prepare Chrome node run: | cd e2e diff --git a/Pipfile b/Pipfile index 69ed42c56..5f3a4e2d3 100644 --- a/Pipfile +++ b/Pipfile @@ -56,6 +56,7 @@ pylint-django = "*" unittest-xml-reporting = "*" black = "*" selenium = "*" +docker = "*" [pipenv] allow_prereleases = true diff --git a/Pipfile.lock b/Pipfile.lock index ede50ef06..c6e9ee81b 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "541f26a45f249fb2e61a597af7be7dee51eb8b40aa1035ae4081a455168128cc" + "sha256": "e1e229f3276f2f76787b55050506f86e65579bc5aab5c7fca8caa319adb7f3d8" }, "pipfile-spec": 6, "requires": { @@ -913,6 +913,13 @@ ], "version": "==1.14.0" }, + "chardet": { + "hashes": [ + "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", + "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691" + ], + "version": "==3.0.4" + }, "click": { "hashes": [ "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a", @@ -1005,6 +1012,14 @@ "index": "pypi", "version": "==2.2" }, + "docker": { + "hashes": [ + "sha256:380a20d38fbfaa872e96ee4d0d23ad9beb0f9ed57ff1c30653cbeb0c9c0964f2", + "sha256:672f51aead26d90d1cfce84a87e6f71fca401bbc2a6287be18603583620a28ba" + ], + "index": "pypi", + "version": "==4.2.1" + }, "gitdb": { "hashes": [ "sha256:91f36bfb1ab7949b3b40e23736db18231bf7593edada2ba5c3a174a7b23657ac", @@ -1174,6 +1189,14 @@ ], "version": "==2020.6.8" }, + "selenium": { + "hashes": [ + "sha256:5f5489a0c5fe2f09cc6bc3f32a0d53441ab36882c987269f2afe805979633ac1", + "sha256:a9779ddc69cf03b75d94062c5e948f763919cf3341c77272f94cd05e6b4c7b32" + ], + "index": "pypi", + "version": "==4.0.0a6.post2" + }, "six": { "hashes": [ "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", @@ -1255,6 +1278,13 @@ "markers": null, "version": "==1.25.9" }, + "websocket-client": { + "hashes": [ + "sha256:0fc45c961324d79c781bab301359d5a1b00b13ad1b10415a4780229ef71a5549", + "sha256:d735b91d6d1692a6a181f2a8c9e0238e5f6373356f561bb9dc4c7af36f452010" + ], + "version": "==0.57.0" + }, "wrapt": { "hashes": [ "sha256:b62ffa81fb85f4332a4f609cab4ac40709470da05643a082ec1eb88e6d9b97d7" diff --git a/e2e/requirements.txt b/e2e/requirements.txt deleted file mode 100644 index c87e8eb53..000000000 --- a/e2e/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -selenium -docker diff --git a/e2e/setup.sh b/e2e/setup.sh index 7859a76e9..7eb5e4116 100755 --- a/e2e/setup.sh +++ b/e2e/setup.sh @@ -16,5 +16,4 @@ cd e2e sudo docker-compose up -d cd .. pipenv sync --dev -pipenv run pip install -r e2e/requirements.txt pipenv shell From 12525051b62b55f9b5054957440f9c92fb266cb2 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Fri, 19 Jun 2020 20:26:04 +0200 Subject: [PATCH 20/64] e2e: add test for providers/oidc with consent --- e2e/test_provider_oidc.py | 79 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 75 insertions(+), 4 deletions(-) diff --git a/e2e/test_provider_oidc.py b/e2e/test_provider_oidc.py index df966b137..ad9fef526 100644 --- a/e2e/test_provider_oidc.py +++ b/e2e/test_provider_oidc.py @@ -26,7 +26,7 @@ class TestProviderOIDC(StaticLiveServerTestCase): command_executor="http://localhost:4444/wd/hub", desired_capabilities=DesiredCapabilities.CHROME, ) - self.driver.implicitly_wait(30) + self.driver.implicitly_wait(5) apply_default_data() self.client_id = generate_client_id() self.client_secret = generate_client_secret() @@ -38,7 +38,7 @@ class TestProviderOIDC(StaticLiveServerTestCase): container = client.containers.run( image="grafana/grafana:latest", detach=True, - name="passbook-e2e-grafana-client", + name=f"passbook-e2e-grafana-client_{self.port}", network_mode="host", auto_remove=True, healthcheck=Healthcheck( @@ -100,7 +100,7 @@ class TestProviderOIDC(StaticLiveServerTestCase): name="Grafana", slug="grafana", provider=provider, ) - self.driver.get(f"http://localhost:3000") + self.driver.get("http://localhost:3000") self.driver.find_element(By.CLASS_NAME, "btn-service--oauth").click() self.driver.find_element(By.ID, "id_uid_field").click() self.driver.find_element(By.ID, "id_uid_field").send_keys("pbadmin") @@ -148,7 +148,78 @@ class TestProviderOIDC(StaticLiveServerTestCase): self.driver.find_element(By.ID, "id_password").send_keys("pbadmin") self.driver.find_element(By.ID, "id_password").send_keys(Keys.ENTER) self.driver.find_element(By.XPATH, "//a[contains(@href, '/profile')]").click() - # sleep() + self.assertEqual( + self.driver.find_element(By.CLASS_NAME, "page-header__title").text, + "passbook Default Admin", + ) + self.assertEqual( + self.driver.find_element( + By.XPATH, + "/html/body/grafana-app/div/div/div/react-profile-wrapper/form[1]/div[1]/div/input", + ).get_attribute("value"), + "passbook Default Admin", + ) + self.assertEqual( + self.driver.find_element( + By.XPATH, + "/html/body/grafana-app/div/div/div/react-profile-wrapper/form[1]/div[2]/div/input", + ).get_attribute("value"), + "root@localhost", + ) + self.assertEqual( + self.driver.find_element( + By.XPATH, + "/html/body/grafana-app/div/div/div/react-profile-wrapper/form[1]/div[3]/div/input", + ).get_attribute("value"), + "root@localhost", + ) + + def test_authorization_consent(self): + """test OpenID Provider flow (default authorization flow with consent)""" + # Bootstrap all needed objects + authorization_flow = Flow.objects.get( + slug="default-provider-authorization-consent" + ) + client = Client.objects.create( + name="grafana", + client_type="confidential", + client_id=self.client_id, + client_secret=self.client_secret, + _redirect_uris="http://localhost:3000/login/generic_oauth", + _scope="openid profile email", + reuse_consent=False, + require_consent=False, + ) + # At least one of these objects must exist + ensure_rsa_key() + # This response_code object might exist or not, depending on the order the tests are run + rp_type, _ = ResponseType.objects.get_or_create(value="code") + client.response_types.set([rp_type]) + client.save() + provider = OpenIDProvider.objects.create( + oidc_client=client, authorization_flow=authorization_flow, + ) + app = Application.objects.create( + name="Grafana", slug="grafana", provider=provider, + ) + + self.driver.get("http://localhost:3000") + self.driver.find_element(By.CLASS_NAME, "btn-service--oauth").click() + self.driver.find_element(By.ID, "id_uid_field").click() + self.driver.find_element(By.ID, "id_uid_field").send_keys("pbadmin") + self.driver.find_element(By.ID, "id_uid_field").send_keys(Keys.ENTER) + self.driver.find_element(By.ID, "id_password").send_keys("pbadmin") + self.driver.find_element(By.ID, "id_password").send_keys(Keys.ENTER) + + self.assertIn( + app.name, + self.driver.find_element( + By.XPATH, "/html/body/div[2]/div/main/div/form/div[2]/p[1]" + ).text, + ) + self.driver.find_element(By.CSS_SELECTOR, "[type=submit]").click() + + self.driver.find_element(By.XPATH, "//a[contains(@href, '/profile')]").click() self.assertEqual( self.driver.find_element(By.CLASS_NAME, "page-header__title").text, "passbook Default Admin", From 03b1a67b446cc1b73b097131782e5b4a984dda0e Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Fri, 19 Jun 2020 20:33:35 +0200 Subject: [PATCH 21/64] flows: change wording of consent on flows --- e2e/test_provider_oidc.py | 17 +++++++++++------ .../flows/migrations/0005_provider_flows.py | 12 ++++++------ passbook/flows/planner.py | 1 + passbook/providers/oidc/views.py | 5 +++-- passbook/stages/consent/stage.py | 6 +++--- 5 files changed, 24 insertions(+), 17 deletions(-) diff --git a/e2e/test_provider_oidc.py b/e2e/test_provider_oidc.py index ad9fef526..0c16c739d 100644 --- a/e2e/test_provider_oidc.py +++ b/e2e/test_provider_oidc.py @@ -77,6 +77,7 @@ class TestProviderOIDC(StaticLiveServerTestCase): def test_redirect_uri_error(self): """test OpenID Provider flow (invalid redirect URI, check error message)""" + sleep(1) # Bootstrap all needed objects authorization_flow = Flow.objects.get(slug="default-provider-authorization") client = Client.objects.create( @@ -113,10 +114,13 @@ class TestProviderOIDC(StaticLiveServerTestCase): "Redirect URI Error", ) - def test_authorization_no_consent(self): - """test OpenID Provider flow (default authorization flow without consent)""" + def test_authorization_consent_implied(self): + """test OpenID Provider flow (default authorization flow with implied consent)""" + sleep(1) # Bootstrap all needed objects - authorization_flow = Flow.objects.get(slug="default-provider-authorization") + authorization_flow = Flow.objects.get( + slug="default-provider-authorization-implicit-consent" + ) client = Client.objects.create( name="grafana", client_type="confidential", @@ -174,11 +178,12 @@ class TestProviderOIDC(StaticLiveServerTestCase): "root@localhost", ) - def test_authorization_consent(self): - """test OpenID Provider flow (default authorization flow with consent)""" + def test_authorization_consent_explicit(self): + """test OpenID Provider flow (default authorization flow with explicit consent)""" + sleep(1) # Bootstrap all needed objects authorization_flow = Flow.objects.get( - slug="default-provider-authorization-consent" + slug="default-provider-authorization-explicit-consent" ) client = Client.objects.create( name="grafana", diff --git a/passbook/flows/migrations/0005_provider_flows.py b/passbook/flows/migrations/0005_provider_flows.py index a197d0532..007f29475 100644 --- a/passbook/flows/migrations/0005_provider_flows.py +++ b/passbook/flows/migrations/0005_provider_flows.py @@ -17,17 +17,17 @@ def create_default_provider_authz_flow( db_alias = schema_editor.connection.alias - # Empty flow for providers where no consent is needed + # Empty flow for providers where consent is implicitly given Flow.objects.create( - name="default-provider-authorization", - slug="default-provider-authorization", + name="Authorize Application", + slug="default-provider-authorization-implicit-consent", designation=FlowDesignation.AUTHORIZATION, ) - # Flow with consent form to obtain user consent for authorization + # Flow with consent form to obtain explicit user consent flow = Flow.objects.create( - name="default-provider-authorization-consent", - slug="default-provider-authorization-consent", + name="Authorize Application", + slug="default-provider-authorization-explicit-consent", designation=FlowDesignation.AUTHORIZATION, ) stage = ConsentStage.objects.create(name="default-provider-authorization-consent") diff --git a/passbook/flows/planner.py b/passbook/flows/planner.py index f91bc08da..aee17ecd9 100644 --- a/passbook/flows/planner.py +++ b/passbook/flows/planner.py @@ -59,6 +59,7 @@ class FlowPlan: self.markers.remove(marker) if not self.has_stages: return None + # pylint: disable=not-callable return self.next() return marked_stage diff --git a/passbook/providers/oidc/views.py b/passbook/providers/oidc/views.py index 1cddbe524..b537222ed 100644 --- a/passbook/providers/oidc/views.py +++ b/passbook/providers/oidc/views.py @@ -1,5 +1,4 @@ """passbook OIDC Views""" -from passbook.stages.consent.stage import PLAN_CONTEXT_CONSENT_TEMPLATE from django.contrib import messages from django.contrib.auth.mixins import LoginRequiredMixin from django.http import HttpRequest, HttpResponse, JsonResponse @@ -24,12 +23,14 @@ from passbook.flows.stage import StageView from passbook.flows.views import SESSION_KEY_PLAN from passbook.lib.utils.urls import redirect_with_qs from passbook.providers.oidc.models import OpenIDProvider +from passbook.stages.consent.stage import PLAN_CONTEXT_CONSENT_TEMPLATE LOGGER = get_logger() PLAN_CONTEXT_PARAMS = "params" PLAN_CONTEXT_SCOPES = "scopes" + class AuthorizationFlowInitView(AccessMixin, LoginRequiredMixin, View): """OIDC Flow initializer, checks access to application and starts flow""" @@ -61,7 +62,7 @@ class AuthorizationFlowInitView(AccessMixin, LoginRequiredMixin, View): PLAN_CONTEXT_APPLICATION: application, PLAN_CONTEXT_PARAMS: endpoint.params, PLAN_CONTEXT_SCOPES: endpoint.get_scopes_information(), - PLAN_CONTEXT_CONSENT_TEMPLATE: "providers/oidc/consent.html" + PLAN_CONTEXT_CONSENT_TEMPLATE: "providers/oidc/consent.html", }, ) plan.append(in_memory_stage(OIDCStage)) diff --git a/passbook/stages/consent/stage.py b/passbook/stages/consent/stage.py index fc9afdf14..3a1fa14a2 100644 --- a/passbook/stages/consent/stage.py +++ b/passbook/stages/consent/stage.py @@ -1,5 +1,5 @@ """passbook consent stage""" -from typing import List, Dict, Any +from typing import Any, Dict, List from django.views.generic import FormView @@ -16,8 +16,8 @@ class ConsentStage(FormView, StageView): def get_context_data(self, **kwargs: Dict[str, Any]) -> Dict[str, Any]: kwargs = super().get_context_data(**kwargs) - kwargs['current_stage'] = self.executor.current_stage - kwargs['context'] = self.executor.plan.context + kwargs["current_stage"] = self.executor.current_stage + kwargs["context"] = self.executor.plan.context return kwargs def get_template_names(self) -> List[str]: From af8cdb34ee7f5d28a7500822bcf3ee30a8b58895 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Fri, 19 Jun 2020 20:35:38 +0200 Subject: [PATCH 22/64] *: fix not all migrations using db_alias --- e2e/test_provider_oidc.py | 4 +- passbook/core/migrations/0003_default_user.py | 4 +- .../flows/migrations/0004_source_flows.py | 50 ++++++++++++------- .../flows/migrations/0005_provider_flows.py | 10 ++-- 4 files changed, 45 insertions(+), 23 deletions(-) diff --git a/e2e/test_provider_oidc.py b/e2e/test_provider_oidc.py index 0c16c739d..52c9fa68d 100644 --- a/e2e/test_provider_oidc.py +++ b/e2e/test_provider_oidc.py @@ -79,7 +79,9 @@ class TestProviderOIDC(StaticLiveServerTestCase): """test OpenID Provider flow (invalid redirect URI, check error message)""" sleep(1) # Bootstrap all needed objects - authorization_flow = Flow.objects.get(slug="default-provider-authorization") + authorization_flow = Flow.objects.get( + slug="default-provider-authorization-implicit-consent" + ) client = Client.objects.create( name="grafana", client_type="confidential", diff --git a/passbook/core/migrations/0003_default_user.py b/passbook/core/migrations/0003_default_user.py index d236c8f90..63af2c780 100644 --- a/passbook/core/migrations/0003_default_user.py +++ b/passbook/core/migrations/0003_default_user.py @@ -9,7 +9,9 @@ def create_default_user(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): # We have to use a direct import here, otherwise we get an object manager error from passbook.core.models import User - pbadmin, _ = User.objects.get_or_create( + db_alias = schema_editor.connection.alias + + pbadmin, _ = User.objects.using(db_alias).get_or_create( username="pbadmin", email="root@localhost", name="passbook Default Admin" ) pbadmin.set_password("pbadmin") # noqa # nosec diff --git a/passbook/flows/migrations/0004_source_flows.py b/passbook/flows/migrations/0004_source_flows.py index 112ff34a3..746424f17 100644 --- a/passbook/flows/migrations/0004_source_flows.py +++ b/passbook/flows/migrations/0004_source_flows.py @@ -32,25 +32,27 @@ def create_default_source_enrollment_flow( db_alias = schema_editor.connection.alias # Create a policy that only allows this flow when doing an SSO Request - flow_policy = ExpressionPolicy.objects.create( + flow_policy = ExpressionPolicy.objects.using(db_alias).create( name="default-source-enrollment-if-sso", expression=FLOW_POLICY_EXPRESSION ) # This creates a Flow used by sources to enroll users # It makes sure that a username is set, and if not, prompts the user for a Username - flow = Flow.objects.create( + flow = Flow.objects.using(db_alias).create( name="default-source-enrollment", slug="default-source-enrollment", designation=FlowDesignation.ENROLLMENT, ) - PolicyBinding.objects.create(policy=flow_policy, target=flow, order=0) + PolicyBinding.objects.using(db_alias).create( + policy=flow_policy, target=flow, order=0 + ) # PromptStage to ask user for their username - prompt_stage = PromptStage.objects.create( + prompt_stage = PromptStage.objects.using(db_alias).create( name="default-source-enrollment-username-prompt", ) prompt_stage.fields.add( - Prompt.objects.create( + Prompt.objects.using(db_alias).create( field_key="username", label="Username", type=FieldTypes.TEXT, @@ -59,20 +61,30 @@ def create_default_source_enrollment_flow( ) ) # Policy to only trigger prompt when no username is given - prompt_policy = ExpressionPolicy.objects.create( + prompt_policy = ExpressionPolicy.objects.using(db_alias).create( name="default-source-enrollment-if-username", expression=PROMPT_POLICY_EXPRESSION, ) # UserWrite stage to create the user, and login stage to log user in - user_write = UserWriteStage.objects.create(name="default-source-enrollment-write") - user_login = UserLoginStage.objects.create(name="default-source-enrollment-login") + user_write = UserWriteStage.objects.using(db_alias).create( + name="default-source-enrollment-write" + ) + user_login = UserLoginStage.objects.using(db_alias).create( + name="default-source-enrollment-login" + ) - binding = FlowStageBinding.objects.create(flow=flow, stage=prompt_stage, order=0) - PolicyBinding.objects.create(policy=prompt_policy, target=binding) + binding = FlowStageBinding.objects.using(db_alias).create( + flow=flow, stage=prompt_stage, order=0 + ) + PolicyBinding.objects.using(db_alias).create(policy=prompt_policy, target=binding) - FlowStageBinding.objects.create(flow=flow, stage=user_write, order=1) - FlowStageBinding.objects.create(flow=flow, stage=user_login, order=2) + FlowStageBinding.objects.using(db_alias).create( + flow=flow, stage=user_write, order=1 + ) + FlowStageBinding.objects.using(db_alias).create( + flow=flow, stage=user_login, order=2 + ) def create_default_source_authentication_flow( @@ -91,22 +103,26 @@ def create_default_source_authentication_flow( db_alias = schema_editor.connection.alias # Create a policy that only allows this flow when doing an SSO Request - flow_policy = ExpressionPolicy.objects.create( + flow_policy = ExpressionPolicy.objects.using(db_alias).create( name="default-source-authentication-if-sso", expression=FLOW_POLICY_EXPRESSION ) # This creates a Flow used by sources to authenticate users - flow = Flow.objects.create( + flow = Flow.objects.using(db_alias).create( name="default-source-authentication", slug="default-source-authentication", designation=FlowDesignation.AUTHENTICATION, ) - PolicyBinding.objects.create(policy=flow_policy, target=flow, order=0) + PolicyBinding.objects.using(db_alias).create( + policy=flow_policy, target=flow, order=0 + ) - user_login = UserLoginStage.objects.create( + user_login = UserLoginStage.objects.using(db_alias).create( name="default-source-authentication-login" ) - FlowStageBinding.objects.create(flow=flow, stage=user_login, order=0) + FlowStageBinding.objects.using(db_alias).create( + flow=flow, stage=user_login, order=0 + ) class Migration(migrations.Migration): diff --git a/passbook/flows/migrations/0005_provider_flows.py b/passbook/flows/migrations/0005_provider_flows.py index 007f29475..6fa5fa48e 100644 --- a/passbook/flows/migrations/0005_provider_flows.py +++ b/passbook/flows/migrations/0005_provider_flows.py @@ -18,20 +18,22 @@ def create_default_provider_authz_flow( db_alias = schema_editor.connection.alias # Empty flow for providers where consent is implicitly given - Flow.objects.create( + Flow.objects.using(db_alias).create( name="Authorize Application", slug="default-provider-authorization-implicit-consent", designation=FlowDesignation.AUTHORIZATION, ) # Flow with consent form to obtain explicit user consent - flow = Flow.objects.create( + flow = Flow.objects.using(db_alias).create( name="Authorize Application", slug="default-provider-authorization-explicit-consent", designation=FlowDesignation.AUTHORIZATION, ) - stage = ConsentStage.objects.create(name="default-provider-authorization-consent") - FlowStageBinding.objects.create(flow=flow, stage=stage, order=0) + stage = ConsentStage.objects.using(db_alias).create( + name="default-provider-authorization-consent" + ) + FlowStageBinding.objects.using(db_alias).create(flow=flow, stage=stage, order=0) class Migration(migrations.Migration): From e91a8f88a0f23e2f1fb91c431214782f13d7fbeb Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Fri, 19 Jun 2020 20:57:42 +0200 Subject: [PATCH 23/64] ci: run full coverage including e2e --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 31f0eed4b..0e536cfef 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -131,7 +131,7 @@ jobs: docker-compose pull -q chrome docker-compose up -d chrome - name: Run coverage - run: pipenv run ./scripts/coverage.sh + run: pipenv run coverage run ./manage.py test --failfast - name: Create XML Report run: pipenv run coverage xml - uses: codecov/codecov-action@v1 From 14fd137f893c038c30822bcfc49827fd2bf45ae1 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Fri, 19 Jun 2020 22:27:20 +0200 Subject: [PATCH 24/64] root: improve test detection --- passbook/root/settings.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/passbook/root/settings.py b/passbook/root/settings.py index e9978e8fa..b36e411c0 100644 --- a/passbook/root/settings.py +++ b/passbook/root/settings.py @@ -356,9 +356,7 @@ TEST_OUTPUT_VERBOSE = 2 TEST_OUTPUT_FILE_NAME = "unittest.xml" -if any("test" in arg for arg in sys.argv): - LOGGER.warning("Testing mode enabled, no logging from now on...") - LOGGING = None +if len(sys.argv) >= 2 and sys.argv[1] == "test": TEST = True CELERY_TASK_ALWAYS_EAGER = True From 0310d463142713763a099aa4bf3bb70cf39d6d1c Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Fri, 19 Jun 2020 22:27:44 +0200 Subject: [PATCH 25/64] e2e: improve race-condition --- e2e/test_enroll_2_step.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/e2e/test_enroll_2_step.py b/e2e/test_enroll_2_step.py index d517d0144..bda3154c9 100644 --- a/e2e/test_enroll_2_step.py +++ b/e2e/test_enroll_2_step.py @@ -3,6 +3,8 @@ from django.contrib.staticfiles.testing import StaticLiveServerTestCase from selenium import webdriver from selenium.webdriver.common.by import By from selenium.webdriver.common.desired_capabilities import DesiredCapabilities +from selenium.webdriver.support import expected_conditions as EC +from selenium.webdriver.support.ui import WebDriverWait from e2e.utils import apply_default_data from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding @@ -22,6 +24,7 @@ class TestEnroll2Step(StaticLiveServerTestCase): command_executor="http://localhost:4444/wd/hub", desired_capabilities=DesiredCapabilities.CHROME, ) + self.wait = WebDriverWait(self.driver, 10) self.driver.implicitly_wait(5) apply_default_data() @@ -97,6 +100,8 @@ class TestEnroll2Step(StaticLiveServerTestCase): self.driver.find_element(By.ID, "id_email").send_keys("foo@bar.baz") self.driver.find_element(By.CSS_SELECTOR, ".pf-c-button").click() self.driver.find_element(By.LINK_TEXT, "foo").click() + + self.wait.until(EC.presence_of_element_located((By.ID, "id_username"))) self.assertEqual( self.driver.find_element(By.XPATH, "//a[contains(@href, '/-/user/')]").text, "foo", From f1e6d912891cbd912d340173d30f9c59c4d84e6b Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Fri, 19 Jun 2020 22:37:48 +0200 Subject: [PATCH 26/64] e2e: fix linting error --- e2e/test_enroll_2_step.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/e2e/test_enroll_2_step.py b/e2e/test_enroll_2_step.py index bda3154c9..143bf1cfc 100644 --- a/e2e/test_enroll_2_step.py +++ b/e2e/test_enroll_2_step.py @@ -3,7 +3,7 @@ from django.contrib.staticfiles.testing import StaticLiveServerTestCase from selenium import webdriver from selenium.webdriver.common.by import By from selenium.webdriver.common.desired_capabilities import DesiredCapabilities -from selenium.webdriver.support import expected_conditions as EC +from selenium.webdriver.support import expected_conditions as ec from selenium.webdriver.support.ui import WebDriverWait from e2e.utils import apply_default_data @@ -101,7 +101,7 @@ class TestEnroll2Step(StaticLiveServerTestCase): self.driver.find_element(By.CSS_SELECTOR, ".pf-c-button").click() self.driver.find_element(By.LINK_TEXT, "foo").click() - self.wait.until(EC.presence_of_element_located((By.ID, "id_username"))) + self.wait.until(ec.presence_of_element_located((By.ID, "id_username"))) self.assertEqual( self.driver.find_element(By.XPATH, "//a[contains(@href, '/-/user/')]").text, "foo", From 7b7305607c6a411ed0cf7c3a1507f03ba0ebe253 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Sat, 20 Jun 2020 15:48:54 +0200 Subject: [PATCH 27/64] root: enable debug logging when testing --- passbook/root/settings.py | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/passbook/root/settings.py b/passbook/root/settings.py index b36e411c0..9698ca73c 100644 --- a/passbook/root/settings.py +++ b/passbook/root/settings.py @@ -330,8 +330,19 @@ LOGGING = { }, "loggers": {}, } + +TEST = False +TEST_RUNNER = "xmlrunner.extra.djangotestrunner.XMLTestRunner" +TEST_OUTPUT_VERBOSE = 2 LOG_LEVEL = CONFIG.y("log_level").upper() +TEST_OUTPUT_FILE_NAME = "unittest.xml" + +if len(sys.argv) >= 2 and sys.argv[1] == "test": + LOG_LEVEL = "DEBUG" + TEST = True + CELERY_TASK_ALWAYS_EAGER = True + _LOGGING_HANDLER_MAP = { "": LOG_LEVEL, "passbook": LOG_LEVEL, @@ -350,16 +361,6 @@ for handler_name, level in _LOGGING_HANDLER_MAP.items(): "propagate": False, } -TEST = False -TEST_RUNNER = "xmlrunner.extra.djangotestrunner.XMLTestRunner" -TEST_OUTPUT_VERBOSE = 2 - -TEST_OUTPUT_FILE_NAME = "unittest.xml" - -if len(sys.argv) >= 2 and sys.argv[1] == "test": - TEST = True - CELERY_TASK_ALWAYS_EAGER = True - _DISALLOWED_ITEMS = [ "INSTALLED_APPS", From 68efcc7bf2f8099fc5dfa8e6acdb5106d9e34f30 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Sat, 20 Jun 2020 17:06:00 +0200 Subject: [PATCH 28/64] e2e: add custom testcase class to simplify code --- e2e/test_enroll_2_step.py | 17 +-------- e2e/test_login_default.py | 17 +-------- e2e/test_provider_oidc.py | 11 ++---- e2e/utils.py | 80 +++++++++++++++++++++++++-------------- passbook/flows/views.py | 4 +- 5 files changed, 61 insertions(+), 68 deletions(-) diff --git a/e2e/test_enroll_2_step.py b/e2e/test_enroll_2_step.py index 143bf1cfc..111284475 100644 --- a/e2e/test_enroll_2_step.py +++ b/e2e/test_enroll_2_step.py @@ -6,7 +6,7 @@ from selenium.webdriver.common.desired_capabilities import DesiredCapabilities from selenium.webdriver.support import expected_conditions as ec from selenium.webdriver.support.ui import WebDriverWait -from e2e.utils import apply_default_data +from e2e.utils import SeleniumTestCase from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding from passbook.policies.expression.models import ExpressionPolicy from passbook.policies.models import PolicyBinding @@ -16,22 +16,9 @@ from passbook.stages.user_login.models import UserLoginStage from passbook.stages.user_write.models import UserWriteStage -class TestEnroll2Step(StaticLiveServerTestCase): +class TestEnroll2Step(SeleniumTestCase): """Test 2-step enroll flow""" - def setUp(self): - self.driver = webdriver.Remote( - command_executor="http://localhost:4444/wd/hub", - desired_capabilities=DesiredCapabilities.CHROME, - ) - self.wait = WebDriverWait(self.driver, 10) - self.driver.implicitly_wait(5) - apply_default_data() - - def tearDown(self): - super().tearDown() - self.driver.quit() - def test_enroll_2_step(self): """Test 2-step enroll flow""" # First stage fields diff --git a/e2e/test_login_default.py b/e2e/test_login_default.py index c8bf5f445..0925d7b3f 100644 --- a/e2e/test_login_default.py +++ b/e2e/test_login_default.py @@ -1,28 +1,15 @@ """test default login flow""" -from django.contrib.staticfiles.testing import StaticLiveServerTestCase from selenium import webdriver from selenium.webdriver.common.by import By from selenium.webdriver.common.desired_capabilities import DesiredCapabilities from selenium.webdriver.common.keys import Keys -from e2e.utils import apply_default_data +from e2e.utils import SeleniumTestCase -class TestLogin(StaticLiveServerTestCase): +class TestLogin(SeleniumTestCase): """test default login flow""" - def setUp(self): - self.driver = webdriver.Remote( - command_executor="http://localhost:4444/wd/hub", - desired_capabilities=DesiredCapabilities.CHROME, - ) - self.driver.implicitly_wait(5) - apply_default_data() - - def tearDown(self): - super().tearDown() - self.driver.quit() - def test_login(self): """test default login flow""" self.driver.get(f"{self.live_server_url}/flows/default-authentication-flow/") diff --git a/e2e/test_provider_oidc.py b/e2e/test_provider_oidc.py index 52c9fa68d..127f6fd40 100644 --- a/e2e/test_provider_oidc.py +++ b/e2e/test_provider_oidc.py @@ -12,22 +12,17 @@ from selenium.webdriver.common.keys import Keys from docker import DockerClient, from_env from docker.models.containers import Container from docker.types import Healthcheck -from e2e.utils import apply_default_data, ensure_rsa_key +from e2e.utils import SeleniumTestCase, ensure_rsa_key from passbook.core.models import Application from passbook.flows.models import Flow from passbook.providers.oidc.models import OpenIDProvider -class TestProviderOIDC(StaticLiveServerTestCase): +class TestProviderOIDC(SeleniumTestCase): """test OpenID Provider flow""" def setUp(self): - self.driver = webdriver.Remote( - command_executor="http://localhost:4444/wd/hub", - desired_capabilities=DesiredCapabilities.CHROME, - ) - self.driver.implicitly_wait(5) - apply_default_data() + super().setUp() self.client_id = generate_client_id() self.client_secret = generate_client_secret() self.container = self.setup_client() diff --git a/e2e/utils.py b/e2e/utils.py index 5434e7da5..61fdf9b1b 100644 --- a/e2e/utils.py +++ b/e2e/utils.py @@ -1,41 +1,17 @@ """passbook e2e testing utilities""" - from glob import glob from importlib.util import module_from_spec, spec_from_file_location from inspect import getmembers, isfunction from Cryptodome.PublicKey import RSA from django.apps import apps +from django.contrib.staticfiles.testing import StaticLiveServerTestCase from django.db import connection, transaction from django.db.utils import IntegrityError - - -def apply_default_data(): - """apply objects created by migrations after tables have been truncated""" - # Find all migration files - # load all functions - migration_files = glob("**/migrations/*.py", recursive=True) - matches = [] - for migration in migration_files: - with open(migration, "r+") as migration_file: - # Check if they have a `RunPython` - if "RunPython" in migration_file.read(): - matches.append(migration) - - with connection.schema_editor() as schema_editor: - for match in matches: - # Load module from file path - spec = spec_from_file_location("", match) - migration_module = module_from_spec(spec) - # pyright: reportGeneralTypeIssues=false - spec.loader.exec_module(migration_module) - # Call all functions from module - for _, func in getmembers(migration_module, isfunction): - with transaction.atomic(): - try: - func(apps, schema_editor) - except IntegrityError: - pass +from selenium import webdriver +from selenium.webdriver.common.desired_capabilities import DesiredCapabilities +from selenium.webdriver.remote.webdriver import WebDriver +from selenium.webdriver.support.ui import WebDriverWait def ensure_rsa_key(): @@ -46,3 +22,49 @@ def ensure_rsa_key(): key = RSA.generate(2048) rsakey = RSAKey(key=key.exportKey("PEM").decode("utf8")) rsakey.save() + + +class SeleniumTestCase(StaticLiveServerTestCase): + def setUp(self): + super().setUp() + self.driver = self._get_driver() + self.driver.implicitly_wait(5) + self.wait = WebDriverWait(self.driver, 10) + self.apply_default_data() + + def _get_driver(self) -> WebDriver: + return webdriver.Remote( + command_executor="http://localhost:4444/wd/hub", + desired_capabilities=DesiredCapabilities.CHROME, + ) + + def tearDown(self): + super().tearDown() + self.driver.quit() + + def apply_default_data(self): + """apply objects created by migrations after tables have been truncated""" + # Find all migration files + # load all functions + migration_files = glob("**/migrations/*.py", recursive=True) + matches = [] + for migration in migration_files: + with open(migration, "r+") as migration_file: + # Check if they have a `RunPython` + if "RunPython" in migration_file.read(): + matches.append(migration) + + with connection.schema_editor() as schema_editor: + for match in matches: + # Load module from file path + spec = spec_from_file_location("", match) + migration_module = module_from_spec(spec) + # pyright: reportGeneralTypeIssues=false + spec.loader.exec_module(migration_module) + # Call all functions from module + for _, func in getmembers(migration_module, isfunction): + with transaction.atomic(): + try: + func(apps, schema_editor) + except IntegrityError: + pass diff --git a/passbook/flows/views.py b/passbook/flows/views.py index 1625e2c01..eea803b43 100644 --- a/passbook/flows/views.py +++ b/passbook/flows/views.py @@ -215,7 +215,9 @@ class FlowExecutorShellView(TemplateView): kwargs["exec_url"] = reverse("passbook_flows:flow-executor", kwargs=self.kwargs) kwargs["msg_url"] = reverse("passbook_api:messages-list") if NEXT_ARG_NAME in self.request.GET: - self.request.session[SESSION_KEY_NEXT] = self.request.GET[NEXT_ARG_NAME] + next_arg = self.request.GET[NEXT_ARG_NAME] + LOGGER.debug("f(exec/shell): Saved next param", next=next_arg) + self.request.session[SESSION_KEY_NEXT] = next_arg return kwargs From 17424ccc3b0dd0f147e727a93a7c6c01dab82c83 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Sat, 20 Jun 2020 17:06:15 +0200 Subject: [PATCH 29/64] e2e: use reverse instead of static URLs --- e2e/test_provider_oidc.py | 12 ++++++------ e2e/utils.py | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/e2e/test_provider_oidc.py b/e2e/test_provider_oidc.py index 127f6fd40..9a3d4e598 100644 --- a/e2e/test_provider_oidc.py +++ b/e2e/test_provider_oidc.py @@ -2,6 +2,7 @@ from time import sleep from django.contrib.staticfiles.testing import StaticLiveServerTestCase +from django.shortcuts import reverse from oauth2_provider.generators import generate_client_id, generate_client_secret from oidc_provider.models import Client, ResponseType from selenium import webdriver @@ -33,7 +34,7 @@ class TestProviderOIDC(SeleniumTestCase): container = client.containers.run( image="grafana/grafana:latest", detach=True, - name=f"passbook-e2e-grafana-client_{self.port}", + name="passbook-e2e-grafana-client", network_mode="host", auto_remove=True, healthcheck=Healthcheck( @@ -47,13 +48,13 @@ class TestProviderOIDC(SeleniumTestCase): "GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET": self.client_secret, "GF_AUTH_GENERIC_OAUTH_SCOPES": "openid email profile", "GF_AUTH_GENERIC_OAUTH_AUTH_URL": ( - f"{self.live_server_url}/application/oidc/authorize" + self.live_server_url + reverse("passbook_providers_oidc:authorize") ), "GF_AUTH_GENERIC_OAUTH_TOKEN_URL": ( - f"{self.live_server_url}/application/oidc/token" + self.live_server_url + reverse("oidc_provider:token") ), "GF_AUTH_GENERIC_OAUTH_API_URL": ( - f"{self.live_server_url}/application/oidc/userinfo" + self.live_server_url + reverse("oidc_provider:userinfo") ), "GF_LOG_LEVEL": "debug", }, @@ -66,9 +67,8 @@ class TestProviderOIDC(SeleniumTestCase): sleep(1) def tearDown(self): - super().tearDown() - self.driver.quit() self.container.kill() + super().tearDown() def test_redirect_uri_error(self): """test OpenID Provider flow (invalid redirect URI, check error message)""" diff --git a/e2e/utils.py b/e2e/utils.py index 61fdf9b1b..7577ac581 100644 --- a/e2e/utils.py +++ b/e2e/utils.py @@ -39,8 +39,8 @@ class SeleniumTestCase(StaticLiveServerTestCase): ) def tearDown(self): - super().tearDown() self.driver.quit() + super().tearDown() def apply_default_data(self): """apply objects created by migrations after tables have been truncated""" From 331faa53bcb09665d6407005071e3001378e2081 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Sat, 20 Jun 2020 19:35:48 +0200 Subject: [PATCH 30/64] providers/saml: fix metadata template using wrong templates --- passbook/providers/saml/models.py | 2 +- passbook/providers/saml/templates/saml/xml/metadata.xml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/passbook/providers/saml/models.py b/passbook/providers/saml/models.py index 26767fbbb..ee4fa31a5 100644 --- a/passbook/providers/saml/models.py +++ b/passbook/providers/saml/models.py @@ -25,7 +25,7 @@ class SAMLBindings(models.TextChoices): class SAMLProvider(Provider): - """Model to save information about a Remote SAML Endpoint""" + """SAML 2.0-based authentication protocol.""" name = models.TextField() processor_path = models.CharField(max_length=255, choices=[]) diff --git a/passbook/providers/saml/templates/saml/xml/metadata.xml b/passbook/providers/saml/templates/saml/xml/metadata.xml index f6a418c5c..3f480bb1e 100644 --- a/passbook/providers/saml/templates/saml/xml/metadata.xml +++ b/passbook/providers/saml/templates/saml/xml/metadata.xml @@ -11,7 +11,7 @@ {% endif %} {{ subject_format }} - - + + From 42e9ce4f7258a6c04358ab23ce865c4e67e980ce Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Sat, 20 Jun 2020 19:40:25 +0200 Subject: [PATCH 31/64] providers/*: fix plan stages not being injected properly --- passbook/providers/oauth/views/oauth2.py | 2 +- passbook/providers/saml/views.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/passbook/providers/oauth/views/oauth2.py b/passbook/providers/oauth/views/oauth2.py index 301d3e432..0196be52a 100644 --- a/passbook/providers/oauth/views/oauth2.py +++ b/passbook/providers/oauth/views/oauth2.py @@ -69,7 +69,7 @@ class AuthorizationFlowInitView(AccessMixin, View): PLAN_CONTEXT_NONCE: request.GET.get(PLAN_CONTEXT_NONCE), }, ) - plan.stages.append(in_memory_stage(OAuth2Stage)) + plan.append(in_memory_stage(OAuth2Stage)) self.request.session[SESSION_KEY_PLAN] = plan return redirect_with_qs( "passbook_flows:flow-executor-shell", diff --git a/passbook/providers/saml/views.py b/passbook/providers/saml/views.py index 8c182ecc3..9b2a3fb11 100644 --- a/passbook/providers/saml/views.py +++ b/passbook/providers/saml/views.py @@ -89,7 +89,7 @@ class SAMLSSOView(LoginRequiredMixin, SAMLAccessMixin, View): self.request, {PLAN_CONTEXT_SSO: True, PLAN_CONTEXT_APPLICATION: self.application}, ) - plan.stages.append(in_memory_stage(SAMLFlowFinalView)) + plan.append(in_memory_stage(SAMLFlowFinalView)) self.request.session[SESSION_KEY_PLAN] = plan return redirect_with_qs( "passbook_flows:flow-executor-shell", From a0f05caf8e8d2a5036aa1fc754cc2c2848ef1dc2 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Sat, 20 Jun 2020 21:49:16 +0200 Subject: [PATCH 32/64] providers/saml: move templates into correct folder --- passbook/providers/saml/forms.py | 2 +- passbook/providers/saml/processors/base.py | 2 +- passbook/providers/saml/processors/salesforce.py | 2 +- .../{saml/idp => providers/saml}/admin_metadata_modal.html | 0 .../{saml/idp => providers/saml}/autosubmit_form.html | 0 .../templates/{saml/idp => providers/saml}/logged_out.html | 0 .../{saml/idp => providers/saml}/property_mapping_form.html | 0 .../{ => providers}/saml/xml/assertions/generic.xml | 2 +- .../{ => providers}/saml/xml/assertions/google_apps.xml | 4 ++-- .../{ => providers}/saml/xml/assertions/salesforce.xml | 2 +- .../saml/templates/{ => providers}/saml/xml/attributes.xml | 0 .../saml/templates/{ => providers}/saml/xml/metadata.xml | 0 .../saml/templates/{ => providers}/saml/xml/response.xml | 0 .../saml/templates/{ => providers}/saml/xml/signature.xml | 0 .../saml/templates/{ => providers}/saml/xml/subject.xml | 0 passbook/providers/saml/utils/xml_render.py | 6 +++--- passbook/providers/saml/utils/xml_signing.py | 2 +- passbook/providers/saml/views.py | 4 ++-- passbook/root/settings.py | 1 - 19 files changed, 13 insertions(+), 14 deletions(-) rename passbook/providers/saml/templates/{saml/idp => providers/saml}/admin_metadata_modal.html (100%) rename passbook/providers/saml/templates/{saml/idp => providers/saml}/autosubmit_form.html (100%) rename passbook/providers/saml/templates/{saml/idp => providers/saml}/logged_out.html (100%) rename passbook/providers/saml/templates/{saml/idp => providers/saml}/property_mapping_form.html (100%) rename passbook/providers/saml/templates/{ => providers}/saml/xml/assertions/generic.xml (94%) rename passbook/providers/saml/templates/{ => providers}/saml/xml/assertions/google_apps.xml (85%) rename passbook/providers/saml/templates/{ => providers}/saml/xml/assertions/salesforce.xml (94%) rename passbook/providers/saml/templates/{ => providers}/saml/xml/attributes.xml (100%) rename passbook/providers/saml/templates/{ => providers}/saml/xml/metadata.xml (100%) rename passbook/providers/saml/templates/{ => providers}/saml/xml/response.xml (100%) rename passbook/providers/saml/templates/{ => providers}/saml/xml/signature.xml (100%) rename passbook/providers/saml/templates/{ => providers}/saml/xml/subject.xml (100%) diff --git a/passbook/providers/saml/forms.py b/passbook/providers/saml/forms.py index 01f44e519..a5b4d17c7 100644 --- a/passbook/providers/saml/forms.py +++ b/passbook/providers/saml/forms.py @@ -59,7 +59,7 @@ class SAMLProviderForm(forms.ModelForm): class SAMLPropertyMappingForm(forms.ModelForm): """SAML Property Mapping form""" - template_name = "saml/idp/property_mapping_form.html" + template_name = "providers/saml/property_mapping_form.html" def clean_expression(self): """Test Syntax""" diff --git a/passbook/providers/saml/processors/base.py b/passbook/providers/saml/processors/base.py index 966f687de..d80201510 100644 --- a/passbook/providers/saml/processors/base.py +++ b/passbook/providers/saml/processors/base.py @@ -132,7 +132,7 @@ class Processor: continue self._assertion_params["ATTRIBUTES"] = attributes self._assertion_xml = get_assertion_xml( - "saml/xml/assertions/generic.xml", self._assertion_params, signed=True + "providers/saml/xml/assertions/generic.xml", self._assertion_params, signed=True ) def _format_response(self): diff --git a/passbook/providers/saml/processors/salesforce.py b/passbook/providers/saml/processors/salesforce.py index b2d3a369a..2c43ca10d 100644 --- a/passbook/providers/saml/processors/salesforce.py +++ b/passbook/providers/saml/processors/salesforce.py @@ -10,5 +10,5 @@ class SalesForceProcessor(GenericProcessor): def _format_assertion(self): super()._format_assertion() self._assertion_xml = get_assertion_xml( - "saml/xml/assertions/salesforce.xml", self._assertion_params, signed=True + "providers/saml/xml/assertions/salesforce.xml", self._assertion_params, signed=True ) diff --git a/passbook/providers/saml/templates/saml/idp/admin_metadata_modal.html b/passbook/providers/saml/templates/providers/saml/admin_metadata_modal.html similarity index 100% rename from passbook/providers/saml/templates/saml/idp/admin_metadata_modal.html rename to passbook/providers/saml/templates/providers/saml/admin_metadata_modal.html diff --git a/passbook/providers/saml/templates/saml/idp/autosubmit_form.html b/passbook/providers/saml/templates/providers/saml/autosubmit_form.html similarity index 100% rename from passbook/providers/saml/templates/saml/idp/autosubmit_form.html rename to passbook/providers/saml/templates/providers/saml/autosubmit_form.html diff --git a/passbook/providers/saml/templates/saml/idp/logged_out.html b/passbook/providers/saml/templates/providers/saml/logged_out.html similarity index 100% rename from passbook/providers/saml/templates/saml/idp/logged_out.html rename to passbook/providers/saml/templates/providers/saml/logged_out.html diff --git a/passbook/providers/saml/templates/saml/idp/property_mapping_form.html b/passbook/providers/saml/templates/providers/saml/property_mapping_form.html similarity index 100% rename from passbook/providers/saml/templates/saml/idp/property_mapping_form.html rename to passbook/providers/saml/templates/providers/saml/property_mapping_form.html diff --git a/passbook/providers/saml/templates/saml/xml/assertions/generic.xml b/passbook/providers/saml/templates/providers/saml/xml/assertions/generic.xml similarity index 94% rename from passbook/providers/saml/templates/saml/xml/assertions/generic.xml rename to passbook/providers/saml/templates/providers/saml/xml/assertions/generic.xml index 1976b936c..c44402aa6 100644 --- a/passbook/providers/saml/templates/saml/xml/assertions/generic.xml +++ b/passbook/providers/saml/templates/providers/saml/xml/assertions/generic.xml @@ -3,7 +3,7 @@ IssueInstant="{{ ISSUE_INSTANT }}" Version="2.0"> {{ ISSUER }} - {% include 'saml/xml/signature.xml' %} + {% include 'providers/saml/xml/signature.xml' %} {{ SUBJECT_STATEMENT }} diff --git a/passbook/providers/saml/templates/saml/xml/assertions/google_apps.xml b/passbook/providers/saml/templates/providers/saml/xml/assertions/google_apps.xml similarity index 85% rename from passbook/providers/saml/templates/saml/xml/assertions/google_apps.xml rename to passbook/providers/saml/templates/providers/saml/xml/assertions/google_apps.xml index b4d262b45..8072b9ee4 100644 --- a/passbook/providers/saml/templates/saml/xml/assertions/google_apps.xml +++ b/passbook/providers/saml/templates/providers/saml/xml/assertions/google_apps.xml @@ -3,8 +3,8 @@ IssueInstant="{{ ISSUE_INSTANT }}" Version="2.0"> {{ ISSUER }} - {% include 'saml/xml/signature.xml' %} - {% include 'saml/xml/subject.xml' %} + {% include 'providers/saml/xml/signature.xml' %} + {% include 'providers/saml/xml/subject.xml' %} diff --git a/passbook/providers/saml/templates/saml/xml/assertions/salesforce.xml b/passbook/providers/saml/templates/providers/saml/xml/assertions/salesforce.xml similarity index 94% rename from passbook/providers/saml/templates/saml/xml/assertions/salesforce.xml rename to passbook/providers/saml/templates/providers/saml/xml/assertions/salesforce.xml index 7451553a0..8887714f2 100644 --- a/passbook/providers/saml/templates/saml/xml/assertions/salesforce.xml +++ b/passbook/providers/saml/templates/providers/saml/xml/assertions/salesforce.xml @@ -4,7 +4,7 @@ Version="2.0"> {{ ISSUER }} {{ ASSERTION_SIGNATURE|safe }} - {% include 'saml/xml/subject.xml' %} + {% include 'providers/saml/xml/subject.xml' %} {{ AUDIENCE }} diff --git a/passbook/providers/saml/templates/saml/xml/attributes.xml b/passbook/providers/saml/templates/providers/saml/xml/attributes.xml similarity index 100% rename from passbook/providers/saml/templates/saml/xml/attributes.xml rename to passbook/providers/saml/templates/providers/saml/xml/attributes.xml diff --git a/passbook/providers/saml/templates/saml/xml/metadata.xml b/passbook/providers/saml/templates/providers/saml/xml/metadata.xml similarity index 100% rename from passbook/providers/saml/templates/saml/xml/metadata.xml rename to passbook/providers/saml/templates/providers/saml/xml/metadata.xml diff --git a/passbook/providers/saml/templates/saml/xml/response.xml b/passbook/providers/saml/templates/providers/saml/xml/response.xml similarity index 100% rename from passbook/providers/saml/templates/saml/xml/response.xml rename to passbook/providers/saml/templates/providers/saml/xml/response.xml diff --git a/passbook/providers/saml/templates/saml/xml/signature.xml b/passbook/providers/saml/templates/providers/saml/xml/signature.xml similarity index 100% rename from passbook/providers/saml/templates/saml/xml/signature.xml rename to passbook/providers/saml/templates/providers/saml/xml/signature.xml diff --git a/passbook/providers/saml/templates/saml/xml/subject.xml b/passbook/providers/saml/templates/providers/saml/xml/subject.xml similarity index 100% rename from passbook/providers/saml/templates/saml/xml/subject.xml rename to passbook/providers/saml/templates/providers/saml/xml/subject.xml diff --git a/passbook/providers/saml/utils/xml_render.py b/passbook/providers/saml/utils/xml_render.py index d740f9ac8..58f1d37bb 100644 --- a/passbook/providers/saml/utils/xml_render.py +++ b/passbook/providers/saml/utils/xml_render.py @@ -28,7 +28,7 @@ def _get_attribute_statement(params): return # Build complete AttributeStatement. params["ATTRIBUTE_STATEMENT"] = render_to_string( - "saml/xml/attributes.xml", {"attributes": attributes} + "providers/saml/xml/attributes.xml", {"attributes": attributes} ) @@ -48,7 +48,7 @@ def _get_in_response_to(params): def _get_subject(params): """Insert Subject. Modifies the params dict.""" - params["SUBJECT_STATEMENT"] = render_to_string("saml/xml/subject.xml", params) + params["SUBJECT_STATEMENT"] = render_to_string("providers/saml/xml/subject.xml", params) def get_assertion_xml(template, parameters, signed=False): @@ -80,7 +80,7 @@ def get_response_xml(parameters, saml_provider: SAMLProvider, assertion_id=""): params["RESPONSE_SIGNATURE"] = "" _get_in_response_to(params) - raw_response = render_to_string("saml/xml/response.xml", params) + raw_response = render_to_string("providers/saml/xml/response.xml", params) if not saml_provider.signing_kp: return raw_response diff --git a/passbook/providers/saml/utils/xml_signing.py b/passbook/providers/saml/utils/xml_signing.py index 215b29830..ba9c0a1f4 100644 --- a/passbook/providers/saml/utils/xml_signing.py +++ b/passbook/providers/saml/utils/xml_signing.py @@ -35,4 +35,4 @@ def sign_with_signxml(data: str, provider: "SAMLProvider", reference_uri=None) - def get_signature_xml() -> str: """Returns XML Signature for subject.""" - return render_to_string("saml/xml/signature.xml", {}) + return render_to_string("providers/saml/xml/signature.xml", {}) diff --git a/passbook/providers/saml/views.py b/passbook/providers/saml/views.py index 9b2a3fb11..dbba7fa59 100644 --- a/passbook/providers/saml/views.py +++ b/passbook/providers/saml/views.py @@ -205,7 +205,7 @@ class SAMLFlowFinalView(StageView): if provider.sp_binding == SAMLBindings.POST: return render( self.request, - "saml/idp/autosubmit_form.html", + "providers/saml/autosubmit_form.html", { "url": response.acs_url, "application": application, @@ -257,7 +257,7 @@ class DescriptorDownloadView(LoginRequiredMixin, SAMLAccessMixin, View): ctx["cert_public_key"] = strip_pem_header( provider.signing_kp.certificate_data.replace("\r", "") ).replace("\n", "") - return render_to_string("saml/xml/metadata.xml", ctx) + return render_to_string("providers/saml/xml/metadata.xml", ctx) def get(self, request: HttpRequest, application_slug: str) -> HttpResponse: """Replies with the XML Metadata IDSSODescriptor.""" diff --git a/passbook/root/settings.py b/passbook/root/settings.py index 9698ca73c..739edc462 100644 --- a/passbook/root/settings.py +++ b/passbook/root/settings.py @@ -333,7 +333,6 @@ LOGGING = { TEST = False TEST_RUNNER = "xmlrunner.extra.djangotestrunner.XMLTestRunner" -TEST_OUTPUT_VERBOSE = 2 LOG_LEVEL = CONFIG.y("log_level").upper() TEST_OUTPUT_FILE_NAME = "unittest.xml" From e4cb9b7ff9b7cef11f3b23f48db4fea21512506f Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Sat, 20 Jun 2020 21:49:48 +0200 Subject: [PATCH 33/64] providers/saml: fix provider has no attribute sp_binding --- passbook/providers/saml/views.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/passbook/providers/saml/views.py b/passbook/providers/saml/views.py index dbba7fa59..5b7ce4bd8 100644 --- a/passbook/providers/saml/views.py +++ b/passbook/providers/saml/views.py @@ -188,7 +188,9 @@ class SAMLFlowFinalView(StageView): def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: application: Application = self.executor.plan.context[PLAN_CONTEXT_APPLICATION] - provider: SAMLProvider = application.provider + provider: SAMLProvider = get_object_or_404( + SAMLProvider, pk=application.provider_id + ) # Log Application Authorization Event.new( EventAction.AUTHORIZE_APPLICATION, From 37532754534f76b3e8f817c490d325b84d2e8a27 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Sat, 20 Jun 2020 21:51:52 +0200 Subject: [PATCH 34/64] providers/saml: make metadata accessible without authentication --- .../migrations/0004_auto_20200620_1950.py | 22 +++++++++++++++++++ passbook/providers/saml/models.py | 6 +++-- passbook/providers/saml/processors/base.py | 4 +++- .../providers/saml/processors/salesforce.py | 4 +++- passbook/providers/saml/utils/xml_render.py | 4 +++- passbook/providers/saml/views.py | 14 +++++------- 6 files changed, 41 insertions(+), 13 deletions(-) create mode 100644 passbook/providers/saml/migrations/0004_auto_20200620_1950.py diff --git a/passbook/providers/saml/migrations/0004_auto_20200620_1950.py b/passbook/providers/saml/migrations/0004_auto_20200620_1950.py new file mode 100644 index 000000000..175baeb5d --- /dev/null +++ b/passbook/providers/saml/migrations/0004_auto_20200620_1950.py @@ -0,0 +1,22 @@ +# Generated by Django 3.0.7 on 2020-06-20 19:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("passbook_providers_saml", "0003_samlprovider_sp_binding"), + ] + + operations = [ + migrations.AlterField( + model_name="samlprovider", + name="sp_binding", + field=models.TextField( + choices=[("redirect", "Redirect"), ("post", "Post")], + default="redirect", + verbose_name="Service Prodier Binding", + ), + ), + ] diff --git a/passbook/providers/saml/models.py b/passbook/providers/saml/models.py index ee4fa31a5..bcb1f7f57 100644 --- a/passbook/providers/saml/models.py +++ b/passbook/providers/saml/models.py @@ -34,7 +34,9 @@ class SAMLProvider(Provider): audience = models.TextField(default="") issuer = models.TextField(help_text=_("Also known as EntityID")) sp_binding = models.TextField( - choices=SAMLBindings.choices, default=SAMLBindings.REDIRECT + choices=SAMLBindings.choices, + default=SAMLBindings.REDIRECT, + verbose_name=_("Service Prodier Binding"), ) assertion_valid_not_before = models.TextField( @@ -142,7 +144,7 @@ class SAMLProvider(Provider): # pylint: disable=no-member metadata = DescriptorDownloadView.get_metadata(request, self) return render_to_string( - "saml/idp/admin_metadata_modal.html", + "providers/saml/admin_metadata_modal.html", {"provider": self, "metadata": metadata}, ) except Provider.application.RelatedObjectDoesNotExist: diff --git a/passbook/providers/saml/processors/base.py b/passbook/providers/saml/processors/base.py index d80201510..05228a0b0 100644 --- a/passbook/providers/saml/processors/base.py +++ b/passbook/providers/saml/processors/base.py @@ -132,7 +132,9 @@ class Processor: continue self._assertion_params["ATTRIBUTES"] = attributes self._assertion_xml = get_assertion_xml( - "providers/saml/xml/assertions/generic.xml", self._assertion_params, signed=True + "providers/saml/xml/assertions/generic.xml", + self._assertion_params, + signed=True, ) def _format_response(self): diff --git a/passbook/providers/saml/processors/salesforce.py b/passbook/providers/saml/processors/salesforce.py index 2c43ca10d..715b93c7f 100644 --- a/passbook/providers/saml/processors/salesforce.py +++ b/passbook/providers/saml/processors/salesforce.py @@ -10,5 +10,7 @@ class SalesForceProcessor(GenericProcessor): def _format_assertion(self): super()._format_assertion() self._assertion_xml = get_assertion_xml( - "providers/saml/xml/assertions/salesforce.xml", self._assertion_params, signed=True + "providers/saml/xml/assertions/salesforce.xml", + self._assertion_params, + signed=True, ) diff --git a/passbook/providers/saml/utils/xml_render.py b/passbook/providers/saml/utils/xml_render.py index 58f1d37bb..55a2dfb16 100644 --- a/passbook/providers/saml/utils/xml_render.py +++ b/passbook/providers/saml/utils/xml_render.py @@ -48,7 +48,9 @@ def _get_in_response_to(params): def _get_subject(params): """Insert Subject. Modifies the params dict.""" - params["SUBJECT_STATEMENT"] = render_to_string("providers/saml/xml/subject.xml", params) + params["SUBJECT_STATEMENT"] = render_to_string( + "providers/saml/xml/subject.xml", params + ) def get_assertion_xml(template, parameters, signed=False): diff --git a/passbook/providers/saml/views.py b/passbook/providers/saml/views.py index 5b7ce4bd8..187a012af 100644 --- a/passbook/providers/saml/views.py +++ b/passbook/providers/saml/views.py @@ -229,7 +229,7 @@ class SAMLFlowFinalView(StageView): return bad_request_message(request, "Invalid sp_binding specified") -class DescriptorDownloadView(LoginRequiredMixin, SAMLAccessMixin, View): +class DescriptorDownloadView(View): """Replies with the XML Metadata IDSSODescriptor.""" @staticmethod @@ -263,14 +263,12 @@ class DescriptorDownloadView(LoginRequiredMixin, SAMLAccessMixin, View): def get(self, request: HttpRequest, application_slug: str) -> HttpResponse: """Replies with the XML Metadata IDSSODescriptor.""" - self.application = get_object_or_404(Application, slug=application_slug) - self.provider: SAMLProvider = get_object_or_404( - SAMLProvider, pk=self.application.provider_id + application = get_object_or_404(Application, slug=application_slug) + provider: SAMLProvider = get_object_or_404( + SAMLProvider, pk=application.provider_id ) - if not self._has_access(): - raise PermissionDenied() try: - metadata = DescriptorDownloadView.get_metadata(request, self.provider) + metadata = DescriptorDownloadView.get_metadata(request, provider) except Provider.application.RelatedObjectDoesNotExist: # pylint: disable=no-member return bad_request_message( request, "Provider is not assigned to an application." @@ -279,5 +277,5 @@ class DescriptorDownloadView(LoginRequiredMixin, SAMLAccessMixin, View): response = HttpResponse(metadata, content_type="application/xml") response[ "Content-Disposition" - ] = f'attachment; filename="{self.provider.name}_passbook_meta.xml"' + ] = f'attachment; filename="{provider.name}_passbook_meta.xml"' return response From c97b946a00657615551a665496c8b92ef27824f5 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Sat, 20 Jun 2020 22:30:45 +0200 Subject: [PATCH 35/64] providers/saml: make SAML provider compatible with consent --- .../saml/templates/providers/saml/consent.html | 14 ++++++++++++++ passbook/providers/saml/views.py | 7 ++++++- 2 files changed, 20 insertions(+), 1 deletion(-) create mode 100644 passbook/providers/saml/templates/providers/saml/consent.html diff --git a/passbook/providers/saml/templates/providers/saml/consent.html b/passbook/providers/saml/templates/providers/saml/consent.html new file mode 100644 index 000000000..f998f7119 --- /dev/null +++ b/passbook/providers/saml/templates/providers/saml/consent.html @@ -0,0 +1,14 @@ +{% extends 'login/form_with_user.html' %} + +{% load i18n %} + +{% block beneath_form %} +
    +

    + {% blocktrans with name=context.application.name %} + You're about to sign into {{ name }}. + {% endblocktrans %} +

    + {{ hidden_inputs }} +
    +{% endblock %} diff --git a/passbook/providers/saml/views.py b/passbook/providers/saml/views.py index 187a012af..adc741475 100644 --- a/passbook/providers/saml/views.py +++ b/passbook/providers/saml/views.py @@ -32,6 +32,7 @@ from passbook.policies.engine import PolicyEngine from passbook.providers.saml.exceptions import CannotHandleAssertion from passbook.providers.saml.models import SAMLBindings, SAMLProvider from passbook.providers.saml.processors.types import SAMLResponseParams +from passbook.stages.consent.stage import PLAN_CONTEXT_CONSENT_TEMPLATE LOGGER = get_logger() URL_VALIDATOR = URLValidator(schemes=("http", "https")) @@ -87,7 +88,11 @@ class SAMLSSOView(LoginRequiredMixin, SAMLAccessMixin, View): planner.allow_empty_flows = True plan = planner.plan( self.request, - {PLAN_CONTEXT_SSO: True, PLAN_CONTEXT_APPLICATION: self.application}, + { + PLAN_CONTEXT_SSO: True, + PLAN_CONTEXT_APPLICATION: self.application, + PLAN_CONTEXT_CONSENT_TEMPLATE: "providers/saml/consent.html", + }, ) plan.append(in_memory_stage(SAMLFlowFinalView)) self.request.session[SESSION_KEY_PLAN] = plan From 4d81172a48090786e3ffb86b09d099cb58d29ef4 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Sat, 20 Jun 2020 23:30:53 +0200 Subject: [PATCH 36/64] providers/oauth: add support for consent stage, cleanup --- passbook/providers/oauth/models.py | 2 +- .../templates/oauth2_provider/authorize.html | 73 ------------------- .../oauth/templates/oauth2_provider/base.html | 1 - .../templates/providers/oauth/consent.html | 20 +++++ .../oauth}/setup_url_modal.html | 0 passbook/providers/oauth/views/oauth2.py | 18 ++++- 6 files changed, 36 insertions(+), 78 deletions(-) delete mode 100644 passbook/providers/oauth/templates/oauth2_provider/authorize.html delete mode 100644 passbook/providers/oauth/templates/oauth2_provider/base.html create mode 100644 passbook/providers/oauth/templates/providers/oauth/consent.html rename passbook/providers/oauth/templates/{oauth2_provider => providers/oauth}/setup_url_modal.html (100%) diff --git a/passbook/providers/oauth/models.py b/passbook/providers/oauth/models.py index c42d3bcb4..157f8a21a 100644 --- a/passbook/providers/oauth/models.py +++ b/passbook/providers/oauth/models.py @@ -23,7 +23,7 @@ class OAuth2Provider(Provider, AbstractApplication): def html_setup_urls(self, request: HttpRequest) -> Optional[str]: """return template and context modal with URLs for authorize, token, openid-config, etc""" return render_to_string( - "oauth2_provider/setup_url_modal.html", + "providers/oauth/setup_url_modal.html", { "provider": self, "authorize_url": request.build_absolute_uri( diff --git a/passbook/providers/oauth/templates/oauth2_provider/authorize.html b/passbook/providers/oauth/templates/oauth2_provider/authorize.html deleted file mode 100644 index 24635ad37..000000000 --- a/passbook/providers/oauth/templates/oauth2_provider/authorize.html +++ /dev/null @@ -1,73 +0,0 @@ -{% extends "login/base.html" %} - -{% load passbook_utils %} -{% load i18n %} - -{% block card_title %} -{% trans 'Authorize Application' %} -{% endblock %} - -{% block card %} -
    - {% csrf_token %} - {% if not error %} - {% csrf_token %} - {% for field in form %} - {% if field.is_hidden %} - {{ field }} - {% endif %} - {% endfor %} -
    -

    - {% blocktrans with remote=application.name %} - You're about to sign into {{ remote }}. - {% endblocktrans %} -

    -

    {% trans "Application requires following permissions" %}

    -
      - {% for scope in scopes_descriptions %} -
    • {{ scope }}
    • - {% endfor %} -
    - {{ form.errors }} - {{ form.non_field_errors }} -
    -
    -

    - {% blocktrans with user=user %} - You are logged in as {{ user }}. Not you? - {% endblocktrans %} - {% trans 'Logout' %} -

    -
    - - - {% else %} - - {% endif %} -
    -{% endblock %} - -{% block scripts %} - -{% endblock %} diff --git a/passbook/providers/oauth/templates/oauth2_provider/base.html b/passbook/providers/oauth/templates/oauth2_provider/base.html deleted file mode 100644 index 8759a6fae..000000000 --- a/passbook/providers/oauth/templates/oauth2_provider/base.html +++ /dev/null @@ -1 +0,0 @@ -{% extends "base/skeleton.html" %} \ No newline at end of file diff --git a/passbook/providers/oauth/templates/providers/oauth/consent.html b/passbook/providers/oauth/templates/providers/oauth/consent.html new file mode 100644 index 000000000..0fb2170c7 --- /dev/null +++ b/passbook/providers/oauth/templates/providers/oauth/consent.html @@ -0,0 +1,20 @@ +{% extends 'login/form_with_user.html' %} + +{% load i18n %} + +{% block beneath_form %} +
    +

    + {% blocktrans with name=context.application.name %} + You're about to sign into {{ name }}. + {% endblocktrans %} +

    +

    {% trans "Application requires following permissions" %}

    +
      + {% for scope in context.scope_descriptions %} +
    • {{ scope }}
    • + {% endfor %} +
    + {{ hidden_inputs }} +
    +{% endblock %} diff --git a/passbook/providers/oauth/templates/oauth2_provider/setup_url_modal.html b/passbook/providers/oauth/templates/providers/oauth/setup_url_modal.html similarity index 100% rename from passbook/providers/oauth/templates/oauth2_provider/setup_url_modal.html rename to passbook/providers/oauth/templates/providers/oauth/setup_url_modal.html diff --git a/passbook/providers/oauth/views/oauth2.py b/passbook/providers/oauth/views/oauth2.py index 0196be52a..95d5a93d8 100644 --- a/passbook/providers/oauth/views/oauth2.py +++ b/passbook/providers/oauth/views/oauth2.py @@ -1,9 +1,11 @@ """passbook OAuth2 Views""" from django.contrib import messages +from django.contrib.auth.mixins import LoginRequiredMixin from django.http import HttpRequest, HttpResponse, HttpResponseRedirect from django.shortcuts import get_object_or_404, redirect from django.views import View from oauth2_provider.exceptions import OAuthToolkitError +from oauth2_provider.scopes import get_scopes_backend from oauth2_provider.views.base import AuthorizationView from structlog import get_logger @@ -20,6 +22,7 @@ from passbook.flows.stage import StageView from passbook.flows.views import SESSION_KEY_PLAN from passbook.lib.utils.urls import redirect_with_qs from passbook.providers.oauth.models import OAuth2Provider +from passbook.stages.consent.stage import PLAN_CONTEXT_CONSENT_TEMPLATE LOGGER = get_logger() @@ -32,9 +35,10 @@ PLAN_CONTEXT_CODE_CHALLENGE = "code_challenge" PLAN_CONTEXT_CODE_CHALLENGE_METHOD = "code_challenge_method" PLAN_CONTEXT_SCOPE = "scope" PLAN_CONTEXT_NONCE = "nonce" +PLAN_CONTEXT_SCOPE_DESCRIPTION = "scope_descriptions" -class AuthorizationFlowInitView(AccessMixin, View): +class AuthorizationFlowInitView(AccessMixin, LoginRequiredMixin, View): """OAuth2 Flow initializer, checks access to application and starts flow""" # pylint: disable=unused-argument @@ -54,8 +58,11 @@ class AuthorizationFlowInitView(AccessMixin, View): return redirect("passbook_providers_oauth:oauth2-permission-denied") # Regardless, we start the planner and return to it planner = FlowPlanner(provider.authorization_flow) - # planner.use_cache = False planner.allow_empty_flows = True + # Save scope descriptions + scopes = request.GET.get(PLAN_CONTEXT_SCOPE) + all_scopes = get_scopes_backend().get_all_scopes() + plan = planner.plan( self.request, { @@ -65,10 +72,15 @@ class AuthorizationFlowInitView(AccessMixin, View): PLAN_CONTEXT_REDIRECT_URI: request.GET.get(PLAN_CONTEXT_REDIRECT_URI), PLAN_CONTEXT_RESPONSE_TYPE: request.GET.get(PLAN_CONTEXT_RESPONSE_TYPE), PLAN_CONTEXT_STATE: request.GET.get(PLAN_CONTEXT_STATE), - PLAN_CONTEXT_SCOPE: request.GET.get(PLAN_CONTEXT_SCOPE), + PLAN_CONTEXT_SCOPE: scopes, PLAN_CONTEXT_NONCE: request.GET.get(PLAN_CONTEXT_NONCE), + PLAN_CONTEXT_SCOPE_DESCRIPTION: [ + all_scopes[scope] for scope in scopes.split(" ") + ], + PLAN_CONTEXT_CONSENT_TEMPLATE: "providers/oauth/consent.html", }, ) + plan.append(in_memory_stage(OAuth2Stage)) self.request.session[SESSION_KEY_PLAN] = plan return redirect_with_qs( From e4a9a84646052f037c535f44ba5486f3c5c3fac3 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Sat, 20 Jun 2020 23:52:06 +0200 Subject: [PATCH 37/64] e2e: cleanup, use USER function instead of typing static strings --- e2e/test_enroll_2_step.py | 10 +++------- e2e/test_login_default.py | 10 ++++------ e2e/test_provider_oidc.py | 34 +++++++++++++++------------------- e2e/utils.py | 12 ++++++++++++ 4 files changed, 34 insertions(+), 32 deletions(-) diff --git a/e2e/test_enroll_2_step.py b/e2e/test_enroll_2_step.py index 111284475..c85915290 100644 --- a/e2e/test_enroll_2_step.py +++ b/e2e/test_enroll_2_step.py @@ -1,12 +1,8 @@ """Test 2-step enroll flow""" -from django.contrib.staticfiles.testing import StaticLiveServerTestCase -from selenium import webdriver from selenium.webdriver.common.by import By -from selenium.webdriver.common.desired_capabilities import DesiredCapabilities from selenium.webdriver.support import expected_conditions as ec -from selenium.webdriver.support.ui import WebDriverWait -from e2e.utils import SeleniumTestCase +from e2e.utils import USER, SeleniumTestCase from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding from passbook.policies.expression.models import ExpressionPolicy from passbook.policies.models import PolicyBinding @@ -80,8 +76,8 @@ class TestEnroll2Step(SeleniumTestCase): self.driver.get(self.live_server_url) self.driver.find_element(By.CSS_SELECTOR, "[role=enroll]").click() self.driver.find_element(By.ID, "id_username").send_keys("foo") - self.driver.find_element(By.ID, "id_password").send_keys("pbadmin") - self.driver.find_element(By.ID, "id_password_repeat").send_keys("pbadmin") + self.driver.find_element(By.ID, "id_password").send_keys(USER().username) + self.driver.find_element(By.ID, "id_password_repeat").send_keys(USER().username) self.driver.find_element(By.CSS_SELECTOR, ".pf-c-button").click() self.driver.find_element(By.ID, "id_name").send_keys("some name") self.driver.find_element(By.ID, "id_email").send_keys("foo@bar.baz") diff --git a/e2e/test_login_default.py b/e2e/test_login_default.py index 0925d7b3f..5b0d8bcad 100644 --- a/e2e/test_login_default.py +++ b/e2e/test_login_default.py @@ -1,10 +1,8 @@ """test default login flow""" -from selenium import webdriver from selenium.webdriver.common.by import By -from selenium.webdriver.common.desired_capabilities import DesiredCapabilities from selenium.webdriver.common.keys import Keys -from e2e.utils import SeleniumTestCase +from e2e.utils import USER, SeleniumTestCase class TestLogin(SeleniumTestCase): @@ -14,11 +12,11 @@ class TestLogin(SeleniumTestCase): """test default login flow""" self.driver.get(f"{self.live_server_url}/flows/default-authentication-flow/") self.driver.find_element(By.ID, "id_uid_field").click() - self.driver.find_element(By.ID, "id_uid_field").send_keys("pbadmin") + self.driver.find_element(By.ID, "id_uid_field").send_keys(USER().username) self.driver.find_element(By.ID, "id_uid_field").send_keys(Keys.ENTER) - self.driver.find_element(By.ID, "id_password").send_keys("pbadmin") + self.driver.find_element(By.ID, "id_password").send_keys(USER().username) self.driver.find_element(By.ID, "id_password").send_keys(Keys.ENTER) self.assertEqual( self.driver.find_element(By.XPATH, "//a[contains(@href, '/-/user/')]").text, - "pbadmin", + USER().username, ) diff --git a/e2e/test_provider_oidc.py b/e2e/test_provider_oidc.py index 9a3d4e598..82f37dfe5 100644 --- a/e2e/test_provider_oidc.py +++ b/e2e/test_provider_oidc.py @@ -1,19 +1,16 @@ """test OpenID Provider flow""" from time import sleep -from django.contrib.staticfiles.testing import StaticLiveServerTestCase from django.shortcuts import reverse from oauth2_provider.generators import generate_client_id, generate_client_secret from oidc_provider.models import Client, ResponseType -from selenium import webdriver from selenium.webdriver.common.by import By -from selenium.webdriver.common.desired_capabilities import DesiredCapabilities from selenium.webdriver.common.keys import Keys from docker import DockerClient, from_env from docker.models.containers import Container from docker.types import Healthcheck -from e2e.utils import SeleniumTestCase, ensure_rsa_key +from e2e.utils import USER, SeleniumTestCase, ensure_rsa_key from passbook.core.models import Application from passbook.flows.models import Flow from passbook.providers.oidc.models import OpenIDProvider @@ -34,7 +31,6 @@ class TestProviderOIDC(SeleniumTestCase): container = client.containers.run( image="grafana/grafana:latest", detach=True, - name="passbook-e2e-grafana-client", network_mode="host", auto_remove=True, healthcheck=Healthcheck( @@ -101,9 +97,9 @@ class TestProviderOIDC(SeleniumTestCase): self.driver.get("http://localhost:3000") self.driver.find_element(By.CLASS_NAME, "btn-service--oauth").click() self.driver.find_element(By.ID, "id_uid_field").click() - self.driver.find_element(By.ID, "id_uid_field").send_keys("pbadmin") + self.driver.find_element(By.ID, "id_uid_field").send_keys(USER().username) self.driver.find_element(By.ID, "id_uid_field").send_keys(Keys.ENTER) - self.driver.find_element(By.ID, "id_password").send_keys("pbadmin") + self.driver.find_element(By.ID, "id_password").send_keys(USER().username) self.driver.find_element(By.ID, "id_password").send_keys(Keys.ENTER) sleep(2) self.assertEqual( @@ -144,35 +140,35 @@ class TestProviderOIDC(SeleniumTestCase): self.driver.get("http://localhost:3000") self.driver.find_element(By.CLASS_NAME, "btn-service--oauth").click() self.driver.find_element(By.ID, "id_uid_field").click() - self.driver.find_element(By.ID, "id_uid_field").send_keys("pbadmin") + self.driver.find_element(By.ID, "id_uid_field").send_keys(USER().username) self.driver.find_element(By.ID, "id_uid_field").send_keys(Keys.ENTER) - self.driver.find_element(By.ID, "id_password").send_keys("pbadmin") + self.driver.find_element(By.ID, "id_password").send_keys(USER().username) self.driver.find_element(By.ID, "id_password").send_keys(Keys.ENTER) self.driver.find_element(By.XPATH, "//a[contains(@href, '/profile')]").click() self.assertEqual( self.driver.find_element(By.CLASS_NAME, "page-header__title").text, - "passbook Default Admin", + USER().name, ) self.assertEqual( self.driver.find_element( By.XPATH, "/html/body/grafana-app/div/div/div/react-profile-wrapper/form[1]/div[1]/div/input", ).get_attribute("value"), - "passbook Default Admin", + USER().name, ) self.assertEqual( self.driver.find_element( By.XPATH, "/html/body/grafana-app/div/div/div/react-profile-wrapper/form[1]/div[2]/div/input", ).get_attribute("value"), - "root@localhost", + USER().email, ) self.assertEqual( self.driver.find_element( By.XPATH, "/html/body/grafana-app/div/div/div/react-profile-wrapper/form[1]/div[3]/div/input", ).get_attribute("value"), - "root@localhost", + USER().email, ) def test_authorization_consent_explicit(self): @@ -208,9 +204,9 @@ class TestProviderOIDC(SeleniumTestCase): self.driver.get("http://localhost:3000") self.driver.find_element(By.CLASS_NAME, "btn-service--oauth").click() self.driver.find_element(By.ID, "id_uid_field").click() - self.driver.find_element(By.ID, "id_uid_field").send_keys("pbadmin") + self.driver.find_element(By.ID, "id_uid_field").send_keys(USER().username) self.driver.find_element(By.ID, "id_uid_field").send_keys(Keys.ENTER) - self.driver.find_element(By.ID, "id_password").send_keys("pbadmin") + self.driver.find_element(By.ID, "id_password").send_keys(USER().username) self.driver.find_element(By.ID, "id_password").send_keys(Keys.ENTER) self.assertIn( @@ -224,26 +220,26 @@ class TestProviderOIDC(SeleniumTestCase): self.driver.find_element(By.XPATH, "//a[contains(@href, '/profile')]").click() self.assertEqual( self.driver.find_element(By.CLASS_NAME, "page-header__title").text, - "passbook Default Admin", + USER().name, ) self.assertEqual( self.driver.find_element( By.XPATH, "/html/body/grafana-app/div/div/div/react-profile-wrapper/form[1]/div[1]/div/input", ).get_attribute("value"), - "passbook Default Admin", + USER().name, ) self.assertEqual( self.driver.find_element( By.XPATH, "/html/body/grafana-app/div/div/div/react-profile-wrapper/form[1]/div[2]/div/input", ).get_attribute("value"), - "root@localhost", + USER().email, ) self.assertEqual( self.driver.find_element( By.XPATH, "/html/body/grafana-app/div/div/div/react-profile-wrapper/form[1]/div[3]/div/input", ).get_attribute("value"), - "root@localhost", + USER().email, ) diff --git a/e2e/utils.py b/e2e/utils.py index 7577ac581..d01c894c3 100644 --- a/e2e/utils.py +++ b/e2e/utils.py @@ -1,4 +1,5 @@ """passbook e2e testing utilities""" +from functools import lru_cache from glob import glob from importlib.util import module_from_spec, spec_from_file_location from inspect import getmembers, isfunction @@ -13,6 +14,15 @@ from selenium.webdriver.common.desired_capabilities import DesiredCapabilities from selenium.webdriver.remote.webdriver import WebDriver from selenium.webdriver.support.ui import WebDriverWait +from passbook.core.models import User + + +@lru_cache +# pylint: disable=invalid-name +def USER() -> User: + """Cached function that always returns pbadmin""" + return User.objects.get(username="pbadmin") + def ensure_rsa_key(): """Ensure that at least one RSAKey Object exists, create one if none exist""" @@ -25,6 +35,8 @@ def ensure_rsa_key(): class SeleniumTestCase(StaticLiveServerTestCase): + """StaticLiveServerTestCase which automatically creates a Webdriver instance""" + def setUp(self): super().setUp() self.driver = self._get_driver() From 4285175bbaad08fc9a50ef9c34fa95be518d9d89 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Sat, 20 Jun 2020 23:53:05 +0200 Subject: [PATCH 38/64] e2e: add tests for oauth and saml provider --- e2e/test_provider_oauth.py | 195 +++++++++++++++++++++++++++++++++++++ e2e/test_provider_saml.py | 175 +++++++++++++++++++++++++++++++++ 2 files changed, 370 insertions(+) create mode 100644 e2e/test_provider_oauth.py create mode 100644 e2e/test_provider_saml.py diff --git a/e2e/test_provider_oauth.py b/e2e/test_provider_oauth.py new file mode 100644 index 000000000..58a9a7f32 --- /dev/null +++ b/e2e/test_provider_oauth.py @@ -0,0 +1,195 @@ +"""test OAuth Provider flow""" +from time import sleep + +from django.shortcuts import reverse +from oauth2_provider.generators import generate_client_id, generate_client_secret +from selenium.webdriver.common.by import By +from selenium.webdriver.common.keys import Keys + +from docker import DockerClient, from_env +from docker.models.containers import Container +from docker.types import Healthcheck +from e2e.utils import USER, SeleniumTestCase +from passbook.core.models import Application +from passbook.flows.models import Flow +from passbook.providers.oauth.models import OAuth2Provider + + +class TestProviderOAuth(SeleniumTestCase): + """test OAuth Provider flow""" + + def setUp(self): + super().setUp() + self.client_id = generate_client_id() + self.client_secret = generate_client_secret() + self.container = self.setup_client() + + def setup_client(self) -> Container: + """Setup client grafana container which we test OAuth against""" + client: DockerClient = from_env() + container = client.containers.run( + image="grafana/grafana:latest", + detach=True, + network_mode="host", + auto_remove=True, + healthcheck=Healthcheck( + test=["CMD", "wget", "--spider", "http://localhost:3000"], + interval=5 * 100 * 1000000, + start_period=1 * 100 * 1000000, + ), + environment={ + "GF_AUTH_GITHUB_ENABLED": "true", + "GF_AUTH_GITHUB_allow_sign_up": "true", + "GF_AUTH_GITHUB_CLIENT_ID": self.client_id, + "GF_AUTH_GITHUB_CLIENT_SECRET": self.client_secret, + "GF_AUTH_GITHUB_SCOPES": "user:email,read:org", + "GF_AUTH_GITHUB_AUTH_URL": ( + self.live_server_url + + reverse("passbook_providers_oauth:github-authorize") + ), + "GF_AUTH_GITHUB_TOKEN_URL": ( + self.live_server_url + + reverse("passbook_providers_oauth:github-access-token") + ), + "GF_AUTH_GITHUB_API_URL": ( + self.live_server_url + + reverse("passbook_providers_oauth:github-user") + ), + "GF_LOG_LEVEL": "debug", + }, + ) + while True: + container.reload() + status = container.attrs.get("State", {}).get("Health", {}).get("Status") + if status == "healthy": + return container + sleep(1) + + def tearDown(self): + self.container.kill() + super().tearDown() + + def test_authorization_consent_implied(self): + """test OAuth Provider flow (default authorization flow with implied consent)""" + sleep(1) + # Bootstrap all needed objects + authorization_flow = Flow.objects.get( + slug="default-provider-authorization-implicit-consent" + ) + provider = OAuth2Provider.objects.create( + name="grafana", + client_type=OAuth2Provider.CLIENT_CONFIDENTIAL, + authorization_grant_type=OAuth2Provider.GRANT_AUTHORIZATION_CODE, + client_id=self.client_id, + client_secret=self.client_secret, + redirect_uris="http://localhost:3000/login/github", + skip_authorization=True, + authorization_flow=authorization_flow, + ) + Application.objects.create( + name="Grafana", slug="grafana", provider=provider, + ) + + self.driver.get("http://localhost:3000") + self.driver.find_element(By.CLASS_NAME, "btn-service--github").click() + self.driver.find_element(By.ID, "id_uid_field").click() + self.driver.find_element(By.ID, "id_uid_field").send_keys(USER().username) + self.driver.find_element(By.ID, "id_uid_field").send_keys(Keys.ENTER) + self.driver.find_element(By.ID, "id_password").send_keys(USER().username) + self.driver.find_element(By.ID, "id_password").send_keys(Keys.ENTER) + self.driver.find_element(By.XPATH, "//a[contains(@href, '/profile')]").click() + self.assertEqual( + self.driver.find_element(By.CLASS_NAME, "page-header__title").text, + USER().username, + ) + self.assertEqual( + self.driver.find_element( + By.XPATH, + "/html/body/grafana-app/div/div/div/react-profile-wrapper/form[1]/div[1]/div/input", + ).get_attribute("value"), + USER().username, + ) + self.assertEqual( + self.driver.find_element( + By.XPATH, + "/html/body/grafana-app/div/div/div/react-profile-wrapper/form[1]/div[2]/div/input", + ).get_attribute("value"), + USER().email, + ) + self.assertEqual( + self.driver.find_element( + By.XPATH, + "/html/body/grafana-app/div/div/div/react-profile-wrapper/form[1]/div[3]/div/input", + ).get_attribute("value"), + USER().username, + ) + + def test_authorization_consent_explicit(self): + """test OAuth Provider flow (default authorization flow with explicit consent)""" + sleep(1) + # Bootstrap all needed objects + authorization_flow = Flow.objects.get( + slug="default-provider-authorization-explicit-consent" + ) + provider = OAuth2Provider.objects.create( + name="grafana", + client_type=OAuth2Provider.CLIENT_CONFIDENTIAL, + authorization_grant_type=OAuth2Provider.GRANT_AUTHORIZATION_CODE, + client_id=self.client_id, + client_secret=self.client_secret, + redirect_uris="http://localhost:3000/login/github", + skip_authorization=True, + authorization_flow=authorization_flow, + ) + app = Application.objects.create( + name="Grafana", slug="grafana", provider=provider, + ) + + self.driver.get("http://localhost:3000") + self.driver.find_element(By.CLASS_NAME, "btn-service--github").click() + self.driver.find_element(By.ID, "id_uid_field").click() + self.driver.find_element(By.ID, "id_uid_field").send_keys(USER().username) + self.driver.find_element(By.ID, "id_uid_field").send_keys(Keys.ENTER) + self.driver.find_element(By.ID, "id_password").send_keys(USER().username) + self.driver.find_element(By.ID, "id_password").send_keys(Keys.ENTER) + + self.assertIn( + app.name, + self.driver.find_element( + By.XPATH, "/html/body/div[2]/div/main/div/form/div[2]/p[1]" + ).text, + ) + self.assertEqual( + "GitHub Compatibility: User Email", + self.driver.find_element( + By.XPATH, "/html/body/div[2]/div/main/div/form/div[2]/ul/li[1]" + ).text, + ) + self.driver.find_element(By.CSS_SELECTOR, "[type=submit]").click() + + self.driver.find_element(By.XPATH, "//a[contains(@href, '/profile')]").click() + self.assertEqual( + self.driver.find_element(By.CLASS_NAME, "page-header__title").text, + USER().username, + ) + self.assertEqual( + self.driver.find_element( + By.XPATH, + "/html/body/grafana-app/div/div/div/react-profile-wrapper/form[1]/div[1]/div/input", + ).get_attribute("value"), + USER().username, + ) + self.assertEqual( + self.driver.find_element( + By.XPATH, + "/html/body/grafana-app/div/div/div/react-profile-wrapper/form[1]/div[2]/div/input", + ).get_attribute("value"), + USER().email, + ) + self.assertEqual( + self.driver.find_element( + By.XPATH, + "/html/body/grafana-app/div/div/div/react-profile-wrapper/form[1]/div[3]/div/input", + ).get_attribute("value"), + USER().username, + ) diff --git a/e2e/test_provider_saml.py b/e2e/test_provider_saml.py new file mode 100644 index 000000000..242bd8686 --- /dev/null +++ b/e2e/test_provider_saml.py @@ -0,0 +1,175 @@ +"""test SAML Provider flow""" +from time import sleep + +from django.shortcuts import reverse +from selenium.webdriver.common.by import By +from selenium.webdriver.common.keys import Keys + +from docker import DockerClient, from_env +from docker.models.containers import Container +from docker.types import Healthcheck +from e2e.utils import USER, SeleniumTestCase +from passbook.core.models import Application +from passbook.crypto.models import CertificateKeyPair +from passbook.flows.models import Flow +from passbook.lib.utils.reflection import class_to_path +from passbook.providers.saml.models import ( + SAMLBindings, + SAMLPropertyMapping, + SAMLProvider, +) +from passbook.providers.saml.processors.generic import GenericProcessor + + +class TestProviderSAML(SeleniumTestCase): + """test SAML Provider flow""" + + container: Container + + def setup_client(self, provider: SAMLProvider) -> Container: + """Setup client saml-sp container which we test SAML against""" + client: DockerClient = from_env() + container = client.containers.run( + image="beryju/saml-test-sp", + detach=True, + network_mode="host", + auto_remove=True, + healthcheck=Healthcheck( + test=["CMD", "wget", "--spider", "http://localhost:9009/health"], + interval=5 * 100 * 1000000, + start_period=1 * 100 * 1000000, + ), + environment={ + "SP_ENTITY_ID": provider.issuer, + "SP_SSO_BINDING": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST", + "SP_METADATA_URL": ( + self.live_server_url + + reverse( + "passbook_providers_saml:metadata", + kwargs={"application_slug": provider.application.slug}, + ) + ), + }, + ) + while True: + container.reload() + status = container.attrs.get("State", {}).get("Health", {}).get("Status") + if status == "healthy": + return container + sleep(1) + + def tearDown(self): + self.container.kill() + super().tearDown() + + def test_sp_initiated_implicit(self): + """test SAML Provider flow SP-initiated flow (implicit consent)""" + # Bootstrap all needed objects + authorization_flow = Flow.objects.get( + slug="default-provider-authorization-implicit-consent" + ) + provider: SAMLProvider = SAMLProvider.objects.create( + name="saml-test", + processor_path=class_to_path(GenericProcessor), + acs_url="http://localhost:9009/saml/acs", + audience="passbook-e2e", + issuer="passbook-e2e", + sp_binding=SAMLBindings.POST, + authorization_flow=authorization_flow, + signing_kp=CertificateKeyPair.objects.first(), + ) + provider.property_mappings.set(SAMLPropertyMapping.objects.all()) + provider.save() + Application.objects.create( + name="SAML", slug="passbook-saml", provider=provider, + ) + self.container = self.setup_client(provider) + self.driver.get("http://localhost:9009") + self.driver.find_element(By.ID, "id_uid_field").click() + self.driver.find_element(By.ID, "id_uid_field").send_keys(USER().username) + self.driver.find_element(By.ID, "id_uid_field").send_keys(Keys.ENTER) + self.driver.find_element(By.ID, "id_password").send_keys(USER().username) + self.driver.find_element(By.ID, "id_password").send_keys(Keys.ENTER) + self.assertEqual( + self.driver.find_element(By.XPATH, "/html/body/pre").text, + f"Hello, {USER().name}!", + ) + + def test_sp_initiated_explicit(self): + """test SAML Provider flow SP-initiated flow (explicit consent)""" + # Bootstrap all needed objects + authorization_flow = Flow.objects.get( + slug="default-provider-authorization-explicit-consent" + ) + provider: SAMLProvider = SAMLProvider.objects.create( + name="saml-test", + processor_path=class_to_path(GenericProcessor), + acs_url="http://localhost:9009/saml/acs", + audience="passbook-e2e", + issuer="passbook-e2e", + sp_binding=SAMLBindings.POST, + authorization_flow=authorization_flow, + signing_kp=CertificateKeyPair.objects.first(), + ) + provider.property_mappings.set(SAMLPropertyMapping.objects.all()) + provider.save() + app = Application.objects.create( + name="SAML", slug="passbook-saml", provider=provider, + ) + self.container = self.setup_client(provider) + self.driver.get("http://localhost:9009") + self.driver.find_element(By.ID, "id_uid_field").click() + self.driver.find_element(By.ID, "id_uid_field").send_keys(USER().username) + self.driver.find_element(By.ID, "id_uid_field").send_keys(Keys.ENTER) + self.driver.find_element(By.ID, "id_password").send_keys(USER().username) + self.driver.find_element(By.ID, "id_password").send_keys(Keys.ENTER) + self.assertIn( + app.name, + self.driver.find_element( + By.XPATH, "/html/body/div[2]/div/main/div/form/div[2]/p[1]" + ).text, + ) + self.driver.find_element(By.CSS_SELECTOR, "[type=submit]").click() + self.assertEqual( + self.driver.find_element(By.XPATH, "/html/body/pre").text, + f"Hello, {USER().name}!", + ) + + def test_idp_initiated_implicit(self): + """test SAML Provider flow IdP-initiated flow (implicit consent)""" + # Bootstrap all needed objects + authorization_flow = Flow.objects.get( + slug="default-provider-authorization-implicit-consent" + ) + provider: SAMLProvider = SAMLProvider.objects.create( + name="saml-test", + processor_path=class_to_path(GenericProcessor), + acs_url="http://localhost:9009/saml/acs", + audience="passbook-e2e", + issuer="passbook-e2e", + sp_binding=SAMLBindings.POST, + authorization_flow=authorization_flow, + signing_kp=CertificateKeyPair.objects.first(), + ) + provider.property_mappings.set(SAMLPropertyMapping.objects.all()) + provider.save() + Application.objects.create( + name="SAML", slug="passbook-saml", provider=provider, + ) + self.container = self.setup_client(provider) + self.driver.get( + self.live_server_url + + reverse( + "passbook_providers_saml:sso-init", + kwargs={"application_slug": provider.application.slug}, + ) + ) + self.driver.find_element(By.ID, "id_uid_field").click() + self.driver.find_element(By.ID, "id_uid_field").send_keys(USER().username) + self.driver.find_element(By.ID, "id_uid_field").send_keys(Keys.ENTER) + self.driver.find_element(By.ID, "id_password").send_keys(USER().username) + self.driver.find_element(By.ID, "id_password").send_keys(Keys.ENTER) + self.assertEqual( + self.driver.find_element(By.XPATH, "/html/body/pre").text, + f"Hello, {USER().name}!", + ) From 7e47b64b053fde9df59a0c2d07c172b169c867dc Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Sat, 20 Jun 2020 23:56:35 +0200 Subject: [PATCH 39/64] e2e: SeleniumTestCase: add url() to reverse into full URL --- e2e/test_provider_oauth.py | 16 ++++++---------- e2e/test_provider_saml.py | 11 ++++------- e2e/utils.py | 5 +++++ 3 files changed, 15 insertions(+), 17 deletions(-) diff --git a/e2e/test_provider_oauth.py b/e2e/test_provider_oauth.py index 58a9a7f32..7f4225480 100644 --- a/e2e/test_provider_oauth.py +++ b/e2e/test_provider_oauth.py @@ -1,7 +1,6 @@ """test OAuth Provider flow""" from time import sleep -from django.shortcuts import reverse from oauth2_provider.generators import generate_client_id, generate_client_secret from selenium.webdriver.common.by import By from selenium.webdriver.common.keys import Keys @@ -43,17 +42,14 @@ class TestProviderOAuth(SeleniumTestCase): "GF_AUTH_GITHUB_CLIENT_ID": self.client_id, "GF_AUTH_GITHUB_CLIENT_SECRET": self.client_secret, "GF_AUTH_GITHUB_SCOPES": "user:email,read:org", - "GF_AUTH_GITHUB_AUTH_URL": ( - self.live_server_url - + reverse("passbook_providers_oauth:github-authorize") + "GF_AUTH_GITHUB_AUTH_URL": self.url( + "passbook_providers_oauth:github-authorize" ), - "GF_AUTH_GITHUB_TOKEN_URL": ( - self.live_server_url - + reverse("passbook_providers_oauth:github-access-token") + "GF_AUTH_GITHUB_TOKEN_URL": self.url( + "passbook_providers_oauth:github-access-token" ), - "GF_AUTH_GITHUB_API_URL": ( - self.live_server_url - + reverse("passbook_providers_oauth:github-user") + "GF_AUTH_GITHUB_API_URL": self.url( + "passbook_providers_oauth:github-user" ), "GF_LOG_LEVEL": "debug", }, diff --git a/e2e/test_provider_saml.py b/e2e/test_provider_saml.py index 242bd8686..790a0ec37 100644 --- a/e2e/test_provider_saml.py +++ b/e2e/test_provider_saml.py @@ -1,7 +1,6 @@ """test SAML Provider flow""" from time import sleep -from django.shortcuts import reverse from selenium.webdriver.common.by import By from selenium.webdriver.common.keys import Keys @@ -43,10 +42,9 @@ class TestProviderSAML(SeleniumTestCase): "SP_ENTITY_ID": provider.issuer, "SP_SSO_BINDING": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST", "SP_METADATA_URL": ( - self.live_server_url - + reverse( + self.url( "passbook_providers_saml:metadata", - kwargs={"application_slug": provider.application.slug}, + application_slug=provider.application.slug, ) ), }, @@ -158,10 +156,9 @@ class TestProviderSAML(SeleniumTestCase): ) self.container = self.setup_client(provider) self.driver.get( - self.live_server_url - + reverse( + self.url( "passbook_providers_saml:sso-init", - kwargs={"application_slug": provider.application.slug}, + application_slug=provider.application.slug, ) ) self.driver.find_element(By.ID, "id_uid_field").click() diff --git a/e2e/utils.py b/e2e/utils.py index d01c894c3..138fdd7f0 100644 --- a/e2e/utils.py +++ b/e2e/utils.py @@ -9,6 +9,7 @@ from django.apps import apps from django.contrib.staticfiles.testing import StaticLiveServerTestCase from django.db import connection, transaction from django.db.utils import IntegrityError +from django.shortcuts import reverse from selenium import webdriver from selenium.webdriver.common.desired_capabilities import DesiredCapabilities from selenium.webdriver.remote.webdriver import WebDriver @@ -54,6 +55,10 @@ class SeleniumTestCase(StaticLiveServerTestCase): self.driver.quit() super().tearDown() + def url(self, view, **kwargs) -> str: + """reverse `view` with `**kwargs` into full URL using live_server_url""" + return self.live_server_url + reverse(view, kwargs=kwargs) + def apply_default_data(self): """apply objects created by migrations after tables have been truncated""" # Find all migration files From 246d00bddec4e936b5f4ffb7c86d8c53a80f79c7 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Sun, 21 Jun 2020 00:26:29 +0200 Subject: [PATCH 40/64] e2e: fix lint error --- e2e/utils.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/e2e/utils.py b/e2e/utils.py index 138fdd7f0..2683bf1da 100644 --- a/e2e/utils.py +++ b/e2e/utils.py @@ -20,7 +20,7 @@ from passbook.core.models import User @lru_cache # pylint: disable=invalid-name -def USER() -> User: +def USER() -> User: # noqa """Cached function that always returns pbadmin""" return User.objects.get(username="pbadmin") @@ -41,6 +41,7 @@ class SeleniumTestCase(StaticLiveServerTestCase): def setUp(self): super().setUp() self.driver = self._get_driver() + self.driver.maximize_window() self.driver.implicitly_wait(5) self.wait = WebDriverWait(self.driver, 10) self.apply_default_data() From 6122dcacc75163628a2544f6819d7623e93245d0 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Sun, 21 Jun 2020 12:40:01 +0200 Subject: [PATCH 41/64] flows: fix flow cache not being cleared correctly when stages are saved --- passbook/flows/apps.py | 6 ++++++ passbook/flows/signals.py | 31 +++++++++++++++++++++++++++++++ passbook/policies/apps.py | 2 +- passbook/root/settings.py | 1 + 4 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 passbook/flows/signals.py diff --git a/passbook/flows/apps.py b/passbook/flows/apps.py index 11f2d21d4..f31f699da 100644 --- a/passbook/flows/apps.py +++ b/passbook/flows/apps.py @@ -1,4 +1,6 @@ """passbook flows app config""" +from importlib import import_module + from django.apps import AppConfig @@ -9,3 +11,7 @@ class PassbookFlowsConfig(AppConfig): label = "passbook_flows" mountpoint = "flows/" verbose_name = "passbook Flows" + + def ready(self): + """Load policy cache clearing signals""" + import_module("passbook.flows.signals") diff --git a/passbook/flows/signals.py b/passbook/flows/signals.py new file mode 100644 index 000000000..d4353ef94 --- /dev/null +++ b/passbook/flows/signals.py @@ -0,0 +1,31 @@ +"""passbook flow signals""" +from django.core.cache import cache +from django.db.models.signals import post_save +from django.dispatch import receiver +from structlog import get_logger + +LOGGER = get_logger() + + +@receiver(post_save) +# pylint: disable=unused-argument +def invalidate_flow_cache(sender, instance, **_): + """Invalidate flow cache when flow is updated""" + from passbook.flows.models import Flow, FlowStageBinding, Stage + from passbook.flows.planner import cache_key + + if isinstance(instance, Flow): + LOGGER.debug("Invalidating Flow cache", flow=instance) + cache.delete(f"{cache_key(instance)}*") + if isinstance(instance, FlowStageBinding): + LOGGER.debug("Invalidating Flow cache from FlowStageBinding", binding=instance) + cache.delete(f"{cache_key(instance.flow)}*") + if isinstance(instance, Stage): + LOGGER.debug("Invalidating Flow cache from Stage", stage=instance) + total = 0 + for binding in FlowStageBinding.objects.filter(stage=instance): + prefix = cache_key(binding.flow) + keys = cache.keys(f"{prefix}*") + total += len(keys) + cache.delete_many(keys) + LOGGER.debug("Deleted keys", len=total) diff --git a/passbook/policies/apps.py b/passbook/policies/apps.py index 946f84609..f300fe6f3 100644 --- a/passbook/policies/apps.py +++ b/passbook/policies/apps.py @@ -12,5 +12,5 @@ class PassbookPoliciesConfig(AppConfig): verbose_name = "passbook Policies" def ready(self): - """Load source_types from config file""" + """Load policy cache clearing signals""" import_module("passbook.policies.signals") diff --git a/passbook/root/settings.py b/passbook/root/settings.py index 739edc462..43983c159 100644 --- a/passbook/root/settings.py +++ b/passbook/root/settings.py @@ -347,6 +347,7 @@ _LOGGING_HANDLER_MAP = { "passbook": LOG_LEVEL, "django": "WARNING", "celery": "WARNING", + "selenium": "WARNING", "grpc": LOG_LEVEL, "oauthlib": LOG_LEVEL, "oauth2_provider": LOG_LEVEL, From 6fdaac9a7db8312191f0e1d6aedc806acbf82e60 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Sun, 21 Jun 2020 12:42:24 +0200 Subject: [PATCH 42/64] e2e: rewrite enroll test to use admin interface for setup --- e2e/test_enroll_2_step.py | 340 +++++++++++++++++++++++++++++++------- 1 file changed, 277 insertions(+), 63 deletions(-) diff --git a/e2e/test_enroll_2_step.py b/e2e/test_enroll_2_step.py index c85915290..19cad0eb8 100644 --- a/e2e/test_enroll_2_step.py +++ b/e2e/test_enroll_2_step.py @@ -1,78 +1,292 @@ """Test 2-step enroll flow""" from selenium.webdriver.common.by import By +from selenium.webdriver.common.keys import Keys from selenium.webdriver.support import expected_conditions as ec from e2e.utils import USER, SeleniumTestCase -from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding -from passbook.policies.expression.models import ExpressionPolicy -from passbook.policies.models import PolicyBinding -from passbook.stages.identification.models import IdentificationStage -from passbook.stages.prompt.models import FieldTypes, Prompt, PromptStage -from passbook.stages.user_login.models import UserLoginStage -from passbook.stages.user_write.models import UserWriteStage class TestEnroll2Step(SeleniumTestCase): """Test 2-step enroll flow""" + # pylint: disable=too-many-statements + def setup_test_enroll_2_step(self): + """Setup all required objects""" + self.driver.find_element(By.ID, "id_uid_field").send_keys(USER().username) + self.driver.find_element(By.ID, "id_uid_field").send_keys(Keys.ENTER) + self.driver.find_element(By.ID, "id_password").send_keys(USER().username) + self.driver.find_element(By.ID, "id_password").send_keys(Keys.ENTER) + self.driver.find_element(By.LINK_TEXT, "Administrate").click() + self.driver.find_element(By.LINK_TEXT, "Prompts").click() + + # Create Password Prompt + self.driver.find_element(By.LINK_TEXT, "Create").click() + self.driver.find_element(By.ID, "id_field_key").send_keys("password") + self.driver.find_element(By.ID, "id_label").send_keys("Password") + dropdown = self.driver.find_element(By.ID, "id_type") + dropdown.find_element(By.XPATH, "//option[. = 'Password']").click() + self.driver.find_element(By.ID, "id_placeholder").send_keys("Password") + self.driver.find_element(By.ID, "id_order").send_keys("1") + self.driver.find_element( + By.CSS_SELECTOR, ".pf-c-form__actions > .pf-m-primary" + ).click() + + # Create Password Repeat Prompt + self.driver.find_element(By.LINK_TEXT, "Create").click() + self.driver.find_element(By.ID, "id_field_key").send_keys("password_repeat") + self.driver.find_element(By.ID, "id_label").send_keys("Password (repeat)") + dropdown = self.driver.find_element(By.ID, "id_type") + dropdown.find_element(By.XPATH, "//option[. = 'Password']").click() + self.driver.find_element(By.ID, "id_placeholder").send_keys("Password (repeat)") + self.driver.find_element(By.ID, "id_order").send_keys("2") + self.driver.find_element( + By.CSS_SELECTOR, ".pf-c-form__actions > .pf-m-primary" + ).click() + + # Create Name Prompt + self.driver.find_element(By.LINK_TEXT, "Create").click() + self.driver.find_element(By.ID, "id_field_key").send_keys("name") + self.driver.find_element(By.ID, "id_label").send_keys("Name") + dropdown = self.driver.find_element(By.ID, "id_type") + dropdown.find_element(By.XPATH, "//option[. = 'Text']").click() + self.driver.find_element(By.ID, "id_placeholder").send_keys("Name") + self.driver.find_element(By.ID, "id_order").send_keys("0") + self.driver.find_element( + By.CSS_SELECTOR, ".pf-c-form__actions > .pf-m-primary" + ).click() + + # Create Email Prompt + self.driver.find_element(By.LINK_TEXT, "Create").click() + self.driver.find_element(By.ID, "id_field_key").send_keys("email") + self.driver.find_element(By.ID, "id_label").send_keys("Email") + dropdown = self.driver.find_element(By.ID, "id_type") + dropdown.find_element(By.XPATH, "//option[. = 'Email']").click() + self.driver.find_element(By.ID, "id_placeholder").send_keys("Email") + self.driver.find_element(By.ID, "id_order").send_keys("1") + self.driver.find_element( + By.CSS_SELECTOR, ".pf-c-form__actions > .pf-m-primary" + ).click() + + self.driver.find_element(By.LINK_TEXT, "Stages").click() + + # Create first enroll prompt stage + self.driver.find_element(By.CSS_SELECTOR, ".pf-c-dropdown__toggle").click() + self.driver.find_element( + By.CSS_SELECTOR, "li:nth-child(9) > .pf-c-dropdown__menu-item > small" + ).click() + self.driver.find_element(By.ID, "id_name").send_keys( + "enroll-prompt-stage-first" + ) + dropdown = self.driver.find_element(By.ID, "id_fields") + dropdown.find_element( + By.XPATH, "//option[. = \"Prompt 'username' type=text\"]" + ).click() + dropdown.find_element( + By.XPATH, "//option[. = \"Prompt 'password' type=password\"]" + ).click() + dropdown.find_element( + By.XPATH, "//option[. = \"Prompt 'password_repeat' type=password\"]" + ).click() + self.driver.find_element( + By.CSS_SELECTOR, ".pf-c-form__actions > .pf-m-primary" + ).click() + + # Create second enroll prompt stage + self.driver.find_element(By.CSS_SELECTOR, ".pf-c-dropdown__toggle").click() + self.driver.find_element( + By.CSS_SELECTOR, "li:nth-child(9) > .pf-c-dropdown__menu-item" + ).click() + self.driver.find_element(By.ID, "id_name").send_keys( + "enroll-prompt-stage-second" + ) + dropdown = self.driver.find_element(By.ID, "id_fields") + dropdown.find_element( + By.XPATH, "//option[. = \"Prompt 'name' type=text\"]" + ).click() + dropdown.find_element( + By.XPATH, "//option[. = \"Prompt 'email' type=email\"]" + ).click() + self.driver.find_element( + By.CSS_SELECTOR, ".pf-c-form__actions > .pf-m-primary" + ).click() + + # Create user write stage + self.driver.find_element(By.CSS_SELECTOR, ".pf-c-dropdown__toggle").click() + self.driver.find_element( + By.CSS_SELECTOR, "li:nth-child(13) > .pf-c-dropdown__menu-item" + ).click() + self.driver.find_element(By.ID, "id_name").send_keys("enroll-user-write") + self.driver.find_element(By.ID, "id_name").send_keys(Keys.ENTER) + self.driver.find_element(By.CSS_SELECTOR, ".pf-c-dropdown__toggle").click() + + # Create user login stage + self.driver.find_element( + By.CSS_SELECTOR, "li:nth-child(11) > .pf-c-dropdown__menu-item" + ).click() + self.driver.find_element(By.ID, "id_name").send_keys("enroll-user-login") + self.driver.find_element( + By.CSS_SELECTOR, ".pf-c-form__actions > .pf-m-primary" + ).click() + + self.driver.find_element( + By.CSS_SELECTOR, + ".pf-c-nav__item:nth-child(7) .pf-c-nav__item:nth-child(1) > .pf-c-nav__link", + ).click() + + # Create password policy + self.driver.find_element(By.CSS_SELECTOR, ".pf-c-dropdown__toggle").click() + self.driver.find_element( + By.CSS_SELECTOR, "li:nth-child(2) > .pf-c-dropdown__menu-item > small" + ).click() + self.driver.find_element(By.ID, "id_name").send_keys( + "policy-enrollment-password-equals" + ) + self.driver.find_element(By.CSS_SELECTOR, ".CodeMirror-scroll").click() + self.driver.find_element(By.CSS_SELECTOR, ".CodeMirror textarea").send_keys( + "return request.context['password'] == request.context['password_repeat']" + ) + self.driver.find_element( + By.CSS_SELECTOR, ".pf-c-form__actions > .pf-m-primary" + ).click() + + # Create password policy binding + self.driver.find_element( + By.CSS_SELECTOR, + ".pf-c-nav__item:nth-child(7) .pf-c-nav__item:nth-child(2) > .pf-c-nav__link", + ).click() + self.driver.find_element(By.LINK_TEXT, "Create").click() + dropdown = self.driver.find_element(By.ID, "id_policy") + dropdown.find_element( + By.XPATH, '//option[. = "Policy policy-enrollment-password-equals"]' + ).click() + self.driver.find_element(By.ID, "id_target").click() + dropdown = self.driver.find_element(By.ID, "id_target") + dropdown.find_element( + By.XPATH, '//option[. = "Prompt Stage enroll-prompt-stage-first"]' + ).click() + self.driver.find_element(By.ID, "id_order").send_keys("0") + self.driver.find_element( + By.CSS_SELECTOR, ".pf-c-form__actions > .pf-m-primary" + ).click() + + # Create Flow + self.driver.find_element( + By.CSS_SELECTOR, + ".pf-c-nav__item:nth-child(6) .pf-c-nav__item:nth-child(1) > .pf-c-nav__link", + ).click() + self.driver.find_element(By.LINK_TEXT, "Create").click() + self.driver.find_element(By.ID, "id_name").send_keys("Welcome") + self.driver.find_element(By.ID, "id_slug").clear() + self.driver.find_element(By.ID, "id_slug").send_keys("default-enrollment-flow") + dropdown = self.driver.find_element(By.ID, "id_designation") + dropdown.find_element(By.XPATH, '//option[. = "Enrollment"]').click() + self.driver.find_element( + By.CSS_SELECTOR, ".pf-c-form__actions > .pf-m-primary" + ).click() + + self.driver.find_element(By.LINK_TEXT, "Stages").click() + + # Edit identification stage + self.driver.find_element( + By.CSS_SELECTOR, "tr:nth-child(11) .pf-m-secondary" + ).click() + self.driver.find_element( + By.CSS_SELECTOR, + ".pf-c-form__group:nth-child(5) .pf-c-form__horizontal-group", + ).click() + self.driver.find_element(By.ID, "id_enrollment_flow").click() + dropdown = self.driver.find_element(By.ID, "id_enrollment_flow") + dropdown.find_element( + By.XPATH, '//option[. = "Flow Welcome (default-enrollment-flow)"]' + ).click() + self.driver.find_element(By.ID, "id_user_fields_add_all_link").click() + self.driver.find_element( + By.CSS_SELECTOR, ".pf-c-form__actions > .pf-m-primary" + ).click() + + self.driver.find_element(By.LINK_TEXT, "Bindings").click() + + # Create Stage binding for first prompt stage + self.driver.find_element(By.LINK_TEXT, "Create").click() + self.driver.find_element(By.ID, "id_flow").click() + dropdown = self.driver.find_element(By.ID, "id_flow") + dropdown.find_element( + By.XPATH, '//option[. = "Flow Welcome (default-enrollment-flow)"]' + ).click() + self.driver.find_element(By.CSS_SELECTOR, ".pf-c-form").click() + self.driver.find_element(By.ID, "id_stage").click() + dropdown = self.driver.find_element(By.ID, "id_stage") + dropdown.find_element( + By.XPATH, '//option[. = "Stage enroll-prompt-stage-first"]' + ).click() + self.driver.find_element(By.ID, "id_order").click() + self.driver.find_element(By.ID, "id_order").send_keys("0") + self.driver.find_element( + By.CSS_SELECTOR, ".pf-c-form__actions > .pf-m-primary" + ).click() + + # Create Stage binding for second prompt stage + self.driver.find_element(By.LINK_TEXT, "Create").click() + self.driver.find_element(By.ID, "id_flow").click() + dropdown = self.driver.find_element(By.ID, "id_flow") + dropdown.find_element( + By.XPATH, '//option[. = "Flow Welcome (default-enrollment-flow)"]' + ).click() + self.driver.find_element(By.ID, "id_stage").click() + dropdown = self.driver.find_element(By.ID, "id_stage") + dropdown.find_element( + By.XPATH, '//option[. = "Stage enroll-prompt-stage-second"]' + ).click() + self.driver.find_element(By.ID, "id_order").click() + self.driver.find_element(By.ID, "id_order").send_keys("1") + self.driver.find_element( + By.CSS_SELECTOR, ".pf-c-form__actions > .pf-m-primary" + ).click() + + # Create Stage binding for user write stage + self.driver.find_element(By.LINK_TEXT, "Create").click() + self.driver.find_element(By.ID, "id_flow").click() + dropdown = self.driver.find_element(By.ID, "id_flow") + dropdown.find_element( + By.XPATH, '//option[. = "Flow Welcome (default-enrollment-flow)"]' + ).click() + self.driver.find_element(By.ID, "id_stage").click() + dropdown = self.driver.find_element(By.ID, "id_stage") + dropdown.find_element( + By.XPATH, '//option[. = "Stage enroll-user-write"]' + ).click() + self.driver.find_element(By.ID, "id_order").click() + self.driver.find_element(By.ID, "id_order").send_keys("2") + self.driver.find_element( + By.CSS_SELECTOR, ".pf-c-form__actions > .pf-m-primary" + ).click() + + # Create Stage binding for user login stage + self.driver.find_element(By.LINK_TEXT, "Create").click() + self.driver.find_element(By.ID, "id_flow").click() + dropdown = self.driver.find_element(By.ID, "id_flow") + dropdown.find_element( + By.XPATH, '//option[. = "Flow Welcome (default-enrollment-flow)"]' + ).click() + self.driver.find_element(By.ID, "id_stage").click() + dropdown = self.driver.find_element(By.ID, "id_stage") + dropdown.find_element( + By.XPATH, '//option[. = "Stage enroll-user-login"]' + ).click() + self.driver.find_element(By.ID, "id_order").click() + self.driver.find_element(By.ID, "id_order").send_keys("3") + self.driver.find_element( + By.CSS_SELECTOR, ".pf-c-form__actions > .pf-m-primary" + ).click() + + self.driver.find_element( + By.CSS_SELECTOR, "#page-default-nav-example div.pf-m-icons > a" + ).click() + def test_enroll_2_step(self): """Test 2-step enroll flow""" - # First stage fields - username_prompt = Prompt.objects.create( - field_key="username", label="Username", order=0, type=FieldTypes.TEXT - ) - password = Prompt.objects.create( - field_key="password", label="Password", order=1, type=FieldTypes.PASSWORD - ) - password_repeat = Prompt.objects.create( - field_key="password_repeat", - label="Password (repeat)", - order=2, - type=FieldTypes.PASSWORD, - ) - - # Second stage fields - name_field = Prompt.objects.create( - field_key="name", label="Name", order=0, type=FieldTypes.TEXT - ) - email = Prompt.objects.create( - field_key="email", label="E-Mail", order=1, type=FieldTypes.EMAIL - ) - - # Stages - first_stage = PromptStage.objects.create(name="prompt-stage-first") - first_stage.fields.set([username_prompt, password, password_repeat]) - first_stage.save() - second_stage = PromptStage.objects.create(name="prompt-stage-second") - second_stage.fields.set([name_field, email]) - second_stage.save() - user_write = UserWriteStage.objects.create(name="enroll-user-write") - user_login = UserLoginStage.objects.create(name="enroll-user-login") - - # Password checking policy - password_policy = ExpressionPolicy.objects.create( - name="policy-enrollment-password-equals", - expression="return request.context['password'] == request.context['password_repeat']", - ) - PolicyBinding.objects.create( - target=first_stage, policy=password_policy, order=0 - ) - - flow = Flow.objects.create( - name="default-enrollment-flow", - slug="default-enrollment-flow", - designation=FlowDesignation.ENROLLMENT, - ) - - # Attach enrollment flow to identification stage - ident_stage: IdentificationStage = IdentificationStage.objects.first() - ident_stage.enrollment_flow = flow - ident_stage.save() - - FlowStageBinding.objects.create(flow=flow, stage=first_stage, order=0) - FlowStageBinding.objects.create(flow=flow, stage=second_stage, order=1) - FlowStageBinding.objects.create(flow=flow, stage=user_write, order=2) - FlowStageBinding.objects.create(flow=flow, stage=user_login, order=3) + self.driver.get(self.live_server_url) + self.setup_test_enroll_2_step() self.driver.get(self.live_server_url) self.driver.find_element(By.CSS_SELECTOR, "[role=enroll]").click() self.driver.find_element(By.ID, "id_username").send_keys("foo") From 3eb2cda37d5229eee1a3e8669c97aadf35d8a05d Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Sun, 21 Jun 2020 12:59:18 +0200 Subject: [PATCH 43/64] e2e: add wait for codemirror --- e2e/test_enroll_2_step.py | 1 + 1 file changed, 1 insertion(+) diff --git a/e2e/test_enroll_2_step.py b/e2e/test_enroll_2_step.py index 19cad0eb8..88d996eb6 100644 --- a/e2e/test_enroll_2_step.py +++ b/e2e/test_enroll_2_step.py @@ -141,6 +141,7 @@ class TestEnroll2Step(SeleniumTestCase): self.driver.find_element(By.ID, "id_name").send_keys( "policy-enrollment-password-equals" ) + self.wait.until(ec.presence_of_element_located((By.CSS_SELECTOR, ".CodeMirror-scroll"))) self.driver.find_element(By.CSS_SELECTOR, ".CodeMirror-scroll").click() self.driver.find_element(By.CSS_SELECTOR, ".CodeMirror textarea").send_keys( "return request.context['password'] == request.context['password_repeat']" From 6643cce84149068d8ab062e73a09f319d9cdf967 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Sun, 21 Jun 2020 13:18:06 +0200 Subject: [PATCH 44/64] ci: install node and run yarn for e2e tests --- .github/workflows/ci.yml | 7 +++++++ e2e/setup.sh | 6 ++++++ 2 files changed, 13 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0e536cfef..f5c8d8a8c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -121,6 +121,9 @@ jobs: - uses: actions/setup-python@v1 with: python-version: '3.8' + - uses: actions/setup-node@v1 + with: + node-version: '12' - name: Install dependencies run: | sudo pip install -U wheel pipenv @@ -130,6 +133,10 @@ jobs: cd e2e docker-compose pull -q chrome docker-compose up -d chrome + - name: Build static files for e2e test + run: | + cd passbook/static/static + yarn - name: Run coverage run: pipenv run coverage run ./manage.py test --failfast - name: Create XML Report diff --git a/e2e/setup.sh b/e2e/setup.sh index 7eb5e4116..a0f0d67ad 100755 --- a/e2e/setup.sh +++ b/e2e/setup.sh @@ -1,6 +1,12 @@ #!/bin/bash -x sudo apt update +# Setup python sudo apt install -y python3.8 python3-pip apt-transport-https ca-certificates curl gnupg-agent software-properties-common +# Setup nodejs +curl -sL https://deb.nodesource.com/setup_12.x | sudo -E bash - +sudo apt-get install -y nodejs +sudo npm install -g yarn +# Setup docker curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add - sudo add-apt-repository \ "deb [arch=amd64] https://download.docker.com/linux/ubuntu \ From 5c49cda884e88c01174733e7e642edc2c4e55c54 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Sun, 21 Jun 2020 13:40:53 +0200 Subject: [PATCH 45/64] e2e: add more safety checks --- e2e/test_enroll_2_step.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/e2e/test_enroll_2_step.py b/e2e/test_enroll_2_step.py index 88d996eb6..712bc39f2 100644 --- a/e2e/test_enroll_2_step.py +++ b/e2e/test_enroll_2_step.py @@ -289,7 +289,9 @@ class TestEnroll2Step(SeleniumTestCase): self.driver.get(self.live_server_url) self.setup_test_enroll_2_step() self.driver.get(self.live_server_url) + self.wait.until(ec.presence_of_element_located((By.CSS_SELECTOR, "[role=enroll]"))) self.driver.find_element(By.CSS_SELECTOR, "[role=enroll]").click() + self.driver.find_element(By.ID, "id_username").send_keys("foo") self.driver.find_element(By.ID, "id_password").send_keys(USER().username) self.driver.find_element(By.ID, "id_password_repeat").send_keys(USER().username) From 0838f518d4f87866ffbaf23780bfc22f4d7ff36a Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Sun, 21 Jun 2020 14:43:48 +0200 Subject: [PATCH 46/64] e2e: save screenshot on failure, upload to github actions --- .github/workflows/ci.yml | 4 ++++ e2e/utils.py | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f5c8d8a8c..1d3144371 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -139,6 +139,10 @@ jobs: yarn - name: Run coverage run: pipenv run coverage run ./manage.py test --failfast + - uses: actions/upload-artifact@v2 + if: failure() + with: + path: out/ - name: Create XML Report run: pipenv run coverage xml - uses: codecov/codecov-action@v1 diff --git a/e2e/utils.py b/e2e/utils.py index 2683bf1da..2847d9a59 100644 --- a/e2e/utils.py +++ b/e2e/utils.py @@ -1,5 +1,6 @@ """passbook e2e testing utilities""" from functools import lru_cache +from os import makedirs from glob import glob from importlib.util import module_from_spec, spec_from_file_location from inspect import getmembers, isfunction @@ -40,6 +41,7 @@ class SeleniumTestCase(StaticLiveServerTestCase): def setUp(self): super().setUp() + makedirs("out", exist_ok=True) self.driver = self._get_driver() self.driver.maximize_window() self.driver.implicitly_wait(5) @@ -53,6 +55,8 @@ class SeleniumTestCase(StaticLiveServerTestCase): ) def tearDown(self): + if self.failureException: + self.driver.save_screenshot("out/{self.__class__.__name__}.png") self.driver.quit() super().tearDown() From 1b3c0adf75c3ab56a8a58abb4a1a9b8ddc3de13f Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Sun, 21 Jun 2020 15:09:01 +0200 Subject: [PATCH 47/64] e2e: cleanup, always take screenshots on teardown --- e2e/test_enroll_2_step.py | 16 +++++++--------- e2e/utils.py | 5 ++--- passbook/core/templates/base/page.html | 2 +- 3 files changed, 10 insertions(+), 13 deletions(-) diff --git a/e2e/test_enroll_2_step.py b/e2e/test_enroll_2_step.py index 712bc39f2..e4e7dbd0f 100644 --- a/e2e/test_enroll_2_step.py +++ b/e2e/test_enroll_2_step.py @@ -141,7 +141,9 @@ class TestEnroll2Step(SeleniumTestCase): self.driver.find_element(By.ID, "id_name").send_keys( "policy-enrollment-password-equals" ) - self.wait.until(ec.presence_of_element_located((By.CSS_SELECTOR, ".CodeMirror-scroll"))) + self.wait.until( + ec.presence_of_element_located((By.CSS_SELECTOR, ".CodeMirror-scroll")) + ) self.driver.find_element(By.CSS_SELECTOR, ".CodeMirror-scroll").click() self.driver.find_element(By.CSS_SELECTOR, ".CodeMirror textarea").send_keys( "return request.context['password'] == request.context['password_repeat']" @@ -264,32 +266,28 @@ class TestEnroll2Step(SeleniumTestCase): # Create Stage binding for user login stage self.driver.find_element(By.LINK_TEXT, "Create").click() - self.driver.find_element(By.ID, "id_flow").click() dropdown = self.driver.find_element(By.ID, "id_flow") dropdown.find_element( By.XPATH, '//option[. = "Flow Welcome (default-enrollment-flow)"]' ).click() - self.driver.find_element(By.ID, "id_stage").click() dropdown = self.driver.find_element(By.ID, "id_stage") dropdown.find_element( By.XPATH, '//option[. = "Stage enroll-user-login"]' ).click() - self.driver.find_element(By.ID, "id_order").click() self.driver.find_element(By.ID, "id_order").send_keys("3") self.driver.find_element( By.CSS_SELECTOR, ".pf-c-form__actions > .pf-m-primary" ).click() - self.driver.find_element( - By.CSS_SELECTOR, "#page-default-nav-example div.pf-m-icons > a" - ).click() + self.driver.find_element(By.CSS_SELECTOR, "[aria-label=logout]").click() def test_enroll_2_step(self): """Test 2-step enroll flow""" self.driver.get(self.live_server_url) self.setup_test_enroll_2_step() - self.driver.get(self.live_server_url) - self.wait.until(ec.presence_of_element_located((By.CSS_SELECTOR, "[role=enroll]"))) + self.wait.until( + ec.presence_of_element_located((By.CSS_SELECTOR, "[role=enroll]")) + ) self.driver.find_element(By.CSS_SELECTOR, "[role=enroll]").click() self.driver.find_element(By.ID, "id_username").send_keys("foo") diff --git a/e2e/utils.py b/e2e/utils.py index 2847d9a59..81acc3739 100644 --- a/e2e/utils.py +++ b/e2e/utils.py @@ -1,9 +1,9 @@ """passbook e2e testing utilities""" from functools import lru_cache -from os import makedirs from glob import glob from importlib.util import module_from_spec, spec_from_file_location from inspect import getmembers, isfunction +from os import makedirs from Cryptodome.PublicKey import RSA from django.apps import apps @@ -55,8 +55,7 @@ class SeleniumTestCase(StaticLiveServerTestCase): ) def tearDown(self): - if self.failureException: - self.driver.save_screenshot("out/{self.__class__.__name__}.png") + self.driver.save_screenshot(f"out/{self.__class__.__name__}.png") self.driver.quit() super().tearDown() diff --git a/passbook/core/templates/base/page.html b/passbook/core/templates/base/page.html index 2a25d2565..f3fea0fd7 100644 --- a/passbook/core/templates/base/page.html +++ b/passbook/core/templates/base/page.html @@ -40,7 +40,7 @@
    From 887163c45ccede38938523226f9ca6ac0b13a74f Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Sun, 21 Jun 2020 18:36:43 +0200 Subject: [PATCH 48/64] e2e: add more failsafe --- e2e/test_enroll_2_step.py | 2 +- e2e/test_provider_oidc.py | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/e2e/test_enroll_2_step.py b/e2e/test_enroll_2_step.py index e4e7dbd0f..821a6e52b 100644 --- a/e2e/test_enroll_2_step.py +++ b/e2e/test_enroll_2_step.py @@ -286,7 +286,7 @@ class TestEnroll2Step(SeleniumTestCase): self.driver.get(self.live_server_url) self.setup_test_enroll_2_step() self.wait.until( - ec.presence_of_element_located((By.CSS_SELECTOR, "[role=enroll]")) + ec.presence_of_element_located(By.CSS_SELECTOR, "[role=enroll]") ) self.driver.find_element(By.CSS_SELECTOR, "[role=enroll]").click() diff --git a/e2e/test_provider_oidc.py b/e2e/test_provider_oidc.py index 82f37dfe5..259ab5528 100644 --- a/e2e/test_provider_oidc.py +++ b/e2e/test_provider_oidc.py @@ -6,6 +6,7 @@ from oauth2_provider.generators import generate_client_id, generate_client_secre from oidc_provider.models import Client, ResponseType from selenium.webdriver.common.by import By from selenium.webdriver.common.keys import Keys +from selenium.webdriver.support import expected_conditions as ec from docker import DockerClient, from_env from docker.models.containers import Container @@ -217,6 +218,9 @@ class TestProviderOIDC(SeleniumTestCase): ) self.driver.find_element(By.CSS_SELECTOR, "[type=submit]").click() + self.wait.until( + ec.presence_of_element_located(By.XPATH, "//a[contains(@href, '/profile')]") + ) self.driver.find_element(By.XPATH, "//a[contains(@href, '/profile')]").click() self.assertEqual( self.driver.find_element(By.CLASS_NAME, "page-header__title").text, From ed4daa64fe158350eb02d4fd5f6be48c82fc06e8 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Sun, 21 Jun 2020 18:44:08 +0200 Subject: [PATCH 49/64] e2e: save screenshots with timestamp instead of class name --- e2e/test_enroll_2_step.py | 2 +- e2e/test_provider_oidc.py | 2 +- e2e/utils.py | 3 ++- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/e2e/test_enroll_2_step.py b/e2e/test_enroll_2_step.py index 821a6e52b..e4e7dbd0f 100644 --- a/e2e/test_enroll_2_step.py +++ b/e2e/test_enroll_2_step.py @@ -286,7 +286,7 @@ class TestEnroll2Step(SeleniumTestCase): self.driver.get(self.live_server_url) self.setup_test_enroll_2_step() self.wait.until( - ec.presence_of_element_located(By.CSS_SELECTOR, "[role=enroll]") + ec.presence_of_element_located((By.CSS_SELECTOR, "[role=enroll]")) ) self.driver.find_element(By.CSS_SELECTOR, "[role=enroll]").click() diff --git a/e2e/test_provider_oidc.py b/e2e/test_provider_oidc.py index 259ab5528..8e6083c63 100644 --- a/e2e/test_provider_oidc.py +++ b/e2e/test_provider_oidc.py @@ -219,7 +219,7 @@ class TestProviderOIDC(SeleniumTestCase): self.driver.find_element(By.CSS_SELECTOR, "[type=submit]").click() self.wait.until( - ec.presence_of_element_located(By.XPATH, "//a[contains(@href, '/profile')]") + ec.presence_of_element_located((By.XPATH, "//a[contains(@href, '/profile')]")) ) self.driver.find_element(By.XPATH, "//a[contains(@href, '/profile')]").click() self.assertEqual( diff --git a/e2e/utils.py b/e2e/utils.py index 81acc3739..4b74176b4 100644 --- a/e2e/utils.py +++ b/e2e/utils.py @@ -1,4 +1,5 @@ """passbook e2e testing utilities""" +from time import time from functools import lru_cache from glob import glob from importlib.util import module_from_spec, spec_from_file_location @@ -55,7 +56,7 @@ class SeleniumTestCase(StaticLiveServerTestCase): ) def tearDown(self): - self.driver.save_screenshot(f"out/{self.__class__.__name__}.png") + self.driver.save_screenshot(f"out/{time()}.png") self.driver.quit() super().tearDown() From fd0f0c65e9fb267a0476542d5254f80a2c294d69 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Sun, 21 Jun 2020 19:03:13 +0200 Subject: [PATCH 50/64] e2e: add more failsafe --- e2e/test_provider_oidc.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/e2e/test_provider_oidc.py b/e2e/test_provider_oidc.py index 8e6083c63..f6c75d959 100644 --- a/e2e/test_provider_oidc.py +++ b/e2e/test_provider_oidc.py @@ -216,6 +216,9 @@ class TestProviderOIDC(SeleniumTestCase): By.XPATH, "/html/body/div[2]/div/main/div/form/div[2]/p[1]" ).text, ) + self.wait.until( + ec.presence_of_element_located((By.CSS_SELECTOR, "[type=submit]")) + ) self.driver.find_element(By.CSS_SELECTOR, "[type=submit]").click() self.wait.until( From f69e20886b743963f57ab1d7567f5b756d05ea04 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Sun, 21 Jun 2020 19:22:30 +0200 Subject: [PATCH 51/64] e2e: use class name and timestamp for screenshots --- e2e/test_enroll_2_step.py | 1 - e2e/utils.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/e2e/test_enroll_2_step.py b/e2e/test_enroll_2_step.py index e4e7dbd0f..d7b4e312f 100644 --- a/e2e/test_enroll_2_step.py +++ b/e2e/test_enroll_2_step.py @@ -299,7 +299,6 @@ class TestEnroll2Step(SeleniumTestCase): self.driver.find_element(By.CSS_SELECTOR, ".pf-c-button").click() self.driver.find_element(By.LINK_TEXT, "foo").click() - self.wait.until(ec.presence_of_element_located((By.ID, "id_username"))) self.assertEqual( self.driver.find_element(By.XPATH, "//a[contains(@href, '/-/user/')]").text, "foo", diff --git a/e2e/utils.py b/e2e/utils.py index 4b74176b4..fa6876796 100644 --- a/e2e/utils.py +++ b/e2e/utils.py @@ -56,7 +56,7 @@ class SeleniumTestCase(StaticLiveServerTestCase): ) def tearDown(self): - self.driver.save_screenshot(f"out/{time()}.png") + self.driver.save_screenshot(f"out/{self.__class__.__name__}_{time()}.png") self.driver.quit() super().tearDown() From 39f51ec33dbc33d860faf5e56aff93b62c945f47 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Sun, 21 Jun 2020 20:13:59 +0200 Subject: [PATCH 52/64] stages/email: fix email account confirmation email template --- .../templates/administration/stage_binding/list.html | 8 ++++---- passbook/stages/email/models.py | 2 +- .../{account_confirm.html => account_confirmation.html} | 4 ++-- .../templates/stages/email/for_email/generic_email.html | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) rename passbook/stages/email/templates/stages/email/for_email/{account_confirm.html => account_confirmation.html} (92%) diff --git a/passbook/admin/templates/administration/stage_binding/list.html b/passbook/admin/templates/administration/stage_binding/list.html index efdf7f235..a4342cf0e 100644 --- a/passbook/admin/templates/administration/stage_binding/list.html +++ b/passbook/admin/templates/administration/stage_binding/list.html @@ -39,8 +39,8 @@ {% for flow in grouped_bindings %}
    - {% blocktrans with name=flow.grouper.name %} - Flow {{ name }} + {% blocktrans with slug=flow.grouper.slug %} + Flow {{ slug }} {% endblocktrans %}
    -
    {{ binding.flow.name }}
    +
    {{ binding.flow.slug }}
    - {{ binding.flow }} + {{ binding.flow.name }}
    - +
    {% trans 'Confirm Account' %} {% trans 'Confirm Account' %}
    diff --git a/passbook/stages/email/templates/stages/email/for_email/password_reset.html b/passbook/stages/email/templates/stages/email/for_email/password_reset.html index 4003b1e9c..c023c3087 100644 --- a/passbook/stages/email/templates/stages/email/for_email/password_reset.html +++ b/passbook/stages/email/templates/stages/email/for_email/password_reset.html @@ -23,7 +23,7 @@ - +
    {% trans 'Reset Password' %} {% trans 'Reset Password' %}