*: cleanup code, return errors in challenge_invalid, fixup rendering

This commit is contained in:
Jens Langhammer 2021-02-20 23:19:27 +01:00
parent 548b1ead2f
commit 511f94fc7f
25 changed files with 306 additions and 296 deletions

View file

@ -3,7 +3,7 @@
{% csrf_token %} {% csrf_token %}
{% if form.non_field_errors %} {% if form.non_field_errors %}
<div class="pf-c-form__group has-error"> <div class="pf-c-form__group">
<p class="pf-c-form__helper-text pf-m-error"> <p class="pf-c-form__helper-text pf-m-error">
{{ form.non_field_errors }} {{ form.non_field_errors }}
</p> </p>
@ -13,7 +13,7 @@
{% if field.field.widget|fieldtype == 'HiddenInput' %} {% if field.field.widget|fieldtype == 'HiddenInput' %}
{{ field }} {{ field }}
{% else %} {% else %}
<div class="pf-c-form__group {% if field.errors %} has-error {% endif %}"> <div class="pf-c-form__group">
{% if field.field.widget|fieldtype == 'RadioSelect' %} {% if field.field.widget|fieldtype == 'RadioSelect' %}
<label class="pf-c-form__label" {% if field.field.required %}class="required" {% endif %} <label class="pf-c-form__label" {% if field.field.required %}class="required" {% endif %}
for="{{ field.name }}-{{ forloop.counter0 }}"> for="{{ field.name }}-{{ forloop.counter0 }}">

View file

@ -4,7 +4,7 @@ from typing import TYPE_CHECKING, Optional
from django.db.models.base import Model from django.db.models.base import Model
from django.http import JsonResponse from django.http import JsonResponse
from rest_framework.fields import ChoiceField, JSONField from rest_framework.fields import ChoiceField, DictField
from rest_framework.serializers import CharField, Serializer from rest_framework.serializers import CharField, Serializer
from authentik.flows.transfer.common import DataclassEncoder from authentik.flows.transfer.common import DataclassEncoder
@ -19,7 +19,19 @@ class ChallengeTypes(Enum):
native = "native" native = "native"
shell = "shell" shell = "shell"
redirect = "redirect" redirect = "redirect"
error = "error"
class ErrorDetailSerializer(Serializer):
"""Serializer for rest_framework's error messages"""
string = CharField()
code = CharField()
def create(self, validated_data: dict) -> Model:
return Model()
def update(self, instance: Model, validated_data: dict) -> Model:
return Model()
class Challenge(Serializer): class Challenge(Serializer):
@ -28,9 +40,12 @@ class Challenge(Serializer):
type = ChoiceField(choices=list(ChallengeTypes)) type = ChoiceField(choices=list(ChallengeTypes))
component = CharField(required=False) component = CharField(required=False)
args = JSONField()
title = CharField(required=False) title = CharField(required=False)
response_errors = DictField(
child=ErrorDetailSerializer(many=True), allow_empty=False, required=False
)
def create(self, validated_data: dict) -> Model: def create(self, validated_data: dict) -> Model:
return Model() return Model()
@ -38,6 +53,18 @@ class Challenge(Serializer):
return Model() return Model()
class RedirectChallenge(Challenge):
"""Challenge type to redirect the client"""
to = CharField()
class ShellChallenge(Challenge):
"""Legacy challenge type to render HTML as-is"""
body = CharField()
class ChallengeResponse(Serializer): class ChallengeResponse(Serializer):
"""Base class for all challenge responses""" """Base class for all challenge responses"""
@ -57,6 +84,6 @@ class ChallengeResponse(Serializer):
class HttpChallengeResponse(JsonResponse): class HttpChallengeResponse(JsonResponse):
"""Subclass of JsonResponse that uses the `DataclassEncoder`""" """Subclass of JsonResponse that uses the `DataclassEncoder`"""
def __init__(self, challenge: Challenge, **kwargs) -> None: def __init__(self, challenge, **kwargs) -> None:
# pyright: reportGeneralTypeIssues=false # pyright: reportGeneralTypeIssues=false
super().__init__(challenge.data, encoder=DataclassEncoder, **kwargs) super().__init__(challenge.data, encoder=DataclassEncoder, **kwargs)

View file

