diff --git a/passbook/admin/views/invitations.py b/passbook/admin/views/invitations.py index 59383d840..dd00bee83 100644 --- a/passbook/admin/views/invitations.py +++ b/passbook/admin/views/invitations.py @@ -11,17 +11,17 @@ from django.utils.translation import ugettext as _ from django.views.generic import DeleteView, ListView from guardian.mixins import PermissionListMixin, PermissionRequiredMixin -from passbook.core.forms.invitations import InvitationForm -from passbook.core.models import Invitation from passbook.core.signals import invitation_created from passbook.lib.views import CreateAssignPermView +from passbook.stages.invitation.forms import InvitationForm +from passbook.stages.invitation.models import Invitation class InvitationListView(LoginRequiredMixin, PermissionListMixin, ListView): """Show list of all invitations""" model = Invitation - permission_required = "passbook_core.view_invitation" + permission_required = "passbook_stages_invitation.view_invitation" template_name = "administration/invitation/list.html" paginate_by = 10 ordering = "-expires" @@ -37,7 +37,7 @@ class InvitationCreateView( model = Invitation form_class = InvitationForm - permission_required = "passbook_core.add_invitation" + permission_required = "passbook_stages_invitation.add_invitation" template_name = "generic/create.html" success_url = reverse_lazy("passbook_admin:invitations") @@ -61,7 +61,7 @@ class InvitationDeleteView( """Delete invitation""" model = Invitation - permission_required = "passbook_core.delete_invitation" + permission_required = "passbook_stages_invitation.delete_invitation" template_name = "generic/delete.html" success_url = reverse_lazy("passbook_admin:invitations") diff --git a/passbook/admin/views/overview.py b/passbook/admin/views/overview.py index c263007dc..41b1ffaa4 100644 --- a/passbook/admin/views/overview.py +++ b/passbook/admin/views/overview.py @@ -5,9 +5,10 @@ from django.views.generic import TemplateView from passbook import __version__ from passbook.admin.mixins import AdminRequiredMixin -from passbook.core.models import Application, Invitation, Policy, Provider, Source, User +from passbook.core.models import Application, Policy, Provider, Source, User from passbook.flows.models import Flow, Stage from passbook.root.celery import CELERY_APP +from passbook.stages.invitation.models import Invitation class AdministrationOverviewView(AdminRequiredMixin, TemplateView): diff --git a/passbook/api/v2/urls.py b/passbook/api/v2/urls.py index 84dc92d77..402f5eddb 100644 --- a/passbook/api/v2/urls.py +++ b/passbook/api/v2/urls.py @@ -11,7 +11,6 @@ from passbook.api.permissions import CustomObjectPermissions from passbook.audit.api import EventViewSet from passbook.core.api.applications import ApplicationViewSet from passbook.core.api.groups import GroupViewSet -from passbook.core.api.invitations import InvitationViewSet from passbook.core.api.policies import PolicyViewSet from passbook.core.api.propertymappings import PropertyMappingViewSet from passbook.core.api.providers import ProviderViewSet @@ -34,6 +33,7 @@ from passbook.sources.oauth.api import OAuthSourceViewSet from passbook.stages.captcha.api import CaptchaStageViewSet from passbook.stages.email.api import EmailStageViewSet from passbook.stages.identification.api import IdentificationStageViewSet +from passbook.stages.invitation.api import InvitationStageViewSet, InvitationViewSet from passbook.stages.otp.api import OTPStageViewSet from passbook.stages.password.api import PasswordStageViewSet from passbook.stages.prompt.api import PromptStageViewSet, PromptViewSet @@ -51,7 +51,6 @@ for _passbook_app in get_apps(): LOGGER.debug("Mounted API URLs", app_name=_passbook_app.name) router.register("core/applications", ApplicationViewSet) -router.register("core/invitations", InvitationViewSet) router.register("core/groups", GroupViewSet) router.register("core/users", UserViewSet) @@ -83,6 +82,8 @@ router.register("stages/all", StageViewSet) router.register("stages/captcha", CaptchaStageViewSet) router.register("stages/email", EmailStageViewSet) router.register("stages/identification", IdentificationStageViewSet) +router.register("stages/invitation", InvitationStageViewSet) +router.register("stages/invitation/invitations", InvitationViewSet) router.register("stages/otp", OTPStageViewSet) router.register("stages/password", PasswordStageViewSet) router.register("stages/prompt", PromptStageViewSet) diff --git a/passbook/core/forms/invitations.py b/passbook/core/forms/invitations.py deleted file mode 100644 index be64ce302..000000000 --- a/passbook/core/forms/invitations.py +++ /dev/null @@ -1,38 +0,0 @@ -"""passbook core invitation form""" - -from django import forms -from django.core.exceptions import ValidationError -from django.utils.translation import gettext as _ - -from passbook.core.models import Invitation, User - - -class InvitationForm(forms.ModelForm): - """InvitationForm""" - - def clean_fixed_username(self): - """Check if username is already used""" - username = self.cleaned_data.get("fixed_username") - if User.objects.filter(username=username).exists(): - raise ValidationError(_("Username is already in use.")) - return username - - def clean_fixed_email(self): - """Check if email is already used""" - email = self.cleaned_data.get("fixed_email") - if User.objects.filter(email=email).exists(): - raise ValidationError(_("E-Mail is already in use.")) - return email - - class Meta: - - model = Invitation - fields = ["expires", "fixed_username", "fixed_email", "needs_confirmation"] - labels = { - "fixed_username": "Force user's username (optional)", - "fixed_email": "Force user's email (optional)", - } - widgets = { - "fixed_username": forms.TextInput(), - "fixed_email": forms.TextInput(), - } diff --git a/passbook/core/migrations/0014_delete_invitation.py b/passbook/core/migrations/0014_delete_invitation.py new file mode 100644 index 000000000..2f3e67b70 --- /dev/null +++ b/passbook/core/migrations/0014_delete_invitation.py @@ -0,0 +1,14 @@ +# Generated by Django 3.0.5 on 2020-05-11 19:57 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("passbook_core", "0013_delete_debugpolicy"), + ] + + operations = [ + migrations.DeleteModel(name="Invitation",), + ] diff --git a/passbook/core/models.py b/passbook/core/models.py index 4894829fd..6c0a6d5ea 100644 --- a/passbook/core/models.py +++ b/passbook/core/models.py @@ -8,7 +8,6 @@ from django.contrib.postgres.fields import JSONField from django.core.exceptions import ValidationError from django.db import models from django.http import HttpRequest -from django.urls import reverse_lazy from django.utils.timezone import now from django.utils.translation import gettext_lazy as _ from django_prometheus.models import ExportModelOperationsMixin @@ -196,30 +195,6 @@ class Policy(ExportModelOperationsMixin("policy"), UUIDModel, CreatedUpdatedMode raise PolicyException() -class Invitation(ExportModelOperationsMixin("invitation"), UUIDModel): - """Single-use invitation link""" - - created_by = models.ForeignKey("User", on_delete=models.CASCADE) - expires = models.DateTimeField(default=None, blank=True, null=True) - fixed_username = models.TextField(blank=True, default=None) - fixed_email = models.TextField(blank=True, default=None) - needs_confirmation = models.BooleanField(default=True) - - @property - def link(self): - """Get link to use invitation""" - qs = f"?invitation={self.uuid.hex}" - return reverse_lazy("passbook_flows:default-enrollment") + qs - - def __str__(self): - return f"Invitation {self.uuid.hex} created by {self.created_by}" - - class Meta: - - verbose_name = _("Invitation") - verbose_name_plural = _("Invitations") - - class Nonce(ExportModelOperationsMixin("nonce"), UUIDModel): """One-time link for password resets/sign-up-confirmations""" diff --git a/passbook/lib/utils/urls.py b/passbook/lib/utils/urls.py index 29f0e9eaf..450b98485 100644 --- a/passbook/lib/utils/urls.py +++ b/passbook/lib/utils/urls.py @@ -20,6 +20,8 @@ def redirect_with_qs(view: str, get_query_set=None, **kwargs) -> HttpResponse: try: target = reverse(view, kwargs=kwargs) except NoReverseMatch: + if not is_url_absolute(view): + return redirect(view) LOGGER.debug("redirect target is not a valid view", view=view) raise else: diff --git a/passbook/root/settings.py b/passbook/root/settings.py index 7abd08760..cd19685d8 100644 --- a/passbook/root/settings.py +++ b/passbook/root/settings.py @@ -108,6 +108,7 @@ INSTALLED_APPS = [ "passbook.stages.email.apps.PassbookStageEmailConfig", "passbook.stages.prompt.apps.PassbookStagPromptConfig", "passbook.stages.identification.apps.PassbookStageIdentificationConfig", + "passbook.stages.invitation.apps.PassbookStageUserInvitationConfig", "passbook.stages.user_login.apps.PassbookStageUserLoginConfig", "passbook.stages.user_logout.apps.PassbookStageUserLogoutConfig", "passbook.stages.user_write.apps.PassbookStageUserWriteConfig", diff --git a/passbook/stages/invitation/__init__.py b/passbook/stages/invitation/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/passbook/core/api/invitations.py b/passbook/stages/invitation/api.py similarity index 50% rename from passbook/core/api/invitations.py rename to passbook/stages/invitation/api.py index c6e451f62..c4218db6f 100644 --- a/passbook/core/api/invitations.py +++ b/passbook/stages/invitation/api.py @@ -1,8 +1,28 @@ -"""Invitation API Views""" +"""Invitation Stage API Views""" from rest_framework.serializers import ModelSerializer from rest_framework.viewsets import ModelViewSet -from passbook.core.models import Invitation +from passbook.stages.invitation.models import Invitation, InvitationStage + + +class InvitationStageSerializer(ModelSerializer): + """InvitationStage Serializer""" + + class Meta: + + model = InvitationStage + fields = [ + "pk", + "name", + "continue_flow_without_invitation", + ] + + +class InvitationStageViewSet(ModelViewSet): + """InvitationStage Viewset""" + + queryset = InvitationStage.objects.all() + serializer_class = InvitationStageSerializer class InvitationSerializer(ModelSerializer): diff --git a/passbook/stages/invitation/apps.py b/passbook/stages/invitation/apps.py new file mode 100644 index 000000000..0b0eddd0b --- /dev/null +++ b/passbook/stages/invitation/apps.py @@ -0,0 +1,10 @@ +"""passbook invitation stage app config""" +from django.apps import AppConfig + + +class PassbookStageUserInvitationConfig(AppConfig): + """passbook invitation stage config""" + + name = "passbook.stages.invitation" + label = "passbook_stages_invitation" + verbose_name = "passbook Stages.User Invitation" diff --git a/passbook/stages/invitation/forms.py b/passbook/stages/invitation/forms.py new file mode 100644 index 000000000..a6a34d17e --- /dev/null +++ b/passbook/stages/invitation/forms.py @@ -0,0 +1,33 @@ +"""passbook flows invitation forms""" +from django import forms +from django.utils.translation import gettext as _ + +from passbook.stages.invitation.models import Invitation, InvitationStage + + +class InvitationStageForm(forms.ModelForm): + """Form to create/edit InvitationStage instances""" + + class Meta: + + model = InvitationStage + fields = ["name", "continue_flow_without_invitation"] + widgets = { + "name": forms.TextInput(), + } + + +class InvitationForm(forms.ModelForm): + """InvitationForm""" + + class Meta: + + model = Invitation + fields = ["expires", "fixed_data"] + labels = { + "fixed_data": _("Optional fixed data to enforce on user enrollment."), + } + widgets = { + "fixed_username": forms.TextInput(), + "fixed_email": forms.TextInput(), + } diff --git a/passbook/stages/invitation/migrations/0001_initial.py b/passbook/stages/invitation/migrations/0001_initial.py new file mode 100644 index 000000000..31c10f124 --- /dev/null +++ b/passbook/stages/invitation/migrations/0001_initial.py @@ -0,0 +1,72 @@ +# Generated by Django 3.0.5 on 2020-05-11 19:09 + +import uuid + +import django.contrib.postgres.fields.jsonb +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("passbook_flows", "0004_auto_20200510_2310"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="InvitationStage", + 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": "Invitation Stage", + "verbose_name_plural": "Invitation Stages", + }, + bases=("passbook_flows.stage",), + ), + migrations.CreateModel( + name="Invitation", + fields=[ + ( + "uuid", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("expires", models.DateTimeField(blank=True, default=None, null=True)), + ( + "fixed_data", + django.contrib.postgres.fields.jsonb.JSONField(default=dict), + ), + ( + "created_by", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "Invitation", + "verbose_name_plural": "Invitations", + }, + ), + ] diff --git a/passbook/stages/invitation/migrations/0002_invitationstage_continue_flow_without_invitation.py b/passbook/stages/invitation/migrations/0002_invitationstage_continue_flow_without_invitation.py new file mode 100644 index 000000000..2772f6c7b --- /dev/null +++ b/passbook/stages/invitation/migrations/0002_invitationstage_continue_flow_without_invitation.py @@ -0,0 +1,21 @@ +# Generated by Django 3.0.5 on 2020-05-11 19:46 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("passbook_stages_invitation", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="invitationstage", + name="continue_flow_without_invitation", + field=models.BooleanField( + default=False, + help_text="If this flag is set, this Stage will jump to the next Stage when no Invitation is given. By default this Stage will cancel the Flow when no invitation is given.", + ), + ), + ] diff --git a/passbook/stages/invitation/migrations/__init__.py b/passbook/stages/invitation/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/passbook/stages/invitation/models.py b/passbook/stages/invitation/models.py new file mode 100644 index 000000000..fed3ee8b0 --- /dev/null +++ b/passbook/stages/invitation/models.py @@ -0,0 +1,50 @@ +"""invitation stage models""" +from django.contrib.postgres.fields import JSONField +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from passbook.core.models import User +from passbook.flows.models import Stage +from passbook.lib.models import UUIDModel + + +class InvitationStage(Stage): + """Invitation stage, to enroll themselves with enforced parameters""" + + continue_flow_without_invitation = models.BooleanField( + default=False, + help_text=_( + ( + "If this flag is set, this Stage will jump to the next Stage when " + "no Invitation is given. By default this Stage will cancel the " + "Flow when no invitation is given." + ) + ), + ) + + type = "passbook.stages.invitation.stage.InvitationStageView" + form = "passbook.stages.invitation.forms.InvitationStageForm" + + def __str__(self): + return f"Invitation Stage {self.name}" + + class Meta: + + verbose_name = _("Invitation Stage") + verbose_name_plural = _("Invitation Stages") + + +class Invitation(UUIDModel): + """Single-use invitation link""" + + created_by = models.ForeignKey(User, on_delete=models.CASCADE) + expires = models.DateTimeField(default=None, blank=True, null=True) + fixed_data = JSONField(default=dict) + + def __str__(self): + return f"Invitation {self.uuid.hex} created by {self.created_by}" + + class Meta: + + verbose_name = _("Invitation") + verbose_name_plural = _("Invitations") diff --git a/passbook/stages/invitation/stage.py b/passbook/stages/invitation/stage.py new file mode 100644 index 000000000..e4ca3a5ef --- /dev/null +++ b/passbook/stages/invitation/stage.py @@ -0,0 +1,26 @@ +"""invitation stage logic""" +from django.http import HttpRequest, HttpResponse +from django.shortcuts import get_object_or_404 + +from passbook.flows.stage import AuthenticationStage +from passbook.stages.invitation.models import Invitation, InvitationStage +from passbook.stages.prompt.stage import PLAN_CONTEXT_PROMPT + +INVITATION_TOKEN_KEY = "token" + + +class InvitationStageView(AuthenticationStage): + """Finalise Authentication flow by logging the user in""" + + def get(self, request: HttpRequest) -> HttpResponse: + stage: InvitationStage = self.executor.current_stage + if INVITATION_TOKEN_KEY not in request.GET: + # No Invitation was given, raise error or continue + if stage.continue_flow_without_invitation: + return self.executor.stage_ok() + return self.executor.stage_invalid() + + token = request.GET[INVITATION_TOKEN_KEY] + invite: Invitation = get_object_or_404(Invitation, pk=token) + self.executor.plan.context[PLAN_CONTEXT_PROMPT] = invite.fixed_data + return self.executor.stage_ok() diff --git a/passbook/stages/invitation/tests.py b/passbook/stages/invitation/tests.py new file mode 100644 index 000000000..59b988d2b --- /dev/null +++ b/passbook/stages/invitation/tests.py @@ -0,0 +1,83 @@ +"""login 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_login.forms import UserLoginStageForm +from passbook.stages.user_login.models import UserLoginStage + + +class TestUserLoginStage(TestCase): + """Login 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-login", + slug="test-login", + designation=FlowDesignation.AUTHENTICATION, + ) + self.stage = UserLoginStage.objects.create(name="login") + 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_without_user(self): + """Test a plan without any pending user, resulting in a denied""" + 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_without_backend(self): + """Test a plan with pending user, without backend, resulting in a denied""" + plan = FlowPlan(flow_pk=self.flow.pk.hex, stages=[self.stage]) + plan.context[PLAN_CONTEXT_PENDING_USER] = self.user + 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(UserLoginStageForm(data).is_valid(), True)