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, )