@ -4,19 +4,21 @@ from typing import Any
from django.http import HttpRequest from django.http import HttpRequest
from django.http.request import QueryDict from django.http.request import QueryDict
from django.http.response import HttpResponse, JsonResponse from django.http.response import HttpResponse
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.generic import TemplateView from django.views.generic import TemplateView
from structlog.stdlib import get_logger
from authentik.flows.challenge import ( from authentik.flows.challenge import (
Challenge, Challenge,
ChallengeResponse, ChallengeTypes, ChallengeResponse,
HttpChallengeResponse, HttpChallengeResponse,
) )
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER
from authentik.flows.views import FlowExecutorView from authentik.flows.views import FlowExecutorView
PLAN_CONTEXT_PENDING_USER_IDENTIFIER = "pending_user_identifier" PLAN_CONTEXT_PENDING_USER_IDENTIFIER = "pending_user_identifier"
LOGGER = get_logger()
FakeUser = namedtuple("User", ["username", "email"]) FakeUser = namedtuple("User", ["username", "email"])
@ -63,8 +65,9 @@ class ChallengeStageView(StageView):
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
challenge = self.get_challenge() challenge = self.get_challenge()
challenge.title = self.executor.flow.title challenge.initial_data["title"] = self.executor.flow.title
challenge.is_valid() if not challenge.is_valid():
LOGGER.warning(challenge.errors)
return HttpChallengeResponse(challenge) return HttpChallengeResponse(challenge)
# pylint: disable=unused-argument # pylint: disable=unused-argument
@ -79,19 +82,25 @@ class ChallengeStageView(StageView):
"""Return the challenge that the client should solve""" """Return the challenge that the client should solve"""
raise NotImplementedError raise NotImplementedError
def challenge_valid(self, challenge: ChallengeResponse) -> HttpResponse: def challenge_valid(self, response: ChallengeResponse) -> HttpResponse:
"""Callback when the challenge has the correct format""" """Callback when the challenge has the correct format"""
raise NotImplementedError raise NotImplementedError
def challenge_invalid(self, challenge: ChallengeResponse) -> HttpResponse: def challenge_invalid(self, response: ChallengeResponse) -> HttpResponse:
"""Callback when the challenge has the incorrect format""" """Callback when the challenge has the incorrect format"""
challenge_response = Challenge(data={ challenge_response = self.get_challenge()
"type": ChallengeTypes.error, challenge_response.initial_data["title"] = self.executor.flow.title
"args": { full_errors = {}
"errors": challenge.errors for field, errors in response.errors.items():
for error in errors:
full_errors.setdefault(field, [])
full_errors[field].append(
{
"string": str(error),
"code": error.code,
} }
})
challenge_response.is_valid()
return HttpChallengeResponse(
challenge_response
) )
challenge_response.initial_data["response_errors"] = full_errors
if not challenge_response.is_valid():
LOGGER.warning(challenge_response.errors)
return HttpChallengeResponse(challenge_response)

View file

@ -26,6 +26,6 @@
{% endblock %} {% endblock %}
{% block main_container %} {% block main_container %}
<ak-flow-executor class="pf-c-login__main" flowBodyUrl="{{ exec_url }}"> <ak-flow-executor class="pf-c-login__main" flowSlug="{{ flow_slug }}">
</ak-flow-executor> </ak-flow-executor>
{% endblock %} {% endblock %}

View file

