*: cleanup code, return errors in challenge_invalid, fixup rendering
This commit is contained in:
parent
548b1ead2f
commit
511f94fc7f
|
@ -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 }}">
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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 %}
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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},
|
||||||
),
|
),
|
||||||
|
|
|
@ -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}
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
|
@ -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"},
|
||||||
)
|
)
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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")
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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):
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
|
@ -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"},
|
||||||
)
|
)
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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())
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -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"]
|
||||||
|
|
|
@ -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}`;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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``;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
Reference in a new issue