security: cure53 fix (#6039)
* ATH-01-001: resolve path and check start before loading blueprints
This is even less of an issue since 411ef239f6
, since with that commit we only allow files that the listing returns
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
* ATH-01-010: fix missing user filter for webauthn device
This prevents an attack that is only possible when an attacker can intercept HTTP traffic and in the case of HTTPS decrypt it.
* ATH-01-008: fix web forms not submitting correctly when pressing enter
When submitting some forms with the Enter key instead of clicking "Confirm"/etc, the form would not get submitted correctly
This would in the worst case is when setting a user's password, where the new password can end up in the URL, but the password was not actually saved to the user.
* ATH-01-004: remove env from admin system endpoint
this endpoint already required admin access, but for debugging the env variables are used very little
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
* ATH-01-003 / ATH-01-012: disable htmlLabels in mermaid
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
* ATH-01-005: use hmac.compare_digest for secret_key authentication
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
* ATH-01-009: migrate impersonation to use API
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
* ATH-01-010: rework
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
* ATH-01-014: save authenticator validation state in flow context
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
bugfixes
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
* ATH-01-012: escape quotation marks
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
* add website
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
* update release ntoes
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
* update with all notes
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
* fix format
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
---------
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
parent
f099bd764e
commit
b0fbd576fc
|
@ -1,5 +1,4 @@
|
||||||
"""authentik administration overview"""
|
"""authentik administration overview"""
|
||||||
import os
|
|
||||||
import platform
|
import platform
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from sys import version as python_version
|
from sys import version as python_version
|
||||||
|
@ -34,7 +33,6 @@ class RuntimeDict(TypedDict):
|
||||||
class SystemSerializer(PassiveSerializer):
|
class SystemSerializer(PassiveSerializer):
|
||||||
"""Get system information."""
|
"""Get system information."""
|
||||||
|
|
||||||
env = SerializerMethodField()
|
|
||||||
http_headers = SerializerMethodField()
|
http_headers = SerializerMethodField()
|
||||||
http_host = SerializerMethodField()
|
http_host = SerializerMethodField()
|
||||||
http_is_secure = SerializerMethodField()
|
http_is_secure = SerializerMethodField()
|
||||||
|
@ -43,10 +41,6 @@ class SystemSerializer(PassiveSerializer):
|
||||||
server_time = SerializerMethodField()
|
server_time = SerializerMethodField()
|
||||||
embedded_outpost_host = SerializerMethodField()
|
embedded_outpost_host = SerializerMethodField()
|
||||||
|
|
||||||
def get_env(self, request: Request) -> dict[str, str]:
|
|
||||||
"""Get Environment"""
|
|
||||||
return os.environ.copy()
|
|
||||||
|
|
||||||
def get_http_headers(self, request: Request) -> dict[str, str]:
|
def get_http_headers(self, request: Request) -> dict[str, str]:
|
||||||
"""Get HTTP Request headers"""
|
"""Get HTTP Request headers"""
|
||||||
headers = {}
|
headers = {}
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
"""API Authentication"""
|
"""API Authentication"""
|
||||||
|
from hmac import compare_digest
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
@ -78,7 +79,7 @@ def token_secret_key(value: str) -> Optional[User]:
|
||||||
and return the service account for the managed outpost"""
|
and return the service account for the managed outpost"""
|
||||||
from authentik.outposts.apps import MANAGED_OUTPOST
|
from authentik.outposts.apps import MANAGED_OUTPOST
|
||||||
|
|
||||||
if value != settings.SECRET_KEY:
|
if not compare_digest(value, settings.SECRET_KEY):
|
||||||
return None
|
return None
|
||||||
outposts = Outpost.objects.filter(managed=MANAGED_OUTPOST)
|
outposts = Outpost.objects.filter(managed=MANAGED_OUTPOST)
|
||||||
if not outposts:
|
if not outposts:
|
||||||
|
|
|
@ -82,7 +82,10 @@ class BlueprintInstance(SerializerModel, ManagedModel, CreatedUpdatedModel):
|
||||||
def retrieve_file(self) -> str:
|
def retrieve_file(self) -> str:
|
||||||
"""Get blueprint from path"""
|
"""Get blueprint from path"""
|
||||||
try:
|
try:
|
||||||
full_path = Path(CONFIG.y("blueprints_dir")).joinpath(Path(self.path))
|
base = Path(CONFIG.y("blueprints_dir"))
|
||||||
|
full_path = base.joinpath(Path(self.path)).resolve()
|
||||||
|
if not str(full_path).startswith(str(base.resolve())):
|
||||||
|
raise BlueprintRetrievalFailed("Invalid blueprint path")
|
||||||
with full_path.open("r", encoding="utf-8") as _file:
|
with full_path.open("r", encoding="utf-8") as _file:
|
||||||
return _file.read()
|
return _file.read()
|
||||||
except (IOError, OSError) as exc:
|
except (IOError, OSError) as exc:
|
||||||
|
|
|
@ -1,34 +1,15 @@
|
||||||
"""authentik managed models tests"""
|
"""authentik managed models tests"""
|
||||||
from typing import Callable, Type
|
|
||||||
|
|
||||||
from django.apps import apps
|
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
|
||||||
from authentik.blueprints.v1.importer import is_model_allowed
|
from authentik.blueprints.models import BlueprintInstance, BlueprintRetrievalFailed
|
||||||
from authentik.lib.models import SerializerModel
|
from authentik.lib.generators import generate_id
|
||||||
|
|
||||||
|
|
||||||
class TestModels(TestCase):
|
class TestModels(TestCase):
|
||||||
"""Test Models"""
|
"""Test Models"""
|
||||||
|
|
||||||
|
def test_retrieve_file(self):
|
||||||
def serializer_tester_factory(test_model: Type[SerializerModel]) -> Callable:
|
"""Test retrieve_file"""
|
||||||
"""Test serializer"""
|
instance = BlueprintInstance.objects.create(name=generate_id(), path="../etc/hosts")
|
||||||
|
with self.assertRaises(BlueprintRetrievalFailed):
|
||||||
def tester(self: TestModels):
|
instance.retrieve()
|
||||||
if test_model._meta.abstract: # pragma: no cover
|
|
||||||
return
|
|
||||||
model_class = test_model()
|
|
||||||
self.assertTrue(isinstance(model_class, SerializerModel))
|
|
||||||
self.assertIsNotNone(model_class.serializer)
|
|
||||||
|
|
||||||
return tester
|
|
||||||
|
|
||||||
|
|
||||||
for app in apps.get_app_configs():
|
|
||||||
if not app.label.startswith("authentik"):
|
|
||||||
continue
|
|
||||||
for model in app.get_models():
|
|
||||||
if not is_model_allowed(model):
|
|
||||||
continue
|
|
||||||
setattr(TestModels, f"test_{app.label}_{model.__name__}", serializer_tester_factory(model))
|
|
||||||
|
|
34
authentik/blueprints/tests/test_serializer_models.py
Normal file
34
authentik/blueprints/tests/test_serializer_models.py
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
"""authentik managed models tests"""
|
||||||
|
from typing import Callable, Type
|
||||||
|
|
||||||
|
from django.apps import apps
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from authentik.blueprints.v1.importer import is_model_allowed
|
||||||
|
from authentik.lib.models import SerializerModel
|
||||||
|
|
||||||
|
|
||||||
|
class TestModels(TestCase):
|
||||||
|
"""Test Models"""
|
||||||
|
|
||||||
|
|
||||||
|
def serializer_tester_factory(test_model: Type[SerializerModel]) -> Callable:
|
||||||
|
"""Test serializer"""
|
||||||
|
|
||||||
|
def tester(self: TestModels):
|
||||||
|
if test_model._meta.abstract: # pragma: no cover
|
||||||
|
return
|
||||||
|
model_class = test_model()
|
||||||
|
self.assertTrue(isinstance(model_class, SerializerModel))
|
||||||
|
self.assertIsNotNone(model_class.serializer)
|
||||||
|
|
||||||
|
return tester
|
||||||
|
|
||||||
|
|
||||||
|
for app in apps.get_app_configs():
|
||||||
|
if not app.label.startswith("authentik"):
|
||||||
|
continue
|
||||||
|
for model in app.get_models():
|
||||||
|
if not is_model_allowed(model):
|
||||||
|
continue
|
||||||
|
setattr(TestModels, f"test_{app.label}_{model.__name__}", serializer_tester_factory(model))
|
|
@ -68,11 +68,12 @@ from authentik.core.models import (
|
||||||
TokenIntents,
|
TokenIntents,
|
||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
from authentik.events.models import EventAction
|
from authentik.events.models import Event, EventAction
|
||||||
from authentik.flows.exceptions import FlowNonApplicableException
|
from authentik.flows.exceptions import FlowNonApplicableException
|
||||||
from authentik.flows.models import FlowToken
|
from authentik.flows.models import FlowToken
|
||||||
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlanner
|
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlanner
|
||||||
from authentik.flows.views.executor import QS_KEY_TOKEN
|
from authentik.flows.views.executor import QS_KEY_TOKEN
|
||||||
|
from authentik.lib.config import CONFIG
|
||||||
from authentik.stages.email.models import EmailStage
|
from authentik.stages.email.models import EmailStage
|
||||||
from authentik.stages.email.tasks import send_mails
|
from authentik.stages.email.tasks import send_mails
|
||||||
from authentik.stages.email.utils import TemplateEmailMessage
|
from authentik.stages.email.utils import TemplateEmailMessage
|
||||||
|
@ -568,6 +569,58 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
||||||
send_mails(email_stage, message)
|
send_mails(email_stage, message)
|
||||||
return Response(status=204)
|
return Response(status=204)
|
||||||
|
|
||||||
|
@permission_required("authentik_core.impersonate")
|
||||||
|
@extend_schema(
|
||||||
|
request=OpenApiTypes.NONE,
|
||||||
|
responses={
|
||||||
|
"204": OpenApiResponse(description="Successfully started impersonation"),
|
||||||
|
"401": OpenApiResponse(description="Access denied"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
@action(detail=True, methods=["POST"])
|
||||||
|
def impersonate(self, request: Request, pk: int) -> Response:
|
||||||
|
"""Impersonate a user"""
|
||||||
|
if not CONFIG.y_bool("impersonation"):
|
||||||
|
LOGGER.debug("User attempted to impersonate", user=request.user)
|
||||||
|
return Response(status=401)
|
||||||
|
if not request.user.has_perm("impersonate"):
|
||||||
|
LOGGER.debug("User attempted to impersonate without permissions", user=request.user)
|
||||||
|
return Response(status=401)
|
||||||
|
|
||||||
|
user_to_be = self.get_object()
|
||||||
|
|
||||||
|
request.session[SESSION_KEY_IMPERSONATE_ORIGINAL_USER] = request.user
|
||||||
|
request.session[SESSION_KEY_IMPERSONATE_USER] = user_to_be
|
||||||
|
|
||||||
|
Event.new(EventAction.IMPERSONATION_STARTED).from_http(request, user_to_be)
|
||||||
|
|
||||||
|
return Response(status=201)
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
request=OpenApiTypes.NONE,
|
||||||
|
responses={
|
||||||
|
"204": OpenApiResponse(description="Successfully started impersonation"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
@action(detail=False, methods=["GET"])
|
||||||
|
def impersonate_end(self, request: Request) -> Response:
|
||||||
|
"""End Impersonation a user"""
|
||||||
|
if (
|
||||||
|
SESSION_KEY_IMPERSONATE_USER not in request.session
|
||||||
|
or SESSION_KEY_IMPERSONATE_ORIGINAL_USER not in request.session
|
||||||
|
):
|
||||||
|
LOGGER.debug("Can't end impersonation", user=request.user)
|
||||||
|
return Response(status=204)
|
||||||
|
|
||||||
|
original_user = request.session[SESSION_KEY_IMPERSONATE_ORIGINAL_USER]
|
||||||
|
|
||||||
|
del request.session[SESSION_KEY_IMPERSONATE_USER]
|
||||||
|
del request.session[SESSION_KEY_IMPERSONATE_ORIGINAL_USER]
|
||||||
|
|
||||||
|
Event.new(EventAction.IMPERSONATION_ENDED).from_http(request, original_user)
|
||||||
|
|
||||||
|
return Response(status=204)
|
||||||
|
|
||||||
def _filter_queryset_for_list(self, queryset: QuerySet) -> QuerySet:
|
def _filter_queryset_for_list(self, queryset: QuerySet) -> QuerySet:
|
||||||
"""Custom filter_queryset method which ignores guardian, but still supports sorting"""
|
"""Custom filter_queryset method which ignores guardian, but still supports sorting"""
|
||||||
for backend in list(self.filter_backends):
|
for backend in list(self.filter_backends):
|
||||||
|
|
|
@ -1,14 +1,14 @@
|
||||||
"""impersonation tests"""
|
"""impersonation tests"""
|
||||||
from json import loads
|
from json import loads
|
||||||
|
|
||||||
from django.test.testcases import TestCase
|
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
from authentik.core.models import User
|
from authentik.core.models import User
|
||||||
from authentik.core.tests.utils import create_test_admin_user
|
from authentik.core.tests.utils import create_test_admin_user
|
||||||
|
|
||||||
|
|
||||||
class TestImpersonation(TestCase):
|
class TestImpersonation(APITestCase):
|
||||||
"""impersonation tests"""
|
"""impersonation tests"""
|
||||||
|
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
|
@ -23,10 +23,10 @@ class TestImpersonation(TestCase):
|
||||||
self.other_user.save()
|
self.other_user.save()
|
||||||
self.client.force_login(self.user)
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
self.client.get(
|
self.client.post(
|
||||||
reverse(
|
reverse(
|
||||||
"authentik_core:impersonate-init",
|
"authentik_api:user-impersonate",
|
||||||
kwargs={"user_id": self.other_user.pk},
|
kwargs={"pk": self.other_user.pk},
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -35,7 +35,7 @@ class TestImpersonation(TestCase):
|
||||||
self.assertEqual(response_body["user"]["username"], self.other_user.username)
|
self.assertEqual(response_body["user"]["username"], self.other_user.username)
|
||||||
self.assertEqual(response_body["original"]["username"], self.user.username)
|
self.assertEqual(response_body["original"]["username"], self.user.username)
|
||||||
|
|
||||||
self.client.get(reverse("authentik_core:impersonate-end"))
|
self.client.get(reverse("authentik_api:user-impersonate-end"))
|
||||||
|
|
||||||
response = self.client.get(reverse("authentik_api:user-me"))
|
response = self.client.get(reverse("authentik_api:user-me"))
|
||||||
response_body = loads(response.content.decode())
|
response_body = loads(response.content.decode())
|
||||||
|
@ -46,9 +46,7 @@ class TestImpersonation(TestCase):
|
||||||
"""test impersonation without permissions"""
|
"""test impersonation without permissions"""
|
||||||
self.client.force_login(self.other_user)
|
self.client.force_login(self.other_user)
|
||||||
|
|
||||||
self.client.get(
|
self.client.get(reverse("authentik_api:user-impersonate", kwargs={"pk": self.user.pk}))
|
||||||
reverse("authentik_core:impersonate-init", kwargs={"user_id": self.user.pk})
|
|
||||||
)
|
|
||||||
|
|
||||||
response = self.client.get(reverse("authentik_api:user-me"))
|
response = self.client.get(reverse("authentik_api:user-me"))
|
||||||
response_body = loads(response.content.decode())
|
response_body = loads(response.content.decode())
|
||||||
|
@ -58,5 +56,5 @@ class TestImpersonation(TestCase):
|
||||||
"""test un-impersonation without impersonating first"""
|
"""test un-impersonation without impersonating first"""
|
||||||
self.client.force_login(self.other_user)
|
self.client.force_login(self.other_user)
|
||||||
|
|
||||||
response = self.client.get(reverse("authentik_core:impersonate-end"))
|
response = self.client.get(reverse("authentik_api:user-impersonate-end"))
|
||||||
self.assertRedirects(response, reverse("authentik_core:if-user"))
|
self.assertEqual(response.status_code, 204)
|
||||||
|
|
|
@ -16,7 +16,7 @@ from authentik.core.api.providers import ProviderViewSet
|
||||||
from authentik.core.api.sources import SourceViewSet, UserSourceConnectionViewSet
|
from authentik.core.api.sources import SourceViewSet, UserSourceConnectionViewSet
|
||||||
from authentik.core.api.tokens import TokenViewSet
|
from authentik.core.api.tokens import TokenViewSet
|
||||||
from authentik.core.api.users import UserViewSet
|
from authentik.core.api.users import UserViewSet
|
||||||
from authentik.core.views import apps, impersonate
|
from authentik.core.views import apps
|
||||||
from authentik.core.views.debug import AccessDeniedView
|
from authentik.core.views.debug import AccessDeniedView
|
||||||
from authentik.core.views.interface import FlowInterfaceView, InterfaceView
|
from authentik.core.views.interface import FlowInterfaceView, InterfaceView
|
||||||
from authentik.core.views.session import EndSessionView
|
from authentik.core.views.session import EndSessionView
|
||||||
|
@ -38,17 +38,6 @@ urlpatterns = [
|
||||||
apps.RedirectToAppLaunch.as_view(),
|
apps.RedirectToAppLaunch.as_view(),
|
||||||
name="application-launch",
|
name="application-launch",
|
||||||
),
|
),
|
||||||
# Impersonation
|
|
||||||
path(
|
|
||||||
"-/impersonation/<int:user_id>/",
|
|
||||||
impersonate.ImpersonateInitView.as_view(),
|
|
||||||
name="impersonate-init",
|
|
||||||
),
|
|
||||||
path(
|
|
||||||
"-/impersonation/end/",
|
|
||||||
impersonate.ImpersonateEndView.as_view(),
|
|
||||||
name="impersonate-end",
|
|
||||||
),
|
|
||||||
# Interfaces
|
# Interfaces
|
||||||
path(
|
path(
|
||||||
"if/admin/",
|
"if/admin/",
|
||||||
|
|
|
@ -1,60 +0,0 @@
|
||||||
"""authentik impersonation views"""
|
|
||||||
|
|
||||||
from django.http import HttpRequest, HttpResponse
|
|
||||||
from django.shortcuts import get_object_or_404, redirect
|
|
||||||
from django.views import View
|
|
||||||
from structlog.stdlib import get_logger
|
|
||||||
|
|
||||||
from authentik.core.middleware import (
|
|
||||||
SESSION_KEY_IMPERSONATE_ORIGINAL_USER,
|
|
||||||
SESSION_KEY_IMPERSONATE_USER,
|
|
||||||
)
|
|
||||||
from authentik.core.models import User
|
|
||||||
from authentik.events.models import Event, EventAction
|
|
||||||
from authentik.lib.config import CONFIG
|
|
||||||
|
|
||||||
LOGGER = get_logger()
|
|
||||||
|
|
||||||
|
|
||||||
class ImpersonateInitView(View):
|
|
||||||
"""Initiate Impersonation"""
|
|
||||||
|
|
||||||
def get(self, request: HttpRequest, user_id: int) -> HttpResponse:
|
|
||||||
"""Impersonation handler, checks permissions"""
|
|
||||||
if not CONFIG.y_bool("impersonation"):
|
|
||||||
LOGGER.debug("User attempted to impersonate", user=request.user)
|
|
||||||
return HttpResponse("Unauthorized", status=401)
|
|
||||||
if not request.user.has_perm("impersonate"):
|
|
||||||
LOGGER.debug("User attempted to impersonate without permissions", user=request.user)
|
|
||||||
return HttpResponse("Unauthorized", status=401)
|
|
||||||
|
|
||||||
user_to_be = get_object_or_404(User, pk=user_id)
|
|
||||||
|
|
||||||
request.session[SESSION_KEY_IMPERSONATE_ORIGINAL_USER] = request.user
|
|
||||||
request.session[SESSION_KEY_IMPERSONATE_USER] = user_to_be
|
|
||||||
|
|
||||||
Event.new(EventAction.IMPERSONATION_STARTED).from_http(request, user_to_be)
|
|
||||||
|
|
||||||
return redirect("authentik_core:if-user")
|
|
||||||
|
|
||||||
|
|
||||||
class ImpersonateEndView(View):
|
|
||||||
"""End User impersonation"""
|
|
||||||
|
|
||||||
def get(self, request: HttpRequest) -> HttpResponse:
|
|
||||||
"""End Impersonation handler"""
|
|
||||||
if (
|
|
||||||
SESSION_KEY_IMPERSONATE_USER not in request.session
|
|
||||||
or SESSION_KEY_IMPERSONATE_ORIGINAL_USER not in request.session
|
|
||||||
):
|
|
||||||
LOGGER.debug("Can't end impersonation", user=request.user)
|
|
||||||
return redirect("authentik_core:if-user")
|
|
||||||
|
|
||||||
original_user = request.session[SESSION_KEY_IMPERSONATE_ORIGINAL_USER]
|
|
||||||
|
|
||||||
del request.session[SESSION_KEY_IMPERSONATE_USER]
|
|
||||||
del request.session[SESSION_KEY_IMPERSONATE_ORIGINAL_USER]
|
|
||||||
|
|
||||||
Event.new(EventAction.IMPERSONATION_ENDED).from_http(request, original_user)
|
|
||||||
|
|
||||||
return redirect("authentik_core:root-redirect")
|
|
|
@ -23,7 +23,8 @@ class DiagramElement:
|
||||||
style: list[str] = field(default_factory=lambda: ["[", "]"])
|
style: list[str] = field(default_factory=lambda: ["[", "]"])
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
element = f'{self.identifier}{self.style[0]}"{self.description}"{self.style[1]}'
|
description = self.description.replace('"', "#quot;")
|
||||||
|
element = f'{self.identifier}{self.style[0]}"{description}"{self.style[1]}'
|
||||||
if self.action is not None:
|
if self.action is not None:
|
||||||
if self.action != "":
|
if self.action != "":
|
||||||
element = f"--{self.action}--> {element}"
|
element = f"--{self.action}--> {element}"
|
||||||
|
|
|
@ -204,12 +204,12 @@ class ChallengeStageView(StageView):
|
||||||
for field, errors in response.errors.items():
|
for field, errors in response.errors.items():
|
||||||
for error in errors:
|
for error in errors:
|
||||||
full_errors.setdefault(field, [])
|
full_errors.setdefault(field, [])
|
||||||
full_errors[field].append(
|
field_error = {
|
||||||
{
|
"string": str(error),
|
||||||
"string": str(error),
|
}
|
||||||
"code": error.code,
|
if hasattr(error, "code"):
|
||||||
}
|
field_error["code"] = error.code
|
||||||
)
|
full_errors[field].append(field_error)
|
||||||
challenge_response.initial_data["response_errors"] = full_errors
|
challenge_response.initial_data["response_errors"] = full_errors
|
||||||
if not challenge_response.is_valid():
|
if not challenge_response.is_valid():
|
||||||
self.logger.error(
|
self.logger.error(
|
||||||
|
|
|
@ -132,9 +132,9 @@ class TestPolicyProcess(TestCase):
|
||||||
)
|
)
|
||||||
binding = PolicyBinding(policy=policy, target=Application.objects.create(name="test"))
|
binding = PolicyBinding(policy=policy, target=Application.objects.create(name="test"))
|
||||||
|
|
||||||
http_request = self.factory.get(reverse("authentik_core:impersonate-end"))
|
http_request = self.factory.get(reverse("authentik_api:user-impersonate-end"))
|
||||||
http_request.user = self.user
|
http_request.user = self.user
|
||||||
http_request.resolver_match = resolve(reverse("authentik_core:impersonate-end"))
|
http_request.resolver_match = resolve(reverse("authentik_api:user-impersonate-end"))
|
||||||
|
|
||||||
request = PolicyRequest(self.user)
|
request = PolicyRequest(self.user)
|
||||||
request.set_http_request(http_request)
|
request.set_http_request(http_request)
|
||||||
|
|
|
@ -66,8 +66,8 @@ def ldap_sync_password(sender, user: User, password: str, **_):
|
||||||
if not sources.exists():
|
if not sources.exists():
|
||||||
return
|
return
|
||||||
source = sources.first()
|
source = sources.first()
|
||||||
changer = LDAPPasswordChanger(source)
|
|
||||||
try:
|
try:
|
||||||
|
changer = LDAPPasswordChanger(source)
|
||||||
changer.change_password(user, password)
|
changer.change_password(user, password)
|
||||||
except LDAPOperationResult as exc:
|
except LDAPOperationResult as exc:
|
||||||
LOGGER.warning("failed to set LDAP password", exc=exc)
|
LOGGER.warning("failed to set LDAP password", exc=exc)
|
||||||
|
|
|
@ -133,6 +133,12 @@ def validate_challenge_webauthn(data: dict, stage_view: StageView, user: User) -
|
||||||
device = WebAuthnDevice.objects.filter(credential_id=credential_id).first()
|
device = WebAuthnDevice.objects.filter(credential_id=credential_id).first()
|
||||||
if not device:
|
if not device:
|
||||||
raise ValidationError("Invalid device")
|
raise ValidationError("Invalid device")
|
||||||
|
# We can only check the device's user if the user we're given isn't anonymous
|
||||||
|
# as this validation is also used for password-less login where webauthn is the very first
|
||||||
|
# step done by a user. Only if this validation happens at a later stage we can check
|
||||||
|
# that the device belongs to the user
|
||||||
|
if not user.is_anonymous and device.user != user:
|
||||||
|
raise ValidationError("Invalid device")
|
||||||
|
|
||||||
stage: AuthenticatorValidateStage = stage_view.executor.current_stage
|
stage: AuthenticatorValidateStage = stage_view.executor.current_stage
|
||||||
|
|
||||||
|
|
|
@ -37,9 +37,9 @@ from authentik.stages.password.stage import PLAN_CONTEXT_METHOD, PLAN_CONTEXT_ME
|
||||||
|
|
||||||
COOKIE_NAME_MFA = "authentik_mfa"
|
COOKIE_NAME_MFA = "authentik_mfa"
|
||||||
|
|
||||||
SESSION_KEY_STAGES = "authentik/stages/authenticator_validate/stages"
|
PLAN_CONTEXT_STAGES = "goauthentik.io/stages/authenticator_validate/stages"
|
||||||
SESSION_KEY_SELECTED_STAGE = "authentik/stages/authenticator_validate/selected_stage"
|
PLAN_CONTEXT_SELECTED_STAGE = "goauthentik.io/stages/authenticator_validate/selected_stage"
|
||||||
SESSION_KEY_DEVICE_CHALLENGES = "authentik/stages/authenticator_validate/device_challenges"
|
PLAN_CONTEXT_DEVICE_CHALLENGES = "goauthentik.io/stages/authenticator_validate/device_challenges"
|
||||||
|
|
||||||
|
|
||||||
class SelectableStageSerializer(PassiveSerializer):
|
class SelectableStageSerializer(PassiveSerializer):
|
||||||
|
@ -73,8 +73,8 @@ class AuthenticatorValidationChallengeResponse(ChallengeResponse):
|
||||||
component = CharField(default="ak-stage-authenticator-validate")
|
component = CharField(default="ak-stage-authenticator-validate")
|
||||||
|
|
||||||
def _challenge_allowed(self, classes: list):
|
def _challenge_allowed(self, classes: list):
|
||||||
device_challenges: list[dict] = self.stage.request.session.get(
|
device_challenges: list[dict] = self.stage.executor.plan.context.get(
|
||||||
SESSION_KEY_DEVICE_CHALLENGES, []
|
PLAN_CONTEXT_DEVICE_CHALLENGES, []
|
||||||
)
|
)
|
||||||
if not any(x["device_class"] in classes for x in device_challenges):
|
if not any(x["device_class"] in classes for x in device_challenges):
|
||||||
raise ValidationError("No compatible device class allowed")
|
raise ValidationError("No compatible device class allowed")
|
||||||
|
@ -104,7 +104,9 @@ class AuthenticatorValidationChallengeResponse(ChallengeResponse):
|
||||||
"""Check which challenge the user has selected. Actual logic only used for SMS stage."""
|
"""Check which challenge the user has selected. Actual logic only used for SMS stage."""
|
||||||
# First check if the challenge is valid
|
# First check if the challenge is valid
|
||||||
allowed = False
|
allowed = False
|
||||||
for device_challenge in self.stage.request.session.get(SESSION_KEY_DEVICE_CHALLENGES, []):
|
for device_challenge in self.stage.executor.plan.context.get(
|
||||||
|
PLAN_CONTEXT_DEVICE_CHALLENGES, []
|
||||||
|
):
|
||||||
if device_challenge.get("device_class", "") == challenge.get(
|
if device_challenge.get("device_class", "") == challenge.get(
|
||||||
"device_class", ""
|
"device_class", ""
|
||||||
) and device_challenge.get("device_uid", "") == challenge.get("device_uid", ""):
|
) and device_challenge.get("device_uid", "") == challenge.get("device_uid", ""):
|
||||||
|
@ -122,11 +124,11 @@ class AuthenticatorValidationChallengeResponse(ChallengeResponse):
|
||||||
|
|
||||||
def validate_selected_stage(self, stage_pk: str) -> str:
|
def validate_selected_stage(self, stage_pk: str) -> str:
|
||||||
"""Check that the selected stage is valid"""
|
"""Check that the selected stage is valid"""
|
||||||
stages = self.stage.request.session.get(SESSION_KEY_STAGES, [])
|
stages = self.stage.executor.plan.context.get(PLAN_CONTEXT_STAGES, [])
|
||||||
if not any(str(stage.pk) == stage_pk for stage in stages):
|
if not any(str(stage.pk) == stage_pk for stage in stages):
|
||||||
raise ValidationError("Selected stage is invalid")
|
raise ValidationError("Selected stage is invalid")
|
||||||
self.stage.logger.debug("Setting selected stage to ", stage=stage_pk)
|
self.stage.logger.debug("Setting selected stage to ", stage=stage_pk)
|
||||||
self.stage.request.session[SESSION_KEY_SELECTED_STAGE] = stage_pk
|
self.stage.executor.plan.context[PLAN_CONTEXT_SELECTED_STAGE] = stage_pk
|
||||||
return stage_pk
|
return stage_pk
|
||||||
|
|
||||||
def validate(self, attrs: dict):
|
def validate(self, attrs: dict):
|
||||||
|
@ -231,7 +233,7 @@ class AuthenticatorValidateStageView(ChallengeStageView):
|
||||||
else:
|
else:
|
||||||
self.logger.debug("No pending user, continuing")
|
self.logger.debug("No pending user, continuing")
|
||||||
return self.executor.stage_ok()
|
return self.executor.stage_ok()
|
||||||
self.request.session[SESSION_KEY_DEVICE_CHALLENGES] = challenges
|
self.executor.plan.context[PLAN_CONTEXT_DEVICE_CHALLENGES] = challenges
|
||||||
|
|
||||||
# No allowed devices
|
# No allowed devices
|
||||||
if len(challenges) < 1:
|
if len(challenges) < 1:
|
||||||
|
@ -264,23 +266,23 @@ class AuthenticatorValidateStageView(ChallengeStageView):
|
||||||
if stage.configuration_stages.count() == 1:
|
if stage.configuration_stages.count() == 1:
|
||||||
next_stage = Stage.objects.get_subclass(pk=stage.configuration_stages.first().pk)
|
next_stage = Stage.objects.get_subclass(pk=stage.configuration_stages.first().pk)
|
||||||
self.logger.debug("Single stage configured, auto-selecting", stage=next_stage)
|
self.logger.debug("Single stage configured, auto-selecting", stage=next_stage)
|
||||||
self.request.session[SESSION_KEY_SELECTED_STAGE] = next_stage
|
self.executor.plan.context[PLAN_CONTEXT_SELECTED_STAGE] = next_stage
|
||||||
# Because that normal execution only happens on post, we directly inject it here and
|
# Because that normal execution only happens on post, we directly inject it here and
|
||||||
# return it
|
# return it
|
||||||
self.executor.plan.insert_stage(next_stage)
|
self.executor.plan.insert_stage(next_stage)
|
||||||
return self.executor.stage_ok()
|
return self.executor.stage_ok()
|
||||||
stages = Stage.objects.filter(pk__in=stage.configuration_stages.all()).select_subclasses()
|
stages = Stage.objects.filter(pk__in=stage.configuration_stages.all()).select_subclasses()
|
||||||
self.request.session[SESSION_KEY_STAGES] = stages
|
self.executor.plan.context[PLAN_CONTEXT_STAGES] = stages
|
||||||
return super().get(self.request, *args, **kwargs)
|
return super().get(self.request, *args, **kwargs)
|
||||||
|
|
||||||
def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||||
res = super().post(request, *args, **kwargs)
|
res = super().post(request, *args, **kwargs)
|
||||||
if (
|
if (
|
||||||
SESSION_KEY_SELECTED_STAGE in self.request.session
|
PLAN_CONTEXT_SELECTED_STAGE in self.executor.plan.context
|
||||||
and self.executor.current_stage.not_configured_action == NotConfiguredAction.CONFIGURE
|
and self.executor.current_stage.not_configured_action == NotConfiguredAction.CONFIGURE
|
||||||
):
|
):
|
||||||
self.logger.debug("Got selected stage in session, running that")
|
self.logger.debug("Got selected stage in context, running that")
|
||||||
stage_pk = self.request.session.get(SESSION_KEY_SELECTED_STAGE)
|
stage_pk = self.executor.plan.context(PLAN_CONTEXT_SELECTED_STAGE)
|
||||||
# Because the foreign key to stage.configuration_stage points to
|
# Because the foreign key to stage.configuration_stage points to
|
||||||
# a base stage class, we need to do another lookup
|
# a base stage class, we need to do another lookup
|
||||||
stage = Stage.objects.get_subclass(pk=stage_pk)
|
stage = Stage.objects.get_subclass(pk=stage_pk)
|
||||||
|
@ -291,8 +293,8 @@ class AuthenticatorValidateStageView(ChallengeStageView):
|
||||||
return res
|
return res
|
||||||
|
|
||||||
def get_challenge(self) -> AuthenticatorValidationChallenge:
|
def get_challenge(self) -> AuthenticatorValidationChallenge:
|
||||||
challenges = self.request.session.get(SESSION_KEY_DEVICE_CHALLENGES, [])
|
challenges = self.executor.plan.context.get(PLAN_CONTEXT_DEVICE_CHALLENGES, [])
|
||||||
stages = self.request.session.get(SESSION_KEY_STAGES, [])
|
stages = self.executor.plan.context.get(PLAN_CONTEXT_STAGES, [])
|
||||||
stage_challenges = []
|
stage_challenges = []
|
||||||
for stage in stages:
|
for stage in stages:
|
||||||
serializer = SelectableStageSerializer(
|
serializer = SelectableStageSerializer(
|
||||||
|
@ -307,6 +309,7 @@ class AuthenticatorValidateStageView(ChallengeStageView):
|
||||||
stage_challenges.append(serializer.data)
|
stage_challenges.append(serializer.data)
|
||||||
return AuthenticatorValidationChallenge(
|
return AuthenticatorValidationChallenge(
|
||||||
data={
|
data={
|
||||||
|
"component": "ak-stage-authenticator-validate",
|
||||||
"type": ChallengeTypes.NATIVE.value,
|
"type": ChallengeTypes.NATIVE.value,
|
||||||
"device_challenges": challenges,
|
"device_challenges": challenges,
|
||||||
"configuration_stages": stage_challenges,
|
"configuration_stages": stage_challenges,
|
||||||
|
@ -386,8 +389,3 @@ class AuthenticatorValidateStageView(ChallengeStageView):
|
||||||
"device": webauthn_device,
|
"device": webauthn_device,
|
||||||
}
|
}
|
||||||
return self.set_valid_mfa_cookie(response.device)
|
return self.set_valid_mfa_cookie(response.device)
|
||||||
|
|
||||||
def cleanup(self):
|
|
||||||
self.request.session.pop(SESSION_KEY_STAGES, None)
|
|
||||||
self.request.session.pop(SESSION_KEY_SELECTED_STAGE, None)
|
|
||||||
self.request.session.pop(SESSION_KEY_DEVICE_CHALLENGES, None)
|
|
||||||
|
|
|
@ -1,26 +1,19 @@
|
||||||
"""Test validator stage"""
|
"""Test validator stage"""
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
from django.contrib.sessions.middleware import SessionMiddleware
|
|
||||||
from django.test.client import RequestFactory
|
from django.test.client import RequestFactory
|
||||||
from django.urls.base import reverse
|
from django.urls.base import reverse
|
||||||
from rest_framework.exceptions import ValidationError
|
|
||||||
|
|
||||||
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
|
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
|
||||||
from authentik.flows.models import FlowDesignation, FlowStageBinding, NotConfiguredAction
|
from authentik.flows.models import FlowDesignation, FlowStageBinding, NotConfiguredAction
|
||||||
from authentik.flows.planner import FlowPlan
|
from authentik.flows.planner import FlowPlan
|
||||||
from authentik.flows.stage import StageView
|
|
||||||
from authentik.flows.tests import FlowTestCase
|
from authentik.flows.tests import FlowTestCase
|
||||||
from authentik.flows.views.executor import SESSION_KEY_PLAN, FlowExecutorView
|
from authentik.flows.views.executor import SESSION_KEY_PLAN
|
||||||
from authentik.lib.generators import generate_id, generate_key
|
from authentik.lib.generators import generate_id, generate_key
|
||||||
from authentik.lib.tests.utils import dummy_get_response
|
|
||||||
from authentik.stages.authenticator_duo.models import AuthenticatorDuoStage, DuoDevice
|
from authentik.stages.authenticator_duo.models import AuthenticatorDuoStage, DuoDevice
|
||||||
from authentik.stages.authenticator_validate.api import AuthenticatorValidateStageSerializer
|
from authentik.stages.authenticator_validate.api import AuthenticatorValidateStageSerializer
|
||||||
from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage, DeviceClasses
|
from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage, DeviceClasses
|
||||||
from authentik.stages.authenticator_validate.stage import (
|
from authentik.stages.authenticator_validate.stage import PLAN_CONTEXT_DEVICE_CHALLENGES
|
||||||
SESSION_KEY_DEVICE_CHALLENGES,
|
|
||||||
AuthenticatorValidationChallengeResponse,
|
|
||||||
)
|
|
||||||
from authentik.stages.identification.models import IdentificationStage, UserFields
|
from authentik.stages.identification.models import IdentificationStage, UserFields
|
||||||
|
|
||||||
|
|
||||||
|
@ -86,12 +79,17 @@ class AuthenticatorValidateStageTests(FlowTestCase):
|
||||||
|
|
||||||
def test_validate_selected_challenge(self):
|
def test_validate_selected_challenge(self):
|
||||||
"""Test validate_selected_challenge"""
|
"""Test validate_selected_challenge"""
|
||||||
# Prepare request with session
|
flow = create_test_flow()
|
||||||
request = self.request_factory.get("/")
|
stage = AuthenticatorValidateStage.objects.create(
|
||||||
|
name=generate_id(),
|
||||||
|
not_configured_action=NotConfiguredAction.CONFIGURE,
|
||||||
|
device_classes=[DeviceClasses.STATIC, DeviceClasses.TOTP],
|
||||||
|
)
|
||||||
|
|
||||||
middleware = SessionMiddleware(dummy_get_response)
|
session = self.client.session
|
||||||
middleware.process_request(request)
|
plan = FlowPlan(flow_pk=flow.pk.hex)
|
||||||
request.session[SESSION_KEY_DEVICE_CHALLENGES] = [
|
plan.append_stage(stage)
|
||||||
|
plan.context[PLAN_CONTEXT_DEVICE_CHALLENGES] = [
|
||||||
{
|
{
|
||||||
"device_class": "static",
|
"device_class": "static",
|
||||||
"device_uid": "1",
|
"device_uid": "1",
|
||||||
|
@ -101,23 +99,43 @@ class AuthenticatorValidateStageTests(FlowTestCase):
|
||||||
"device_uid": "2",
|
"device_uid": "2",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
request.session.save()
|
session[SESSION_KEY_PLAN] = plan
|
||||||
|
session.save()
|
||||||
|
|
||||||
res = AuthenticatorValidationChallengeResponse()
|
response = self.client.post(
|
||||||
res.stage = StageView(FlowExecutorView())
|
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
|
||||||
res.stage.request = request
|
data={
|
||||||
with self.assertRaises(ValidationError):
|
"selected_challenge": {
|
||||||
res.validate_selected_challenge(
|
|
||||||
{
|
|
||||||
"device_class": "baz",
|
"device_class": "baz",
|
||||||
"device_uid": "quox",
|
"device_uid": "quox",
|
||||||
|
"challenge": {},
|
||||||
}
|
}
|
||||||
)
|
},
|
||||||
res.validate_selected_challenge(
|
)
|
||||||
{
|
self.assertStageResponse(
|
||||||
"device_class": "static",
|
response,
|
||||||
"device_uid": "1",
|
flow,
|
||||||
}
|
response_errors={
|
||||||
|
"selected_challenge": [{"string": "invalid challenge selected", "code": "invalid"}]
|
||||||
|
},
|
||||||
|
component="ak-stage-authenticator-validate",
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
|
||||||
|
data={
|
||||||
|
"selected_challenge": {
|
||||||
|
"device_class": "static",
|
||||||
|
"device_uid": "1",
|
||||||
|
"challenge": {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertStageResponse(
|
||||||
|
response,
|
||||||
|
flow,
|
||||||
|
response_errors={"non_field_errors": [{"string": "Empty response", "code": "invalid"}]},
|
||||||
|
component="ak-stage-authenticator-validate",
|
||||||
)
|
)
|
||||||
|
|
||||||
@patch(
|
@patch(
|
||||||
|
|
|
@ -22,7 +22,7 @@ from authentik.stages.authenticator_validate.challenge import (
|
||||||
)
|
)
|
||||||
from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage, DeviceClasses
|
from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage, DeviceClasses
|
||||||
from authentik.stages.authenticator_validate.stage import (
|
from authentik.stages.authenticator_validate.stage import (
|
||||||
SESSION_KEY_DEVICE_CHALLENGES,
|
PLAN_CONTEXT_DEVICE_CHALLENGES,
|
||||||
AuthenticatorValidateStageView,
|
AuthenticatorValidateStageView,
|
||||||
)
|
)
|
||||||
from authentik.stages.authenticator_webauthn.models import UserVerification, WebAuthnDevice
|
from authentik.stages.authenticator_webauthn.models import UserVerification, WebAuthnDevice
|
||||||
|
@ -211,14 +211,14 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase):
|
||||||
plan.append_stage(stage)
|
plan.append_stage(stage)
|
||||||
plan.append_stage(UserLoginStage(name=generate_id()))
|
plan.append_stage(UserLoginStage(name=generate_id()))
|
||||||
plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
|
plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
|
||||||
session[SESSION_KEY_PLAN] = plan
|
plan.context[PLAN_CONTEXT_DEVICE_CHALLENGES] = [
|
||||||
session[SESSION_KEY_DEVICE_CHALLENGES] = [
|
|
||||||
{
|
{
|
||||||
"device_class": device.__class__.__name__.lower().replace("device", ""),
|
"device_class": device.__class__.__name__.lower().replace("device", ""),
|
||||||
"device_uid": device.pk,
|
"device_uid": device.pk,
|
||||||
"challenge": {},
|
"challenge": {},
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
session[SESSION_KEY_PLAN] = plan
|
||||||
session[SESSION_KEY_WEBAUTHN_CHALLENGE] = base64url_to_bytes(
|
session[SESSION_KEY_WEBAUTHN_CHALLENGE] = base64url_to_bytes(
|
||||||
"g98I51mQvZXo5lxLfhrD2zfolhZbLRyCgqkkYap1jwSaJ13BguoJWCF9_Lg3AgO4Wh-Bqa556JE20oKsYbl6RA"
|
"g98I51mQvZXo5lxLfhrD2zfolhZbLRyCgqkkYap1jwSaJ13BguoJWCF9_Lg3AgO4Wh-Bqa556JE20oKsYbl6RA"
|
||||||
)
|
)
|
||||||
|
@ -283,14 +283,14 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase):
|
||||||
plan = FlowPlan(flow_pk=flow.pk.hex)
|
plan = FlowPlan(flow_pk=flow.pk.hex)
|
||||||
plan.append_stage(stage)
|
plan.append_stage(stage)
|
||||||
plan.append_stage(UserLoginStage(name=generate_id()))
|
plan.append_stage(UserLoginStage(name=generate_id()))
|
||||||
session[SESSION_KEY_PLAN] = plan
|
plan.context[PLAN_CONTEXT_DEVICE_CHALLENGES] = [
|
||||||
session[SESSION_KEY_DEVICE_CHALLENGES] = [
|
|
||||||
{
|
{
|
||||||
"device_class": device.__class__.__name__.lower().replace("device", ""),
|
"device_class": device.__class__.__name__.lower().replace("device", ""),
|
||||||
"device_uid": device.pk,
|
"device_uid": device.pk,
|
||||||
"challenge": {},
|
"challenge": {},
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
session[SESSION_KEY_PLAN] = plan
|
||||||
session[SESSION_KEY_WEBAUTHN_CHALLENGE] = base64url_to_bytes(
|
session[SESSION_KEY_WEBAUTHN_CHALLENGE] = base64url_to_bytes(
|
||||||
"g98I51mQvZXo5lxLfhrD2zfolhZbLRyCgqkkYap1jwSaJ13BguoJWCF9_Lg3AgO4Wh-Bqa556JE20oKsYbl6RA"
|
"g98I51mQvZXo5lxLfhrD2zfolhZbLRyCgqkkYap1jwSaJ13BguoJWCF9_Lg3AgO4Wh-Bqa556JE20oKsYbl6RA"
|
||||||
)
|
)
|
||||||
|
|
62
schema.yml
62
schema.yml
|
@ -4812,6 +4812,38 @@ paths:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/GenericError'
|
$ref: '#/components/schemas/GenericError'
|
||||||
description: ''
|
description: ''
|
||||||
|
/core/users/{id}/impersonate/:
|
||||||
|
post:
|
||||||
|
operationId: core_users_impersonate_create
|
||||||
|
description: Impersonate a user
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: id
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
description: A unique integer value identifying this User.
|
||||||
|
required: true
|
||||||
|
tags:
|
||||||
|
- core
|
||||||
|
security:
|
||||||
|
- authentik: []
|
||||||
|
responses:
|
||||||
|
'204':
|
||||||
|
description: Successfully started impersonation
|
||||||
|
'401':
|
||||||
|
description: Access denied
|
||||||
|
'400':
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ValidationError'
|
||||||
|
description: ''
|
||||||
|
'403':
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/GenericError'
|
||||||
|
description: ''
|
||||||
/core/users/{id}/metrics/:
|
/core/users/{id}/metrics/:
|
||||||
get:
|
get:
|
||||||
operationId: core_users_metrics_retrieve
|
operationId: core_users_metrics_retrieve
|
||||||
|
@ -4991,6 +5023,29 @@ paths:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/GenericError'
|
$ref: '#/components/schemas/GenericError'
|
||||||
description: ''
|
description: ''
|
||||||
|
/core/users/impersonate_end/:
|
||||||
|
get:
|
||||||
|
operationId: core_users_impersonate_end_retrieve
|
||||||
|
description: End Impersonation a user
|
||||||
|
tags:
|
||||||
|
- core
|
||||||
|
security:
|
||||||
|
- authentik: []
|
||||||
|
responses:
|
||||||
|
'204':
|
||||||
|
description: Successfully started impersonation
|
||||||
|
'400':
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ValidationError'
|
||||||
|
description: ''
|
||||||
|
'403':
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/GenericError'
|
||||||
|
description: ''
|
||||||
/core/users/me/:
|
/core/users/me/:
|
||||||
get:
|
get:
|
||||||
operationId: core_users_me_retrieve
|
operationId: core_users_me_retrieve
|
||||||
|
@ -41003,12 +41058,6 @@ components:
|
||||||
type: object
|
type: object
|
||||||
description: Get system information.
|
description: Get system information.
|
||||||
properties:
|
properties:
|
||||||
env:
|
|
||||||
type: object
|
|
||||||
additionalProperties:
|
|
||||||
type: string
|
|
||||||
description: Get Environment
|
|
||||||
readOnly: true
|
|
||||||
http_headers:
|
http_headers:
|
||||||
type: object
|
type: object
|
||||||
additionalProperties:
|
additionalProperties:
|
||||||
|
@ -41062,7 +41111,6 @@ components:
|
||||||
readOnly: true
|
readOnly: true
|
||||||
required:
|
required:
|
||||||
- embedded_outpost_host
|
- embedded_outpost_host
|
||||||
- env
|
|
||||||
- http_headers
|
- http_headers
|
||||||
- http_host
|
- http_host
|
||||||
- http_is_secure
|
- http_is_secure
|
||||||
|
|
|
@ -30,7 +30,7 @@ import PFDrawer from "@patternfly/patternfly/components/Drawer/drawer.css";
|
||||||
import PFPage from "@patternfly/patternfly/components/Page/page.css";
|
import PFPage from "@patternfly/patternfly/components/Page/page.css";
|
||||||
import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||||
|
|
||||||
import { AdminApi, SessionUser, UiThemeEnum, Version } from "@goauthentik/api";
|
import { AdminApi, CoreApi, SessionUser, UiThemeEnum, Version } from "@goauthentik/api";
|
||||||
|
|
||||||
autoDetectLanguage();
|
autoDetectLanguage();
|
||||||
|
|
||||||
|
@ -178,10 +178,11 @@ export class AdminInterface extends Interface {
|
||||||
${this.user?.original
|
${this.user?.original
|
||||||
? html`<ak-sidebar-item
|
? html`<ak-sidebar-item
|
||||||
?highlight=${true}
|
?highlight=${true}
|
||||||
?isAbsoluteLink=${true}
|
@click=${() => {
|
||||||
path=${`/-/impersonation/end/?back=${encodeURIComponent(
|
new CoreApi(DEFAULT_CONFIG).coreUsersImpersonateEndRetrieve().then(() => {
|
||||||
`${window.location.pathname}#${window.location.hash}`,
|
window.location.reload();
|
||||||
)}`}
|
});
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<span slot="label"
|
<span slot="label"
|
||||||
>${msg(
|
>${msg(
|
||||||
|
|
|
@ -114,9 +114,12 @@ export class ApplicationCheckAccessForm extends Form<{ forUser: number }> {
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
renderForm(): TemplateResult {
|
renderInlineForm(): TemplateResult {
|
||||||
return html`<form class="pf-c-form pf-m-horizontal">
|
return html`<ak-form-element-horizontal
|
||||||
<ak-form-element-horizontal label=${msg("User")} ?required=${true} name="forUser">
|
label=${msg("User")}
|
||||||
|
?required=${true}
|
||||||
|
name="forUser"
|
||||||
|
>
|
||||||
<ak-search-select
|
<ak-search-select
|
||||||
.fetchObjects=${async (query?: string): Promise<User[]> => {
|
.fetchObjects=${async (query?: string): Promise<User[]> => {
|
||||||
const args: CoreUsersListRequest = {
|
const args: CoreUsersListRequest = {
|
||||||
|
@ -143,7 +146,6 @@ export class ApplicationCheckAccessForm extends Form<{ forUser: number }> {
|
||||||
>
|
>
|
||||||
</ak-search-select>
|
</ak-search-select>
|
||||||
</ak-form-element-horizontal>
|
</ak-form-element-horizontal>
|
||||||
${this.result ? this.renderResult() : html``}
|
${this.result ? this.renderResult() : html``}`;
|
||||||
</form>`;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,9 +20,8 @@ export class CertificateKeyPairForm extends Form<CertificateGenerationRequest> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
renderForm(): TemplateResult {
|
renderInlineForm(): TemplateResult {
|
||||||
return html`<form class="pf-c-form pf-m-horizontal">
|
return html`<ak-form-element-horizontal
|
||||||
<ak-form-element-horizontal
|
|
||||||
label=${msg("Common Name")}
|
label=${msg("Common Name")}
|
||||||
name="commonName"
|
name="commonName"
|
||||||
?required=${true}
|
?required=${true}
|
||||||
|
@ -41,7 +40,6 @@ export class CertificateKeyPairForm extends Form<CertificateGenerationRequest> {
|
||||||
?required=${true}
|
?required=${true}
|
||||||
>
|
>
|
||||||
<input class="pf-c-form-control" type="number" value="365" />
|
<input class="pf-c-form-control" type="number" value="365" />
|
||||||
</ak-form-element-horizontal>
|
</ak-form-element-horizontal>`;
|
||||||
</form>`;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -86,9 +86,8 @@ export class FlowImportForm extends Form<Flow> {
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
renderForm(): TemplateResult {
|
renderInlineForm(): TemplateResult {
|
||||||
return html`<form class="pf-c-form pf-m-horizontal">
|
return html`<ak-form-element-horizontal label=${msg("Flow")} name="flow">
|
||||||
<ak-form-element-horizontal label=${msg("Flow")} name="flow">
|
|
||||||
<input type="file" value="" class="pf-c-form-control" />
|
<input type="file" value="" class="pf-c-form-control" />
|
||||||
<p class="pf-c-form__helper-text">
|
<p class="pf-c-form__helper-text">
|
||||||
${msg(
|
${msg(
|
||||||
|
@ -96,7 +95,6 @@ export class FlowImportForm extends Form<Flow> {
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
</ak-form-element-horizontal>
|
</ak-form-element-horizontal>
|
||||||
${this.result ? this.renderResult() : html``}
|
${this.result ? this.renderResult() : html``}`;
|
||||||
</form>`;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -45,41 +45,39 @@ export class RelatedGroupAdd extends Form<{ groups: string[] }> {
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
renderForm(): TemplateResult {
|
renderInlineForm(): TemplateResult {
|
||||||
return html`<form class="pf-c-form pf-m-horizontal">
|
return html`<ak-form-element-horizontal label=${msg("Groups to add")} name="groups">
|
||||||
<ak-form-element-horizontal label=${msg("Groups to add")} name="groups">
|
<div class="pf-c-input-group">
|
||||||
<div class="pf-c-input-group">
|
<ak-user-group-select-table
|
||||||
<ak-user-group-select-table
|
.confirm=${(items: Group[]) => {
|
||||||
.confirm=${(items: Group[]) => {
|
this.groupsToAdd = items;
|
||||||
this.groupsToAdd = items;
|
this.requestUpdate();
|
||||||
this.requestUpdate();
|
return Promise.resolve();
|
||||||
return Promise.resolve();
|
}}
|
||||||
}}
|
>
|
||||||
>
|
<button slot="trigger" class="pf-c-button pf-m-control" type="button">
|
||||||
<button slot="trigger" class="pf-c-button pf-m-control" type="button">
|
<i class="fas fa-plus" aria-hidden="true"></i>
|
||||||
<i class="fas fa-plus" aria-hidden="true"></i>
|
</button>
|
||||||
</button>
|
</ak-user-group-select-table>
|
||||||
</ak-user-group-select-table>
|
<div class="pf-c-form-control">
|
||||||
<div class="pf-c-form-control">
|
<ak-chip-group>
|
||||||
<ak-chip-group>
|
${this.groupsToAdd.map((group) => {
|
||||||
${this.groupsToAdd.map((group) => {
|
return html`<ak-chip
|
||||||
return html`<ak-chip
|
.removable=${true}
|
||||||
.removable=${true}
|
value=${ifDefined(group.pk)}
|
||||||
value=${ifDefined(group.pk)}
|
@remove=${() => {
|
||||||
@remove=${() => {
|
const idx = this.groupsToAdd.indexOf(group);
|
||||||
const idx = this.groupsToAdd.indexOf(group);
|
this.groupsToAdd.splice(idx, 1);
|
||||||
this.groupsToAdd.splice(idx, 1);
|
this.requestUpdate();
|
||||||
this.requestUpdate();
|
}}
|
||||||
}}
|
>
|
||||||
>
|
${group.name}
|
||||||
${group.name}
|
</ak-chip>`;
|
||||||
</ak-chip>`;
|
})}
|
||||||
})}
|
</ak-chip-group>
|
||||||
</ak-chip-group>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</ak-form-element-horizontal>
|
</div>
|
||||||
</form> `;
|
</ak-form-element-horizontal>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -115,9 +115,8 @@ export class PolicyTestForm extends Form<PolicyTestRequest> {
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
renderForm(): TemplateResult {
|
renderInlineForm(): TemplateResult {
|
||||||
return html`<form class="pf-c-form pf-m-horizontal">
|
return html`<ak-form-element-horizontal label=${msg("User")} ?required=${true} name="user">
|
||||||
<ak-form-element-horizontal label=${msg("User")} ?required=${true} name="user">
|
|
||||||
<ak-search-select
|
<ak-search-select
|
||||||
.fetchObjects=${async (query?: string): Promise<User[]> => {
|
.fetchObjects=${async (query?: string): Promise<User[]> => {
|
||||||
const args: CoreUsersListRequest = {
|
const args: CoreUsersListRequest = {
|
||||||
|
@ -154,7 +153,6 @@ export class PolicyTestForm extends Form<PolicyTestRequest> {
|
||||||
${msg("Set custom attributes using YAML or JSON.")}
|
${msg("Set custom attributes using YAML or JSON.")}
|
||||||
</p>
|
</p>
|
||||||
</ak-form-element-horizontal>
|
</ak-form-element-horizontal>
|
||||||
${this.result ? this.renderResult() : html``}
|
${this.result ? this.renderResult() : html``}`;
|
||||||
</form>`;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -118,9 +118,8 @@ export class PolicyTestForm extends Form<PolicyTestRequest> {
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
renderForm(): TemplateResult {
|
renderInlineForm(): TemplateResult {
|
||||||
return html`<form class="pf-c-form pf-m-horizontal">
|
return html`<ak-form-element-horizontal label=${msg("User")} ?required=${true} name="user">
|
||||||
<ak-form-element-horizontal label=${msg("User")} ?required=${true} name="user">
|
|
||||||
<ak-search-select
|
<ak-search-select
|
||||||
.fetchObjects=${async (query?: string): Promise<User[]> => {
|
.fetchObjects=${async (query?: string): Promise<User[]> => {
|
||||||
const args: CoreUsersListRequest = {
|
const args: CoreUsersListRequest = {
|
||||||
|
@ -155,7 +154,6 @@ export class PolicyTestForm extends Form<PolicyTestRequest> {
|
||||||
</ak-codemirror>
|
</ak-codemirror>
|
||||||
<p class="pf-c-form__helper-text">${this.renderExampleButtons()}</p>
|
<p class="pf-c-form__helper-text">${this.renderExampleButtons()}</p>
|
||||||
</ak-form-element-horizontal>
|
</ak-form-element-horizontal>
|
||||||
${this.result ? this.renderResult() : html``}
|
${this.result ? this.renderResult() : html``}`;
|
||||||
</form>`;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -36,9 +36,8 @@ export class SAMLProviderImportForm extends Form<SAMLProvider> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
renderForm(): TemplateResult {
|
renderInlineForm(): TemplateResult {
|
||||||
return html`<form class="pf-c-form pf-m-horizontal">
|
return html`<ak-form-element-horizontal label=${msg("Name")} ?required=${true} name="name">
|
||||||
<ak-form-element-horizontal label=${msg("Name")} ?required=${true} name="name">
|
|
||||||
<input type="text" class="pf-c-form-control" required />
|
<input type="text" class="pf-c-form-control" required />
|
||||||
</ak-form-element-horizontal>
|
</ak-form-element-horizontal>
|
||||||
<ak-form-element-horizontal
|
<ak-form-element-horizontal
|
||||||
|
@ -76,7 +75,6 @@ export class SAMLProviderImportForm extends Form<SAMLProvider> {
|
||||||
|
|
||||||
<ak-form-element-horizontal label=${msg("Metadata")} name="metadata">
|
<ak-form-element-horizontal label=${msg("Metadata")} name="metadata">
|
||||||
<input type="file" value="" class="pf-c-form-control" />
|
<input type="file" value="" class="pf-c-form-control" />
|
||||||
</ak-form-element-horizontal>
|
</ak-form-element-horizontal>`;
|
||||||
</form>`;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -58,9 +58,8 @@ export class RelatedUserAdd extends Form<{ users: number[] }> {
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
renderForm(): TemplateResult {
|
renderInlineForm(): TemplateResult {
|
||||||
return html`<form class="pf-c-form pf-m-horizontal">
|
return html`${this.group?.isSuperuser ? html`` : html``}
|
||||||
${this.group?.isSuperuser ? html`` : html``}
|
|
||||||
<ak-form-element-horizontal label=${msg("Users to add")} name="users">
|
<ak-form-element-horizontal label=${msg("Users to add")} name="users">
|
||||||
<div class="pf-c-input-group">
|
<div class="pf-c-input-group">
|
||||||
<ak-group-member-select-table
|
<ak-group-member-select-table
|
||||||
|
@ -92,8 +91,7 @@ export class RelatedUserAdd extends Form<{ users: number[] }> {
|
||||||
</ak-chip-group>
|
</ak-chip-group>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ak-form-element-horizontal>
|
</ak-form-element-horizontal>`;
|
||||||
</form> `;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -194,12 +192,20 @@ export class RelatedUserList extends Table<User> {
|
||||||
</ak-forms-modal>
|
</ak-forms-modal>
|
||||||
${rootInterface()?.config?.capabilities.includes(CapabilitiesEnum.CanImpersonate)
|
${rootInterface()?.config?.capabilities.includes(CapabilitiesEnum.CanImpersonate)
|
||||||
? html`
|
? html`
|
||||||
<a
|
<ak-action-button
|
||||||
class="pf-c-button pf-m-tertiary"
|
class="pf-m-tertiary"
|
||||||
href="${`/-/impersonation/${item.pk}/`}"
|
.apiRequest=${() => {
|
||||||
|
return new CoreApi(DEFAULT_CONFIG)
|
||||||
|
.coreUsersImpersonateCreate({
|
||||||
|
id: item.pk,
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
window.location.href = "/";
|
||||||
|
});
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
${msg("Impersonate")}
|
${msg("Impersonate")}
|
||||||
</a>
|
</ak-action-button>
|
||||||
`
|
`
|
||||||
: html``}`,
|
: html``}`,
|
||||||
];
|
];
|
||||||
|
|
|
@ -34,9 +34,12 @@ export class ServiceAccountForm extends Form<UserServiceAccountRequest> {
|
||||||
this.result = undefined;
|
this.result = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
renderRequestForm(): TemplateResult {
|
renderInlineForm(): TemplateResult {
|
||||||
return html`<form class="pf-c-form pf-m-horizontal">
|
return html`<ak-form-element-horizontal
|
||||||
<ak-form-element-horizontal label=${msg("Username")} ?required=${true} name="name">
|
label=${msg("Username")}
|
||||||
|
?required=${true}
|
||||||
|
name="name"
|
||||||
|
>
|
||||||
<input type="text" value="" class="pf-c-form-control" required />
|
<input type="text" value="" class="pf-c-form-control" required />
|
||||||
<p class="pf-c-form__helper-text">
|
<p class="pf-c-form__helper-text">
|
||||||
${msg("User's primary identifier. 150 characters or fewer.")}
|
${msg("User's primary identifier. 150 characters or fewer.")}
|
||||||
|
@ -81,8 +84,7 @@ export class ServiceAccountForm extends Form<UserServiceAccountRequest> {
|
||||||
value="${dateTimeLocal(new Date(Date.now() + 1000 * 60 ** 2 * 24 * 360))}"
|
value="${dateTimeLocal(new Date(Date.now() + 1000 * 60 ** 2 * 24 * 360))}"
|
||||||
class="pf-c-form-control"
|
class="pf-c-form-control"
|
||||||
/>
|
/>
|
||||||
</ak-form-element-horizontal>
|
</ak-form-element-horizontal>`;
|
||||||
</form>`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
renderResponseForm(): TemplateResult {
|
renderResponseForm(): TemplateResult {
|
||||||
|
@ -120,6 +122,6 @@ export class ServiceAccountForm extends Form<UserServiceAccountRequest> {
|
||||||
if (this.result) {
|
if (this.result) {
|
||||||
return this.renderResponseForm();
|
return this.renderResponseForm();
|
||||||
}
|
}
|
||||||
return this.renderRequestForm();
|
return super.renderForm();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -197,12 +197,20 @@ export class UserListPage extends TablePage<User> {
|
||||||
</ak-forms-modal>
|
</ak-forms-modal>
|
||||||
${rootInterface()?.config?.capabilities.includes(CapabilitiesEnum.CanImpersonate)
|
${rootInterface()?.config?.capabilities.includes(CapabilitiesEnum.CanImpersonate)
|
||||||
? html`
|
? html`
|
||||||
<a
|
<ak-action-button
|
||||||
class="pf-c-button pf-m-tertiary"
|
class="pf-m-tertiary"
|
||||||
href="${`/-/impersonation/${item.pk}/`}"
|
.apiRequest=${() => {
|
||||||
|
return new CoreApi(DEFAULT_CONFIG)
|
||||||
|
.coreUsersImpersonateCreate({
|
||||||
|
id: item.pk,
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
window.location.href = "/";
|
||||||
|
});
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
${msg("Impersonate")}
|
${msg("Impersonate")}
|
||||||
</a>
|
</ak-action-button>
|
||||||
`
|
`
|
||||||
: html``}`,
|
: html``}`,
|
||||||
];
|
];
|
||||||
|
|
|
@ -25,11 +25,13 @@ export class UserPasswordForm extends Form<UserPasswordSetRequest> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
renderForm(): TemplateResult {
|
renderInlineForm(): TemplateResult {
|
||||||
return html`<form class="pf-c-form pf-m-horizontal">
|
return html`<ak-form-element-horizontal
|
||||||
<ak-form-element-horizontal label=${msg("Password")} ?required=${true} name="password">
|
label=${msg("Password")}
|
||||||
<input type="password" value="" class="pf-c-form-control" required />
|
?required=${true}
|
||||||
</ak-form-element-horizontal>
|
name="password"
|
||||||
</form>`;
|
>
|
||||||
|
<input type="password" value="" class="pf-c-form-control" required />
|
||||||
|
</ak-form-element-horizontal>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -31,36 +31,34 @@ export class UserResetEmailForm extends Form<CoreUsersRecoveryEmailRetrieveReque
|
||||||
return new CoreApi(DEFAULT_CONFIG).coreUsersRecoveryEmailRetrieve(data);
|
return new CoreApi(DEFAULT_CONFIG).coreUsersRecoveryEmailRetrieve(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
renderForm(): TemplateResult {
|
renderInlineForm(): TemplateResult {
|
||||||
return html`<form class="pf-c-form pf-m-horizontal">
|
return html`<ak-form-element-horizontal
|
||||||
<ak-form-element-horizontal
|
label=${msg("Email stage")}
|
||||||
label=${msg("Email stage")}
|
?required=${true}
|
||||||
?required=${true}
|
name="emailStage"
|
||||||
name="emailStage"
|
>
|
||||||
|
<ak-search-select
|
||||||
|
.fetchObjects=${async (query?: string): Promise<Stage[]> => {
|
||||||
|
const args: StagesAllListRequest = {
|
||||||
|
ordering: "name",
|
||||||
|
};
|
||||||
|
if (query !== undefined) {
|
||||||
|
args.search = query;
|
||||||
|
}
|
||||||
|
const stages = await new StagesApi(DEFAULT_CONFIG).stagesEmailList(args);
|
||||||
|
return stages.results;
|
||||||
|
}}
|
||||||
|
.groupBy=${(items: Stage[]) => {
|
||||||
|
return groupBy(items, (stage) => stage.verboseNamePlural);
|
||||||
|
}}
|
||||||
|
.renderElement=${(stage: Stage): string => {
|
||||||
|
return stage.name;
|
||||||
|
}}
|
||||||
|
.value=${(stage: Stage | undefined): string | undefined => {
|
||||||
|
return stage?.pk;
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<ak-search-select
|
</ak-search-select>
|
||||||
.fetchObjects=${async (query?: string): Promise<Stage[]> => {
|
</ak-form-element-horizontal>`;
|
||||||
const args: StagesAllListRequest = {
|
|
||||||
ordering: "name",
|
|
||||||
};
|
|
||||||
if (query !== undefined) {
|
|
||||||
args.search = query;
|
|
||||||
}
|
|
||||||
const stages = await new StagesApi(DEFAULT_CONFIG).stagesEmailList(args);
|
|
||||||
return stages.results;
|
|
||||||
}}
|
|
||||||
.groupBy=${(items: Stage[]) => {
|
|
||||||
return groupBy(items, (stage) => stage.verboseNamePlural);
|
|
||||||
}}
|
|
||||||
.renderElement=${(stage: Stage): string => {
|
|
||||||
return stage.name;
|
|
||||||
}}
|
|
||||||
.value=${(stage: Stage | undefined): string | undefined => {
|
|
||||||
return stage?.pk;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
</ak-search-select>
|
|
||||||
</ak-form-element-horizontal>
|
|
||||||
</form>`;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -204,12 +204,20 @@ export class UserViewPage extends AKElement {
|
||||||
)
|
)
|
||||||
? html`
|
? html`
|
||||||
<div class="pf-c-card__footer">
|
<div class="pf-c-card__footer">
|
||||||
<a
|
<ak-action-button
|
||||||
class="pf-c-button pf-m-tertiary"
|
class="pf-m-tertiary"
|
||||||
href="${`/-/impersonation/${this.user?.pk}/`}"
|
.apiRequest=${() => {
|
||||||
|
return new CoreApi(DEFAULT_CONFIG)
|
||||||
|
.coreUsersImpersonateCreate({
|
||||||
|
id: this.user?.pk || 0,
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
window.location.href = "/";
|
||||||
|
});
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
${msg("Impersonate")}
|
${msg("Impersonate")}
|
||||||
</a>
|
</ak-action-button>
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
: html``}
|
: html``}
|
||||||
|
|
|
@ -46,6 +46,7 @@ export class Diagram extends AKElement {
|
||||||
flowchart: {
|
flowchart: {
|
||||||
curve: "linear",
|
curve: "linear",
|
||||||
},
|
},
|
||||||
|
htmlLabels: false,
|
||||||
};
|
};
|
||||||
mermaid.initialize(this.config);
|
mermaid.initialize(this.config);
|
||||||
}
|
}
|
||||||
|
|
|
@ -283,9 +283,23 @@ export abstract class Form<T> extends AKElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
renderForm(): TemplateResult {
|
renderForm(): TemplateResult {
|
||||||
|
const inline = this.renderInlineForm();
|
||||||
|
if (inline) {
|
||||||
|
return html`<form class="pf-c-form pf-m-horizontal" @submit=${this.submit}>
|
||||||
|
${inline}
|
||||||
|
</form>`;
|
||||||
|
}
|
||||||
return html`<slot></slot>`;
|
return html`<slot></slot>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inline form render callback when inheriting this class, should be overwritten
|
||||||
|
* instead of `this.renderForm`
|
||||||
|
*/
|
||||||
|
renderInlineForm(): TemplateResult | undefined {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
renderNonFieldErrors(): TemplateResult {
|
renderNonFieldErrors(): TemplateResult {
|
||||||
if (!this.nonFieldErrors) {
|
if (!this.nonFieldErrors) {
|
||||||
return html``;
|
return html``;
|
||||||
|
|
|
@ -11,6 +11,7 @@ import { me } from "@goauthentik/common/users";
|
||||||
import { first } from "@goauthentik/common/utils";
|
import { first } from "@goauthentik/common/utils";
|
||||||
import { WebsocketClient } from "@goauthentik/common/ws";
|
import { WebsocketClient } from "@goauthentik/common/ws";
|
||||||
import { Interface } from "@goauthentik/elements/Base";
|
import { Interface } from "@goauthentik/elements/Base";
|
||||||
|
import "@goauthentik/elements/buttons/ActionButton";
|
||||||
import "@goauthentik/elements/messages/MessageContainer";
|
import "@goauthentik/elements/messages/MessageContainer";
|
||||||
import "@goauthentik/elements/notifications/APIDrawer";
|
import "@goauthentik/elements/notifications/APIDrawer";
|
||||||
import "@goauthentik/elements/notifications/NotificationDrawer";
|
import "@goauthentik/elements/notifications/NotificationDrawer";
|
||||||
|
@ -35,7 +36,7 @@ import PFPage from "@patternfly/patternfly/components/Page/page.css";
|
||||||
import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||||
import PFDisplay from "@patternfly/patternfly/utilities/Display/display.css";
|
import PFDisplay from "@patternfly/patternfly/utilities/Display/display.css";
|
||||||
|
|
||||||
import { EventsApi, SessionUser } from "@goauthentik/api";
|
import { CoreApi, EventsApi, SessionUser } from "@goauthentik/api";
|
||||||
|
|
||||||
autoDetectLanguage();
|
autoDetectLanguage();
|
||||||
|
|
||||||
|
@ -233,18 +234,23 @@ export class UserInterface extends Interface {
|
||||||
: html``}
|
: html``}
|
||||||
</div>
|
</div>
|
||||||
${this.me.original
|
${this.me.original
|
||||||
? html`<div class="pf-c-page__header-tools">
|
? html`
|
||||||
<div class="pf-c-page__header-tools-group">
|
<div class="pf-c-page__header-tools">
|
||||||
<a
|
<div class="pf-c-page__header-tools-group">
|
||||||
class="pf-c-button pf-m-warning pf-m-small"
|
<ak-action-button
|
||||||
href=${`/-/impersonation/end/?back=${encodeURIComponent(
|
class="pf-m-warning pf-m-small"
|
||||||
`${window.location.pathname}#${window.location.hash}`,
|
.apiRequest=${() => {
|
||||||
)}`}
|
return new CoreApi(DEFAULT_CONFIG)
|
||||||
>
|
.coreUsersImpersonateEndRetrieve()
|
||||||
${msg("Stop impersonation")}
|
.then(() => {
|
||||||
</a>
|
window.location.reload();
|
||||||
</div>
|
});
|
||||||
</div>`
|
}}
|
||||||
|
>
|
||||||
|
${msg("Stop impersonation")}
|
||||||
|
</ak-action-button>
|
||||||
|
</div>
|
||||||
|
</div>`
|
||||||
: html``}
|
: html``}
|
||||||
<div class="pf-c-page__header-tools-group">
|
<div class="pf-c-page__header-tools-group">
|
||||||
<div class="pf-c-page__header-tools-item pf-m-hidden pf-m-visible-on-md">
|
<div class="pf-c-page__header-tools-item pf-m-hidden pf-m-visible-on-md">
|
||||||
|
|
|
@ -107,6 +107,10 @@ image:
|
||||||
- lifecycle: fix worker healthcheck (#5259)
|
- lifecycle: fix worker healthcheck (#5259)
|
||||||
- lifecycle: re-add exec to ak wrapper (#5253)
|
- lifecycle: re-add exec to ak wrapper (#5253)
|
||||||
|
|
||||||
|
## Fixed in 2023.4.2
|
||||||
|
|
||||||
|
- security: Address pen-test findings from the [2023-06 Cure53 Code audit](../../security/2023-06-cure53.md)
|
||||||
|
|
||||||
## API Changes
|
## API Changes
|
||||||
|
|
||||||
#### What's New
|
#### What's New
|
||||||
|
|
|
@ -144,6 +144,10 @@ image:
|
||||||
- providers/ldap: fix LDAP Outpost application selection (#5812)
|
- providers/ldap: fix LDAP Outpost application selection (#5812)
|
||||||
- web/flows: fix RedirectStage not detecting absolute URLs correctly (#5781)
|
- web/flows: fix RedirectStage not detecting absolute URLs correctly (#5781)
|
||||||
|
|
||||||
|
## Fixed in 2023.5.4
|
||||||
|
|
||||||
|
- security: Address pen-test findings from the [2023-06 Cure53 Code audit](../../security/2023-06-cure53.md)
|
||||||
|
|
||||||
## API Changes
|
## API Changes
|
||||||
|
|
||||||
#### What's Changed
|
#### What's Changed
|
||||||
|
|
67
website/docs/security/2023-06-cure53.md
Normal file
67
website/docs/security/2023-06-cure53.md
Normal file
|
@ -0,0 +1,67 @@
|
||||||
|
# 2023-06 Cure53 Code audit
|
||||||
|
|
||||||
|
In May/June of 2023, we've had a Pen-test conducted by [Cure53](https://cure53.de). The following security updates, 2023.4.2 and 2023.5.3 were released as a response to the found issues.
|
||||||
|
|
||||||
|
From the complete report, these are the points we're addressing with this update:
|
||||||
|
|
||||||
|
### ATH-01-001: Path traversal on blueprints allows arbitrary file-read (Medium)
|
||||||
|
|
||||||
|
This had accidentally been patched by a previous commit already; and was also only possible for users with superuser permissions.
|
||||||
|
|
||||||
|
### ATH-01-003: CSS injection via faulty string replacement in Mermaid (Low)
|
||||||
|
|
||||||
|
This is an unrelated issue that was found with a third-party dependency ([Mermaid](https://mermaid.js.org/)), fixed with https://github.com/mermaid-js/mermaid/releases/tag/v10.2.2
|
||||||
|
|
||||||
|
Additionally we've also taken steps to further mitigate possible issues that could be caused in this way.
|
||||||
|
|
||||||
|
### ATH-01-008: User-passwords disclosed to third-party service (High)
|
||||||
|
|
||||||
|
In certain circumstances, using the Enter key to submit some forms instead of clicking submit would cause the frontend to change the URL instead of calling the API, which could lead to sensitive data being disclosed.
|
||||||
|
|
||||||
|
### ATH-01-009: Lack of CSRF protection in impersonate feature (Low)
|
||||||
|
|
||||||
|
Previous the URL to start an impersonation was a simple GET URL request, which was susceptible to CSRF. This has been changed to an API Post request.
|
||||||
|
|
||||||
|
### ATH-01-010: Web authentication bypass via key confusion (High)
|
||||||
|
|
||||||
|
When using WebAuthn to authenticate, the owner of the WebAuthn device wasn't checked. However to exploit this, an attacker would need to be able to already intercept HTTP traffic and read the data.
|
||||||
|
|
||||||
|
### ATH-01-014: Authentication challenges abused by foreign flow (Medium)
|
||||||
|
|
||||||
|
Previously it was possible to use an MFA authenticator class that wasn't allowed in a flow, if another flow existed that allowed this class. The patch changes data to be isolated per flow to prevent this issue.
|
||||||
|
|
||||||
|
### ATH-01-004: Information disclosure on system endpoint (Info)
|
||||||
|
|
||||||
|
The `/api/v3/admin/system/` (only accessible to superusers) endpoint returns a large amount of system info (mostly used for debugging), like the HTTP headers sent to the server. It also included all environment variables set for authentik. The environment variables have been removed.
|
||||||
|
|
||||||
|
### ATH-01-005: Timing-unsafe comparison in API authentication (Info)
|
||||||
|
|
||||||
|
In the API authentication that is used by the embedded outpost (API authentication via Secret key), a timing-unsafe comparison was used.
|
||||||
|
|
||||||
|
### ATH-01-012: Unintended diagram created due to unescaped quotes (Info)
|
||||||
|
|
||||||
|
Related to ATH-01-003, it was possible to insert unintended diagrams into generated diagrams.
|
||||||
|
|
||||||
|
## Additional info
|
||||||
|
|
||||||
|
In addition to the points above, several of the findings are classified as intended features (such as the expression policies), however these are points where we do also see room for improvement that we will address in the future.
|
||||||
|
|
||||||
|
### ATH-01-002: Stored XSS in help text of prompt module (Medium)
|
||||||
|
|
||||||
|
Prompt help texts can use HTML to add markup, which also includes the option to include JavaScript. This is only possible to configure for superusers, and in the future we're planning to add an additional toggle to limit this.
|
||||||
|
|
||||||
|
### ATH-01-006: Arbitrary code execution via expressions (Critical)
|
||||||
|
|
||||||
|
This is the intended function of expression policies/property mappings, which also requires superuser permissions to edit. We're planning to also add a toggle to limit the functions that can be executed to the ones provided by authentik, and prevent the importing of modules.
|
||||||
|
|
||||||
|
### ATH-01-007: SSRF via blueprints feature for fetching manifests (Medium)
|
||||||
|
|
||||||
|
Blueprints can be fetched via OCI registries, which could be potentially used for server-side request forgery. This can only be accessed by superusers, and we're planning to add an option to limit the resolved IP ranges this functionality can connect to.
|
||||||
|
|
||||||
|
### ATH-01-013: XSS via CAPTCHA JavaScript URL (Medium)
|
||||||
|
|
||||||
|
Similar to ATH-01-002, any arbitrary JavaScript can be loaded using the Captcha stage. This is also limited to superusers.
|
||||||
|
|
||||||
|
### ATH-01-011: Weak default configs in logout/change password flows (Info)
|
||||||
|
|
||||||
|
The default logout flow does not do any additional validation and logs the user out with a single GET request. The default password-change flow does not verify the users current password, nor does it show the current users info.
|
|
@ -321,6 +321,7 @@ module.exports = {
|
||||||
},
|
},
|
||||||
items: [
|
items: [
|
||||||
"security/policy",
|
"security/policy",
|
||||||
|
"security/2023-06-cure53",
|
||||||
"security/CVE-2022-23555",
|
"security/CVE-2022-23555",
|
||||||
"security/CVE-2022-46145",
|
"security/CVE-2022-46145",
|
||||||
"security/CVE-2022-46172",
|
"security/CVE-2022-46172",
|
||||||
|
|
Reference in a new issue