@ -282,7 +282,7 @@ class TestFlowExecutor(TestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertJSONEqual( self.assertJSONEqual(
force_str(response.content), force_str(response.content),
{"args": {"to": reverse("authentik_core:shell")}, "type": "redirect"}, {"to": reverse("authentik_core:shell"), "type": "redirect"},
) )
def test_reevaluate_keep(self): def test_reevaluate_keep(self):
@ -359,7 +359,7 @@ class TestFlowExecutor(TestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertJSONEqual( self.assertJSONEqual(
force_str(response.content), force_str(response.content),
{"args": {"to": reverse("authentik_core:shell")}, "type": "redirect"}, {"to": reverse("authentik_core:shell"), "type": "redirect"},
) )
def test_reevaluate_remove_consecutive(self): def test_reevaluate_remove_consecutive(self):
@ -435,7 +435,7 @@ class TestFlowExecutor(TestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertJSONEqual( self.assertJSONEqual(
force_str(response.content), force_str(response.content),
{"args": {"to": reverse("authentik_core:shell")}, "type": "redirect"}, {"to": reverse("authentik_core:shell"), "type": "redirect"},
) )
def test_stageview_user_identifier(self): def test_stageview_user_identifier(self):

View file

@ -4,7 +4,7 @@ from typing import Any, Optional
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.http import Http404, HttpRequest, HttpResponse, HttpResponseRedirect from django.http import Http404, HttpRequest, HttpResponse, HttpResponseRedirect
from django.shortcuts import get_object_or_404, redirect, reverse from django.shortcuts import get_object_or_404, redirect
from django.template.response import TemplateResponse from django.template.response import TemplateResponse
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.views.decorators.clickjacking import xframe_options_sameorigin from django.views.decorators.clickjacking import xframe_options_sameorigin
@ -13,7 +13,12 @@ from structlog.stdlib import BoundLogger, get_logger
from authentik.core.models import USER_ATTRIBUTE_DEBUG from authentik.core.models import USER_ATTRIBUTE_DEBUG
from authentik.events.models import cleanse_dict from authentik.events.models import cleanse_dict
from authentik.flows.challenge import Challenge, ChallengeTypes, HttpChallengeResponse from authentik.flows.challenge import (
ChallengeTypes,
HttpChallengeResponse,
RedirectChallenge,
ShellChallenge,
)
from authentik.flows.exceptions import EmptyFlowException, FlowNonApplicableException from authentik.flows.exceptions import EmptyFlowException, FlowNonApplicableException
from authentik.flows.models import ConfigurableStage, Flow, FlowDesignation, Stage from authentik.flows.models import ConfigurableStage, Flow, FlowDesignation, Stage
from authentik.flows.planner import ( from authentik.flows.planner import (
@ -241,7 +246,7 @@ class FlowExecutorShellView(TemplateView):
def get_context_data(self, **kwargs) -> dict[str, Any]: def get_context_data(self, **kwargs) -> dict[str, Any]:
flow: Flow = get_object_or_404(Flow, slug=self.kwargs.get("flow_slug")) flow: Flow = get_object_or_404(Flow, slug=self.kwargs.get("flow_slug"))
kwargs["background_url"] = flow.background.url kwargs["background_url"] = flow.background.url
kwargs["exec_url"] = reverse("authentik_api:flow-executor", kwargs=self.kwargs) kwargs["flow_slug"] = flow.slug
self.request.session[SESSION_KEY_GET] = self.request.GET self.request.session[SESSION_KEY_GET] = self.request.GET
return kwargs return kwargs
@ -286,37 +291,30 @@ def to_stage_response(request: HttpRequest, source: HttpResponse) -> HttpRespons
redirect_url = source["Location"] redirect_url = source["Location"]
if request.path != redirect_url: if request.path != redirect_url:
return HttpChallengeResponse( return HttpChallengeResponse(
Challenge( RedirectChallenge(
{"type": ChallengeTypes.redirect, "args": {"to": redirect_url}} {"type": ChallengeTypes.redirect, "to": str(redirect_url)}
) )
) )
# return JsonResponse({"type": "redirect", "to": redirect_url})
return source return source
if isinstance(source, TemplateResponse): if isinstance(source, TemplateResponse):
return HttpChallengeResponse( return HttpChallengeResponse(
Challenge( ShellChallenge(
{ {
"type": ChallengeTypes.shell, "type": ChallengeTypes.shell,
"args": {"body": source.render().content.decode("utf-8")}, "body": source.render().content.decode("utf-8"),
} }
) )
) )
# return JsonResponse(
# {"type": "template", "body": }
# )
# Check for actual HttpResponse (without isinstance as we dont want to check inheritance) # Check for actual HttpResponse (without isinstance as we dont want to check inheritance)
if source.__class__ == HttpResponse: if source.__class__ == HttpResponse:
return HttpChallengeResponse( return HttpChallengeResponse(
Challenge( ShellChallenge(
{ {
"type": ChallengeTypes.shell, "type": ChallengeTypes.shell,
"args": {"body": source.content.decode("utf-8")}, "body": source.content.decode("utf-8"),
} }
) )
) )
# return JsonResponse(
# {"type": "template", "body": }
# )
return source return source

View file

@ -4,7 +4,7 @@ from typing import Optional, Type
from django.db import models from django.db import models
from django.forms import ModelForm from django.forms import ModelForm
from django.templatetags.static import static from django.templatetags.static import static
from django.urls import reverse, reverse_lazy from django.urls import reverse
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from rest_framework.serializers import Serializer from rest_framework.serializers import Serializer
@ -57,7 +57,7 @@ class OAuthSource(Source):
@property @property
def ui_login_button(self) -> UILoginButton: def ui_login_button(self) -> UILoginButton:
return UILoginButton( return UILoginButton(
url=reverse_lazy( url=reverse(
"authentik_sources_oauth:oauth-client-login", "authentik_sources_oauth:oauth-client-login",
kwargs={"source_slug": self.slug}, kwargs={"source_slug": self.slug},
), ),

View file

@ -165,7 +165,7 @@ class SAMLSource(Source):
def ui_login_button(self) -> UILoginButton: def ui_login_button(self) -> UILoginButton:
return UILoginButton( return UILoginButton(
name=self.name, name=self.name,
url=reverse_lazy( url=reverse(
"authentik_sources_saml:login", kwargs={"source_slug": self.slug} "authentik_sources_saml:login", kwargs={"source_slug": self.slug}
), ),
) )

View file

@ -51,5 +51,5 @@ class TestCaptchaStage(TestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertJSONEqual( self.assertJSONEqual(
force_str(response.content), force_str(response.content),
{"args": {"to": reverse("authentik_core:shell")}, "type": "redirect"}, {"to": reverse("authentik_core:shell"), "type": "redirect"},
) )

View file

@ -51,7 +51,7 @@ class TestConsentStage(TestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertJSONEqual( self.assertJSONEqual(
force_str(response.content), force_str(response.content),
{"args": {"to": reverse("authentik_core:shell")}, "type": "redirect"}, {"to": reverse("authentik_core:shell"), "type": "redirect"},
) )
self.assertFalse(UserConsent.objects.filter(user=self.user).exists()) self.assertFalse(UserConsent.objects.filter(user=self.user).exists())
@ -82,7 +82,7 @@ class TestConsentStage(TestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertJSONEqual( self.assertJSONEqual(
force_str(response.content), force_str(response.content),
{"args": {"to": reverse("authentik_core:shell")}, "type": "redirect"}, {"to": reverse("authentik_core:shell"), "type": "redirect"},
) )
self.assertTrue( self.assertTrue(
UserConsent.objects.filter( UserConsent.objects.filter(
@ -119,7 +119,7 @@ class TestConsentStage(TestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertJSONEqual( self.assertJSONEqual(
force_str(response.content), force_str(response.content),
{"args": {"to": reverse("authentik_core:shell")}, "type": "redirect"}, {"to": reverse("authentik_core:shell"), "type": "redirect"},
) )
self.assertTrue( self.assertTrue(
UserConsent.objects.filter( UserConsent.objects.filter(

View file

@ -47,7 +47,7 @@ class TestDummyStage(TestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertJSONEqual( self.assertJSONEqual(
force_str(response.content), force_str(response.content),
{"args": {"to": reverse("authentik_core:shell")}, "type": "redirect"}, {"to": reverse("authentik_core:shell"), "type": "redirect"},
) )
def test_form(self): def test_form(self):

View file

@ -126,7 +126,7 @@ class TestEmailStage(TestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertJSONEqual( self.assertJSONEqual(
force_str(response.content), force_str(response.content),
{"args": {"to": reverse("authentik_core:shell")}, "type": "redirect"}, {"to": reverse("authentik_core:shell"), "type": "redirect"},
) )
session = self.client.session session = self.client.session

View file

@ -40,35 +40,3 @@ class IdentificationStageForm(forms.ModelForm):
"name": forms.TextInput(), "name": forms.TextInput(),
"user_fields": ArrayFieldSelectMultiple(choices=UserFields.choices), "user_fields": ArrayFieldSelectMultiple(choices=UserFields.choices),
} }
class IdentificationForm(forms.Form):
"""Allow users to login"""
stage: IdentificationStage
title = _("Log in to your account")
uid_field = forms.CharField(label=_(""))
def __init__(self, *args, **kwargs):
self.stage = kwargs.pop("stage")
super().__init__(*args, **kwargs)
if self.stage.user_fields == [UserFields.E_MAIL]:
self.fields["uid_field"] = forms.EmailField()
label = human_list([x.title() for x in self.stage.user_fields])
self.fields["uid_field"].label = label
self.fields["uid_field"].widget.attrs.update(
{
"placeholder": _(label),
"autofocus": "autofocus",
# Autocomplete according to
# https://www.chromium.org/developers/design-documents/form-styles-that-chromium-understands
"autocomplete": "username",
}
)
def clean_uid_field(self):
"""Validate uid_field after EmailValidator if 'email' is the only selected uid_fields"""
if self.stage.user_fields == [UserFields.E_MAIL]:
validate_email(self.cleaned_data.get("uid_field"))
return self.cleaned_data.get("uid_field")

View file

@ -1,16 +1,18 @@
"""Identification stage logic""" """Identification stage logic"""
from typing import Optional, Union from dataclasses import asdict
from typing import Optional
from django.db.models import Q from django.db.models import Q
from django.db.models.base import Model
from django.http import HttpResponse from django.http import HttpResponse
from django.urls import reverse from django.urls import reverse
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from rest_framework.fields import CharField from rest_framework.fields import CharField
from rest_framework.serializers import ValidationError from rest_framework.serializers import Serializer, ValidationError
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
from authentik.core.api.applications import ApplicationSerializer
from authentik.core.models import Source, User from authentik.core.models import Source, User
from authentik.core.types import UILoginButton
from authentik.flows.challenge import Challenge, ChallengeResponse, ChallengeTypes from authentik.flows.challenge import Challenge, ChallengeResponse, ChallengeTypes
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER
from authentik.flows.stage import ( from authentik.flows.stage import (
@ -23,6 +25,32 @@ from authentik.stages.identification.models import IdentificationStage, UserFiel
LOGGER = get_logger() LOGGER = get_logger()
class UILoginButtonSerializer(Serializer):
"""Serializer for Login buttons of sources"""
name = CharField()
url = CharField()
icon_url = CharField()
def create(self, validated_data: dict) -> Model:
return Model()
def update(self, instance: Model, validated_data: dict) -> Model:
return Model()
class IdentificationChallenge(Challenge):
"""Identification challenges with all UI elements"""
input_type = CharField()
application_pre = ApplicationSerializer(required=False)
enroll_url = CharField(required=False)
recovery_url = CharField(required=False)
primary_action = CharField()
sources = UILoginButtonSerializer(many=True, required=False)
class IdentificationChallengeResponse(ChallengeResponse): class IdentificationChallengeResponse(ChallengeResponse):
"""Identification challenge""" """Identification challenge"""
@ -63,25 +91,33 @@ class IdentificationStageView(ChallengeStageView):
def get_challenge(self) -> Challenge: def get_challenge(self) -> Challenge:
current_stage: IdentificationStage = self.executor.current_stage current_stage: IdentificationStage = self.executor.current_stage
args: dict[str, Union[str, list[UILoginButton]]] = {"input_type": "text"} challenge = IdentificationChallenge(
data={
"type": ChallengeTypes.native,
"component": "ak-stage-identification",
"primary_action": _("Log in"),
"input_type": "text",
}
)
if current_stage.user_fields == [UserFields.E_MAIL]: if current_stage.user_fields == [UserFields.E_MAIL]:
args["input_type"] = "email" challenge.initial_data["input_type"] = "email"
# If the user has been redirected to us whilst trying to access an # If the user has been redirected to us whilst trying to access an
# application, SESSION_KEY_APPLICATION_PRE is set in the session # application, SESSION_KEY_APPLICATION_PRE is set in the session
if SESSION_KEY_APPLICATION_PRE in self.request.session: if SESSION_KEY_APPLICATION_PRE in self.request.session:
args["application_pre"] = self.request.session[SESSION_KEY_APPLICATION_PRE] challenge.initial_data["application_pre"] = self.request.session[
SESSION_KEY_APPLICATION_PRE
]
# Check for related enrollment and recovery flow, add URL to view # Check for related enrollment and recovery flow, add URL to view
if current_stage.enrollment_flow: if current_stage.enrollment_flow:
args["enroll_url"] = reverse( challenge.initial_data["enroll_url"] = reverse(
"authentik_flows:flow-executor-shell", "authentik_flows:flow-executor-shell",
kwargs={"flow_slug": current_stage.enrollment_flow.slug}, kwargs={"flow_slug": current_stage.enrollment_flow.slug},
) )
if current_stage.recovery_flow: if current_stage.recovery_flow:
args["recovery_url"] = reverse( challenge.initial_data["recovery_url"] = reverse(
"authentik_flows:flow-executor-shell", "authentik_flows:flow-executor-shell",
kwargs={"flow_slug": current_stage.recovery_flow.slug}, kwargs={"flow_slug": current_stage.recovery_flow.slug},
) )
args["primary_action"] = _("Log in")
# Check all enabled source, add them if they have a UI Login button. # Check all enabled source, add them if they have a UI Login button.
ui_sources = [] ui_sources = []
@ -91,15 +127,9 @@ class IdentificationStageView(ChallengeStageView):
for source in sources: for source in sources:
ui_login_button = source.ui_login_button ui_login_button = source.ui_login_button
if ui_login_button: if ui_login_button:
ui_sources.append(ui_login_button) ui_sources.append(asdict(ui_login_button))
args["sources"] = ui_sources challenge.initial_data["sources"] = ui_sources
return Challenge( return challenge
data={
"type": ChallengeTypes.native,
"component": "ak-stage-identification",
"args": args,
}
)
def challenge_valid( def challenge_valid(
self, challenge: IdentificationChallengeResponse self, challenge: IdentificationChallengeResponse

View file

@ -57,7 +57,7 @@ class TestIdentificationStage(TestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertJSONEqual( self.assertJSONEqual(
force_str(response.content), force_str(response.content),
{"args": {"to": reverse("authentik_core:shell")}, "type": "redirect"}, {"to": reverse("authentik_core:shell"), "type": "redirect"},
) )
def test_invalid_with_username(self): def test_invalid_with_username(self):
@ -109,10 +109,10 @@ class TestIdentificationStage(TestCase):
{ {
"type": "native", "type": "native",
"component": "ak-stage-identification", "component": "ak-stage-identification",
"args": {
"input_type": "email", "input_type": "email",
"enroll_url": "/flows/unique-enrollment-string/", "enroll_url": "/flows/unique-enrollment-string/",
"primary_action": "Log in", "primary_action": "Log in",
"title": self.flow.title,
"sources": [ "sources": [
{ {
"icon_url": "/static/authentik/sources/.svg", "icon_url": "/static/authentik/sources/.svg",
@ -121,7 +121,6 @@ class TestIdentificationStage(TestCase):
} }
], ],
}, },
},
) )
def test_recovery_flow(self): def test_recovery_flow(self):
@ -149,10 +148,10 @@ class TestIdentificationStage(TestCase):
{ {
"type": "native", "type": "native",
"component": "ak-stage-identification", "component": "ak-stage-identification",
"args": {
"input_type": "email", "input_type": "email",
"recovery_url": "/flows/unique-recovery-string/", "recovery_url": "/flows/unique-recovery-string/",
"primary_action": "Log in", "primary_action": "Log in",
"title": self.flow.title,
"sources": [ "sources": [
{ {
"icon_url": "/static/authentik/sources/.svg", "icon_url": "/static/authentik/sources/.svg",
@ -161,5 +160,4 @@ class TestIdentificationStage(TestCase):
} }
], ],
}, },
},
) )

View file

@ -85,7 +85,7 @@ class TestUserLoginStage(TestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertJSONEqual( self.assertJSONEqual(
force_str(response.content), force_str(response.content),
{"args": {"to": reverse("authentik_core:shell")}, "type": "redirect"}, {"to": reverse("authentik_core:shell"), "type": "redirect"},
) )
self.stage.continue_flow_without_invitation = False self.stage.continue_flow_without_invitation = False
@ -124,5 +124,5 @@ class TestUserLoginStage(TestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertJSONEqual( self.assertJSONEqual(
force_str(response.content), force_str(response.content),
{"args": {"to": reverse("authentik_core:shell")}, "type": "redirect"}, {"to": reverse("authentik_core:shell"), "type": "redirect"},
) )

View file

@ -110,7 +110,7 @@ class TestPasswordStage(TestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertJSONEqual( self.assertJSONEqual(
force_str(response.content), force_str(response.content),
{"args": {"to": reverse("authentik_core:shell")}, "type": "redirect"}, {"to": reverse("authentik_core:shell"), "type": "redirect"},
) )
def test_invalid_password(self): def test_invalid_password(self):

View file

@ -164,7 +164,7 @@ class TestPromptStage(TestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertJSONEqual( self.assertJSONEqual(
force_str(response.content), force_str(response.content),
{"args": {"to": reverse("authentik_core:shell")}, "type": "redirect"}, {"to": reverse("authentik_core:shell"), "type": "redirect"},
) )
# Check that valid data has been saved # Check that valid data has been saved

View file

@ -85,7 +85,7 @@ class TestUserDeleteStage(TestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertJSONEqual( self.assertJSONEqual(
force_str(response.content), force_str(response.content),
{"args": {"to": reverse("authentik_core:shell")}, "type": "redirect"}, {"to": reverse("authentik_core:shell"), "type": "redirect"},
) )
self.assertFalse(User.objects.filter(username=self.username).exists()) self.assertFalse(User.objects.filter(username=self.username).exists())

View file

@ -53,7 +53,7 @@ class TestUserLoginStage(TestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertJSONEqual( self.assertJSONEqual(
force_str(response.content), force_str(response.content),
{"args": {"to": reverse("authentik_core:shell")}, "type": "redirect"}, {"to": reverse("authentik_core:shell"), "type": "redirect"},
) )
@patch( @patch(

View file

@ -49,7 +49,7 @@ class TestUserLogoutStage(TestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertJSONEqual( self.assertJSONEqual(
force_str(response.content), force_str(response.content),
{"args": {"to": reverse("authentik_core:shell")}, "type": "redirect"}, {"to": reverse("authentik_core:shell"), "type": "redirect"},
) )
def test_form(self): def test_form(self):

View file

@ -61,7 +61,7 @@ class TestUserWriteStage(TestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertJSONEqual( self.assertJSONEqual(
force_str(response.content), force_str(response.content),
{"args": {"to": reverse("authentik_core:shell")}, "type": "redirect"}, {"to": reverse("authentik_core:shell"), "type": "redirect"},
) )
user_qs = User.objects.filter( user_qs = User.objects.filter(
username=plan.context[PLAN_CONTEXT_PROMPT]["username"] username=plan.context[PLAN_CONTEXT_PROMPT]["username"]
@ -98,7 +98,7 @@ class TestUserWriteStage(TestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertJSONEqual( self.assertJSONEqual(
force_str(response.content), force_str(response.content),
{"args": {"to": reverse("authentik_core:shell")}, "type": "redirect"}, {"to": reverse("authentik_core:shell"), "type": "redirect"},
) )
user_qs = User.objects.filter( user_qs = User.objects.filter(
username=plan.context[PLAN_CONTEXT_PROMPT]["username"] username=plan.context[PLAN_CONTEXT_PROMPT]["username"]

View file

@ -1,6 +1,36 @@
import { DefaultClient, AKResponse, QueryArguments, BaseInheritanceModel } from "./Client"; import { DefaultClient, AKResponse, QueryArguments, BaseInheritanceModel } from "./Client";
import { TypeCreate } from "./Providers"; import { TypeCreate } from "./Providers";
export enum ChallengeTypes {
native = "native",
response = "response",
shell = "shell",
redirect = "redirect",
}
export interface Error {
code: string;
string: string;
}
export interface ErrorDict {
[key: string]: Error[];
}
export interface Challenge {
type: ChallengeTypes;
component?: string;
title?: string;
response_errors?: ErrorDict;
}
export interface ShellChallenge extends Challenge {
body: string;
}
export interface RedirectChallenge extends Challenge {
to: string;
}
export enum FlowDesignation { export enum FlowDesignation {
Authentication = "authentication", Authentication = "authentication",
Authorization = "authorization", Authorization = "authorization",
@ -44,6 +74,11 @@ export class Flow {
return r.count; return r.count;
}); });
} }
static executor(slug: string): Promise<Challenge> {
return DefaultClient.fetch(["flows", "executor", slug]);
}
static adminUrl(rest: string): string { static adminUrl(rest: string): string {
return `/administration/flows/${rest}`; return `/administration/flows/${rest}`;
} }

View file

@ -1,13 +1,14 @@
import { gettext } from "django"; import { gettext } from "django";
import { CSSResult, customElement, html, property, TemplateResult } from "lit-element"; import { CSSResult, customElement, html, LitElement, property, TemplateResult } from "lit-element";
import { Challenge, Error } from "../../../api/Flows";
import { COMMON_STYLES } from "../../../common/styles"; import { COMMON_STYLES } from "../../../common/styles";
import { BaseStage } from "../base"; import { BaseStage } from "../base";
export interface IdentificationStageArgs { export interface IdentificationChallenge extends Challenge {
input_type: string; input_type: string;
primary_action: string; primary_action: string;
sources: UILoginButton[]; sources?: UILoginButton[];
application_pre?: string; application_pre?: string;
@ -22,11 +23,42 @@ export interface UILoginButton {
icon_url?: string; icon_url?: string;
} }
@customElement("ak-form-element")
export class FormElement extends LitElement {
static get styles(): CSSResult[] {
return COMMON_STYLES;
}
@property()
label?: string;
@property({type: Boolean})
required = false;
@property({attribute: false})
errors?: Error[];
render(): TemplateResult {
return html`<div class="pf-c-form__group">
<label class="pf-c-form__label">
<span class="pf-c-form__label-text">${this.label}</span>
${this.required ? html`<span class="pf-c-form__label-required" aria-hidden="true">*</span>` : html``}
</label>
<slot></slot>
${(this.errors || []).map((error) => {
return html`<p class="pf-c-form__helper-text pf-m-error">${error.string}</p>`;
})}
</div>`;
}
}
@customElement("ak-stage-identification") @customElement("ak-stage-identification")
export class IdentificationStage extends BaseStage { export class IdentificationStage extends BaseStage {
@property({attribute: false}) @property({attribute: false})
args?: IdentificationStageArgs; challenge?: IdentificationChallenge;
static get styles(): CSSResult[] { static get styles(): CSSResult[] {
return COMMON_STYLES; return COMMON_STYLES;
@ -51,58 +83,58 @@ export class IdentificationStage extends BaseStage {
} }
renderFooter(): TemplateResult { renderFooter(): TemplateResult {
if (!(this.args?.enroll_url && this.args.recovery_url)) { if (!(this.challenge?.enroll_url && this.challenge.recovery_url)) {
return html``; return html``;
} }
return html`<div class="pf-c-login__main-footer-band"> return html`<div class="pf-c-login__main-footer-band">
${this.args.enroll_url ? html` ${this.challenge.enroll_url ? html`
<p class="pf-c-login__main-footer-band-item"> <p class="pf-c-login__main-footer-band-item">
${gettext("Need an account?")} ${gettext("Need an account?")}
<a id="enroll" href="${this.args.enroll_url}">${gettext("Sign up.")}</a> <a id="enroll" href="${this.challenge.enroll_url}">${gettext("Sign up.")}</a>
</p>` : html``} </p>` : html``}
${this.args.recovery_url ? html` ${this.challenge.recovery_url ? html`
<p class="pf-c-login__main-footer-band-item"> <p class="pf-c-login__main-footer-band-item">
${gettext("Need an account?")} ${gettext("Need an account?")}
<a id="recovery" href="${this.args.recovery_url}">${gettext("Forgot username or password?")}</a> <a id="recovery" href="${this.challenge.recovery_url}">${gettext("Forgot username or password?")}</a>
</p>` : html``} </p>` : html``}
</div>`; </div>`;
} }
render(): TemplateResult { render(): TemplateResult {
if (!this.args) { if (!this.challenge) {
return html`<ak-loading-state></ak-loading-state>`; return html`<ak-loading-state></ak-loading-state>`;
} }
return html`<header class="pf-c-login__main-header"> return html`<header class="pf-c-login__main-header">
<h1 class="pf-c-title pf-m-3xl"> <h1 class="pf-c-title pf-m-3xl">
${gettext("Log in to your account")} ${this.challenge.title}
</h1> </h1>
</header> </header>
<div class="pf-c-login__main-body"> <div class="pf-c-login__main-body">
<form class="pf-c-form" @submit=${(e) => {this.submit(e);}}> <form class="pf-c-form" @submit=${(e: Event) => {this.submit(e);}}>
${this.args.application_pre ? ${this.challenge.application_pre ?
html`<p> html`<p>
${gettext(`Login to continue to ${this.args.application_pre}.`)} ${gettext(`Login to continue to ${this.challenge.application_pre}.`)}
</p>`: </p>`:
html``} html``}
<div class="pf-c-form__group"> <ak-form-element
<label class="pf-c-form__label"> label="${gettext("Email or Username")}"
<span class="pf-c-form__label-text">${gettext("Email or Username")}</span> ?required="${true}"
<span class="pf-c-form__label-required" aria-hidden="true">*</span> class="pf-c-form__group"
</label> .errors=${(this.challenge?.response_errors || {})["uid_field"]}>
<input type="text" name="uid_field" placeholder="Email or Username" autofocus autocomplete="username" class="pf-c-form-control" required=""> <input type="text" name="uid_field" placeholder="Email or Username" autofocus autocomplete="username" class="pf-c-form-control" required="">
</div> </ak-form-element>
<div class="pf-c-form__group pf-m-action"> <div class="pf-c-form__group pf-m-action">
<button type="submit" class="pf-c-button pf-m-primary pf-m-block"> <button type="submit" class="pf-c-button pf-m-primary pf-m-block">
${this.args.primary_action} ${this.challenge.primary_action}
</button> </button>
</div> </div>
</form> </form>
</div> </div>
<footer class="pf-c-login__main-footer"> <footer class="pf-c-login__main-footer">
<ul class="pf-c-login__main-footer-links"> <ul class="pf-c-login__main-footer-links">
${this.args.sources.map((source) => { ${(this.challenge.sources || []).map((source) => {
return this.renderSource(source); return this.renderSource(source);
})} })}
</ul> </ul>

View file

@ -1,32 +1,19 @@
import { gettext } from "django"; import { gettext } from "django";
import { LitElement, html, customElement, property, TemplateResult } from "lit-element"; import { LitElement, html, customElement, property, TemplateResult } from "lit-element";
import { unsafeHTML } from "lit-html/directives/unsafe-html"; import { unsafeHTML } from "lit-html/directives/unsafe-html";
import { SentryIgnoredError } from "../../common/errors";
import { getCookie } from "../../utils"; import { getCookie } from "../../utils";
import "../../elements/stages/identification/IdentificationStage"; import "../../elements/stages/identification/IdentificationStage";
import { ShellChallenge, Challenge, ChallengeTypes, Flow, RedirectChallenge } from "../../api/Flows";
enum ChallengeTypes { import { DefaultClient } from "../../api/Client";
native = "native", import { IdentificationChallenge } from "../../elements/stages/identification/IdentificationStage";
response = "response",
shell = "shell",
redirect = "redirect",
}
interface Challenge {
type: ChallengeTypes;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
args: any;
component?: string;
title?: string;
}
@customElement("ak-flow-executor") @customElement("ak-flow-executor")
export class FlowExecutor extends LitElement { export class FlowExecutor extends LitElement {
@property() @property()
flowBodyUrl = ""; flowSlug = "";
@property({attribute: false}) @property({attribute: false})
flowBody?: TemplateResult; challenge?: Challenge;
createRenderRoot(): Element | ShadowRoot { createRenderRoot(): Element | ShadowRoot {
return this; return this;
@ -41,7 +28,7 @@ export class FlowExecutor extends LitElement {
submit(formData?: FormData): void { submit(formData?: FormData): void {
const csrftoken = getCookie("authentik_csrf"); const csrftoken = getCookie("authentik_csrf");
const request = new Request(this.flowBodyUrl, { const request = new Request(DefaultClient.makeUrl(["flows", "executor", this.flowSlug]), {
headers: { headers: {
"X-CSRFToken": csrftoken, "X-CSRFToken": csrftoken,
}, },
@ -55,7 +42,7 @@ export class FlowExecutor extends LitElement {
return response.json(); return response.json();
}) })
.then((data) => { .then((data) => {
this.updateCard(data); this.challenge = data;
}) })
.catch((e) => { .catch((e) => {
this.errorMessage(e); this.errorMessage(e);
@ -63,112 +50,18 @@ export class FlowExecutor extends LitElement {
} }
firstUpdated(): void { firstUpdated(): void {
fetch(this.flowBodyUrl) Flow.executor(this.flowSlug).then((challenge) => {
.then((r) => { this.challenge = challenge;
if (r.status === 404) { }).catch((e) => {
// Fallback when the flow does not exist, just redirect to the root
window.location.pathname = "/";
} else if (!r.ok) {
throw new SentryIgnoredError(r.statusText);
}
return r;
})
.then((r) => {
return r.json();
})
.then((r) => {
this.updateCard(r);
})
.catch((e) => {
// Catch JSON or Update errors // Catch JSON or Update errors
this.errorMessage(e); this.errorMessage(e);
}); });
} }
async updateCard(data: Challenge): Promise<void> {
switch (data.type) {
case ChallengeTypes.redirect:
console.debug(`authentik/flows: redirecting to ${data.args.to}`);
window.location.assign(data.args.to || "");
break;
case ChallengeTypes.shell:
this.flowBody = html`${unsafeHTML(data.args.body)}`;
await this.requestUpdate();
this.checkAutofocus();
this.loadFormCode();
this.setFormSubmitHandlers();
break;
case ChallengeTypes.native:
switch (data.component) {
case "ak-stage-identification":
this.flowBody = html`<ak-stage-identification .host=${this} .args=${data.args}></ak-stage-identification>`;
break;
default:
break;
}
break;
default:
console.debug(`authentik/flows: unexpected data type ${data.type}`);
break;
}
}
loadFormCode(): void {
this.querySelectorAll("script").forEach((script) => {
const newScript = document.createElement("script");
newScript.src = script.src;
document.head.appendChild(newScript);
});
}
checkAutofocus(): void {
const autofocusElement = <HTMLElement>this.querySelector("[autofocus]");
if (autofocusElement !== null) {
autofocusElement.focus();
}
}
updateFormAction(form: HTMLFormElement): boolean {
for (let index = 0; index < form.elements.length; index++) {
const element = <HTMLInputElement>form.elements[index];
if (element.value === form.action) {
console.debug(
"authentik/flows: Found Form action URL in form elements, not changing form action."
);
return false;
}
}
form.action = this.flowBodyUrl;
console.debug(`authentik/flows: updated form.action ${this.flowBodyUrl}`);
return true;
}
checkAutosubmit(form: HTMLFormElement): void {
if ("autosubmit" in form.attributes) {
return form.submit();
}
}
setFormSubmitHandlers(): void {
this.querySelectorAll("form").forEach((form) => {
console.debug(`authentik/flows: Checking for autosubmit attribute ${form}`);
this.checkAutosubmit(form);
console.debug(`authentik/flows: Setting action for form ${form}`);
this.updateFormAction(form);
console.debug(`authentik/flows: Adding handler for form ${form}`);
form.addEventListener("submit", (e) => {
e.preventDefault();
const formData = new FormData(form);
this.flowBody = undefined;
this.submit(formData);
});
form.classList.add("ak-flow-wrapped");
});
}
errorMessage(error: string): void { errorMessage(error: string): void {
this.flowBody = html` this.challenge = <ShellChallenge>{
<style> type: ChallengeTypes.shell,
body: `<style>
.ak-exception { .ak-exception {
font-family: monospace; font-family: monospace;
overflow-x: scroll; overflow-x: scroll;
@ -182,7 +75,8 @@ export class FlowExecutor extends LitElement {
<div class="pf-c-login__main-body"> <div class="pf-c-login__main-body">
<h3>${gettext("Something went wrong! Please try again later.")}</h3> <h3>${gettext("Something went wrong! Please try again later.")}</h3>
<pre class="ak-exception">${error}</pre> <pre class="ak-exception">${error}</pre>
</div>`; </div>`
};
} }
loading(): TemplateResult { loading(): TemplateResult {
@ -196,9 +90,28 @@ export class FlowExecutor extends LitElement {
} }
render(): TemplateResult { render(): TemplateResult {
if (this.flowBody) { if (!this.challenge) {
return this.flowBody;
}
return this.loading(); return this.loading();
} }
switch(this.challenge.type) {
case ChallengeTypes.redirect:
console.debug(`authentik/flows: redirecting to ${(this.challenge as RedirectChallenge).to}`);
window.location.assign((this.challenge as RedirectChallenge).to);
break;
case ChallengeTypes.shell:
return html`${unsafeHTML((this.challenge as ShellChallenge).body)}`;
case ChallengeTypes.native:
switch (this.challenge.component) {
case "ak-stage-identification":
return html`<ak-stage-identification .host=${this} .challenge=${this.challenge as IdentificationChallenge}></ak-stage-identification>`;
default:
break;
}
break;
default:
console.debug(`authentik/flows: unexpected data type ${this.challenge.type}`);
break;
}
return html``;
}
} }