Compare commits
34 Commits
version/20
...
version-20
| Author | SHA1 | Date | |
|---|---|---|---|
| 6da73037ce | |||
| 8e84fe6efd | |||
| 74eab55c61 | |||
| 06137fc633 | |||
| 63ec664532 | |||
| 4e4516f9a2 | |||
| 748a8e560f | |||
| d6c35787b0 | |||
| cc214a0eb7 | |||
| 0c9fd5f056 | |||
| 92a1f7e01a | |||
| 1a727b9ea0 | |||
| 28cc75af29 | |||
| 0ad245f7f6 | |||
| b10957e5df | |||
| 3adf79c493 | |||
| f478593826 | |||
| edf4de7271 | |||
| db43869e25 | |||
| 8a668af5f6 | |||
| eef233fd11 | |||
| 833b350c42 | |||
| b388265d98 | |||
| faefd9776d | |||
| a5ee159189 | |||
| 35c739ee84 | |||
| e9764333ea | |||
| 22af17be2c | |||
| 679bf17d6f | |||
| cbfa51fb31 | |||
| 5f8c21cc88 | |||
| 69b3d1722b | |||
| fa4ce1d629 | |||
| e4a392834f |
@ -1,5 +1,5 @@
|
|||||||
[bumpversion]
|
[bumpversion]
|
||||||
current_version = 2025.2.1
|
current_version = 2025.2.4
|
||||||
tag = True
|
tag = True
|
||||||
commit = True
|
commit = True
|
||||||
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(?:-(?P<rc_t>[a-zA-Z-]+)(?P<rc_n>[1-9]\\d*))?
|
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(?:-(?P<rc_t>[a-zA-Z-]+)(?P<rc_n>[1-9]\\d*))?
|
||||||
|
|||||||
@ -20,8 +20,8 @@ Even if the issue is not a CVE, we still greatly appreciate your help in hardeni
|
|||||||
|
|
||||||
| Version | Supported |
|
| Version | Supported |
|
||||||
| --------- | --------- |
|
| --------- | --------- |
|
||||||
| 2024.10.x | ✅ |
|
|
||||||
| 2024.12.x | ✅ |
|
| 2024.12.x | ✅ |
|
||||||
|
| 2025.2.x | ✅ |
|
||||||
|
|
||||||
## Reporting a Vulnerability
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from os import environ
|
from os import environ
|
||||||
|
|
||||||
__version__ = "2025.2.1"
|
__version__ = "2025.2.4"
|
||||||
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
|
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -59,7 +59,7 @@ class SystemInfoSerializer(PassiveSerializer):
|
|||||||
if not isinstance(value, str):
|
if not isinstance(value, str):
|
||||||
continue
|
continue
|
||||||
actual_value = value
|
actual_value = value
|
||||||
if raw_session in actual_value:
|
if raw_session is not None and raw_session in actual_value:
|
||||||
actual_value = actual_value.replace(
|
actual_value = actual_value.replace(
|
||||||
raw_session, SafeExceptionReporterFilter.cleansed_substitute
|
raw_session, SafeExceptionReporterFilter.cleansed_substitute
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,13 +1,14 @@
|
|||||||
"""User API Views"""
|
"""User API Views"""
|
||||||
|
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
from importlib import import_module
|
||||||
from json import loads
|
from json import loads
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
from django.contrib.auth import update_session_auth_hash
|
from django.contrib.auth import update_session_auth_hash
|
||||||
from django.contrib.auth.models import Permission
|
from django.contrib.auth.models import Permission
|
||||||
from django.contrib.sessions.backends.cache import KEY_PREFIX
|
from django.contrib.sessions.backends.base import SessionBase
|
||||||
from django.core.cache import cache
|
|
||||||
from django.db.models.functions import ExtractHour
|
from django.db.models.functions import ExtractHour
|
||||||
from django.db.transaction import atomic
|
from django.db.transaction import atomic
|
||||||
from django.db.utils import IntegrityError
|
from django.db.utils import IntegrityError
|
||||||
@ -91,6 +92,7 @@ from authentik.stages.email.tasks import send_mails
|
|||||||
from authentik.stages.email.utils import TemplateEmailMessage
|
from authentik.stages.email.utils import TemplateEmailMessage
|
||||||
|
|
||||||
LOGGER = get_logger()
|
LOGGER = get_logger()
|
||||||
|
SessionStore: SessionBase = import_module(settings.SESSION_ENGINE).SessionStore
|
||||||
|
|
||||||
|
|
||||||
class UserGroupSerializer(ModelSerializer):
|
class UserGroupSerializer(ModelSerializer):
|
||||||
@ -373,7 +375,7 @@ class UsersFilter(FilterSet):
|
|||||||
method="filter_attributes",
|
method="filter_attributes",
|
||||||
)
|
)
|
||||||
|
|
||||||
is_superuser = BooleanFilter(field_name="ak_groups", lookup_expr="is_superuser")
|
is_superuser = BooleanFilter(field_name="ak_groups", method="filter_is_superuser")
|
||||||
uuid = UUIDFilter(field_name="uuid")
|
uuid = UUIDFilter(field_name="uuid")
|
||||||
|
|
||||||
path = CharFilter(field_name="path")
|
path = CharFilter(field_name="path")
|
||||||
@ -391,6 +393,11 @@ class UsersFilter(FilterSet):
|
|||||||
queryset=Group.objects.all().order_by("name"),
|
queryset=Group.objects.all().order_by("name"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def filter_is_superuser(self, queryset, name, value):
|
||||||
|
if value:
|
||||||
|
return queryset.filter(ak_groups__is_superuser=True).distinct()
|
||||||
|
return queryset.exclude(ak_groups__is_superuser=True).distinct()
|
||||||
|
|
||||||
def filter_attributes(self, queryset, name, value):
|
def filter_attributes(self, queryset, name, value):
|
||||||
"""Filter attributes by query args"""
|
"""Filter attributes by query args"""
|
||||||
try:
|
try:
|
||||||
@ -769,7 +776,8 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
|||||||
if not instance.is_active:
|
if not instance.is_active:
|
||||||
sessions = AuthenticatedSession.objects.filter(user=instance)
|
sessions = AuthenticatedSession.objects.filter(user=instance)
|
||||||
session_ids = sessions.values_list("session_key", flat=True)
|
session_ids = sessions.values_list("session_key", flat=True)
|
||||||
cache.delete_many(f"{KEY_PREFIX}{session}" for session in session_ids)
|
for session in session_ids:
|
||||||
|
SessionStore(session).delete()
|
||||||
sessions.delete()
|
sessions.delete()
|
||||||
LOGGER.debug("Deleted user's sessions", user=instance.username)
|
LOGGER.debug("Deleted user's sessions", user=instance.username)
|
||||||
return response
|
return response
|
||||||
|
|||||||
@ -1,7 +1,10 @@
|
|||||||
"""authentik core signals"""
|
"""authentik core signals"""
|
||||||
|
|
||||||
|
from importlib import import_module
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
from django.contrib.auth.signals import user_logged_in, user_logged_out
|
from django.contrib.auth.signals import user_logged_in, user_logged_out
|
||||||
from django.contrib.sessions.backends.cache import KEY_PREFIX
|
from django.contrib.sessions.backends.base import SessionBase
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.core.signals import Signal
|
from django.core.signals import Signal
|
||||||
from django.db.models import Model
|
from django.db.models import Model
|
||||||
@ -25,6 +28,7 @@ password_changed = Signal()
|
|||||||
login_failed = Signal()
|
login_failed = Signal()
|
||||||
|
|
||||||
LOGGER = get_logger()
|
LOGGER = get_logger()
|
||||||
|
SessionStore: SessionBase = import_module(settings.SESSION_ENGINE).SessionStore
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_save, sender=Application)
|
@receiver(post_save, sender=Application)
|
||||||
@ -60,8 +64,7 @@ def user_logged_out_session(sender, request: HttpRequest, user: User, **_):
|
|||||||
@receiver(pre_delete, sender=AuthenticatedSession)
|
@receiver(pre_delete, sender=AuthenticatedSession)
|
||||||
def authenticated_session_delete(sender: type[Model], instance: "AuthenticatedSession", **_):
|
def authenticated_session_delete(sender: type[Model], instance: "AuthenticatedSession", **_):
|
||||||
"""Delete session when authenticated session is deleted"""
|
"""Delete session when authenticated session is deleted"""
|
||||||
cache_key = f"{KEY_PREFIX}{instance.session_key}"
|
SessionStore(instance.session_key).delete()
|
||||||
cache.delete(cache_key)
|
|
||||||
|
|
||||||
|
|
||||||
@receiver(pre_save)
|
@receiver(pre_save)
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
"""Test Users API"""
|
"""Test Users API"""
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from json import loads
|
||||||
|
|
||||||
from django.contrib.sessions.backends.cache import KEY_PREFIX
|
from django.contrib.sessions.backends.cache import KEY_PREFIX
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
@ -15,7 +16,11 @@ from authentik.core.models import (
|
|||||||
User,
|
User,
|
||||||
UserTypes,
|
UserTypes,
|
||||||
)
|
)
|
||||||
from authentik.core.tests.utils import create_test_admin_user, create_test_brand, create_test_flow
|
from authentik.core.tests.utils import (
|
||||||
|
create_test_admin_user,
|
||||||
|
create_test_brand,
|
||||||
|
create_test_flow,
|
||||||
|
)
|
||||||
from authentik.flows.models import FlowDesignation
|
from authentik.flows.models import FlowDesignation
|
||||||
from authentik.lib.generators import generate_id, generate_key
|
from authentik.lib.generators import generate_id, generate_key
|
||||||
from authentik.stages.email.models import EmailStage
|
from authentik.stages.email.models import EmailStage
|
||||||
@ -41,6 +46,32 @@ class TestUsersAPI(APITestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_filter_is_superuser(self):
|
||||||
|
"""Test API filtering by superuser status"""
|
||||||
|
self.client.force_login(self.admin)
|
||||||
|
# Test superuser
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("authentik_api:user-list"),
|
||||||
|
data={
|
||||||
|
"is_superuser": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
body = loads(response.content)
|
||||||
|
self.assertEqual(len(body["results"]), 1)
|
||||||
|
self.assertEqual(body["results"][0]["username"], self.admin.username)
|
||||||
|
# Test non-superuser
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("authentik_api:user-list"),
|
||||||
|
data={
|
||||||
|
"is_superuser": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
body = loads(response.content)
|
||||||
|
self.assertEqual(len(body["results"]), 1, body)
|
||||||
|
self.assertEqual(body["results"][0]["username"], self.user.username)
|
||||||
|
|
||||||
def test_list_with_groups(self):
|
def test_list_with_groups(self):
|
||||||
"""Test listing with groups"""
|
"""Test listing with groups"""
|
||||||
self.client.force_login(self.admin)
|
self.client.force_login(self.admin)
|
||||||
|
|||||||
@ -55,7 +55,7 @@ class RedirectToAppLaunch(View):
|
|||||||
)
|
)
|
||||||
except FlowNonApplicableException:
|
except FlowNonApplicableException:
|
||||||
raise Http404 from None
|
raise Http404 from None
|
||||||
plan.insert_stage(in_memory_stage(RedirectToAppStage))
|
plan.append_stage(in_memory_stage(RedirectToAppStage))
|
||||||
return plan.to_redirect(request, flow)
|
return plan.to_redirect(request, flow)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -89,9 +89,9 @@ class SourceStageFinal(StageView):
|
|||||||
This stage uses the override flow token to resume execution of the initial flow the
|
This stage uses the override flow token to resume execution of the initial flow the
|
||||||
source stage is bound to."""
|
source stage is bound to."""
|
||||||
|
|
||||||
def dispatch(self):
|
def dispatch(self, *args, **kwargs):
|
||||||
token: FlowToken = self.request.session.get(SESSION_KEY_OVERRIDE_FLOW_TOKEN)
|
token: FlowToken = self.request.session.get(SESSION_KEY_OVERRIDE_FLOW_TOKEN)
|
||||||
self._logger.info("Replacing source flow with overridden flow", flow=token.flow.slug)
|
self.logger.info("Replacing source flow with overridden flow", flow=token.flow.slug)
|
||||||
plan = token.plan
|
plan = token.plan
|
||||||
plan.context[PLAN_CONTEXT_IS_RESTORED] = token
|
plan.context[PLAN_CONTEXT_IS_RESTORED] = token
|
||||||
response = plan.to_redirect(self.request, token.flow)
|
response = plan.to_redirect(self.request, token.flow)
|
||||||
|
|||||||
@ -4,7 +4,8 @@ from django.urls import reverse
|
|||||||
|
|
||||||
from authentik.core.tests.utils import create_test_flow, create_test_user
|
from authentik.core.tests.utils import create_test_flow, create_test_user
|
||||||
from authentik.enterprise.stages.source.models import SourceStage
|
from authentik.enterprise.stages.source.models import SourceStage
|
||||||
from authentik.flows.models import FlowDesignation, FlowStageBinding, FlowToken
|
from authentik.enterprise.stages.source.stage import SourceStageFinal
|
||||||
|
from authentik.flows.models import FlowDesignation, FlowStageBinding, FlowToken, in_memory_stage
|
||||||
from authentik.flows.planner import PLAN_CONTEXT_IS_RESTORED, FlowPlan
|
from authentik.flows.planner import PLAN_CONTEXT_IS_RESTORED, FlowPlan
|
||||||
from authentik.flows.tests import FlowTestCase
|
from authentik.flows.tests import FlowTestCase
|
||||||
from authentik.flows.views.executor import SESSION_KEY_PLAN
|
from authentik.flows.views.executor import SESSION_KEY_PLAN
|
||||||
@ -87,6 +88,7 @@ class TestSourceStage(FlowTestCase):
|
|||||||
self.assertIsNotNone(flow_token)
|
self.assertIsNotNone(flow_token)
|
||||||
session = self.client.session
|
session = self.client.session
|
||||||
plan: FlowPlan = session[SESSION_KEY_PLAN]
|
plan: FlowPlan = session[SESSION_KEY_PLAN]
|
||||||
|
plan.insert_stage(in_memory_stage(SourceStageFinal), index=0)
|
||||||
plan.context[PLAN_CONTEXT_IS_RESTORED] = flow_token
|
plan.context[PLAN_CONTEXT_IS_RESTORED] = flow_token
|
||||||
session[SESSION_KEY_PLAN] = plan
|
session[SESSION_KEY_PLAN] = plan
|
||||||
session.save()
|
session.save()
|
||||||
@ -96,4 +98,6 @@ class TestSourceStage(FlowTestCase):
|
|||||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), follow=True
|
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), follow=True
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertStageRedirects(response, reverse("authentik_core:root-redirect"))
|
self.assertStageRedirects(
|
||||||
|
response, reverse("authentik_core:if-flow", kwargs={"flow_slug": flow.slug})
|
||||||
|
)
|
||||||
|
|||||||
@ -76,10 +76,10 @@ class FlowPlan:
|
|||||||
self.bindings.append(binding)
|
self.bindings.append(binding)
|
||||||
self.markers.append(marker or StageMarker())
|
self.markers.append(marker or StageMarker())
|
||||||
|
|
||||||
def insert_stage(self, stage: Stage, marker: StageMarker | None = None):
|
def insert_stage(self, stage: Stage, marker: StageMarker | None = None, index=1):
|
||||||
"""Insert stage into plan, as immediate next stage"""
|
"""Insert stage into plan, as immediate next stage"""
|
||||||
self.bindings.insert(1, FlowStageBinding(stage=stage, order=0))
|
self.bindings.insert(index, FlowStageBinding(stage=stage, order=0))
|
||||||
self.markers.insert(1, marker or StageMarker())
|
self.markers.insert(index, marker or StageMarker())
|
||||||
|
|
||||||
def redirect(self, destination: str):
|
def redirect(self, destination: str):
|
||||||
"""Insert a redirect stage as next stage"""
|
"""Insert a redirect stage as next stage"""
|
||||||
|
|||||||
@ -282,16 +282,14 @@ class ConfigLoader:
|
|||||||
|
|
||||||
def get_optional_int(self, path: str, default=None) -> int | None:
|
def get_optional_int(self, path: str, default=None) -> int | None:
|
||||||
"""Wrapper for get that converts value into int or None if set"""
|
"""Wrapper for get that converts value into int or None if set"""
|
||||||
value = self.get(path, default)
|
value = self.get(path, UNSET)
|
||||||
if value is UNSET:
|
if value is UNSET:
|
||||||
return default
|
return default
|
||||||
try:
|
try:
|
||||||
return int(value)
|
return int(value)
|
||||||
except (ValueError, TypeError) as exc:
|
except (ValueError, TypeError) as exc:
|
||||||
if value is None or (isinstance(value, str) and value.lower() == "null"):
|
if value is None or (isinstance(value, str) and value.lower() == "null"):
|
||||||
return default
|
return None
|
||||||
if value is UNSET:
|
|
||||||
return default
|
|
||||||
self.log("warning", "Failed to parse config as int", path=path, exc=str(exc))
|
self.log("warning", "Failed to parse config as int", path=path, exc=str(exc))
|
||||||
return default
|
return default
|
||||||
|
|
||||||
@ -372,9 +370,9 @@ def django_db_config(config: ConfigLoader | None = None) -> dict:
|
|||||||
"sslcert": config.get("postgresql.sslcert"),
|
"sslcert": config.get("postgresql.sslcert"),
|
||||||
"sslkey": config.get("postgresql.sslkey"),
|
"sslkey": config.get("postgresql.sslkey"),
|
||||||
},
|
},
|
||||||
"CONN_MAX_AGE": CONFIG.get_optional_int("postgresql.conn_max_age", 0),
|
"CONN_MAX_AGE": config.get_optional_int("postgresql.conn_max_age", 0),
|
||||||
"CONN_HEALTH_CHECKS": CONFIG.get_bool("postgresql.conn_health_checks", False),
|
"CONN_HEALTH_CHECKS": config.get_bool("postgresql.conn_health_checks", False),
|
||||||
"DISABLE_SERVER_SIDE_CURSORS": CONFIG.get_bool(
|
"DISABLE_SERVER_SIDE_CURSORS": config.get_bool(
|
||||||
"postgresql.disable_server_side_cursors", False
|
"postgresql.disable_server_side_cursors", False
|
||||||
),
|
),
|
||||||
"TEST": {
|
"TEST": {
|
||||||
@ -383,8 +381,8 @@ def django_db_config(config: ConfigLoader | None = None) -> dict:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
conn_max_age = CONFIG.get_optional_int("postgresql.conn_max_age", UNSET)
|
conn_max_age = config.get_optional_int("postgresql.conn_max_age", UNSET)
|
||||||
disable_server_side_cursors = CONFIG.get_bool("postgresql.disable_server_side_cursors", UNSET)
|
disable_server_side_cursors = config.get_bool("postgresql.disable_server_side_cursors", UNSET)
|
||||||
if config.get_bool("postgresql.use_pgpool", False):
|
if config.get_bool("postgresql.use_pgpool", False):
|
||||||
db["default"]["DISABLE_SERVER_SIDE_CURSORS"] = True
|
db["default"]["DISABLE_SERVER_SIDE_CURSORS"] = True
|
||||||
if disable_server_side_cursors is not UNSET:
|
if disable_server_side_cursors is not UNSET:
|
||||||
|
|||||||
@ -158,6 +158,18 @@ class TestConfig(TestCase):
|
|||||||
test_obj = Test()
|
test_obj = Test()
|
||||||
dumps(test_obj, indent=4, cls=AttrEncoder)
|
dumps(test_obj, indent=4, cls=AttrEncoder)
|
||||||
|
|
||||||
|
def test_get_optional_int(self):
|
||||||
|
config = ConfigLoader()
|
||||||
|
self.assertEqual(config.get_optional_int("foo", 21), 21)
|
||||||
|
self.assertEqual(config.get_optional_int("foo"), None)
|
||||||
|
config.set("foo", "21")
|
||||||
|
self.assertEqual(config.get_optional_int("foo"), 21)
|
||||||
|
self.assertEqual(config.get_optional_int("foo", 0), 21)
|
||||||
|
self.assertEqual(config.get_optional_int("foo", "null"), 21)
|
||||||
|
config.set("foo", "null")
|
||||||
|
self.assertEqual(config.get_optional_int("foo"), None)
|
||||||
|
self.assertEqual(config.get_optional_int("foo", 21), None)
|
||||||
|
|
||||||
@mock.patch.dict(environ, check_deprecations_env_vars)
|
@mock.patch.dict(environ, check_deprecations_env_vars)
|
||||||
def test_check_deprecations(self):
|
def test_check_deprecations(self):
|
||||||
"""Test config key re-write for deprecated env vars"""
|
"""Test config key re-write for deprecated env vars"""
|
||||||
@ -221,6 +233,16 @@ class TestConfig(TestCase):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_db_conn_max_age(self):
|
||||||
|
"""Test DB conn_max_age Config"""
|
||||||
|
config = ConfigLoader()
|
||||||
|
config.set("postgresql.conn_max_age", "null")
|
||||||
|
conf = django_db_config(config)
|
||||||
|
self.assertEqual(
|
||||||
|
conf["default"]["CONN_MAX_AGE"],
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
def test_db_read_replicas(self):
|
def test_db_read_replicas(self):
|
||||||
"""Test read replicas"""
|
"""Test read replicas"""
|
||||||
config = ConfigLoader()
|
config = ConfigLoader()
|
||||||
|
|||||||
@ -71,7 +71,7 @@ class CodeValidatorView(PolicyAccessView):
|
|||||||
except FlowNonApplicableException:
|
except FlowNonApplicableException:
|
||||||
LOGGER.warning("Flow not applicable to user")
|
LOGGER.warning("Flow not applicable to user")
|
||||||
return None
|
return None
|
||||||
plan.insert_stage(in_memory_stage(OAuthDeviceCodeFinishStage))
|
plan.append_stage(in_memory_stage(OAuthDeviceCodeFinishStage))
|
||||||
return plan.to_redirect(self.request, self.token.provider.authorization_flow)
|
return plan.to_redirect(self.request, self.token.provider.authorization_flow)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -34,5 +34,5 @@ class EndSessionView(PolicyAccessView):
|
|||||||
PLAN_CONTEXT_APPLICATION: self.application,
|
PLAN_CONTEXT_APPLICATION: self.application,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
plan.insert_stage(in_memory_stage(SessionEndStage))
|
plan.append_stage(in_memory_stage(SessionEndStage))
|
||||||
return plan.to_redirect(self.request, self.flow)
|
return plan.to_redirect(self.request, self.flow)
|
||||||
|
|||||||
@ -36,17 +36,17 @@ class IngressReconciler(KubernetesObjectReconciler[V1Ingress]):
|
|||||||
def reconciler_name() -> str:
|
def reconciler_name() -> str:
|
||||||
return "ingress"
|
return "ingress"
|
||||||
|
|
||||||
def _check_annotations(self, reference: V1Ingress):
|
def _check_annotations(self, current: V1Ingress, reference: V1Ingress):
|
||||||
"""Check that all annotations *we* set are correct"""
|
"""Check that all annotations *we* set are correct"""
|
||||||
for key, value in self.get_ingress_annotations().items():
|
for key, value in reference.metadata.annotations.items():
|
||||||
if key not in reference.metadata.annotations:
|
if key not in current.metadata.annotations:
|
||||||
raise NeedsUpdate()
|
raise NeedsUpdate()
|
||||||
if reference.metadata.annotations[key] != value:
|
if current.metadata.annotations[key] != value:
|
||||||
raise NeedsUpdate()
|
raise NeedsUpdate()
|
||||||
|
|
||||||
def reconcile(self, current: V1Ingress, reference: V1Ingress):
|
def reconcile(self, current: V1Ingress, reference: V1Ingress):
|
||||||
super().reconcile(current, reference)
|
super().reconcile(current, reference)
|
||||||
self._check_annotations(reference)
|
self._check_annotations(current, reference)
|
||||||
# Create a list of all expected host and tls hosts
|
# Create a list of all expected host and tls hosts
|
||||||
expected_hosts = []
|
expected_hosts = []
|
||||||
expected_hosts_tls = []
|
expected_hosts_tls = []
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
"""RAC app config"""
|
"""RAC app config"""
|
||||||
|
|
||||||
from django.apps import AppConfig
|
from authentik.blueprints.apps import ManagedAppConfig
|
||||||
|
|
||||||
|
|
||||||
class AuthentikProviderRAC(AppConfig):
|
class AuthentikProviderRAC(ManagedAppConfig):
|
||||||
"""authentik rac app config"""
|
"""authentik rac app config"""
|
||||||
|
|
||||||
name = "authentik.providers.rac"
|
name = "authentik.providers.rac"
|
||||||
|
|||||||
@ -4,8 +4,7 @@ from asgiref.sync import async_to_sync
|
|||||||
from channels.layers import get_channel_layer
|
from channels.layers import get_channel_layer
|
||||||
from django.contrib.auth.signals import user_logged_out
|
from django.contrib.auth.signals import user_logged_out
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.db.models import Model
|
from django.db.models.signals import post_delete, post_save, pre_delete
|
||||||
from django.db.models.signals import post_save, pre_delete
|
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
|
|
||||||
@ -46,12 +45,8 @@ def pre_delete_connection_token_disconnect(sender, instance: ConnectionToken, **
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_save, sender=Endpoint)
|
@receiver([post_save, post_delete], sender=Endpoint)
|
||||||
def post_save_endpoint(sender: type[Model], instance, created: bool, **_):
|
def post_save_post_delete_endpoint(**_):
|
||||||
"""Clear user's endpoint cache upon endpoint creation"""
|
"""Clear user's endpoint cache upon endpoint creation or deletion"""
|
||||||
if not created: # pragma: no cover
|
|
||||||
return
|
|
||||||
|
|
||||||
# Delete user endpoint cache
|
|
||||||
keys = cache.keys(user_endpoint_cache_key("*"))
|
keys = cache.keys(user_endpoint_cache_key("*"))
|
||||||
cache.delete_many(keys)
|
cache.delete_many(keys)
|
||||||
|
|||||||
@ -46,7 +46,7 @@ class RACStartView(PolicyAccessView):
|
|||||||
)
|
)
|
||||||
except FlowNonApplicableException:
|
except FlowNonApplicableException:
|
||||||
raise Http404 from None
|
raise Http404 from None
|
||||||
plan.insert_stage(
|
plan.append_stage(
|
||||||
in_memory_stage(
|
in_memory_stage(
|
||||||
RACFinalStage,
|
RACFinalStage,
|
||||||
application=self.application,
|
application=self.application,
|
||||||
|
|||||||
@ -61,7 +61,7 @@ class SAMLSLOView(PolicyAccessView):
|
|||||||
PLAN_CONTEXT_APPLICATION: self.application,
|
PLAN_CONTEXT_APPLICATION: self.application,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
plan.insert_stage(in_memory_stage(SessionEndStage))
|
plan.append_stage(in_memory_stage(SessionEndStage))
|
||||||
return plan.to_redirect(self.request, self.flow)
|
return plan.to_redirect(self.request, self.flow)
|
||||||
|
|
||||||
def post(self, request: HttpRequest, application_slug: str) -> HttpResponse:
|
def post(self, request: HttpRequest, application_slug: str) -> HttpResponse:
|
||||||
|
|||||||
@ -243,6 +243,7 @@ class SCIMGroupClient(SCIMClient[Group, SCIMProviderGroup, SCIMGroupSchema]):
|
|||||||
if user.value not in users_should:
|
if user.value not in users_should:
|
||||||
users_to_remove.append(user.value)
|
users_to_remove.append(user.value)
|
||||||
# Check users that should be in the group and add them
|
# Check users that should be in the group and add them
|
||||||
|
if current_group.members is not None:
|
||||||
for user in users_should:
|
for user in users_should:
|
||||||
if len([x for x in current_group.members if x.value == user]) < 1:
|
if len([x for x in current_group.members if x.value == user]) < 1:
|
||||||
users_to_add.append(user)
|
users_to_add.append(user)
|
||||||
|
|||||||
@ -1,10 +1,12 @@
|
|||||||
"""User client"""
|
"""User client"""
|
||||||
|
|
||||||
|
from django.db import transaction
|
||||||
|
from django.utils.http import urlencode
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from authentik.core.models import User
|
from authentik.core.models import User
|
||||||
from authentik.lib.sync.mapper import PropertyMappingManager
|
from authentik.lib.sync.mapper import PropertyMappingManager
|
||||||
from authentik.lib.sync.outgoing.exceptions import StopSync
|
from authentik.lib.sync.outgoing.exceptions import ObjectExistsSyncException, StopSync
|
||||||
from authentik.policies.utils import delete_none_values
|
from authentik.policies.utils import delete_none_values
|
||||||
from authentik.providers.scim.clients.base import SCIMClient
|
from authentik.providers.scim.clients.base import SCIMClient
|
||||||
from authentik.providers.scim.clients.schema import SCIM_USER_SCHEMA
|
from authentik.providers.scim.clients.schema import SCIM_USER_SCHEMA
|
||||||
@ -55,6 +57,8 @@ class SCIMUserClient(SCIMClient[User, SCIMProviderUser, SCIMUserSchema]):
|
|||||||
def create(self, user: User):
|
def create(self, user: User):
|
||||||
"""Create user from scratch and create a connection object"""
|
"""Create user from scratch and create a connection object"""
|
||||||
scim_user = self.to_schema(user, None)
|
scim_user = self.to_schema(user, None)
|
||||||
|
with transaction.atomic():
|
||||||
|
try:
|
||||||
response = self._request(
|
response = self._request(
|
||||||
"POST",
|
"POST",
|
||||||
"/Users",
|
"/Users",
|
||||||
@ -63,10 +67,25 @@ class SCIMUserClient(SCIMClient[User, SCIMProviderUser, SCIMUserSchema]):
|
|||||||
exclude_unset=True,
|
exclude_unset=True,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
except ObjectExistsSyncException as exc:
|
||||||
|
if not self._config.filter.supported:
|
||||||
|
raise exc
|
||||||
|
users = self._request(
|
||||||
|
"GET", f"/Users?{urlencode({'filter': f'userName eq {scim_user.userName}'})}"
|
||||||
|
)
|
||||||
|
users_res = users.get("Resources", [])
|
||||||
|
if len(users_res) < 1:
|
||||||
|
raise exc
|
||||||
|
return SCIMProviderUser.objects.create(
|
||||||
|
provider=self.provider, user=user, scim_id=users_res[0]["id"]
|
||||||
|
)
|
||||||
|
else:
|
||||||
scim_id = response.get("id")
|
scim_id = response.get("id")
|
||||||
if not scim_id or scim_id == "":
|
if not scim_id or scim_id == "":
|
||||||
raise StopSync("SCIM Response with missing or invalid `id`")
|
raise StopSync("SCIM Response with missing or invalid `id`")
|
||||||
return SCIMProviderUser.objects.create(provider=self.provider, user=user, scim_id=scim_id)
|
return SCIMProviderUser.objects.create(
|
||||||
|
provider=self.provider, user=user, scim_id=scim_id
|
||||||
|
)
|
||||||
|
|
||||||
def update(self, user: User, connection: SCIMProviderUser):
|
def update(self, user: User, connection: SCIMProviderUser):
|
||||||
"""Update existing user"""
|
"""Update existing user"""
|
||||||
|
|||||||
@ -68,8 +68,6 @@ class OAuth2Client(BaseOAuthClient):
|
|||||||
error_desc = self.get_request_arg("error_description", None)
|
error_desc = self.get_request_arg("error_description", None)
|
||||||
return {"error": error_desc or error or _("No token received.")}
|
return {"error": error_desc or error or _("No token received.")}
|
||||||
args = {
|
args = {
|
||||||
"client_id": self.get_client_id(),
|
|
||||||
"client_secret": self.get_client_secret(),
|
|
||||||
"redirect_uri": callback,
|
"redirect_uri": callback,
|
||||||
"code": code,
|
"code": code,
|
||||||
"grant_type": "authorization_code",
|
"grant_type": "authorization_code",
|
||||||
|
|||||||
@ -28,7 +28,7 @@ def update_well_known_jwks(self: SystemTask):
|
|||||||
LOGGER.warning("Failed to update well_known", source=source, exc=exc, text=text)
|
LOGGER.warning("Failed to update well_known", source=source, exc=exc, text=text)
|
||||||
messages.append(f"Failed to update OIDC configuration for {source.slug}")
|
messages.append(f"Failed to update OIDC configuration for {source.slug}")
|
||||||
continue
|
continue
|
||||||
config = well_known_config.json()
|
config: dict = well_known_config.json()
|
||||||
try:
|
try:
|
||||||
dirty = False
|
dirty = False
|
||||||
source_attr_key = (
|
source_attr_key = (
|
||||||
@ -40,7 +40,9 @@ def update_well_known_jwks(self: SystemTask):
|
|||||||
for source_attr, config_key in source_attr_key:
|
for source_attr, config_key in source_attr_key:
|
||||||
# Check if we're actually changing anything to only
|
# Check if we're actually changing anything to only
|
||||||
# save when something has changed
|
# save when something has changed
|
||||||
if getattr(source, source_attr, "") != config[config_key]:
|
if config_key not in config:
|
||||||
|
continue
|
||||||
|
if getattr(source, source_attr, "") != config.get(config_key, ""):
|
||||||
dirty = True
|
dirty = True
|
||||||
setattr(source, source_attr, config[config_key])
|
setattr(source, source_attr, config[config_key])
|
||||||
except (IndexError, KeyError) as exc:
|
except (IndexError, KeyError) as exc:
|
||||||
|
|||||||
@ -7,6 +7,7 @@ from django.utils.translation import gettext_lazy as _
|
|||||||
from django.views import View
|
from django.views import View
|
||||||
from rest_framework.serializers import BaseSerializer
|
from rest_framework.serializers import BaseSerializer
|
||||||
|
|
||||||
|
from authentik.core.types import UserSettingSerializer
|
||||||
from authentik.events.models import Event, EventAction
|
from authentik.events.models import Event, EventAction
|
||||||
from authentik.flows.exceptions import StageInvalidException
|
from authentik.flows.exceptions import StageInvalidException
|
||||||
from authentik.flows.models import ConfigurableStage, FriendlyNamedStage, Stage
|
from authentik.flows.models import ConfigurableStage, FriendlyNamedStage, Stage
|
||||||
@ -71,6 +72,14 @@ class AuthenticatorEmailStage(ConfigurableStage, FriendlyNamedStage, Stage):
|
|||||||
def component(self) -> str:
|
def component(self) -> str:
|
||||||
return "ak-stage-authenticator-email-form"
|
return "ak-stage-authenticator-email-form"
|
||||||
|
|
||||||
|
def ui_user_settings(self) -> UserSettingSerializer | None:
|
||||||
|
return UserSettingSerializer(
|
||||||
|
data={
|
||||||
|
"title": self.friendly_name or str(self._meta.verbose_name),
|
||||||
|
"component": "ak-user-settings-authenticator-email",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def backend_class(self) -> type[BaseEmailBackend]:
|
def backend_class(self) -> type[BaseEmailBackend]:
|
||||||
"""Get the email backend class to use"""
|
"""Get the email backend class to use"""
|
||||||
|
|||||||
@ -104,6 +104,13 @@ def send_mail(
|
|||||||
# can't be converted to json)
|
# can't be converted to json)
|
||||||
message_object.attach(logo_data())
|
message_object.attach(logo_data())
|
||||||
|
|
||||||
|
if (
|
||||||
|
message_object.to
|
||||||
|
and isinstance(message_object.to[0], str)
|
||||||
|
and "=?utf-8?" in message_object.to[0]
|
||||||
|
):
|
||||||
|
message_object.to = [message_object.to[0].split("<")[-1].replace(">", "")]
|
||||||
|
|
||||||
LOGGER.debug("Sending mail", to=message_object.to)
|
LOGGER.debug("Sending mail", to=message_object.to)
|
||||||
backend.send_messages([message_object])
|
backend.send_messages([message_object])
|
||||||
Event.new(
|
Event.new(
|
||||||
|
|||||||
@ -8,7 +8,7 @@ from django.core.mail.backends.locmem import EmailBackend
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from authentik.core.models import User
|
from authentik.core.models import User
|
||||||
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, create_test_user
|
||||||
from authentik.events.models import Event, EventAction
|
from authentik.events.models import Event, EventAction
|
||||||
from authentik.flows.markers import StageMarker
|
from authentik.flows.markers import StageMarker
|
||||||
from authentik.flows.models import FlowDesignation, FlowStageBinding
|
from authentik.flows.models import FlowDesignation, FlowStageBinding
|
||||||
@ -67,6 +67,67 @@ class TestEmailStageSending(FlowTestCase):
|
|||||||
self.assertEqual(event.context["to_email"], [f"{self.user.name} <{self.user.email}>"])
|
self.assertEqual(event.context["to_email"], [f"{self.user.name} <{self.user.email}>"])
|
||||||
self.assertEqual(event.context["from_email"], "system@authentik.local")
|
self.assertEqual(event.context["from_email"], "system@authentik.local")
|
||||||
|
|
||||||
|
def test_newlines_long_name(self):
|
||||||
|
"""Test with pending user"""
|
||||||
|
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
|
||||||
|
long_user = create_test_user()
|
||||||
|
long_user.name = "Test User\r\n Many Words\r\n"
|
||||||
|
long_user.save()
|
||||||
|
plan.context[PLAN_CONTEXT_PENDING_USER] = long_user
|
||||||
|
session = self.client.session
|
||||||
|
session[SESSION_KEY_PLAN] = plan
|
||||||
|
session.save()
|
||||||
|
Event.objects.filter(action=EventAction.EMAIL_SENT).delete()
|
||||||
|
|
||||||
|
url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
|
||||||
|
with patch(
|
||||||
|
"authentik.stages.email.models.EmailStage.backend_class",
|
||||||
|
PropertyMock(return_value=EmailBackend),
|
||||||
|
):
|
||||||
|
response = self.client.post(url)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertStageResponse(
|
||||||
|
response,
|
||||||
|
self.flow,
|
||||||
|
response_errors={
|
||||||
|
"non_field_errors": [{"string": "email-sent", "code": "email-sent"}]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(len(mail.outbox), 1)
|
||||||
|
self.assertEqual(mail.outbox[0].subject, "authentik")
|
||||||
|
self.assertEqual(mail.outbox[0].to, [f"Test User Many Words <{long_user.email}>"])
|
||||||
|
|
||||||
|
def test_utf8_name(self):
|
||||||
|
"""Test with pending user"""
|
||||||
|
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
|
||||||
|
utf8_user = create_test_user()
|
||||||
|
utf8_user.name = "Cirilo ЉМНЊ el cirilico И̂ӢЙӤ "
|
||||||
|
utf8_user.email = "cyrillic@authentik.local"
|
||||||
|
utf8_user.save()
|
||||||
|
plan.context[PLAN_CONTEXT_PENDING_USER] = utf8_user
|
||||||
|
session = self.client.session
|
||||||
|
session[SESSION_KEY_PLAN] = plan
|
||||||
|
session.save()
|
||||||
|
Event.objects.filter(action=EventAction.EMAIL_SENT).delete()
|
||||||
|
|
||||||
|
url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
|
||||||
|
with patch(
|
||||||
|
"authentik.stages.email.models.EmailStage.backend_class",
|
||||||
|
PropertyMock(return_value=EmailBackend),
|
||||||
|
):
|
||||||
|
response = self.client.post(url)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertStageResponse(
|
||||||
|
response,
|
||||||
|
self.flow,
|
||||||
|
response_errors={
|
||||||
|
"non_field_errors": [{"string": "email-sent", "code": "email-sent"}]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(len(mail.outbox), 1)
|
||||||
|
self.assertEqual(mail.outbox[0].subject, "authentik")
|
||||||
|
self.assertEqual(mail.outbox[0].to, [f"{utf8_user.email}"])
|
||||||
|
|
||||||
def test_pending_fake_user(self):
|
def test_pending_fake_user(self):
|
||||||
"""Test with pending (fake) user"""
|
"""Test with pending (fake) user"""
|
||||||
self.flow.designation = FlowDesignation.RECOVERY
|
self.flow.designation = FlowDesignation.RECOVERY
|
||||||
|
|||||||
@ -32,7 +32,14 @@ class TemplateEmailMessage(EmailMultiAlternatives):
|
|||||||
sanitized_to = []
|
sanitized_to = []
|
||||||
# Ensure that all recipients are valid
|
# Ensure that all recipients are valid
|
||||||
for recipient_name, recipient_email in to:
|
for recipient_name, recipient_email in to:
|
||||||
sanitized_to.append(sanitize_address((recipient_name, recipient_email), "utf-8"))
|
# Remove any newline characters from name and email before sanitizing
|
||||||
|
clean_name = (
|
||||||
|
recipient_name.replace("\n", " ").replace("\r", " ") if recipient_name else ""
|
||||||
|
)
|
||||||
|
clean_email = (
|
||||||
|
recipient_email.replace("\n", "").replace("\r", "") if recipient_email else ""
|
||||||
|
)
|
||||||
|
sanitized_to.append(sanitize_address((clean_name, clean_email), "utf-8"))
|
||||||
super().__init__(to=sanitized_to, **kwargs)
|
super().__init__(to=sanitized_to, **kwargs)
|
||||||
if not template_name:
|
if not template_name:
|
||||||
return
|
return
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
"$schema": "http://json-schema.org/draft-07/schema",
|
"$schema": "http://json-schema.org/draft-07/schema",
|
||||||
"$id": "https://goauthentik.io/blueprints/schema.json",
|
"$id": "https://goauthentik.io/blueprints/schema.json",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"title": "authentik 2025.2.1 Blueprint schema",
|
"title": "authentik 2025.2.4 Blueprint schema",
|
||||||
"required": [
|
"required": [
|
||||||
"version",
|
"version",
|
||||||
"entries"
|
"entries"
|
||||||
|
|||||||
@ -31,7 +31,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- redis:/data
|
- redis:/data
|
||||||
server:
|
server:
|
||||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.2.1}
|
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.2.4}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command: server
|
command: server
|
||||||
environment:
|
environment:
|
||||||
@ -54,7 +54,7 @@ services:
|
|||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
worker:
|
worker:
|
||||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.2.1}
|
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.2.4}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command: worker
|
command: worker
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
@ -29,4 +29,4 @@ func UserAgent() string {
|
|||||||
return fmt.Sprintf("authentik@%s", FullVersion())
|
return fmt.Sprintf("authentik@%s", FullVersion())
|
||||||
}
|
}
|
||||||
|
|
||||||
const VERSION = "2025.2.1"
|
const VERSION = "2025.2.4"
|
||||||
|
|||||||
@ -35,13 +35,19 @@ func Paginator[Tobj any, Treq any, Tres PaginatorResponse[Tobj]](
|
|||||||
req PaginatorRequest[Treq, Tres],
|
req PaginatorRequest[Treq, Tres],
|
||||||
opts PaginatorOptions,
|
opts PaginatorOptions,
|
||||||
) ([]Tobj, error) {
|
) ([]Tobj, error) {
|
||||||
|
if opts.Logger == nil {
|
||||||
|
opts.Logger = log.NewEntry(log.StandardLogger())
|
||||||
|
}
|
||||||
var bfreq, cfreq interface{}
|
var bfreq, cfreq interface{}
|
||||||
fetchOffset := func(page int32) (Tres, error) {
|
fetchOffset := func(page int32) (Tres, error) {
|
||||||
bfreq = req.Page(page)
|
bfreq = req.Page(page)
|
||||||
cfreq = bfreq.(PaginatorRequest[Treq, Tres]).PageSize(int32(opts.PageSize))
|
cfreq = bfreq.(PaginatorRequest[Treq, Tres]).PageSize(int32(opts.PageSize))
|
||||||
res, _, err := cfreq.(PaginatorRequest[Treq, Tres]).Execute()
|
res, hres, err := cfreq.(PaginatorRequest[Treq, Tres]).Execute()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
opts.Logger.WithError(err).WithField("page", page).Warning("failed to fetch page")
|
opts.Logger.WithError(err).WithField("page", page).Warning("failed to fetch page")
|
||||||
|
if hres != nil && hres.StatusCode >= 400 && hres.StatusCode < 500 {
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return res, err
|
return res, err
|
||||||
}
|
}
|
||||||
@ -51,6 +57,9 @@ func Paginator[Tobj any, Treq any, Tres PaginatorResponse[Tobj]](
|
|||||||
for {
|
for {
|
||||||
apiObjects, err := fetchOffset(page)
|
apiObjects, err := fetchOffset(page)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if page == 1 {
|
||||||
|
return objects, err
|
||||||
|
}
|
||||||
errs = append(errs, err)
|
errs = append(errs, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,64 @@
|
|||||||
package ak
|
package ak
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"goauthentik.io/api/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeAPIType struct{}
|
||||||
|
|
||||||
|
type fakeAPIResponse struct {
|
||||||
|
results []fakeAPIType
|
||||||
|
pagination api.Pagination
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fapi *fakeAPIResponse) GetResults() []fakeAPIType { return fapi.results }
|
||||||
|
func (fapi *fakeAPIResponse) GetPagination() api.Pagination { return fapi.pagination }
|
||||||
|
|
||||||
|
type fakeAPIRequest struct {
|
||||||
|
res *fakeAPIResponse
|
||||||
|
http *http.Response
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fapi *fakeAPIRequest) Page(page int32) *fakeAPIRequest { return fapi }
|
||||||
|
func (fapi *fakeAPIRequest) PageSize(size int32) *fakeAPIRequest { return fapi }
|
||||||
|
func (fapi *fakeAPIRequest) Execute() (*fakeAPIResponse, *http.Response, error) {
|
||||||
|
return fapi.res, fapi.http, fapi.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_Simple(t *testing.T) {
|
||||||
|
req := &fakeAPIRequest{
|
||||||
|
res: &fakeAPIResponse{
|
||||||
|
results: []fakeAPIType{
|
||||||
|
{},
|
||||||
|
},
|
||||||
|
pagination: api.Pagination{
|
||||||
|
TotalPages: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
res, err := Paginator(req, PaginatorOptions{})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Len(t, res, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_BadRequest(t *testing.T) {
|
||||||
|
req := &fakeAPIRequest{
|
||||||
|
http: &http.Response{
|
||||||
|
StatusCode: 400,
|
||||||
|
},
|
||||||
|
err: errors.New("foo"),
|
||||||
|
}
|
||||||
|
res, err := Paginator(req, PaginatorOptions{})
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Equal(t, []fakeAPIType{}, res)
|
||||||
|
}
|
||||||
|
|
||||||
// func Test_PaginatorCompile(t *testing.T) {
|
// func Test_PaginatorCompile(t *testing.T) {
|
||||||
// req := api.ApiCoreUsersListRequest{}
|
// req := api.ApiCoreUsersListRequest{}
|
||||||
// Paginator(req, PaginatorOptions{
|
// Paginator(req, PaginatorOptions{
|
||||||
|
|||||||
@ -82,7 +82,8 @@ if [[ "$1" == "server" ]]; then
|
|||||||
run_authentik
|
run_authentik
|
||||||
elif [[ "$1" == "worker" ]]; then
|
elif [[ "$1" == "worker" ]]; then
|
||||||
set_mode "worker"
|
set_mode "worker"
|
||||||
check_if_root "python -m manage worker"
|
shift
|
||||||
|
check_if_root "python -m manage worker $@"
|
||||||
elif [[ "$1" == "worker-status" ]]; then
|
elif [[ "$1" == "worker-status" ]]; then
|
||||||
wait_for_db
|
wait_for_db
|
||||||
celery -A authentik.root.celery flower \
|
celery -A authentik.root.celery flower \
|
||||||
|
|||||||
@ -26,7 +26,7 @@ Parameters:
|
|||||||
Description: authentik Docker image
|
Description: authentik Docker image
|
||||||
AuthentikVersion:
|
AuthentikVersion:
|
||||||
Type: String
|
Type: String
|
||||||
Default: 2025.2.1
|
Default: 2025.2.4
|
||||||
Description: authentik Docker image tag
|
Description: authentik Docker image tag
|
||||||
AuthentikServerCPU:
|
AuthentikServerCPU:
|
||||||
Type: Number
|
Type: Number
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "@goauthentik/authentik",
|
"name": "@goauthentik/authentik",
|
||||||
"version": "2025.2.1",
|
"version": "2025.2.4",
|
||||||
"private": true
|
"private": true
|
||||||
}
|
}
|
||||||
|
|||||||
374
poetry.lock
generated
374
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,6 @@
|
|||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "authentik"
|
name = "authentik"
|
||||||
version = "2025.2.1"
|
version = "2025.2.4"
|
||||||
description = ""
|
description = ""
|
||||||
authors = ["authentik Team <hello@goauthentik.io>"]
|
authors = ["authentik Team <hello@goauthentik.io>"]
|
||||||
|
|
||||||
@ -91,7 +91,7 @@ cryptography = "*"
|
|||||||
dacite = "*"
|
dacite = "*"
|
||||||
deepmerge = "*"
|
deepmerge = "*"
|
||||||
defusedxml = "*"
|
defusedxml = "*"
|
||||||
django = "*"
|
django = "5.0.14"
|
||||||
django-countries = "*"
|
django-countries = "*"
|
||||||
django-cte = "*"
|
django-cte = "*"
|
||||||
django-filter = "*"
|
django-filter = "*"
|
||||||
@ -123,7 +123,7 @@ kubernetes = "*"
|
|||||||
ldap3 = "*"
|
ldap3 = "*"
|
||||||
lxml = "*"
|
lxml = "*"
|
||||||
msgraph-sdk = "*"
|
msgraph-sdk = "*"
|
||||||
opencontainers = { git = "https://github.com/vsoch/oci-python", rev = "20d69d9cc50a0fef31605b46f06da0c94f1ec3cf", extras = ["reggie"] }
|
opencontainers = { git = "https://github.com/BeryJu/oci-python", rev = "c791b19056769cd67957322806809ab70f5bead8", extras = ["reggie"] }
|
||||||
packaging = "*"
|
packaging = "*"
|
||||||
paramiko = "*"
|
paramiko = "*"
|
||||||
psycopg = { extras = ["c"], version = "*" }
|
psycopg = { extras = ["c"], version = "*" }
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
openapi: 3.0.3
|
openapi: 3.0.3
|
||||||
info:
|
info:
|
||||||
title: authentik
|
title: authentik
|
||||||
version: 2025.2.1
|
version: 2025.2.4
|
||||||
description: Making authentication simple.
|
description: Making authentication simple.
|
||||||
contact:
|
contact:
|
||||||
email: hello@goauthentik.io
|
email: hello@goauthentik.io
|
||||||
|
|||||||
@ -94,7 +94,7 @@ export class ApplicationEntitlementsPage extends Table<ApplicationEntitlement> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
renderExpanded(item: ApplicationEntitlement): TemplateResult {
|
renderExpanded(item: ApplicationEntitlement): TemplateResult {
|
||||||
return html` <td></td>
|
return html`<td></td>
|
||||||
<td role="cell" colspan="4">
|
<td role="cell" colspan="4">
|
||||||
<div class="pf-c-table__expandable-row-content">
|
<div class="pf-c-table__expandable-row-content">
|
||||||
<div class="pf-c-content">
|
<div class="pf-c-content">
|
||||||
|
|||||||
@ -58,7 +58,7 @@ export class ApplicationWizardBindingsStep extends ApplicationWizardStep {
|
|||||||
get bindingsAsColumns() {
|
get bindingsAsColumns() {
|
||||||
return this.wizard.bindings.map((binding, index) => {
|
return this.wizard.bindings.map((binding, index) => {
|
||||||
const { order, enabled, timeout } = binding;
|
const { order, enabled, timeout } = binding;
|
||||||
const isSet = P.string.minLength(1);
|
const isSet = P.union(P.string.minLength(1), P.number);
|
||||||
const policy = match(binding)
|
const policy = match(binding)
|
||||||
.with({ policy: isSet }, (v) => msg(str`Policy ${v.policyObj?.name}`))
|
.with({ policy: isSet }, (v) => msg(str`Policy ${v.policyObj?.name}`))
|
||||||
.with({ group: isSet }, (v) => msg(str`Group ${v.groupObj?.name}`))
|
.with({ group: isSet }, (v) => msg(str`Group ${v.groupObj?.name}`))
|
||||||
|
|||||||
@ -21,12 +21,22 @@ export class RelatedApplicationButton extends AKElement {
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
provider?: Provider;
|
provider?: Provider;
|
||||||
|
|
||||||
|
@property()
|
||||||
|
mode: "primary" | "backchannel" = "primary";
|
||||||
|
|
||||||
render(): TemplateResult {
|
render(): TemplateResult {
|
||||||
if (this.provider?.assignedApplicationSlug) {
|
if (this.mode === "primary" && this.provider?.assignedApplicationSlug) {
|
||||||
return html`<a href="#/core/applications/${this.provider.assignedApplicationSlug}">
|
return html`<a href="#/core/applications/${this.provider.assignedApplicationSlug}">
|
||||||
${this.provider.assignedApplicationName}
|
${this.provider.assignedApplicationName}
|
||||||
</a>`;
|
</a>`;
|
||||||
}
|
}
|
||||||
|
if (this.mode === "backchannel" && this.provider?.assignedBackchannelApplicationSlug) {
|
||||||
|
return html`<a
|
||||||
|
href="#/core/applications/${this.provider.assignedBackchannelApplicationSlug}"
|
||||||
|
>
|
||||||
|
${this.provider.assignedBackchannelApplicationName}
|
||||||
|
</a>`;
|
||||||
|
}
|
||||||
return html`<ak-forms-modal>
|
return html`<ak-forms-modal>
|
||||||
<span slot="submit"> ${msg("Create")} </span>
|
<span slot="submit"> ${msg("Create")} </span>
|
||||||
<span slot="header"> ${msg("Create Application")} </span>
|
<span slot="header"> ${msg("Create Application")} </span>
|
||||||
|
|||||||
@ -7,10 +7,10 @@ import { EVENT_REFRESH } from "@goauthentik/common/constants";
|
|||||||
import "@goauthentik/components/events/ObjectChangelog";
|
import "@goauthentik/components/events/ObjectChangelog";
|
||||||
import { AKElement } from "@goauthentik/elements/Base";
|
import { AKElement } from "@goauthentik/elements/Base";
|
||||||
import "@goauthentik/elements/Markdown";
|
import "@goauthentik/elements/Markdown";
|
||||||
import "@goauthentik/elements/SyncStatusCard";
|
|
||||||
import "@goauthentik/elements/Tabs";
|
import "@goauthentik/elements/Tabs";
|
||||||
import "@goauthentik/elements/buttons/ActionButton";
|
import "@goauthentik/elements/buttons/ActionButton";
|
||||||
import "@goauthentik/elements/buttons/ModalButton";
|
import "@goauthentik/elements/buttons/ModalButton";
|
||||||
|
import "@goauthentik/elements/sync/SyncStatusCard";
|
||||||
|
|
||||||
import { msg } from "@lit/localize";
|
import { msg } from "@lit/localize";
|
||||||
import { CSSResult, PropertyValues, TemplateResult, html } from "lit";
|
import { CSSResult, PropertyValues, TemplateResult, html } from "lit";
|
||||||
|
|||||||
@ -9,10 +9,10 @@ import "@goauthentik/components/events/ObjectChangelog";
|
|||||||
import MDSCIMProvider from "@goauthentik/docs/add-secure-apps/providers/scim/index.md";
|
import MDSCIMProvider from "@goauthentik/docs/add-secure-apps/providers/scim/index.md";
|
||||||
import { AKElement } from "@goauthentik/elements/Base";
|
import { AKElement } from "@goauthentik/elements/Base";
|
||||||
import "@goauthentik/elements/Markdown";
|
import "@goauthentik/elements/Markdown";
|
||||||
import "@goauthentik/elements/SyncStatusCard";
|
|
||||||
import "@goauthentik/elements/Tabs";
|
import "@goauthentik/elements/Tabs";
|
||||||
import "@goauthentik/elements/buttons/ActionButton";
|
import "@goauthentik/elements/buttons/ActionButton";
|
||||||
import "@goauthentik/elements/buttons/ModalButton";
|
import "@goauthentik/elements/buttons/ModalButton";
|
||||||
|
import "@goauthentik/elements/sync/SyncStatusCard";
|
||||||
|
|
||||||
import { msg } from "@lit/localize";
|
import { msg } from "@lit/localize";
|
||||||
import { CSSResult, PropertyValues, TemplateResult, html } from "lit";
|
import { CSSResult, PropertyValues, TemplateResult, html } from "lit";
|
||||||
@ -173,6 +173,7 @@ export class SCIMProviderViewPage extends AKElement {
|
|||||||
<dd class="pf-c-description-list__description">
|
<dd class="pf-c-description-list__description">
|
||||||
<div class="pf-c-description-list__text">
|
<div class="pf-c-description-list__text">
|
||||||
<ak-provider-related-application
|
<ak-provider-related-application
|
||||||
|
mode="backchannel"
|
||||||
.provider=${this.provider}
|
.provider=${this.provider}
|
||||||
></ak-provider-related-application>
|
></ak-provider-related-application>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -8,11 +8,11 @@ import MDSourceKerberosBrowser from "@goauthentik/docs/users-sources/sources/pro
|
|||||||
import { AKElement } from "@goauthentik/elements/Base";
|
import { AKElement } from "@goauthentik/elements/Base";
|
||||||
import "@goauthentik/elements/CodeMirror";
|
import "@goauthentik/elements/CodeMirror";
|
||||||
import "@goauthentik/elements/Markdown";
|
import "@goauthentik/elements/Markdown";
|
||||||
import "@goauthentik/elements/SyncStatusCard";
|
|
||||||
import "@goauthentik/elements/Tabs";
|
import "@goauthentik/elements/Tabs";
|
||||||
import "@goauthentik/elements/buttons/ActionButton";
|
import "@goauthentik/elements/buttons/ActionButton";
|
||||||
import "@goauthentik/elements/buttons/SpinnerButton";
|
import "@goauthentik/elements/buttons/SpinnerButton";
|
||||||
import "@goauthentik/elements/forms/ModalForm";
|
import "@goauthentik/elements/forms/ModalForm";
|
||||||
|
import "@goauthentik/elements/sync/SyncStatusCard";
|
||||||
|
|
||||||
import { msg } from "@lit/localize";
|
import { msg } from "@lit/localize";
|
||||||
import { CSSResult, TemplateResult, html } from "lit";
|
import { CSSResult, TemplateResult, html } from "lit";
|
||||||
|
|||||||
@ -6,11 +6,11 @@ import { EVENT_REFRESH } from "@goauthentik/common/constants";
|
|||||||
import "@goauthentik/components/events/ObjectChangelog";
|
import "@goauthentik/components/events/ObjectChangelog";
|
||||||
import { AKElement } from "@goauthentik/elements/Base";
|
import { AKElement } from "@goauthentik/elements/Base";
|
||||||
import "@goauthentik/elements/CodeMirror";
|
import "@goauthentik/elements/CodeMirror";
|
||||||
import "@goauthentik/elements/SyncStatusCard";
|
|
||||||
import "@goauthentik/elements/Tabs";
|
import "@goauthentik/elements/Tabs";
|
||||||
import "@goauthentik/elements/buttons/ActionButton";
|
import "@goauthentik/elements/buttons/ActionButton";
|
||||||
import "@goauthentik/elements/buttons/SpinnerButton";
|
import "@goauthentik/elements/buttons/SpinnerButton";
|
||||||
import "@goauthentik/elements/forms/ModalForm";
|
import "@goauthentik/elements/forms/ModalForm";
|
||||||
|
import "@goauthentik/elements/sync/SyncStatusCard";
|
||||||
|
|
||||||
import { msg } from "@lit/localize";
|
import { msg } from "@lit/localize";
|
||||||
import { CSSResult, TemplateResult, html } from "lit";
|
import { CSSResult, TemplateResult, html } from "lit";
|
||||||
|
|||||||
@ -3,7 +3,7 @@ export const SUCCESS_CLASS = "pf-m-success";
|
|||||||
export const ERROR_CLASS = "pf-m-danger";
|
export const ERROR_CLASS = "pf-m-danger";
|
||||||
export const PROGRESS_CLASS = "pf-m-in-progress";
|
export const PROGRESS_CLASS = "pf-m-in-progress";
|
||||||
export const CURRENT_CLASS = "pf-m-current";
|
export const CURRENT_CLASS = "pf-m-current";
|
||||||
export const VERSION = "2025.2.1";
|
export const VERSION = "2025.2.4";
|
||||||
export const TITLE_DEFAULT = "authentik";
|
export const TITLE_DEFAULT = "authentik";
|
||||||
export const ROUTE_SEPARATOR = ";";
|
export const ROUTE_SEPARATOR = ";";
|
||||||
|
|
||||||
|
|||||||
157
web/src/elements/sync/SyncStatusCard.stories.ts
Normal file
157
web/src/elements/sync/SyncStatusCard.stories.ts
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
import type { Meta, StoryObj } from "@storybook/web-components";
|
||||||
|
|
||||||
|
import { html } from "lit";
|
||||||
|
|
||||||
|
import { LogLevelEnum, SyncStatus, SystemTaskStatusEnum } from "@goauthentik/api";
|
||||||
|
|
||||||
|
import "./SyncStatusCard";
|
||||||
|
|
||||||
|
const metadata: Meta<SyncStatus> = {
|
||||||
|
title: "Elements/<ak-sync-status-card>",
|
||||||
|
component: "ak-sync-status-card",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default metadata;
|
||||||
|
|
||||||
|
export const Running: StoryObj = {
|
||||||
|
args: {
|
||||||
|
status: {
|
||||||
|
isRunning: true,
|
||||||
|
tasks: [],
|
||||||
|
} as SyncStatus,
|
||||||
|
},
|
||||||
|
// @ts-ignore
|
||||||
|
render: ({ status }: SyncStatus) => {
|
||||||
|
return html` <div style="background-color: #f0f0f0; padding: 1rem;">
|
||||||
|
<ak-sync-status-card
|
||||||
|
.fetch=${async () => {
|
||||||
|
return status;
|
||||||
|
}}
|
||||||
|
></ak-sync-status-card>
|
||||||
|
</div>`;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SingleTask: StoryObj = {
|
||||||
|
args: {
|
||||||
|
status: {
|
||||||
|
isRunning: false,
|
||||||
|
tasks: [
|
||||||
|
{
|
||||||
|
uuid: "9ff42169-8249-4b67-ae3d-e455d822de2b",
|
||||||
|
name: "Single task",
|
||||||
|
fullName: "foo:bar:baz",
|
||||||
|
status: SystemTaskStatusEnum.Successful,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
logger: "foo",
|
||||||
|
event: "bar",
|
||||||
|
attributes: {
|
||||||
|
foo: "bar",
|
||||||
|
},
|
||||||
|
timestamp: new Date(),
|
||||||
|
logLevel: LogLevelEnum.Info,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
description: "foo",
|
||||||
|
startTimestamp: new Date(),
|
||||||
|
finishTimestamp: new Date(),
|
||||||
|
duration: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as SyncStatus,
|
||||||
|
},
|
||||||
|
// @ts-ignore
|
||||||
|
render: ({ status }: SyncStatus) => {
|
||||||
|
return html` <div style="background-color: #f0f0f0; padding: 1rem;">
|
||||||
|
<ak-sync-status-card
|
||||||
|
.fetch=${async () => {
|
||||||
|
return status;
|
||||||
|
}}
|
||||||
|
></ak-sync-status-card>
|
||||||
|
</div>`;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MultipleTasks: StoryObj = {
|
||||||
|
args: {
|
||||||
|
status: {
|
||||||
|
isRunning: false,
|
||||||
|
tasks: [
|
||||||
|
{
|
||||||
|
uuid: "9ff42169-8249-4b67-ae3d-e455d822de2b",
|
||||||
|
name: "Single task",
|
||||||
|
fullName: "foo:bar:baz",
|
||||||
|
status: SystemTaskStatusEnum.Successful,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
logger: "foo",
|
||||||
|
event: "bar",
|
||||||
|
attributes: {
|
||||||
|
foo: "bar",
|
||||||
|
},
|
||||||
|
timestamp: new Date(),
|
||||||
|
logLevel: LogLevelEnum.Info,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
description: "foo",
|
||||||
|
startTimestamp: new Date(),
|
||||||
|
finishTimestamp: new Date(),
|
||||||
|
duration: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uuid: "9ff42169-8249-4b67-ae3d-e455d822de2b",
|
||||||
|
name: "Single task",
|
||||||
|
fullName: "foo:bar:baz",
|
||||||
|
status: SystemTaskStatusEnum.Successful,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
logger: "foo",
|
||||||
|
event: "bar",
|
||||||
|
attributes: {
|
||||||
|
foo: "bar",
|
||||||
|
},
|
||||||
|
timestamp: new Date(),
|
||||||
|
logLevel: LogLevelEnum.Info,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
description: "foo",
|
||||||
|
startTimestamp: new Date(),
|
||||||
|
finishTimestamp: new Date(),
|
||||||
|
duration: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uuid: "9ff42169-8249-4b67-ae3d-e455d822de2b",
|
||||||
|
name: "Single task",
|
||||||
|
fullName: "foo:bar:baz",
|
||||||
|
status: SystemTaskStatusEnum.Successful,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
logger: "foo",
|
||||||
|
event: "bar",
|
||||||
|
attributes: {
|
||||||
|
foo: "bar",
|
||||||
|
},
|
||||||
|
timestamp: new Date(),
|
||||||
|
logLevel: LogLevelEnum.Info,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
description: "foo",
|
||||||
|
startTimestamp: new Date(),
|
||||||
|
finishTimestamp: new Date(),
|
||||||
|
duration: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as SyncStatus,
|
||||||
|
},
|
||||||
|
// @ts-ignore
|
||||||
|
render: ({ status }: SyncStatus) => {
|
||||||
|
return html` <div style="background-color: #f0f0f0; padding: 1rem;">
|
||||||
|
<ak-sync-status-card
|
||||||
|
.fetch=${async () => {
|
||||||
|
return status;
|
||||||
|
}}
|
||||||
|
></ak-sync-status-card>
|
||||||
|
</div>`;
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -3,17 +3,92 @@ import { getRelativeTime } from "@goauthentik/common/utils";
|
|||||||
import "@goauthentik/components/ak-status-label";
|
import "@goauthentik/components/ak-status-label";
|
||||||
import { AKElement } from "@goauthentik/elements/Base";
|
import { AKElement } from "@goauthentik/elements/Base";
|
||||||
import "@goauthentik/elements/EmptyState";
|
import "@goauthentik/elements/EmptyState";
|
||||||
|
import "@goauthentik/elements/buttons/ActionButton";
|
||||||
import "@goauthentik/elements/events/LogViewer";
|
import "@goauthentik/elements/events/LogViewer";
|
||||||
|
import { PaginatedResponse, Table, TableColumn } from "@goauthentik/elements/table/Table";
|
||||||
|
|
||||||
import { msg, str } from "@lit/localize";
|
import { msg } from "@lit/localize";
|
||||||
import { CSSResult, TemplateResult, html, nothing } from "lit";
|
import { CSSResult, TemplateResult, css, html } from "lit";
|
||||||
import { customElement, property, state } from "lit/decorators.js";
|
import { customElement, property, state } from "lit/decorators.js";
|
||||||
|
|
||||||
import PFCard from "@patternfly/patternfly/components/Card/card.css";
|
import PFCard from "@patternfly/patternfly/components/Card/card.css";
|
||||||
|
import PFTable from "@patternfly/patternfly/components/Table/table.css";
|
||||||
import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||||
|
|
||||||
import { SyncStatus, SystemTask, SystemTaskStatusEnum } from "@goauthentik/api";
|
import { SyncStatus, SystemTask, SystemTaskStatusEnum } from "@goauthentik/api";
|
||||||
|
|
||||||
|
@customElement("ak-sync-status-table")
|
||||||
|
export class SyncStatusTable extends Table<SystemTask> {
|
||||||
|
@property({ attribute: false })
|
||||||
|
tasks: SystemTask[] = [];
|
||||||
|
|
||||||
|
expandable = true;
|
||||||
|
|
||||||
|
static get styles() {
|
||||||
|
return super.styles.concat(css`
|
||||||
|
code:not(:last-of-type)::after {
|
||||||
|
content: "-";
|
||||||
|
margin: 0 0.25rem;
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async apiEndpoint(): Promise<PaginatedResponse<SystemTask>> {
|
||||||
|
return {
|
||||||
|
pagination: {
|
||||||
|
next: 0,
|
||||||
|
previous: 0,
|
||||||
|
count: this.tasks.length,
|
||||||
|
current: 1,
|
||||||
|
totalPages: 1,
|
||||||
|
startIndex: 0,
|
||||||
|
endIndex: this.tasks.length,
|
||||||
|
},
|
||||||
|
results: this.tasks,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
columns(): TableColumn[] {
|
||||||
|
return [
|
||||||
|
new TableColumn(msg("Task")),
|
||||||
|
new TableColumn(msg("Status")),
|
||||||
|
new TableColumn(msg("Finished")),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
row(item: SystemTask): TemplateResult[] {
|
||||||
|
const nameParts = item.fullName.split(":");
|
||||||
|
nameParts.shift();
|
||||||
|
return [
|
||||||
|
html`<div>${item.name}</div>
|
||||||
|
<small>${nameParts.map((part) => html`<code>${part}</code>`)}</small>`,
|
||||||
|
html`<ak-status-label
|
||||||
|
?good=${item.status === SystemTaskStatusEnum.Successful}
|
||||||
|
good-label=${msg("Finished successfully")}
|
||||||
|
bad-label=${msg("Finished with errors")}
|
||||||
|
></ak-status-label>`,
|
||||||
|
html`<div>${getRelativeTime(item.finishTimestamp)}</div>
|
||||||
|
<small>${item.finishTimestamp.toLocaleString()}</small>`,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
renderExpanded(item: SystemTask): TemplateResult {
|
||||||
|
return html`<td role="cell" colspan="4">
|
||||||
|
<div class="pf-c-table__expandable-row-content">
|
||||||
|
<ak-log-viewer .logs=${item?.messages}></ak-log-viewer>
|
||||||
|
</div>
|
||||||
|
</td>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderToolbarContainer() {
|
||||||
|
return html``;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderTablePagination() {
|
||||||
|
return html``;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@customElement("ak-sync-status-card")
|
@customElement("ak-sync-status-card")
|
||||||
export class SyncStatusCard extends AKElement {
|
export class SyncStatusCard extends AKElement {
|
||||||
@state()
|
@state()
|
||||||
@ -29,7 +104,7 @@ export class SyncStatusCard extends AKElement {
|
|||||||
triggerSync!: () => Promise<unknown>;
|
triggerSync!: () => Promise<unknown>;
|
||||||
|
|
||||||
static get styles(): CSSResult[] {
|
static get styles(): CSSResult[] {
|
||||||
return [PFBase, PFCard];
|
return [PFBase, PFCard, PFTable];
|
||||||
}
|
}
|
||||||
|
|
||||||
firstUpdated() {
|
firstUpdated() {
|
||||||
@ -40,25 +115,6 @@ export class SyncStatusCard extends AKElement {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
renderSyncTask(task: SystemTask): TemplateResult {
|
|
||||||
return html`<li>
|
|
||||||
${(this.syncState?.tasks || []).length > 1 ? html`<span>${task.name}</span>` : nothing}
|
|
||||||
<span
|
|
||||||
><ak-status-label
|
|
||||||
?good=${task.status === SystemTaskStatusEnum.Successful}
|
|
||||||
good-label=${msg("Finished successfully")}
|
|
||||||
bad-label=${msg("Finished with errors")}
|
|
||||||
></ak-status-label
|
|
||||||
></span>
|
|
||||||
<span
|
|
||||||
>${msg(
|
|
||||||
str`Finished ${getRelativeTime(task.finishTimestamp)} (${task.finishTimestamp.toLocaleString()})`,
|
|
||||||
)}</span
|
|
||||||
>
|
|
||||||
<ak-log-viewer .logs=${task?.messages}></ak-log-viewer>
|
|
||||||
</li> `;
|
|
||||||
}
|
|
||||||
|
|
||||||
renderSyncStatus(): TemplateResult {
|
renderSyncStatus(): TemplateResult {
|
||||||
if (this.loading) {
|
if (this.loading) {
|
||||||
return html`<ak-empty-state ?loading=${true}></ak-empty-state>`;
|
return html`<ak-empty-state ?loading=${true}></ak-empty-state>`;
|
||||||
@ -72,13 +128,7 @@ export class SyncStatusCard extends AKElement {
|
|||||||
if (this.syncState.tasks.length < 1) {
|
if (this.syncState.tasks.length < 1) {
|
||||||
return html`${msg("Not synced yet.")}`;
|
return html`${msg("Not synced yet.")}`;
|
||||||
}
|
}
|
||||||
return html`
|
return html`<ak-sync-status-table .tasks=${this.syncState.tasks}></ak-sync-status-table>`;
|
||||||
<ul class="pf-c-list">
|
|
||||||
${this.syncState.tasks.map((task) => {
|
|
||||||
return this.renderSyncTask(task);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
render(): TemplateResult {
|
render(): TemplateResult {
|
||||||
@ -120,6 +170,7 @@ export class SyncStatusCard extends AKElement {
|
|||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface HTMLElementTagNameMap {
|
interface HTMLElementTagNameMap {
|
||||||
|
"ak-sync-status-table": SyncStatusTable;
|
||||||
"ak-sync-status-card": SyncStatusCard;
|
"ak-sync-status-card": SyncStatusCard;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -93,10 +93,12 @@ export class AuthenticatorValidateStageWebCode extends BaseDeviceStage<
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
name="code"
|
name="code"
|
||||||
inputmode="${this.deviceChallenge?.deviceClass === DeviceClassesEnum.Static
|
inputmode="${this.deviceChallenge?.deviceClass ===
|
||||||
|
DeviceClassesEnum.Static
|
||||||
? "text"
|
? "text"
|
||||||
: "numeric"}"
|
: "numeric"}"
|
||||||
pattern="${this.deviceChallenge?.deviceClass === DeviceClassesEnum.Static
|
pattern="${this.deviceChallenge?.deviceClass ===
|
||||||
|
DeviceClassesEnum.Static
|
||||||
? "[0-9a-zA-Z]*"
|
? "[0-9a-zA-Z]*"
|
||||||
: "[0-9]*"}"
|
: "[0-9]*"}"
|
||||||
placeholder="${msg("Please enter your code")}"
|
placeholder="${msg("Please enter your code")}"
|
||||||
@ -115,7 +117,10 @@ export class AuthenticatorValidateStageWebCode extends BaseDeviceStage<
|
|||||||
${this.renderReturnToDevicePicker()}
|
${this.renderReturnToDevicePicker()}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>`;
|
</div>
|
||||||
|
<footer class="pf-c-login__main-footer">
|
||||||
|
<ul class="pf-c-login__main-footer-links"></ul>
|
||||||
|
</footer>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -72,7 +72,9 @@ export class BaseStage<
|
|||||||
}
|
}
|
||||||
return this.host?.submit(object as unknown as Tout).then((successful) => {
|
return this.host?.submit(object as unknown as Tout).then((successful) => {
|
||||||
if (successful) {
|
if (successful) {
|
||||||
this.cleanup();
|
this.onSubmitSuccess();
|
||||||
|
} else {
|
||||||
|
this.onSubmitFailure();
|
||||||
}
|
}
|
||||||
return successful;
|
return successful;
|
||||||
});
|
});
|
||||||
@ -124,7 +126,11 @@ export class BaseStage<
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
cleanup(): void {
|
onSubmitSuccess(): void {
|
||||||
|
// Method that can be overridden by stages
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onSubmitFailure(): void {
|
||||||
// Method that can be overridden by stages
|
// Method that can be overridden by stages
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import { randomId } from "@goauthentik/elements/utils/randomId";
|
|||||||
import "@goauthentik/flow/FormStatic";
|
import "@goauthentik/flow/FormStatic";
|
||||||
import { BaseStage } from "@goauthentik/flow/stages/base";
|
import { BaseStage } from "@goauthentik/flow/stages/base";
|
||||||
import { P, match } from "ts-pattern";
|
import { P, match } from "ts-pattern";
|
||||||
import type { TurnstileObject } from "turnstile-types";
|
import type * as _ from "turnstile-types";
|
||||||
|
|
||||||
import { msg } from "@lit/localize";
|
import { msg } from "@lit/localize";
|
||||||
import { CSSResult, PropertyValues, TemplateResult, css, html, nothing } from "lit";
|
import { CSSResult, PropertyValues, TemplateResult, css, html, nothing } from "lit";
|
||||||
@ -24,10 +24,6 @@ import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
|||||||
|
|
||||||
import { CaptchaChallenge, CaptchaChallengeResponseRequest } from "@goauthentik/api";
|
import { CaptchaChallenge, CaptchaChallengeResponseRequest } from "@goauthentik/api";
|
||||||
|
|
||||||
interface TurnstileWindow extends Window {
|
|
||||||
turnstile: TurnstileObject;
|
|
||||||
}
|
|
||||||
|
|
||||||
type TokenHandler = (token: string) => void;
|
type TokenHandler = (token: string) => void;
|
||||||
|
|
||||||
type Dims = { height: number };
|
type Dims = { height: number };
|
||||||
@ -52,6 +48,8 @@ type CaptchaHandler = {
|
|||||||
name: string;
|
name: string;
|
||||||
interactive: () => Promise<unknown>;
|
interactive: () => Promise<unknown>;
|
||||||
execute: () => Promise<unknown>;
|
execute: () => Promise<unknown>;
|
||||||
|
refreshInteractive: () => Promise<unknown>;
|
||||||
|
refresh: () => Promise<unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
// A container iframe for a hosted Captcha, with an event emitter to monitor when the Captcha forces
|
// A container iframe for a hosted Captcha, with an event emitter to monitor when the Captcha forces
|
||||||
@ -119,6 +117,12 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
|
|||||||
this.host.submit({ component: "ak-stage-captcha", token });
|
this.host.submit({ component: "ak-stage-captcha", token });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
refreshedAt = new Date();
|
||||||
|
|
||||||
|
@state()
|
||||||
|
activeHandler?: CaptchaHandler = undefined;
|
||||||
|
|
||||||
@state()
|
@state()
|
||||||
error?: string;
|
error?: string;
|
||||||
|
|
||||||
@ -127,16 +131,22 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
|
|||||||
name: "grecaptcha",
|
name: "grecaptcha",
|
||||||
interactive: this.renderGReCaptchaFrame,
|
interactive: this.renderGReCaptchaFrame,
|
||||||
execute: this.executeGReCaptcha,
|
execute: this.executeGReCaptcha,
|
||||||
|
refreshInteractive: this.refreshGReCaptchaFrame,
|
||||||
|
refresh: this.refreshGReCaptcha,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "hcaptcha",
|
name: "hcaptcha",
|
||||||
interactive: this.renderHCaptchaFrame,
|
interactive: this.renderHCaptchaFrame,
|
||||||
execute: this.executeHCaptcha,
|
execute: this.executeHCaptcha,
|
||||||
|
refreshInteractive: this.refreshHCaptchaFrame,
|
||||||
|
refresh: this.refreshHCaptcha,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "turnstile",
|
name: "turnstile",
|
||||||
interactive: this.renderTurnstileFrame,
|
interactive: this.renderTurnstileFrame,
|
||||||
execute: this.executeTurnstile,
|
execute: this.executeTurnstile,
|
||||||
|
refreshInteractive: this.refreshTurnstileFrame,
|
||||||
|
refresh: this.refreshTurnstile,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -230,6 +240,15 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async refreshGReCaptchaFrame() {
|
||||||
|
(this.captchaFrame.contentWindow as typeof window)?.grecaptcha.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshGReCaptcha() {
|
||||||
|
window.grecaptcha.reset();
|
||||||
|
window.grecaptcha.execute();
|
||||||
|
}
|
||||||
|
|
||||||
async renderHCaptchaFrame() {
|
async renderHCaptchaFrame() {
|
||||||
this.renderFrame(
|
this.renderFrame(
|
||||||
html`<div
|
html`<div
|
||||||
@ -251,6 +270,15 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async refreshHCaptchaFrame() {
|
||||||
|
(this.captchaFrame.contentWindow as typeof window)?.hcaptcha.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshHCaptcha() {
|
||||||
|
window.hcaptcha.reset();
|
||||||
|
window.hcaptcha.execute();
|
||||||
|
}
|
||||||
|
|
||||||
async renderTurnstileFrame() {
|
async renderTurnstileFrame() {
|
||||||
this.renderFrame(
|
this.renderFrame(
|
||||||
html`<div
|
html`<div
|
||||||
@ -262,13 +290,18 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
|
|||||||
}
|
}
|
||||||
|
|
||||||
async executeTurnstile() {
|
async executeTurnstile() {
|
||||||
return (window as unknown as TurnstileWindow).turnstile.render(
|
return window.turnstile.render(this.captchaDocumentContainer, {
|
||||||
this.captchaDocumentContainer,
|
|
||||||
{
|
|
||||||
sitekey: this.challenge.siteKey,
|
sitekey: this.challenge.siteKey,
|
||||||
callback: this.onTokenChange,
|
callback: this.onTokenChange,
|
||||||
},
|
});
|
||||||
);
|
}
|
||||||
|
|
||||||
|
async refreshTurnstileFrame() {
|
||||||
|
(this.captchaFrame.contentWindow as typeof window)?.turnstile.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshTurnstile() {
|
||||||
|
window.turnstile.reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
async renderFrame(captchaElement: TemplateResult) {
|
async renderFrame(captchaElement: TemplateResult) {
|
||||||
@ -336,16 +369,19 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
|
|||||||
const handlers = this.handlers.filter(({ name }) => Object.hasOwn(window, name));
|
const handlers = this.handlers.filter(({ name }) => Object.hasOwn(window, name));
|
||||||
let lastError = undefined;
|
let lastError = undefined;
|
||||||
let found = false;
|
let found = false;
|
||||||
for (const { name, interactive, execute } of handlers) {
|
for (const handler of handlers) {
|
||||||
console.debug(`authentik/stages/captcha: trying handler ${name}`);
|
console.debug(`authentik/stages/captcha: trying handler ${handler.name}`);
|
||||||
try {
|
try {
|
||||||
const runner = this.challenge.interactive ? interactive : execute;
|
const runner = this.challenge.interactive
|
||||||
|
? handler.interactive
|
||||||
|
: handler.execute;
|
||||||
await runner.apply(this);
|
await runner.apply(this);
|
||||||
console.debug(`authentik/stages/captcha[${name}]: handler succeeded`);
|
console.debug(`authentik/stages/captcha[${handler.name}]: handler succeeded`);
|
||||||
found = true;
|
found = true;
|
||||||
|
this.activeHandler = handler;
|
||||||
break;
|
break;
|
||||||
} catch (exc) {
|
} catch (exc) {
|
||||||
console.debug(`authentik/stages/captcha[${name}]: handler failed`);
|
console.debug(`authentik/stages/captcha[${handler.name}]: handler failed`);
|
||||||
console.debug(exc);
|
console.debug(exc);
|
||||||
lastError = exc;
|
lastError = exc;
|
||||||
}
|
}
|
||||||
@ -370,6 +406,19 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
|
|||||||
document.body.appendChild(this.captchaDocumentContainer);
|
document.body.appendChild(this.captchaDocumentContainer);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updated(changedProperties: PropertyValues<this>) {
|
||||||
|
if (!changedProperties.has("refreshedAt") || !this.challenge) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.debug("authentik/stages/captcha: refresh triggered");
|
||||||
|
if (this.challenge.interactive) {
|
||||||
|
this.activeHandler?.refreshInteractive.apply(this);
|
||||||
|
} else {
|
||||||
|
this.activeHandler?.refresh.apply(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
|||||||
@ -49,6 +49,8 @@ export class IdentificationStage extends BaseStage<
|
|||||||
|
|
||||||
@state()
|
@state()
|
||||||
captchaToken = "";
|
captchaToken = "";
|
||||||
|
@state()
|
||||||
|
captchaRefreshedAt = new Date();
|
||||||
|
|
||||||
static get styles(): CSSResult[] {
|
static get styles(): CSSResult[] {
|
||||||
return [
|
return [
|
||||||
@ -179,12 +181,16 @@ export class IdentificationStage extends BaseStage<
|
|||||||
this.form.appendChild(totp);
|
this.form.appendChild(totp);
|
||||||
}
|
}
|
||||||
|
|
||||||
cleanup(): void {
|
onSubmitSuccess(): void {
|
||||||
if (this.form) {
|
if (this.form) {
|
||||||
this.form.remove();
|
this.form.remove();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onSubmitFailure(): void {
|
||||||
|
this.captchaRefreshedAt = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
renderSource(source: LoginSource): TemplateResult {
|
renderSource(source: LoginSource): TemplateResult {
|
||||||
const icon = renderSourceIcon(source.name, source.iconUrl);
|
const icon = renderSourceIcon(source.name, source.iconUrl);
|
||||||
return html`<li class="pf-c-login__main-footer-links-item">
|
return html`<li class="pf-c-login__main-footer-links-item">
|
||||||
@ -287,6 +293,7 @@ export class IdentificationStage extends BaseStage<
|
|||||||
.onTokenChange=${(token: string) => {
|
.onTokenChange=${(token: string) => {
|
||||||
this.captchaToken = token;
|
this.captchaToken = token;
|
||||||
}}
|
}}
|
||||||
|
.refreshedAt=${this.captchaRefreshedAt}
|
||||||
embedded
|
embedded
|
||||||
></ak-stage-captcha>
|
></ak-stage-captcha>
|
||||||
`
|
`
|
||||||
|
|||||||
@ -170,8 +170,16 @@ class UserInterfacePresentation extends AKElement {
|
|||||||
slot="extra"
|
slot="extra"
|
||||||
>
|
>
|
||||||
${msg("Admin interface")}
|
${msg("Admin interface")}
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
class="pf-c-button pf-m-secondary pf-m-small pf-u-display-none-on-md pf-u-display-block"
|
||||||
|
href="${globalAK().api.base}if/admin/"
|
||||||
|
slot="extra"
|
||||||
|
>
|
||||||
|
${msg("Admin")}
|
||||||
</a>`;
|
</a>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
// The `!` in the field definitions above only re-assure typescript and eslint that the
|
// The `!` in the field definitions above only re-assure typescript and eslint that the
|
||||||
// values *should* be available, not that they *are*. Thus this contract check; it asserts
|
// values *should* be available, not that they *are*. Thus this contract check; it asserts
|
||||||
|
|||||||
@ -59,6 +59,10 @@ export class UserSettingsPage extends AKElement {
|
|||||||
:host([theme="dark"]) .pf-c-page__main-section {
|
:host([theme="dark"]) .pf-c-page__main-section {
|
||||||
--pf-c-page__main-section--BackgroundColor: transparent;
|
--pf-c-page__main-section--BackgroundColor: transparent;
|
||||||
}
|
}
|
||||||
|
.pf-c-page__main {
|
||||||
|
min-height: 100vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
@media screen and (min-width: 1200px) {
|
@media screen and (min-width: 1200px) {
|
||||||
:host {
|
:host {
|
||||||
width: 90rem;
|
width: 90rem;
|
||||||
|
|||||||
BIN
website/docs/developer-docs/setup/debug_vscode.png
Normal file
BIN
website/docs/developer-docs/setup/debug_vscode.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 805 KiB |
53
website/docs/developer-docs/setup/debugging.md
Normal file
53
website/docs/developer-docs/setup/debugging.md
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
---
|
||||||
|
title: Debugging authentik
|
||||||
|
---
|
||||||
|
|
||||||
|
This page describes how to debug different components of an authentik instance, running either in production or in a development setup. To learn more about the structure of authentik, refer to our [architecture documentation](../../core/architecture).
|
||||||
|
|
||||||
|
## authentik Server & Worker (Python)
|
||||||
|
|
||||||
|
The majority of the authentik codebase is in Python, running in Gunicorn for the server and Celery for the worker. These instructions show how this code can be debugged/inspected. The local debugging setup requires a setup as described in [Full development environment](./full-dev-environment.mdx)
|
||||||
|
|
||||||
|
Note that authentik uses [debugpy](https://github.com/microsoft/debugpy), which relies on the "Debug Adapter Protocol" (DAP). These instructions demonstrate debugging using [Visual Studio Code](https://code.visualstudio.com/), however they should be adaptable to other editors that support DAP.
|
||||||
|
|
||||||
|
To enable the debugging server, set the environment variable `AUTHENTIK_DEBUGGER` to `true`. This will launch the debugging server (by default on port _9901_).
|
||||||
|
|
||||||
|
With this setup in place, you can set Breakpoints in VS Code. To connect to the debugging server, run the command `> Debug: Start Debugging" in VS Code.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
:::info
|
||||||
|
Note that due to the Python debugger for VS Code, when a Python file in authentik is saved and the Django process restarts, you must manually reconnect the Debug session. Automatic re-connection is not supported for the Python debugger (see [here](https://github.com/microsoft/vscode-python/issues/19998) and [here](https://github.com/microsoft/vscode-python/issues/1182)).
|
||||||
|
:::
|
||||||
|
|
||||||
|
#### Debugging in containers
|
||||||
|
|
||||||
|
When debugging an authentik instance running in containers, there are some additional steps that need to be taken in addition to the steps above.
|
||||||
|
|
||||||
|
A local clone of the authentik repository is required to be able to set breakpoints in the code. The locally checked out repository must be on the same version/commit as the authentik version running in the containers. To checkout version 2024.12.3 for example, you can run `git checkout version/2024.12.3`.
|
||||||
|
|
||||||
|
The debug port needs to be accessible on the local machine. By default, this is port 9901. Additionally, the container being debugged must be started as `root`, because additional dependencies need to be installed on startup.
|
||||||
|
|
||||||
|
When running in Docker Compose, a file `docker-compose.override.yml` can be created next to the authentik docker-compose.yml file to expose the port, change the user, and enable debug mode.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
# Replace `server` with `worker` to debug the worker container.
|
||||||
|
server:
|
||||||
|
user: root
|
||||||
|
healthcheck:
|
||||||
|
disable: true
|
||||||
|
environment:
|
||||||
|
AUTHENTIK_DEBUGGER: "true"
|
||||||
|
AUTHENTIK_LOG_LEVEL: "debug"
|
||||||
|
ports:
|
||||||
|
- 9901:9901
|
||||||
|
```
|
||||||
|
|
||||||
|
After re-creating the containers with `AUTHENTIK_DEBUGGER` set to `true` and the port mapped, the steps are identical to the steps above.
|
||||||
|
|
||||||
|
If the authentik instance is running on a remote server, the `.vscode/launch.json` file needs to be adjusted to point to the IP of the remote server. Alternatively, it is also possible to forward the debug port via an SSH tunnel, using `-L 9901:127.0.0.1:9901`.
|
||||||
|
|
||||||
|
## authentik Server / Outposts (Golang)
|
||||||
|
|
||||||
|
Outposts, as well as some auxiliary code of the authentik server, are written in Go. These components can be debugged using standard Golang tooling, such as [Delve](https://github.com/go-delve/delve).
|
||||||
@ -2,9 +2,9 @@
|
|||||||
title: Full development environment
|
title: Full development environment
|
||||||
---
|
---
|
||||||
|
|
||||||
import Tabs from '@theme/Tabs';
|
import Tabs from "@theme/Tabs";
|
||||||
import TabItem from '@theme/TabItem';
|
import TabItem from "@theme/TabItem";
|
||||||
import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment';
|
import ExecutionEnvironment from "@docusaurus/ExecutionEnvironment";
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
@ -70,7 +70,9 @@ instructions](https://golangci-lint.run/welcome/install/#other-ci).
|
|||||||
|
|
||||||
</TabItem>
|
</TabItem>
|
||||||
|
|
||||||
<TabItem value="windows">[We request community input on running the full dev environment on Windows]</TabItem>
|
<TabItem value="windows">
|
||||||
|
[We request community input on running the full dev environment on Windows]
|
||||||
|
</TabItem>
|
||||||
|
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
@ -77,7 +77,7 @@ To check if your config has been applied correctly, you can run the following co
|
|||||||
- `AUTHENTIK_POSTGRESQL__SSLCERT`: Path to x509 client certificate to authenticate to server
|
- `AUTHENTIK_POSTGRESQL__SSLCERT`: Path to x509 client certificate to authenticate to server
|
||||||
- `AUTHENTIK_POSTGRESQL__SSLKEY`: Path to private key of `SSLCERT` certificate
|
- `AUTHENTIK_POSTGRESQL__SSLKEY`: Path to private key of `SSLCERT` certificate
|
||||||
- `AUTHENTIK_POSTGRESQL__CONN_MAX_AGE`: Database connection lifetime. Defaults to `0` (no persistent connections). Can be set to `null` for unlimited persistent connections. See [Django's documentation](https://docs.djangoproject.com/en/stable/ref/settings/#conn-max-age) for more details.
|
- `AUTHENTIK_POSTGRESQL__CONN_MAX_AGE`: Database connection lifetime. Defaults to `0` (no persistent connections). Can be set to `null` for unlimited persistent connections. See [Django's documentation](https://docs.djangoproject.com/en/stable/ref/settings/#conn-max-age) for more details.
|
||||||
- `AUTHENTIK_POSTGRESQL__CONN_HEALTH_CHECK`: Existing persistent database connections will be health checked before they are reused if set to `true`. Defaults to `false`. See [Django's documentation](https://docs.djangoproject.com/en/stable/ref/settings/#conn-health-checks) for more details.
|
- `AUTHENTIK_POSTGRESQL__CONN_HEALTH_CHECKS`: Existing persistent database connections will be health checked before they are reused if set to `true`. Defaults to `false`. See [Django's documentation](https://docs.djangoproject.com/en/stable/ref/settings/#conn-health-checks) for more details.
|
||||||
- `AUTHENTIK_POSTGRESQL__DISABLE_SERVER_SIDE_CURSORS`: Disable server side cursors when set to `true`. Defaults to `false`. See [Django's documentation](https://docs.djangoproject.com/en/stable/ref/settings/#disable-server-side-cursors) for more details.
|
- `AUTHENTIK_POSTGRESQL__DISABLE_SERVER_SIDE_CURSORS`: Disable server side cursors when set to `true`. Defaults to `false`. See [Django's documentation](https://docs.djangoproject.com/en/stable/ref/settings/#disable-server-side-cursors) for more details.
|
||||||
|
|
||||||
The PostgreSQL settings `HOST`, `PORT`, `USER`, and `PASSWORD` support hot-reloading. Adding and removing read replicas doesn't support hot-reloading.
|
The PostgreSQL settings `HOST`, `PORT`, `USER`, and `PASSWORD` support hot-reloading. Adding and removing read replicas doesn't support hot-reloading.
|
||||||
@ -108,7 +108,7 @@ The same PostgreSQL settings as described above are used for each read replica.
|
|||||||
- `AUTHENTIK_POSTGRESQL__READ_REPLICAS__0__SSLCERT`
|
- `AUTHENTIK_POSTGRESQL__READ_REPLICAS__0__SSLCERT`
|
||||||
- `AUTHENTIK_POSTGRESQL__READ_REPLICAS__0__SSLKEY`
|
- `AUTHENTIK_POSTGRESQL__READ_REPLICAS__0__SSLKEY`
|
||||||
- `AUTHENTIK_POSTGRESQL__READ_REPLICAS__0__CONN_MAX_AGE`
|
- `AUTHENTIK_POSTGRESQL__READ_REPLICAS__0__CONN_MAX_AGE`
|
||||||
- `AUTHENTIK_POSTGRESQL__READ_REPLICAS__0__CONN_HEALTH_CHECK`
|
- `AUTHENTIK_POSTGRESQL__READ_REPLICAS__0__CONN_HEALTH_CHECKS`
|
||||||
- `AUTHENTIK_POSTGRESQL__READ_REPLICAS__0__DISABLE_SERVER_SIDE_CURSORS`
|
- `AUTHENTIK_POSTGRESQL__READ_REPLICAS__0__DISABLE_SERVER_SIDE_CURSORS`
|
||||||
|
|
||||||
### Using a PostgreSQL connection pooler (PgBouncer or PgPool)
|
### Using a PostgreSQL connection pooler (PgBouncer or PgPool)
|
||||||
@ -125,7 +125,7 @@ When your PostgreSQL database(s) are running behind a connection pooler, like Pg
|
|||||||
|
|
||||||
Using a connection pooler in transaction pool mode (e.g. PgPool, or PgBouncer in transaction or statement pool mode) requires disabling server-side cursors, so this setting must be set to `false`.
|
Using a connection pooler in transaction pool mode (e.g. PgPool, or PgBouncer in transaction or statement pool mode) requires disabling server-side cursors, so this setting must be set to `false`.
|
||||||
|
|
||||||
Additionally, you can set `AUTHENTIK_POSTGRESQL__CONN_HEALTH_CHECK` to perform health checks on persistent database connections before they are reused.
|
Additionally, you can set `AUTHENTIK_POSTGRESQL__CONN_HEALTH_CHECKS` to perform health checks on persistent database connections before they are reused.
|
||||||
|
|
||||||
## Redis Settings
|
## Redis Settings
|
||||||
|
|
||||||
@ -175,6 +175,7 @@ Additionally, you can set `AUTHENTIK_POSTGRESQL__CONN_HEALTH_CHECK` to perform h
|
|||||||
- `AUTHENTIK_LISTEN__LDAPS`: Listening address:port (e.g. `0.0.0.0:6636`) for LDAPS (Applies to LDAP outpost)
|
- `AUTHENTIK_LISTEN__LDAPS`: Listening address:port (e.g. `0.0.0.0:6636`) for LDAPS (Applies to LDAP outpost)
|
||||||
- `AUTHENTIK_LISTEN__METRICS`: Listening address:port (e.g. `0.0.0.0:9300`) for Prometheus metrics (Applies to All)
|
- `AUTHENTIK_LISTEN__METRICS`: Listening address:port (e.g. `0.0.0.0:9300`) for Prometheus metrics (Applies to All)
|
||||||
- `AUTHENTIK_LISTEN__DEBUG`: Listening address:port (e.g. `0.0.0.0:9900`) for Go Debugging metrics (Applies to All)
|
- `AUTHENTIK_LISTEN__DEBUG`: Listening address:port (e.g. `0.0.0.0:9900`) for Go Debugging metrics (Applies to All)
|
||||||
|
- `AUTHENTIK_LISTEN__DEBUG_PY`: Listening address:port (e.g. `0.0.0.0:9901`) for Python debugging server (Applies to Server, see [Debugging](../../developer-docs/setup/debugging.md))
|
||||||
- `AUTHENTIK_LISTEN__TRUSTED_PROXY_CIDRS`: List of comma-separated CIDRs that proxy headers should be accepted from (Applies to Server)
|
- `AUTHENTIK_LISTEN__TRUSTED_PROXY_CIDRS`: List of comma-separated CIDRs that proxy headers should be accepted from (Applies to Server)
|
||||||
|
|
||||||
Defaults to `127.0.0.0/8`, `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `fe80::/10`, `::1/128`.
|
Defaults to `127.0.0.0/8`, `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `fe80::/10`, `::1/128`.
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
23
website/docs/security/cves/CVE-2025-29928.md
Normal file
23
website/docs/security/cves/CVE-2025-29928.md
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
# CVE-2025-29928
|
||||||
|
|
||||||
|
## Deletion of sessions did not revoke sessions when using database session storage
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
|
||||||
|
When authentik was configured to use the database for session storage (which is a non-default setting), deleting sessions via the Web Interface or the API would not revoke the session and the session holder would continue to have access to authentik.
|
||||||
|
|
||||||
|
This also affects automatic session deletion when a user is set to inactive or a user is deleted.
|
||||||
|
|
||||||
|
### Patches
|
||||||
|
|
||||||
|
authentik 2025.2.3 and 2024.12.4 fix this issue.
|
||||||
|
|
||||||
|
### Workarounds
|
||||||
|
|
||||||
|
Switching to the cache-based session storage until the authentik instance can be upgraded is recommended.
|
||||||
|
|
||||||
|
### For more information
|
||||||
|
|
||||||
|
If you have any questions or comments about this advisory:
|
||||||
|
|
||||||
|
- Email us at [security@goauthentik.io](mailto:security@goauthentik.io).
|
||||||
@ -2,13 +2,14 @@ import { generateVersionDropdown } from "./src/utils.js";
|
|||||||
import apiReference from "./docs/developer-docs/api/reference/sidebar";
|
import apiReference from "./docs/developer-docs/api/reference/sidebar";
|
||||||
|
|
||||||
const releases = [
|
const releases = [
|
||||||
|
"releases/2025/v2025.2",
|
||||||
"releases/2024/v2024.12",
|
"releases/2024/v2024.12",
|
||||||
"releases/2024/v2024.10",
|
"releases/2024/v2024.10",
|
||||||
"releases/2024/v2024.8",
|
|
||||||
{
|
{
|
||||||
type: "category",
|
type: "category",
|
||||||
label: "Previous versions",
|
label: "Previous versions",
|
||||||
items: [
|
items: [
|
||||||
|
"releases/2024/v2024.8",
|
||||||
"releases/2024/v2024.6",
|
"releases/2024/v2024.6",
|
||||||
"releases/2024/v2024.4",
|
"releases/2024/v2024.4",
|
||||||
"releases/2024/v2024.2",
|
"releases/2024/v2024.2",
|
||||||
@ -619,11 +620,12 @@ export default {
|
|||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
type: "category",
|
type: "category",
|
||||||
label: "Setup",
|
label: "Development environment",
|
||||||
items: [
|
items: [
|
||||||
"developer-docs/setup/full-dev-environment",
|
"developer-docs/setup/full-dev-environment",
|
||||||
"developer-docs/setup/frontend-dev-environment",
|
"developer-docs/setup/frontend-dev-environment",
|
||||||
"developer-docs/setup/website-dev-environment",
|
"developer-docs/setup/website-dev-environment",
|
||||||
|
"developer-docs/setup/debugging",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -701,6 +703,11 @@ export default {
|
|||||||
type: "category",
|
type: "category",
|
||||||
label: "CVEs",
|
label: "CVEs",
|
||||||
items: [
|
items: [
|
||||||
|
{
|
||||||
|
type: "category",
|
||||||
|
label: "2024",
|
||||||
|
items: ["security/cves/CVE-2025-29928"],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
type: "category",
|
type: "category",
|
||||||
label: "2024",
|
label: "2024",
|
||||||
|
|||||||
Reference in New Issue
Block a user