flows: migrate access denied message to webcompoennts

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
This commit is contained in:
Jens Langhammer 2021-03-23 17:23:44 +01:00
parent c6c4636b9b
commit cfe7bc8155
12 changed files with 189 additions and 77 deletions

View File

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

View File

@ -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 %}
<form method="POST" class="pf-c-form">
{% csrf_token %}
{% include 'partials/form.html' %}
<div class="pf-c-form__group">
<p>
<i class="pf-icon pf-icon-error-circle-o"></i>
{% trans 'Request has been denied.' %}
</p>
{% if error %}
<hr>
<p>
{{ error }}
</p>
{% endif %}
{% if policy_result %}
<hr>
<em>
{% trans 'Explanation:' %}
</em>
<ul class="pf-c-list">
{% for source_result in policy_result.source_results %}
<li>
{% blocktrans with name=source_result.source_policy.name result=source_result.passing %}
Policy '{{ name }}' returned result '{{ result }}'
{% endblocktrans %}
{% if source_result.messages %}
<ul class="pf-c-list">
{% for message in source_result.messages %}
<li>{{ message }}</li>
{% endfor %}
</ul>
{% endif %}
</li>
{% endfor %}
</ul>
{% endif %}
</div>
{% if 'back' in request.GET %}
<a href="{% back %}" class="btn btn-primary btn-block btn-lg">{% trans 'Back' %}</a>
{% endif %}
</form>
{% endblock %}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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`<ak-stage-access-denied .host=${this} .challenge=${this.challenge as AccessDeniedChallenge}></ak-stage-access-denied>`;
case "ak-stage-identification":
return html`<ak-stage-identification .host=${this} .challenge=${this.challenge as IdentificationChallenge}></ak-stage-identification>`;
case "ak-stage-password":

View File

@ -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<string, unknown>;
}
@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`<ak-empty-state
?loading="${true}"
header=${gettext("Loading")}>
</ak-empty-state>`;
}
return html`<header class="pf-c-login__main-header">
<h1 class="pf-c-title pf-m-3xl">
${this.challenge.title}
</h1>
</header>
<div class="pf-c-login__main-body">
<form method="POST" class="pf-c-form">
<div class="pf-c-form__group">
<p>
<i class="pf-icon pf-icon-error-circle-o"></i>
${gettext("Request has been denied.")}
</p>
${this.challenge?.error_message &&
html`<hr>
<p>${this.challenge.error_message}</p>`}
${this.challenge.policy_result &&
html`<hr>
<em>
${gettext("Explanation:")}
</em>
<ul class="pf-c-list">
{% for source_result in policy_result.source_results %}
<li>
{% blocktrans with name=source_result.source_policy.name result=source_result.passing %}
Policy '{{ name }}' returned result '{{ result }}'
{% endblocktrans %}
{% if source_result.messages %}
<ul class="pf-c-list">
{% for message in source_result.messages %}
<li>{{ message }}</li>
{% endfor %}
</ul>
{% endif %}
</li>
{% endfor %}
</ul>`}
</div>
</form>
</div>
<footer class="pf-c-login__main-footer">
<ul class="pf-c-login__main-footer-links">
</ul>
</footer>`;
}
}