stages/password: create default password change flow

This commit is contained in:
Jens Langhammer 2020-06-29 16:26:21 +02:00
parent d6a8d8292d
commit 21ba969072
7 changed files with 193 additions and 3 deletions

View File

@ -8,3 +8,4 @@ class PassbookStagePasswordConfig(AppConfig):
name = "passbook.stages.password" name = "passbook.stages.password"
label = "passbook_stages_password" label = "passbook_stages_password"
verbose_name = "passbook Stages.Password" verbose_name = "passbook Stages.Password"
mountpoint = "-/user/stage/password/"

View File

@ -3,6 +3,7 @@ from django import forms
from django.contrib.admin.widgets import FilteredSelectMultiple from django.contrib.admin.widgets import FilteredSelectMultiple
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from passbook.flows.models import Flow, FlowDesignation
from passbook.stages.password.models import PasswordStage from passbook.stages.password.models import PasswordStage
@ -40,14 +41,19 @@ class PasswordForm(forms.Form):
class PasswordStageForm(forms.ModelForm): class PasswordStageForm(forms.ModelForm):
"""Form to create/edit Password Stages""" """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: class Meta:
model = PasswordStage model = PasswordStage
fields = ["name", "backends"] fields = ["name", "backends", "change_flow"]
widgets = { widgets = {
"name": forms.TextInput(), "name": forms.TextInput(),
"backends": FilteredSelectMultiple( "backends": FilteredSelectMultiple(
_("backends"), False, choices=get_authentication_backends() _("backends"), False, choices=get_authentication_backends()
), ),
"password_policies": FilteredSelectMultiple(_("password policies"), False),
} }

View File

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

View File

@ -1,9 +1,15 @@
"""password stage models""" """password stage models"""
from typing import Optional
from django.contrib.postgres.fields import ArrayField from django.contrib.postgres.fields import ArrayField
from django.db import models 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 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): class PasswordStage(Stage):
@ -14,9 +20,32 @@ class PasswordStage(Stage):
help_text=_("Selection of backends to test the password against."), 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" type = "passbook.stages.password.stage.PasswordStage"
form = "passbook.stages.password.forms.PasswordStageForm" 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): def __str__(self):
return f"Password Stage {self.name}" return f"Password Stage {self.name}"

View File

@ -0,0 +1,8 @@
"""password stage URLs"""
from django.urls import path
from passbook.stages.password.views import ChangeFlowInitView
urlpatterns = [
path("<uuid:stage_uuid>/change/", ChangeFlowInitView.as_view(), name="change")
]

View File

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

View File

@ -1,5 +1,6 @@
"""Write stage logic""" """Write stage logic"""
from django.contrib import messages from django.contrib import messages
from django.contrib.auth import update_session_auth_hash
from django.contrib.auth.backends import ModelBackend from django.contrib.auth.backends import ModelBackend
from django.http import HttpRequest, HttpResponse from django.http import HttpRequest, HttpResponse
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
@ -48,6 +49,10 @@ class UserWriteStageView(StageView):
else: else:
user.attributes[key] = value user.attributes[key] = value
user.save() 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( LOGGER.debug(
"Updated existing user", user=user, flow_slug=self.executor.flow.slug, "Updated existing user", user=user, flow_slug=self.executor.flow.slug,
) )