From cfe7bc8155c95b261d91b26bfe054489e5995d28 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Tue, 23 Mar 2021 17:23:44 +0100 Subject: [PATCH] flows: migrate access denied message to webcompoennts Signed-off-by: Jens Langhammer --- authentik/flows/challenge.py | 6 ++ .../flows/templates/flows/denied_shell.html | 57 ------------- authentik/flows/tests/test_views.py | 12 ++- authentik/flows/views.py | 14 +++- authentik/stages/deny/tests.py | 11 ++- authentik/stages/invitation/tests.py | 12 ++- authentik/stages/password/tests.py | 22 ++++- authentik/stages/user_delete/tests.py | 12 ++- authentik/stages/user_login/tests.py | 22 ++++- authentik/stages/user_write/tests.py | 12 ++- web/src/flows/FlowExecutor.ts | 4 + .../flows/access_denied/FlowAccessDenied.ts | 82 +++++++++++++++++++ 12 files changed, 189 insertions(+), 77 deletions(-) delete mode 100644 authentik/flows/templates/flows/denied_shell.html create mode 100644 web/src/flows/access_denied/FlowAccessDenied.ts 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 %} -
- {% csrf_token %} - {% include 'partials/form.html' %} -
-

- - {% trans 'Request has been denied.' %} -

- {% if error %} -
-

- {{ error }} -

- {% endif %} - {% if policy_result %} -
- - {% trans 'Explanation:' %} - -
    - {% for source_result in policy_result.source_results %} -
  • - {% blocktrans with name=source_result.source_policy.name result=source_result.passing %} - Policy '{{ name }}' returned result '{{ result }}' - {% endblocktrans %} - {% if source_result.messages %} -
      - {% for message in source_result.messages %} -
    • {{ message }}
    • - {% endfor %} -
    - {% endif %} -
  • - {% endfor %} -
- {% endif %} -
- {% if 'back' in request.GET %} - {% trans 'Back' %} - {% endif %} -
-{% 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``; case "ak-stage-identification": return html``; case "ak-stage-password": diff --git a/web/src/flows/access_denied/FlowAccessDenied.ts b/web/src/flows/access_denied/FlowAccessDenied.ts new file mode 100644 index 000000000..e6fe27c79 --- /dev/null +++ b/web/src/flows/access_denied/FlowAccessDenied.ts @@ -0,0 +1,82 @@ +import { Challenge } from "authentik-api"; +import { CSSResult, customElement, html, property, TemplateResult } from "lit-element"; +import { BaseStage } from "../stages/base"; +import PFLogin from "@patternfly/patternfly/components/Login/login.css"; +import PFTitle from "@patternfly/patternfly/components/Title/title.css"; +import PFBase from "@patternfly/patternfly/patternfly-base.css"; +import PFForm from "@patternfly/patternfly/components/Form/form.css"; +import PFList from "@patternfly/patternfly/components/List/list.css"; +import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css"; +import AKGlobal from "../../authentik.css"; +import { gettext } from "django"; + +import "../../elements/EmptyState"; + +export interface AccessDeniedChallenge extends Challenge { + error_message?: string; + policy_result?: Record; +} + +@customElement("ak-stage-access-denied") +export class FlowAccessDenied extends BaseStage { + + @property({ attribute: false }) + challenge?: AccessDeniedChallenge; + + static get styles(): CSSResult[] { + return [PFBase, PFLogin, PFForm, PFList, PFFormControl, PFTitle, AKGlobal]; + } + + render(): TemplateResult { + if (!this.challenge) { + return html` + `; + } + return html` + + `; + } + +}