diff --git a/authentik/flows/challenge.py b/authentik/flows/challenge.py index 2a7b07e78..b4a53e7b8 100644 --- a/authentik/flows/challenge.py +++ b/authentik/flows/challenge.py @@ -75,6 +75,12 @@ class WithUserInfoChallenge(Challenge): pending_user_avatar = CharField() +class AccessDeniedChallenge(Challenge): + """Challenge when a flow's active stage calls `stage_invalid()`.""" + + error_message = CharField(required=False) + + class PermissionSerializer(Serializer): """Permission used for consent""" diff --git a/authentik/flows/templates/flows/denied_shell.html b/authentik/flows/templates/flows/denied_shell.html deleted file mode 100644 index 768c4b02b..000000000 --- a/authentik/flows/templates/flows/denied_shell.html +++ /dev/null @@ -1,57 +0,0 @@ -{% extends 'login/base.html' %} - -{% load static %} -{% load i18n %} -{% load authentik_utils %} - -{% block card_title %} -{% trans 'Permission denied' %} -{% endblock %} - -{% block title %} -{% trans 'Permission denied' %} -{% endblock %} - -{% block card %} -
-{% endblock %} diff --git a/authentik/flows/tests/test_views.py b/authentik/flows/tests/test_views.py index 196409ec5..7c4f294ff 100644 --- a/authentik/flows/tests/test_views.py +++ b/authentik/flows/tests/test_views.py @@ -17,7 +17,6 @@ from authentik.flows.stage import PLAN_CONTEXT_PENDING_USER_IDENTIFIER, StageVie from authentik.flows.views import NEXT_ARG_NAME, SESSION_KEY_PLAN, FlowExecutorView from authentik.lib.config import CONFIG from authentik.policies.dummy.models import DummyPolicy -from authentik.policies.http import AccessDeniedResponse from authentik.policies.models import PolicyBinding from authentik.policies.types import PolicyResult from authentik.stages.dummy.models import DummyStage @@ -89,8 +88,15 @@ class TestFlowExecutor(TestCase): reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), ) self.assertEqual(response.status_code, 200) - self.assertIsInstance(response, AccessDeniedResponse) - self.assertInHTML(FlowNonApplicableException.__doc__, response.rendered_content) + self.assertJSONEqual( + force_str(response.content), + { + "component": "ak-stage-access-denied", + "error_message": FlowNonApplicableException.__doc__, + "title": "", + "type": ChallengeTypes.native.value, + }, + ) @patch( "authentik.flows.views.to_stage_response", diff --git a/authentik/flows/views.py b/authentik/flows/views.py index d45c814a9..d95d68c64 100644 --- a/authentik/flows/views.py +++ b/authentik/flows/views.py @@ -17,6 +17,7 @@ from structlog.stdlib import BoundLogger, get_logger from authentik.core.models import USER_ATTRIBUTE_DEBUG from authentik.events.models import cleanse_dict from authentik.flows.challenge import ( + AccessDeniedChallenge, Challenge, ChallengeResponse, ChallengeTypes, @@ -34,7 +35,6 @@ from authentik.flows.planner import ( ) from authentik.lib.utils.reflection import class_to_path from authentik.lib.utils.urls import is_url_absolute, redirect_with_qs -from authentik.policies.http import AccessDeniedResponse LOGGER = get_logger() # Argument used to redirect user after login @@ -212,10 +212,16 @@ class FlowExecutorView(APIView): is a superuser.""" self._logger.debug("f(exec): Stage invalid") self.cancel() - response = AccessDeniedResponse( - self.request, template="flows/denied_shell.html" + response = HttpChallengeResponse( + AccessDeniedChallenge( + { + "error_message": error_message, + "title": self.flow.title, + "type": ChallengeTypes.native.value, + "component": "ak-stage-access-denied", + } + ) ) - response.error_message = error_message return to_stage_response(self.request, response) def cancel(self): diff --git a/authentik/stages/deny/tests.py b/authentik/stages/deny/tests.py index 96c88b132..d82fdbaca 100644 --- a/authentik/stages/deny/tests.py +++ b/authentik/stages/deny/tests.py @@ -4,6 +4,7 @@ from django.urls import reverse from django.utils.encoding import force_str from authentik.core.models import User +from authentik.flows.challenge import ChallengeTypes from authentik.flows.markers import StageMarker from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding from authentik.flows.planner import FlowPlan @@ -42,7 +43,15 @@ class TestUserDenyStage(TestCase): ) self.assertEqual(response.status_code, 200) - self.assertIn("Permission denied", force_str(response.content)) + self.assertJSONEqual( + force_str(response.content), + { + "component": "ak-stage-access-denied", + "error_message": None, + "title": "", + "type": ChallengeTypes.native.value, + }, + ) def test_form(self): """Test Form""" diff --git a/authentik/stages/invitation/tests.py b/authentik/stages/invitation/tests.py index 0ae180c24..3f9220939 100644 --- a/authentik/stages/invitation/tests.py +++ b/authentik/stages/invitation/tests.py @@ -7,12 +7,12 @@ from django.utils.encoding import force_str from guardian.shortcuts import get_anonymous_user from authentik.core.models import User +from authentik.flows.challenge import ChallengeTypes from authentik.flows.markers import StageMarker from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan from authentik.flows.tests.test_views import TO_STAGE_RESPONSE_MOCK from authentik.flows.views import SESSION_KEY_PLAN -from authentik.policies.http import AccessDeniedResponse from authentik.stages.invitation.forms import InvitationStageForm from authentik.stages.invitation.models import Invitation, InvitationStage from authentik.stages.invitation.stage import INVITATION_TOKEN_KEY, PLAN_CONTEXT_PROMPT @@ -61,7 +61,15 @@ class TestUserLoginStage(TestCase): reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}) ) self.assertEqual(response.status_code, 200) - self.assertIsInstance(response, AccessDeniedResponse) + self.assertJSONEqual( + force_str(response.content), + { + "component": "ak-stage-access-denied", + "error_message": None, + "title": "", + "type": ChallengeTypes.native.value, + }, + ) def test_without_invitation_continue(self): """Test without any invitation, continue_flow_without_invitation is set.""" diff --git a/authentik/stages/password/tests.py b/authentik/stages/password/tests.py index 2e5fb6933..9cb27c7a9 100644 --- a/authentik/stages/password/tests.py +++ b/authentik/stages/password/tests.py @@ -9,12 +9,12 @@ from django.urls import reverse from django.utils.encoding import force_str from authentik.core.models import User +from authentik.flows.challenge import ChallengeTypes from authentik.flows.markers import StageMarker from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan from authentik.flows.tests.test_views import TO_STAGE_RESPONSE_MOCK from authentik.flows.views import SESSION_KEY_PLAN -from authentik.policies.http import AccessDeniedResponse from authentik.stages.password.models import PasswordStage MOCK_BACKEND_AUTHENTICATE = MagicMock(side_effect=PermissionDenied("test")) @@ -66,7 +66,15 @@ class TestPasswordStage(TestCase): ) self.assertEqual(response.status_code, 200) - self.assertIsInstance(response, AccessDeniedResponse) + self.assertJSONEqual( + force_str(response.content), + { + "component": "ak-stage-access-denied", + "error_message": None, + "title": "", + "type": ChallengeTypes.native.value, + }, + ) def test_recovery_flow_link(self): """Test link to the default recovery flow""" @@ -192,4 +200,12 @@ class TestPasswordStage(TestCase): ) self.assertEqual(response.status_code, 200) - self.assertIsInstance(response, AccessDeniedResponse) + self.assertJSONEqual( + force_str(response.content), + { + "component": "ak-stage-access-denied", + "error_message": None, + "title": "", + "type": ChallengeTypes.native.value, + }, + ) diff --git a/authentik/stages/user_delete/tests.py b/authentik/stages/user_delete/tests.py index 5062695aa..135474c61 100644 --- a/authentik/stages/user_delete/tests.py +++ b/authentik/stages/user_delete/tests.py @@ -6,12 +6,12 @@ from django.urls import reverse from django.utils.encoding import force_str from authentik.core.models import User +from authentik.flows.challenge import ChallengeTypes from authentik.flows.markers import StageMarker from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan from authentik.flows.tests.test_views import TO_STAGE_RESPONSE_MOCK from authentik.flows.views import SESSION_KEY_PLAN -from authentik.policies.http import AccessDeniedResponse from authentik.stages.user_delete.models import UserDeleteStage @@ -49,7 +49,15 @@ class TestUserDeleteStage(TestCase): reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}) ) self.assertEqual(response.status_code, 200) - self.assertIsInstance(response, AccessDeniedResponse) + self.assertJSONEqual( + force_str(response.content), + { + "component": "ak-stage-access-denied", + "error_message": None, + "title": "", + "type": ChallengeTypes.native.value, + }, + ) def test_user_delete_get(self): """Test Form render""" diff --git a/authentik/stages/user_login/tests.py b/authentik/stages/user_login/tests.py index 05320d36c..99ba9fe63 100644 --- a/authentik/stages/user_login/tests.py +++ b/authentik/stages/user_login/tests.py @@ -6,12 +6,12 @@ from django.urls import reverse from django.utils.encoding import force_str from authentik.core.models import User +from authentik.flows.challenge import ChallengeTypes from authentik.flows.markers import StageMarker from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan from authentik.flows.tests.test_views import TO_STAGE_RESPONSE_MOCK from authentik.flows.views import SESSION_KEY_PLAN -from authentik.policies.http import AccessDeniedResponse from authentik.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND from authentik.stages.user_login.forms import UserLoginStageForm from authentik.stages.user_login.models import UserLoginStage @@ -74,7 +74,15 @@ class TestUserLoginStage(TestCase): ) self.assertEqual(response.status_code, 200) - self.assertIsInstance(response, AccessDeniedResponse) + self.assertJSONEqual( + force_str(response.content), + { + "component": "ak-stage-access-denied", + "error_message": None, + "title": "", + "type": ChallengeTypes.native.value, + }, + ) @patch( "authentik.flows.views.to_stage_response", @@ -95,7 +103,15 @@ class TestUserLoginStage(TestCase): ) self.assertEqual(response.status_code, 200) - self.assertIsInstance(response, AccessDeniedResponse) + self.assertJSONEqual( + force_str(response.content), + { + "component": "ak-stage-access-denied", + "error_message": None, + "title": "", + "type": ChallengeTypes.native.value, + }, + ) def test_form(self): """Test Form""" diff --git a/authentik/stages/user_write/tests.py b/authentik/stages/user_write/tests.py index 277dd2caa..037f581ee 100644 --- a/authentik/stages/user_write/tests.py +++ b/authentik/stages/user_write/tests.py @@ -8,12 +8,12 @@ from django.urls import reverse from django.utils.encoding import force_str from authentik.core.models import User +from authentik.flows.challenge import ChallengeTypes from authentik.flows.markers import StageMarker from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan from authentik.flows.tests.test_views import TO_STAGE_RESPONSE_MOCK from authentik.flows.views import SESSION_KEY_PLAN -from authentik.policies.http import AccessDeniedResponse from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT from authentik.stages.user_write.forms import UserWriteStageForm from authentik.stages.user_write.models import UserWriteStage @@ -126,7 +126,15 @@ class TestUserWriteStage(TestCase): ) self.assertEqual(response.status_code, 200) - self.assertIsInstance(response, AccessDeniedResponse) + self.assertJSONEqual( + force_str(response.content), + { + "component": "ak-stage-access-denied", + "error_message": None, + "title": "", + "type": ChallengeTypes.native.value, + }, + ) def test_form(self): """Test Form""" diff --git a/web/src/flows/FlowExecutor.ts b/web/src/flows/FlowExecutor.ts index bdec49c96..abb97b3b1 100644 --- a/web/src/flows/FlowExecutor.ts +++ b/web/src/flows/FlowExecutor.ts @@ -20,6 +20,7 @@ import "./stages/email/EmailStage"; import "./stages/identification/IdentificationStage"; import "./stages/password/PasswordStage"; import "./stages/prompt/PromptStage"; +import "./access_denied/FlowAccessDenied"; import { ShellChallenge, RedirectChallenge } from "../api/Flows"; import { IdentificationChallenge } from "./stages/identification/IdentificationStage"; import { PasswordChallenge } from "./stages/password/PasswordStage"; @@ -38,6 +39,7 @@ import { DEFAULT_CONFIG } from "../api/Config"; import { ifDefined } from "lit-html/directives/if-defined"; import { until } from "lit-html/directives/until"; import { TITLE_SUFFIX } from "../elements/router/RouterOutlet"; +import { AccessDeniedChallenge } from "./access_denied/FlowAccessDenied"; @customElement("ak-flow-executor") export class FlowExecutor extends LitElement implements StageHost { @@ -175,6 +177,8 @@ export class FlowExecutor extends LitElement implements StageHost { return html`${unsafeHTML((this.challenge as ShellChallenge).body)}`; case ChallengeTypeEnum.Native: switch (this.challenge.component) { + case "ak-stage-access-denied": + return html`