stages/invitation: start extracting invitation from core

This commit is contained in:
Jens Langhammer 2020-05-11 21:58:02 +02:00
parent d49c58f326
commit 7500e622f6
18 changed files with 344 additions and 73 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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