flows: mount executor under api, implement initial challenge design

This commit is contained in:
Jens Langhammer 2021-02-17 23:52:49 +01:00
parent 8708e487ae
commit eb01b42425
33 changed files with 482 additions and 218 deletions

View File

@ -29,6 +29,7 @@ from authentik.flows.api import (
FlowViewSet,
StageViewSet,
)
from authentik.flows.views import FlowExecutorView
from authentik.outposts.api.outpost_service_connections import (
DockerServiceConnectionViewSet,
KubernetesServiceConnectionViewSet,
@ -184,4 +185,9 @@ urlpatterns = [
name="schema-swagger-ui",
),
path("redoc/", SchemaView.with_ui("redoc", cache_timeout=0), name="schema-redoc"),
path(
"flows/executor/<slug:flow_slug>/",
FlowExecutorView.as_view(),
name="flow-executor",
),
] + router.urls

View File

@ -16,7 +16,6 @@
<link rel="stylesheet" type="text/css" href="{% static 'dist/fontawesome.min.css' %}?v={{ ak_version }}">
<link rel="stylesheet" type="text/css" href="{% static 'dist/authentik.css' %}?v={{ ak_version }}">
<script src="{% url 'javascript-catalog' %}?v={{ ak_version }}"></script>
<script src="{% static 'dist/main.js' %}?v={{ ak_version }}" type="module"></script>
{% block head %}
{% endblock %}
</head>

View File

@ -1,5 +1,9 @@
{% extends "base/skeleton.html" %}
{% block head %}
<script src="{% static 'dist/main.js' %}?v={{ ak_version }}" type="module"></script>
{% endblock %}
{% block body %}
<ak-interface-admin></ak-interface-admin>
{% endblock %}

View File

@ -0,0 +1,37 @@
from enum import Enum
from json.encoder import JSONEncoder
from django.http import JsonResponse
from rest_framework.fields import ChoiceField, DictField, JSONField
from rest_framework.serializers import CharField, Serializer
class ChallengeTypes(Enum):
native = "native"
shell = "shell"
redirect = "redirect"
class Challenge(Serializer):
type = ChoiceField(choices=list(ChallengeTypes))
component = CharField(required=False)
args = JSONField()
class ChallengeResponse(Serializer):
pass
class ChallengeEncoder(JSONEncoder):
def default(self, obj):
if isinstance(obj, Enum):
return obj.value
return super().default(obj)
class HttpChallengeResponse(JsonResponse):
def __init__(self, challenge: Challenge, **kwargs) -> None:
super().__init__(challenge.data, encoder=ChallengeEncoder, **kwargs)

View File

@ -3,9 +3,15 @@ from collections import namedtuple
from typing import Any, Dict
from django.http import HttpRequest
from django.http.response import HttpResponse, JsonResponse
from django.utils.translation import gettext_lazy as _
from django.views.generic import TemplateView
from authentik.flows.challenge import (
Challenge,
ChallengeResponse,
HttpChallengeResponse,
)
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER
from authentik.flows.views import FlowExecutorView
@ -43,3 +49,28 @@ class StageView(TemplateView):
)
kwargs["primary_action"] = _("Continue")
return super().get_context_data(**kwargs)
class ChallengeStageView(StageView):
response_class = ChallengeResponse
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
challenge = self.get_challenge()
challenge.is_valid()
return HttpChallengeResponse(challenge)
def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
challenge = self.response_class(data=request.POST)
if not challenge.is_valid():
return self.challenge_invalid(challenge)
return self.challenge_valid(challenge)
def get_challenge(self) -> Challenge:
raise NotImplementedError
def challenge_valid(self, challenge: ChallengeResponse) -> HttpResponse:
raise NotImplementedError
def challenge_invalid(self, challenge: ChallengeResponse) -> HttpResponse:
return JsonResponse(challenge.errors)

View File

@ -22,11 +22,10 @@
background-position: center;
}
</style>
<script src="{% static 'dist/flow.js' %}?v={{ ak_version }}" type="module"></script>
{% endblock %}
{% block main_container %}
<ak-flow-shell-card
class="pf-c-login__main"
flowBodyUrl="{{ exec_url }}">
</ak-flow-shell-card>
<ak-flow-executor class="pf-c-login__main" flowBodyUrl="{{ exec_url }}">
</ak-flow-executor>
{% endblock %}

View File

