Compare commits
12 Commits
root/maint
...
version/20
Author | SHA1 | Date | |
---|---|---|---|
2739376a2a | |||
152121175b | |||
1d57a258f3 | |||
f15cac39c8 | |||
ce77d82b24 | |||
c3fe57197d | |||
267938d435 | |||
6a7c2e0662 | |||
5336afb1b4 | |||
9bb44055a3 | |||
143663d293 | |||
bd54d034e1 |
@ -1,5 +1,5 @@
|
||||
[bumpversion]
|
||||
current_version = 2023.5.3
|
||||
current_version = 2023.5.4
|
||||
tag = True
|
||||
commit = True
|
||||
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)
|
||||
|
@ -2,7 +2,7 @@
|
||||
from os import environ
|
||||
from typing import Optional
|
||||
|
||||
__version__ = "2023.5.3"
|
||||
__version__ = "2023.5.4"
|
||||
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
|
||||
|
||||
|
||||
|
@ -1,5 +1,4 @@
|
||||
"""authentik administration overview"""
|
||||
import os
|
||||
import platform
|
||||
from datetime import datetime
|
||||
from sys import version as python_version
|
||||
@ -34,7 +33,6 @@ class RuntimeDict(TypedDict):
|
||||
class SystemSerializer(PassiveSerializer):
|
||||
"""Get system information."""
|
||||
|
||||
env = SerializerMethodField()
|
||||
http_headers = SerializerMethodField()
|
||||
http_host = SerializerMethodField()
|
||||
http_is_secure = SerializerMethodField()
|
||||
@ -43,10 +41,6 @@ class SystemSerializer(PassiveSerializer):
|
||||
server_time = 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]:
|
||||
"""Get HTTP Request headers"""
|
||||
headers = {}
|
||||
|
@ -1,4 +1,5 @@
|
||||
"""API Authentication"""
|
||||
from hmac import compare_digest
|
||||
from typing import Any, Optional
|
||||
|
||||
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"""
|
||||
from authentik.outposts.apps import MANAGED_OUTPOST
|
||||
|
||||
if value != settings.SECRET_KEY:
|
||||
if not compare_digest(value, settings.SECRET_KEY):
|
||||
return None
|
||||
outposts = Outpost.objects.filter(managed=MANAGED_OUTPOST)
|
||||
if not outposts:
|
||||
|
@ -82,7 +82,10 @@ class BlueprintInstance(SerializerModel, ManagedModel, CreatedUpdatedModel):
|
||||
def retrieve_file(self) -> str:
|
||||
"""Get blueprint from path"""
|
||||
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:
|
||||
return _file.read()
|
||||
except (IOError, OSError) as exc:
|
||||
|
@ -1,34 +1,15 @@
|
||||
"""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
|
||||
from authentik.blueprints.models import BlueprintInstance, BlueprintRetrievalFailed
|
||||
from authentik.lib.generators import generate_id
|
||||
|
||||
|
||||
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))
|
||||
def test_retrieve_file(self):
|
||||
"""Test retrieve_file"""
|
||||
instance = BlueprintInstance.objects.create(name=generate_id(), path="../etc/hosts")
|
||||
with self.assertRaises(BlueprintRetrievalFailed):
|
||||
instance.retrieve()
|
||||
|
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))
|
@ -67,11 +67,12 @@ from authentik.core.models import (
|
||||
TokenIntents,
|
||||
User,
|
||||
)
|
||||
from authentik.events.models import EventAction
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.flows.exceptions import FlowNonApplicableException
|
||||
from authentik.flows.models import FlowToken
|
||||
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlanner
|
||||
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.tasks import send_mails
|
||||
from authentik.stages.email.utils import TemplateEmailMessage
|
||||
@ -543,6 +544,58 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
||||
send_mails(email_stage, message)
|
||||
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:
|
||||
"""Custom filter_queryset method which ignores guardian, but still supports sorting"""
|
||||
for backend in list(self.filter_backends):
|
||||
|
@ -1,14 +1,14 @@
|
||||
"""impersonation tests"""
|
||||
from json import loads
|
||||
|
||||
from django.test.testcases import TestCase
|
||||
from django.urls import reverse
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from authentik.core.models import User
|
||||
from authentik.core.tests.utils import create_test_admin_user
|
||||
|
||||
|
||||
class TestImpersonation(TestCase):
|
||||
class TestImpersonation(APITestCase):
|
||||
"""impersonation tests"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
@ -23,10 +23,10 @@ class TestImpersonation(TestCase):
|
||||
self.other_user.save()
|
||||
self.client.force_login(self.user)
|
||||
|
||||
self.client.get(
|
||||
self.client.post(
|
||||
reverse(
|
||||
"authentik_core:impersonate-init",
|
||||
kwargs={"user_id": self.other_user.pk},
|
||||
"authentik_api:user-impersonate",
|
||||
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["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_body = loads(response.content.decode())
|
||||
@ -46,9 +46,7 @@ class TestImpersonation(TestCase):
|
||||
"""test impersonation without permissions"""
|
||||
self.client.force_login(self.other_user)
|
||||
|
||||
self.client.get(
|
||||
reverse("authentik_core:impersonate-init", kwargs={"user_id": self.user.pk})
|
||||
)
|
||||
self.client.get(reverse("authentik_api:user-impersonate", kwargs={"pk": self.user.pk}))
|
||||
|
||||
response = self.client.get(reverse("authentik_api:user-me"))
|
||||
response_body = loads(response.content.decode())
|
||||
@ -58,5 +56,5 @@ class TestImpersonation(TestCase):
|
||||
"""test un-impersonation without impersonating first"""
|
||||
self.client.force_login(self.other_user)
|
||||
|
||||
response = self.client.get(reverse("authentik_core:impersonate-end"))
|
||||
self.assertRedirects(response, reverse("authentik_core:if-user"))
|
||||
response = self.client.get(reverse("authentik_api:user-impersonate-end"))
|
||||
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.tokens import TokenViewSet
|
||||
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.interface import FlowInterfaceView, InterfaceView
|
||||
from authentik.core.views.session import EndSessionView
|
||||
@ -38,17 +38,6 @@ urlpatterns = [
|
||||
apps.RedirectToAppLaunch.as_view(),
|
||||
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
|
||||
path(
|
||||
"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: ["[", "]"])
|
||||
|
||||
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 != "":
|
||||
element = f"--{self.action}--> {element}"
|
||||
|
@ -204,12 +204,12 @@ class ChallengeStageView(StageView):
|
||||
for field, errors in response.errors.items():
|
||||
for error in errors:
|
||||
full_errors.setdefault(field, [])
|
||||
full_errors[field].append(
|
||||
{
|
||||
"string": str(error),
|
||||
"code": error.code,
|
||||
}
|
||||
)
|
||||
field_error = {
|
||||
"string": str(error),
|
||||
}
|
||||
if hasattr(error, "code"):
|
||||
field_error["code"] = error.code
|
||||
full_errors[field].append(field_error)
|
||||
challenge_response.initial_data["response_errors"] = full_errors
|
||||
if not challenge_response.is_valid():
|
||||
self.logger.error(
|
||||
|
@ -132,9 +132,9 @@ class TestPolicyProcess(TestCase):
|
||||
)
|
||||
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.resolver_match = resolve(reverse("authentik_core:impersonate-end"))
|
||||
http_request.resolver_match = resolve(reverse("authentik_api:user-impersonate-end"))
|
||||
|
||||
request = PolicyRequest(self.user)
|
||||
request.set_http_request(http_request)
|
||||
|
@ -66,8 +66,8 @@ def ldap_sync_password(sender, user: User, password: str, **_):
|
||||
if not sources.exists():
|
||||
return
|
||||
source = sources.first()
|
||||
changer = LDAPPasswordChanger(source)
|
||||
try:
|
||||
changer = LDAPPasswordChanger(source)
|
||||
changer.change_password(user, password)
|
||||
except LDAPOperationResult as 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()
|
||||
if not 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
|
||||
|
||||
|
@ -36,9 +36,9 @@ from authentik.stages.password.stage import PLAN_CONTEXT_METHOD, PLAN_CONTEXT_ME
|
||||
|
||||
COOKIE_NAME_MFA = "authentik_mfa"
|
||||
|
||||
SESSION_KEY_STAGES = "authentik/stages/authenticator_validate/stages"
|
||||
SESSION_KEY_SELECTED_STAGE = "authentik/stages/authenticator_validate/selected_stage"
|
||||
SESSION_KEY_DEVICE_CHALLENGES = "authentik/stages/authenticator_validate/device_challenges"
|
||||
PLAN_CONTEXT_STAGES = "goauthentik.io/stages/authenticator_validate/stages"
|
||||
PLAN_CONTEXT_SELECTED_STAGE = "goauthentik.io/stages/authenticator_validate/selected_stage"
|
||||
PLAN_CONTEXT_DEVICE_CHALLENGES = "goauthentik.io/stages/authenticator_validate/device_challenges"
|
||||
|
||||
|
||||
class SelectableStageSerializer(PassiveSerializer):
|
||||
@ -72,8 +72,8 @@ class AuthenticatorValidationChallengeResponse(ChallengeResponse):
|
||||
component = CharField(default="ak-stage-authenticator-validate")
|
||||
|
||||
def _challenge_allowed(self, classes: list):
|
||||
device_challenges: list[dict] = self.stage.request.session.get(
|
||||
SESSION_KEY_DEVICE_CHALLENGES, []
|
||||
device_challenges: list[dict] = self.stage.executor.plan.context.get(
|
||||
PLAN_CONTEXT_DEVICE_CHALLENGES, []
|
||||
)
|
||||
if not any(x["device_class"] in classes for x in device_challenges):
|
||||
raise ValidationError("No compatible device class allowed")
|
||||
@ -103,7 +103,9 @@ class AuthenticatorValidationChallengeResponse(ChallengeResponse):
|
||||
"""Check which challenge the user has selected. Actual logic only used for SMS stage."""
|
||||
# First check if the challenge is valid
|
||||
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(
|
||||
"device_class", ""
|
||||
) and device_challenge.get("device_uid", "") == challenge.get("device_uid", ""):
|
||||
@ -121,11 +123,11 @@ class AuthenticatorValidationChallengeResponse(ChallengeResponse):
|
||||
|
||||
def validate_selected_stage(self, stage_pk: str) -> str:
|
||||
"""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):
|
||||
raise ValidationError("Selected stage is invalid")
|
||||
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
|
||||
|
||||
def validate(self, attrs: dict):
|
||||
@ -230,7 +232,7 @@ class AuthenticatorValidateStageView(ChallengeStageView):
|
||||
else:
|
||||
self.logger.debug("No pending user, continuing")
|
||||
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
|
||||
if len(challenges) < 1:
|
||||
@ -263,23 +265,23 @@ class AuthenticatorValidateStageView(ChallengeStageView):
|
||||
if stage.configuration_stages.count() == 1:
|
||||
next_stage = Stage.objects.get_subclass(pk=stage.configuration_stages.first().pk)
|
||||
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
|
||||
# return it
|
||||
self.executor.plan.insert_stage(next_stage)
|
||||
return self.executor.stage_ok()
|
||||
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)
|
||||
|
||||
def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||
res = super().post(request, *args, **kwargs)
|
||||
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
|
||||
):
|
||||
self.logger.debug("Got selected stage in session, running that")
|
||||
stage_pk = self.request.session.get(SESSION_KEY_SELECTED_STAGE)
|
||||
self.logger.debug("Got selected stage in context, running that")
|
||||
stage_pk = self.executor.plan.context(PLAN_CONTEXT_SELECTED_STAGE)
|
||||
# Because the foreign key to stage.configuration_stage points to
|
||||
# a base stage class, we need to do another lookup
|
||||
stage = Stage.objects.get_subclass(pk=stage_pk)
|
||||
@ -290,8 +292,8 @@ class AuthenticatorValidateStageView(ChallengeStageView):
|
||||
return res
|
||||
|
||||
def get_challenge(self) -> AuthenticatorValidationChallenge:
|
||||
challenges = self.request.session.get(SESSION_KEY_DEVICE_CHALLENGES, [])
|
||||
stages = self.request.session.get(SESSION_KEY_STAGES, [])
|
||||
challenges = self.executor.plan.context.get(PLAN_CONTEXT_DEVICE_CHALLENGES, [])
|
||||
stages = self.executor.plan.context.get(PLAN_CONTEXT_STAGES, [])
|
||||
stage_challenges = []
|
||||
for stage in stages:
|
||||
serializer = SelectableStageSerializer(
|
||||
@ -306,6 +308,7 @@ class AuthenticatorValidateStageView(ChallengeStageView):
|
||||
stage_challenges.append(serializer.data)
|
||||
return AuthenticatorValidationChallenge(
|
||||
data={
|
||||
"component": "ak-stage-authenticator-validate",
|
||||
"type": ChallengeTypes.NATIVE.value,
|
||||
"device_challenges": challenges,
|
||||
"configuration_stages": stage_challenges,
|
||||
@ -385,8 +388,3 @@ class AuthenticatorValidateStageView(ChallengeStageView):
|
||||
"device": webauthn_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"""
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from django.contrib.sessions.middleware import SessionMiddleware
|
||||
from django.test.client import RequestFactory
|
||||
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.flows.models import FlowDesignation, FlowStageBinding, NotConfiguredAction
|
||||
from authentik.flows.planner import FlowPlan
|
||||
from authentik.flows.stage import StageView
|
||||
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.tests.utils import dummy_get_response
|
||||
from authentik.stages.authenticator_duo.models import AuthenticatorDuoStage, DuoDevice
|
||||
from authentik.stages.authenticator_validate.api import AuthenticatorValidateStageSerializer
|
||||
from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage, DeviceClasses
|
||||
from authentik.stages.authenticator_validate.stage import (
|
||||
SESSION_KEY_DEVICE_CHALLENGES,
|
||||
AuthenticatorValidationChallengeResponse,
|
||||
)
|
||||
from authentik.stages.authenticator_validate.stage import PLAN_CONTEXT_DEVICE_CHALLENGES
|
||||
from authentik.stages.identification.models import IdentificationStage, UserFields
|
||||
|
||||
|
||||
@ -86,12 +79,17 @@ class AuthenticatorValidateStageTests(FlowTestCase):
|
||||
|
||||
def test_validate_selected_challenge(self):
|
||||
"""Test validate_selected_challenge"""
|
||||
# Prepare request with session
|
||||
request = self.request_factory.get("/")
|
||||
flow = create_test_flow()
|
||||
stage = AuthenticatorValidateStage.objects.create(
|
||||
name=generate_id(),
|
||||
not_configured_action=NotConfiguredAction.CONFIGURE,
|
||||
device_classes=[DeviceClasses.STATIC, DeviceClasses.TOTP],
|
||||
)
|
||||
|
||||
middleware = SessionMiddleware(dummy_get_response)
|
||||
middleware.process_request(request)
|
||||
request.session[SESSION_KEY_DEVICE_CHALLENGES] = [
|
||||
session = self.client.session
|
||||
plan = FlowPlan(flow_pk=flow.pk.hex)
|
||||
plan.append_stage(stage)
|
||||
plan.context[PLAN_CONTEXT_DEVICE_CHALLENGES] = [
|
||||
{
|
||||
"device_class": "static",
|
||||
"device_uid": "1",
|
||||
@ -101,23 +99,43 @@ class AuthenticatorValidateStageTests(FlowTestCase):
|
||||
"device_uid": "2",
|
||||
},
|
||||
]
|
||||
request.session.save()
|
||||
session[SESSION_KEY_PLAN] = plan
|
||||
session.save()
|
||||
|
||||
res = AuthenticatorValidationChallengeResponse()
|
||||
res.stage = StageView(FlowExecutorView())
|
||||
res.stage.request = request
|
||||
with self.assertRaises(ValidationError):
|
||||
res.validate_selected_challenge(
|
||||
{
|
||||
response = self.client.post(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
|
||||
data={
|
||||
"selected_challenge": {
|
||||
"device_class": "baz",
|
||||
"device_uid": "quox",
|
||||
"challenge": {},
|
||||
}
|
||||
)
|
||||
res.validate_selected_challenge(
|
||||
{
|
||||
"device_class": "static",
|
||||
"device_uid": "1",
|
||||
}
|
||||
},
|
||||
)
|
||||
self.assertStageResponse(
|
||||
response,
|
||||
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(
|
||||
|
@ -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.stage import (
|
||||
SESSION_KEY_DEVICE_CHALLENGES,
|
||||
PLAN_CONTEXT_DEVICE_CHALLENGES,
|
||||
AuthenticatorValidateStageView,
|
||||
)
|
||||
from authentik.stages.authenticator_webauthn.models import UserVerification, WebAuthnDevice
|
||||
@ -211,14 +211,14 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase):
|
||||
plan.append_stage(stage)
|
||||
plan.append_stage(UserLoginStage(name=generate_id()))
|
||||
plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
|
||||
session[SESSION_KEY_PLAN] = plan
|
||||
session[SESSION_KEY_DEVICE_CHALLENGES] = [
|
||||
plan.context[PLAN_CONTEXT_DEVICE_CHALLENGES] = [
|
||||
{
|
||||
"device_class": device.__class__.__name__.lower().replace("device", ""),
|
||||
"device_uid": device.pk,
|
||||
"challenge": {},
|
||||
}
|
||||
]
|
||||
session[SESSION_KEY_PLAN] = plan
|
||||
session[SESSION_KEY_WEBAUTHN_CHALLENGE] = base64url_to_bytes(
|
||||
"g98I51mQvZXo5lxLfhrD2zfolhZbLRyCgqkkYap1jwSaJ13BguoJWCF9_Lg3AgO4Wh-Bqa556JE20oKsYbl6RA"
|
||||
)
|
||||
@ -283,14 +283,14 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase):
|
||||
plan = FlowPlan(flow_pk=flow.pk.hex)
|
||||
plan.append_stage(stage)
|
||||
plan.append_stage(UserLoginStage(name=generate_id()))
|
||||
session[SESSION_KEY_PLAN] = plan
|
||||
session[SESSION_KEY_DEVICE_CHALLENGES] = [
|
||||
plan.context[PLAN_CONTEXT_DEVICE_CHALLENGES] = [
|
||||
{
|
||||
"device_class": device.__class__.__name__.lower().replace("device", ""),
|
||||
"device_uid": device.pk,
|
||||
"challenge": {},
|
||||
}
|
||||
]
|
||||
session[SESSION_KEY_PLAN] = plan
|
||||
session[SESSION_KEY_WEBAUTHN_CHALLENGE] = base64url_to_bytes(
|
||||
"g98I51mQvZXo5lxLfhrD2zfolhZbLRyCgqkkYap1jwSaJ13BguoJWCF9_Lg3AgO4Wh-Bqa556JE20oKsYbl6RA"
|
||||
)
|
||||
|
@ -32,7 +32,7 @@ services:
|
||||
volumes:
|
||||
- redis:/data
|
||||
server:
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2023.5.3}
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2023.5.4}
|
||||
restart: unless-stopped
|
||||
command: server
|
||||
environment:
|
||||
@ -50,7 +50,7 @@ services:
|
||||
- "${COMPOSE_PORT_HTTP:-9000}:9000"
|
||||
- "${COMPOSE_PORT_HTTPS:-9443}:9443"
|
||||
worker:
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2023.5.3}
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2023.5.4}
|
||||
restart: unless-stopped
|
||||
command: worker
|
||||
environment:
|
||||
|
@ -29,4 +29,4 @@ func UserAgent() string {
|
||||
return fmt.Sprintf("authentik@%s", FullVersion())
|
||||
}
|
||||
|
||||
const VERSION = "2023.5.3"
|
||||
const VERSION = "2023.5.4"
|
||||
|
@ -113,7 +113,7 @@ filterwarnings = [
|
||||
|
||||
[tool.poetry]
|
||||
name = "authentik"
|
||||
version = "2023.5.3"
|
||||
version = "2023.5.4"
|
||||
description = ""
|
||||
authors = ["authentik Team <hello@goauthentik.io>"]
|
||||
|
||||
|
64
schema.yml
64
schema.yml
@ -1,7 +1,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: authentik
|
||||
version: 2023.5.3
|
||||
version: 2023.5.4
|
||||
description: Making authentication simple.
|
||||
contact:
|
||||
email: hello@goauthentik.io
|
||||
@ -4783,6 +4783,38 @@ paths:
|
||||
schema:
|
||||
$ref: '#/components/schemas/GenericError'
|
||||
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/:
|
||||
get:
|
||||
operationId: core_users_metrics_retrieve
|
||||
@ -4962,6 +4994,29 @@ paths:
|
||||
schema:
|
||||
$ref: '#/components/schemas/GenericError'
|
||||
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/:
|
||||
get:
|
||||
operationId: core_users_me_retrieve
|
||||
@ -40493,12 +40548,6 @@ components:
|
||||
type: object
|
||||
description: Get system information.
|
||||
properties:
|
||||
env:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
description: Get Environment
|
||||
readOnly: true
|
||||
http_headers:
|
||||
type: object
|
||||
additionalProperties:
|
||||
@ -40552,7 +40601,6 @@ components:
|
||||
readOnly: true
|
||||
required:
|
||||
- embedded_outpost_host
|
||||
- env
|
||||
- http_headers
|
||||
- http_host
|
||||
- http_is_secure
|
||||
|
8
web/package-lock.json
generated
8
web/package-lock.json
generated
@ -17,7 +17,7 @@
|
||||
"@codemirror/theme-one-dark": "^6.1.2",
|
||||
"@formatjs/intl-listformat": "^7.2.2",
|
||||
"@fortawesome/fontawesome-free": "^6.4.0",
|
||||
"@goauthentik/api": "^2023.5.0-1684333401",
|
||||
"@goauthentik/api": "^2023.5.3-1687462221",
|
||||
"@lingui/cli": "^4.1.2",
|
||||
"@lingui/core": "^4.1.2",
|
||||
"@lingui/detect-locale": "^4.1.2",
|
||||
@ -2127,9 +2127,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@goauthentik/api": {
|
||||
"version": "2023.5.0-1684333401",
|
||||
"resolved": "https://registry.npmjs.org/@goauthentik/api/-/api-2023.5.0-1684333401.tgz",
|
||||
"integrity": "sha512-lbLXgqhvI65w3uodsJMdGIvFvJUzpleC0M4QneiXH3rTNbufVZWP9WbLCRLynKzEateOf3XSgjQ322BeZBA2bA=="
|
||||
"version": "2023.5.3-1687462221",
|
||||
"resolved": "https://registry.npmjs.org/@goauthentik/api/-/api-2023.5.3-1687462221.tgz",
|
||||
"integrity": "sha512-34LJCBVPOfdlIHhDPQEA7NS7mJvrKJKHSfa2HPQClyVM3o5us8Bp4yJKs2nm4hGime3rbZAwYYq7n75tccr7rQ=="
|
||||
},
|
||||
"node_modules/@hcaptcha/types": {
|
||||
"version": "1.0.3",
|
||||
|
@ -24,7 +24,7 @@
|
||||
"@codemirror/theme-one-dark": "^6.1.2",
|
||||
"@formatjs/intl-listformat": "^7.2.2",
|
||||
"@fortawesome/fontawesome-free": "^6.4.0",
|
||||
"@goauthentik/api": "^2023.5.0-1684333401",
|
||||
"@goauthentik/api": "^2023.5.3-1687462221",
|
||||
"@lingui/cli": "^4.1.2",
|
||||
"@lingui/core": "^4.1.2",
|
||||
"@lingui/detect-locale": "^4.1.2",
|
||||
|
@ -31,7 +31,7 @@ import PFDrawer from "@patternfly/patternfly/components/Drawer/drawer.css";
|
||||
import PFPage from "@patternfly/patternfly/components/Page/page.css";
|
||||
import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||
|
||||
import { AdminApi, SessionUser, Version } from "@goauthentik/api";
|
||||
import { AdminApi, CoreApi, SessionUser, Version } from "@goauthentik/api";
|
||||
|
||||
autoDetectLanguage();
|
||||
|
||||
@ -175,10 +175,11 @@ export class AdminInterface extends Interface {
|
||||
${this.user?.original
|
||||
? html`<ak-sidebar-item
|
||||
?highlight=${true}
|
||||
?isAbsoluteLink=${true}
|
||||
path=${`/-/impersonation/end/?back=${encodeURIComponent(
|
||||
`${window.location.pathname}#${window.location.hash}`,
|
||||
)}`}
|
||||
@click=${() => {
|
||||
new CoreApi(DEFAULT_CONFIG).coreUsersImpersonateEndRetrieve().then(() => {
|
||||
window.location.reload();
|
||||
});
|
||||
}}
|
||||
>
|
||||
<span slot="label"
|
||||
>${t`You're currently impersonating ${this.user.user.username}. Click to stop.`}</span
|
||||
|
@ -115,9 +115,8 @@ export class ApplicationCheckAccessForm extends Form<{ forUser: number }> {
|
||||
`;
|
||||
}
|
||||
|
||||
renderForm(): TemplateResult {
|
||||
return html`<form class="pf-c-form pf-m-horizontal">
|
||||
<ak-form-element-horizontal label=${t`User`} ?required=${true} name="forUser">
|
||||
renderInlineForm(): TemplateResult {
|
||||
return html`<ak-form-element-horizontal label=${t`User`} ?required=${true} name="forUser">
|
||||
<ak-search-select
|
||||
.fetchObjects=${async (query?: string): Promise<User[]> => {
|
||||
const args: CoreUsersListRequest = {
|
||||
@ -144,7 +143,6 @@ export class ApplicationCheckAccessForm extends Form<{ forUser: number }> {
|
||||
>
|
||||
</ak-search-select>
|
||||
</ak-form-element-horizontal>
|
||||
${this.result ? this.renderResult() : html``}
|
||||
</form>`;
|
||||
${this.result ? this.renderResult() : html``}`;
|
||||
}
|
||||
}
|
||||
|
@ -21,9 +21,12 @@ export class CertificateKeyPairForm extends Form<CertificateGenerationRequest> {
|
||||
});
|
||||
}
|
||||
|
||||
renderForm(): TemplateResult {
|
||||
return html`<form class="pf-c-form pf-m-horizontal">
|
||||
<ak-form-element-horizontal label=${t`Common Name`} name="commonName" ?required=${true}>
|
||||
renderInlineForm(): TemplateResult {
|
||||
return html`<ak-form-element-horizontal
|
||||
label=${t`Common Name`}
|
||||
name="commonName"
|
||||
?required=${true}
|
||||
>
|
||||
<input type="text" class="pf-c-form-control" required />
|
||||
</ak-form-element-horizontal>
|
||||
<ak-form-element-horizontal label=${t`Subject-alt name`} name="subjectAltName">
|
||||
@ -38,7 +41,6 @@ export class CertificateKeyPairForm extends Form<CertificateGenerationRequest> {
|
||||
?required=${true}
|
||||
>
|
||||
<input class="pf-c-form-control" type="number" value="365" />
|
||||
</ak-form-element-horizontal>
|
||||
</form>`;
|
||||
</ak-form-element-horizontal>`;
|
||||
}
|
||||
}
|
||||
|
@ -87,15 +87,13 @@ export class FlowImportForm extends Form<Flow> {
|
||||
`;
|
||||
}
|
||||
|
||||
renderForm(): TemplateResult {
|
||||
return html`<form class="pf-c-form pf-m-horizontal">
|
||||
<ak-form-element-horizontal label=${t`Flow`} name="flow">
|
||||
renderInlineForm(): TemplateResult {
|
||||
return html`<ak-form-element-horizontal label=${t`Flow`} name="flow">
|
||||
<input type="file" value="" class="pf-c-form-control" />
|
||||
<p class="pf-c-form__helper-text">
|
||||
${t`.yaml files, which can be found on goauthentik.io and can be exported by authentik.`}
|
||||
</p>
|
||||
</ak-form-element-horizontal>
|
||||
${this.result ? this.renderResult() : html``}
|
||||
</form>`;
|
||||
${this.result ? this.renderResult() : html``}`;
|
||||
}
|
||||
}
|
||||
|
@ -46,41 +46,39 @@ export class RelatedGroupAdd extends Form<{ groups: string[] }> {
|
||||
return data;
|
||||
}
|
||||
|
||||
renderForm(): TemplateResult {
|
||||
return html`<form class="pf-c-form pf-m-horizontal">
|
||||
<ak-form-element-horizontal label=${t`Groups to add`} name="groups">
|
||||
<div class="pf-c-input-group">
|
||||
<ak-user-group-select-table
|
||||
.confirm=${(items: Group[]) => {
|
||||
this.groupsToAdd = items;
|
||||
this.requestUpdate();
|
||||
return Promise.resolve();
|
||||
}}
|
||||
>
|
||||
<button slot="trigger" class="pf-c-button pf-m-control" type="button">
|
||||
<i class="fas fa-plus" aria-hidden="true"></i>
|
||||
</button>
|
||||
</ak-user-group-select-table>
|
||||
<div class="pf-c-form-control">
|
||||
<ak-chip-group>
|
||||
${this.groupsToAdd.map((group) => {
|
||||
return html`<ak-chip
|
||||
.removable=${true}
|
||||
value=${ifDefined(group.pk)}
|
||||
@remove=${() => {
|
||||
const idx = this.groupsToAdd.indexOf(group);
|
||||
this.groupsToAdd.splice(idx, 1);
|
||||
this.requestUpdate();
|
||||
}}
|
||||
>
|
||||
${group.name}
|
||||
</ak-chip>`;
|
||||
})}
|
||||
</ak-chip-group>
|
||||
</div>
|
||||
renderInlineForm(): TemplateResult {
|
||||
return html`<ak-form-element-horizontal label=${t`Groups to add`} name="groups">
|
||||
<div class="pf-c-input-group">
|
||||
<ak-user-group-select-table
|
||||
.confirm=${(items: Group[]) => {
|
||||
this.groupsToAdd = items;
|
||||
this.requestUpdate();
|
||||
return Promise.resolve();
|
||||
}}
|
||||
>
|
||||
<button slot="trigger" class="pf-c-button pf-m-control" type="button">
|
||||
<i class="fas fa-plus" aria-hidden="true"></i>
|
||||
</button>
|
||||
</ak-user-group-select-table>
|
||||
<div class="pf-c-form-control">
|
||||
<ak-chip-group>
|
||||
${this.groupsToAdd.map((group) => {
|
||||
return html`<ak-chip
|
||||
.removable=${true}
|
||||
value=${ifDefined(group.pk)}
|
||||
@remove=${() => {
|
||||
const idx = this.groupsToAdd.indexOf(group);
|
||||
this.groupsToAdd.splice(idx, 1);
|
||||
this.requestUpdate();
|
||||
}}
|
||||
>
|
||||
${group.name}
|
||||
</ak-chip>`;
|
||||
})}
|
||||
</ak-chip-group>
|
||||
</div>
|
||||
</ak-form-element-horizontal>
|
||||
</form> `;
|
||||
</div>
|
||||
</ak-form-element-horizontal>`;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -116,9 +116,8 @@ export class PolicyTestForm extends Form<PolicyTestRequest> {
|
||||
`;
|
||||
}
|
||||
|
||||
renderForm(): TemplateResult {
|
||||
return html`<form class="pf-c-form pf-m-horizontal">
|
||||
<ak-form-element-horizontal label=${t`User`} ?required=${true} name="user">
|
||||
renderInlineForm(): TemplateResult {
|
||||
return html`<ak-form-element-horizontal label=${t`User`} ?required=${true} name="user">
|
||||
<ak-search-select
|
||||
.fetchObjects=${async (query?: string): Promise<User[]> => {
|
||||
const args: CoreUsersListRequest = {
|
||||
@ -155,7 +154,6 @@ export class PolicyTestForm extends Form<PolicyTestRequest> {
|
||||
${t`Set custom attributes using YAML or JSON.`}
|
||||
</p>
|
||||
</ak-form-element-horizontal>
|
||||
${this.result ? this.renderResult() : html``}
|
||||
</form>`;
|
||||
${this.result ? this.renderResult() : html``}`;
|
||||
}
|
||||
}
|
||||
|
@ -119,9 +119,8 @@ export class PolicyTestForm extends Form<PolicyTestRequest> {
|
||||
`;
|
||||
}
|
||||
|
||||
renderForm(): TemplateResult {
|
||||
return html`<form class="pf-c-form pf-m-horizontal">
|
||||
<ak-form-element-horizontal label=${t`User`} ?required=${true} name="user">
|
||||
renderInlineForm(): TemplateResult {
|
||||
return html`<ak-form-element-horizontal label=${t`User`} ?required=${true} name="user">
|
||||
<ak-search-select
|
||||
.fetchObjects=${async (query?: string): Promise<User[]> => {
|
||||
const args: CoreUsersListRequest = {
|
||||
@ -156,7 +155,6 @@ export class PolicyTestForm extends Form<PolicyTestRequest> {
|
||||
</ak-codemirror>
|
||||
<p class="pf-c-form__helper-text">${this.renderExampleButtons()}</p>
|
||||
</ak-form-element-horizontal>
|
||||
${this.result ? this.renderResult() : html``}
|
||||
</form>`;
|
||||
${this.result ? this.renderResult() : html``}`;
|
||||
}
|
||||
}
|
||||
|
@ -37,9 +37,8 @@ export class SAMLProviderImportForm extends Form<SAMLProvider> {
|
||||
});
|
||||
}
|
||||
|
||||
renderForm(): TemplateResult {
|
||||
return html`<form class="pf-c-form pf-m-horizontal">
|
||||
<ak-form-element-horizontal label=${t`Name`} ?required=${true} name="name">
|
||||
renderInlineForm(): TemplateResult {
|
||||
return html`<ak-form-element-horizontal label=${t`Name`} ?required=${true} name="name">
|
||||
<input type="text" class="pf-c-form-control" required />
|
||||
</ak-form-element-horizontal>
|
||||
<ak-form-element-horizontal
|
||||
@ -77,7 +76,6 @@ export class SAMLProviderImportForm extends Form<SAMLProvider> {
|
||||
|
||||
<ak-form-element-horizontal label=${t`Metadata`} name="metadata">
|
||||
<input type="file" value="" class="pf-c-form-control" />
|
||||
</ak-form-element-horizontal>
|
||||
</form>`;
|
||||
</ak-form-element-horizontal>`;
|
||||
}
|
||||
}
|
||||
|
@ -59,9 +59,8 @@ export class RelatedUserAdd extends Form<{ users: number[] }> {
|
||||
return data;
|
||||
}
|
||||
|
||||
renderForm(): TemplateResult {
|
||||
return html`<form class="pf-c-form pf-m-horizontal">
|
||||
${this.group?.isSuperuser ? html`` : html``}
|
||||
renderInlineForm(): TemplateResult {
|
||||
return html`${this.group?.isSuperuser ? html`` : html``}
|
||||
<ak-form-element-horizontal label=${t`Users to add`} name="users">
|
||||
<div class="pf-c-input-group">
|
||||
<ak-group-member-select-table
|
||||
@ -93,8 +92,7 @@ export class RelatedUserAdd extends Form<{ users: number[] }> {
|
||||
</ak-chip-group>
|
||||
</div>
|
||||
</div>
|
||||
</ak-form-element-horizontal>
|
||||
</form> `;
|
||||
</ak-form-element-horizontal>`;
|
||||
}
|
||||
}
|
||||
|
||||
@ -193,12 +191,20 @@ export class RelatedUserList extends Table<User> {
|
||||
</ak-forms-modal>
|
||||
${rootInterface()?.config?.capabilities.includes(CapabilitiesEnum.CanImpersonate)
|
||||
? html`
|
||||
<a
|
||||
class="pf-c-button pf-m-tertiary"
|
||||
href="${`/-/impersonation/${item.pk}/`}"
|
||||
<ak-action-button
|
||||
class="pf-m-tertiary"
|
||||
.apiRequest=${() => {
|
||||
return new CoreApi(DEFAULT_CONFIG)
|
||||
.coreUsersImpersonateCreate({
|
||||
id: item.pk,
|
||||
})
|
||||
.then(() => {
|
||||
window.location.href = "/";
|
||||
});
|
||||
}}
|
||||
>
|
||||
${t`Impersonate`}
|
||||
</a>
|
||||
</ak-action-button>
|
||||
`
|
||||
: html``}`,
|
||||
];
|
||||
|
@ -35,9 +35,8 @@ export class ServiceAccountForm extends Form<UserServiceAccountRequest> {
|
||||
this.result = undefined;
|
||||
}
|
||||
|
||||
renderRequestForm(): TemplateResult {
|
||||
return html`<form class="pf-c-form pf-m-horizontal">
|
||||
<ak-form-element-horizontal label=${t`Username`} ?required=${true} name="name">
|
||||
renderInlineForm(): TemplateResult {
|
||||
return html`<ak-form-element-horizontal label=${t`Username`} ?required=${true} name="name">
|
||||
<input type="text" value="" class="pf-c-form-control" required />
|
||||
<p class="pf-c-form__helper-text">
|
||||
${t`User's primary identifier. 150 characters or fewer.`}
|
||||
@ -78,8 +77,7 @@ export class ServiceAccountForm extends Form<UserServiceAccountRequest> {
|
||||
value="${dateTimeLocal(new Date(Date.now() + 1000 * 60 ** 2 * 24 * 360))}"
|
||||
class="pf-c-form-control"
|
||||
/>
|
||||
</ak-form-element-horizontal>
|
||||
</form>`;
|
||||
</ak-form-element-horizontal>`;
|
||||
}
|
||||
|
||||
renderResponseForm(): TemplateResult {
|
||||
@ -113,6 +111,6 @@ export class ServiceAccountForm extends Form<UserServiceAccountRequest> {
|
||||
if (this.result) {
|
||||
return this.renderResponseForm();
|
||||
}
|
||||
return this.renderRequestForm();
|
||||
return super.renderForm();
|
||||
}
|
||||
}
|
||||
|
@ -196,12 +196,20 @@ export class UserListPage extends TablePage<User> {
|
||||
</ak-forms-modal>
|
||||
${rootInterface()?.config?.capabilities.includes(CapabilitiesEnum.CanImpersonate)
|
||||
? html`
|
||||
<a
|
||||
class="pf-c-button pf-m-tertiary"
|
||||
href="${`/-/impersonation/${item.pk}/`}"
|
||||
<ak-action-button
|
||||
class="pf-m-tertiary"
|
||||
.apiRequest=${() => {
|
||||
return new CoreApi(DEFAULT_CONFIG)
|
||||
.coreUsersImpersonateCreate({
|
||||
id: item.pk,
|
||||
})
|
||||
.then(() => {
|
||||
window.location.href = "/";
|
||||
});
|
||||
}}
|
||||
>
|
||||
${t`Impersonate`}
|
||||
</a>
|
||||
</ak-action-button>
|
||||
`
|
||||
: html``}`,
|
||||
];
|
||||
|
@ -26,11 +26,13 @@ export class UserPasswordForm extends Form<UserPasswordSetRequest> {
|
||||
});
|
||||
}
|
||||
|
||||
renderForm(): TemplateResult {
|
||||
return html`<form class="pf-c-form pf-m-horizontal">
|
||||
<ak-form-element-horizontal label=${t`Password`} ?required=${true} name="password">
|
||||
<input type="password" value="" class="pf-c-form-control" required />
|
||||
</ak-form-element-horizontal>
|
||||
</form>`;
|
||||
renderInlineForm(): TemplateResult {
|
||||
return html`<ak-form-element-horizontal
|
||||
label=${t`Password`}
|
||||
?required=${true}
|
||||
name="password"
|
||||
>
|
||||
<input type="password" value="" class="pf-c-form-control" required />
|
||||
</ak-form-element-horizontal>`;
|
||||
}
|
||||
}
|
||||
|
@ -32,32 +32,34 @@ export class UserResetEmailForm extends Form<CoreUsersRecoveryEmailRetrieveReque
|
||||
return new CoreApi(DEFAULT_CONFIG).coreUsersRecoveryEmailRetrieve(data);
|
||||
}
|
||||
|
||||
renderForm(): TemplateResult {
|
||||
return html`<form class="pf-c-form pf-m-horizontal">
|
||||
<ak-form-element-horizontal label=${t`Email stage`} ?required=${true} 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-form-element-horizontal>
|
||||
</form>`;
|
||||
renderInlineForm(): TemplateResult {
|
||||
return html`<ak-form-element-horizontal
|
||||
label=${t`Email stage`}
|
||||
?required=${true}
|
||||
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-form-element-horizontal>`;
|
||||
}
|
||||
}
|
||||
|
@ -201,12 +201,20 @@ export class UserViewPage extends AKElement {
|
||||
)
|
||||
? html`
|
||||
<div class="pf-c-card__footer">
|
||||
<a
|
||||
class="pf-c-button pf-m-tertiary"
|
||||
href="${`/-/impersonation/${this.user?.pk}/`}"
|
||||
<ak-action-button
|
||||
class="pf-m-tertiary"
|
||||
.apiRequest=${() => {
|
||||
return new CoreApi(DEFAULT_CONFIG)
|
||||
.coreUsersImpersonateCreate({
|
||||
id: this.user?.pk || 0,
|
||||
})
|
||||
.then(() => {
|
||||
window.location.href = "/";
|
||||
});
|
||||
}}
|
||||
>
|
||||
${t`Impersonate`}
|
||||
</a>
|
||||
</ak-action-button>
|
||||
</div>
|
||||
`
|
||||
: html``}
|
||||
|
@ -3,7 +3,7 @@ export const SUCCESS_CLASS = "pf-m-success";
|
||||
export const ERROR_CLASS = "pf-m-danger";
|
||||
export const PROGRESS_CLASS = "pf-m-in-progress";
|
||||
export const CURRENT_CLASS = "pf-m-current";
|
||||
export const VERSION = "2023.5.3";
|
||||
export const VERSION = "2023.5.4";
|
||||
export const TITLE_DEFAULT = "authentik";
|
||||
export const ROUTE_SEPARATOR = ";";
|
||||
|
||||
|
@ -46,6 +46,7 @@ export class Diagram extends AKElement {
|
||||
flowchart: {
|
||||
curve: "linear",
|
||||
},
|
||||
htmlLabels: false,
|
||||
};
|
||||
mermaid.initialize(this.config);
|
||||
}
|
||||
|
@ -283,9 +283,23 @@ export abstract class Form<T> extends AKElement {
|
||||
}
|
||||
|
||||
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>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline form render callback when inheriting this class, should be overwritten
|
||||
* instead of `this.renderForm`
|
||||
*/
|
||||
renderInlineForm(): TemplateResult | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
renderNonFieldErrors(): TemplateResult {
|
||||
if (!this.nonFieldErrors) {
|
||||
return html``;
|
||||
|
@ -11,6 +11,7 @@ import { me } from "@goauthentik/common/users";
|
||||
import { first } from "@goauthentik/common/utils";
|
||||
import { WebsocketClient } from "@goauthentik/common/ws";
|
||||
import { Interface } from "@goauthentik/elements/Base";
|
||||
import "@goauthentik/elements/buttons/ActionButton";
|
||||
import "@goauthentik/elements/messages/MessageContainer";
|
||||
import "@goauthentik/elements/notifications/APIDrawer";
|
||||
import "@goauthentik/elements/notifications/NotificationDrawer";
|
||||
@ -36,7 +37,7 @@ import PFPage from "@patternfly/patternfly/components/Page/page.css";
|
||||
import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||
import PFDisplay from "@patternfly/patternfly/utilities/Display/display.css";
|
||||
|
||||
import { EventsApi, SessionUser } from "@goauthentik/api";
|
||||
import { CoreApi, EventsApi, SessionUser } from "@goauthentik/api";
|
||||
|
||||
autoDetectLanguage();
|
||||
|
||||
@ -234,18 +235,23 @@ export class UserInterface extends Interface {
|
||||
: html``}
|
||||
</div>
|
||||
${this.me.original
|
||||
? html`<div class="pf-c-page__header-tools">
|
||||
<div class="pf-c-page__header-tools-group">
|
||||
<a
|
||||
class="pf-c-button pf-m-warning pf-m-small"
|
||||
href=${`/-/impersonation/end/?back=${encodeURIComponent(
|
||||
`${window.location.pathname}#${window.location.hash}`,
|
||||
)}`}
|
||||
>
|
||||
${t`Stop impersonation`}
|
||||
</a>
|
||||
</div>
|
||||
</div>`
|
||||
? html`
|
||||
<div class="pf-c-page__header-tools">
|
||||
<div class="pf-c-page__header-tools-group">
|
||||
<ak-action-button
|
||||
class="pf-m-warning pf-m-small"
|
||||
.apiRequest=${() => {
|
||||
return new CoreApi(DEFAULT_CONFIG)
|
||||
.coreUsersImpersonateEndRetrieve()
|
||||
.then(() => {
|
||||
window.location.reload();
|
||||
});
|
||||
}}
|
||||
>
|
||||
${t`Stop impersonation`}
|
||||
</ak-action-button>
|
||||
</div>
|
||||
</div>`
|
||||
: html``}
|
||||
<div class="pf-c-page__header-tools-group">
|
||||
<div class="pf-c-page__header-tools-item pf-m-hidden pf-m-visible-on-md">
|
||||
|
Reference in New Issue
Block a user