From 5b2bf7519af83b823dfcad6696fcd0732419e825 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Sun, 10 May 2020 23:38:15 +0200 Subject: [PATCH] stages/user_create -> user_write: Stage can create and update existing users --- passbook/api/v2/urls.py | 4 +- .../flows/migrations/0002_default_flows.py | 9 +- passbook/root/settings.py | 2 +- passbook/stages/user_create/api.py | 24 ---- passbook/stages/user_create/apps.py | 10 -- passbook/stages/user_create/forms.py | 16 --- passbook/stages/user_create/models.py | 19 --- passbook/stages/user_create/stage.py | 39 ------- passbook/stages/user_create/tests.py | 73 ------------ .../{user_create => user_write}/__init__.py | 0 passbook/stages/user_write/api.py | 24 ++++ passbook/stages/user_write/apps.py | 10 ++ passbook/stages/user_write/forms.py | 16 +++ .../migrations/0001_initial.py | 8 +- .../migrations/__init__.py | 0 passbook/stages/user_write/models.py | 19 +++ passbook/stages/user_write/stage.py | 52 +++++++++ passbook/stages/user_write/tests.py | 110 ++++++++++++++++++ 18 files changed, 241 insertions(+), 194 deletions(-) delete mode 100644 passbook/stages/user_create/api.py delete mode 100644 passbook/stages/user_create/apps.py delete mode 100644 passbook/stages/user_create/forms.py delete mode 100644 passbook/stages/user_create/models.py delete mode 100644 passbook/stages/user_create/stage.py delete mode 100644 passbook/stages/user_create/tests.py rename passbook/stages/{user_create => user_write}/__init__.py (100%) create mode 100644 passbook/stages/user_write/api.py create mode 100644 passbook/stages/user_write/apps.py create mode 100644 passbook/stages/user_write/forms.py rename passbook/stages/{user_create => user_write}/migrations/0001_initial.py (80%) rename passbook/stages/{user_create => user_write}/migrations/__init__.py (100%) create mode 100644 passbook/stages/user_write/models.py create mode 100644 passbook/stages/user_write/stage.py create mode 100644 passbook/stages/user_write/tests.py diff --git a/passbook/api/v2/urls.py b/passbook/api/v2/urls.py index b06c3e45b..4b6370a62 100644 --- a/passbook/api/v2/urls.py +++ b/passbook/api/v2/urls.py @@ -37,8 +37,8 @@ from passbook.stages.identification.api import IdentificationStageViewSet from passbook.stages.otp.api import OTPStageViewSet from passbook.stages.password.api import PasswordStageViewSet from passbook.stages.prompt.api import PromptStageViewSet, PromptViewSet -from passbook.stages.user_create.api import UserCreateStageViewSet from passbook.stages.user_login.api import UserLoginStageViewSet +from passbook.stages.user_write.api import UserWriteStageViewSet LOGGER = get_logger() router = routers.DefaultRouter() @@ -86,7 +86,7 @@ router.register("stages/otp", OTPStageViewSet) router.register("stages/password", PasswordStageViewSet) router.register("stages/prompt", PromptStageViewSet) router.register("stages/prompt/prompts", PromptViewSet) -router.register("stages/user_create", UserCreateStageViewSet) +router.register("stages/user_write", UserWriteStageViewSet) router.register("stages/user_login", UserLoginStageViewSet) router.register("flows", FlowViewSet) diff --git a/passbook/flows/migrations/0002_default_flows.py b/passbook/flows/migrations/0002_default_flows.py index 2db2f3af4..160c97544 100644 --- a/passbook/flows/migrations/0002_default_flows.py +++ b/passbook/flows/migrations/0002_default_flows.py @@ -37,22 +37,19 @@ def create_default_flow(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): if not UserLoginStage.objects.using(db_alias).exists(): UserLoginStage.objects.using(db_alias).create(name="authentication") - ident_stage = IdentificationStage.objects.using(db_alias).first() - pw_stage = PasswordStage.objects.using(db_alias).first() - login_stage = UserLoginStage.objects.using(db_alias).first() flow = Flow.objects.using(db_alias).create( name="default-authentication-flow", slug="default-authentication-flow", designation=FlowDesignation.AUTHENTICATION, ) FlowStageBinding.objects.using(db_alias).create( - flow=flow, stage=ident_stage, order=0, + flow=flow, stage=IdentificationStage.objects.using(db_alias).first(), order=0, ) FlowStageBinding.objects.using(db_alias).create( - flow=flow, stage=pw_stage, order=1, + flow=flow, stage=PasswordStage.objects.using(db_alias).first(), order=1, ) FlowStageBinding.objects.using(db_alias).create( - flow=flow, stage=login_stage, order=2, + flow=flow, stage=UserLoginStage.objects.using(db_alias).first(), order=2, ) diff --git a/passbook/root/settings.py b/passbook/root/settings.py index b711f396b..8315e4003 100644 --- a/passbook/root/settings.py +++ b/passbook/root/settings.py @@ -108,8 +108,8 @@ INSTALLED_APPS = [ "passbook.stages.email.apps.PassbookStageEmailConfig", "passbook.stages.prompt.apps.PassbookStagPromptConfig", "passbook.stages.identification.apps.PassbookStageIdentificationConfig", - "passbook.stages.user_create.apps.PassbookStageUserCreateConfig", "passbook.stages.user_login.apps.PassbookStageUserLoginConfig", + "passbook.stages.user_write.apps.PassbookStageUserWriteConfig", "passbook.stages.otp.apps.PassbookStageOTPConfig", "passbook.stages.password.apps.PassbookStagePasswordConfig", "passbook.static.apps.PassbookStaticConfig", diff --git a/passbook/stages/user_create/api.py b/passbook/stages/user_create/api.py deleted file mode 100644 index d95415eed..000000000 --- a/passbook/stages/user_create/api.py +++ /dev/null @@ -1,24 +0,0 @@ -"""User Create Stage API Views""" -from rest_framework.serializers import ModelSerializer -from rest_framework.viewsets import ModelViewSet - -from passbook.stages.user_create.models import UserCreateStage - - -class UserCreateStageSerializer(ModelSerializer): - """UserCreateStage Serializer""" - - class Meta: - - model = UserCreateStage - fields = [ - "pk", - "name", - ] - - -class UserCreateStageViewSet(ModelViewSet): - """UserCreateStage Viewset""" - - queryset = UserCreateStage.objects.all() - serializer_class = UserCreateStageSerializer diff --git a/passbook/stages/user_create/apps.py b/passbook/stages/user_create/apps.py deleted file mode 100644 index 27bb3b467..000000000 --- a/passbook/stages/user_create/apps.py +++ /dev/null @@ -1,10 +0,0 @@ -"""passbook create stage app config""" -from django.apps import AppConfig - - -class PassbookStageUserCreateConfig(AppConfig): - """passbook create stage config""" - - name = "passbook.stages.user_create" - label = "passbook_stages_user_create" - verbose_name = "passbook Stages.User Create" diff --git a/passbook/stages/user_create/forms.py b/passbook/stages/user_create/forms.py deleted file mode 100644 index edf5bb784..000000000 --- a/passbook/stages/user_create/forms.py +++ /dev/null @@ -1,16 +0,0 @@ -"""passbook flows create forms""" -from django import forms - -from passbook.stages.user_create.models import UserCreateStage - - -class UserCreateStageForm(forms.ModelForm): - """Form to create/edit UserCreateStage instances""" - - class Meta: - - model = UserCreateStage - fields = ["name"] - widgets = { - "name": forms.TextInput(), - } diff --git a/passbook/stages/user_create/models.py b/passbook/stages/user_create/models.py deleted file mode 100644 index f0b48a97b..000000000 --- a/passbook/stages/user_create/models.py +++ /dev/null @@ -1,19 +0,0 @@ -"""create stage models""" -from django.utils.translation import gettext_lazy as _ - -from passbook.flows.models import Stage - - -class UserCreateStage(Stage): - """Create stage, create a user from saved data.""" - - type = "passbook.stages.user_create.stage.UserCreateStageView" - form = "passbook.stages.user_create.forms.UserCreateStageForm" - - def __str__(self): - return f"User Create Stage {self.name}" - - class Meta: - - verbose_name = _("User Create Stage") - verbose_name_plural = _("User Create Stages") diff --git a/passbook/stages/user_create/stage.py b/passbook/stages/user_create/stage.py deleted file mode 100644 index 122324683..000000000 --- a/passbook/stages/user_create/stage.py +++ /dev/null @@ -1,39 +0,0 @@ -"""Create stage logic""" -from django.contrib import messages -from django.contrib.auth.backends import ModelBackend -from django.http import HttpRequest, HttpResponse -from django.utils.translation import gettext as _ -from structlog import get_logger - -from passbook.core.models import User -from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER -from passbook.flows.stage import AuthenticationStage -from passbook.lib.utils.reflection import class_to_path -from passbook.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND -from passbook.stages.prompt.stage import PLAN_CONTEXT_PROMPT - -LOGGER = get_logger() - - -class UserCreateStageView(AuthenticationStage): - """Finalise Enrollment flow by creating a user object.""" - - def get(self, request: HttpRequest) -> HttpResponse: - if PLAN_CONTEXT_PROMPT not in self.executor.plan.context: - message = _("No Pending data.") - messages.error(request, message) - LOGGER.debug(message) - return self.executor.stage_invalid() - data = self.executor.plan.context[PLAN_CONTEXT_PROMPT] - user = User.objects.create_user(**data) - # Set created user as pending_user, so this can be chained with user_login - self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] = user - self.executor.plan.context[PLAN_CONTEXT_AUTHENTICATION_BACKEND] = class_to_path( - ModelBackend - ) - LOGGER.debug( - "Created user", - user=self.executor.plan.context[PLAN_CONTEXT_PENDING_USER], - flow_slug=self.executor.flow.slug, - ) - return self.executor.stage_ok() diff --git a/passbook/stages/user_create/tests.py b/passbook/stages/user_create/tests.py deleted file mode 100644 index ae0295822..000000000 --- a/passbook/stages/user_create/tests.py +++ /dev/null @@ -1,73 +0,0 @@ -"""create tests""" -import string -from random import SystemRandom - -from django.shortcuts import reverse -from django.test import Client, TestCase - -from passbook.core.models import User -from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding -from passbook.flows.planner import FlowPlan -from passbook.flows.views import SESSION_KEY_PLAN -from passbook.stages.prompt.stage import PLAN_CONTEXT_PROMPT -from passbook.stages.user_create.models import UserCreateStage - - -class TestUserCreateStage(TestCase): - """Create tests""" - - def setUp(self): - super().setUp() - self.client = Client() - - self.password = "".join( - SystemRandom().choice(string.ascii_uppercase + string.digits) - for _ in range(8) - ) - self.flow = Flow.objects.create( - name="test-create", - slug="test-create", - designation=FlowDesignation.AUTHENTICATION, - ) - self.stage = UserCreateStage.objects.create(name="create") - FlowStageBinding.objects.create(flow=self.flow, stage=self.stage, order=2) - - def test_valid_create(self): - """Test creation of user""" - plan = FlowPlan(flow_pk=self.flow.pk.hex, stages=[self.stage]) - plan.context[PLAN_CONTEXT_PROMPT] = { - "username": "test-user", - "name": "name", - "email": "test@beryju.org", - "password": self.password, - } - session = self.client.session - session[SESSION_KEY_PLAN] = plan - session.save() - - response = self.client.get( - reverse( - "passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug} - ) - ) - self.assertEqual(response.status_code, 302) - self.assertTrue( - User.objects.filter( - username=plan.context[PLAN_CONTEXT_PROMPT]["username"] - ).exists() - ) - - def test_without_data(self): - """Test without data results in error""" - plan = FlowPlan(flow_pk=self.flow.pk.hex, stages=[self.stage]) - session = self.client.session - session[SESSION_KEY_PLAN] = plan - session.save() - - response = self.client.get( - reverse( - "passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug} - ) - ) - self.assertEqual(response.status_code, 302) - self.assertEqual(response.url, reverse("passbook_flows:denied")) diff --git a/passbook/stages/user_create/__init__.py b/passbook/stages/user_write/__init__.py similarity index 100% rename from passbook/stages/user_create/__init__.py rename to passbook/stages/user_write/__init__.py diff --git a/passbook/stages/user_write/api.py b/passbook/stages/user_write/api.py new file mode 100644 index 000000000..8e32a36ae --- /dev/null +++ b/passbook/stages/user_write/api.py @@ -0,0 +1,24 @@ +"""User Write Stage API Views""" +from rest_framework.serializers import ModelSerializer +from rest_framework.viewsets import ModelViewSet + +from passbook.stages.user_write.models import UserWriteStage + + +class UserWriteStageSerializer(ModelSerializer): + """UserWriteStage Serializer""" + + class Meta: + + model = UserWriteStage + fields = [ + "pk", + "name", + ] + + +class UserWriteStageViewSet(ModelViewSet): + """UserWriteStage Viewset""" + + queryset = UserWriteStage.objects.all() + serializer_class = UserWriteStageSerializer diff --git a/passbook/stages/user_write/apps.py b/passbook/stages/user_write/apps.py new file mode 100644 index 000000000..f2dceb575 --- /dev/null +++ b/passbook/stages/user_write/apps.py @@ -0,0 +1,10 @@ +"""passbook write stage app config""" +from django.apps import AppConfig + + +class PassbookStageUserWriteConfig(AppConfig): + """passbook write stage config""" + + name = "passbook.stages.user_write" + label = "passbook_stages_user_write" + verbose_name = "passbook Stages.User Write" diff --git a/passbook/stages/user_write/forms.py b/passbook/stages/user_write/forms.py new file mode 100644 index 000000000..f4e00d8ae --- /dev/null +++ b/passbook/stages/user_write/forms.py @@ -0,0 +1,16 @@ +"""passbook flows write forms""" +from django import forms + +from passbook.stages.user_write.models import UserWriteStage + + +class UserWriteStageForm(forms.ModelForm): + """Form to write/edit UserWriteStage instances""" + + class Meta: + + model = UserWriteStage + fields = ["name"] + widgets = { + "name": forms.TextInput(), + } diff --git a/passbook/stages/user_create/migrations/0001_initial.py b/passbook/stages/user_write/migrations/0001_initial.py similarity index 80% rename from passbook/stages/user_create/migrations/0001_initial.py rename to passbook/stages/user_write/migrations/0001_initial.py index 35f3d418f..177866967 100644 --- a/passbook/stages/user_create/migrations/0001_initial.py +++ b/passbook/stages/user_write/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 3.0.5 on 2020-05-10 14:26 +# Generated by Django 3.0.5 on 2020-05-10 21:21 import django.db.models.deletion from django.db import migrations, models @@ -14,7 +14,7 @@ class Migration(migrations.Migration): operations = [ migrations.CreateModel( - name="UserCreateStage", + name="UserWriteStage", fields=[ ( "stage_ptr", @@ -29,8 +29,8 @@ class Migration(migrations.Migration): ), ], options={ - "verbose_name": "User Create Stage", - "verbose_name_plural": "User Create Stages", + "verbose_name": "User Write Stage", + "verbose_name_plural": "User Write Stages", }, bases=("passbook_flows.stage",), ), diff --git a/passbook/stages/user_create/migrations/__init__.py b/passbook/stages/user_write/migrations/__init__.py similarity index 100% rename from passbook/stages/user_create/migrations/__init__.py rename to passbook/stages/user_write/migrations/__init__.py diff --git a/passbook/stages/user_write/models.py b/passbook/stages/user_write/models.py new file mode 100644 index 000000000..078b4e174 --- /dev/null +++ b/passbook/stages/user_write/models.py @@ -0,0 +1,19 @@ +"""write stage models""" +from django.utils.translation import gettext_lazy as _ + +from passbook.flows.models import Stage + + +class UserWriteStage(Stage): + """Write stage, write a user from saved data.""" + + type = "passbook.stages.user_write.stage.UserWriteStageView" + form = "passbook.stages.user_write.forms.UserWriteStageForm" + + def __str__(self): + return f"User Write Stage {self.name}" + + class Meta: + + verbose_name = _("User Write Stage") + verbose_name_plural = _("User Write Stages") diff --git a/passbook/stages/user_write/stage.py b/passbook/stages/user_write/stage.py new file mode 100644 index 000000000..20c689064 --- /dev/null +++ b/passbook/stages/user_write/stage.py @@ -0,0 +1,52 @@ +"""Write stage logic""" +from django.contrib import messages +from django.contrib.auth.backends import ModelBackend +from django.http import HttpRequest, HttpResponse +from django.utils.translation import gettext as _ +from structlog import get_logger + +from passbook.core.models import User +from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER +from passbook.flows.stage import AuthenticationStage +from passbook.lib.utils.reflection import class_to_path +from passbook.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND +from passbook.stages.prompt.stage import PLAN_CONTEXT_PROMPT + +LOGGER = get_logger() + + +class UserWriteStageView(AuthenticationStage): + """Finalise Enrollment flow by creating a user object.""" + + def get(self, request: HttpRequest) -> HttpResponse: + if PLAN_CONTEXT_PROMPT not in self.executor.plan.context: + message = _("No Pending data.") + messages.error(request, message) + LOGGER.debug(message) + return self.executor.stage_invalid() + data = self.executor.plan.context[PLAN_CONTEXT_PROMPT] + if PLAN_CONTEXT_PENDING_USER in self.executor.plan.context: + user = self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] + for key, value in data.items(): + setter_name = f"set_{key}" + if hasattr(user, setter_name): + setter = getattr(user, setter_name) + if callable(setter): + setter(value) + else: + setattr(user, key, value) + user.save() + LOGGER.debug( + "Updated existing user", user=user, flow_slug=self.executor.flow.slug, + ) + else: + user = User.objects.create_user(**data) + # Set created user as pending_user, so this can be chained with user_login + self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] = user + self.executor.plan.context[ + PLAN_CONTEXT_AUTHENTICATION_BACKEND + ] = class_to_path(ModelBackend) + LOGGER.debug( + "Created new user", user=user, flow_slug=self.executor.flow.slug, + ) + return self.executor.stage_ok() diff --git a/passbook/stages/user_write/tests.py b/passbook/stages/user_write/tests.py new file mode 100644 index 000000000..5bad06809 --- /dev/null +++ b/passbook/stages/user_write/tests.py @@ -0,0 +1,110 @@ +"""write tests""" +import string +from random import SystemRandom + +from django.shortcuts import reverse +from django.test import Client, TestCase + +from passbook.core.models import User +from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding +from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan +from passbook.flows.views import SESSION_KEY_PLAN +from passbook.stages.prompt.stage import PLAN_CONTEXT_PROMPT +from passbook.stages.user_write.forms import UserWriteStageForm +from passbook.stages.user_write.models import UserWriteStage + + +class TestUserWriteStage(TestCase): + """Write tests""" + + def setUp(self): + super().setUp() + self.client = Client() + + self.flow = Flow.objects.create( + name="test-write", + slug="test-write", + designation=FlowDesignation.AUTHENTICATION, + ) + self.stage = UserWriteStage.objects.create(name="write") + FlowStageBinding.objects.create(flow=self.flow, stage=self.stage, order=2) + + def test_user_create(self): + """Test creation of user""" + password = "".join( + SystemRandom().choice(string.ascii_uppercase + string.digits) + for _ in range(8) + ) + + plan = FlowPlan(flow_pk=self.flow.pk.hex, stages=[self.stage]) + plan.context[PLAN_CONTEXT_PROMPT] = { + "username": "test-user", + "name": "name", + "email": "test@beryju.org", + "password": password, + } + session = self.client.session + session[SESSION_KEY_PLAN] = plan + session.save() + + response = self.client.get( + reverse( + "passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug} + ) + ) + self.assertEqual(response.status_code, 302) + user_qs = User.objects.filter( + username=plan.context[PLAN_CONTEXT_PROMPT]["username"] + ) + self.assertTrue(user_qs.exists()) + self.assertTrue(user_qs.first().check_password(password)) + + def test_user_update(self): + """Test update of existing user""" + new_password = "".join( + SystemRandom().choice(string.ascii_uppercase + string.digits) + for _ in range(8) + ) + plan = FlowPlan(flow_pk=self.flow.pk.hex, stages=[self.stage]) + plan.context[PLAN_CONTEXT_PENDING_USER] = User.objects.create( + username="unittest", email="test@beryju.org" + ) + plan.context[PLAN_CONTEXT_PROMPT] = { + "username": "test-user-new", + "password": new_password, + } + session = self.client.session + session[SESSION_KEY_PLAN] = plan + session.save() + + response = self.client.get( + reverse( + "passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug} + ) + ) + self.assertEqual(response.status_code, 302) + user_qs = User.objects.filter( + username=plan.context[PLAN_CONTEXT_PROMPT]["username"] + ) + self.assertTrue(user_qs.exists()) + self.assertTrue(user_qs.first().check_password(new_password)) + + def test_without_data(self): + """Test without data results in error""" + plan = FlowPlan(flow_pk=self.flow.pk.hex, stages=[self.stage]) + session = self.client.session + session[SESSION_KEY_PLAN] = plan + session.save() + + response = self.client.get( + reverse( + "passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug} + ) + ) + self.assertEqual(response.status_code, 302) + self.assertEqual(response.url, reverse("passbook_flows:denied")) + + def test_form(self): + """Test Form""" + data = {"name": "test"} + self.assertEqual(UserWriteStageForm(data).is_valid(), True)