@ -43,7 +43,7 @@ class TestFlowPlanner(TestCase):
designation=FlowDesignation.AUTHENTICATION,
)
request = self.request_factory.get(
reverse("authentik_flows:flow-executor", kwargs={"flow_slug": flow.slug}),
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
)
request.user = get_anonymous_user()
@ -63,7 +63,7 @@ class TestFlowPlanner(TestCase):
designation=FlowDesignation.AUTHENTICATION,
)
request = self.request_factory.get(
reverse("authentik_flows:flow-executor", kwargs={"flow_slug": flow.slug}),
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
)
request.user = get_anonymous_user()
@ -83,7 +83,7 @@ class TestFlowPlanner(TestCase):
target=flow, stage=DummyStage.objects.create(name="dummy"), order=0
)
request = self.request_factory.get(
reverse("authentik_flows:flow-executor", kwargs={"flow_slug": flow.slug}),
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
)
request.user = get_anonymous_user()
@ -112,7 +112,7 @@ class TestFlowPlanner(TestCase):
user = User.objects.create(username="test-user")
request = self.request_factory.get(
reverse("authentik_flows:flow-executor", kwargs={"flow_slug": flow.slug}),
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
)
request.user = user
planner = FlowPlanner(flow)
@ -136,7 +136,7 @@ class TestFlowPlanner(TestCase):
)
request = self.request_factory.get(
reverse("authentik_flows:flow-executor", kwargs={"flow_slug": flow.slug}),
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
)
request.user = get_anonymous_user()
@ -167,7 +167,7 @@ class TestFlowPlanner(TestCase):
PolicyBinding.objects.create(policy=false_policy, target=binding2, order=0)
request = self.request_factory.get(
reverse("authentik_flows:flow-executor", kwargs={"flow_slug": flow.slug}),
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
)
request.user = get_anonymous_user()

View File

@ -62,9 +62,7 @@ class TestFlowExecutor(TestCase):
cancel_mock = MagicMock()
with patch("authentik.flows.views.FlowExecutorView.cancel", cancel_mock):
response = self.client.get(
reverse(
"authentik_flows:flow-executor", kwargs={"flow_slug": flow.slug}
),
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
)
self.assertEqual(response.status_code, 302)
self.assertEqual(cancel_mock.call_count, 2)
@ -87,7 +85,7 @@ class TestFlowExecutor(TestCase):
CONFIG.update_from_dict({"domain": "testserver"})
response = self.client.get(
reverse("authentik_flows:flow-executor", kwargs={"flow_slug": flow.slug}),
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
)
self.assertEqual(response.status_code, 200)
self.assertIsInstance(response, AccessDeniedResponse)
@ -107,7 +105,7 @@ class TestFlowExecutor(TestCase):
CONFIG.update_from_dict({"domain": "testserver"})
response = self.client.get(
reverse("authentik_flows:flow-executor", kwargs={"flow_slug": flow.slug}),
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, reverse("authentik_core:shell"))
@ -126,7 +124,7 @@ class TestFlowExecutor(TestCase):
CONFIG.update_from_dict({"domain": "testserver"})
dest = "/unique-string"
url = reverse("authentik_flows:flow-executor", kwargs={"flow_slug": flow.slug})
url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug})
response = self.client.get(url + f"?{NEXT_ARG_NAME}={dest}")
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, reverse("authentik_core:shell"))
@ -146,7 +144,7 @@ class TestFlowExecutor(TestCase):
)
exec_url = reverse(
"authentik_flows:flow-executor", kwargs={"flow_slug": flow.slug}
"authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}
)
# First Request, start planning, renders form
response = self.client.get(exec_url)
@ -196,7 +194,7 @@ class TestFlowExecutor(TestCase):
):
exec_url = reverse(
"authentik_flows:flow-executor", kwargs={"flow_slug": flow.slug}
"authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}
)
# First request, run the planner
response = self.client.get(exec_url)
@ -250,7 +248,7 @@ class TestFlowExecutor(TestCase):
):
exec_url = reverse(
"authentik_flows:flow-executor", kwargs={"flow_slug": flow.slug}
"authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}
)
# First request, run the planner
response = self.client.get(exec_url)
@ -317,7 +315,7 @@ class TestFlowExecutor(TestCase):
):
exec_url = reverse(
"authentik_flows:flow-executor", kwargs={"flow_slug": flow.slug}
"authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}
)
# First request, run the planner
response = self.client.get(exec_url)
@ -401,7 +399,7 @@ class TestFlowExecutor(TestCase):
):
exec_url = reverse(
"authentik_flows:flow-executor", kwargs={"flow_slug": flow.slug}
"authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}
)
# First request, run the planner
response = self.client.get(exec_url)
@ -455,7 +453,7 @@ class TestFlowExecutor(TestCase):
user = User.objects.create(username="test-user")
request = self.request_factory.get(
reverse("authentik_flows:flow-executor", kwargs={"flow_slug": flow.slug}),
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
)
request.user = user
planner = FlowPlanner(flow)

View File

