diff --git a/Pipfile.lock b/Pipfile.lock index 3f4fd3420..06a8d01b3 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -18,10 +18,10 @@ "default": { "amqp": { "hashes": [ - "sha256:6e649ca13a7df3faacdc8bbb280aa9a6602d22fd9d545336077e573a1f4ff3b8", - "sha256:77f1aef9410698d20eaeac5b73a87817365f457a507d82edf292e12cbb83b08d" + "sha256:24dbaff8ce4f30566bb88976b398e8c4e77637171af3af6f1b9650f48890e60b", + "sha256:bb68f8d2bced8f93ccfd07d96c689b716b3227720add971be980accfc2952139" ], - "version": "==2.5.2" + "version": "==2.6.0" }, "asgiref": { "hashes": [ @@ -53,18 +53,18 @@ }, "boto3": { "hashes": [ - "sha256:1bdab4f87ff39d5aab59b0aae69965bf604fa5608984c673877f4c62c1f16240", - "sha256:2b4924ccc1603d562969b9f3c8c74ff4a1f3bdbafe857c990422c73d8e2e229e" + "sha256:26f8564b46d009b8f4c6470a6d6cde147b282a197339c7e31cbb0fe9fd9e5f5d", + "sha256:f59d0bd230ed3a4b932c5c4e497a0e0ff3c93b46b7e8cde54efb6fe10c8266ba" ], "index": "pypi", - "version": "==1.13.18" + "version": "==1.13.20" }, "botocore": { "hashes": [ - "sha256:93574cf95a64c71d35c12c93a23f6214cf2f4b461be3bda3a436381cbe126a84", - "sha256:e65eb27cae262a510e335bc0c0e286e9e42381b1da0aafaa79fa13c1d8d74a95" + "sha256:990f3fc33dec746829740b1a9e1fe86183cdc96aedba6a632ccfcbae03e097cc", + "sha256:d4cc47ac989a7f1d2992ef7679fb423a7966f687becf623a291a555a2d7ce1c0" ], - "version": "==1.16.18" + "version": "==1.16.20" }, "celery": { "hashes": [ @@ -364,11 +364,11 @@ }, "kombu": { "hashes": [ - "sha256:2d1cda774126a044d91a7ff5fa6d09edf99f46924ab332a810760fe6740e9b76", - "sha256:598e7e749d6ab54f646b74b2d2df67755dee13894f73ab02a2a9feb8870c7cb2" + "sha256:ab0afaa5388dd2979cbc439d3623b86a4f7a58d41f621096bef7767c37bc2505", + "sha256:aece08f48706743aaa1b9d607fee300559481eafcc5ee56451aa0ef867a3be07" ], "index": "pypi", - "version": "==4.6.8" + "version": "==4.6.9" }, "ldap3": { "hashes": [ @@ -688,10 +688,10 @@ }, "redis": { "hashes": [ - "sha256:2ef11f489003f151777c064c5dbc6653dfb9f3eade159bcadc524619fddc2242", - "sha256:6d65e84bc58091140081ee9d9c187aab0480097750fac44239307a3bdf0b1251" + "sha256:0e7e0cfca8660dea8b7d5cd8c4f6c5e29e11f31158c0b0ae91a397f00e5a05a2", + "sha256:432b788c4530cfe16d8d943a09d40ca6c16149727e4afe8c2c9d5580c59d9f24" ], - "version": "==3.5.2" + "version": "==3.5.3" }, "requests": { "hashes": [ @@ -858,10 +858,10 @@ }, "autopep8": { "hashes": [ - "sha256:152fd8fe47d02082be86e05001ec23d6f420086db56b17fc883f3f965fb34954" + "sha256:60fd8c4341bab59963dafd5d2a566e94f547e660b9b396f772afe67d8481dbf0" ], "index": "pypi", - "version": "==1.5.2" + "version": "==1.5.3" }, "bandit": { "hashes": [ @@ -971,10 +971,10 @@ }, "gitpython": { "hashes": [ - "sha256:864a47472548f3ba716ca202e034c1900f197c0fb3a08f641c20c3cafd15ed94", - "sha256:da3b2cf819974789da34f95ac218ef99f515a928685db141327c09b73dd69c09" + "sha256:e107af4d873daed64648b4f4beb89f89f0cfbe3ef558fc7821ed2331c2f8da1a", + "sha256:ef1d60b01b5ce0040ad3ec20bc64f783362d41fa0822a2742d3586e1f49bb8ac" ], - "version": "==3.1.2" + "version": "==3.1.3" }, "isort": { "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/overview.html b/passbook/admin/templates/administration/overview.html index d053f8c3d..21dcb8906 100644 --- a/passbook/admin/templates/administration/overview.html +++ b/passbook/admin/templates/administration/overview.html @@ -55,15 +55,26 @@
- {% if factor_count < 1 %} - {{ factor_count }} + {% if stage_count < 1 %} + {{ stage_count }}

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

{% else %} - {{ factor_count }} + {{ stage_count }} {% endif %}
+ +
+
+ {% trans 'Flows' %} +
+
+
+ {{ flow_count }} +
+
+
diff --git a/passbook/admin/templates/administration/stage_prompt/list.html b/passbook/admin/templates/administration/stage_prompt/list.html index 5c13689b4..5aff63c69 100644 --- a/passbook/admin/templates/administration/stage_prompt/list.html +++ b/passbook/admin/templates/administration/stage_prompt/list.html @@ -29,6 +29,7 @@ {% trans 'Field' %} {% trans 'Label' %} {% trans 'Type' %} + {% trans 'Order' %} {% trans 'Flows' %} @@ -51,6 +52,11 @@ {{ prompt.type }}
+ +
+ {{ prompt.order }} +
+
+ diff --git a/passbook/stages/identification/templates/stages/identification/recovery.html b/passbook/stages/identification/templates/stages/identification/recovery.html index 1dab0ae77..4c9d07c18 100644 --- a/passbook/stages/identification/templates/stages/identification/recovery.html +++ b/passbook/stages/identification/templates/stages/identification/recovery.html @@ -1,72 +1,29 @@ -{% extends 'base/skeleton.html' %} - -{% load static %} {% load i18n %} +{% load static %} -{% block body %} -
- - - - - - - - - - - +
+

+ {% trans 'Trouble Logging In?' %} +

+
+
+ {% block card %} +
+ {% block above_form %} + {% endblock %} + + {% include 'partials/form.html' %} + + {% block beneath_form %} + {% endblock %} +
+ +
+
+ {% endblock %}
-{% include 'partials/messages.html' %} -
- -
-{% endblock %} + diff --git a/passbook/stages/identification/tests.py b/passbook/stages/identification/tests.py index 4ecf255e9..fa8c3919d 100644 --- a/passbook/stages/identification/tests.py +++ b/passbook/stages/identification/tests.py @@ -85,15 +85,19 @@ class TestIdentificationStage(TestCase): slug="unique-enrollment-string", designation=FlowDesignation.ENROLLMENT, ) + self.stage.enrollment_flow = flow + self.stage.save() FlowStageBinding.objects.create( flow=flow, stage=self.stage, order=0, ) response = self.client.get( - reverse("passbook_flows:flow-executor", kwargs={"flow_slug": flow.slug}), + reverse( + "passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug} + ), ) self.assertEqual(response.status_code, 200) - self.assertIn(flow.name, response.rendered_content) + self.assertIn(flow.slug, response.rendered_content) def test_recovery_flow(self): """Test that recovery flow is linked correctly""" @@ -102,12 +106,16 @@ class TestIdentificationStage(TestCase): slug="unique-recovery-string", designation=FlowDesignation.RECOVERY, ) + self.stage.recovery_flow = flow + self.stage.save() FlowStageBinding.objects.create( flow=flow, stage=self.stage, order=0, ) response = self.client.get( - reverse("passbook_flows:flow-executor", kwargs={"flow_slug": flow.slug}), + reverse( + "passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug} + ), ) self.assertEqual(response.status_code, 200) - self.assertIn(flow.name, response.rendered_content) + self.assertIn(flow.slug, response.rendered_content) diff --git a/passbook/stages/prompt/api.py b/passbook/stages/prompt/api.py index 381e9427c..1c4d4a01b 100644 --- a/passbook/stages/prompt/api.py +++ b/passbook/stages/prompt/api.py @@ -38,6 +38,7 @@ class PromptSerializer(ModelSerializer): "type", "required", "placeholder", + "order", ] diff --git a/passbook/stages/prompt/forms.py b/passbook/stages/prompt/forms.py index 14830f36d..e9157d198 100644 --- a/passbook/stages/prompt/forms.py +++ b/passbook/stages/prompt/forms.py @@ -31,6 +31,7 @@ class PromptAdminForm(forms.ModelForm): "type", "required", "placeholder", + "order", ] widgets = { "label": forms.TextInput(), @@ -48,16 +49,19 @@ class PromptForm(forms.Form): self.stage = stage self.plan = plan super().__init__(*args, **kwargs) - for field in self.stage.fields.all(): + # list() is called so we only load the fields once + fields = list(self.stage.fields.all()) + for field in fields: field: Prompt self.fields[field.field_key] = field.field + self.field_order = sorted(fields, key=lambda x: x.order) 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(list(result.messages)) diff --git a/passbook/stages/prompt/migrations/0002_auto_20200528_2059.py b/passbook/stages/prompt/migrations/0002_auto_20200528_2059.py new file mode 100644 index 000000000..5ecb06b4b --- /dev/null +++ b/passbook/stages/prompt/migrations/0002_auto_20200528_2059.py @@ -0,0 +1,35 @@ +# Generated by Django 3.0.6 on 2020-05-28 20:59 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("passbook_stages_prompt", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="prompt", name="order", field=models.IntegerField(default=0), + ), + migrations.AlterField( + model_name="prompt", + name="type", + field=models.CharField( + choices=[ + ("text", "Text"), + ("e-mail", "Email"), + ("password", "Password"), + ("number", "Number"), + ("checkbox", "Checkbox"), + ("data", "Date"), + ("data-time", "Date Time"), + ("separator", "Separator"), + ("hidden", "Hidden"), + ("static", "Static"), + ], + max_length=100, + ), + ), + ] diff --git a/passbook/stages/prompt/models.py b/passbook/stages/prompt/models.py index 908d121b7..fc12c7b2e 100644 --- a/passbook/stages/prompt/models.py +++ b/passbook/stages/prompt/models.py @@ -16,7 +16,13 @@ class FieldTypes(models.TextChoices): EMAIL = "e-mail" PASSWORD = "password" # noqa # nosec NUMBER = "number" + CHECKBOX = "checkbox" + DATE = "data" + DATE_TIME = "data-time" + + SEPARATOR = "separator" HIDDEN = "hidden" + STATIC = "static" class Prompt(models.Model): @@ -32,41 +38,37 @@ class Prompt(models.Model): required = models.BooleanField(default=True) placeholder = models.TextField() + order = models.IntegerField(default=0) + @property def field(self): """Return instantiated form input field""" attrs = {"placeholder": _(self.placeholder)} - if self.type == FieldTypes.TEXT: - return forms.CharField( - label=_(self.label), - widget=forms.TextInput(attrs=attrs), - required=self.required, - ) + field_class = forms.CharField + widget = forms.TextInput(attrs=attrs) + kwargs = { + "label": _(self.label), + "required": self.required, + } if self.type == FieldTypes.EMAIL: - return forms.EmailField( - label=_(self.label), - widget=forms.TextInput(attrs=attrs), - required=self.required, - ) + field_class = forms.EmailField if self.type == FieldTypes.PASSWORD: - return forms.CharField( - label=_(self.label), - widget=forms.PasswordInput(attrs=attrs), - required=self.required, - ) + widget = forms.PasswordInput(attrs=attrs) if self.type == FieldTypes.NUMBER: - return forms.IntegerField( - label=_(self.label), - widget=forms.NumberInput(attrs=attrs), - required=self.required, - ) + field_class = forms.IntegerField + widget = forms.NumberInput(attrs=attrs) if self.type == FieldTypes.HIDDEN: - return forms.CharField( - widget=forms.HiddenInput(attrs=attrs), - required=False, - initial=self.placeholder, - ) - raise ValueError("field_type is not valid, not one of FieldTypes.") + widget = forms.HiddenInput(attrs=attrs) + kwargs["required"] = False + kwargs["initial"] = self.placeholder + if self.type == FieldTypes.CHECKBOX: + field_class = forms.CheckboxInput + kwargs["required"] = False + + # TODO: Implement static + # TODO: Implement separator + kwargs["widget"] = widget + return field_class(**kwargs) def save(self, *args, **kwargs): if self.type not in FieldTypes: diff --git a/passbook/stages/prompt/tests.py b/passbook/stages/prompt/tests.py index d4d2e108d..85b66c1f8 100644 --- a/passbook/stages/prompt/tests.py +++ b/passbook/stages/prompt/tests.py @@ -93,25 +93,6 @@ class TestPromptStage(TestCase): FlowStageBinding.objects.create(flow=self.flow, stage=self.stage, order=2) - def test_invalid_type(self): - """Test that invalid form type raises an error""" - with self.assertRaises(ValueError): - _ = Prompt.objects.create( - field_key="hidden_prompt", - type="invalid", - required=True, - placeholder="HIDDEN_PLACEHOLDER", - ) - with self.assertRaises(ValueError): - prompt = Prompt.objects.create( - field_key="hidden_prompt", - type=FieldTypes.HIDDEN, - required=True, - placeholder="HIDDEN_PLACEHOLDER", - ) - with patch.object(prompt, "type", MagicMock(return_value="invalid")): - _ = prompt.field - def test_render(self): """Test render of form, check if all prompts are rendered correctly""" plan = FlowPlan(flow_pk=self.flow.pk.hex, stages=[self.stage]) @@ -139,7 +120,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 +132,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/passbook/stages/user_write/stage.py b/passbook/stages/user_write/stage.py index 4910e0426..28eab9853 100644 --- a/passbook/stages/user_write/stage.py +++ b/passbook/stages/user_write/stage.py @@ -25,33 +25,30 @@ class UserWriteStageView(StageView): LOGGER.debug(message) return self.executor.stage_invalid() data = self.executor.plan.context[PLAN_CONTEXT_PROMPT] - if PLAN_CONTEXT_PENDING_USER in self.executor.plan.context: - user = self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] - for key, value in data.items(): - setter_name = f"set_{key}" - # Check if user has a setter for this key, like set_password - if hasattr(user, setter_name): - setter = getattr(user, setter_name) - if callable(setter): - setter(value) - # User has this key already - elif hasattr(user, key): - setattr(user, key, value) - # Otherwise we just save it as custom attribute - else: - user.attributes[key] = value - user.save() - LOGGER.debug( - "Updated existing user", user=user, flow_slug=self.executor.flow.slug, - ) - else: - user = User.objects.create_user(**data) - # Set created user as pending_user, so this can be chained with user_login - self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] = user + if PLAN_CONTEXT_PENDING_USER not in self.executor.plan.context: + self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] = User() self.executor.plan.context[ PLAN_CONTEXT_AUTHENTICATION_BACKEND ] = class_to_path(ModelBackend) LOGGER.debug( - "Created new user", user=user, flow_slug=self.executor.flow.slug, + "Created new user", flow_slug=self.executor.flow.slug, ) + user = self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] + for key, value in data.items(): + setter_name = f"set_{key}" + # Check if user has a setter for this key, like set_password + if hasattr(user, setter_name): + setter = getattr(user, setter_name) + if callable(setter): + setter(value) + # User has this key already + elif hasattr(user, key): + setattr(user, key, value) + # Otherwise we just save it as custom attribute + else: + user.attributes[key] = value + user.save() + LOGGER.debug( + "Updated existing user", user=user, flow_slug=self.executor.flow.slug, + ) return self.executor.stage_ok() diff --git a/passbook/stages/user_write/tests.py b/passbook/stages/user_write/tests.py index 5bad06809..d37012207 100644 --- a/passbook/stages/user_write/tests.py +++ b/passbook/stages/user_write/tests.py @@ -72,6 +72,7 @@ class TestUserWriteStage(TestCase): plan.context[PLAN_CONTEXT_PROMPT] = { "username": "test-user-new", "password": new_password, + "some-custom-attribute": "test", } session = self.client.session session[SESSION_KEY_PLAN] = plan @@ -88,6 +89,7 @@ class TestUserWriteStage(TestCase): ) self.assertTrue(user_qs.exists()) self.assertTrue(user_qs.first().check_password(new_password)) + self.assertEqual(user_qs.first().attributes["some-custom-attribute"], "test") def test_without_data(self): """Test without data results in error""" diff --git a/swagger.yaml b/swagger.yaml index ce498dd6f..31c87f5be 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 @@ -5690,8 +5606,6 @@ definitions: - bind_cn - bind_password - base_dn - - additional_user_dn - - additional_group_dn type: object properties: pk: @@ -5738,12 +5652,10 @@ definitions: title: Addition User DN description: Prepended to Base DN for User-queries. type: string - minLength: 1 additional_group_dn: title: Addition Group DN description: Prepended to Base DN for Group-queries. type: string - minLength: 1 user_object_filter: title: User object filter description: Consider Objects matching this filter to be Users. @@ -5764,6 +5676,9 @@ definitions: description: Field which contains a unique Identifier. type: string minLength: 1 + sync_users: + title: Sync users + type: boolean sync_groups: title: Sync groups type: boolean @@ -6003,6 +5918,20 @@ definitions: enum: - stages/identification/login.html - stages/identification/recovery.html + enrollment_flow: + title: Enrollment flow + description: Optional enrollment flow, which is linked at the bottom of the + page. + type: string + format: uuid + x-nullable: true + recovery_flow: + title: Recovery flow + description: Optional enrollment flow, which is linked at the bottom of the + page. + type: string + format: uuid + x-nullable: true InvitationStage: required: - name @@ -6112,7 +6041,12 @@ definitions: - e-mail - password - number + - checkbox + - data + - data-time + - separator - hidden + - static required: title: Required type: boolean @@ -6120,6 +6054,11 @@ definitions: title: Placeholder type: string minLength: 1 + order: + title: Order + type: integer + maximum: 2147483647 + minimum: -2147483648 PromptStage: required: - name