diff --git a/authentik/api/v2/urls.py b/authentik/api/v2/urls.py index df1bd26eb..648b468c1 100644 --- a/authentik/api/v2/urls.py +++ b/authentik/api/v2/urls.py @@ -59,6 +59,7 @@ from authentik.stages.authenticator_validate.api import ( from authentik.stages.authenticator_webauthn.api import AuthenticateWebAuthnStageViewSet from authentik.stages.captcha.api import CaptchaStageViewSet from authentik.stages.consent.api import ConsentStageViewSet +from authentik.stages.deny.api import DenyStageViewSet from authentik.stages.dummy.api import DummyStageViewSet from authentik.stages.email.api import EmailStageViewSet from authentik.stages.identification.api import IdentificationStageViewSet @@ -135,6 +136,7 @@ router.register("stages/authenticator/validate", AuthenticatorValidateStageViewS router.register("stages/authenticator/webauthn", AuthenticateWebAuthnStageViewSet) router.register("stages/captcha", CaptchaStageViewSet) router.register("stages/consent", ConsentStageViewSet) +router.register("stages/deny", DenyStageViewSet) router.register("stages/email", EmailStageViewSet) router.register("stages/identification", IdentificationStageViewSet) router.register("stages/invitation/invitations", InvitationViewSet) diff --git a/authentik/root/settings.py b/authentik/root/settings.py index ebb503edb..6b9153b0a 100644 --- a/authentik/root/settings.py +++ b/authentik/root/settings.py @@ -114,6 +114,7 @@ INSTALLED_APPS = [ "authentik.stages.authenticator_webauthn.apps.AuthentikStageAuthenticatorWebAuthnConfig", "authentik.stages.captcha.apps.AuthentikStageCaptchaConfig", "authentik.stages.consent.apps.AuthentikStageConsentConfig", + "authentik.stages.deny.apps.AuthentikStageDenyConfig", "authentik.stages.dummy.apps.AuthentikStageDummyConfig", "authentik.stages.email.apps.AuthentikStageEmailConfig", "authentik.stages.identification.apps.AuthentikStageIdentificationConfig", diff --git a/authentik/stages/deny/__init__.py b/authentik/stages/deny/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/authentik/stages/deny/api.py b/authentik/stages/deny/api.py new file mode 100644 index 000000000..89c1d6e08 --- /dev/null +++ b/authentik/stages/deny/api.py @@ -0,0 +1,21 @@ +"""deny Stage API Views""" +from rest_framework.viewsets import ModelViewSet + +from authentik.flows.api.stages import StageSerializer +from authentik.stages.deny.models import DenyStage + + +class DenyStageSerializer(StageSerializer): + """DenyStage Serializer""" + + class Meta: + + model = DenyStage + fields = StageSerializer.Meta.fields + + +class DenyStageViewSet(ModelViewSet): + """DenyStage Viewset""" + + queryset = DenyStage.objects.all() + serializer_class = DenyStageSerializer diff --git a/authentik/stages/deny/apps.py b/authentik/stages/deny/apps.py new file mode 100644 index 000000000..f7ccba462 --- /dev/null +++ b/authentik/stages/deny/apps.py @@ -0,0 +1,10 @@ +"""authentik deny stage app config""" +from django.apps import AppConfig + + +class AuthentikStageDenyConfig(AppConfig): + """authentik deny stage config""" + + name = "authentik.stages.deny" + label = "authentik_stages_deny" + verbose_name = "authentik Stages.Deny" diff --git a/authentik/stages/deny/forms.py b/authentik/stages/deny/forms.py new file mode 100644 index 000000000..d1c66e646 --- /dev/null +++ b/authentik/stages/deny/forms.py @@ -0,0 +1,16 @@ +"""authentik flows deny forms""" +from django import forms + +from authentik.stages.deny.models import DenyStage + + +class DenyStageForm(forms.ModelForm): + """Form to create/edit DenyStage instances""" + + class Meta: + + model = DenyStage + fields = ["name"] + widgets = { + "name": forms.TextInput(), + } diff --git a/authentik/stages/deny/migrations/0001_initial.py b/authentik/stages/deny/migrations/0001_initial.py new file mode 100644 index 000000000..4937b00f2 --- /dev/null +++ b/authentik/stages/deny/migrations/0001_initial.py @@ -0,0 +1,37 @@ +# Generated by Django 3.1.7 on 2021-03-01 18:59 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("authentik_flows", "0016_auto_20201202_1307"), + ] + + operations = [ + migrations.CreateModel( + name="DenyStage", + fields=[ + ( + "stage_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="authentik_flows.stage", + ), + ), + ], + options={ + "verbose_name": "Deny Stage", + "verbose_name_plural": "Deny Stages", + }, + bases=("authentik_flows.stage",), + ), + ] diff --git a/authentik/stages/deny/migrations/__init__.py b/authentik/stages/deny/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/authentik/stages/deny/models.py b/authentik/stages/deny/models.py new file mode 100644 index 000000000..5c201de7d --- /dev/null +++ b/authentik/stages/deny/models.py @@ -0,0 +1,36 @@ +"""deny stage models""" +from typing import Type + +from django.forms import ModelForm +from django.utils.translation import gettext_lazy as _ +from django.views import View +from rest_framework.serializers import BaseSerializer + +from authentik.flows.models import Stage + + +class DenyStage(Stage): + """Cancells the current flow.""" + + @property + def serializer(self) -> BaseSerializer: + from authentik.stages.deny.api import DenyStageSerializer + + return DenyStageSerializer + + @property + def type(self) -> Type[View]: + from authentik.stages.deny.stage import DenyStageView + + return DenyStageView + + @property + def form(self) -> Type[ModelForm]: + from authentik.stages.deny.forms import DenyStageForm + + return DenyStageForm + + class Meta: + + verbose_name = _("Deny Stage") + verbose_name_plural = _("Deny Stages") diff --git a/authentik/stages/deny/stage.py b/authentik/stages/deny/stage.py new file mode 100644 index 000000000..82c433058 --- /dev/null +++ b/authentik/stages/deny/stage.py @@ -0,0 +1,15 @@ +"""Deny stage logic""" +from django.http import HttpRequest, HttpResponse +from structlog.stdlib import get_logger + +from authentik.flows.stage import StageView + +LOGGER = get_logger() + + +class DenyStageView(StageView): + """Cancells the current flow""" + + def get(self, request: HttpRequest) -> HttpResponse: + """Cancells the current flow""" + return self.executor.stage_invalid() diff --git a/authentik/stages/deny/tests.py b/authentik/stages/deny/tests.py new file mode 100644 index 000000000..96c88b132 --- /dev/null +++ b/authentik/stages/deny/tests.py @@ -0,0 +1,50 @@ +"""deny tests""" +from django.test import Client, TestCase +from django.urls import reverse +from django.utils.encoding import force_str + +from authentik.core.models import User +from authentik.flows.markers import StageMarker +from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding +from authentik.flows.planner import FlowPlan +from authentik.flows.views import SESSION_KEY_PLAN +from authentik.stages.deny.forms import DenyStageForm +from authentik.stages.deny.models import DenyStage + + +class TestUserDenyStage(TestCase): + """Deny 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 = DenyStage.objects.create(name="logout") + FlowStageBinding.objects.create(target=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], markers=[StageMarker()] + ) + session = self.client.session + session[SESSION_KEY_PLAN] = plan + session.save() + + response = self.client.get( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}) + ) + + self.assertEqual(response.status_code, 200) + self.assertIn("Permission denied", force_str(response.content)) + + def test_form(self): + """Test Form""" + data = {"name": "test"} + self.assertEqual(DenyStageForm(data).is_valid(), True) diff --git a/swagger.yaml b/swagger.yaml index 96cca515a..92bd46365 100755 --- a/swagger.yaml +++ b/swagger.yaml @@ -6469,6 +6469,133 @@ paths: required: true type: string format: uuid + /stages/deny/: + get: + operationId: stages_deny_list + description: DenyStage Viewset + parameters: + - name: ordering + in: query + description: Which field to use when ordering the results. + required: false + type: string + - name: search + in: query + description: A search term. + required: false + type: string + - name: page + in: query + description: A page number within the paginated result set. + required: false + type: integer + - name: page_size + in: query + description: Number of results to return per page. + required: false + type: integer + responses: + '200': + description: '' + schema: + required: + - count + - results + type: object + properties: + count: + type: integer + next: + type: string + format: uri + x-nullable: true + previous: + type: string + format: uri + x-nullable: true + results: + type: array + items: + $ref: '#/definitions/DenyStage' + tags: + - stages + post: + operationId: stages_deny_create + description: DenyStage Viewset + parameters: + - name: data + in: body + required: true + schema: + $ref: '#/definitions/DenyStage' + responses: + '201': + description: '' + schema: + $ref: '#/definitions/DenyStage' + tags: + - stages + parameters: [] + /stages/deny/{stage_uuid}/: + get: + operationId: stages_deny_read + description: DenyStage Viewset + parameters: [] + responses: + '200': + description: '' + schema: + $ref: '#/definitions/DenyStage' + tags: + - stages + put: + operationId: stages_deny_update + description: DenyStage Viewset + parameters: + - name: data + in: body + required: true + schema: + $ref: '#/definitions/DenyStage' + responses: + '200': + description: '' + schema: + $ref: '#/definitions/DenyStage' + tags: + - stages + patch: + operationId: stages_deny_partial_update + description: DenyStage Viewset + parameters: + - name: data + in: body + required: true + schema: + $ref: '#/definitions/DenyStage' + responses: + '200': + description: '' + schema: + $ref: '#/definitions/DenyStage' + tags: + - stages + delete: + operationId: stages_deny_delete + description: DenyStage Viewset + parameters: [] + responses: + '204': + description: '' + tags: + - stages + parameters: + - name: stage_uuid + in: path + description: A UUID string identifying this Deny Stage. + required: true + type: string + format: uuid /stages/dummy/: get: operationId: stages_dummy_list @@ -9684,6 +9811,7 @@ definitions: - authentik.stages.authenticator_webauthn - authentik.stages.captcha - authentik.stages.consent + - authentik.stages.deny - authentik.stages.dummy - authentik.stages.email - authentik.stages.identification @@ -11206,6 +11334,38 @@ definitions: description: 'Offset after which consent expires. (Format: hours=1;minutes=2;seconds=3).' type: string minLength: 1 + DenyStage: + description: DenyStage Serializer + required: + - name + type: object + properties: + pk: + title: Stage uuid + type: string + format: uuid + readOnly: true + name: + title: Name + type: string + minLength: 1 + object_type: + title: Object type + type: string + readOnly: true + verbose_name: + title: Verbose name + type: string + readOnly: true + verbose_name_plural: + title: Verbose name plural + type: string + readOnly: true + flow_set: + description: '' + type: array + items: + $ref: '#/definitions/Flow' DummyStage: description: DummyStage Serializer required: diff --git a/website/docs/releases/next.md b/website/docs/releases/2021.3.md similarity index 81% rename from website/docs/releases/next.md rename to website/docs/releases/2021.3.md index 3c4be4474..b0a230e32 100644 --- a/website/docs/releases/next.md +++ b/website/docs/releases/2021.3.md @@ -1,5 +1,5 @@ --- -title: Next release +title: Release 2021.1.3 --- ## Headline Changes @@ -32,6 +32,13 @@ title: Next release It also allows other services to use the flow executor via an API, which will be used by the outpost further down the road. +- Deny stage + + A new stage which simply denies access. This can be used to conditionally deny access to users during a flow. Authorization flows for example required an authenticated user, but there was no previous way to block access for un-authenticated users. + + If you conditionally include this stage in a flow, make sure to disable "Evaluate on plan", as that will always include the stage in the flow, irregardless of the inputs. + + ## Upgrading This release does not introduce any new requirements.