@ -19,6 +19,7 @@ from structlog.stdlib import BoundLogger, get_logger
from authentik.core.models import USER_ATTRIBUTE_DEBUG
from authentik.events.models import cleanse_dict
from authentik.flows.challenge import Challenge, ChallengeTypes, HttpChallengeResponse
from authentik.flows.exceptions import EmptyFlowException, FlowNonApplicableException
from authentik.flows.models import ConfigurableStage, Flow, FlowDesignation, Stage
from authentik.flows.planner import (
@ -176,7 +177,7 @@ class FlowExecutorView(View):
reamining=len(self.plan.stages),
)
return redirect_with_qs(
"authentik_flows:flow-executor", self.request.GET, **self.kwargs
"authentik_api:flow-executor", self.request.GET, **self.kwargs
)
# User passed all stages
self._logger.debug(
@ -246,9 +247,7 @@ class FlowExecutorShellView(TemplateView):
def get_context_data(self, **kwargs) -> Dict[str, Any]:
flow: Flow = get_object_or_404(Flow, slug=self.kwargs.get("flow_slug"))
kwargs["background_url"] = flow.background.url
kwargs["exec_url"] = reverse(
"authentik_flows:flow-executor", kwargs=self.kwargs
)
kwargs["exec_url"] = reverse("authentik_api:flow-executor", kwargs=self.kwargs)
self.request.session[SESSION_KEY_GET] = self.request.GET
return kwargs
@ -292,17 +291,38 @@ def to_stage_response(request: HttpRequest, source: HttpResponse) -> HttpRespons
if isinstance(source, HttpResponseRedirect) or source.status_code == 302:
redirect_url = source["Location"]
if request.path != redirect_url:
return JsonResponse({"type": "redirect", "to": redirect_url})
return HttpChallengeResponse(
Challenge(
{"type": ChallengeTypes.redirect, "args": {"to": redirect_url}}
)
)
# return JsonResponse({"type": "redirect", "to": redirect_url})
return source
if isinstance(source, TemplateResponse):
return JsonResponse(
{"type": "template", "body": source.render().content.decode("utf-8")}
return HttpChallengeResponse(
Challenge(
{
"type": ChallengeTypes.shell,
"args": {"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)
if source.__class__ == HttpResponse:
return JsonResponse(
{"type": "template", "body": source.content.decode("utf-8")}
return HttpChallengeResponse(
Challenge(
{
"type": ChallengeTypes.shell,
"args": {"body": source.content.decode("utf-8")},
}
)
)
# return JsonResponse(
# {"type": "template", "body": }
# )
return source

View File

@ -4,28 +4,37 @@ from typing import Any, Dict
from django.http import HttpRequest, HttpResponse
from django.views.generic import FormView
from django_otp import user_has_device
from rest_framework.fields import IntegerField
from structlog.stdlib import get_logger
from authentik.flows.challenge import Challenge, ChallengeResponse, ChallengeTypes
from authentik.flows.models import NotConfiguredAction
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER
from authentik.flows.stage import StageView
from authentik.flows.stage import ChallengeStageView, StageView
from authentik.stages.authenticator_validate.forms import ValidationForm
from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage
LOGGER = get_logger()
class AuthenticatorValidateStageView(FormView, StageView):
class CodeChallengeResponse(ChallengeResponse):
code = IntegerField(min_value=0)
class WebAuthnChallengeResponse(ChallengeResponse):
pass
class AuthenticatorValidateStageView(ChallengeStageView):
"""OTP Validation"""
form_class = ValidationForm
def get_form_kwargs(self, **kwargs) -> Dict[str, Any]:
kwargs = super().get_form_kwargs(**kwargs)
kwargs["user"] = self.executor.plan.context.get(PLAN_CONTEXT_PENDING_USER)
return kwargs
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
"""Check if a user is set, and check if the user has any devices
if not, we can skip this entire stage"""
user = self.executor.plan.context.get(PLAN_CONTEXT_PENDING_USER)
if not user:
LOGGER.debug("No pending user, continuing")
@ -39,8 +48,27 @@ class AuthenticatorValidateStageView(FormView, StageView):
return self.executor.stage_ok()
return super().get(request, *args, **kwargs)
def form_valid(self, form: ValidationForm) -> HttpResponse:
"""Verify OTP Token"""
# Since we do token checking in the form, we know the token is valid here
# so we can just continue
return self.executor.stage_ok()
# def get_form_kwargs(self, **kwargs) -> Dict[str, Any]:
# kwargs = super().get_form_kwargs(**kwargs)
# kwargs["user"] = self.executor.plan.context.get(PLAN_CONTEXT_PENDING_USER)
# return kwargs
def get_challenge(self) -> Challenge:
return Challenge(
{
"type": ChallengeTypes.native,
# TODO: use component based on devices
"component": "ak-stage-authenticator-validate",
"args": {"user": "foo.bar.baz"},
}
)
def post_challenge(self, challenge: Challenge) -> HttpResponse:
print(challenge)
return super().post_challenge(challenge)
# def form_valid(self, form: ValidationForm) -> HttpResponse:
# """Verify OTP Token"""
# # Since we do token checking in the form, we know the token is valid here
# # so we can just continue
# return self.executor.stage_ok()

View File

@ -44,7 +44,7 @@ class TestCaptchaStage(TestCase):
session.save()
response = self.client.post(
reverse(
"authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
"authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}
),
{"g-recaptcha-response": "PASSED"},
)

View File

@ -45,7 +45,7 @@ class TestConsentStage(TestCase):
session[SESSION_KEY_PLAN] = plan
session.save()
response = self.client.post(
reverse("authentik_flows:flow-executor", kwargs={"flow_slug": flow.slug}),
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
{},
)
self.assertEqual(response.status_code, 200)
@ -76,7 +76,7 @@ class TestConsentStage(TestCase):
session[SESSION_KEY_PLAN] = plan
session.save()
response = self.client.post(
reverse("authentik_flows:flow-executor", kwargs={"flow_slug": flow.slug}),
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
{},
)
self.assertEqual(response.status_code, 200)
@ -113,7 +113,7 @@ class TestConsentStage(TestCase):
session[SESSION_KEY_PLAN] = plan
session.save()
response = self.client.post(
reverse("authentik_flows:flow-executor", kwargs={"flow_slug": flow.slug}),
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
{},
)
self.assertEqual(response.status_code, 200)

View File

@ -34,16 +34,14 @@ class TestDummyStage(TestCase):
def test_valid_render(self):
"""Test that View renders correctly"""
response = self.client.get(
reverse(
"authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
)
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
)
self.assertEqual(response.status_code, 200)
def test_post(self):
"""Test with valid email, check that URL redirects back to itself"""
url = reverse(
"authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
"authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}
)
response = self.client.post(url, {})
self.assertEqual(response.status_code, 200)

View File

@ -46,7 +46,7 @@ class TestEmailStageSending(TestCase):
session.save()
url = reverse(
"authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
"authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}
)
with self.settings(
EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend"
@ -67,7 +67,7 @@ class TestEmailStageSending(TestCase):
session.save()
url = reverse(
"authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
"authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}
)
with self.settings(
EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend"

View File

@ -46,7 +46,7 @@ class TestEmailStage(TestCase):
session.save()
url = reverse(
"authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
"authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}
)
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
@ -61,7 +61,7 @@ class TestEmailStage(TestCase):
session.save()
url = reverse(
"authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
"authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}
)
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
@ -77,7 +77,7 @@ class TestEmailStage(TestCase):
session.save()
url = reverse(
"authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
"authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}
)
with self.settings(
EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend"
@ -118,7 +118,7 @@ class TestEmailStage(TestCase):
# Call the actual executor to get the JSON Response
response = self.client.get(
reverse(
"authentik_flows:flow-executor",
"authentik_api:flow-executor",
kwargs={"flow_slug": self.flow.slug},
)
)

View File

@ -7,64 +7,33 @@ from django.http import HttpResponse
from django.shortcuts import reverse
from django.utils.translation import gettext as _
from django.views.generic import FormView
from rest_framework.fields import CharField
from structlog.stdlib import get_logger
from authentik.core.models import Source, User
from authentik.core.types import UILoginButton
from authentik.flows.challenge import Challenge, ChallengeResponse, ChallengeTypes
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER
from authentik.flows.stage import PLAN_CONTEXT_PENDING_USER_IDENTIFIER, StageView
from authentik.flows.stage import (
PLAN_CONTEXT_PENDING_USER_IDENTIFIER,
ChallengeStageView,
StageView,
)
from authentik.flows.views import SESSION_KEY_APPLICATION_PRE
from authentik.stages.identification.forms import IdentificationForm
from authentik.stages.identification.models import IdentificationStage
from authentik.stages.identification.models import IdentificationStage, UserFields
LOGGER = get_logger()
class IdentificationStageView(FormView, StageView):
class IdentificationChallengeResponse(ChallengeResponse):
uid_field = CharField()
class IdentificationStageView(ChallengeStageView):
"""Form to identify the user"""
form_class = IdentificationForm
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs["stage"] = self.executor.current_stage
return kwargs
def get_template_names(self) -> List[str]:
current_stage: IdentificationStage = self.executor.current_stage
return [current_stage.template]
def get_context_data(self, **kwargs):
current_stage: IdentificationStage = self.executor.current_stage
# If the user has been redirected to us whilst trying to access an
# application, SESSION_KEY_APPLICATION_PRE is set in the session
if SESSION_KEY_APPLICATION_PRE in self.request.session:
kwargs["application_pre"] = self.request.session[
SESSION_KEY_APPLICATION_PRE
]
# Check for related enrollment and recovery flow, add URL to view
if current_stage.enrollment_flow:
kwargs["enroll_url"] = reverse(
"authentik_flows:flow-executor-shell",
kwargs={"flow_slug": current_stage.enrollment_flow.slug},
)
if current_stage.recovery_flow:
kwargs["recovery_url"] = reverse(
"authentik_flows:flow-executor-shell",
kwargs={"flow_slug": current_stage.recovery_flow.slug},
)
kwargs["primary_action"] = _("Log in")
# Check all enabled source, add them if they have a UI Login button.
kwargs["sources"] = []
sources: List[Source] = (
Source.objects.filter(enabled=True).order_by("name").select_subclasses()
)
for source in sources:
ui_login_button = source.ui_login_button
if ui_login_button:
kwargs["sources"].append(ui_login_button)
return super().get_context_data(**kwargs)
def get_user(self, uid_value: str) -> Optional[User]:
"""Find user instance. Returns None if no user was found."""
current_stage: IdentificationStage = self.executor.current_stage
@ -82,14 +51,54 @@ class IdentificationStageView(FormView, StageView):
return users.first()
return None
def form_valid(self, form: IdentificationForm) -> HttpResponse:
"""Form data is valid"""
user_identifier = form.cleaned_data.get("uid_field")
def get_challenge(self) -> Challenge:
current_stage: IdentificationStage = self.executor.current_stage
args = {"input_type": "text"}
if current_stage.user_fields == [UserFields.E_MAIL]:
args["input_type"] = "email"
# If the user has been redirected to us whilst trying to access an
# application, SESSION_KEY_APPLICATION_PRE is set in the session
if SESSION_KEY_APPLICATION_PRE in self.request.session:
args["application_pre"] = self.request.session[SESSION_KEY_APPLICATION_PRE]
# Check for related enrollment and recovery flow, add URL to view
if current_stage.enrollment_flow:
args["enroll_url"] = reverse(
"authentik_flows:flow-executor-shell",
args={"flow_slug": current_stage.enrollment_flow.slug},
)
if current_stage.recovery_flow:
args["recovery_url"] = reverse(
"authentik_flows:flow-executor-shell",
args={"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.
args["sources"] = []
sources: List[Source] = (
Source.objects.filter(enabled=True).order_by("name").select_subclasses()
)
for source in sources:
ui_login_button = source.ui_login_button
if ui_login_button:
args["sources"].append(ui_login_button)
return Challenge(
data={
"type": ChallengeTypes.native,
"component": "ak-stage-identification",
"args": args,
}
)
def challenge_valid(
self, challenge: IdentificationChallengeResponse
) -> HttpResponse:
user_identifier = challenge.data.get("uid_field")
pre_user = self.get_user(user_identifier)
if not pre_user:
LOGGER.debug("invalid_login")
messages.error(self.request, _("Failed to authenticate."))
return self.form_invalid(form)
return self.challenge_invalid(challenge)
self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] = pre_user
current_stage: IdentificationStage = self.executor.current_stage

View File

@ -43,9 +43,7 @@ class TestIdentificationStage(TestCase):
def test_valid_render(self):
"""Test that View renders correctly"""
response = self.client.get(
reverse(
"authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
)
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
)
self.assertEqual(response.status_code, 200)
@ -53,7 +51,7 @@ class TestIdentificationStage(TestCase):
"""Test with valid email, check that URL redirects back to itself"""
form_data = {"uid_field": self.user.email}
url = reverse(
"authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
"authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}
)
response = self.client.post(url, form_data)
self.assertEqual(response.status_code, 200)
@ -67,7 +65,7 @@ class TestIdentificationStage(TestCase):
form_data = {"uid_field": self.user.username}
response = self.client.post(
reverse(
"authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
"authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}
),
form_data,
)
@ -78,7 +76,7 @@ class TestIdentificationStage(TestCase):
form_data = {"uid_field": self.user.email + "test"}
response = self.client.post(
reverse(
"authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
"authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}
),
form_data,
)
@ -101,7 +99,7 @@ class TestIdentificationStage(TestCase):
response = self.client.get(
reverse(
"authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
"authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}
),
)
self.assertEqual(response.status_code, 200)
@ -124,7 +122,7 @@ class TestIdentificationStage(TestCase):
response = self.client.get(
reverse(
"authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
"authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}
),
)
self.assertEqual(response.status_code, 200)

View File

@ -58,9 +58,7 @@ class TestUserLoginStage(TestCase):
session.save()
response = self.client.get(
reverse(
"authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
)
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
)
self.assertEqual(response.status_code, 200)
self.assertIsInstance(response, AccessDeniedResponse)
@ -81,9 +79,7 @@ class TestUserLoginStage(TestCase):
session.save()
response = self.client.get(
reverse(
"authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
)
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
)
self.assertEqual(response.status_code, 200)
@ -115,7 +111,7 @@ class TestUserLoginStage(TestCase):
with patch("authentik.flows.views.FlowExecutorView.cancel", MagicMock()):
base_url = reverse(
"authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
"authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}
)
response = self.client.get(
base_url + f"?{INVITATION_TOKEN_KEY}={invite.pk.hex}"

View File

@ -5,6 +5,6 @@
{% block beneath_form %}
{% if recovery_flow %}
<a href="{% url 'authentik_flows:flow-executor' flow_slug=recovery_flow.slug %}">{% trans 'Forgot password?' %}</a>
<a href="{% url 'authentik_api:flow-executor' flow_slug=recovery_flow.slug %}">{% trans 'Forgot password?' %}</a>
{% endif %}
{% endblock %}

View File

@ -59,7 +59,7 @@ class TestPasswordStage(TestCase):
response = self.client.post(
reverse(
"authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
"authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}
),
# Still have to send the password so the form is valid
{"password": self.password},
@ -83,7 +83,7 @@ class TestPasswordStage(TestCase):
response = self.client.get(
reverse(
"authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
"authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}
),
)
self.assertEqual(response.status_code, 200)
@ -101,7 +101,7 @@ class TestPasswordStage(TestCase):
response = self.client.post(
reverse(
"authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
"authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}
),
# Form data
{"password": self.password},
@ -125,7 +125,7 @@ class TestPasswordStage(TestCase):
response = self.client.post(
reverse(
"authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
"authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}
),
# Form data
{"password": self.password + "test"},
@ -145,7 +145,7 @@ class TestPasswordStage(TestCase):
for _ in range(self.stage.failed_attempts_before_cancel):
response = self.client.post(
reverse(
"authentik_flows:flow-executor",
"authentik_api:flow-executor",
kwargs={"flow_slug": self.flow.slug},
),
# Form data
@ -155,7 +155,7 @@ class TestPasswordStage(TestCase):
response = self.client.post(
reverse(
"authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
"authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}
),
# Form data
{"password": self.password + "test"},
@ -185,7 +185,7 @@ class TestPasswordStage(TestCase):
response = self.client.post(
reverse(
"authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
"authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}
),
# Form data
{"password": self.password + "test"},

View File

@ -104,9 +104,7 @@ class TestPromptStage(TestCase):
session.save()
response = self.client.get(
reverse(
"authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
)
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
)
self.assertEqual(response.status_code, 200)
for prompt in self.stage.fields.all():
@ -158,7 +156,7 @@ class TestPromptStage(TestCase):
with patch("authentik.flows.views.FlowExecutorView.cancel", MagicMock()):
response = self.client.post(
reverse(
"authentik_flows:flow-executor",
"authentik_api:flow-executor",
kwargs={"flow_slug": self.flow.slug},
),
form.cleaned_data,

View File

@ -46,9 +46,7 @@ class TestUserDeleteStage(TestCase):
session.save()
response = self.client.get(
reverse(
"authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
)
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
)
self.assertEqual(response.status_code, 200)
self.assertIsInstance(response, AccessDeniedResponse)
@ -64,9 +62,7 @@ class TestUserDeleteStage(TestCase):
session.save()
response = self.client.get(
reverse(
"authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
)
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
)
self.assertEqual(response.status_code, 200)
@ -82,7 +78,7 @@ class TestUserDeleteStage(TestCase):
response = self.client.post(
reverse(
"authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
"authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}
),
{},
)

View File

@ -47,9 +47,7 @@ class TestUserLoginStage(TestCase):
session.save()
response = self.client.get(
reverse(
"authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
)
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
)
self.assertEqual(response.status_code, 200)
@ -72,9 +70,7 @@ class TestUserLoginStage(TestCase):
session.save()
response = self.client.get(
reverse(
"authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
)
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
)
self.assertEqual(response.status_code, 200)
@ -95,9 +91,7 @@ class TestUserLoginStage(TestCase):
session.save()
response = self.client.get(
reverse(
"authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
)
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
)
self.assertEqual(response.status_code, 200)

View File

@ -43,9 +43,7 @@ class TestUserLogoutStage(TestCase):
session.save()
response = self.client.get(
reverse(
"authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
)
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
)
self.assertEqual(response.status_code, 200)

View File

@ -55,9 +55,7 @@ class TestUserWriteStage(TestCase):
session.save()
response = self.client.get(
reverse(
"authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
)
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
)
self.assertEqual(response.status_code, 200)
@ -94,9 +92,7 @@ class TestUserWriteStage(TestCase):
session.save()
response = self.client.get(
reverse(
"authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
)
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
)
self.assertEqual(response.status_code, 200)
@ -126,9 +122,7 @@ class TestUserWriteStage(TestCase):
session.save()
response = self.client.get(
reverse(
"authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
)
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
)
self.assertEqual(response.status_code, 200)

View File

@ -48,4 +48,33 @@ export default [
},
external: ["django"]
},
{
input: "./src/flow.ts",
output: [
{
format: "es",
dir: "dist",
sourcemap: true,
},
],
plugins: [
cssimport(),
typescript(),
externalGlobals({
django: "django"
}),
resolve({ browser: true }),
commonjs(),
sourcemaps(),
terser(),
copy({
targets: [...resources],
copyOnce: false,
}),
],
watch: {
clearScreen: false,
},
external: ["django"]
},
];

View File

@ -0,0 +1,10 @@
import { customElement, html, LitElement, TemplateResult } from "lit-element";
@customElement("ak-stage-authenticator-validate")
export class AuthenticatorValidateStage extends LitElement {
render(): TemplateResult {
return html`ak-stage-authenticator-validate`;
}
}

View File

@ -0,0 +1,10 @@
import { LitElement } from "lit-element";
import { FlowExecutor } from "../../pages/generic/FlowExecutor";
export class BaseStage extends LitElement {
// submit()
host?: FlowExecutor;
}

View File

@ -0,0 +1,101 @@
import { gettext } from "django";
import { CSSResult, customElement, html, property, TemplateResult } from "lit-element";
import { COMMON_STYLES } from "../../../common/styles";
import { BaseStage } from "../base";
export interface IdentificationStageArgs {
input_type: string;
primary_action: string;
sources: string[];
application_pre?: string;
}
@customElement("ak-stage-identification")
export class IdentificationStage extends BaseStage {
@property({attribute: false})
args?: IdentificationStageArgs;
static get styles(): CSSResult[] {
return COMMON_STYLES;
}
render(): TemplateResult {
if (!this.args) {
return html`<ak-loading-state></ak-loading-state>`;
}
return html`<header class="pf-c-login__main-header">
<h1 class="pf-c-title pf-m-3xl">
${gettext("Log in to your account")}
</h1>
</header>
<div class="pf-c-login__main-body">
<form class="pf-c-form">
${this.args.application_pre ?
html`<p>
${gettext(`Login to continue to ${this.args.application_pre}.`)}
</p>`:
html``}
<div class="pf-c-form__group">
<label class="pf-c-form__label" for="uid_field-0">
<span class="pf-c-form__label-text">${gettext("Email or Username")}</span>
<span class="pf-c-form__label-required" aria-hidden="true">*</span>
</label>
<input type="text" name="uid_field" placeholder="Email or Username" autofocus autocomplete="username" class="pf-c-form-control" required="" id="id_uid_field">
</div>
<div class="pf-c-form__group pf-m-action">
<button class="pf-c-button pf-m-primary pf-m-block" @click=${(e: Event) => {
e.preventDefault();
const form = new FormData(this.shadowRoot.querySelector("form"));
this.host?.submit(form);
}}>
${this.args.primary_action}
</button>
</div>
</form>
</div>
<footer class="pf-c-login__main-footer">
<ul class="pf-c-login__main-footer-links">
${this.args.sources.map(() => {
// TODO: source testing
// TODO: Placeholder and label for input above
return html``;
// {% for source in sources %}
// <li class="pf-c-login__main-footer-links-item">
// <a href="{{ source.url }}" class="pf-c-login__main-footer-links-item-link">
// {% if source.icon_path %}
// <img src="{% static source.icon_path %}" style="width:24px;" alt="{{ source.name }}">
// {% elif source.icon_url %}
// <img src="icon_url" alt="{{ source.name }}">
// {% else %}
// <i class="pf-icon pf-icon-arrow" title="{{ source.name }}"></i>
// {% endif %}
// </a>
// </li>
// {% endfor %}
})}
</ul>
{% if enroll_url or recovery_url %}
<div class="pf-c-login__main-footer-band">
{% if enroll_url %}
<p class="pf-c-login__main-footer-band-item">
{% trans 'Need an account?' %}
<a role="enroll" href="{{ enroll_url }}">{% trans 'Sign up.' %}</a>
</p>
{% endif %}
{% if recovery_url %}
<p class="pf-c-login__main-footer-band-item">
<a role="recovery" href="{{ recovery_url }}">{% trans 'Forgot username or password?' %}</a>
</p>
{% endif %}
</div>
{% endif %}
</footer>`;
}
}

3
web/src/flow.ts Normal file
View File

@ -0,0 +1,3 @@
import "construct-style-sheets-polyfill";
import "./pages/generic/FlowExecutor";

View File

@ -22,7 +22,6 @@ import "./elements/Spinner";
import "./elements/Tabs";
import "./elements/router/RouterOutlet";
import "./pages/generic/FlowShellCard";
import "./pages/generic/SiteShell";
import "./pages/admin-overview/AdminOverviewPage";
@ -33,5 +32,7 @@ import "./pages/LibraryPage";
import "./elements/stages/authenticator_webauthn/WebAuthnRegister";
import "./elements/stages/authenticator_webauthn/WebAuthnAuth";
import "./elements/stages/authenticator_validate/AuthenticatorValidateStage";
import "./elements/stages/identification/IdentificationStage";
import "./interfaces/AdminInterface";

View File

@ -7,6 +7,7 @@ import "../../elements/Tabs";
import "../../elements/AdminLoginsChart";
import "../../elements/buttons/ModalButton";
import "../../elements/buttons/SpinnerButton";
import "../../elements/buttons/Dropdown";
import "../../elements/policies/BoundPoliciesList";
import { FlowStageBinding, Stage } from "../../api/Flows";
import { until } from "lit-html/directives/until";

View File

@ -1,25 +1,30 @@
import { gettext } from "django";
import { LitElement, html, customElement, property, TemplateResult } from "lit-element";
import { unsafeHTML } from "lit-html/directives/unsafe-html";
import { SentryIgnoredError } from "../../common/errors";
import { getCookie } from "../../utils";
import "../../elements/stages/identification/IdentificationStage";
enum ResponseType {
enum ChallengeTypes {
native = "native",
response = "response",
shell = "shell",
redirect = "redirect",
template = "template",
}
interface Response {
type: ResponseType;
to?: string;
body?: string;
interface Challenge {
type: ChallengeTypes;
args: { [key: string]: string };
component: string;
}
@customElement("ak-flow-shell-card")
export class FlowShellCard extends LitElement {
@customElement("ak-flow-executor")
export class FlowExecutor extends LitElement {
@property()
flowBodyUrl = "";
@property()
flowBody?: string;
@property({attribute: false})
flowBody?: TemplateResult;
createRenderRoot(): Element | ShadowRoot {
return this;
@ -28,28 +33,33 @@ export class FlowShellCard extends LitElement {
constructor() {
super();
this.addEventListener("ak-flow-submit", () => {
const csrftoken = getCookie("authentik_csrf");
const request = new Request(this.flowBodyUrl, {
headers: {
"X-CSRFToken": csrftoken,
},
});
fetch(request, {
method: "POST",
mode: "same-origin"
})
.then((response) => {
return response.json();
})
.then((data) => {
this.updateCard(data);
})
.catch((e) => {
this.errorMessage(e);
});
this.submit();
});
}
submit(formData?: FormData): void {
const csrftoken = getCookie("authentik_csrf");
const request = new Request(this.flowBodyUrl, {
headers: {
"X-CSRFToken": csrftoken,
},
});
fetch(request, {
method: "POST",
mode: "same-origin",
body: formData,
})
.then((response) => {
return response.json();
})
.then((data) => {
this.updateCard(data);
})
.catch((e) => {
this.errorMessage(e);
});
}
firstUpdated(): void {
fetch(this.flowBodyUrl)
.then((r) => {
@ -73,19 +83,29 @@ export class FlowShellCard extends LitElement {
});
}
async updateCard(data: Response): Promise<void> {
async updateCard(data: Challenge): Promise<void> {
switch (data.type) {
case ResponseType.redirect:
console.debug(`authentik/flows: redirecting to ${data.to}`);
window.location.assign(data.to || "");
case ChallengeTypes.redirect:
console.debug(`authentik/flows: redirecting to ${data.args.to}`);
window.location.assign(data.args.to || "");
break;
case ResponseType.template:
this.flowBody = data.body;
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;
}
// this.flowBody = html`${unsafeHTML(`<${data.component} .args="${data.args}"></${data.component}>`)}`;
break;
default:
console.debug(`authentik/flows: unexpected data type ${data.type}`);
break;
@ -139,26 +159,14 @@ export class FlowShellCard extends LitElement {
e.preventDefault();
const formData = new FormData(form);
this.flowBody = undefined;
fetch(this.flowBodyUrl, {
method: "post",
body: formData,
})
.then((response) => {
return response.json();
})
.then((data) => {
this.updateCard(data);
})
.catch((e) => {
this.errorMessage(e);
});
this.submit(formData);
});
form.classList.add("ak-flow-wrapped");
});
}
errorMessage(error: string): void {
this.flowBody = `
this.flowBody = html`
<style>
.ak-exception {
font-family: monospace;
@ -167,13 +175,11 @@ export class FlowShellCard extends LitElement {
</style>
<header class="pf-c-login__main-header">
<h1 class="pf-c-title pf-m-3xl">
Whoops!
${gettext("Whoops!")}
</h1>
</header>
<div class="pf-c-login__main-body">
<h3>
Something went wrong! Please try again later.
</h3>
<h3>${gettext("Something went wrong! Please try again later.")}</h3>
<pre class="ak-exception">${error}</pre>
</div>`;
}
@ -190,7 +196,7 @@ export class FlowShellCard extends LitElement {
render(): TemplateResult {
if (this.flowBody) {
return html(<TemplateStringsArray>(<unknown>[this.flowBody]));
return this.flowBody;
}
return this.loading();
}