diff --git a/authentik/flows/challenge.py b/authentik/flows/challenge.py index 830cf4806..af5030276 100644 --- a/authentik/flows/challenge.py +++ b/authentik/flows/challenge.py @@ -1,6 +1,7 @@ """Challenge helpers""" from enum import Enum +from django.db.models.base import Model from django.http import JsonResponse from rest_framework.fields import ChoiceField, JSONField from rest_framework.serializers import CharField, Serializer @@ -23,11 +24,24 @@ class Challenge(Serializer): type = ChoiceField(choices=list(ChallengeTypes)) component = CharField(required=False) args = JSONField() + title = CharField(required=False) + + def create(self, validated_data: dict) -> Model: + return Model() + + def update(self, instance: Model, validated_data: dict) -> Model: + return Model() class ChallengeResponse(Serializer): """Base class for all challenge responses""" + def create(self, validated_data: dict) -> Model: + return Model() + + def update(self, instance: Model, validated_data: dict) -> Model: + return Model() + class HttpChallengeResponse(JsonResponse): """Subclass of JsonResponse that uses the `DataclassEncoder`""" diff --git a/authentik/flows/stage.py b/authentik/flows/stage.py index 5f1a72296..d5ba4e40d 100644 --- a/authentik/flows/stage.py +++ b/authentik/flows/stage.py @@ -1,6 +1,6 @@ """authentik stage Base view""" from collections import namedtuple -from typing import Any +from typing import Any, Type from django.http import HttpRequest from django.http.response import HttpResponse, JsonResponse @@ -52,25 +52,36 @@ class StageView(TemplateView): class ChallengeStageView(StageView): + """Stage view which response with a challenge""" response_class = ChallengeResponse + def get_response_class(self) -> Type[ChallengeResponse]: + """Return the response class type""" + return self.response_class + def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: challenge = self.get_challenge() + challenge.title = self.executor.flow.title challenge.is_valid() return HttpChallengeResponse(challenge) + # pylint: disable=unused-argument def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: - challenge: ChallengeResponse = self.response_class(data=request.POST) + """Handle challenge response""" + challenge: ChallengeResponse = self.get_response_class()(data=request.POST) if not challenge.is_valid(): return self.challenge_invalid(challenge) return self.challenge_valid(challenge) def get_challenge(self) -> Challenge: + """Return the challenge that the client should solve""" raise NotImplementedError def challenge_valid(self, challenge: ChallengeResponse) -> HttpResponse: + """Callback when the challenge has the correct format""" raise NotImplementedError def challenge_invalid(self, challenge: ChallengeResponse) -> HttpResponse: + """Callback when the challenge has the incorrect format""" return JsonResponse(challenge.errors) diff --git a/authentik/flows/tests/test_views.py b/authentik/flows/tests/test_views.py index badeab777..b97ae1579 100644 --- a/authentik/flows/tests/test_views.py +++ b/authentik/flows/tests/test_views.py @@ -282,7 +282,7 @@ class TestFlowExecutor(TestCase): self.assertEqual(response.status_code, 200) self.assertJSONEqual( force_str(response.content), - {"type": "redirect", "to": reverse("authentik_core:shell")}, + {"args": {"to": reverse("authentik_core:shell")}, "type": "redirect"}, ) def test_reevaluate_keep(self): @@ -435,7 +435,7 @@ class TestFlowExecutor(TestCase): self.assertEqual(response.status_code, 200) self.assertJSONEqual( force_str(response.content), - {"type": "redirect", "to": reverse("authentik_core:shell")}, + {"args": {"to": reverse("authentik_core:shell")}, "type": "redirect"}, ) def test_stageview_user_identifier(self): diff --git a/authentik/flows/views.py b/authentik/flows/views.py index ec6d23cd4..30d297c2e 100644 --- a/authentik/flows/views.py +++ b/authentik/flows/views.py @@ -3,13 +3,7 @@ from traceback import format_tb from typing import Any, Optional from django.contrib.auth.mixins import LoginRequiredMixin -from django.http import ( - Http404, - HttpRequest, - HttpResponse, - HttpResponseRedirect, - JsonResponse, -) +from django.http import Http404, HttpRequest, HttpResponse, HttpResponseRedirect from django.shortcuts import get_object_or_404, redirect, reverse from django.template.response import TemplateResponse from django.utils.decorators import method_decorator diff --git a/authentik/stages/authenticator_validate/stage.py b/authentik/stages/authenticator_validate/stage.py index fe2e59fe6..54d4367a2 100644 --- a/authentik/stages/authenticator_validate/stage.py +++ b/authentik/stages/authenticator_validate/stage.py @@ -1,8 +1,5 @@ """OTP Validation""" -from typing import Any - from django.http import HttpRequest, HttpResponse -from django.views.generic import FormView from django_otp import user_has_device from rest_framework.fields import IntegerField from structlog.stdlib import get_logger @@ -10,7 +7,7 @@ from structlog.stdlib import get_logger from authentik.flows.challenge import Challenge, ChallengeResponse, ChallengeTypes from authentik.flows.models import NotConfiguredAction from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER -from authentik.flows.stage import ChallengeStageView, StageView +from authentik.flows.stage import ChallengeStageView from authentik.stages.authenticator_validate.forms import ValidationForm from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage @@ -18,13 +15,13 @@ LOGGER = get_logger() class CodeChallengeResponse(ChallengeResponse): + """Challenge used for Code-based authenticators""" code = IntegerField(min_value=0) class WebAuthnChallengeResponse(ChallengeResponse): - - pass + """Challenge used for WebAuthn authenticators""" class AuthenticatorValidateStageView(ChallengeStageView): @@ -32,10 +29,10 @@ class AuthenticatorValidateStageView(ChallengeStageView): form_class = ValidationForm - def get_form_kwargs(self, **kwargs) -> dict[str, Any]: - kwargs = super().get_form_kwargs(**kwargs) - kwargs["user"] = self.executor.plan.context.get(PLAN_CONTEXT_PENDING_USER) - return kwargs + # def get_form_kwargs(self, **kwargs) -> dict[str, Any]: + # kwargs = super().get_form_kwargs(**kwargs) + # kwargs["user"] = self.executor.plan.context.get(PLAN_CONTEXT_PENDING_USER) + # return kwargs def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: """Check if a user is set, and check if the user has any devices @@ -68,9 +65,9 @@ class AuthenticatorValidateStageView(ChallengeStageView): } ) - def post_challenge(self, challenge: Challenge) -> HttpResponse: + def challenge_valid(self, challenge: ChallengeResponse) -> HttpResponse: print(challenge) - return super().post_challenge(challenge) + return HttpResponse() # def form_valid(self, form: ValidationForm) -> HttpResponse: # """Verify OTP Token""" diff --git a/authentik/stages/captcha/tests.py b/authentik/stages/captcha/tests.py index 278d9bd2b..b975a7c63 100644 --- a/authentik/stages/captcha/tests.py +++ b/authentik/stages/captcha/tests.py @@ -51,5 +51,5 @@ class TestCaptchaStage(TestCase): self.assertEqual(response.status_code, 200) self.assertJSONEqual( force_str(response.content), - {"type": "redirect", "to": reverse("authentik_core:shell")}, + {"args": {"to": reverse("authentik_core:shell")}, "type": "redirect"}, ) diff --git a/authentik/stages/consent/tests.py b/authentik/stages/consent/tests.py index 572edcca0..73879774b 100644 --- a/authentik/stages/consent/tests.py +++ b/authentik/stages/consent/tests.py @@ -51,7 +51,7 @@ class TestConsentStage(TestCase): self.assertEqual(response.status_code, 200) self.assertJSONEqual( force_str(response.content), - {"type": "redirect", "to": reverse("authentik_core:shell")}, + {"args": {"to": reverse("authentik_core:shell")}, "type": "redirect"}, ) self.assertFalse(UserConsent.objects.filter(user=self.user).exists()) @@ -82,7 +82,7 @@ class TestConsentStage(TestCase): self.assertEqual(response.status_code, 200) self.assertJSONEqual( force_str(response.content), - {"type": "redirect", "to": reverse("authentik_core:shell")}, + {"args": {"to": reverse("authentik_core:shell")}, "type": "redirect"}, ) self.assertTrue( UserConsent.objects.filter( @@ -119,7 +119,7 @@ class TestConsentStage(TestCase): self.assertEqual(response.status_code, 200) self.assertJSONEqual( force_str(response.content), - {"type": "redirect", "to": reverse("authentik_core:shell")}, + {"args": {"to": reverse("authentik_core:shell")}, "type": "redirect"}, ) self.assertTrue( UserConsent.objects.filter( diff --git a/authentik/stages/dummy/tests.py b/authentik/stages/dummy/tests.py index 56570192a..7ec2b6f83 100644 --- a/authentik/stages/dummy/tests.py +++ b/authentik/stages/dummy/tests.py @@ -47,7 +47,7 @@ class TestDummyStage(TestCase): self.assertEqual(response.status_code, 200) self.assertJSONEqual( force_str(response.content), - {"type": "redirect", "to": reverse("authentik_core:shell")}, + {"args": {"to": reverse("authentik_core:shell")}, "type": "redirect"}, ) def test_form(self): diff --git a/authentik/stages/email/tests/test_stage.py b/authentik/stages/email/tests/test_stage.py index a852653e5..73f648289 100644 --- a/authentik/stages/email/tests/test_stage.py +++ b/authentik/stages/email/tests/test_stage.py @@ -126,7 +126,7 @@ class TestEmailStage(TestCase): self.assertEqual(response.status_code, 200) self.assertJSONEqual( force_str(response.content), - {"type": "redirect", "to": reverse("authentik_core:shell")}, + {"args": {"to": reverse("authentik_core:shell")}, "type": "redirect"}, ) session = self.client.session diff --git a/authentik/stages/identification/stage.py b/authentik/stages/identification/stage.py index 2c329e06a..21989be1d 100644 --- a/authentik/stages/identification/stage.py +++ b/authentik/stages/identification/stage.py @@ -6,7 +6,6 @@ from django.db.models import Q from django.http import HttpResponse from django.urls import reverse from django.utils.translation import gettext as _ -from django.views.generic import FormView from rest_framework.fields import CharField from structlog.stdlib import get_logger @@ -17,19 +16,20 @@ from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER from authentik.flows.stage import ( PLAN_CONTEXT_PENDING_USER_IDENTIFIER, ChallengeStageView, - StageView, ) from authentik.flows.views import SESSION_KEY_APPLICATION_PRE -from authentik.stages.identification.forms import IdentificationForm from authentik.stages.identification.models import IdentificationStage, UserFields LOGGER = get_logger() class IdentificationChallengeResponse(ChallengeResponse): + """Identification challenge""" uid_field = CharField() + # TODO: Validate here instead of challenge_valid() + class IdentificationStageView(ChallengeStageView): """Form to identify the user""" @@ -66,12 +66,12 @@ class IdentificationStageView(ChallengeStageView): if current_stage.enrollment_flow: args["enroll_url"] = reverse( "authentik_flows:flow-executor-shell", - args={"flow_slug": current_stage.enrollment_flow.slug}, + kwargs={"flow_slug": current_stage.enrollment_flow.slug}, ) if current_stage.recovery_flow: args["recovery_url"] = reverse( "authentik_flows:flow-executor-shell", - args={"flow_slug": current_stage.recovery_flow.slug}, + kwargs={"flow_slug": current_stage.recovery_flow.slug}, ) args["primary_action"] = _("Log in") diff --git a/authentik/stages/identification/tests.py b/authentik/stages/identification/tests.py index 1c29b4792..88c3ee4a2 100644 --- a/authentik/stages/identification/tests.py +++ b/authentik/stages/identification/tests.py @@ -57,7 +57,7 @@ class TestIdentificationStage(TestCase): self.assertEqual(response.status_code, 200) self.assertJSONEqual( force_str(response.content), - {"type": "redirect", "to": reverse("authentik_core:shell")}, + {"args": {"to": reverse("authentik_core:shell")}, "type": "redirect"}, ) def test_invalid_with_username(self): @@ -87,6 +87,7 @@ class TestIdentificationStage(TestCase): flow = Flow.objects.create( name="enroll-test", slug="unique-enrollment-string", + title="unique-enrollment-string", designation=FlowDesignation.ENROLLMENT, ) self.stage.enrollment_flow = flow @@ -103,7 +104,25 @@ class TestIdentificationStage(TestCase): ), ) self.assertEqual(response.status_code, 200) - self.assertIn(flow.slug, force_str(response.content)) + self.assertJSONEqual( + force_str(response.content), + { + "type": "native", + "component": "ak-stage-identification", + "args": { + "input_type": "email", + "enroll_url": "/flows/unique-enrollment-string/", + "primary_action": "Log in", + "sources": [ + { + "icon_url": "/static/authentik/sources/.svg", + "name": "test", + "url": "/source/oauth/login/test/", + } + ], + }, + }, + ) def test_recovery_flow(self): """Test that recovery flow is linked correctly""" @@ -119,11 +138,28 @@ class TestIdentificationStage(TestCase): stage=self.stage, order=0, ) - response = self.client.get( reverse( "authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug} ), ) self.assertEqual(response.status_code, 200) - self.assertIn(flow.slug, force_str(response.content)) + self.assertJSONEqual( + force_str(response.content), + { + "type": "native", + "component": "ak-stage-identification", + "args": { + "input_type": "email", + "recovery_url": "/flows/unique-recovery-string/", + "primary_action": "Log in", + "sources": [ + { + "icon_url": "/static/authentik/sources/.svg", + "name": "test", + "url": "/source/oauth/login/test/", + } + ], + }, + }, + ) diff --git a/authentik/stages/invitation/tests.py b/authentik/stages/invitation/tests.py index 19412dd0e..f81f68d79 100644 --- a/authentik/stages/invitation/tests.py +++ b/authentik/stages/invitation/tests.py @@ -85,7 +85,7 @@ class TestUserLoginStage(TestCase): self.assertEqual(response.status_code, 200) self.assertJSONEqual( force_str(response.content), - {"type": "redirect", "to": reverse("authentik_core:shell")}, + {"args": {"to": reverse("authentik_core:shell")}, "type": "redirect"}, ) self.stage.continue_flow_without_invitation = False @@ -124,5 +124,5 @@ class TestUserLoginStage(TestCase): self.assertEqual(response.status_code, 200) self.assertJSONEqual( force_str(response.content), - {"type": "redirect", "to": reverse("authentik_core:shell")}, + {"args": {"to": reverse("authentik_core:shell")}, "type": "redirect"}, ) diff --git a/authentik/stages/password/tests.py b/authentik/stages/password/tests.py index 2a5e87af5..fcc1a40ef 100644 --- a/authentik/stages/password/tests.py +++ b/authentik/stages/password/tests.py @@ -110,7 +110,7 @@ class TestPasswordStage(TestCase): self.assertEqual(response.status_code, 200) self.assertJSONEqual( force_str(response.content), - {"type": "redirect", "to": reverse("authentik_core:shell")}, + {"args": {"to": reverse("authentik_core:shell")}, "type": "redirect"}, ) def test_invalid_password(self): diff --git a/authentik/stages/prompt/tests.py b/authentik/stages/prompt/tests.py index d209849c8..cb706e50e 100644 --- a/authentik/stages/prompt/tests.py +++ b/authentik/stages/prompt/tests.py @@ -164,7 +164,7 @@ class TestPromptStage(TestCase): self.assertEqual(response.status_code, 200) self.assertJSONEqual( force_str(response.content), - {"type": "redirect", "to": reverse("authentik_core:shell")}, + {"args": {"to": reverse("authentik_core:shell")}, "type": "redirect"}, ) # Check that valid data has been saved diff --git a/authentik/stages/user_delete/tests.py b/authentik/stages/user_delete/tests.py index 1c691101d..fbc213a1b 100644 --- a/authentik/stages/user_delete/tests.py +++ b/authentik/stages/user_delete/tests.py @@ -85,7 +85,7 @@ class TestUserDeleteStage(TestCase): self.assertEqual(response.status_code, 200) self.assertJSONEqual( force_str(response.content), - {"type": "redirect", "to": reverse("authentik_core:shell")}, + {"args": {"to": reverse("authentik_core:shell")}, "type": "redirect"}, ) self.assertFalse(User.objects.filter(username=self.username).exists()) diff --git a/authentik/stages/user_login/tests.py b/authentik/stages/user_login/tests.py index 219009169..43f576450 100644 --- a/authentik/stages/user_login/tests.py +++ b/authentik/stages/user_login/tests.py @@ -53,7 +53,7 @@ class TestUserLoginStage(TestCase): self.assertEqual(response.status_code, 200) self.assertJSONEqual( force_str(response.content), - {"type": "redirect", "to": reverse("authentik_core:shell")}, + {"args": {"to": reverse("authentik_core:shell")}, "type": "redirect"}, ) @patch( diff --git a/authentik/stages/user_logout/tests.py b/authentik/stages/user_logout/tests.py index 9dba5a0d4..d6aba0a40 100644 --- a/authentik/stages/user_logout/tests.py +++ b/authentik/stages/user_logout/tests.py @@ -49,7 +49,7 @@ class TestUserLogoutStage(TestCase): self.assertEqual(response.status_code, 200) self.assertJSONEqual( force_str(response.content), - {"type": "redirect", "to": reverse("authentik_core:shell")}, + {"args": {"to": reverse("authentik_core:shell")}, "type": "redirect"}, ) def test_form(self): diff --git a/authentik/stages/user_write/tests.py b/authentik/stages/user_write/tests.py index 4f5b213ce..91e6a94e2 100644 --- a/authentik/stages/user_write/tests.py +++ b/authentik/stages/user_write/tests.py @@ -61,7 +61,7 @@ class TestUserWriteStage(TestCase): self.assertEqual(response.status_code, 200) self.assertJSONEqual( force_str(response.content), - {"type": "redirect", "to": reverse("authentik_core:shell")}, + {"args": {"to": reverse("authentik_core:shell")}, "type": "redirect"}, ) user_qs = User.objects.filter( username=plan.context[PLAN_CONTEXT_PROMPT]["username"] @@ -98,7 +98,7 @@ class TestUserWriteStage(TestCase): self.assertEqual(response.status_code, 200) self.assertJSONEqual( force_str(response.content), - {"type": "redirect", "to": reverse("authentik_core:shell")}, + {"args": {"to": reverse("authentik_core:shell")}, "type": "redirect"}, ) user_qs = User.objects.filter( username=plan.context[PLAN_CONTEXT_PROMPT]["username"] diff --git a/web/src/pages/generic/FlowExecutor.ts b/web/src/pages/generic/FlowExecutor.ts index 414bc13ad..634b74d1e 100644 --- a/web/src/pages/generic/FlowExecutor.ts +++ b/web/src/pages/generic/FlowExecutor.ts @@ -15,7 +15,8 @@ enum ChallengeTypes { interface Challenge { type: ChallengeTypes; args: { [key: string]: string }; - component: string; + component?: string; + title?: string; } @customElement("ak-flow-executor")