diff --git a/passbook/api/v2/urls.py b/passbook/api/v2/urls.py index 4b6370a62..84dc92d77 100644 --- a/passbook/api/v2/urls.py +++ b/passbook/api/v2/urls.py @@ -38,6 +38,7 @@ 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_login.api import UserLoginStageViewSet +from passbook.stages.user_logout.api import UserLogoutStageViewSet from passbook.stages.user_write.api import UserWriteStageViewSet LOGGER = get_logger() @@ -86,8 +87,9 @@ 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_write", UserWriteStageViewSet) router.register("stages/user_login", UserLoginStageViewSet) +router.register("stages/user_logout", UserLogoutStageViewSet) +router.register("stages/user_write", UserWriteStageViewSet) router.register("flows", FlowViewSet) router.register("flows/bindings", FlowStageBindingViewSet) diff --git a/passbook/root/settings.py b/passbook/root/settings.py index ccb5afdff..7abd08760 100644 --- a/passbook/root/settings.py +++ b/passbook/root/settings.py @@ -109,6 +109,7 @@ INSTALLED_APPS = [ "passbook.stages.prompt.apps.PassbookStagPromptConfig", "passbook.stages.identification.apps.PassbookStageIdentificationConfig", "passbook.stages.user_login.apps.PassbookStageUserLoginConfig", + "passbook.stages.user_logout.apps.PassbookStageUserLogoutConfig", "passbook.stages.user_write.apps.PassbookStageUserWriteConfig", "passbook.stages.otp.apps.PassbookStageOTPConfig", "passbook.stages.password.apps.PassbookStagePasswordConfig", diff --git a/passbook/stages/user_logout/__init__.py b/passbook/stages/user_logout/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/passbook/stages/user_logout/api.py b/passbook/stages/user_logout/api.py new file mode 100644 index 000000000..4200a36d9 --- /dev/null +++ b/passbook/stages/user_logout/api.py @@ -0,0 +1,24 @@ +"""Logout Stage API Views""" +from rest_framework.serializers import ModelSerializer +from rest_framework.viewsets import ModelViewSet + +from passbook.stages.user_logout.models import UserLogoutStage + + +class UserLogoutStageSerializer(ModelSerializer): + """UserLogoutStage Serializer""" + + class Meta: + + model = UserLogoutStage + fields = [ + "pk", + "name", + ] + + +class UserLogoutStageViewSet(ModelViewSet): + """UserLogoutStage Viewset""" + + queryset = UserLogoutStage.objects.all() + serializer_class = UserLogoutStageSerializer diff --git a/passbook/stages/user_logout/apps.py b/passbook/stages/user_logout/apps.py new file mode 100644 index 000000000..7d7c0ee80 --- /dev/null +++ b/passbook/stages/user_logout/apps.py @@ -0,0 +1,10 @@ +"""passbook logout stage app config""" +from django.apps import AppConfig + + +class PassbookStageUserLogoutConfig(AppConfig): + """passbook logout stage config""" + + name = "passbook.stages.user_logout" + label = "passbook_stages_user_logout" + verbose_name = "passbook Stages.User Logout" diff --git a/passbook/stages/user_logout/forms.py b/passbook/stages/user_logout/forms.py new file mode 100644 index 000000000..a2e879835 --- /dev/null +++ b/passbook/stages/user_logout/forms.py @@ -0,0 +1,16 @@ +"""passbook flows logout forms""" +from django import forms + +from passbook.stages.user_logout.models import UserLogoutStage + + +class UserLogoutStageForm(forms.ModelForm): + """Form to create/edit UserLogoutStage instances""" + + class Meta: + + model = UserLogoutStage + fields = ["name"] + widgets = { + "name": forms.TextInput(), + } diff --git a/passbook/stages/user_logout/migrations/0001_initial.py b/passbook/stages/user_logout/migrations/0001_initial.py new file mode 100644 index 000000000..53456156e --- /dev/null +++ b/passbook/stages/user_logout/migrations/0001_initial.py @@ -0,0 +1,37 @@ +# Generated by Django 3.0.5 on 2020-05-10 22:56 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("passbook_flows", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="UserLogoutStage", + fields=[ + ( + "stage_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="passbook_flows.Stage", + ), + ), + ], + options={ + "verbose_name": "User Logout Stage", + "verbose_name_plural": "User Logout Stages", + }, + bases=("passbook_flows.stage",), + ), + ] diff --git a/passbook/stages/user_logout/migrations/__init__.py b/passbook/stages/user_logout/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/passbook/stages/user_logout/models.py b/passbook/stages/user_logout/models.py new file mode 100644 index 000000000..55a03983b --- /dev/null +++ b/passbook/stages/user_logout/models.py @@ -0,0 +1,19 @@ +"""logout stage models""" +from django.utils.translation import gettext_lazy as _ + +from passbook.flows.models import Stage + + +class UserLogoutStage(Stage): + """Logout stage, allows a user to identify themselves to authenticate.""" + + type = "passbook.stages.user_logout.stage.UserLogoutStageView" + form = "passbook.stages.user_logout.forms.UserLogoutStageForm" + + def __str__(self): + return f"User Logout Stage {self.name}" + + class Meta: + + verbose_name = _("User Logout Stage") + verbose_name_plural = _("User Logout Stages") diff --git a/passbook/stages/user_logout/stage.py b/passbook/stages/user_logout/stage.py new file mode 100644 index 000000000..ef360b3ed --- /dev/null +++ b/passbook/stages/user_logout/stage.py @@ -0,0 +1,22 @@ +"""Logout stage logic""" +from django.contrib.auth import logout +from django.http import HttpRequest, HttpResponse +from structlog import get_logger + +from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER +from passbook.flows.stage import AuthenticationStage + +LOGGER = get_logger() + + +class UserLogoutStageView(AuthenticationStage): + """Finalise Authentication flow by logging the user in""" + + def get(self, request: HttpRequest) -> HttpResponse: + logout(self.request) + LOGGER.debug( + "Logged out", + 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_logout/tests.py b/passbook/stages/user_logout/tests.py new file mode 100644 index 000000000..6f5a309e4 --- /dev/null +++ b/passbook/stages/user_logout/tests.py @@ -0,0 +1,52 @@ +"""logout tests""" +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.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND +from passbook.stages.user_logout.forms import UserLogoutStageForm +from passbook.stages.user_logout.models import UserLogoutStage + + +class TestUserLogoutStage(TestCase): + """Logout tests""" + + def setUp(self): + super().setUp() + self.user = User.objects.create(username="unittest", email="test@beryju.org") + self.client = Client() + + self.flow = Flow.objects.create( + name="test-logout", + slug="test-logout", + designation=FlowDesignation.AUTHENTICATION, + ) + self.stage = UserLogoutStage.objects.create(name="logout") + FlowStageBinding.objects.create(flow=self.flow, stage=self.stage, order=2) + + def test_valid_password(self): + """Test with a valid pending user and backend""" + plan = FlowPlan(flow_pk=self.flow.pk.hex, stages=[self.stage]) + plan.context[PLAN_CONTEXT_PENDING_USER] = self.user + plan.context[ + PLAN_CONTEXT_AUTHENTICATION_BACKEND + ] = "django.contrib.auth.backends.ModelBackend" + 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_core:overview")) + + def test_form(self): + """Test Form""" + data = {"name": "test"} + self.assertEqual(UserLogoutStageForm(data).is_valid(), True)