From b8654c06bf3e18637859e2f0054945f5c9413741 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Mon, 29 Jun 2020 11:12:30 +0200 Subject: [PATCH 1/8] flows: remove generic "password change" designation and add setup_stage --- .../templates/administration/flow/list.html | 6 ++-- .../templatetags/passbook_user_settings.py | 4 +-- .../migrations/0006_auto_20200629_0857.py | 29 +++++++++++++++++++ passbook/flows/models.py | 2 +- passbook/flows/urls.py | 5 ---- swagger.yaml | 2 +- 6 files changed, 35 insertions(+), 13 deletions(-) create mode 100644 passbook/flows/migrations/0006_auto_20200629_0857.py diff --git a/passbook/admin/templates/administration/flow/list.html b/passbook/admin/templates/administration/flow/list.html index 2e913d5d5..c013e9348 100644 --- a/passbook/admin/templates/administration/flow/list.html +++ b/passbook/admin/templates/administration/flow/list.html @@ -27,7 +27,7 @@ - + @@ -39,8 +39,8 @@
{% trans 'Name' %}{% trans 'Identifier' %} {% trans 'Designation' %} {% trans 'Stages' %} {% trans 'Policies' %}
-
{{ flow.name }}
- {{ flow.slug }} +
{{ flow.slug }}
+ {{ flow.name }}
diff --git a/passbook/core/templatetags/passbook_user_settings.py b/passbook/core/templatetags/passbook_user_settings.py index 7c0287eec..1af1b9c97 100644 --- a/passbook/core/templatetags/passbook_user_settings.py +++ b/passbook/core/templatetags/passbook_user_settings.py @@ -38,9 +38,7 @@ def user_sources(context: RequestContext) -> List[UIUserSettings]: user_settings = source.ui_user_settings if not user_settings: continue - policy_engine = PolicyEngine( - source.policies.all(), user, context.get("request") - ) + policy_engine = PolicyEngine(source, user, context.get("request")) policy_engine.build() if policy_engine.passing: matching_sources.append(user_settings) diff --git a/passbook/flows/migrations/0006_auto_20200629_0857.py b/passbook/flows/migrations/0006_auto_20200629_0857.py new file mode 100644 index 000000000..2a917c35f --- /dev/null +++ b/passbook/flows/migrations/0006_auto_20200629_0857.py @@ -0,0 +1,29 @@ +# Generated by Django 3.0.7 on 2020-06-29 08:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("passbook_flows", "0005_provider_flows"), + ] + + operations = [ + migrations.AlterField( + model_name="flow", + name="designation", + field=models.CharField( + choices=[ + ("authentication", "Authentication"), + ("authorization", "Authorization"), + ("invalidation", "Invalidation"), + ("enrollment", "Enrollment"), + ("unenrollment", "Unrenollment"), + ("recovery", "Recovery"), + ("stage_setup", "Stage Setup"), + ], + max_length=100, + ), + ), + ] diff --git a/passbook/flows/models.py b/passbook/flows/models.py index d049a6885..44ea86c91 100644 --- a/passbook/flows/models.py +++ b/passbook/flows/models.py @@ -25,7 +25,7 @@ class FlowDesignation(models.TextChoices): ENROLLMENT = "enrollment" UNRENOLLMENT = "unenrollment" RECOVERY = "recovery" - PASSWORD_CHANGE = "password_change" # nosec # noqa + STAGE_SETUP = "stage_setup" class Stage(models.Model): diff --git a/passbook/flows/urls.py b/passbook/flows/urls.py index c1a50a526..ca83335f7 100644 --- a/passbook/flows/urls.py +++ b/passbook/flows/urls.py @@ -36,11 +36,6 @@ urlpatterns = [ ToDefaultFlow.as_view(designation=FlowDesignation.UNRENOLLMENT), name="default-unenrollment", ), - path( - "-/default/password_change/", - ToDefaultFlow.as_view(designation=FlowDesignation.PASSWORD_CHANGE), - name="default-password-change", - ), path("b//", FlowExecutorView.as_view(), name="flow-executor"), path( "/", FlowExecutorShellView.as_view(), name="flow-executor-shell" diff --git a/swagger.yaml b/swagger.yaml index c891e99e8..8a11bd93f 100755 --- a/swagger.yaml +++ b/swagger.yaml @@ -5177,7 +5177,7 @@ definitions: - enrollment - unenrollment - recovery - - password_change + - stage_setup stages: type: array items: From ec823aebedc889320256bd245c21fb3b1001615b Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Mon, 29 Jun 2020 16:19:39 +0200 Subject: [PATCH 2/8] flows: update migrations to use update_or_create --- .../flows/migrations/0002_default_flows.py | 75 ++++++++---------- .../flows/migrations/0004_source_flows.py | 76 ++++++++++--------- .../flows/migrations/0005_provider_flows.py | 18 +++-- passbook/sources/saml/processors/base.py | 2 +- passbook/stages/identification/forms.py | 10 +++ passbook/stages/prompt/forms.py | 3 + 6 files changed, 97 insertions(+), 87 deletions(-) diff --git a/passbook/flows/migrations/0002_default_flows.py b/passbook/flows/migrations/0002_default_flows.py index 5977cc8ee..15b45625c 100644 --- a/passbook/flows/migrations/0002_default_flows.py +++ b/passbook/flows/migrations/0002_default_flows.py @@ -20,42 +20,38 @@ def create_default_authentication_flow( ) db_alias = schema_editor.connection.alias - if ( - Flow.objects.using(db_alias) - .filter(designation=FlowDesignation.AUTHENTICATION) - .exists() - ): - # Only create default flow when none exist - return + identification_stage, _ = IdentificationStage.objects.using( + db_alias + ).update_or_create( + name="default-authentication-identification", + defaults={ + "user_fields": [UserFields.E_MAIL, UserFields.USERNAME], + "template": Templates.DEFAULT_LOGIN, + }, + ) - if not IdentificationStage.objects.using(db_alias).exists(): - IdentificationStage.objects.using(db_alias).create( - name="identification", - user_fields=[UserFields.E_MAIL, UserFields.USERNAME], - template=Templates.DEFAULT_LOGIN, - ) + password_stage, _ = PasswordStage.objects.using(db_alias).update_or_create( + name="default-authentication-password", + defaults={"backends": ["django.contrib.auth.backends.ModelBackend"]}, + ) - if not PasswordStage.objects.using(db_alias).exists(): - PasswordStage.objects.using(db_alias).create( - name="password", backends=["django.contrib.auth.backends.ModelBackend"], - ) + login_stage, _ = UserLoginStage.objects.using(db_alias).update_or_create( + name="default-authentication-login" + ) - if not UserLoginStage.objects.using(db_alias).exists(): - UserLoginStage.objects.using(db_alias).create(name="authentication") - - flow = Flow.objects.using(db_alias).create( - name="Welcome to passbook!", + flow, _ = Flow.objects.using(db_alias).update_or_create( slug="default-authentication-flow", designation=FlowDesignation.AUTHENTICATION, + defaults={"name": "Welcome to passbook!",}, ) - FlowStageBinding.objects.using(db_alias).create( - flow=flow, stage=IdentificationStage.objects.using(db_alias).first(), order=0, + FlowStageBinding.objects.using(db_alias).update_or_create( + flow=flow, stage=identification_stage, defaults={"order": 0,}, ) - FlowStageBinding.objects.using(db_alias).create( - flow=flow, stage=PasswordStage.objects.using(db_alias).first(), order=1, + FlowStageBinding.objects.using(db_alias).update_or_create( + flow=flow, stage=password_stage, defaults={"order": 1,}, ) - FlowStageBinding.objects.using(db_alias).create( - flow=flow, stage=UserLoginStage.objects.using(db_alias).first(), order=2, + FlowStageBinding.objects.using(db_alias).update_or_create( + flow=flow, stage=login_stage, defaults={"order": 2,}, ) @@ -67,24 +63,19 @@ def create_default_invalidation_flow( UserLogoutStage = apps.get_model("passbook_stages_user_logout", "UserLogoutStage") db_alias = schema_editor.connection.alias - if ( - Flow.objects.using(db_alias) - .filter(designation=FlowDesignation.INVALIDATION) - .exists() - ): - # Only create default flow when none exist - return + UserLogoutStage.objects.using(db_alias).update_or_create( + name="default-invalidation-logout" + ) - if not UserLogoutStage.objects.using(db_alias).exists(): - UserLogoutStage.objects.using(db_alias).create(name="logout") - - flow = Flow.objects.using(db_alias).create( - name="default-invalidation-flow", + flow, _ = Flow.objects.using(db_alias).update_or_create( slug="default-invalidation-flow", designation=FlowDesignation.INVALIDATION, + defaults={"name": "Logout",}, ) - FlowStageBinding.objects.using(db_alias).create( - flow=flow, stage=UserLogoutStage.objects.using(db_alias).first(), order=0, + FlowStageBinding.objects.using(db_alias).update_or_create( + flow=flow, + stage=UserLogoutStage.objects.using(db_alias).first(), + defaults={"order": 0,}, ) diff --git a/passbook/flows/migrations/0004_source_flows.py b/passbook/flows/migrations/0004_source_flows.py index 4a2f04432..933f986b5 100644 --- a/passbook/flows/migrations/0004_source_flows.py +++ b/passbook/flows/migrations/0004_source_flows.py @@ -34,60 +34,63 @@ 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.using(db_alias).create( - name="default-source-enrollment-if-sso", expression=FLOW_POLICY_EXPRESSION + flow_policy, _ = ExpressionPolicy.objects.using(db_alias).update_or_create( + name="default-source-enrollment-if-sso", + defaults={"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.using(db_alias).create( - name="default-source-enrollment", + flow, _ = Flow.objects.using(db_alias).update_or_create( slug="default-source-enrollment", designation=FlowDesignation.ENROLLMENT, + defaults={"name": "Welcome to passbook!",}, ) - PolicyBinding.objects.using(db_alias).create( - policy=flow_policy, target=flow, order=0 + PolicyBinding.objects.using(db_alias).update_or_create( + policy=flow_policy, target=flow, defaults={"order": 0} ) # PromptStage to ask user for their username - prompt_stage = PromptStage.objects.using(db_alias).create( + prompt_stage, _ = PromptStage.objects.using(db_alias).update_or_create( name="default-source-enrollment-username-prompt", ) - prompt_stage.fields.add( - Prompt.objects.using(db_alias).create( - field_key="username", - label="Username", - type=FieldTypes.TEXT, - required=True, - placeholder="Username", - ) + prompt, _ = Prompt.objects.using(db_alias).update_or_create( + field_key="username", + defaults={ + "label": "Username", + "type": FieldTypes.TEXT, + "required": True, + "placeholder": "Username", + }, ) + prompt_stage.fields.add(prompt) + # Policy to only trigger prompt when no username is given - prompt_policy = ExpressionPolicy.objects.using(db_alias).create( + prompt_policy, _ = ExpressionPolicy.objects.using(db_alias).update_or_create( name="default-source-enrollment-if-username", - expression=PROMPT_POLICY_EXPRESSION, + defaults={"expression": PROMPT_POLICY_EXPRESSION}, ) # UserWrite stage to create the user, and login stage to log user in - user_write = UserWriteStage.objects.using(db_alias).create( + user_write, _ = UserWriteStage.objects.using(db_alias).update_or_create( name="default-source-enrollment-write" ) - user_login = UserLoginStage.objects.using(db_alias).create( + user_login, _ = UserLoginStage.objects.using(db_alias).update_or_create( name="default-source-enrollment-login" ) - binding = FlowStageBinding.objects.using(db_alias).create( - flow=flow, stage=prompt_stage, order=0 + binding, _ = FlowStageBinding.objects.using(db_alias).update_or_create( + flow=flow, stage=prompt_stage, defaults={"order": 0} ) - PolicyBinding.objects.using(db_alias).create( - policy=prompt_policy, target=binding, order=0 + PolicyBinding.objects.using(db_alias).update_or_create( + policy=prompt_policy, target=binding, defaults={"order": 0} ) - FlowStageBinding.objects.using(db_alias).create( - flow=flow, stage=user_write, order=1 + FlowStageBinding.objects.using(db_alias).update_or_create( + flow=flow, stage=user_write, defaults={"order": 1} ) - FlowStageBinding.objects.using(db_alias).create( - flow=flow, stage=user_login, order=2 + FlowStageBinding.objects.using(db_alias).update_or_create( + flow=flow, stage=user_login, defaults={"order": 2} ) @@ -107,25 +110,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.using(db_alias).create( - name="default-source-authentication-if-sso", expression=FLOW_POLICY_EXPRESSION + flow_policy, _ = ExpressionPolicy.objects.using(db_alias).update_or_create( + name="default-source-authentication-if-sso", + defaults={"expression": FLOW_POLICY_EXPRESSION,}, ) # This creates a Flow used by sources to authenticate users - flow = Flow.objects.using(db_alias).create( - name="default-source-authentication", + flow, _ = Flow.objects.using(db_alias).update_or_create( slug="default-source-authentication", designation=FlowDesignation.AUTHENTICATION, + defaults={"name": "Welcome to passbook!",}, ) - PolicyBinding.objects.using(db_alias).create( - policy=flow_policy, target=flow, order=0 + PolicyBinding.objects.using(db_alias).update_or_create( + policy=flow_policy, target=flow, defaults={"order": 0} ) - user_login = UserLoginStage.objects.using(db_alias).create( + user_login, _ = UserLoginStage.objects.using(db_alias).update_or_create( name="default-source-authentication-login" ) - FlowStageBinding.objects.using(db_alias).create( - flow=flow, stage=user_login, order=0 + FlowStageBinding.objects.using(db_alias).update_or_create( + flow=flow, stage=user_login, defaults={"order": 0} ) diff --git a/passbook/flows/migrations/0005_provider_flows.py b/passbook/flows/migrations/0005_provider_flows.py index 6fa5fa48e..764bf826d 100644 --- a/passbook/flows/migrations/0005_provider_flows.py +++ b/passbook/flows/migrations/0005_provider_flows.py @@ -7,7 +7,7 @@ from django.db.backends.base.schema import BaseDatabaseSchemaEditor from passbook.flows.models import FlowDesignation -def create_default_provider_authz_flow( +def create_default_provider_authorization_flow( apps: Apps, schema_editor: BaseDatabaseSchemaEditor ): Flow = apps.get_model("passbook_flows", "Flow") @@ -18,22 +18,24 @@ def create_default_provider_authz_flow( db_alias = schema_editor.connection.alias # Empty flow for providers where consent is implicitly given - Flow.objects.using(db_alias).create( - name="Authorize Application", + Flow.objects.using(db_alias).update_or_create( slug="default-provider-authorization-implicit-consent", designation=FlowDesignation.AUTHORIZATION, + defaults={"name": "Authorize Application"}, ) # Flow with consent form to obtain explicit user consent - flow = Flow.objects.using(db_alias).create( - name="Authorize Application", + flow, _ = Flow.objects.using(db_alias).update_or_create( slug="default-provider-authorization-explicit-consent", designation=FlowDesignation.AUTHORIZATION, + defaults={"name": "Authorize Application"}, ) - stage = ConsentStage.objects.using(db_alias).create( + stage, _ = ConsentStage.objects.using(db_alias).update_or_create( name="default-provider-authorization-consent" ) - FlowStageBinding.objects.using(db_alias).create(flow=flow, stage=stage, order=0) + FlowStageBinding.objects.using(db_alias).update_or_create( + flow=flow, stage=stage, defaults={"order": 0} + ) class Migration(migrations.Migration): @@ -43,4 +45,4 @@ class Migration(migrations.Migration): ("passbook_stages_consent", "0001_initial"), ] - operations = [migrations.RunPython(create_default_provider_authz_flow)] + operations = [migrations.RunPython(create_default_provider_authorization_flow)] diff --git a/passbook/sources/saml/processors/base.py b/passbook/sources/saml/processors/base.py index 996d1d7c5..036c034f5 100644 --- a/passbook/sources/saml/processors/base.py +++ b/passbook/sources/saml/processors/base.py @@ -153,7 +153,7 @@ class Processor: self, request: HttpRequest, flow: Flow, **kwargs ) -> HttpResponse: kwargs[PLAN_CONTEXT_SSO] = True - request.session[SESSION_KEY_PLAN] = FlowPlanner(flow).plan(request, kwargs,) + request.session[SESSION_KEY_PLAN] = FlowPlanner(flow).plan(request, kwargs) return redirect_with_qs( "passbook_flows:flow-executor-shell", request.GET, flow_slug=flow.slug, ) diff --git a/passbook/stages/identification/forms.py b/passbook/stages/identification/forms.py index e99292640..faf8389e6 100644 --- a/passbook/stages/identification/forms.py +++ b/passbook/stages/identification/forms.py @@ -5,6 +5,7 @@ from django.core.validators import validate_email from django.utils.translation import gettext_lazy as _ from structlog import get_logger +from passbook.flows.models import Flow, FlowDesignation from passbook.lib.utils.ui import human_list from passbook.stages.identification.models import IdentificationStage, UserFields @@ -14,6 +15,15 @@ LOGGER = get_logger() class IdentificationStageForm(forms.ModelForm): """Form to create/edit IdentificationStage instances""" + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["enrollment_flow"].queryset = Flow.objects.filter( + designation=FlowDesignation.ENROLLMENT + ) + self.fields["recovery_flow"].queryset = Flow.objects.filter( + designation=FlowDesignation.RECOVERY + ) + class Meta: model = IdentificationStage diff --git a/passbook/stages/prompt/forms.py b/passbook/stages/prompt/forms.py index e9157d198..15bb648ae 100644 --- a/passbook/stages/prompt/forms.py +++ b/passbook/stages/prompt/forms.py @@ -1,5 +1,7 @@ """Prompt forms""" from django import forms +from django.contrib.admin.widgets import FilteredSelectMultiple +from django.utils.translation import gettext_lazy as _ from guardian.shortcuts import get_anonymous_user from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan @@ -16,6 +18,7 @@ class PromptStageForm(forms.ModelForm): fields = ["name", "fields"] widgets = { "name": forms.TextInput(), + "fields": FilteredSelectMultiple(_("prompts"), False), } From 693a92ada51d29e8d112e5d12064f7a6b6fc00c7 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Mon, 29 Jun 2020 16:19:56 +0200 Subject: [PATCH 3/8] audit: fix sanitize_dict updating source dict --- passbook/audit/models.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/passbook/audit/models.py b/passbook/audit/models.py index 66c4b7980..4c211e6a3 100644 --- a/passbook/audit/models.py +++ b/passbook/audit/models.py @@ -27,15 +27,16 @@ def sanitize_dict(source: Dict[Any, Any]) -> Dict[Any, Any]: name: str, pk: Any }""" + final_dict = {} for key, value in source.items(): if isinstance(value, dict): - source[key] = sanitize_dict(value) + final_dict[key] = sanitize_dict(value) elif isinstance(value, models.Model): model_content_type = ContentType.objects.get_for_model(value) name = str(value) if hasattr(value, "name"): name = value.name - source[key] = sanitize_dict( + final_dict[key] = sanitize_dict( { "app": model_content_type.app_label, "model_name": model_content_type.model, @@ -44,8 +45,10 @@ def sanitize_dict(source: Dict[Any, Any]) -> Dict[Any, Any]: } ) elif isinstance(value, UUID): - source[key] = value.hex - return source + final_dict[key] = value.hex + else: + final_dict[key] = value + return final_dict class EventAction(Enum): From d6a8d8292d8fd76563bb2e28ae73b05ebdc55f76 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Mon, 29 Jun 2020 16:20:33 +0200 Subject: [PATCH 4/8] core: UIUserSettings: remove icon, rename view_name to URL for complete URL --- passbook/core/templates/user/base.html | 4 +--- passbook/core/types.py | 3 +-- passbook/flows/views.py | 3 ++- passbook/sources/oauth/models.py | 8 +------- passbook/stages/otp/models.py | 5 ++--- 5 files changed, 7 insertions(+), 16 deletions(-) diff --git a/passbook/core/templates/user/base.html b/passbook/core/templates/user/base.html index 4c0c6798b..944684480 100644 --- a/passbook/core/templates/user/base.html +++ b/passbook/core/templates/user/base.html @@ -25,8 +25,7 @@
    {% for stage in user_stages_loc %}
  • - - + {{ stage.name }}
  • @@ -43,7 +42,6 @@
  • - {{ source.name }}
  • diff --git a/passbook/core/types.py b/passbook/core/types.py index 86d6eeea6..7804fcb29 100644 --- a/passbook/core/types.py +++ b/passbook/core/types.py @@ -8,8 +8,7 @@ class UIUserSettings: """Dataclass for Stage and Source's user_settings""" name: str - icon: str - view_name: str + url: str @dataclass diff --git a/passbook/flows/views.py b/passbook/flows/views.py index 0182afa53..fa670b8be 100644 --- a/passbook/flows/views.py +++ b/passbook/flows/views.py @@ -15,6 +15,7 @@ from django.views.decorators.clickjacking import xframe_options_sameorigin from django.views.generic import TemplateView, View from structlog import get_logger +from passbook.audit.models import sanitize_dict from passbook.core.views.utils import PermissionDeniedView from passbook.flows.exceptions import EmptyFlowException, FlowNonApplicableException from passbook.flows.models import Flow, FlowDesignation, Stage @@ -161,7 +162,7 @@ class FlowExecutorView(View): LOGGER.debug( "f(exec): User passed all stages", flow_slug=self.flow.slug, - context=self.plan.context, + context=sanitize_dict(self.plan.context), ) return self._flow_done() diff --git a/passbook/sources/oauth/models.py b/passbook/sources/oauth/models.py index 43f2a6280..09058c041 100644 --- a/passbook/sources/oauth/models.py +++ b/passbook/sources/oauth/models.py @@ -62,15 +62,9 @@ class OAuthSource(Source): @property def ui_user_settings(self) -> UIUserSettings: - icon_type = self.provider_type - if icon_type == "azure ad": - icon_type = "windows" - icon_class = f"fab fa-{icon_type}" view_name = "passbook_sources_oauth:oauth-client-user" return UIUserSettings( - name=self.name, - icon=icon_class, - view_name=reverse((view_name), kwargs={"source_slug": self.slug}), + name=self.name, url=reverse(view_name, kwargs={"source_slug": self.slug}), ) def __str__(self) -> str: diff --git a/passbook/stages/otp/models.py b/passbook/stages/otp/models.py index f0a6e1c88..10015be61 100644 --- a/passbook/stages/otp/models.py +++ b/passbook/stages/otp/models.py @@ -1,5 +1,6 @@ """OTP Stage""" from django.db import models +from django.urls import reverse from django.utils.translation import gettext as _ from passbook.core.types import UIUserSettings @@ -20,9 +21,7 @@ class OTPStage(Stage): @property def ui_user_settings(self) -> UIUserSettings: return UIUserSettings( - name="OTP", - icon="pficon-locked", - view_name="passbook_stages_otp:otp-user-settings", + name="OTP", url=reverse("passbook_stages_otp:otp-user-settings"), ) def __str__(self): From 21ba969072846388f567f27fb56a03c96cd42619 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Mon, 29 Jun 2020 16:26:21 +0200 Subject: [PATCH 5/8] stages/password: create default password change flow --- passbook/stages/password/apps.py | 1 + passbook/stages/password/forms.py | 10 +- .../0002_passwordstage_change_flow.py | 109 ++++++++++++++++++ passbook/stages/password/models.py | 31 ++++- passbook/stages/password/urls.py | 8 ++ passbook/stages/password/views.py | 32 +++++ passbook/stages/user_write/stage.py | 5 + 7 files changed, 193 insertions(+), 3 deletions(-) create mode 100644 passbook/stages/password/migrations/0002_passwordstage_change_flow.py create mode 100644 passbook/stages/password/urls.py create mode 100644 passbook/stages/password/views.py diff --git a/passbook/stages/password/apps.py b/passbook/stages/password/apps.py index 087e1f90c..7b7fdb8b8 100644 --- a/passbook/stages/password/apps.py +++ b/passbook/stages/password/apps.py @@ -8,3 +8,4 @@ class PassbookStagePasswordConfig(AppConfig): name = "passbook.stages.password" label = "passbook_stages_password" verbose_name = "passbook Stages.Password" + mountpoint = "-/user/stage/password/" diff --git a/passbook/stages/password/forms.py b/passbook/stages/password/forms.py index 41d2bbe7b..1ac9fadbb 100644 --- a/passbook/stages/password/forms.py +++ b/passbook/stages/password/forms.py @@ -3,6 +3,7 @@ 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, FlowDesignation from passbook.stages.password.models import PasswordStage @@ -40,14 +41,19 @@ class PasswordForm(forms.Form): class PasswordStageForm(forms.ModelForm): """Form to create/edit Password Stages""" + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["change_flow"].queryset = Flow.objects.filter( + designation=FlowDesignation.STAGE_SETUP + ) + class Meta: model = PasswordStage - fields = ["name", "backends"] + fields = ["name", "backends", "change_flow"] widgets = { "name": forms.TextInput(), "backends": FilteredSelectMultiple( _("backends"), False, choices=get_authentication_backends() ), - "password_policies": FilteredSelectMultiple(_("password policies"), False), } diff --git a/passbook/stages/password/migrations/0002_passwordstage_change_flow.py b/passbook/stages/password/migrations/0002_passwordstage_change_flow.py new file mode 100644 index 000000000..b08377526 --- /dev/null +++ b/passbook/stages/password/migrations/0002_passwordstage_change_flow.py @@ -0,0 +1,109 @@ +# Generated by Django 3.0.7 on 2020-06-29 08:51 + +import django.db.models.deletion +from django.apps.registry import Apps +from django.db import migrations, models +from django.db.backends.base.schema import BaseDatabaseSchemaEditor + +from passbook.flows.models import FlowDesignation +from passbook.stages.prompt.models import FieldTypes + +PROMPT_POLICY_EXPRESSION = """# Check that both passwords are equal. +return request.context['password'] == request.context['password_repeat']""" + + +def create_default_password_change(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): + Flow = apps.get_model("passbook_flows", "Flow") + FlowStageBinding = apps.get_model("passbook_flows", "FlowStageBinding") + + PolicyBinding = apps.get_model("passbook_policies", "PolicyBinding") + + ExpressionPolicy = apps.get_model( + "passbook_policies_expression", "ExpressionPolicy" + ) + + PromptStage = apps.get_model("passbook_stages_prompt", "PromptStage") + Prompt = apps.get_model("passbook_stages_prompt", "Prompt") + + UserWriteStage = apps.get_model("passbook_stages_user_write", "UserWriteStage") + + db_alias = schema_editor.connection.alias + + flow, _ = Flow.objects.using(db_alias).update_or_create( + slug="default-password-change", + designation=FlowDesignation.STAGE_SETUP, + defaults={"name": "Change Password"}, + ) + + prompt_stage, _ = PromptStage.objects.using(db_alias).update_or_create( + name="default-password-change-prompt", + ) + password_prompt, _ = Prompt.objects.using(db_alias).update_or_create( + field_key="password", + defaults={ + "label": "Password", + "type": FieldTypes.PASSWORD, + "required": True, + "placeholder": "Password", + "order": 0, + }, + ) + password_rep_prompt, _ = Prompt.objects.using(db_alias).update_or_create( + field_key="password_repeat", + defaults={ + "label": "Password (repeat)", + "type": FieldTypes.PASSWORD, + "required": True, + "placeholder": "Password (repeat)", + "order": 1, + }, + ) + prompt_stage.fields.add(password_prompt) + prompt_stage.fields.add(password_rep_prompt) + + # Policy to only trigger prompt when no username is given + prompt_policy, _ = ExpressionPolicy.objects.using(db_alias).update_or_create( + name="default-password-change-password-equal", + defaults={"expression": PROMPT_POLICY_EXPRESSION}, + ) + PolicyBinding.objects.using(db_alias).update_or_create( + policy=prompt_policy, target=prompt_stage, defaults={"order": 0} + ) + + user_write, _ = UserWriteStage.objects.using(db_alias).update_or_create( + name="default-password-change-write" + ) + + FlowStageBinding.objects.using(db_alias).update_or_create( + flow=flow, stage=prompt_stage, defaults={"order": 0} + ) + FlowStageBinding.objects.using(db_alias).update_or_create( + flow=flow, stage=user_write, defaults={"order": 1} + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ("passbook_flows", "0006_auto_20200629_0857"), + ("passbook_policies_expression", "0001_initial"), + ("passbook_policies", "0001_initial"), + ("passbook_stages_password", "0001_initial"), + ("passbook_stages_prompt", "0004_auto_20200618_1735"), + ("passbook_stages_user_write", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="passwordstage", + name="change_flow", + field=models.ForeignKey( + blank=True, + help_text="Flow used by an authenticated user to change their password. If empty, user will be unable to change their password.", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="passbook_flows.Flow", + ), + ), + migrations.RunPython(create_default_password_change), + ] diff --git a/passbook/stages/password/models.py b/passbook/stages/password/models.py index 8d585cfb6..943254f18 100644 --- a/passbook/stages/password/models.py +++ b/passbook/stages/password/models.py @@ -1,9 +1,15 @@ """password stage models""" +from typing import Optional + from django.contrib.postgres.fields import ArrayField from django.db import models +from django.shortcuts import reverse +from django.utils.http import urlencode from django.utils.translation import gettext_lazy as _ -from passbook.flows.models import Stage +from passbook.core.types import UIUserSettings +from passbook.flows.models import Flow, Stage +from passbook.flows.views import NEXT_ARG_NAME class PasswordStage(Stage): @@ -14,9 +20,32 @@ class PasswordStage(Stage): help_text=_("Selection of backends to test the password against."), ) + change_flow = models.ForeignKey( + Flow, + on_delete=models.SET_NULL, + null=True, + blank=True, + help_text=_( + ( + "Flow used by an authenticated user to change their password. " + "If empty, user will be unable to change their password." + ) + ), + ) + type = "passbook.stages.password.stage.PasswordStage" form = "passbook.stages.password.forms.PasswordStageForm" + @property + def ui_user_settings(self) -> Optional[UIUserSettings]: + if not self.change_flow: + return None + base_url = reverse( + "passbook_stages_password:change", kwargs={"stage_uuid": self.pk} + ) + args = urlencode({NEXT_ARG_NAME: reverse("passbook_core:user-settings")}) + return UIUserSettings(name=self.name, url=f"{base_url}?{args}") + def __str__(self): return f"Password Stage {self.name}" diff --git a/passbook/stages/password/urls.py b/passbook/stages/password/urls.py new file mode 100644 index 000000000..3e8f2ae4c --- /dev/null +++ b/passbook/stages/password/urls.py @@ -0,0 +1,8 @@ +"""password stage URLs""" +from django.urls import path + +from passbook.stages.password.views import ChangeFlowInitView + +urlpatterns = [ + path("/change/", ChangeFlowInitView.as_view(), name="change") +] diff --git a/passbook/stages/password/views.py b/passbook/stages/password/views.py new file mode 100644 index 000000000..051553ba4 --- /dev/null +++ b/passbook/stages/password/views.py @@ -0,0 +1,32 @@ +"""password stage views""" +from django.contrib.auth.mixins import LoginRequiredMixin +from django.http import Http404, HttpRequest, HttpResponse +from django.shortcuts import get_object_or_404 +from django.views import View + +from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlanner +from passbook.flows.views import SESSION_KEY_PLAN +from passbook.lib.utils.urls import redirect_with_qs +from passbook.stages.password.models import PasswordStage + + +class ChangeFlowInitView(LoginRequiredMixin, View): + """Initiate planner for selected change flow and redirect to flow executor, + or raise Http404 if no change_flow has been set.""" + + def get(self, request: HttpRequest, stage_uuid: str) -> HttpResponse: + """Initiate planner for selected change flow and redirect to flow executor, + or raise Http404 if no change_flow has been set.""" + stage: PasswordStage = get_object_or_404(PasswordStage, pk=stage_uuid) + if not stage.change_flow: + raise Http404 + + plan = FlowPlanner(stage.change_flow).plan( + request, {PLAN_CONTEXT_PENDING_USER: request.user,} + ) + request.session[SESSION_KEY_PLAN] = plan + return redirect_with_qs( + "passbook_flows:flow-executor-shell", + self.request.GET, + flow_slug=stage.change_flow.slug, + ) diff --git a/passbook/stages/user_write/stage.py b/passbook/stages/user_write/stage.py index 28eab9853..bf1973b8e 100644 --- a/passbook/stages/user_write/stage.py +++ b/passbook/stages/user_write/stage.py @@ -1,5 +1,6 @@ """Write stage logic""" from django.contrib import messages +from django.contrib.auth import update_session_auth_hash from django.contrib.auth.backends import ModelBackend from django.http import HttpRequest, HttpResponse from django.utils.translation import gettext as _ @@ -48,6 +49,10 @@ class UserWriteStageView(StageView): else: user.attributes[key] = value user.save() + # Check if the password has been updated, and update the session auth hash + if any(["password" in x for x in data.keys()]): + update_session_auth_hash(self.request, user) + LOGGER.debug("Updated session hash", user=user) LOGGER.debug( "Updated existing user", user=user, flow_slug=self.executor.flow.slug, ) From 96a6ac85dfc554c3598ee4d04135d3ebaec45e89 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Mon, 29 Jun 2020 19:13:07 +0200 Subject: [PATCH 6/8] audit: add cleanse_dict function to ensure no passwords end in logs --- passbook/audit/models.py | 19 ++++++++++++++++++- passbook/flows/views.py | 4 ++-- passbook/stages/password/views.py | 2 +- 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/passbook/audit/models.py b/passbook/audit/models.py index 4c211e6a3..e5dfebab6 100644 --- a/passbook/audit/models.py +++ b/passbook/audit/models.py @@ -12,6 +12,7 @@ from django.core.exceptions import ValidationError from django.db import models from django.http import HttpRequest from django.utils.translation import gettext as _ +from django.views.debug import CLEANSED_SUBSTITUTE, HIDDEN_SETTINGS from guardian.shortcuts import get_anonymous_user from structlog import get_logger @@ -20,6 +21,22 @@ from passbook.lib.utils.http import get_client_ip LOGGER = get_logger() +def cleanse_dict(source: Dict[Any, Any]) -> Dict[Any, Any]: + """Cleanse a dictionary, recursively""" + final_dict = {} + for key, value in source.items(): + try: + if HIDDEN_SETTINGS.search(key): + final_dict[key] = CLEANSED_SUBSTITUTE + else: + final_dict[key] = value + except TypeError: + final_dict[key] = value + if isinstance(value, dict): + final_dict[key] = cleanse_dict(value) + return final_dict + + def sanitize_dict(source: Dict[Any, Any]) -> Dict[Any, Any]: """clean source of all Models that would interfere with the JSONField. Models are replaced with a dictionary of { @@ -107,7 +124,7 @@ class Event(models.Model): ) if not app: app = getmodule(stack()[_inspect_offset][0]).__name__ - cleaned_kwargs = sanitize_dict(kwargs) + cleaned_kwargs = cleanse_dict(sanitize_dict(kwargs)) event = Event(action=action.value, app=app, context=cleaned_kwargs) return event diff --git a/passbook/flows/views.py b/passbook/flows/views.py index fa670b8be..c78f1aa38 100644 --- a/passbook/flows/views.py +++ b/passbook/flows/views.py @@ -15,7 +15,7 @@ from django.views.decorators.clickjacking import xframe_options_sameorigin from django.views.generic import TemplateView, View from structlog import get_logger -from passbook.audit.models import sanitize_dict +from passbook.audit.models import cleanse_dict from passbook.core.views.utils import PermissionDeniedView from passbook.flows.exceptions import EmptyFlowException, FlowNonApplicableException from passbook.flows.models import Flow, FlowDesignation, Stage @@ -162,7 +162,7 @@ class FlowExecutorView(View): LOGGER.debug( "f(exec): User passed all stages", flow_slug=self.flow.slug, - context=sanitize_dict(self.plan.context), + context=cleanse_dict(self.plan.context), ) return self._flow_done() diff --git a/passbook/stages/password/views.py b/passbook/stages/password/views.py index 051553ba4..98d0e77a7 100644 --- a/passbook/stages/password/views.py +++ b/passbook/stages/password/views.py @@ -22,7 +22,7 @@ class ChangeFlowInitView(LoginRequiredMixin, View): raise Http404 plan = FlowPlanner(stage.change_flow).plan( - request, {PLAN_CONTEXT_PENDING_USER: request.user,} + request, {PLAN_CONTEXT_PENDING_USER: request.user} ) request.session[SESSION_KEY_PLAN] = plan return redirect_with_qs( From 8d31eef47d47ffb1850b7e7359220887ce1fd647 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Mon, 29 Jun 2020 21:39:04 +0200 Subject: [PATCH 7/8] stages/password: assign default password change flow to password stage add e2e tests for password change flow --- e2e/passbook.side | 198 ++++++++ e2e/test_enroll.py | 477 ------------------ e2e/test_flows_enroll.py | 260 ++++++++++ ...t_login_default.py => test_flows_login.py} | 2 +- e2e/test_flows_stage_setup.py | 41 ++ .../0002_passwordstage_change_flow.py | 17 + passbook/stages/password/models.py | 2 +- 7 files changed, 518 insertions(+), 479 deletions(-) delete mode 100644 e2e/test_enroll.py create mode 100644 e2e/test_flows_enroll.py rename e2e/{test_login_default.py => test_flows_login.py} (95%) create mode 100644 e2e/test_flows_stage_setup.py diff --git a/e2e/passbook.side b/e2e/passbook.side index af2abbb3c..631e930f8 100644 --- a/e2e/passbook.side +++ b/e2e/passbook.side @@ -286,6 +286,204 @@ ], "value": "foo@bar.baz" }] + }, { + "id": "1a3172e0-ac23-4781-9367-19afccee4f4a", + "name": "flows stage setup password", + "commands": [{ + "id": "77784f77-d840-4b3d-a42f-7928f02fb7e1", + "comment": "", + "command": "open", + "target": "/flows/default-authentication-flow/?next=%2F", + "targets": [], + "value": "" + }, { + "id": "783aa9a6-81e5-49c6-8789-2f360a5750b1", + "comment": "", + "command": "setWindowSize", + "target": "1699x1417", + "targets": [], + "value": "" + }, { + "id": "cb0cd63e-30e9-4443-af59-5345fe26dc88", + "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": "8466ded1-c5f6-451c-b63f-0889da38503a", + "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": "pbadmin" + }, { + "id": "27383093-d01a-4416-8fc6-9caad4926cd3", + "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": "4602745a-0ebb-4425-a841-a1ed4899659d", + "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": "d1ff4f81-d8f9-45dc-ad5d-f99b54c0cd18", + "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": "014c8f57-7ef2-469c-b700-efa94ba81b66", + "comment": "", + "command": "click", + "target": "css=.pf-c-page__header", + "targets": [ + ["css=.pf-c-page__header", "css:finder"], + ["xpath=//div[@id='page-default-nav-example']/header", "xpath:idRelative"], + ["xpath=//header", "xpath:position"] + ], + "value": "" + }, { + "id": "14e86b6f-6add-4bcc-913a-42b1e7322c79", + "comment": "", + "command": "click", + "target": "linkText=pbadmin", + "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": "" + }, { + "id": "8280da13-632e-4cba-9e18-ecae0d57d052", + "comment": "", + "command": "click", + "target": "linkText=Change password", + "targets": [ + ["linkText=Change password", "linkText"], + ["css=.pf-c-nav__section:nth-child(2) .pf-c-nav__link", "css:finder"], + ["xpath=//a[contains(text(),'Change password')]", "xpath:link"], + ["xpath=//nav[@id='page-default-nav-example-primary-nav']/section[2]/ul/li/a", "xpath:idRelative"], + ["xpath=//a[contains(@href, '/-/user/stage/password/b929b529-e384-4409-8d40-ac4a195fcab2/change/?next=%2F-%2Fuser%2F')]", "xpath:href"], + ["xpath=//section[2]/ul/li/a", "xpath:position"], + ["xpath=//a[contains(.,'Change password')]", "xpath:innerText"] + ], + "value": "" + }, { + "id": "716d7e0c-79dc-469b-a31f-dceaa0765e9c", + "comment": "", + "command": "click", + "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/input", "xpath:idRelative"], + ["xpath=//div/input", "xpath:position"] + ], + "value": "" + }, { + "id": "77005d70-adf0-4add-8329-b092d43f829a", + "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/input", "xpath:idRelative"], + ["xpath=//div/input", "xpath:position"] + ], + "value": "test" + }, { + "id": "965ca365-99f4-45d1-97c3-c944269341b9", + "comment": "", + "command": "click", + "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[2]/input", "xpath:idRelative"], + ["xpath=//div[2]/input", "xpath:position"] + ], + "value": "" + }, { + "id": "9b421468-c65e-4943-b6b1-1e80410a6b87", + "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[2]/input", "xpath:idRelative"], + ["xpath=//div[2]/input", "xpath:position"] + ], + "value": "test" + }, { + "id": "572c1400-a0f2-499f-808a-18c1f56bf13f", + "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": "" + }] }], "suites": [{ "id": "495657fb-3f5e-4431-877c-4d0b248c0841", diff --git a/e2e/test_enroll.py b/e2e/test_enroll.py deleted file mode 100644 index 38935bc94..000000000 --- a/e2e/test_enroll.py +++ /dev/null @@ -1,477 +0,0 @@ -"""Test Enroll flow""" -from time import sleep - -from django.test import override_settings -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 -from docker.types import Healthcheck -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.email.models import EmailStage, EmailTemplates -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 TestEnroll(SeleniumTestCase): - """Test Enroll flow""" - - def setUp(self): - super().setUp() - self.container = self.setup_client() - - def setup_client(self) -> Container: - """Setup test IdP container""" - client: DockerClient = from_env() - container = client.containers.run( - image="mailhog/mailhog", - detach=True, - network_mode="host", - auto_remove=True, - healthcheck=Healthcheck( - test=["CMD", "wget", "-s", "http://localhost:8025"], - interval=5 * 100 * 1000000, - start_period=1 * 100 * 1000000, - ), - ) - 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() - - # 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.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']" - ) - 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() - dropdown = self.driver.find_element(By.ID, "id_flow") - dropdown.find_element( - By.XPATH, '//option[. = "Flow Welcome (default-enrollment-flow)"]' - ).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").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, "[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.wait.until( - ec.presence_of_element_located((By.CSS_SELECTOR, "[role=enroll]")) - ) - self.driver.find_element(By.CSS_SELECTOR, "[role=enroll]").click() - - self.wait.until(ec.presence_of_element_located((By.ID, "id_username"))) - 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) - 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.wait.until(ec.presence_of_element_located((By.LINK_TEXT, "foo"))) - self.driver.find_element(By.LINK_TEXT, "foo").click() - - self.wait_for_url(self.url("passbook_core:user-settings")) - 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", - ) - - @override_settings(EMAIL_BACKEND="django.core.mail.backends.smtp.EmailBackend") - def test_enroll_email(self): - """Test enroll with Email verification""" - # 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() - email_stage = EmailStage.objects.create( - name="enroll-email", - host="localhost", - port=1025, - template=EmailTemplates.ACCOUNT_CONFIRM, - ) - 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=email_stage, order=3) - FlowStageBinding.objects.create(flow=flow, stage=user_login, order=4) - - 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(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") - self.driver.find_element(By.CSS_SELECTOR, ".pf-c-button").click() - sleep(3) - - # Open Mailhog - self.driver.get("http://localhost:8025") - - # Click on first message - self.driver.find_element(By.CLASS_NAME, "msglist-message").click() - sleep(3) - self.driver.switch_to.frame(self.driver.find_element(By.CLASS_NAME, "tab-pane")) - self.driver.find_element(By.ID, "confirm").click() - self.driver.close() - self.driver.switch_to.window(self.driver.window_handles[0]) - - # We're now logged in - sleep(3) - self.wait.until( - ec.presence_of_element_located( - (By.XPATH, "//a[contains(@href, '/-/user/')]") - ) - ) - self.driver.find_element(By.XPATH, "//a[contains(@href, '/-/user/')]").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_flows_enroll.py b/e2e/test_flows_enroll.py new file mode 100644 index 000000000..0fec78d3f --- /dev/null +++ b/e2e/test_flows_enroll.py @@ -0,0 +1,260 @@ +"""Test Enroll flow""" +from time import sleep + +from django.test import override_settings +from selenium.webdriver.common.by import By +from selenium.webdriver.support import expected_conditions as ec + +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.flows.models import Flow, FlowDesignation, FlowStageBinding +from passbook.policies.expression.models import ExpressionPolicy +from passbook.policies.models import PolicyBinding +from passbook.stages.email.models import EmailStage, EmailTemplates +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 TestFlowsEnroll(SeleniumTestCase): + """Test Enroll flow""" + + def setUp(self): + super().setUp() + self.container = self.setup_client() + + def setup_client(self) -> Container: + """Setup test IdP container""" + client: DockerClient = from_env() + container = client.containers.run( + image="mailhog/mailhog", + detach=True, + network_mode="host", + auto_remove=True, + healthcheck=Healthcheck( + test=["CMD", "wget", "-s", "http://localhost:8025"], + interval=5 * 100 * 1000000, + start_period=1 * 100 * 1000000, + ), + ) + 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_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.wait.until( + ec.presence_of_element_located((By.CSS_SELECTOR, "[role=enroll]")) + ) + self.driver.find_element(By.CSS_SELECTOR, "[role=enroll]").click() + + self.wait.until(ec.presence_of_element_located((By.ID, "id_username"))) + 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) + 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.wait.until(ec.presence_of_element_located((By.LINK_TEXT, "foo"))) + self.driver.find_element(By.LINK_TEXT, "foo").click() + + self.wait_for_url(self.url("passbook_core:user-settings")) + 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", + ) + + @override_settings(EMAIL_BACKEND="django.core.mail.backends.smtp.EmailBackend") + def test_enroll_email(self): + """Test enroll with Email verification""" + # 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() + email_stage = EmailStage.objects.create( + name="enroll-email", + host="localhost", + port=1025, + template=EmailTemplates.ACCOUNT_CONFIRM, + ) + 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=email_stage, order=3) + FlowStageBinding.objects.create(flow=flow, stage=user_login, order=4) + + 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(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") + self.driver.find_element(By.CSS_SELECTOR, ".pf-c-button").click() + sleep(3) + + # Open Mailhog + self.driver.get("http://localhost:8025") + + # Click on first message + self.driver.find_element(By.CLASS_NAME, "msglist-message").click() + sleep(3) + self.driver.switch_to.frame(self.driver.find_element(By.CLASS_NAME, "tab-pane")) + self.driver.find_element(By.ID, "confirm").click() + self.driver.close() + self.driver.switch_to.window(self.driver.window_handles[0]) + + # We're now logged in + sleep(3) + self.wait.until( + ec.presence_of_element_located( + (By.XPATH, "//a[contains(@href, '/-/user/')]") + ) + ) + self.driver.find_element(By.XPATH, "//a[contains(@href, '/-/user/')]").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_flows_login.py similarity index 95% rename from e2e/test_login_default.py rename to e2e/test_flows_login.py index 5b0d8bcad..c92b3cbc6 100644 --- a/e2e/test_login_default.py +++ b/e2e/test_flows_login.py @@ -5,7 +5,7 @@ from selenium.webdriver.common.keys import Keys from e2e.utils import USER, SeleniumTestCase -class TestLogin(SeleniumTestCase): +class TestFlowsLogin(SeleniumTestCase): """test default login flow""" def test_login(self): diff --git a/e2e/test_flows_stage_setup.py b/e2e/test_flows_stage_setup.py new file mode 100644 index 000000000..ca32a99d2 --- /dev/null +++ b/e2e/test_flows_stage_setup.py @@ -0,0 +1,41 @@ +"""test stage setup flows (password change)""" +import string +from random import SystemRandom +from time import sleep + +from selenium.webdriver.common.by import By +from selenium.webdriver.common.keys import Keys + +from e2e.utils import USER, SeleniumTestCase +from passbook.core.models import User + + +class TestFlowsStageSetup(SeleniumTestCase): + """test stage setup flows""" + + def test_password_change(self): + """test password change flow""" + new_password = "".join( + SystemRandom().choice(string.ascii_uppercase + string.digits) + for _ in range(8) + ) + + self.driver.get( + f"{self.live_server_url}/flows/default-authentication-flow/?next=%2F" + ) + 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.CSS_SELECTOR, ".pf-c-page__header").click() + self.driver.find_element(By.XPATH, "//a[contains(@href, '/-/user/')]").click() + self.driver.find_element(By.LINK_TEXT, "Change password").click() + self.driver.find_element(By.ID, "id_password").send_keys(new_password) + self.driver.find_element(By.ID, "id_password_repeat").click() + self.driver.find_element(By.ID, "id_password_repeat").send_keys(new_password) + self.driver.find_element(By.CSS_SELECTOR, ".pf-c-button").click() + + sleep(2) + # Because USER() is cached, we need to get the user manually here + user = User.objects.get(username=USER().username) + self.assertTrue(user.check_password(new_password)) diff --git a/passbook/stages/password/migrations/0002_passwordstage_change_flow.py b/passbook/stages/password/migrations/0002_passwordstage_change_flow.py index b08377526..8b3fa4ade 100644 --- a/passbook/stages/password/migrations/0002_passwordstage_change_flow.py +++ b/passbook/stages/password/migrations/0002_passwordstage_change_flow.py @@ -82,6 +82,22 @@ def create_default_password_change(apps: Apps, schema_editor: BaseDatabaseSchema ) +def update_default_stage_change(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): + PasswordStage = apps.get_model("passbook_stages_password", "PasswordStage") + Flow = apps.get_model("passbook_flows", "Flow") + + flow = Flow.objects.get( + slug="default-password-change", designation=FlowDesignation.STAGE_SETUP, + ) + + stages = PasswordStage.objects.filter(name="default-authentication-password") + if not stages.exists(): + return + stage = stages.first() + stage.change_flow = flow + stage.save() + + class Migration(migrations.Migration): dependencies = [ @@ -106,4 +122,5 @@ class Migration(migrations.Migration): ), ), migrations.RunPython(create_default_password_change), + migrations.RunPython(update_default_stage_change), ] diff --git a/passbook/stages/password/models.py b/passbook/stages/password/models.py index 943254f18..0a327f341 100644 --- a/passbook/stages/password/models.py +++ b/passbook/stages/password/models.py @@ -44,7 +44,7 @@ class PasswordStage(Stage): "passbook_stages_password:change", kwargs={"stage_uuid": self.pk} ) args = urlencode({NEXT_ARG_NAME: reverse("passbook_core:user-settings")}) - return UIUserSettings(name=self.name, url=f"{base_url}?{args}") + return UIUserSettings(name=_("Change password"), url=f"{base_url}?{args}") def __str__(self): return f"Password Stage {self.name}" From 05183ed937169a695390879f9e79865d27cfb876 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Mon, 29 Jun 2020 22:11:50 +0200 Subject: [PATCH 8/8] e2e: saml provider: wait for URL --- e2e/test_provider_saml.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/e2e/test_provider_saml.py b/e2e/test_provider_saml.py index 790a0ec37..197f244f1 100644 --- a/e2e/test_provider_saml.py +++ b/e2e/test_provider_saml.py @@ -88,6 +88,7 @@ class TestProviderSAML(SeleniumTestCase): 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.wait_for_url("http://localhost:9009/") self.assertEqual( self.driver.find_element(By.XPATH, "/html/body/pre").text, f"Hello, {USER().name}!", @@ -128,6 +129,7 @@ class TestProviderSAML(SeleniumTestCase): ).text, ) self.driver.find_element(By.CSS_SELECTOR, "[type=submit]").click() + self.wait_for_url("http://localhost:9009/") self.assertEqual( self.driver.find_element(By.XPATH, "/html/body/pre").text, f"Hello, {USER().name}!", @@ -166,6 +168,7 @@ class TestProviderSAML(SeleniumTestCase): 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.wait_for_url("http://localhost:9009/") self.assertEqual( self.driver.find_element(By.XPATH, "/html/body/pre").text, f"Hello, {USER().name}!",