stages/user_create -> user_write: Stage can create and update existing users

This commit is contained in:
Jens Langhammer 2020-05-10 23:38:15 +02:00
parent 631cf77f89
commit 5b2bf7519a
18 changed files with 241 additions and 194 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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"

View File

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

View File

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

View File

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

View File

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

View File

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