Compare commits
32 Commits
outposts/s
...
admin/add-
Author | SHA1 | Date | |
---|---|---|---|
bb4602745e | |||
0ae373bc1e | |||
6facb5872e | |||
c67de17dd8 | |||
2128e7f45f | |||
0e7a4849f6 | |||
85343fa5d4 | |||
12f16241fb | |||
2c3a040e35 | |||
ec0dd8c6a0 | |||
7b8c27ad2c | |||
79b80c2ed2 | |||
28485e8a15 | |||
e86b4514bc | |||
179f5c7acf | |||
e7538b85e1 | |||
ab8f5a2ac4 | |||
67c22c1313 | |||
74e090239a | |||
e5f0fc6469 | |||
945987f10f | |||
4ba360e7af | |||
a8fd0c376f | |||
0e5d647238 | |||
306f227813 | |||
e89e592061 | |||
454bf554a6 | |||
eab6ca96a7 | |||
7746d2ab7a | |||
4fe38172e3 | |||
e6082e0f08 | |||
9402c19966 |
2
Makefile
2
Makefile
@ -21,7 +21,7 @@ pg_name := $(shell python -m authentik.lib.config postgresql.name 2>/dev/null)
|
||||
CODESPELL_ARGS = -D - -D .github/codespell-dictionary.txt \
|
||||
-I .github/codespell-words.txt \
|
||||
-S 'web/src/locales/**' \
|
||||
-S 'website/developer-docs/api/reference/**' \
|
||||
-S 'website/docs/developer-docs/api/reference/**' \
|
||||
-S '**/node_modules/**' \
|
||||
-S '**/dist/**' \
|
||||
$(PY_SOURCES) \
|
||||
|
@ -50,7 +50,6 @@ from authentik.enterprise.providers.microsoft_entra.models import (
|
||||
MicrosoftEntraProviderGroup,
|
||||
MicrosoftEntraProviderUser,
|
||||
)
|
||||
from authentik.enterprise.providers.rac.models import ConnectionToken
|
||||
from authentik.enterprise.providers.ssf.models import StreamEvent
|
||||
from authentik.enterprise.stages.authenticator_endpoint_gdtc.models import (
|
||||
EndpointDevice,
|
||||
@ -72,6 +71,7 @@ from authentik.providers.oauth2.models import (
|
||||
DeviceToken,
|
||||
RefreshToken,
|
||||
)
|
||||
from authentik.providers.rac.models import ConnectionToken
|
||||
from authentik.providers.scim.models import SCIMProviderGroup, SCIMProviderUser
|
||||
from authentik.rbac.models import Role
|
||||
from authentik.sources.scim.models import SCIMSourceGroup, SCIMSourceUser
|
||||
|
@ -4,6 +4,7 @@ from json import loads
|
||||
|
||||
from django.db.models import Prefetch
|
||||
from django.http import Http404
|
||||
from django.utils.translation import gettext as _
|
||||
from django_filters.filters import CharFilter, ModelMultipleChoiceFilter
|
||||
from django_filters.filterset import FilterSet
|
||||
from drf_spectacular.utils import (
|
||||
@ -81,9 +82,37 @@ class GroupSerializer(ModelSerializer):
|
||||
if not self.instance or not parent:
|
||||
return parent
|
||||
if str(parent.group_uuid) == str(self.instance.group_uuid):
|
||||
raise ValidationError("Cannot set group as parent of itself.")
|
||||
raise ValidationError(_("Cannot set group as parent of itself."))
|
||||
return parent
|
||||
|
||||
def validate_is_superuser(self, superuser: bool):
|
||||
"""Ensure that the user creating this group has permissions to set the superuser flag"""
|
||||
request: Request = self.context.get("request", None)
|
||||
if not request:
|
||||
return superuser
|
||||
# If we're updating an instance, and the state hasn't changed, we don't need to check perms
|
||||
if self.instance and superuser == self.instance.is_superuser:
|
||||
return superuser
|
||||
user: User = request.user
|
||||
perm = (
|
||||
"authentik_core.enable_group_superuser"
|
||||
if superuser
|
||||
else "authentik_core.disable_group_superuser"
|
||||
)
|
||||
has_perm = user.has_perm(perm)
|
||||
if self.instance and not has_perm:
|
||||
has_perm = user.has_perm(perm, self.instance)
|
||||
if not has_perm:
|
||||
raise ValidationError(
|
||||
_(
|
||||
(
|
||||
"User does not have permission to set "
|
||||
"superuser status to {superuser_status}."
|
||||
).format_map({"superuser_status": superuser})
|
||||
)
|
||||
)
|
||||
return superuser
|
||||
|
||||
class Meta:
|
||||
model = Group
|
||||
fields = [
|
||||
|
@ -1,11 +1,12 @@
|
||||
"""User API Views"""
|
||||
|
||||
from datetime import timedelta
|
||||
from datetime import datetime, timedelta
|
||||
from hashlib import sha256
|
||||
from json import loads
|
||||
from typing import Any
|
||||
|
||||
from django.contrib.auth import update_session_auth_hash
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.contrib.auth.models import AnonymousUser, Permission
|
||||
from django.contrib.sessions.backends.cache import KEY_PREFIX
|
||||
from django.core.cache import cache
|
||||
from django.db.models.functions import ExtractHour
|
||||
@ -84,6 +85,7 @@ from authentik.flows.models import FlowToken
|
||||
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlanner
|
||||
from authentik.flows.views.executor import QS_KEY_TOKEN
|
||||
from authentik.lib.avatars import get_avatar
|
||||
from authentik.lib.utils.time import timedelta_from_string, timedelta_string_validator
|
||||
from authentik.rbac.decorators import permission_required
|
||||
from authentik.rbac.models import get_permission_choices
|
||||
from authentik.stages.email.models import EmailStage
|
||||
@ -446,15 +448,19 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
||||
def list(self, request, *args, **kwargs):
|
||||
return super().list(request, *args, **kwargs)
|
||||
|
||||
def _create_recovery_link(self) -> tuple[str, Token]:
|
||||
def _create_recovery_link(self, expires: datetime) -> tuple[str, Token]:
|
||||
"""Create a recovery link (when the current brand has a recovery flow set),
|
||||
that can either be shown to an admin or sent to the user directly"""
|
||||
brand: Brand = self.request._request.brand
|
||||
# Check that there is a recovery flow, if not return an error
|
||||
flow = brand.flow_recovery
|
||||
if not flow:
|
||||
raise ValidationError({"non_field_errors": "No recovery flow set."})
|
||||
raise ValidationError(
|
||||
{"non_field_errors": [_("Recovery flow is not set for this brand.")]}
|
||||
)
|
||||
# Mimic an unauthenticated user navigating the recovery flow
|
||||
user: User = self.get_object()
|
||||
self.request._request.user = AnonymousUser()
|
||||
planner = FlowPlanner(flow)
|
||||
planner.allow_empty_flows = True
|
||||
try:
|
||||
@ -466,16 +472,16 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
||||
)
|
||||
except FlowNonApplicableException:
|
||||
raise ValidationError(
|
||||
{"non_field_errors": "Recovery flow not applicable to user"}
|
||||
{"non_field_errors": [_("Recovery flow is not applicable to this user.")]}
|
||||
) from None
|
||||
token, __ = FlowToken.objects.update_or_create(
|
||||
identifier=f"{user.uid}-password-reset",
|
||||
defaults={
|
||||
"user": user,
|
||||
"flow": flow,
|
||||
"_plan": FlowToken.pickle(plan),
|
||||
},
|
||||
token = FlowToken.objects.create(
|
||||
identifier=f"{user.uid}-password-reset-{sha256(str(datetime.now()).encode('UTF-8')).hexdigest()[:8]}",
|
||||
user=user,
|
||||
flow=flow,
|
||||
_plan=FlowToken.pickle(plan),
|
||||
expires=expires,
|
||||
)
|
||||
|
||||
querystring = urlencode({QS_KEY_TOKEN: token.key})
|
||||
link = self.request.build_absolute_uri(
|
||||
reverse_lazy("authentik_core:if-flow", kwargs={"flow_slug": flow.slug})
|
||||
@ -610,61 +616,68 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
||||
|
||||
@permission_required("authentik_core.reset_user_password")
|
||||
@extend_schema(
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="email_stage",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.STR,
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="token_duration",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.STR,
|
||||
required=True,
|
||||
),
|
||||
],
|
||||
responses={
|
||||
"200": LinkSerializer(many=False),
|
||||
},
|
||||
request=None,
|
||||
)
|
||||
@action(detail=True, pagination_class=None, filter_backends=[], methods=["POST"])
|
||||
def recovery(self, request: Request, pk: int) -> Response:
|
||||
def recovery_link(self, request: Request, pk: int) -> Response:
|
||||
"""Create a temporary link that a user can use to recover their accounts"""
|
||||
link, _ = self._create_recovery_link()
|
||||
return Response({"link": link})
|
||||
token_duration = request.query_params.get("token_duration", "")
|
||||
timedelta_string_validator(token_duration)
|
||||
expires = now() + timedelta_from_string(token_duration)
|
||||
link, token = self._create_recovery_link(expires)
|
||||
|
||||
@permission_required("authentik_core.reset_user_password")
|
||||
@extend_schema(
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="email_stage",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.STR,
|
||||
required=True,
|
||||
if email_stage := request.query_params.get("email_stage"):
|
||||
for_user: User = self.get_object()
|
||||
if for_user.email == "":
|
||||
LOGGER.debug("User doesn't have an email address")
|
||||
raise ValidationError(
|
||||
{"non_field_errors": [_("User does not have an email address set.")]}
|
||||
)
|
||||
|
||||
# Lookup the email stage to assure the current user can access it
|
||||
stages = get_objects_for_user(
|
||||
request.user, "authentik_stages_email.view_emailstage"
|
||||
).filter(pk=email_stage)
|
||||
if not stages.exists():
|
||||
if stages := EmailStage.objects.filter(pk=email_stage).exists():
|
||||
raise ValidationError(
|
||||
{"non_field_errors": [_("User has no permissions to this Email stage.")]}
|
||||
)
|
||||
else:
|
||||
raise ValidationError(
|
||||
{"non_field_errors": [_("The given Email stage does not exist.")]}
|
||||
)
|
||||
email_stage: EmailStage = stages.first()
|
||||
message = TemplateEmailMessage(
|
||||
subject=_(email_stage.subject),
|
||||
to=[(for_user.name, for_user.email)],
|
||||
template_name=email_stage.template,
|
||||
language=for_user.locale(request),
|
||||
template_context={
|
||||
"url": link,
|
||||
"user": for_user,
|
||||
"expires": token.expires,
|
||||
},
|
||||
)
|
||||
],
|
||||
responses={
|
||||
"204": OpenApiResponse(description="Successfully sent recover email"),
|
||||
},
|
||||
request=None,
|
||||
)
|
||||
@action(detail=True, pagination_class=None, filter_backends=[], methods=["POST"])
|
||||
def recovery_email(self, request: Request, pk: int) -> Response:
|
||||
"""Create a temporary link that a user can use to recover their accounts"""
|
||||
for_user: User = self.get_object()
|
||||
if for_user.email == "":
|
||||
LOGGER.debug("User doesn't have an email address")
|
||||
raise ValidationError({"non_field_errors": "User does not have an email address set."})
|
||||
link, token = self._create_recovery_link()
|
||||
# Lookup the email stage to assure the current user can access it
|
||||
stages = get_objects_for_user(
|
||||
request.user, "authentik_stages_email.view_emailstage"
|
||||
).filter(pk=request.query_params.get("email_stage"))
|
||||
if not stages.exists():
|
||||
LOGGER.debug("Email stage does not exist/user has no permissions")
|
||||
raise ValidationError({"non_field_errors": "Email stage does not exist."})
|
||||
email_stage: EmailStage = stages.first()
|
||||
message = TemplateEmailMessage(
|
||||
subject=_(email_stage.subject),
|
||||
to=[(for_user.name, for_user.email)],
|
||||
template_name=email_stage.template,
|
||||
language=for_user.locale(request),
|
||||
template_context={
|
||||
"url": link,
|
||||
"user": for_user,
|
||||
"expires": token.expires,
|
||||
},
|
||||
)
|
||||
send_mails(email_stage, message)
|
||||
return Response(status=204)
|
||||
send_mails(email_stage, message)
|
||||
|
||||
return Response({"link": link})
|
||||
|
||||
@permission_required("authentik_core.impersonate")
|
||||
@extend_schema(
|
||||
|
26
authentik/core/migrations/0043_alter_group_options.py
Normal file
26
authentik/core/migrations/0043_alter_group_options.py
Normal file
@ -0,0 +1,26 @@
|
||||
# Generated by Django 5.0.11 on 2025-01-30 23:55
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_core", "0042_authenticatedsession_authentik_c_expires_08251d_idx_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name="group",
|
||||
options={
|
||||
"permissions": [
|
||||
("add_user_to_group", "Add user to group"),
|
||||
("remove_user_from_group", "Remove user from group"),
|
||||
("enable_group_superuser", "Enable superuser status"),
|
||||
("disable_group_superuser", "Disable superuser status"),
|
||||
],
|
||||
"verbose_name": "Group",
|
||||
"verbose_name_plural": "Groups",
|
||||
},
|
||||
),
|
||||
]
|
@ -204,6 +204,8 @@ class Group(SerializerModel, AttributesMixin):
|
||||
permissions = [
|
||||
("add_user_to_group", _("Add user to group")),
|
||||
("remove_user_from_group", _("Remove user from group")),
|
||||
("enable_group_superuser", _("Enable superuser status")),
|
||||
("disable_group_superuser", _("Disable superuser status")),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
|
@ -67,6 +67,8 @@ def clean_expired_models(self: SystemTask):
|
||||
raise ImproperlyConfigured(
|
||||
"Invalid session_storage setting, allowed values are db and cache"
|
||||
)
|
||||
if CONFIG.get("session_storage", "cache") == "db":
|
||||
DBSessionStore.clear_expired()
|
||||
LOGGER.debug("Expired sessions", model=AuthenticatedSession, amount=amount)
|
||||
|
||||
messages.append(f"Expired {amount} {AuthenticatedSession._meta.verbose_name_plural}")
|
||||
|
@ -4,7 +4,7 @@ from django.urls.base import reverse
|
||||
from guardian.shortcuts import assign_perm
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from authentik.core.models import Group, User
|
||||
from authentik.core.models import Group
|
||||
from authentik.core.tests.utils import create_test_admin_user, create_test_user
|
||||
from authentik.lib.generators import generate_id
|
||||
|
||||
@ -14,7 +14,7 @@ class TestGroupsAPI(APITestCase):
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.login_user = create_test_user()
|
||||
self.user = User.objects.create(username="test-user")
|
||||
self.user = create_test_user()
|
||||
|
||||
def test_list_with_users(self):
|
||||
"""Test listing with users"""
|
||||
@ -109,3 +109,57 @@ class TestGroupsAPI(APITestCase):
|
||||
},
|
||||
)
|
||||
self.assertEqual(res.status_code, 400)
|
||||
|
||||
def test_superuser_no_perm(self):
|
||||
"""Test creating a superuser group without permission"""
|
||||
assign_perm("authentik_core.add_group", self.login_user)
|
||||
self.client.force_login(self.login_user)
|
||||
res = self.client.post(
|
||||
reverse("authentik_api:group-list"),
|
||||
data={"name": generate_id(), "is_superuser": True},
|
||||
)
|
||||
self.assertEqual(res.status_code, 400)
|
||||
self.assertJSONEqual(
|
||||
res.content,
|
||||
{"is_superuser": ["User does not have permission to set superuser status to True."]},
|
||||
)
|
||||
|
||||
def test_superuser_update_no_perm(self):
|
||||
"""Test updating a superuser group without permission"""
|
||||
group = Group.objects.create(name=generate_id(), is_superuser=True)
|
||||
assign_perm("view_group", self.login_user, group)
|
||||
assign_perm("change_group", self.login_user, group)
|
||||
self.client.force_login(self.login_user)
|
||||
res = self.client.patch(
|
||||
reverse("authentik_api:group-detail", kwargs={"pk": group.pk}),
|
||||
data={"is_superuser": False},
|
||||
)
|
||||
self.assertEqual(res.status_code, 400)
|
||||
self.assertJSONEqual(
|
||||
res.content,
|
||||
{"is_superuser": ["User does not have permission to set superuser status to False."]},
|
||||
)
|
||||
|
||||
def test_superuser_update_no_change(self):
|
||||
"""Test updating a superuser group without permission
|
||||
and without changing the superuser status"""
|
||||
group = Group.objects.create(name=generate_id(), is_superuser=True)
|
||||
assign_perm("view_group", self.login_user, group)
|
||||
assign_perm("change_group", self.login_user, group)
|
||||
self.client.force_login(self.login_user)
|
||||
res = self.client.patch(
|
||||
reverse("authentik_api:group-detail", kwargs={"pk": group.pk}),
|
||||
data={"name": generate_id(), "is_superuser": True},
|
||||
)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
|
||||
def test_superuser_create(self):
|
||||
"""Test creating a superuser group with permission"""
|
||||
assign_perm("authentik_core.add_group", self.login_user)
|
||||
assign_perm("authentik_core.enable_group_superuser", self.login_user)
|
||||
self.client.force_login(self.login_user)
|
||||
res = self.client.post(
|
||||
reverse("authentik_api:group-list"),
|
||||
data={"name": generate_id(), "is_superuser": True},
|
||||
)
|
||||
self.assertEqual(res.status_code, 201)
|
||||
|
@ -97,6 +97,8 @@ class EnterpriseAuditMiddleware(AuditMiddleware):
|
||||
thread_kwargs: dict | None = None,
|
||||
**_,
|
||||
):
|
||||
if not self.enabled:
|
||||
return super().post_save_handler(request, sender, instance, created, thread_kwargs, **_)
|
||||
if not should_log_model(instance):
|
||||
return None
|
||||
thread_kwargs = {}
|
||||
@ -122,6 +124,8 @@ class EnterpriseAuditMiddleware(AuditMiddleware):
|
||||
):
|
||||
thread_kwargs = {}
|
||||
m2m_field = None
|
||||
if not self.enabled:
|
||||
return super().m2m_changed_handler(request, sender, instance, action, thread_kwargs)
|
||||
# For the audit log we don't care about `pre_` or `post_` so we trim that part off
|
||||
_, _, action_direction = action.partition("_")
|
||||
# resolve the "through" model to an actual field
|
||||
|
@ -1,14 +0,0 @@
|
||||
"""RAC app config"""
|
||||
|
||||
from authentik.enterprise.apps import EnterpriseConfig
|
||||
|
||||
|
||||
class AuthentikEnterpriseProviderRAC(EnterpriseConfig):
|
||||
"""authentik enterprise rac app config"""
|
||||
|
||||
name = "authentik.enterprise.providers.rac"
|
||||
label = "authentik_providers_rac"
|
||||
verbose_name = "authentik Enterprise.Providers.RAC"
|
||||
default = True
|
||||
mountpoint = ""
|
||||
ws_mountpoint = "authentik.enterprise.providers.rac.urls"
|
@ -16,7 +16,6 @@ TENANT_APPS = [
|
||||
"authentik.enterprise.audit",
|
||||
"authentik.enterprise.providers.google_workspace",
|
||||
"authentik.enterprise.providers.microsoft_entra",
|
||||
"authentik.enterprise.providers.rac",
|
||||
"authentik.enterprise.providers.ssf",
|
||||
"authentik.enterprise.stages.authenticator_endpoint_gdtc",
|
||||
"authentik.enterprise.stages.source",
|
||||
|
@ -36,6 +36,15 @@ class FlowAuthenticationRequirement(models.TextChoices):
|
||||
REQUIRE_REDIRECT = "require_redirect"
|
||||
REQUIRE_OUTPOST = "require_outpost"
|
||||
|
||||
@property
|
||||
def possibly_unauthenticated(self) -> bool:
|
||||
"""Check if unauthenticated users can run this flow. Flows like this may require additional
|
||||
hardening."""
|
||||
return self in [
|
||||
FlowAuthenticationRequirement.NONE,
|
||||
FlowAuthenticationRequirement.REQUIRE_UNAUTHENTICATED,
|
||||
]
|
||||
|
||||
|
||||
class NotConfiguredAction(models.TextChoices):
|
||||
"""Decides how the FlowExecutor should proceed when a stage isn't configured"""
|
||||
|
54
authentik/lib/utils/email.py
Normal file
54
authentik/lib/utils/email.py
Normal file
@ -0,0 +1,54 @@
|
||||
"""Email utility functions"""
|
||||
|
||||
|
||||
def mask_email(email: str | None) -> str | None:
|
||||
"""Mask email address for privacy
|
||||
|
||||
Args:
|
||||
email: Email address to mask
|
||||
Returns:
|
||||
Masked email address or None if input is None
|
||||
Example:
|
||||
mask_email("myname@company.org")
|
||||
'm*****@c******.org'
|
||||
"""
|
||||
if not email:
|
||||
return None
|
||||
|
||||
# Basic email format validation
|
||||
if email.count("@") != 1:
|
||||
raise ValueError("Invalid email format: Must contain exactly one '@' symbol")
|
||||
|
||||
local, domain = email.split("@")
|
||||
if not local or not domain:
|
||||
raise ValueError("Invalid email format: Local and domain parts cannot be empty")
|
||||
|
||||
domain_parts = domain.split(".")
|
||||
if len(domain_parts) < 2: # noqa: PLR2004
|
||||
raise ValueError("Invalid email format: Domain must contain at least one dot")
|
||||
|
||||
limit = 2
|
||||
|
||||
# Mask local part (keep first char)
|
||||
if len(local) <= limit:
|
||||
masked_local = "*" * len(local)
|
||||
else:
|
||||
masked_local = local[0] + "*" * (len(local) - 1)
|
||||
|
||||
# Mask each domain part except the last one (TLD)
|
||||
masked_domain_parts = []
|
||||
for _i, part in enumerate(domain_parts[:-1]): # Process all parts except TLD
|
||||
if not part: # Check for empty parts (consecutive dots)
|
||||
raise ValueError("Invalid email format: Domain parts cannot be empty")
|
||||
if len(part) <= limit:
|
||||
masked_part = "*" * len(part)
|
||||
else:
|
||||
masked_part = part[0] + "*" * (len(part) - 1)
|
||||
masked_domain_parts.append(masked_part)
|
||||
|
||||
# Add TLD unchanged
|
||||
if not domain_parts[-1]: # Check if TLD is empty
|
||||
raise ValueError("Invalid email format: TLD cannot be empty")
|
||||
masked_domain_parts.append(domain_parts[-1])
|
||||
|
||||
return f"{masked_local}@{'.'.join(masked_domain_parts)}"
|
@ -31,7 +31,7 @@ def timedelta_string_validator(value: str):
|
||||
|
||||
|
||||
def timedelta_from_string(expr: str) -> datetime.timedelta:
|
||||
"""Convert a string with the format of 'hours=1;minute=3;seconds=5' to a
|
||||
"""Convert a string with the format of 'hours=1;minutes=3;seconds=5' to a
|
||||
`datetime.timedelta` Object with hours = 1, minutes = 3, seconds = 5"""
|
||||
kwargs = {}
|
||||
for duration_pair in expr.split(";"):
|
||||
|
@ -19,7 +19,6 @@ from authentik.core.api.used_by import UsedByMixin
|
||||
from authentik.core.api.utils import JSONDictField, ModelSerializer, PassiveSerializer
|
||||
from authentik.core.models import Provider
|
||||
from authentik.enterprise.license import LicenseKey
|
||||
from authentik.enterprise.providers.rac.models import RACProvider
|
||||
from authentik.lib.utils.time import timedelta_from_string, timedelta_string_validator
|
||||
from authentik.outposts.api.service_connections import ServiceConnectionSerializer
|
||||
from authentik.outposts.apps import MANAGED_OUTPOST, MANAGED_OUTPOST_NAME
|
||||
@ -31,6 +30,7 @@ from authentik.outposts.models import (
|
||||
)
|
||||
from authentik.providers.ldap.models import LDAPProvider
|
||||
from authentik.providers.proxy.models import ProxyProvider
|
||||
from authentik.providers.rac.models import RACProvider
|
||||
from authentik.providers.radius.models import RadiusProvider
|
||||
|
||||
|
||||
|
@ -128,12 +128,6 @@ class OutpostConsumer(JsonWebsocketConsumer):
|
||||
state.args.update(msg.args)
|
||||
elif msg.instruction == WebsocketMessageInstruction.ACK:
|
||||
return
|
||||
elif msg.instruction == WebsocketMessageInstruction.PROVIDER_SPECIFIC:
|
||||
if "response_channel" not in msg.args:
|
||||
return
|
||||
self.logger.debug("Posted response to channel", msg=msg)
|
||||
async_to_sync(self.channel_layer.send)(msg.args.get("response_channel"), content)
|
||||
return
|
||||
GAUGE_OUTPOSTS_LAST_UPDATE.labels(
|
||||
tenant=connection.schema_name,
|
||||
outpost=self.outpost.name,
|
||||
|
@ -1,86 +0,0 @@
|
||||
from base64 import b64decode
|
||||
from dataclasses import asdict, dataclass
|
||||
from random import choice
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
from channels.layers import get_channel_layer
|
||||
from channels_redis.pubsub import RedisPubSubChannelLayer
|
||||
from requests.adapters import BaseAdapter
|
||||
from requests.models import PreparedRequest, Response
|
||||
from requests.utils import CaseInsensitiveDict
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.outposts.models import Outpost
|
||||
|
||||
|
||||
@dataclass
|
||||
class OutpostPreparedRequest:
|
||||
uid: str
|
||||
method: str
|
||||
url: str
|
||||
headers: dict[str, str]
|
||||
body: Any
|
||||
ssl_verify: bool
|
||||
timeout: int
|
||||
|
||||
@staticmethod
|
||||
def from_requests(req: PreparedRequest) -> "OutpostPreparedRequest":
|
||||
return OutpostPreparedRequest(
|
||||
uid=str(uuid4()),
|
||||
method=req.method,
|
||||
url=req.url,
|
||||
headers=req.headers._store,
|
||||
body=req.body,
|
||||
ssl_verify=True,
|
||||
timeout=0,
|
||||
)
|
||||
|
||||
@property
|
||||
def response_channel(self) -> str:
|
||||
return f"authentik_outpost_http_response_{self.uid}"
|
||||
|
||||
|
||||
class OutpostHTTPAdapter(BaseAdapter):
|
||||
"""Requests Adapter that sends HTTP requests via a specified Outpost"""
|
||||
|
||||
def __init__(self, outpost: Outpost, default_timeout=10):
|
||||
super().__init__()
|
||||
self.__outpost = outpost
|
||||
self.__logger = get_logger().bind()
|
||||
self.__layer: RedisPubSubChannelLayer = get_channel_layer()
|
||||
self.default_timeout = default_timeout
|
||||
|
||||
def parse_response(self, raw_response: dict, req: PreparedRequest) -> Response:
|
||||
res = Response()
|
||||
res.request = req
|
||||
res.status_code = raw_response.get("status")
|
||||
res.url = raw_response.get("final_url")
|
||||
res.headers = CaseInsensitiveDict(raw_response.get("headers"))
|
||||
res._content = b64decode(raw_response.get("body"))
|
||||
return res
|
||||
|
||||
def send(self, request, stream=False, timeout=None, verify=True, cert=None, proxies=None):
|
||||
# Convert request so we can send it to the outpost
|
||||
converted = OutpostPreparedRequest.from_requests(request)
|
||||
converted.ssl_verify = verify
|
||||
converted.timeout = timeout if timeout else self.default_timeout
|
||||
# Pick one of the outpost instances
|
||||
state = choice(self.__outpost.state) # nosec
|
||||
self.__logger.debug("sending HTTP request to outpost", uid=converted.uid)
|
||||
async_to_sync(self.__layer.send)(
|
||||
state.uid,
|
||||
{
|
||||
"type": "event.provider.specific",
|
||||
"sub_type": "http_request",
|
||||
"response_channel": converted.response_channel,
|
||||
"request": asdict(converted),
|
||||
},
|
||||
)
|
||||
self.__logger.debug("receiving HTTP response from outpost", uid=converted.uid)
|
||||
raw_response = async_to_sync(self.__layer.receive)(
|
||||
converted.response_channel,
|
||||
)
|
||||
self.__logger.debug("received HTTP response from outpost", uid=converted.uid)
|
||||
return self.parse_response(raw_response.get("args", {}).get("response", {}), request)
|
@ -98,7 +98,6 @@ class OutpostType(models.TextChoices):
|
||||
LDAP = "ldap"
|
||||
RADIUS = "radius"
|
||||
RAC = "rac"
|
||||
SCIM = "scim"
|
||||
|
||||
|
||||
def default_outpost_config(host: str | None = None):
|
||||
|
@ -18,8 +18,6 @@ from kubernetes.config.kube_config import KUBE_CONFIG_DEFAULT_LOCATION
|
||||
from structlog.stdlib import get_logger
|
||||
from yaml import safe_load
|
||||
|
||||
from authentik.enterprise.providers.rac.controllers.docker import RACDockerController
|
||||
from authentik.enterprise.providers.rac.controllers.kubernetes import RACKubernetesController
|
||||
from authentik.events.models import TaskStatus
|
||||
from authentik.events.system_tasks import SystemTask, prefill_task
|
||||
from authentik.lib.config import CONFIG
|
||||
@ -41,17 +39,17 @@ from authentik.providers.ldap.controllers.docker import LDAPDockerController
|
||||
from authentik.providers.ldap.controllers.kubernetes import LDAPKubernetesController
|
||||
from authentik.providers.proxy.controllers.docker import ProxyDockerController
|
||||
from authentik.providers.proxy.controllers.kubernetes import ProxyKubernetesController
|
||||
from authentik.providers.rac.controllers.docker import RACDockerController
|
||||
from authentik.providers.rac.controllers.kubernetes import RACKubernetesController
|
||||
from authentik.providers.radius.controllers.docker import RadiusDockerController
|
||||
from authentik.providers.radius.controllers.kubernetes import RadiusKubernetesController
|
||||
from authentik.providers.scim.controllers.docker import SCIMDockerController
|
||||
from authentik.providers.scim.controllers.kubernetes import SCIMKubernetesController
|
||||
from authentik.root.celery import CELERY_APP
|
||||
|
||||
LOGGER = get_logger()
|
||||
CACHE_KEY_OUTPOST_DOWN = "goauthentik.io/outposts/teardown/%s"
|
||||
|
||||
|
||||
def controller_for_outpost(outpost: Outpost) -> type[BaseController] | None: # noqa: PLR0911
|
||||
def controller_for_outpost(outpost: Outpost) -> type[BaseController] | None:
|
||||
"""Get a controller for the outpost, when a service connection is defined"""
|
||||
if not outpost.service_connection:
|
||||
return None
|
||||
@ -76,11 +74,6 @@ def controller_for_outpost(outpost: Outpost) -> type[BaseController] | None: #
|
||||
return RACDockerController
|
||||
if isinstance(service_connection, KubernetesServiceConnection):
|
||||
return RACKubernetesController
|
||||
if outpost.type == OutpostType.SCIM:
|
||||
if isinstance(service_connection, DockerServiceConnection):
|
||||
return SCIMDockerController
|
||||
if isinstance(service_connection, KubernetesServiceConnection):
|
||||
return SCIMKubernetesController
|
||||
return None
|
||||
|
||||
|
||||
|
@ -42,6 +42,12 @@ class GeoIPPolicySerializer(CountryFieldMixin, PolicySerializer):
|
||||
"asns",
|
||||
"countries",
|
||||
"countries_obj",
|
||||
"check_history_distance",
|
||||
"history_max_distance_km",
|
||||
"distance_tolerance_km",
|
||||
"history_login_count",
|
||||
"check_impossible_travel",
|
||||
"impossible_tolerance_km",
|
||||
]
|
||||
|
||||
|
||||
|
@ -0,0 +1,43 @@
|
||||
# Generated by Django 5.0.10 on 2025-01-02 20:40
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_policies_geoip", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="geoippolicy",
|
||||
name="check_history_distance",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="geoippolicy",
|
||||
name="check_impossible_travel",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="geoippolicy",
|
||||
name="distance_tolerance_km",
|
||||
field=models.PositiveIntegerField(default=50),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="geoippolicy",
|
||||
name="history_login_count",
|
||||
field=models.PositiveIntegerField(default=5),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="geoippolicy",
|
||||
name="history_max_distance_km",
|
||||
field=models.PositiveBigIntegerField(default=100),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="geoippolicy",
|
||||
name="impossible_tolerance_km",
|
||||
field=models.PositiveIntegerField(default=100),
|
||||
),
|
||||
]
|
@ -4,15 +4,21 @@ from itertools import chain
|
||||
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.db import models
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext as _
|
||||
from django_countries.fields import CountryField
|
||||
from geopy import distance
|
||||
from rest_framework.serializers import BaseSerializer
|
||||
|
||||
from authentik.events.context_processors.geoip import GeoIPDict
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.policies.exceptions import PolicyException
|
||||
from authentik.policies.geoip.exceptions import GeoIPNotFoundException
|
||||
from authentik.policies.models import Policy
|
||||
from authentik.policies.types import PolicyRequest, PolicyResult
|
||||
|
||||
MAX_DISTANCE_HOUR_KM = 1000
|
||||
|
||||
|
||||
class GeoIPPolicy(Policy):
|
||||
"""Ensure the user satisfies requirements of geography or network topology, based on IP
|
||||
@ -21,6 +27,15 @@ class GeoIPPolicy(Policy):
|
||||
asns = ArrayField(models.IntegerField(), blank=True, default=list)
|
||||
countries = CountryField(multiple=True, blank=True)
|
||||
|
||||
distance_tolerance_km = models.PositiveIntegerField(default=50)
|
||||
|
||||
check_history_distance = models.BooleanField(default=False)
|
||||
history_max_distance_km = models.PositiveBigIntegerField(default=100)
|
||||
history_login_count = models.PositiveIntegerField(default=5)
|
||||
|
||||
check_impossible_travel = models.BooleanField(default=False)
|
||||
impossible_tolerance_km = models.PositiveIntegerField(default=100)
|
||||
|
||||
@property
|
||||
def serializer(self) -> type[BaseSerializer]:
|
||||
from authentik.policies.geoip.api import GeoIPPolicySerializer
|
||||
@ -37,21 +52,27 @@ class GeoIPPolicy(Policy):
|
||||
- the client IP is advertised by an autonomous system with ASN in the `asns`
|
||||
- the client IP is geolocated in a country of `countries`
|
||||
"""
|
||||
results: list[PolicyResult] = []
|
||||
static_results: list[PolicyResult] = []
|
||||
dynamic_results: list[PolicyResult] = []
|
||||
|
||||
if self.asns:
|
||||
results.append(self.passes_asn(request))
|
||||
static_results.append(self.passes_asn(request))
|
||||
if self.countries:
|
||||
results.append(self.passes_country(request))
|
||||
static_results.append(self.passes_country(request))
|
||||
|
||||
if not results:
|
||||
if self.check_history_distance or self.check_impossible_travel:
|
||||
dynamic_results.append(self.passes_distance(request))
|
||||
|
||||
if not static_results and not dynamic_results:
|
||||
return PolicyResult(True)
|
||||
|
||||
passing = any(r.passing for r in results)
|
||||
messages = chain(*[r.messages for r in results])
|
||||
passing = any(r.passing for r in static_results) and all(r.passing for r in dynamic_results)
|
||||
messages = chain(
|
||||
*[r.messages for r in static_results], *[r.messages for r in dynamic_results]
|
||||
)
|
||||
|
||||
result = PolicyResult(passing, *messages)
|
||||
result.source_results = results
|
||||
result.source_results = list(chain(static_results, dynamic_results))
|
||||
|
||||
return result
|
||||
|
||||
@ -73,7 +94,7 @@ class GeoIPPolicy(Policy):
|
||||
|
||||
def passes_country(self, request: PolicyRequest) -> PolicyResult:
|
||||
# This is not a single get chain because `request.context` can contain `{ "geoip": None }`.
|
||||
geoip_data = request.context.get("geoip")
|
||||
geoip_data: GeoIPDict | None = request.context.get("geoip")
|
||||
country = geoip_data.get("country") if geoip_data else None
|
||||
|
||||
if not country:
|
||||
@ -87,6 +108,42 @@ class GeoIPPolicy(Policy):
|
||||
|
||||
return PolicyResult(True)
|
||||
|
||||
def passes_distance(self, request: PolicyRequest) -> PolicyResult:
|
||||
"""Check if current policy execution is out of distance range compared
|
||||
to previous authentication requests"""
|
||||
# Get previous login event and GeoIP data
|
||||
previous_logins = Event.objects.filter(
|
||||
action=EventAction.LOGIN, user__pk=request.user.pk, context__geo__isnull=False
|
||||
).order_by("-created")[: self.history_login_count]
|
||||
_now = now()
|
||||
geoip_data: GeoIPDict | None = request.context.get("geoip")
|
||||
if not geoip_data:
|
||||
return PolicyResult(False)
|
||||
for previous_login in previous_logins:
|
||||
previous_login_geoip: GeoIPDict = previous_login.context["geo"]
|
||||
|
||||
# Figure out distance
|
||||
dist = distance.geodesic(
|
||||
(previous_login_geoip["lat"], previous_login_geoip["long"]),
|
||||
(geoip_data["lat"], geoip_data["long"]),
|
||||
)
|
||||
if self.check_history_distance and dist.km >= (
|
||||
self.history_max_distance_km - self.distance_tolerance_km
|
||||
):
|
||||
return PolicyResult(
|
||||
False, _("Distance from previous authentication is larger than threshold.")
|
||||
)
|
||||
# Check if distance between `previous_login` and now is more
|
||||
# than max distance per hour times the amount of hours since the previous login
|
||||
# (round down to the lowest closest time of hours)
|
||||
# clamped to be at least 1 hour
|
||||
rel_time_hours = max(int((_now - previous_login.created).total_seconds() / 3600), 1)
|
||||
if self.check_impossible_travel and dist.km >= (
|
||||
(MAX_DISTANCE_HOUR_KM * rel_time_hours) - self.distance_tolerance_km
|
||||
):
|
||||
return PolicyResult(False, _("Distance is further than possible."))
|
||||
return PolicyResult(True)
|
||||
|
||||
class Meta(Policy.PolicyMeta):
|
||||
verbose_name = _("GeoIP Policy")
|
||||
verbose_name_plural = _("GeoIP Policies")
|
||||
|
@ -1,8 +1,10 @@
|
||||
"""geoip policy tests"""
|
||||
|
||||
from django.test import TestCase
|
||||
from guardian.shortcuts import get_anonymous_user
|
||||
|
||||
from authentik.core.tests.utils import create_test_user
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.events.utils import get_user
|
||||
from authentik.policies.engine import PolicyRequest, PolicyResult
|
||||
from authentik.policies.exceptions import PolicyException
|
||||
from authentik.policies.geoip.exceptions import GeoIPNotFoundException
|
||||
@ -14,8 +16,8 @@ class TestGeoIPPolicy(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
self.request = PolicyRequest(get_anonymous_user())
|
||||
self.user = create_test_user()
|
||||
self.request = PolicyRequest(self.user)
|
||||
|
||||
self.context_disabled_geoip = {}
|
||||
self.context_unknown_ip = {"asn": None, "geoip": None}
|
||||
@ -126,3 +128,70 @@ class TestGeoIPPolicy(TestCase):
|
||||
result: PolicyResult = policy.passes(self.request)
|
||||
|
||||
self.assertTrue(result.passing)
|
||||
|
||||
def test_history(self):
|
||||
"""Test history checks"""
|
||||
Event.objects.create(
|
||||
action=EventAction.LOGIN,
|
||||
user=get_user(self.user),
|
||||
context={
|
||||
# Random location in Canada
|
||||
"geo": {"lat": 55.868351, "long": -104.441011},
|
||||
},
|
||||
)
|
||||
# Random location in Poland
|
||||
self.request.context["geoip"] = {"lat": 50.950613, "long": 20.363679}
|
||||
|
||||
policy = GeoIPPolicy.objects.create(check_history_distance=True)
|
||||
|
||||
result: PolicyResult = policy.passes(self.request)
|
||||
self.assertFalse(result.passing)
|
||||
|
||||
def test_history_no_data(self):
|
||||
"""Test history checks (with no geoip data in context)"""
|
||||
Event.objects.create(
|
||||
action=EventAction.LOGIN,
|
||||
user=get_user(self.user),
|
||||
context={
|
||||
# Random location in Canada
|
||||
"geo": {"lat": 55.868351, "long": -104.441011},
|
||||
},
|
||||
)
|
||||
|
||||
policy = GeoIPPolicy.objects.create(check_history_distance=True)
|
||||
|
||||
result: PolicyResult = policy.passes(self.request)
|
||||
self.assertFalse(result.passing)
|
||||
|
||||
def test_history_impossible_travel(self):
|
||||
"""Test history checks"""
|
||||
Event.objects.create(
|
||||
action=EventAction.LOGIN,
|
||||
user=get_user(self.user),
|
||||
context={
|
||||
# Random location in Canada
|
||||
"geo": {"lat": 55.868351, "long": -104.441011},
|
||||
},
|
||||
)
|
||||
# Random location in Poland
|
||||
self.request.context["geoip"] = {"lat": 50.950613, "long": 20.363679}
|
||||
|
||||
policy = GeoIPPolicy.objects.create(check_impossible_travel=True)
|
||||
|
||||
result: PolicyResult = policy.passes(self.request)
|
||||
self.assertFalse(result.passing)
|
||||
|
||||
def test_history_no_geoip(self):
|
||||
"""Test history checks (previous login with no geoip data)"""
|
||||
Event.objects.create(
|
||||
action=EventAction.LOGIN,
|
||||
user=get_user(self.user),
|
||||
context={},
|
||||
)
|
||||
# Random location in Poland
|
||||
self.request.context["geoip"] = {"lat": 50.950613, "long": 20.363679}
|
||||
|
||||
policy = GeoIPPolicy.objects.create(check_history_distance=True)
|
||||
|
||||
result: PolicyResult = policy.passes(self.request)
|
||||
self.assertFalse(result.passing)
|
||||
|
@ -6,13 +6,12 @@ from rest_framework.viewsets import GenericViewSet
|
||||
from authentik.core.api.groups import GroupMemberSerializer
|
||||
from authentik.core.api.used_by import UsedByMixin
|
||||
from authentik.core.api.utils import ModelSerializer
|
||||
from authentik.enterprise.api import EnterpriseRequiredMixin
|
||||
from authentik.enterprise.providers.rac.api.endpoints import EndpointSerializer
|
||||
from authentik.enterprise.providers.rac.api.providers import RACProviderSerializer
|
||||
from authentik.enterprise.providers.rac.models import ConnectionToken
|
||||
from authentik.providers.rac.api.endpoints import EndpointSerializer
|
||||
from authentik.providers.rac.api.providers import RACProviderSerializer
|
||||
from authentik.providers.rac.models import ConnectionToken
|
||||
|
||||
|
||||
class ConnectionTokenSerializer(EnterpriseRequiredMixin, ModelSerializer):
|
||||
class ConnectionTokenSerializer(ModelSerializer):
|
||||
"""ConnectionToken Serializer"""
|
||||
|
||||
provider_obj = RACProviderSerializer(source="provider", read_only=True)
|
@ -14,10 +14,9 @@ from structlog.stdlib import get_logger
|
||||
from authentik.core.api.used_by import UsedByMixin
|
||||
from authentik.core.api.utils import ModelSerializer
|
||||
from authentik.core.models import Provider
|
||||
from authentik.enterprise.api import EnterpriseRequiredMixin
|
||||
from authentik.enterprise.providers.rac.api.providers import RACProviderSerializer
|
||||
from authentik.enterprise.providers.rac.models import Endpoint
|
||||
from authentik.policies.engine import PolicyEngine
|
||||
from authentik.providers.rac.api.providers import RACProviderSerializer
|
||||
from authentik.providers.rac.models import Endpoint
|
||||
from authentik.rbac.filters import ObjectFilter
|
||||
|
||||
LOGGER = get_logger()
|
||||
@ -28,7 +27,7 @@ def user_endpoint_cache_key(user_pk: str) -> str:
|
||||
return f"goauthentik.io/providers/rac/endpoint_access/{user_pk}"
|
||||
|
||||
|
||||
class EndpointSerializer(EnterpriseRequiredMixin, ModelSerializer):
|
||||
class EndpointSerializer(ModelSerializer):
|
||||
"""Endpoint Serializer"""
|
||||
|
||||
provider_obj = RACProviderSerializer(source="provider", read_only=True)
|
@ -10,7 +10,7 @@ from rest_framework.viewsets import ModelViewSet
|
||||
from authentik.core.api.property_mappings import PropertyMappingSerializer
|
||||
from authentik.core.api.used_by import UsedByMixin
|
||||
from authentik.core.api.utils import JSONDictField
|
||||
from authentik.enterprise.providers.rac.models import RACPropertyMapping
|
||||
from authentik.providers.rac.models import RACPropertyMapping
|
||||
|
||||
|
||||
class RACPropertyMappingSerializer(PropertyMappingSerializer):
|
@ -5,11 +5,10 @@ from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from authentik.core.api.providers import ProviderSerializer
|
||||
from authentik.core.api.used_by import UsedByMixin
|
||||
from authentik.enterprise.api import EnterpriseRequiredMixin
|
||||
from authentik.enterprise.providers.rac.models import RACProvider
|
||||
from authentik.providers.rac.models import RACProvider
|
||||
|
||||
|
||||
class RACProviderSerializer(EnterpriseRequiredMixin, ProviderSerializer):
|
||||
class RACProviderSerializer(ProviderSerializer):
|
||||
"""RACProvider Serializer"""
|
||||
|
||||
outpost_set = ListField(child=CharField(), read_only=True, source="outpost_set.all")
|
14
authentik/providers/rac/apps.py
Normal file
14
authentik/providers/rac/apps.py
Normal file
@ -0,0 +1,14 @@
|
||||
"""RAC app config"""
|
||||
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AuthentikProviderRAC(AppConfig):
|
||||
"""authentik rac app config"""
|
||||
|
||||
name = "authentik.providers.rac"
|
||||
label = "authentik_providers_rac"
|
||||
verbose_name = "authentik Providers.RAC"
|
||||
default = True
|
||||
mountpoint = ""
|
||||
ws_mountpoint = "authentik.providers.rac.urls"
|
@ -7,22 +7,22 @@ from channels.generic.websocket import AsyncWebsocketConsumer
|
||||
from django.http.request import QueryDict
|
||||
from structlog.stdlib import BoundLogger, get_logger
|
||||
|
||||
from authentik.enterprise.providers.rac.models import ConnectionToken, RACProvider
|
||||
from authentik.outposts.consumer import OUTPOST_GROUP_INSTANCE
|
||||
from authentik.outposts.models import Outpost, OutpostState, OutpostType
|
||||
from authentik.providers.rac.models import ConnectionToken, RACProvider
|
||||
|
||||
# Global broadcast group, which messages are sent to when the outpost connects back
|
||||
# to authentik for a specific connection
|
||||
# The `RACClientConsumer` consumer adds itself to this group on connection,
|
||||
# and removes itself once it has been assigned a specific outpost channel
|
||||
RAC_CLIENT_GROUP = "group_enterprise_rac_client"
|
||||
RAC_CLIENT_GROUP = "group_rac_client"
|
||||
# A group for all connections in a given authentik session ID
|
||||
# A disconnect message is sent to this group when the session expires/is deleted
|
||||
RAC_CLIENT_GROUP_SESSION = "group_enterprise_rac_client_%(session)s"
|
||||
RAC_CLIENT_GROUP_SESSION = "group_rac_client_%(session)s"
|
||||
# A group for all connections with a specific token, which in almost all cases
|
||||
# is just one connection, however this is used to disconnect the connection
|
||||
# when the token is deleted
|
||||
RAC_CLIENT_GROUP_TOKEN = "group_enterprise_rac_token_%(token)s" # nosec
|
||||
RAC_CLIENT_GROUP_TOKEN = "group_rac_token_%(token)s" # nosec
|
||||
|
||||
# Step 1: Client connects to this websocket endpoint
|
||||
# Step 2: We prepare all the connection args for Guac
|
@ -3,7 +3,7 @@
|
||||
from channels.exceptions import ChannelFull
|
||||
from channels.generic.websocket import AsyncWebsocketConsumer
|
||||
|
||||
from authentik.enterprise.providers.rac.consumer_client import RAC_CLIENT_GROUP
|
||||
from authentik.providers.rac.consumer_client import RAC_CLIENT_GROUP
|
||||
|
||||
|
||||
class RACOutpostConsumer(AsyncWebsocketConsumer):
|
@ -74,7 +74,7 @@ class RACProvider(Provider):
|
||||
|
||||
@property
|
||||
def serializer(self) -> type[Serializer]:
|
||||
from authentik.enterprise.providers.rac.api.providers import RACProviderSerializer
|
||||
from authentik.providers.rac.api.providers import RACProviderSerializer
|
||||
|
||||
return RACProviderSerializer
|
||||
|
||||
@ -100,7 +100,7 @@ class Endpoint(SerializerModel, PolicyBindingModel):
|
||||
|
||||
@property
|
||||
def serializer(self) -> type[Serializer]:
|
||||
from authentik.enterprise.providers.rac.api.endpoints import EndpointSerializer
|
||||
from authentik.providers.rac.api.endpoints import EndpointSerializer
|
||||
|
||||
return EndpointSerializer
|
||||
|
||||
@ -129,7 +129,7 @@ class RACPropertyMapping(PropertyMapping):
|
||||
|
||||
@property
|
||||
def serializer(self) -> type[Serializer]:
|
||||
from authentik.enterprise.providers.rac.api.property_mappings import (
|
||||
from authentik.providers.rac.api.property_mappings import (
|
||||
RACPropertyMappingSerializer,
|
||||
)
|
||||
|
@ -10,12 +10,12 @@ from django.dispatch import receiver
|
||||
from django.http import HttpRequest
|
||||
|
||||
from authentik.core.models import User
|
||||
from authentik.enterprise.providers.rac.api.endpoints import user_endpoint_cache_key
|
||||
from authentik.enterprise.providers.rac.consumer_client import (
|
||||
from authentik.providers.rac.api.endpoints import user_endpoint_cache_key
|
||||
from authentik.providers.rac.consumer_client import (
|
||||
RAC_CLIENT_GROUP_SESSION,
|
||||
RAC_CLIENT_GROUP_TOKEN,
|
||||
)
|
||||
from authentik.enterprise.providers.rac.models import ConnectionToken, Endpoint
|
||||
from authentik.providers.rac.models import ConnectionToken, Endpoint
|
||||
|
||||
|
||||
@receiver(user_logged_out)
|
@ -3,7 +3,7 @@
|
||||
{% load authentik_core %}
|
||||
|
||||
{% block head %}
|
||||
<script src="{% versioned_script 'dist/enterprise/rac/index-%v.js' %}" type="module"></script>
|
||||
<script src="{% versioned_script 'dist/rac/index-%v.js' %}" type="module"></script>
|
||||
<meta name="theme-color" content="#18191a" media="(prefers-color-scheme: dark)">
|
||||
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
|
||||
<link rel="icon" href="{{ tenant.branding_favicon_url }}">
|
@ -1,16 +1,9 @@
|
||||
"""Test RAC Provider"""
|
||||
|
||||
from datetime import timedelta
|
||||
from time import mktime
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from django.urls import reverse
|
||||
from django.utils.timezone import now
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
|
||||
from authentik.enterprise.license import LicenseKey
|
||||
from authentik.enterprise.models import License
|
||||
from authentik.lib.generators import generate_id
|
||||
|
||||
|
||||
@ -20,21 +13,8 @@ class TestAPI(APITestCase):
|
||||
def setUp(self) -> None:
|
||||
self.user = create_test_admin_user()
|
||||
|
||||
@patch(
|
||||
"authentik.enterprise.license.LicenseKey.validate",
|
||||
MagicMock(
|
||||
return_value=LicenseKey(
|
||||
aud="",
|
||||
exp=int(mktime((now() + timedelta(days=3000)).timetuple())),
|
||||
name=generate_id(),
|
||||
internal_users=100,
|
||||
external_users=100,
|
||||
)
|
||||
),
|
||||
)
|
||||
def test_create(self):
|
||||
"""Test creation of RAC Provider"""
|
||||
License.objects.create(key=generate_id())
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.post(
|
||||
reverse("authentik_api:racprovider-list"),
|
@ -5,10 +5,10 @@ from rest_framework.test import APITestCase
|
||||
|
||||
from authentik.core.models import Application
|
||||
from authentik.core.tests.utils import create_test_admin_user
|
||||
from authentik.enterprise.providers.rac.models import Endpoint, Protocols, RACProvider
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.policies.dummy.models import DummyPolicy
|
||||
from authentik.policies.models import PolicyBinding
|
||||
from authentik.providers.rac.models import Endpoint, Protocols, RACProvider
|
||||
|
||||
|
||||
class TestEndpointsAPI(APITestCase):
|
@ -4,14 +4,14 @@ from django.test import TransactionTestCase
|
||||
|
||||
from authentik.core.models import Application, AuthenticatedSession
|
||||
from authentik.core.tests.utils import create_test_admin_user
|
||||
from authentik.enterprise.providers.rac.models import (
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.providers.rac.models import (
|
||||
ConnectionToken,
|
||||
Endpoint,
|
||||
Protocols,
|
||||
RACPropertyMapping,
|
||||
RACProvider,
|
||||
)
|
||||
from authentik.lib.generators import generate_id
|
||||
|
||||
|
||||
class TestModels(TransactionTestCase):
|
@ -1,23 +1,17 @@
|
||||
"""RAC Views tests"""
|
||||
|
||||
from datetime import timedelta
|
||||
from json import loads
|
||||
from time import mktime
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from django.urls import reverse
|
||||
from django.utils.timezone import now
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from authentik.core.models import Application
|
||||
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
|
||||
from authentik.enterprise.license import LicenseKey
|
||||
from authentik.enterprise.models import License
|
||||
from authentik.enterprise.providers.rac.models import Endpoint, Protocols, RACProvider
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.policies.denied import AccessDeniedResponse
|
||||
from authentik.policies.dummy.models import DummyPolicy
|
||||
from authentik.policies.models import PolicyBinding
|
||||
from authentik.providers.rac.models import Endpoint, Protocols, RACProvider
|
||||
|
||||
|
||||
class TestRACViews(APITestCase):
|
||||
@ -39,21 +33,8 @@ class TestRACViews(APITestCase):
|
||||
provider=self.provider,
|
||||
)
|
||||
|
||||
@patch(
|
||||
"authentik.enterprise.license.LicenseKey.validate",
|
||||
MagicMock(
|
||||
return_value=LicenseKey(
|
||||
aud="",
|
||||
exp=int(mktime((now() + timedelta(days=3000)).timetuple())),
|
||||
name=generate_id(),
|
||||
internal_users=100,
|
||||
external_users=100,
|
||||
)
|
||||
),
|
||||
)
|
||||
def test_no_policy(self):
|
||||
"""Test request"""
|
||||
License.objects.create(key=generate_id())
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.get(
|
||||
reverse(
|
||||
@ -70,18 +51,6 @@ class TestRACViews(APITestCase):
|
||||
final_response = self.client.get(next_url)
|
||||
self.assertEqual(final_response.status_code, 200)
|
||||
|
||||
@patch(
|
||||
"authentik.enterprise.license.LicenseKey.validate",
|
||||
MagicMock(
|
||||
return_value=LicenseKey(
|
||||
aud="",
|
||||
exp=int(mktime((now() + timedelta(days=3000)).timetuple())),
|
||||
name=generate_id(),
|
||||
internal_users=100,
|
||||
external_users=100,
|
||||
)
|
||||
),
|
||||
)
|
||||
def test_app_deny(self):
|
||||
"""Test request (deny on app level)"""
|
||||
PolicyBinding.objects.create(
|
||||
@ -89,7 +58,6 @@ class TestRACViews(APITestCase):
|
||||
policy=DummyPolicy.objects.create(name="deny", result=False, wait_min=1, wait_max=2),
|
||||
order=0,
|
||||
)
|
||||
License.objects.create(key=generate_id())
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.get(
|
||||
reverse(
|
||||
@ -99,18 +67,6 @@ class TestRACViews(APITestCase):
|
||||
)
|
||||
self.assertIsInstance(response, AccessDeniedResponse)
|
||||
|
||||
@patch(
|
||||
"authentik.enterprise.license.LicenseKey.validate",
|
||||
MagicMock(
|
||||
return_value=LicenseKey(
|
||||
aud="",
|
||||
exp=int(mktime((now() + timedelta(days=3000)).timetuple())),
|
||||
name=generate_id(),
|
||||
internal_users=100,
|
||||
external_users=100,
|
||||
)
|
||||
),
|
||||
)
|
||||
def test_endpoint_deny(self):
|
||||
"""Test request (deny on endpoint level)"""
|
||||
PolicyBinding.objects.create(
|
||||
@ -118,7 +74,6 @@ class TestRACViews(APITestCase):
|
||||
policy=DummyPolicy.objects.create(name="deny", result=False, wait_min=1, wait_max=2),
|
||||
order=0,
|
||||
)
|
||||
License.objects.create(key=generate_id())
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.get(
|
||||
reverse(
|
@ -4,14 +4,14 @@ from channels.auth import AuthMiddleware
|
||||
from channels.sessions import CookieMiddleware
|
||||
from django.urls import path
|
||||
|
||||
from authentik.enterprise.providers.rac.api.connection_tokens import ConnectionTokenViewSet
|
||||
from authentik.enterprise.providers.rac.api.endpoints import EndpointViewSet
|
||||
from authentik.enterprise.providers.rac.api.property_mappings import RACPropertyMappingViewSet
|
||||
from authentik.enterprise.providers.rac.api.providers import RACProviderViewSet
|
||||
from authentik.enterprise.providers.rac.consumer_client import RACClientConsumer
|
||||
from authentik.enterprise.providers.rac.consumer_outpost import RACOutpostConsumer
|
||||
from authentik.enterprise.providers.rac.views import RACInterface, RACStartView
|
||||
from authentik.outposts.channels import TokenOutpostMiddleware
|
||||
from authentik.providers.rac.api.connection_tokens import ConnectionTokenViewSet
|
||||
from authentik.providers.rac.api.endpoints import EndpointViewSet
|
||||
from authentik.providers.rac.api.property_mappings import RACPropertyMappingViewSet
|
||||
from authentik.providers.rac.api.providers import RACProviderViewSet
|
||||
from authentik.providers.rac.consumer_client import RACClientConsumer
|
||||
from authentik.providers.rac.consumer_outpost import RACOutpostConsumer
|
||||
from authentik.providers.rac.views import RACInterface, RACStartView
|
||||
from authentik.root.asgi_middleware import SessionMiddleware
|
||||
from authentik.root.middleware import ChannelsLoggingMiddleware
|
||||
|
@ -10,8 +10,6 @@ from django.utils.translation import gettext as _
|
||||
|
||||
from authentik.core.models import Application, AuthenticatedSession
|
||||
from authentik.core.views.interface import InterfaceView
|
||||
from authentik.enterprise.policy import EnterprisePolicyAccessView
|
||||
from authentik.enterprise.providers.rac.models import ConnectionToken, Endpoint, RACProvider
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.flows.challenge import RedirectChallenge
|
||||
from authentik.flows.exceptions import FlowNonApplicableException
|
||||
@ -20,9 +18,11 @@ from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, FlowPlanner
|
||||
from authentik.flows.stage import RedirectStage
|
||||
from authentik.lib.utils.time import timedelta_from_string
|
||||
from authentik.policies.engine import PolicyEngine
|
||||
from authentik.policies.views import PolicyAccessView
|
||||
from authentik.providers.rac.models import ConnectionToken, Endpoint, RACProvider
|
||||
|
||||
|
||||
class RACStartView(EnterprisePolicyAccessView):
|
||||
class RACStartView(PolicyAccessView):
|
||||
"""Start a RAC connection by checking access and creating a connection token"""
|
||||
|
||||
endpoint: Endpoint
|
@ -19,7 +19,6 @@ from authentik.lib.sync.outgoing.exceptions import (
|
||||
TransientSyncException,
|
||||
)
|
||||
from authentik.lib.utils.http import get_http_session
|
||||
from authentik.outposts.http import OutpostHTTPAdapter
|
||||
from authentik.providers.scim.clients.exceptions import SCIMRequestException
|
||||
from authentik.providers.scim.clients.schema import ServiceProviderConfiguration
|
||||
from authentik.providers.scim.models import SCIMProvider
|
||||
@ -42,7 +41,8 @@ class SCIMClient[TModel: "Model", TConnection: "Model", TSchema: "BaseModel"](
|
||||
|
||||
def __init__(self, provider: SCIMProvider):
|
||||
super().__init__(provider)
|
||||
self._session = self.get_session(provider)
|
||||
self._session = get_http_session()
|
||||
self._session.verify = provider.verify_certificates
|
||||
self.provider = provider
|
||||
# Remove trailing slashes as we assume the URL doesn't have any
|
||||
base_url = provider.url
|
||||
@ -52,15 +52,6 @@ class SCIMClient[TModel: "Model", TConnection: "Model", TSchema: "BaseModel"](
|
||||
self.token = provider.token
|
||||
self._config = self.get_service_provider_config()
|
||||
|
||||
def get_session(self, provider: SCIMProvider):
|
||||
session = get_http_session()
|
||||
if self.provider.outpost_set.exists():
|
||||
adapter = OutpostHTTPAdapter()
|
||||
session.mount("https://", adapter)
|
||||
session.mount("http://", adapter)
|
||||
session.verify = provider.verify_certificates
|
||||
return session
|
||||
|
||||
def _request(self, method: str, path: str, **kwargs) -> dict:
|
||||
"""Wrapper to send a request to the full URL"""
|
||||
try:
|
||||
|
@ -1,12 +0,0 @@
|
||||
"""SCIM Provider Docker Controller"""
|
||||
|
||||
from authentik.outposts.controllers.docker import DockerController
|
||||
from authentik.outposts.models import DockerServiceConnection, Outpost
|
||||
|
||||
|
||||
class SCIMDockerController(DockerController):
|
||||
"""SCIM Provider Docker Controller"""
|
||||
|
||||
def __init__(self, outpost: Outpost, connection: DockerServiceConnection):
|
||||
super().__init__(outpost, connection)
|
||||
self.deployment_ports = []
|
@ -1,14 +0,0 @@
|
||||
"""SCIM Provider Kubernetes Controller"""
|
||||
|
||||
from authentik.outposts.controllers.k8s.service import ServiceReconciler
|
||||
from authentik.outposts.controllers.kubernetes import KubernetesController
|
||||
from authentik.outposts.models import KubernetesServiceConnection, Outpost
|
||||
|
||||
|
||||
class SCIMKubernetesController(KubernetesController):
|
||||
"""SCIM Provider Kubernetes Controller"""
|
||||
|
||||
def __init__(self, outpost: Outpost, connection: KubernetesServiceConnection):
|
||||
super().__init__(outpost, connection)
|
||||
self.deployment_ports = []
|
||||
del self.reconcilers[ServiceReconciler.reconciler_name()]
|
@ -87,6 +87,7 @@ TENANT_APPS = [
|
||||
"authentik.providers.ldap",
|
||||
"authentik.providers.oauth2",
|
||||
"authentik.providers.proxy",
|
||||
"authentik.providers.rac",
|
||||
"authentik.providers.radius",
|
||||
"authentik.providers.saml",
|
||||
"authentik.providers.scim",
|
||||
@ -100,6 +101,7 @@ TENANT_APPS = [
|
||||
"authentik.sources.scim",
|
||||
"authentik.stages.authenticator",
|
||||
"authentik.stages.authenticator_duo",
|
||||
"authentik.stages.authenticator_email",
|
||||
"authentik.stages.authenticator_sms",
|
||||
"authentik.stages.authenticator_static",
|
||||
"authentik.stages.authenticator_totp",
|
||||
|
85
authentik/stages/authenticator_email/api.py
Normal file
85
authentik/stages/authenticator_email/api.py
Normal file
@ -0,0 +1,85 @@
|
||||
"""AuthenticatorEmailStage API Views"""
|
||||
|
||||
from rest_framework import mixins
|
||||
from rest_framework.viewsets import GenericViewSet, ModelViewSet
|
||||
|
||||
from authentik.core.api.groups import GroupMemberSerializer
|
||||
from authentik.core.api.used_by import UsedByMixin
|
||||
from authentik.core.api.utils import ModelSerializer
|
||||
from authentik.flows.api.stages import StageSerializer
|
||||
from authentik.stages.authenticator_email.models import AuthenticatorEmailStage, EmailDevice
|
||||
|
||||
|
||||
class AuthenticatorEmailStageSerializer(StageSerializer):
|
||||
"""AuthenticatorEmailStage Serializer"""
|
||||
|
||||
class Meta:
|
||||
model = AuthenticatorEmailStage
|
||||
fields = StageSerializer.Meta.fields + [
|
||||
"configure_flow",
|
||||
"friendly_name",
|
||||
"use_global_settings",
|
||||
"host",
|
||||
"port",
|
||||
"username",
|
||||
"password",
|
||||
"use_tls",
|
||||
"use_ssl",
|
||||
"timeout",
|
||||
"from_address",
|
||||
"subject",
|
||||
"token_expiry",
|
||||
"template",
|
||||
]
|
||||
|
||||
|
||||
class AuthenticatorEmailStageViewSet(UsedByMixin, ModelViewSet):
|
||||
"""AuthenticatorEmailStage Viewset"""
|
||||
|
||||
queryset = AuthenticatorEmailStage.objects.all()
|
||||
serializer_class = AuthenticatorEmailStageSerializer
|
||||
filterset_fields = "__all__"
|
||||
ordering = ["name"]
|
||||
search_fields = ["name"]
|
||||
|
||||
|
||||
class EmailDeviceSerializer(ModelSerializer):
|
||||
"""Serializer for email authenticator devices"""
|
||||
|
||||
user = GroupMemberSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = EmailDevice
|
||||
fields = ["name", "pk", "email", "user"]
|
||||
depth = 2
|
||||
extra_kwargs = {
|
||||
"email": {"read_only": True},
|
||||
}
|
||||
|
||||
|
||||
class EmailDeviceViewSet(
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.UpdateModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
UsedByMixin,
|
||||
mixins.ListModelMixin,
|
||||
GenericViewSet,
|
||||
):
|
||||
"""Viewset for email authenticator devices"""
|
||||
|
||||
queryset = EmailDevice.objects.all()
|
||||
serializer_class = EmailDeviceSerializer
|
||||
search_fields = ["name"]
|
||||
filterset_fields = ["name"]
|
||||
ordering = ["name"]
|
||||
owner_field = "user"
|
||||
|
||||
|
||||
class EmailAdminDeviceViewSet(ModelViewSet):
|
||||
"""Viewset for email authenticator devices (for admins)"""
|
||||
|
||||
queryset = EmailDevice.objects.all()
|
||||
serializer_class = EmailDeviceSerializer
|
||||
search_fields = ["name"]
|
||||
filterset_fields = ["name"]
|
||||
ordering = ["name"]
|
12
authentik/stages/authenticator_email/apps.py
Normal file
12
authentik/stages/authenticator_email/apps.py
Normal file
@ -0,0 +1,12 @@
|
||||
"""Email Authenticator"""
|
||||
|
||||
from authentik.blueprints.apps import ManagedAppConfig
|
||||
|
||||
|
||||
class AuthentikStageAuthenticatorEmailConfig(ManagedAppConfig):
|
||||
"""Email Authenticator App config"""
|
||||
|
||||
name = "authentik.stages.authenticator_email"
|
||||
label = "authentik_stages_authenticator_email"
|
||||
verbose_name = "authentik Stages.Authenticator.Email"
|
||||
default = True
|
132
authentik/stages/authenticator_email/migrations/0001_initial.py
Normal file
132
authentik/stages/authenticator_email/migrations/0001_initial.py
Normal file
@ -0,0 +1,132 @@
|
||||
# Generated by Django 5.0.10 on 2025-01-27 20:05
|
||||
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
import authentik.lib.utils.time
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("authentik_flows", "0027_auto_20231028_1424"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="AuthenticatorEmailStage",
|
||||
fields=[
|
||||
(
|
||||
"stage_ptr",
|
||||
models.OneToOneField(
|
||||
auto_created=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
parent_link=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
to="authentik_flows.stage",
|
||||
),
|
||||
),
|
||||
("friendly_name", models.TextField(null=True)),
|
||||
(
|
||||
"use_global_settings",
|
||||
models.BooleanField(
|
||||
default=False,
|
||||
help_text="When enabled, global Email connection settings will be used and connection settings below will be ignored.",
|
||||
),
|
||||
),
|
||||
("host", models.TextField(default="localhost")),
|
||||
("port", models.IntegerField(default=25)),
|
||||
("username", models.TextField(blank=True, default="")),
|
||||
("password", models.TextField(blank=True, default="")),
|
||||
("use_tls", models.BooleanField(default=False)),
|
||||
("use_ssl", models.BooleanField(default=False)),
|
||||
("timeout", models.IntegerField(default=10)),
|
||||
(
|
||||
"from_address",
|
||||
models.EmailField(default="system@authentik.local", max_length=254),
|
||||
),
|
||||
(
|
||||
"token_expiry",
|
||||
models.TextField(
|
||||
default="minutes=30",
|
||||
help_text="Time the token sent is valid (Format: hours=3,minutes=17,seconds=300).",
|
||||
validators=[authentik.lib.utils.time.timedelta_string_validator],
|
||||
),
|
||||
),
|
||||
("subject", models.TextField(default="authentik Sign-in code")),
|
||||
("template", models.TextField(default="email/email_otp.html")),
|
||||
(
|
||||
"configure_flow",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="Flow used by an authenticated user to configure this Stage. If empty, user will not be able to configure this stage.",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
to="authentik_flows.flow",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Email Authenticator Setup Stage",
|
||||
"verbose_name_plural": "Email Authenticator Setup Stages",
|
||||
},
|
||||
bases=("authentik_flows.stage", models.Model),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="EmailDevice",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
|
||||
),
|
||||
),
|
||||
("created", models.DateTimeField(auto_now_add=True)),
|
||||
("last_updated", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"name",
|
||||
models.CharField(
|
||||
help_text="The human-readable name of this device.", max_length=64
|
||||
),
|
||||
),
|
||||
(
|
||||
"confirmed",
|
||||
models.BooleanField(default=True, help_text="Is this device ready for use?"),
|
||||
),
|
||||
("token", models.CharField(blank=True, max_length=16, null=True)),
|
||||
(
|
||||
"valid_until",
|
||||
models.DateTimeField(
|
||||
default=django.utils.timezone.now,
|
||||
help_text="The timestamp of the moment of expiry of the saved token.",
|
||||
),
|
||||
),
|
||||
("email", models.EmailField(max_length=254)),
|
||||
("last_used", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"stage",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="authentik_stages_authenticator_email.authenticatoremailstage",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Email Device",
|
||||
"verbose_name_plural": "Email Devices",
|
||||
"unique_together": {("user", "email")},
|
||||
},
|
||||
),
|
||||
]
|
167
authentik/stages/authenticator_email/models.py
Normal file
167
authentik/stages/authenticator_email/models.py
Normal file
@ -0,0 +1,167 @@
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.mail.backends.base import BaseEmailBackend
|
||||
from django.core.mail.backends.smtp import EmailBackend
|
||||
from django.db import models
|
||||
from django.template import TemplateSyntaxError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views import View
|
||||
from rest_framework.serializers import BaseSerializer
|
||||
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.flows.exceptions import StageInvalidException
|
||||
from authentik.flows.models import ConfigurableStage, FriendlyNamedStage, Stage
|
||||
from authentik.lib.config import CONFIG
|
||||
from authentik.lib.models import SerializerModel
|
||||
from authentik.lib.utils.errors import exception_to_string
|
||||
from authentik.lib.utils.time import timedelta_string_validator
|
||||
from authentik.stages.authenticator.models import SideChannelDevice
|
||||
from authentik.stages.email.utils import TemplateEmailMessage
|
||||
|
||||
|
||||
class EmailTemplates(models.TextChoices):
|
||||
"""Templates used for rendering the Email"""
|
||||
|
||||
EMAIL_OTP = (
|
||||
"email/email_otp.html",
|
||||
_("Email OTP"),
|
||||
) # nosec
|
||||
|
||||
|
||||
class AuthenticatorEmailStage(ConfigurableStage, FriendlyNamedStage, Stage):
|
||||
"""Use Email-based authentication instead of authenticator-based."""
|
||||
|
||||
use_global_settings = models.BooleanField(
|
||||
default=False,
|
||||
help_text=_(
|
||||
"When enabled, global Email connection settings will be used and "
|
||||
"connection settings below will be ignored."
|
||||
),
|
||||
)
|
||||
|
||||
host = models.TextField(default="localhost")
|
||||
port = models.IntegerField(default=25)
|
||||
username = models.TextField(default="", blank=True)
|
||||
password = models.TextField(default="", blank=True)
|
||||
use_tls = models.BooleanField(default=False)
|
||||
use_ssl = models.BooleanField(default=False)
|
||||
timeout = models.IntegerField(default=10)
|
||||
from_address = models.EmailField(default="system@authentik.local")
|
||||
|
||||
token_expiry = models.TextField(
|
||||
default="minutes=30",
|
||||
validators=[timedelta_string_validator],
|
||||
help_text=_("Time the token sent is valid (Format: hours=3,minutes=17,seconds=300)."),
|
||||
)
|
||||
subject = models.TextField(default="authentik Sign-in code")
|
||||
template = models.TextField(default=EmailTemplates.EMAIL_OTP)
|
||||
|
||||
@property
|
||||
def serializer(self) -> type[BaseSerializer]:
|
||||
from authentik.stages.authenticator_email.api import AuthenticatorEmailStageSerializer
|
||||
|
||||
return AuthenticatorEmailStageSerializer
|
||||
|
||||
@property
|
||||
def view(self) -> type[View]:
|
||||
from authentik.stages.authenticator_email.stage import AuthenticatorEmailStageView
|
||||
|
||||
return AuthenticatorEmailStageView
|
||||
|
||||
@property
|
||||
def component(self) -> str:
|
||||
return "ak-stage-authenticator-email-form"
|
||||
|
||||
@property
|
||||
def backend_class(self) -> type[BaseEmailBackend]:
|
||||
"""Get the email backend class to use"""
|
||||
return EmailBackend
|
||||
|
||||
@property
|
||||
def backend(self) -> BaseEmailBackend:
|
||||
"""Get fully configured Email Backend instance"""
|
||||
if self.use_global_settings:
|
||||
CONFIG.refresh("email.password")
|
||||
return self.backend_class(
|
||||
host=CONFIG.get("email.host"),
|
||||
port=CONFIG.get_int("email.port"),
|
||||
username=CONFIG.get("email.username"),
|
||||
password=CONFIG.get("email.password"),
|
||||
use_tls=CONFIG.get_bool("email.use_tls", False),
|
||||
use_ssl=CONFIG.get_bool("email.use_ssl", False),
|
||||
timeout=CONFIG.get_int("email.timeout"),
|
||||
)
|
||||
return self.backend_class(
|
||||
host=self.host,
|
||||
port=self.port,
|
||||
username=self.username,
|
||||
password=self.password,
|
||||
use_tls=self.use_tls,
|
||||
use_ssl=self.use_ssl,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
|
||||
def send(self, device: "EmailDevice"):
|
||||
# Lazy import here to avoid circular import
|
||||
from authentik.stages.email.tasks import send_mails
|
||||
|
||||
# Compose the message using templates
|
||||
message = device._compose_email()
|
||||
return send_mails(device.stage, message)
|
||||
|
||||
def __str__(self):
|
||||
return f"Email Authenticator Stage {self.name}"
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Email Authenticator Setup Stage")
|
||||
verbose_name_plural = _("Email Authenticator Setup Stages")
|
||||
|
||||
|
||||
class EmailDevice(SerializerModel, SideChannelDevice):
|
||||
"""Email Device"""
|
||||
|
||||
user = models.ForeignKey(get_user_model(), on_delete=models.CASCADE)
|
||||
email = models.EmailField()
|
||||
stage = models.ForeignKey(AuthenticatorEmailStage, on_delete=models.CASCADE)
|
||||
last_used = models.DateTimeField(auto_now=True)
|
||||
|
||||
@property
|
||||
def serializer(self) -> type[BaseSerializer]:
|
||||
from authentik.stages.authenticator_email.api import EmailDeviceSerializer
|
||||
|
||||
return EmailDeviceSerializer
|
||||
|
||||
def _compose_email(self) -> TemplateEmailMessage:
|
||||
try:
|
||||
pending_user = self.user
|
||||
stage = self.stage
|
||||
email = self.email
|
||||
|
||||
message = TemplateEmailMessage(
|
||||
subject=_(stage.subject),
|
||||
to=[(pending_user.name, email)],
|
||||
template_name=stage.template,
|
||||
template_context={
|
||||
"user": pending_user,
|
||||
"expires": self.valid_until,
|
||||
"token": self.token,
|
||||
},
|
||||
)
|
||||
return message
|
||||
except TemplateSyntaxError as exc:
|
||||
Event.new(
|
||||
EventAction.CONFIGURATION_ERROR,
|
||||
message=_("Exception occurred while rendering E-mail template"),
|
||||
error=exception_to_string(exc),
|
||||
template=stage.template,
|
||||
).from_http(self.request)
|
||||
raise StageInvalidException from exc
|
||||
|
||||
def __str__(self):
|
||||
if not self.pk:
|
||||
return "New Email Device"
|
||||
return f"Email Device for {self.user_id}"
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Email Device")
|
||||
verbose_name_plural = _("Email Devices")
|
||||
unique_together = (("user", "email"),)
|
177
authentik/stages/authenticator_email/stage.py
Normal file
177
authentik/stages/authenticator_email/stage.py
Normal file
@ -0,0 +1,177 @@
|
||||
"""Email Setup stage"""
|
||||
|
||||
from django.db.models import Q
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.http.request import QueryDict
|
||||
from django.template.exceptions import TemplateSyntaxError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.fields import BooleanField, CharField, IntegerField
|
||||
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.flows.challenge import (
|
||||
Challenge,
|
||||
ChallengeResponse,
|
||||
WithUserInfoChallenge,
|
||||
)
|
||||
from authentik.flows.exceptions import StageInvalidException
|
||||
from authentik.flows.stage import ChallengeStageView
|
||||
from authentik.lib.utils.email import mask_email
|
||||
from authentik.lib.utils.errors import exception_to_string
|
||||
from authentik.lib.utils.time import timedelta_from_string
|
||||
from authentik.stages.authenticator_email.models import (
|
||||
AuthenticatorEmailStage,
|
||||
EmailDevice,
|
||||
)
|
||||
from authentik.stages.email.tasks import send_mails
|
||||
from authentik.stages.email.utils import TemplateEmailMessage
|
||||
from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT
|
||||
|
||||
SESSION_KEY_EMAIL_DEVICE = "authentik/stages/authenticator_email/email_device"
|
||||
PLAN_CONTEXT_EMAIL = "email"
|
||||
PLAN_CONTEXT_EMAIL_SENT = "email_sent"
|
||||
PLAN_CONTEXT_EMAIL_OVERRIDE = "email"
|
||||
|
||||
|
||||
class AuthenticatorEmailChallenge(WithUserInfoChallenge):
|
||||
"""Authenticator Email Setup challenge"""
|
||||
|
||||
# Set to true if no previous prompt stage set the email
|
||||
# this stage will also check prompt_data.email
|
||||
email = CharField(default=None, allow_blank=True, allow_null=True)
|
||||
email_required = BooleanField(default=True)
|
||||
component = CharField(default="ak-stage-authenticator-email")
|
||||
|
||||
|
||||
class AuthenticatorEmailChallengeResponse(ChallengeResponse):
|
||||
"""Authenticator Email Challenge response, device is set by get_response_instance"""
|
||||
|
||||
device: EmailDevice
|
||||
|
||||
code = IntegerField(required=False)
|
||||
email = CharField(required=False)
|
||||
|
||||
component = CharField(default="ak-stage-authenticator-email")
|
||||
|
||||
def validate(self, attrs: dict) -> dict:
|
||||
"""Check"""
|
||||
if "code" not in attrs:
|
||||
if "email" not in attrs:
|
||||
raise ValidationError("email required")
|
||||
self.device.email = attrs["email"]
|
||||
self.stage.validate_and_send(attrs["email"])
|
||||
return super().validate(attrs)
|
||||
if not self.device.verify_token(str(attrs["code"])):
|
||||
raise ValidationError(_("Code does not match"))
|
||||
self.device.confirmed = True
|
||||
return super().validate(attrs)
|
||||
|
||||
|
||||
class AuthenticatorEmailStageView(ChallengeStageView):
|
||||
"""Authenticator Email Setup stage"""
|
||||
|
||||
response_class = AuthenticatorEmailChallengeResponse
|
||||
|
||||
def validate_and_send(self, email: str):
|
||||
"""Validate email and send message"""
|
||||
pending_user = self.get_pending_user()
|
||||
|
||||
stage: AuthenticatorEmailStage = self.executor.current_stage
|
||||
if EmailDevice.objects.filter(Q(email=email), stage=stage.pk).exists():
|
||||
raise ValidationError(_("Invalid email"))
|
||||
|
||||
device: EmailDevice = self.request.session[SESSION_KEY_EMAIL_DEVICE]
|
||||
|
||||
try:
|
||||
message = TemplateEmailMessage(
|
||||
subject=_(stage.subject),
|
||||
to=[(pending_user.name, email)],
|
||||
language=pending_user.locale(self.request),
|
||||
template_name=stage.template,
|
||||
template_context={
|
||||
"user": pending_user,
|
||||
"expires": device.valid_until,
|
||||
"token": device.token,
|
||||
},
|
||||
)
|
||||
|
||||
send_mails(stage, message)
|
||||
except TemplateSyntaxError as exc:
|
||||
Event.new(
|
||||
EventAction.CONFIGURATION_ERROR,
|
||||
message=_("Exception occurred while rendering E-mail template"),
|
||||
error=exception_to_string(exc),
|
||||
template=stage.template,
|
||||
).from_http(self.request)
|
||||
raise StageInvalidException from exc
|
||||
|
||||
def _has_email(self) -> str | None:
|
||||
context = self.executor.plan.context
|
||||
|
||||
# Check user's email attribute
|
||||
user = self.get_pending_user()
|
||||
if user.email:
|
||||
self.logger.debug("got email from user attributes")
|
||||
return user.email
|
||||
# Check plan context for email
|
||||
if PLAN_CONTEXT_EMAIL in context.get(PLAN_CONTEXT_PROMPT, {}):
|
||||
self.logger.debug("got email from plan context")
|
||||
return context.get(PLAN_CONTEXT_PROMPT, {}).get(PLAN_CONTEXT_EMAIL)
|
||||
# Check device for email
|
||||
if SESSION_KEY_EMAIL_DEVICE in self.request.session:
|
||||
self.logger.debug("got email from device in session")
|
||||
device: EmailDevice = self.request.session[SESSION_KEY_EMAIL_DEVICE]
|
||||
if device.email == "":
|
||||
return None
|
||||
return device.email
|
||||
return None
|
||||
|
||||
def get_challenge(self, *args, **kwargs) -> Challenge:
|
||||
email = self._has_email()
|
||||
return AuthenticatorEmailChallenge(
|
||||
data={
|
||||
"email": mask_email(email),
|
||||
"email_required": email is None,
|
||||
}
|
||||
)
|
||||
|
||||
def get_response_instance(self, data: QueryDict) -> ChallengeResponse:
|
||||
response = super().get_response_instance(data)
|
||||
response.device = self.request.session[SESSION_KEY_EMAIL_DEVICE]
|
||||
return response
|
||||
|
||||
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||
user = self.get_pending_user()
|
||||
|
||||
stage: AuthenticatorEmailStage = self.executor.current_stage
|
||||
if SESSION_KEY_EMAIL_DEVICE not in self.request.session:
|
||||
device = EmailDevice(user=user, confirmed=False, stage=stage, name="Email Device")
|
||||
valid_secs: int = timedelta_from_string(stage.token_expiry).total_seconds()
|
||||
device.generate_token(valid_secs=valid_secs, commit=False)
|
||||
self.request.session[SESSION_KEY_EMAIL_DEVICE] = device
|
||||
if email := self._has_email():
|
||||
device.email = email
|
||||
try:
|
||||
self.validate_and_send(email)
|
||||
except ValidationError as exc:
|
||||
# We had an email given already (at this point only possible from flow
|
||||
# context), but an error occurred while sending (most likely)
|
||||
# due to a duplicate device, so delete the email we got given, reset the state
|
||||
# (ish) and retry
|
||||
device.email = ""
|
||||
self.executor.plan.context.get(PLAN_CONTEXT_PROMPT, {}).pop(
|
||||
PLAN_CONTEXT_EMAIL, None
|
||||
)
|
||||
self.request.session.pop(SESSION_KEY_EMAIL_DEVICE, None)
|
||||
self.logger.warning("failed to send email to pre-set address", exc=exc)
|
||||
return self.get(request, *args, **kwargs)
|
||||
return super().get(request, *args, **kwargs)
|
||||
|
||||
def challenge_valid(self, response: ChallengeResponse) -> HttpResponse:
|
||||
"""Email Token is validated by challenge"""
|
||||
device: EmailDevice = self.request.session[SESSION_KEY_EMAIL_DEVICE]
|
||||
if not device.confirmed:
|
||||
return self.challenge_invalid(response)
|
||||
device.save()
|
||||
del self.request.session[SESSION_KEY_EMAIL_DEVICE]
|
||||
return self.executor.stage_ok()
|
@ -0,0 +1,44 @@
|
||||
{% extends "email/base.html" %}
|
||||
|
||||
{% load i18n %}
|
||||
{% load humanize %}
|
||||
|
||||
{% block content %}
|
||||
<tr>
|
||||
<td align="center">
|
||||
<h1>
|
||||
{% blocktrans with username=user.username %}
|
||||
Hi {{ username }},
|
||||
{% endblocktrans %}
|
||||
</h1>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table border="0">
|
||||
<tr>
|
||||
<td align="center" style="max-width: 300px; padding: 20px 0; color: #212124;">
|
||||
{% blocktrans %}
|
||||
Email MFA code.
|
||||
{% endblocktrans %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" class="btn btn-primary">
|
||||
{{ token }}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
{% endblock %}
|
||||
|
||||
{% block sub_content %}
|
||||
<tr>
|
||||
<td style="padding: 20px; font-size: 12px; color: #212124;" align="center">
|
||||
{% blocktrans with expires=expires|timeuntil %}
|
||||
If you did not request this code, please ignore this email. The code above is valid for {{ expires }}.
|
||||
{% endblocktrans %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endblock %}
|
@ -0,0 +1,13 @@
|
||||
{% load i18n %}{% load humanize %}{% autoescape off %}{% blocktrans with username=user.username %}Hi {{ username }},{% endblocktrans %}
|
||||
|
||||
{% blocktrans %}
|
||||
Email MFA code
|
||||
{% endblocktrans %}
|
||||
{{ token }}
|
||||
{% blocktrans with expires=expires|timeuntil %}
|
||||
If you did not request this code, please ignore this email. The code above is valid for {{ expires }}.
|
||||
{% endblocktrans %}
|
||||
|
||||
--
|
||||
Powered by goauthentik.io.
|
||||
{% endautoescape %}
|
340
authentik/stages/authenticator_email/tests.py
Normal file
340
authentik/stages/authenticator_email/tests.py
Normal file
@ -0,0 +1,340 @@
|
||||
"""Test Email Authenticator API"""
|
||||
|
||||
from datetime import timedelta
|
||||
from unittest.mock import MagicMock, PropertyMock, patch
|
||||
|
||||
from django.core import mail
|
||||
from django.core.mail.backends.smtp import EmailBackend
|
||||
from django.db.utils import IntegrityError
|
||||
from django.template.exceptions import TemplateDoesNotExist
|
||||
from django.urls import reverse
|
||||
from django.utils.timezone import now
|
||||
|
||||
from authentik.core.tests.utils import create_test_admin_user, create_test_flow, create_test_user
|
||||
from authentik.flows.models import FlowStageBinding
|
||||
from authentik.flows.tests import FlowTestCase
|
||||
from authentik.lib.config import CONFIG
|
||||
from authentik.lib.utils.email import mask_email
|
||||
from authentik.stages.authenticator_email.api import (
|
||||
AuthenticatorEmailStageSerializer,
|
||||
EmailDeviceSerializer,
|
||||
)
|
||||
from authentik.stages.authenticator_email.models import AuthenticatorEmailStage, EmailDevice
|
||||
from authentik.stages.authenticator_email.stage import (
|
||||
SESSION_KEY_EMAIL_DEVICE,
|
||||
)
|
||||
from authentik.stages.email.utils import TemplateEmailMessage
|
||||
|
||||
|
||||
class TestAuthenticatorEmailStage(FlowTestCase):
|
||||
"""Test Email Authenticator stage"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.flow = create_test_flow()
|
||||
self.user = create_test_admin_user()
|
||||
self.user_noemail = create_test_user(email="")
|
||||
self.stage = AuthenticatorEmailStage.objects.create(
|
||||
name="email-authenticator",
|
||||
use_global_settings=True,
|
||||
from_address="test@authentik.local",
|
||||
configure_flow=self.flow,
|
||||
token_expiry="minutes=30",
|
||||
) # nosec
|
||||
self.binding = FlowStageBinding.objects.create(target=self.flow, stage=self.stage, order=0)
|
||||
self.device = EmailDevice.objects.create(
|
||||
user=self.user,
|
||||
stage=self.stage,
|
||||
email="test@authentik.local",
|
||||
)
|
||||
self.client.force_login(self.user)
|
||||
|
||||
def test_device_str(self):
|
||||
"""Test string representation of device"""
|
||||
self.assertEqual(str(self.device), f"Email Device for {self.user.pk}")
|
||||
# Test unsaved device
|
||||
unsaved_device = EmailDevice(
|
||||
user=self.user,
|
||||
stage=self.stage,
|
||||
email="test@authentik.local",
|
||||
)
|
||||
self.assertEqual(str(unsaved_device), "New Email Device")
|
||||
|
||||
def test_stage_str(self):
|
||||
"""Test string representation of stage"""
|
||||
self.assertEqual(str(self.stage), f"Email Authenticator Stage {self.stage.name}")
|
||||
|
||||
def test_token_lifecycle(self):
|
||||
"""Test token generation, validation and expiry"""
|
||||
# Initially no token
|
||||
self.assertIsNone(self.device.token)
|
||||
|
||||
# Generate token
|
||||
self.device.generate_token()
|
||||
token = self.device.token
|
||||
self.assertIsNotNone(token)
|
||||
self.assertIsNotNone(self.device.valid_until)
|
||||
self.assertTrue(self.device.valid_until > now())
|
||||
|
||||
# Verify invalid token
|
||||
self.assertFalse(self.device.verify_token("000000"))
|
||||
|
||||
# Verify correct token (should clear token after verification)
|
||||
self.assertTrue(self.device.verify_token(token))
|
||||
self.assertIsNone(self.device.token)
|
||||
|
||||
def test_stage_no_prefill(self):
|
||||
"""Test stage without prefilled email"""
|
||||
self.client.force_login(self.user_noemail)
|
||||
with patch(
|
||||
"authentik.stages.authenticator_email.models.AuthenticatorEmailStage.backend_class",
|
||||
PropertyMock(return_value=EmailBackend),
|
||||
):
|
||||
response = self.client.get(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
|
||||
)
|
||||
self.assertStageResponse(
|
||||
response,
|
||||
self.flow,
|
||||
self.user_noemail,
|
||||
component="ak-stage-authenticator-email",
|
||||
email_required=True,
|
||||
)
|
||||
|
||||
def test_stage_submit(self):
|
||||
"""Test stage email submission"""
|
||||
# Initialize the flow
|
||||
response = self.client.get(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
|
||||
)
|
||||
self.assertStageResponse(
|
||||
response,
|
||||
self.flow,
|
||||
self.user,
|
||||
component="ak-stage-authenticator-email",
|
||||
email_required=False,
|
||||
)
|
||||
|
||||
# Test email submission with locmem backend
|
||||
def mock_send_mails(stage, *messages):
|
||||
"""Mock send_mails to send directly"""
|
||||
for message in messages:
|
||||
message.send()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"authentik.stages.authenticator_email.models.AuthenticatorEmailStage.backend_class",
|
||||
return_value=EmailBackend,
|
||||
),
|
||||
patch(
|
||||
"authentik.stages.authenticator_email.stage.send_mails",
|
||||
side_effect=mock_send_mails,
|
||||
),
|
||||
):
|
||||
response = self.client.post(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
|
||||
data={"component": "ak-stage-authenticator-email", "email": "test@example.com"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
sent_mail = mail.outbox[0]
|
||||
self.assertEqual(sent_mail.subject, self.stage.subject)
|
||||
self.assertEqual(sent_mail.to, [f"{self.user} <test@example.com>"])
|
||||
# Get from_address from global email config to test if global settings are being used
|
||||
from_address_global = CONFIG.get("email.from")
|
||||
self.assertEqual(sent_mail.from_email, from_address_global)
|
||||
|
||||
self.assertStageResponse(
|
||||
response,
|
||||
self.flow,
|
||||
self.user,
|
||||
component="ak-stage-authenticator-email",
|
||||
response_errors={},
|
||||
email_required=False,
|
||||
)
|
||||
|
||||
def test_email_template(self):
|
||||
"""Test email template rendering"""
|
||||
self.device.generate_token()
|
||||
message = self.device._compose_email()
|
||||
|
||||
self.assertIsInstance(message, TemplateEmailMessage)
|
||||
self.assertEqual(message.subject, self.stage.subject)
|
||||
self.assertEqual(message.to, [f"{self.user.name} <{self.device.email}>"])
|
||||
self.assertTrue(self.device.token in message.body)
|
||||
|
||||
def test_duplicate_email(self):
|
||||
"""Test attempting to use same email twice"""
|
||||
email = "test2@authentik.local"
|
||||
# First device
|
||||
EmailDevice.objects.create(
|
||||
user=self.user,
|
||||
stage=self.stage,
|
||||
email=email,
|
||||
)
|
||||
# Attempt to create second device with same email
|
||||
with self.assertRaises(IntegrityError):
|
||||
EmailDevice.objects.create(
|
||||
user=self.user,
|
||||
stage=self.stage,
|
||||
email=email,
|
||||
)
|
||||
|
||||
def test_token_expiry(self):
|
||||
"""Test token expiration behavior"""
|
||||
self.device.generate_token()
|
||||
token = self.device.token
|
||||
# Set token as expired
|
||||
self.device.valid_until = now() - timedelta(minutes=1)
|
||||
self.device.save()
|
||||
# Verify expired token fails
|
||||
self.assertFalse(self.device.verify_token(token))
|
||||
|
||||
def test_template_errors(self):
|
||||
"""Test handling of template errors"""
|
||||
self.stage.template = "{% invalid template %}"
|
||||
with self.assertRaises(TemplateDoesNotExist):
|
||||
self.stage.send(self.device)
|
||||
|
||||
def test_challenge_response_validation(self):
|
||||
"""Test challenge response validation"""
|
||||
# Initialize the flow
|
||||
self.client.force_login(self.user_noemail)
|
||||
with patch(
|
||||
"authentik.stages.authenticator_email.models.AuthenticatorEmailStage.backend_class",
|
||||
PropertyMock(return_value=EmailBackend),
|
||||
):
|
||||
response = self.client.get(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
|
||||
)
|
||||
|
||||
# Test missing code and email
|
||||
response = self.client.post(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
|
||||
data={"component": "ak-stage-authenticator-email"},
|
||||
)
|
||||
self.assertIn("email required", str(response.content))
|
||||
|
||||
# Test invalid code
|
||||
response = self.client.post(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
|
||||
data={"component": "ak-stage-authenticator-email", "code": "000000"},
|
||||
)
|
||||
self.assertIn("Code does not match", str(response.content))
|
||||
|
||||
# Test valid code
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.get(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
|
||||
)
|
||||
device = self.device
|
||||
token = device.token
|
||||
response = self.client.post(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
|
||||
data={"component": "ak-stage-authenticator-email", "code": token},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(device.confirmed)
|
||||
|
||||
def test_challenge_generation(self):
|
||||
"""Test challenge generation"""
|
||||
# Test with masked email
|
||||
with patch(
|
||||
"authentik.stages.authenticator_email.models.AuthenticatorEmailStage.backend_class",
|
||||
PropertyMock(return_value=EmailBackend),
|
||||
):
|
||||
response = self.client.get(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
|
||||
)
|
||||
self.assertStageResponse(
|
||||
response,
|
||||
self.flow,
|
||||
self.user,
|
||||
component="ak-stage-authenticator-email",
|
||||
email_required=False,
|
||||
)
|
||||
masked_email = mask_email(self.user.email)
|
||||
self.assertEqual(masked_email, response.json()["email"])
|
||||
|
||||
# Test without email
|
||||
self.client.force_login(self.user_noemail)
|
||||
response = self.client.get(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
|
||||
)
|
||||
self.assertStageResponse(
|
||||
response,
|
||||
self.flow,
|
||||
self.user_noemail,
|
||||
component="ak-stage-authenticator-email",
|
||||
email_required=True,
|
||||
)
|
||||
self.assertIsNone(response.json()["email"])
|
||||
|
||||
def test_session_management(self):
|
||||
"""Test session device management"""
|
||||
# Test device creation in session
|
||||
with patch(
|
||||
"authentik.stages.authenticator_email.models.AuthenticatorEmailStage.backend_class",
|
||||
PropertyMock(return_value=EmailBackend),
|
||||
):
|
||||
# Delete any existing devices for this test
|
||||
EmailDevice.objects.filter(user=self.user).delete()
|
||||
|
||||
response = self.client.get(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
|
||||
)
|
||||
self.assertIn(SESSION_KEY_EMAIL_DEVICE, self.client.session)
|
||||
device = self.client.session[SESSION_KEY_EMAIL_DEVICE]
|
||||
self.assertIsInstance(device, EmailDevice)
|
||||
self.assertFalse(device.confirmed)
|
||||
self.assertEqual(device.user, self.user)
|
||||
|
||||
# Test device confirmation and cleanup
|
||||
device.confirmed = True
|
||||
device.email = "new_test@authentik.local" # Use a different email
|
||||
self.client.session[SESSION_KEY_EMAIL_DEVICE] = device
|
||||
self.client.session.save()
|
||||
response = self.client.post(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
|
||||
data={"component": "ak-stage-authenticator-email", "code": device.token},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(device.confirmed)
|
||||
# Session key should be removed after device is saved
|
||||
device.save()
|
||||
self.assertNotIn(SESSION_KEY_EMAIL_DEVICE, self.client.session)
|
||||
|
||||
def test_model_properties_and_methods(self):
|
||||
"""Test model properties"""
|
||||
device = self.device
|
||||
stage = self.stage
|
||||
|
||||
self.assertEqual(stage.serializer, AuthenticatorEmailStageSerializer)
|
||||
self.assertIsInstance(stage.backend, EmailBackend)
|
||||
self.assertEqual(device.serializer, EmailDeviceSerializer)
|
||||
|
||||
# Test AuthenticatorEmailStage send method
|
||||
with patch(
|
||||
"authentik.stages.authenticator_email.models.AuthenticatorEmailStage.backend_class",
|
||||
return_value=EmailBackend,
|
||||
):
|
||||
self.device.generate_token()
|
||||
# Test EmailDevice _compose_email method
|
||||
message = self.device._compose_email()
|
||||
self.assertIsInstance(message, TemplateEmailMessage)
|
||||
self.assertEqual(message.subject, self.stage.subject)
|
||||
self.assertEqual(message.to, [f"{self.user.name} <{self.device.email}>"])
|
||||
self.assertTrue(self.device.token in message.body)
|
||||
# Test AuthenticatorEmailStage send method
|
||||
self.stage.send(device)
|
||||
|
||||
def test_email_tasks(self):
|
||||
|
||||
email_send_mock = MagicMock()
|
||||
with patch(
|
||||
"authentik.stages.email.tasks.send_mails",
|
||||
email_send_mock,
|
||||
):
|
||||
# Test AuthenticatorEmailStage send method
|
||||
self.stage.send(self.device)
|
||||
email_send_mock.assert_called_once()
|
17
authentik/stages/authenticator_email/urls.py
Normal file
17
authentik/stages/authenticator_email/urls.py
Normal file
@ -0,0 +1,17 @@
|
||||
"""API URLs"""
|
||||
|
||||
from authentik.stages.authenticator_email.api import (
|
||||
AuthenticatorEmailStageViewSet,
|
||||
EmailAdminDeviceViewSet,
|
||||
EmailDeviceViewSet,
|
||||
)
|
||||
|
||||
api_urlpatterns = [
|
||||
("authenticators/email", EmailDeviceViewSet),
|
||||
(
|
||||
"authenticators/admin/email",
|
||||
EmailAdminDeviceViewSet,
|
||||
"admin-emaildevice",
|
||||
),
|
||||
("stages/authenticator/email", AuthenticatorEmailStageViewSet),
|
||||
]
|
@ -26,10 +26,13 @@ from authentik.events.middleware import audit_ignore
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.flows.stage import StageView
|
||||
from authentik.flows.views.executor import SESSION_KEY_APPLICATION_PRE
|
||||
from authentik.lib.utils.email import mask_email
|
||||
from authentik.lib.utils.time import timedelta_from_string
|
||||
from authentik.root.middleware import ClientIPMiddleware
|
||||
from authentik.stages.authenticator import match_token
|
||||
from authentik.stages.authenticator.models import Device
|
||||
from authentik.stages.authenticator_duo.models import AuthenticatorDuoStage, DuoDevice
|
||||
from authentik.stages.authenticator_email.models import EmailDevice
|
||||
from authentik.stages.authenticator_sms.models import SMSDevice
|
||||
from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage, DeviceClasses
|
||||
from authentik.stages.authenticator_webauthn.models import UserVerification, WebAuthnDevice
|
||||
@ -54,6 +57,8 @@ def get_challenge_for_device(
|
||||
"""Generate challenge for a single device"""
|
||||
if isinstance(device, WebAuthnDevice):
|
||||
return get_webauthn_challenge(request, stage, device)
|
||||
if isinstance(device, EmailDevice):
|
||||
return {"email": mask_email(device.email)}
|
||||
# Code-based challenges have no hints
|
||||
return {}
|
||||
|
||||
@ -103,6 +108,8 @@ def select_challenge(request: HttpRequest, device: Device):
|
||||
"""Callback when the user selected a challenge in the frontend."""
|
||||
if isinstance(device, SMSDevice):
|
||||
select_challenge_sms(request, device)
|
||||
elif isinstance(device, EmailDevice):
|
||||
select_challenge_email(request, device)
|
||||
|
||||
|
||||
def select_challenge_sms(request: HttpRequest, device: SMSDevice):
|
||||
@ -111,6 +118,13 @@ def select_challenge_sms(request: HttpRequest, device: SMSDevice):
|
||||
device.stage.send(device.token, device)
|
||||
|
||||
|
||||
def select_challenge_email(request: HttpRequest, device: EmailDevice):
|
||||
"""Send Email"""
|
||||
valid_secs: int = timedelta_from_string(device.stage.token_expiry).total_seconds()
|
||||
device.generate_token(valid_secs=valid_secs)
|
||||
device.stage.send(device)
|
||||
|
||||
|
||||
def validate_challenge_code(code: str, stage_view: StageView, user: User) -> Device:
|
||||
"""Validate code-based challenges. We test against every device, on purpose, as
|
||||
the user mustn't choose between totp and static devices."""
|
||||
|
@ -0,0 +1,37 @@
|
||||
# Generated by Django 5.0.10 on 2025-01-16 02:48
|
||||
|
||||
import authentik.stages.authenticator_validate.models
|
||||
import django.contrib.postgres.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
(
|
||||
"authentik_stages_authenticator_validate",
|
||||
"0013_authenticatorvalidatestage_webauthn_allowed_device_types",
|
||||
),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="authenticatorvalidatestage",
|
||||
name="device_classes",
|
||||
field=django.contrib.postgres.fields.ArrayField(
|
||||
base_field=models.TextField(
|
||||
choices=[
|
||||
("static", "Static"),
|
||||
("totp", "TOTP"),
|
||||
("webauthn", "WebAuthn"),
|
||||
("duo", "Duo"),
|
||||
("sms", "SMS"),
|
||||
("email", "Email"),
|
||||
]
|
||||
),
|
||||
default=authentik.stages.authenticator_validate.models.default_device_classes,
|
||||
help_text="Device classes which can be used to authenticate",
|
||||
size=None,
|
||||
),
|
||||
),
|
||||
]
|
@ -20,6 +20,7 @@ class DeviceClasses(models.TextChoices):
|
||||
WEBAUTHN = "webauthn", _("WebAuthn")
|
||||
DUO = "duo", _("Duo")
|
||||
SMS = "sms", _("SMS")
|
||||
EMAIL = "email", _("Email")
|
||||
|
||||
|
||||
def default_device_classes() -> list:
|
||||
@ -30,6 +31,7 @@ def default_device_classes() -> list:
|
||||
DeviceClasses.WEBAUTHN,
|
||||
DeviceClasses.DUO,
|
||||
DeviceClasses.SMS,
|
||||
DeviceClasses.EMAIL,
|
||||
]
|
||||
|
||||
|
||||
|
@ -23,6 +23,7 @@ from authentik.flows.stage import ChallengeStageView
|
||||
from authentik.lib.utils.time import timedelta_from_string
|
||||
from authentik.stages.authenticator import devices_for_user
|
||||
from authentik.stages.authenticator.models import Device
|
||||
from authentik.stages.authenticator_email.models import EmailDevice
|
||||
from authentik.stages.authenticator_sms.models import SMSDevice
|
||||
from authentik.stages.authenticator_validate.challenge import (
|
||||
DeviceChallenge,
|
||||
@ -84,7 +85,9 @@ class AuthenticatorValidationChallengeResponse(ChallengeResponse):
|
||||
|
||||
def validate_code(self, code: str) -> str:
|
||||
"""Validate code-based response, raise error if code isn't allowed"""
|
||||
self._challenge_allowed([DeviceClasses.TOTP, DeviceClasses.STATIC, DeviceClasses.SMS])
|
||||
self._challenge_allowed(
|
||||
[DeviceClasses.TOTP, DeviceClasses.STATIC, DeviceClasses.SMS, DeviceClasses.EMAIL]
|
||||
)
|
||||
self.device = validate_challenge_code(code, self.stage, self.stage.get_pending_user())
|
||||
return code
|
||||
|
||||
@ -117,12 +120,17 @@ class AuthenticatorValidationChallengeResponse(ChallengeResponse):
|
||||
if not allowed:
|
||||
raise ValidationError("invalid challenge selected")
|
||||
|
||||
if challenge.get("device_class", "") != "sms":
|
||||
return challenge
|
||||
devices = SMSDevice.objects.filter(pk=int(challenge.get("device_uid", "0")))
|
||||
if not devices.exists():
|
||||
raise ValidationError("invalid challenge selected")
|
||||
select_challenge(self.stage.request, devices.first())
|
||||
device_class = challenge.get("device_class", "")
|
||||
if device_class == "sms":
|
||||
devices = SMSDevice.objects.filter(pk=int(challenge.get("device_uid", "0")))
|
||||
if not devices.exists():
|
||||
raise ValidationError("invalid challenge selected")
|
||||
select_challenge(self.stage.request, devices.first())
|
||||
elif device_class == "email":
|
||||
devices = EmailDevice.objects.filter(pk=int(challenge.get("device_uid", "0")))
|
||||
if not devices.exists():
|
||||
raise ValidationError("invalid challenge selected")
|
||||
select_challenge(self.stage.request, devices.first())
|
||||
return challenge
|
||||
|
||||
def validate_selected_stage(self, stage_pk: str) -> str:
|
||||
|
183
authentik/stages/authenticator_validate/tests/test_email.py
Normal file
183
authentik/stages/authenticator_validate/tests/test_email.py
Normal file
@ -0,0 +1,183 @@
|
||||
"""Test validator stage for Email devices"""
|
||||
|
||||
from django.test.client import RequestFactory
|
||||
from django.urls.base import reverse
|
||||
|
||||
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
|
||||
from authentik.flows.models import FlowStageBinding, NotConfiguredAction
|
||||
from authentik.flows.tests import FlowTestCase
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.lib.utils.email import mask_email
|
||||
from authentik.stages.authenticator_email.models import AuthenticatorEmailStage, EmailDevice
|
||||
from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage, DeviceClasses
|
||||
from authentik.stages.identification.models import IdentificationStage, UserFields
|
||||
|
||||
|
||||
class AuthenticatorValidateStageEmailTests(FlowTestCase):
|
||||
"""Test validator stage for Email devices"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.user = create_test_admin_user()
|
||||
self.request_factory = RequestFactory()
|
||||
# Create email authenticator stage
|
||||
self.stage = AuthenticatorEmailStage.objects.create(
|
||||
name="email-authenticator",
|
||||
use_global_settings=True,
|
||||
from_address="test@authentik.local",
|
||||
)
|
||||
# Create identification stage
|
||||
self.ident_stage = IdentificationStage.objects.create(
|
||||
name=generate_id(),
|
||||
user_fields=[UserFields.USERNAME],
|
||||
)
|
||||
# Create validation stage
|
||||
self.validate_stage = AuthenticatorValidateStage.objects.create(
|
||||
name=generate_id(),
|
||||
device_classes=[DeviceClasses.EMAIL],
|
||||
)
|
||||
# Create flow with both stages
|
||||
self.flow = create_test_flow()
|
||||
FlowStageBinding.objects.create(target=self.flow, stage=self.ident_stage, order=0)
|
||||
FlowStageBinding.objects.create(target=self.flow, stage=self.validate_stage, order=1)
|
||||
|
||||
def _identify_user(self):
|
||||
"""Helper to identify user in flow"""
|
||||
response = self.client.post(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
|
||||
{"uid_field": self.user.username},
|
||||
follow=True,
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
return response
|
||||
|
||||
def _send_challenge(self, device):
|
||||
"""Helper to send challenge for device"""
|
||||
response = self.client.post(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
|
||||
{
|
||||
"component": "ak-stage-authenticator-validate",
|
||||
"selected_challenge": {
|
||||
"device_class": "email",
|
||||
"device_uid": str(device.pk),
|
||||
"challenge": {},
|
||||
"last_used": device.last_used.isoformat() if device.last_used else None,
|
||||
},
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
return response
|
||||
|
||||
def test_happy_path(self):
|
||||
"""Test validator stage with valid code"""
|
||||
# Create a device for our user
|
||||
device = EmailDevice.objects.create(
|
||||
user=self.user,
|
||||
confirmed=True,
|
||||
stage=self.stage,
|
||||
email="xx@0.co",
|
||||
) # Short email for testing purposes
|
||||
|
||||
# First identify the user
|
||||
self._identify_user()
|
||||
|
||||
# Send the challenge
|
||||
response = self._send_challenge(device)
|
||||
response_data = self.assertStageResponse(
|
||||
response,
|
||||
flow=self.flow,
|
||||
component="ak-stage-authenticator-validate",
|
||||
)
|
||||
|
||||
# Get the device challenge from the response and verify it matches
|
||||
device_challenge = response_data["device_challenges"][0]
|
||||
self.assertEqual(device_challenge["device_class"], "email")
|
||||
self.assertEqual(device_challenge["device_uid"], str(device.pk))
|
||||
self.assertEqual(device_challenge["challenge"], {"email": mask_email(device.email)})
|
||||
|
||||
# Generate a token for the device
|
||||
device.generate_token()
|
||||
|
||||
# Submit the valid code
|
||||
response = self.client.post(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
|
||||
{"component": "ak-stage-authenticator-validate", "code": device.token},
|
||||
)
|
||||
# Should redirect to root since this is the last stage
|
||||
self.assertStageRedirects(response, "/")
|
||||
|
||||
def test_no_device(self):
|
||||
"""Test validator stage without configured device"""
|
||||
configuration_stage = AuthenticatorEmailStage.objects.create(
|
||||
name=generate_id(),
|
||||
use_global_settings=True,
|
||||
from_address="test@authentik.local",
|
||||
)
|
||||
stage = AuthenticatorValidateStage.objects.create(
|
||||
name=generate_id(),
|
||||
not_configured_action=NotConfiguredAction.CONFIGURE,
|
||||
device_classes=[DeviceClasses.EMAIL],
|
||||
)
|
||||
stage.configuration_stages.set([configuration_stage])
|
||||
flow = create_test_flow()
|
||||
FlowStageBinding.objects.create(target=flow, stage=stage, order=2)
|
||||
|
||||
response = self.client.post(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
|
||||
{"component": "ak-stage-authenticator-validate"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
response_data = self.assertStageResponse(
|
||||
response,
|
||||
flow=flow,
|
||||
component="ak-stage-authenticator-validate",
|
||||
)
|
||||
self.assertEqual(response_data["configuration_stages"], [])
|
||||
self.assertEqual(response_data["device_challenges"], [])
|
||||
self.assertEqual(
|
||||
response_data["response_errors"],
|
||||
{"non_field_errors": [{"code": "invalid", "string": "Empty response"}]},
|
||||
)
|
||||
|
||||
def test_invalid_code(self):
|
||||
"""Test validator stage with invalid code"""
|
||||
# Create a device for our user
|
||||
device = EmailDevice.objects.create(
|
||||
user=self.user,
|
||||
confirmed=True,
|
||||
stage=self.stage,
|
||||
email="test@authentik.local",
|
||||
)
|
||||
|
||||
# First identify the user
|
||||
self._identify_user()
|
||||
|
||||
# Send the challenge
|
||||
self._send_challenge(device)
|
||||
|
||||
# Generate a token for the device
|
||||
device.generate_token()
|
||||
|
||||
# Try invalid code and verify error message
|
||||
response = self.client.post(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
|
||||
{"component": "ak-stage-authenticator-validate", "code": "invalid"},
|
||||
)
|
||||
response_data = self.assertStageResponse(
|
||||
response,
|
||||
flow=self.flow,
|
||||
component="ak-stage-authenticator-validate",
|
||||
)
|
||||
self.assertEqual(
|
||||
response_data["response_errors"],
|
||||
{
|
||||
"code": [
|
||||
{
|
||||
"code": "invalid",
|
||||
"string": (
|
||||
"Invalid Token. Please ensure the time on your device "
|
||||
"is accurate and try again."
|
||||
),
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
@ -17,7 +17,7 @@ from rest_framework.serializers import ValidationError
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.flows.challenge import Challenge, ChallengeResponse
|
||||
from authentik.flows.exceptions import StageInvalidException
|
||||
from authentik.flows.models import FlowDesignation, FlowToken
|
||||
from authentik.flows.models import FlowAuthenticationRequirement, FlowToken
|
||||
from authentik.flows.planner import PLAN_CONTEXT_IS_RESTORED, PLAN_CONTEXT_PENDING_USER
|
||||
from authentik.flows.stage import ChallengeStageView
|
||||
from authentik.flows.views.executor import QS_KEY_TOKEN, QS_QUERY
|
||||
@ -97,14 +97,27 @@ class EmailStageView(ChallengeStageView):
|
||||
"""Helper function that sends the actual email. Implies that you've
|
||||
already checked that there is a pending user."""
|
||||
pending_user = self.get_pending_user()
|
||||
if not pending_user.pk and self.executor.flow.designation == FlowDesignation.RECOVERY:
|
||||
# Pending user does not have a primary key, and we're in a recovery flow,
|
||||
# which means the user entered an invalid identifier, so we pretend to send the
|
||||
# email, to not disclose if the user exists
|
||||
return
|
||||
email = self.executor.plan.context.get(PLAN_CONTEXT_EMAIL_OVERRIDE, None)
|
||||
email = self.executor.plan.context.get(PLAN_CONTEXT_EMAIL_OVERRIDE, pending_user.email)
|
||||
if FlowAuthenticationRequirement(
|
||||
self.executor.flow.authentication
|
||||
).possibly_unauthenticated:
|
||||
# In possibly unauthenticated flows, do not disclose whether user or their email exists
|
||||
# to prevent enumeration attacks
|
||||
if not pending_user.pk:
|
||||
self.logger.debug(
|
||||
"User object does not exist. Email not sent.", pending_user=pending_user
|
||||
)
|
||||
return
|
||||
if not email:
|
||||
self.logger.debug(
|
||||
"No recipient email address could be determined. Email not sent.",
|
||||
pending_user=pending_user,
|
||||
)
|
||||
return
|
||||
if not email:
|
||||
email = pending_user.email
|
||||
raise StageInvalidException(
|
||||
"No recipient email address could be determined. Email not sent."
|
||||
)
|
||||
current_stage: EmailStage = self.executor.current_stage
|
||||
token = self.get_token()
|
||||
# Send mail to user
|
||||
@ -133,7 +146,9 @@ class EmailStageView(ChallengeStageView):
|
||||
|
||||
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||
# Check if the user came back from the email link to verify
|
||||
restore_token: FlowToken = self.executor.plan.context.get(PLAN_CONTEXT_IS_RESTORED, None)
|
||||
restore_token: FlowToken | None = self.executor.plan.context.get(
|
||||
PLAN_CONTEXT_IS_RESTORED, None
|
||||
)
|
||||
user = self.get_pending_user()
|
||||
if restore_token:
|
||||
if restore_token.user != user:
|
||||
|
@ -13,17 +13,28 @@ from structlog.stdlib import get_logger
|
||||
from authentik.events.models import Event, EventAction, TaskStatus
|
||||
from authentik.events.system_tasks import SystemTask
|
||||
from authentik.root.celery import CELERY_APP
|
||||
from authentik.stages.authenticator_email.models import AuthenticatorEmailStage
|
||||
from authentik.stages.email.models import EmailStage
|
||||
from authentik.stages.email.utils import logo_data
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
def send_mails(stage: EmailStage, *messages: list[EmailMultiAlternatives]):
|
||||
"""Wrapper to convert EmailMessage to dict and send it from worker"""
|
||||
def send_mails(
|
||||
stage: EmailStage | AuthenticatorEmailStage, *messages: list[EmailMultiAlternatives]
|
||||
):
|
||||
"""Wrapper to convert EmailMessage to dict and send it from worker
|
||||
|
||||
Args:
|
||||
stage: Either an EmailStage or AuthenticatorEmailStage instance
|
||||
messages: List of email messages to send
|
||||
Returns:
|
||||
Celery group promise for the email sending tasks
|
||||
"""
|
||||
tasks = []
|
||||
stage_class = stage.__class__
|
||||
for message in messages:
|
||||
tasks.append(send_mail.s(message.__dict__, str(stage.pk)))
|
||||
tasks.append(send_mail.s(message.__dict__, stage_class, str(stage.pk)))
|
||||
lazy_group = group(*tasks)
|
||||
promise = lazy_group()
|
||||
return promise
|
||||
@ -47,23 +58,28 @@ def get_email_body(email: EmailMultiAlternatives) -> str:
|
||||
retry_backoff=True,
|
||||
base=SystemTask,
|
||||
)
|
||||
def send_mail(self: SystemTask, message: dict[Any, Any], email_stage_pk: str | None = None):
|
||||
def send_mail(
|
||||
self: SystemTask,
|
||||
message: dict[Any, Any],
|
||||
stage_class: EmailStage | AuthenticatorEmailStage = EmailStage,
|
||||
email_stage_pk: str | None = None,
|
||||
):
|
||||
"""Send Email for Email Stage. Retries are scheduled automatically."""
|
||||
self.save_on_success = False
|
||||
message_id = make_msgid(domain=DNS_NAME)
|
||||
self.set_uid(slugify(message_id.replace(".", "_").replace("@", "_")))
|
||||
try:
|
||||
if not email_stage_pk:
|
||||
stage: EmailStage = EmailStage(use_global_settings=True)
|
||||
stage: EmailStage | AuthenticatorEmailStage = stage_class(use_global_settings=True)
|
||||
else:
|
||||
stages = EmailStage.objects.filter(pk=email_stage_pk)
|
||||
stages = stage_class.objects.filter(pk=email_stage_pk)
|
||||
if not stages.exists():
|
||||
self.set_status(
|
||||
TaskStatus.WARNING,
|
||||
"Email stage does not exist anymore. Discarding message.",
|
||||
)
|
||||
return
|
||||
stage: EmailStage = stages.first()
|
||||
stage: EmailStage | AuthenticatorEmailStage = stages.first()
|
||||
try:
|
||||
backend = stage.backend
|
||||
except ValueError as exc:
|
||||
|
30
blueprints/example/flows-authenticator-email-setup.yaml
Normal file
30
blueprints/example/flows-authenticator-email-setup.yaml
Normal file
@ -0,0 +1,30 @@
|
||||
version: 1
|
||||
metadata:
|
||||
labels:
|
||||
blueprints.goauthentik.io/instantiate: "false"
|
||||
name: Example - Email MFA setup flow
|
||||
entries:
|
||||
- attrs:
|
||||
designation: stage_configuration
|
||||
name: Default Email Authenticator Flow
|
||||
title: Setup Email Two-Factor Authentication
|
||||
authentication: require_authenticated
|
||||
identifiers:
|
||||
slug: default-authenticator-email-setup
|
||||
model: authentik_flows.flow
|
||||
id: flow
|
||||
- attrs:
|
||||
configure_flow: !KeyOf flow
|
||||
friendly_name: Email Authenticator
|
||||
use_global_settings: true
|
||||
token_expiry: minutes=30
|
||||
subject: authentik Sign-in code
|
||||
identifiers:
|
||||
name: default-authenticator-email-setup
|
||||
id: default-authenticator-email-setup
|
||||
model: authentik_stages_authenticator_email.authenticatoremailstage
|
||||
- identifiers:
|
||||
order: 0
|
||||
stage: !KeyOf default-authenticator-email-setup
|
||||
target: !KeyOf flow
|
||||
model: authentik_flows.flowstagebinding
|
File diff suppressed because it is too large
Load Diff
185
cmd/scim/main.go
185
cmd/scim/main.go
@ -1,185 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/mitchellh/mapstructure"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"goauthentik.io/internal/common"
|
||||
"goauthentik.io/internal/debug"
|
||||
"goauthentik.io/internal/outpost/ak"
|
||||
"goauthentik.io/internal/outpost/ak/healthcheck"
|
||||
)
|
||||
|
||||
const helpMessage = `authentik SCIM
|
||||
|
||||
Required environment variables:
|
||||
- AUTHENTIK_HOST: URL to connect to (format "http://authentik.company")
|
||||
- AUTHENTIK_TOKEN: Token to authenticate with
|
||||
- AUTHENTIK_INSECURE: Skip SSL Certificate verification`
|
||||
|
||||
var rootCmd = &cobra.Command{
|
||||
Long: helpMessage,
|
||||
PersistentPreRun: func(cmd *cobra.Command, args []string) {
|
||||
log.SetLevel(log.DebugLevel)
|
||||
log.SetFormatter(&log.JSONFormatter{
|
||||
FieldMap: log.FieldMap{
|
||||
log.FieldKeyMsg: "event",
|
||||
log.FieldKeyTime: "timestamp",
|
||||
},
|
||||
DisableHTMLEscape: true,
|
||||
})
|
||||
},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
debug.EnableDebugServer()
|
||||
akURL, found := os.LookupEnv("AUTHENTIK_HOST")
|
||||
if !found {
|
||||
fmt.Println("env AUTHENTIK_HOST not set!")
|
||||
fmt.Println(helpMessage)
|
||||
os.Exit(1)
|
||||
}
|
||||
akToken, found := os.LookupEnv("AUTHENTIK_TOKEN")
|
||||
if !found {
|
||||
fmt.Println("env AUTHENTIK_TOKEN not set!")
|
||||
fmt.Println(helpMessage)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
akURLActual, err := url.Parse(akURL)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
fmt.Println(helpMessage)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
ex := common.Init()
|
||||
defer common.Defer()
|
||||
go func() {
|
||||
for {
|
||||
<-ex
|
||||
os.Exit(0)
|
||||
}
|
||||
}()
|
||||
|
||||
ac := ak.NewAPIController(*akURLActual, akToken)
|
||||
if ac == nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
defer ac.Shutdown()
|
||||
|
||||
ac.Server = &SCIMOutpost{
|
||||
ac: ac,
|
||||
log: log.WithField("logger", "authentik.outpost.scim"),
|
||||
}
|
||||
|
||||
err = ac.Start()
|
||||
if err != nil {
|
||||
log.WithError(err).Panic("Failed to run server")
|
||||
}
|
||||
|
||||
for {
|
||||
<-ex
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
type HTTPRequest struct {
|
||||
Uid string `mapstructure:"uid"`
|
||||
Method string `mapstructure:"method"`
|
||||
URL string `mapstructure:"url"`
|
||||
Headers map[string][]string `mapstructure:"headers"`
|
||||
Body interface{} `mapstructure:"body"`
|
||||
SSLVerify bool `mapstructure:"ssl_verify"`
|
||||
Timeout int `mapstructure:"timeout"`
|
||||
}
|
||||
|
||||
type RequestArgs struct {
|
||||
Request HTTPRequest `mapstructure:"request"`
|
||||
ResponseChannel string `mapstructure:"response_channel"`
|
||||
}
|
||||
|
||||
type SCIMOutpost struct {
|
||||
ac *ak.APIController
|
||||
log *log.Entry
|
||||
}
|
||||
|
||||
func (s *SCIMOutpost) Type() string { return "SCIM" }
|
||||
func (s *SCIMOutpost) Stop() error { return nil }
|
||||
func (s *SCIMOutpost) Refresh() error { return nil }
|
||||
func (s *SCIMOutpost) TimerFlowCacheExpiry(context.Context) {}
|
||||
|
||||
func (s *SCIMOutpost) Start() error {
|
||||
s.ac.AddWSHandler(func(ctx context.Context, args map[string]interface{}) {
|
||||
rd := RequestArgs{}
|
||||
err := mapstructure.Decode(args, &rd)
|
||||
if err != nil {
|
||||
s.log.WithError(err).Warning("failed to parse http request")
|
||||
return
|
||||
}
|
||||
s.log.WithField("rd", rd).WithField("raw", args).Debug("request data")
|
||||
ctx, canc := context.WithTimeout(ctx, time.Duration(rd.Request.Timeout)*time.Second)
|
||||
defer canc()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, rd.Request.Method, rd.Request.URL, nil)
|
||||
if err != nil {
|
||||
s.log.WithError(err).Warning("failed to create request")
|
||||
return
|
||||
}
|
||||
|
||||
tr := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: !rd.Request.SSLVerify},
|
||||
TLSHandshakeTimeout: time.Duration(rd.Request.Timeout) * time.Second,
|
||||
IdleConnTimeout: time.Duration(rd.Request.Timeout) * time.Second,
|
||||
ResponseHeaderTimeout: time.Duration(rd.Request.Timeout) * time.Second,
|
||||
ExpectContinueTimeout: time.Duration(rd.Request.Timeout) * time.Second,
|
||||
}
|
||||
c := &http.Client{
|
||||
Transport: tr,
|
||||
}
|
||||
s.log.WithField("url", req.URL.Host).Debug("sending HTTP request")
|
||||
res, err := c.Do(req)
|
||||
if err != nil {
|
||||
s.log.WithError(err).Warning("failed to send request")
|
||||
return
|
||||
}
|
||||
body, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
s.log.WithError(err).Warning("failed to read body")
|
||||
return
|
||||
}
|
||||
s.log.WithField("res", res.StatusCode).Debug("sending HTTP response")
|
||||
err = s.ac.SendWS(ak.WebsocketInstructionProviderSpecific, map[string]interface{}{
|
||||
"sub_type": "http_response",
|
||||
"response_channel": rd.ResponseChannel,
|
||||
"response": map[string]interface{}{
|
||||
"status": res.StatusCode,
|
||||
"final_url": res.Request.URL.String(),
|
||||
"headers": res.Header,
|
||||
"body": base64.StdEncoding.EncodeToString(body),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
s.log.WithError(err).Warning("failed to send http response")
|
||||
return
|
||||
}
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
rootCmd.AddCommand(healthcheck.Command)
|
||||
err := rootCmd.Execute()
|
||||
if err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
6
go.mod
6
go.mod
@ -26,10 +26,10 @@ require (
|
||||
github.com/redis/go-redis/v9 v9.7.0
|
||||
github.com/sethvargo/go-envconfig v1.1.1
|
||||
github.com/sirupsen/logrus v1.9.3
|
||||
github.com/spf13/cobra v1.8.1
|
||||
github.com/spf13/cobra v1.9.1
|
||||
github.com/stretchr/testify v1.10.0
|
||||
github.com/wwt/guac v1.3.2
|
||||
goauthentik.io/api/v3 v3.2024123.4
|
||||
goauthentik.io/api/v3 v3.2024123.6
|
||||
golang.org/x/exp v0.0.0-20230210204819-062eb4c674ab
|
||||
golang.org/x/oauth2 v0.26.0
|
||||
golang.org/x/sync v0.11.0
|
||||
@ -71,7 +71,7 @@ require (
|
||||
github.com/prometheus/client_model v0.6.1 // indirect
|
||||
github.com/prometheus/common v0.55.0 // indirect
|
||||
github.com/prometheus/procfs v0.15.1 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
github.com/spf13/pflag v1.0.6 // indirect
|
||||
go.mongodb.org/mongo-driver v1.14.0 // indirect
|
||||
go.opentelemetry.io/otel v1.24.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.24.0 // indirect
|
||||
|
14
go.sum
14
go.sum
@ -57,7 +57,7 @@ github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDk
|
||||
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||
github.com/coreos/go-oidc/v3 v3.12.0 h1:sJk+8G2qq94rDI6ehZ71Bol3oUHy63qNYmkiSjrc/Jo=
|
||||
github.com/coreos/go-oidc/v3 v3.12.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@ -259,10 +259,10 @@ github.com/sethvargo/go-envconfig v1.1.1/go.mod h1:JLd0KFWQYzyENqnEPWWZ49i4vzZo/
|
||||
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
|
||||
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
|
||||
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
|
||||
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
||||
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
@ -299,8 +299,8 @@ go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y
|
||||
go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
goauthentik.io/api/v3 v3.2024123.4 h1:JYLsUjkJ7kT+jHO72DyFTXFwKEGAcOOlLh36SRG9BDw=
|
||||
goauthentik.io/api/v3 v3.2024123.4/go.mod h1:zz+mEZg8rY/7eEjkMGWJ2DnGqk+zqxuybGCGrR2O4Kw=
|
||||
goauthentik.io/api/v3 v3.2024123.6 h1:AGOCa7Fc/9eONCPEW4sEhTiyEBvxN57Lfqz1zm6Gy98=
|
||||
goauthentik.io/api/v3 v3.2024123.6/go.mod h1:zz+mEZg8rY/7eEjkMGWJ2DnGqk+zqxuybGCGrR2O4Kw=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
|
@ -95,7 +95,7 @@ func NewAPIController(akURL url.URL, token string) *APIController {
|
||||
time.Sleep(time.Second * 3)
|
||||
}
|
||||
if len(outposts.Results) < 1 {
|
||||
panic("No outposts found with given token, ensure the given token corresponds to an authentik Outpost")
|
||||
panic("No outposts found with given token, ensure the given token corresponds to an authenitk Outpost")
|
||||
}
|
||||
outpost := outposts.Results[0]
|
||||
|
||||
|
@ -233,19 +233,15 @@ func (a *APIController) AddWSHandler(handler WSHandler) {
|
||||
a.wsHandlers = append(a.wsHandlers, handler)
|
||||
}
|
||||
|
||||
func (a *APIController) SendWS(inst WebsocketInstruction, args map[string]interface{}) error {
|
||||
msg := websocketMessage{
|
||||
Instruction: inst,
|
||||
Args: args,
|
||||
}
|
||||
err := a.wsConn.WriteJSON(msg)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *APIController) SendWSHello(args map[string]interface{}) error {
|
||||
allArgs := a.getWebsocketPingArgs()
|
||||
for key, value := range args {
|
||||
allArgs[key] = value
|
||||
}
|
||||
return a.SendWS(WebsocketInstructionHello, args)
|
||||
aliveMsg := websocketMessage{
|
||||
Instruction: WebsocketInstructionHello,
|
||||
Args: allArgs,
|
||||
}
|
||||
err := a.wsConn.WriteJSON(aliveMsg)
|
||||
return err
|
||||
}
|
||||
|
@ -1,19 +1,19 @@
|
||||
package ak
|
||||
|
||||
type WebsocketInstruction int
|
||||
type websocketInstruction int
|
||||
|
||||
const (
|
||||
// WebsocketInstructionAck Code used to acknowledge a previous message
|
||||
WebsocketInstructionAck WebsocketInstruction = 0
|
||||
WebsocketInstructionAck websocketInstruction = 0
|
||||
// WebsocketInstructionHello Code used to send a healthcheck keepalive
|
||||
WebsocketInstructionHello WebsocketInstruction = 1
|
||||
WebsocketInstructionHello websocketInstruction = 1
|
||||
// WebsocketInstructionTriggerUpdate Code received to trigger a config update
|
||||
WebsocketInstructionTriggerUpdate WebsocketInstruction = 2
|
||||
WebsocketInstructionTriggerUpdate websocketInstruction = 2
|
||||
// WebsocketInstructionProviderSpecific Code received to trigger some provider specific function
|
||||
WebsocketInstructionProviderSpecific WebsocketInstruction = 3
|
||||
WebsocketInstructionProviderSpecific websocketInstruction = 3
|
||||
)
|
||||
|
||||
type websocketMessage struct {
|
||||
Instruction WebsocketInstruction `json:"instruction"`
|
||||
Instruction websocketInstruction `json:"instruction"`
|
||||
Args map[string]interface{} `json:"args"`
|
||||
}
|
||||
|
8
lifecycle/aws/package-lock.json
generated
8
lifecycle/aws/package-lock.json
generated
@ -9,7 +9,7 @@
|
||||
"version": "0.0.0",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"aws-cdk": "^2.178.2",
|
||||
"aws-cdk": "^2.179.0",
|
||||
"cross-env": "^7.0.3"
|
||||
},
|
||||
"engines": {
|
||||
@ -17,9 +17,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/aws-cdk": {
|
||||
"version": "2.178.2",
|
||||
"resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.178.2.tgz",
|
||||
"integrity": "sha512-ojMCMnBGinvDUD6+BOOlUOB9pjsYXoQdFVbf4bvi3dy3nwn557r0j6qDUcJMeikzPJ6YWzfAdL0fYxBZg4xcOg==",
|
||||
"version": "2.179.0",
|
||||
"resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.179.0.tgz",
|
||||
"integrity": "sha512-aA2+8S2g4UBQHkUEt0mYd16VLt/ucR+QfyUJi34LDKRAhOCNDjPCZ4z9z/JEDyuni0BdzsYA55pnpDN9tMULpA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
|
@ -10,7 +10,7 @@
|
||||
"node": ">=20"
|
||||
},
|
||||
"devDependencies": {
|
||||
"aws-cdk": "^2.178.2",
|
||||
"aws-cdk": "^2.179.0",
|
||||
"cross-env": "^7.0.3"
|
||||
}
|
||||
}
|
||||
|
Binary file not shown.
@ -8,7 +8,7 @@
|
||||
# 刘松, 2022
|
||||
# Tianhao Chai <cth451@gmail.com>, 2024
|
||||
# Jens L. <jens@goauthentik.io>, 2024
|
||||
# deluxghost, 2024
|
||||
# deluxghost, 2025
|
||||
#
|
||||
#, fuzzy
|
||||
msgid ""
|
||||
@ -17,7 +17,7 @@ msgstr ""
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-02-14 14:49+0000\n"
|
||||
"PO-Revision-Date: 2022-09-26 16:47+0000\n"
|
||||
"Last-Translator: deluxghost, 2024\n"
|
||||
"Last-Translator: deluxghost, 2025\n"
|
||||
"Language-Team: Chinese Simplified (https://app.transifex.com/authentik/teams/119923/zh-Hans/)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
@ -568,39 +568,39 @@ msgstr "签名密钥"
|
||||
|
||||
#: authentik/enterprise/providers/ssf/models.py
|
||||
msgid "Key used to sign the SSF Events."
|
||||
msgstr ""
|
||||
msgstr "用于签名 SSF 时间的密钥。"
|
||||
|
||||
#: authentik/enterprise/providers/ssf/models.py
|
||||
msgid "Shared Signals Framework Provider"
|
||||
msgstr ""
|
||||
msgstr "Shared Signals Framework 提供程序"
|
||||
|
||||
#: authentik/enterprise/providers/ssf/models.py
|
||||
msgid "Shared Signals Framework Providers"
|
||||
msgstr ""
|
||||
msgstr "Shared Signals Framework 提供程序"
|
||||
|
||||
#: authentik/enterprise/providers/ssf/models.py
|
||||
msgid "Add stream to SSF provider"
|
||||
msgstr ""
|
||||
msgstr "向 SSF 提供程序添加流"
|
||||
|
||||
#: authentik/enterprise/providers/ssf/models.py
|
||||
msgid "SSF Stream"
|
||||
msgstr ""
|
||||
msgstr "SSF 流"
|
||||
|
||||
#: authentik/enterprise/providers/ssf/models.py
|
||||
msgid "SSF Streams"
|
||||
msgstr ""
|
||||
msgstr "SSF 流"
|
||||
|
||||
#: authentik/enterprise/providers/ssf/models.py
|
||||
msgid "SSF Stream Event"
|
||||
msgstr ""
|
||||
msgstr "SSF 流事件"
|
||||
|
||||
#: authentik/enterprise/providers/ssf/models.py
|
||||
msgid "SSF Stream Events"
|
||||
msgstr ""
|
||||
msgstr "SSF 流事件"
|
||||
|
||||
#: authentik/enterprise/providers/ssf/tasks.py
|
||||
msgid "Failed to send request"
|
||||
msgstr ""
|
||||
msgstr "发送请求失败"
|
||||
|
||||
#: authentik/enterprise/stages/authenticator_endpoint_gdtc/models.py
|
||||
msgid "Endpoint Authenticator Google Device Trust Connector Stage"
|
||||
@ -878,7 +878,7 @@ msgstr "在流程规划过程中评估策略。"
|
||||
|
||||
#: authentik/flows/models.py
|
||||
msgid "Evaluate policies when the Stage is presented to the user."
|
||||
msgstr ""
|
||||
msgstr "在阶段呈现给用户时评估策略。"
|
||||
|
||||
#: authentik/flows/models.py
|
||||
msgid ""
|
||||
|
@ -7,7 +7,7 @@
|
||||
# Chen Zhikai, 2022
|
||||
# 刘松, 2022
|
||||
# Jens L. <jens@goauthentik.io>, 2024
|
||||
# deluxghost, 2024
|
||||
# deluxghost, 2025
|
||||
#
|
||||
#, fuzzy
|
||||
msgid ""
|
||||
@ -16,7 +16,7 @@ msgstr ""
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-02-14 14:49+0000\n"
|
||||
"PO-Revision-Date: 2022-09-26 16:47+0000\n"
|
||||
"Last-Translator: deluxghost, 2024\n"
|
||||
"Last-Translator: deluxghost, 2025\n"
|
||||
"Language-Team: Chinese (China) (https://app.transifex.com/authentik/teams/119923/zh_CN/)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
@ -567,39 +567,39 @@ msgstr "签名密钥"
|
||||
|
||||
#: authentik/enterprise/providers/ssf/models.py
|
||||
msgid "Key used to sign the SSF Events."
|
||||
msgstr ""
|
||||
msgstr "用于签名 SSF 时间的密钥。"
|
||||
|
||||
#: authentik/enterprise/providers/ssf/models.py
|
||||
msgid "Shared Signals Framework Provider"
|
||||
msgstr ""
|
||||
msgstr "Shared Signals Framework 提供程序"
|
||||
|
||||
#: authentik/enterprise/providers/ssf/models.py
|
||||
msgid "Shared Signals Framework Providers"
|
||||
msgstr ""
|
||||
msgstr "Shared Signals Framework 提供程序"
|
||||
|
||||
#: authentik/enterprise/providers/ssf/models.py
|
||||
msgid "Add stream to SSF provider"
|
||||
msgstr ""
|
||||
msgstr "向 SSF 提供程序添加流"
|
||||
|
||||
#: authentik/enterprise/providers/ssf/models.py
|
||||
msgid "SSF Stream"
|
||||
msgstr ""
|
||||
msgstr "SSF 流"
|
||||
|
||||
#: authentik/enterprise/providers/ssf/models.py
|
||||
msgid "SSF Streams"
|
||||
msgstr ""
|
||||
msgstr "SSF 流"
|
||||
|
||||
#: authentik/enterprise/providers/ssf/models.py
|
||||
msgid "SSF Stream Event"
|
||||
msgstr ""
|
||||
msgstr "SSF 流事件"
|
||||
|
||||
#: authentik/enterprise/providers/ssf/models.py
|
||||
msgid "SSF Stream Events"
|
||||
msgstr ""
|
||||
msgstr "SSF 流事件"
|
||||
|
||||
#: authentik/enterprise/providers/ssf/tasks.py
|
||||
msgid "Failed to send request"
|
||||
msgstr ""
|
||||
msgstr "发送请求失败"
|
||||
|
||||
#: authentik/enterprise/stages/authenticator_endpoint_gdtc/models.py
|
||||
msgid "Endpoint Authenticator Google Device Trust Connector Stage"
|
||||
@ -877,7 +877,7 @@ msgstr "在流程规划过程中评估策略。"
|
||||
|
||||
#: authentik/flows/models.py
|
||||
msgid "Evaluate policies when the Stage is presented to the user."
|
||||
msgstr ""
|
||||
msgstr "在阶段呈现给用户时评估策略。"
|
||||
|
||||
#: authentik/flows/models.py
|
||||
msgid ""
|
||||
|
88
poetry.lock
generated
88
poetry.lock
generated
@ -358,22 +358,6 @@ jsii = ">=1.105.0,<2.0.0"
|
||||
publication = ">=0.0.3"
|
||||
typeguard = ">=2.13.3,<4.3.0"
|
||||
|
||||
[[package]]
|
||||
name = "aws-cdk-asset-kubectl-v20"
|
||||
version = "2.1.3"
|
||||
description = "A Lambda Layer that contains kubectl v1.20"
|
||||
optional = false
|
||||
python-versions = "~=3.8"
|
||||
files = [
|
||||
{file = "aws_cdk.asset_kubectl_v20-2.1.3-py3-none-any.whl", hash = "sha256:d5612e5bd03c215a28ce53193b1144ecf4e93b3b6779563c046a8a74d83a3979"},
|
||||
{file = "aws_cdk_asset_kubectl_v20-2.1.3.tar.gz", hash = "sha256:237cd8530d9e8be0bbc7159af927dbb6b7f91bf3f4099c8ef4d9a213b34264be"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
jsii = ">=1.103.1,<2.0.0"
|
||||
publication = ">=0.0.3"
|
||||
typeguard = ">=2.13.3,<5.0.0"
|
||||
|
||||
[[package]]
|
||||
name = "aws-cdk-asset-node-proxy-agent-v6"
|
||||
version = "2.1.0"
|
||||
@ -408,18 +392,17 @@ typeguard = ">=2.13.3,<4.3.0"
|
||||
|
||||
[[package]]
|
||||
name = "aws-cdk-lib"
|
||||
version = "2.178.2"
|
||||
version = "2.179.0"
|
||||
description = "Version 2 of the AWS Cloud Development Kit library"
|
||||
optional = false
|
||||
python-versions = "~=3.8"
|
||||
files = [
|
||||
{file = "aws_cdk_lib-2.178.2-py3-none-any.whl", hash = "sha256:624383e57fe2b32f7d0fc098b78b4cd21d19ae3af3f24b01f32ec4795baaee25"},
|
||||
{file = "aws_cdk_lib-2.178.2.tar.gz", hash = "sha256:c00757885b74023350bb34f388f6447155e802ecf827e595bda917098a4925fe"},
|
||||
{file = "aws_cdk_lib-2.179.0-py3-none-any.whl", hash = "sha256:1d7b88ee69067b8d58dac9eeb6697bbaf5d5c032a3070898389c41e7c4f3e3d7"},
|
||||
{file = "aws_cdk_lib-2.179.0.tar.gz", hash = "sha256:b653a55754f4020a4b36e4ae183d213e76e27b18b842cbf9e430e9eccb700550"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
"aws-cdk.asset-awscli-v1" = ">=2.2.208,<3.0.0"
|
||||
"aws-cdk.asset-kubectl-v20" = ">=2.1.3,<3.0.0"
|
||||
"aws-cdk.asset-node-proxy-agent-v6" = ">=2.1.0,<3.0.0"
|
||||
"aws-cdk.cloud-assembly-schema" = ">=39.2.0,<40.0.0"
|
||||
constructs = ">=10.0.0,<11.0.0"
|
||||
@ -466,13 +449,13 @@ typing-extensions = ">=4.0.0"
|
||||
|
||||
[[package]]
|
||||
name = "bandit"
|
||||
version = "1.8.2"
|
||||
version = "1.8.3"
|
||||
description = "Security oriented static analyser for python code."
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
files = [
|
||||
{file = "bandit-1.8.2-py3-none-any.whl", hash = "sha256:df6146ad73dd30e8cbda4e29689ddda48364e36ff655dbfc86998401fcf1721f"},
|
||||
{file = "bandit-1.8.2.tar.gz", hash = "sha256:e00ad5a6bc676c0954669fe13818024d66b70e42cf5adb971480cf3b671e835f"},
|
||||
{file = "bandit-1.8.3-py3-none-any.whl", hash = "sha256:28f04dc0d258e1dd0f99dee8eefa13d1cb5e3fde1a5ab0c523971f97b289bcd8"},
|
||||
{file = "bandit-1.8.3.tar.gz", hash = "sha256:f5847beb654d309422985c36644649924e0ea4425c76dec2e89110b87506193a"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@ -1417,13 +1400,13 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "django-filter"
|
||||
version = "24.3"
|
||||
version = "25.1"
|
||||
description = "Django-filter is a reusable Django application for allowing users to filter querysets dynamically."
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
python-versions = ">=3.9"
|
||||
files = [
|
||||
{file = "django_filter-24.3-py3-none-any.whl", hash = "sha256:c4852822928ce17fb699bcfccd644b3574f1a2d80aeb2b4ff4f16b02dd49dc64"},
|
||||
{file = "django_filter-24.3.tar.gz", hash = "sha256:d8ccaf6732afd21ca0542f6733b11591030fa98669f8d15599b358e24a2cd9c3"},
|
||||
{file = "django_filter-25.1-py3-none-any.whl", hash = "sha256:4fa48677cf5857b9b1347fed23e355ea792464e0fe07244d1fdfb8a806215b80"},
|
||||
{file = "django_filter-25.1.tar.gz", hash = "sha256:1ec9eef48fa8da1c0ac9b411744b16c3f4c31176c867886e4c48da369c407153"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@ -1520,13 +1503,13 @@ hiredis = ["redis[hiredis] (>=3,!=4.0.0,!=4.0.1)"]
|
||||
|
||||
[[package]]
|
||||
name = "django-storages"
|
||||
version = "1.14.4"
|
||||
version = "1.14.5"
|
||||
description = "Support for many storage backends in Django"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
files = [
|
||||
{file = "django-storages-1.14.4.tar.gz", hash = "sha256:69aca94d26e6714d14ad63f33d13619e697508ee33ede184e462ed766dc2a73f"},
|
||||
{file = "django_storages-1.14.4-py3-none-any.whl", hash = "sha256:d61930acb4a25e3aebebc6addaf946a3b1df31c803a6bf1af2f31c9047febaa3"},
|
||||
{file = "django_storages-1.14.5-py3-none-any.whl", hash = "sha256:5ce9c69426f24f379821fd688442314e4aa03de87ae43183c4e16915f4c165d4"},
|
||||
{file = "django_storages-1.14.5.tar.gz", hash = "sha256:ace80dbee311258453e30cd5cfd91096b834180ccf09bc1f4d2cb6d38d68571a"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@ -1537,7 +1520,7 @@ Django = ">=3.2"
|
||||
azure = ["azure-core (>=1.13)", "azure-storage-blob (>=12)"]
|
||||
boto3 = ["boto3 (>=1.4.4)"]
|
||||
dropbox = ["dropbox (>=7.2.1)"]
|
||||
google = ["google-cloud-storage (>=1.27)"]
|
||||
google = ["google-cloud-storage (>=1.32)"]
|
||||
libcloud = ["apache-libcloud"]
|
||||
s3 = ["boto3 (>=1.4.4)"]
|
||||
sftp = ["paramiko (>=1.15)"]
|
||||
@ -1884,6 +1867,17 @@ files = [
|
||||
{file = "frozenlist-1.4.1.tar.gz", hash = "sha256:c037a86e8513059a2613aaba4d817bb90b9d9b6b69aace3ce9c877e8c8ed402b"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "geographiclib"
|
||||
version = "2.0"
|
||||
description = "The geodesic routines from GeographicLib"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
files = [
|
||||
{file = "geographiclib-2.0-py3-none-any.whl", hash = "sha256:6b7225248e45ff7edcee32becc4e0a1504c606ac5ee163a5656d482e0cd38734"},
|
||||
{file = "geographiclib-2.0.tar.gz", hash = "sha256:f7f41c85dc3e1c2d3d935ec86660dc3b2c848c83e17f9a9e51ba9d5146a15859"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "geoip2"
|
||||
version = "5.0.1"
|
||||
@ -1903,6 +1897,29 @@ requests = ">=2.24.0,<3.0.0"
|
||||
[package.extras]
|
||||
test = ["pytest-httpserver (>=1.0.10)"]
|
||||
|
||||
[[package]]
|
||||
name = "geopy"
|
||||
version = "2.4.1"
|
||||
description = "Python Geocoding Toolbox"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
files = [
|
||||
{file = "geopy-2.4.1-py3-none-any.whl", hash = "sha256:ae8b4bc5c1131820f4d75fce9d4aaaca0c85189b3aa5d64c3dcaf5e3b7b882a7"},
|
||||
{file = "geopy-2.4.1.tar.gz", hash = "sha256:50283d8e7ad07d89be5cb027338c6365a32044df3ae2556ad3f52f4840b3d0d1"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
geographiclib = ">=1.52,<3"
|
||||
|
||||
[package.extras]
|
||||
aiohttp = ["aiohttp"]
|
||||
dev = ["coverage", "flake8 (>=5.0,<5.1)", "isort (>=5.10.0,<5.11.0)", "pytest (>=3.10)", "pytest-asyncio (>=0.17)", "readme-renderer", "sphinx (<=4.3.2)", "sphinx-issues", "sphinx-rtd-theme (>=0.5.0)"]
|
||||
dev-docs = ["readme-renderer", "sphinx (<=4.3.2)", "sphinx-issues", "sphinx-rtd-theme (>=0.5.0)"]
|
||||
dev-lint = ["flake8 (>=5.0,<5.1)", "isort (>=5.10.0,<5.11.0)"]
|
||||
dev-test = ["coverage", "pytest (>=3.10)", "pytest-asyncio (>=0.17)", "sphinx (<=4.3.2)"]
|
||||
requests = ["requests (>=2.16.2)", "urllib3 (>=1.24.2)"]
|
||||
timezone = ["pytz"]
|
||||
|
||||
[[package]]
|
||||
name = "google-api-core"
|
||||
version = "2.19.1"
|
||||
@ -4611,13 +4628,13 @@ websocket-client = ">=1.8,<2.0"
|
||||
|
||||
[[package]]
|
||||
name = "sentry-sdk"
|
||||
version = "2.21.0"
|
||||
version = "2.22.0"
|
||||
description = "Python client for Sentry (https://sentry.io)"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
files = [
|
||||
{file = "sentry_sdk-2.21.0-py2.py3-none-any.whl", hash = "sha256:7623cfa9e2c8150948a81ca253b8e2bfe4ce0b96ab12f8cd78e3ac9c490fd92f"},
|
||||
{file = "sentry_sdk-2.21.0.tar.gz", hash = "sha256:a6d38e0fb35edda191acf80b188ec713c863aaa5ad8d5798decb8671d02077b6"},
|
||||
{file = "sentry_sdk-2.22.0-py2.py3-none-any.whl", hash = "sha256:3d791d631a6c97aad4da7074081a57073126c69487560c6f8bffcf586461de66"},
|
||||
{file = "sentry_sdk-2.22.0.tar.gz", hash = "sha256:b4bf43bb38f547c84b2eadcefbe389b36ef75f3f38253d7a74d6b928c07ae944"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@ -4661,6 +4678,7 @@ sanic = ["sanic (>=0.8)"]
|
||||
sqlalchemy = ["sqlalchemy (>=1.2)"]
|
||||
starlette = ["starlette (>=0.19.1)"]
|
||||
starlite = ["starlite (>=1.48)"]
|
||||
statsig = ["statsig (>=0.55.3)"]
|
||||
tornado = ["tornado (>=6)"]
|
||||
unleash = ["UnleashClient (>=6.0.1)"]
|
||||
|
||||
@ -5847,4 +5865,4 @@ files = [
|
||||
[metadata]
|
||||
lock-version = "2.0"
|
||||
python-versions = "~3.12"
|
||||
content-hash = "a3915ac2ef2bb53f7cd67070912cdaf717c3bf73ed972fa337a9b07fce162451"
|
||||
content-hash = "8a6bfd4833e415a9f4f613ab4f33e60c8332b9f5743583222cdb7190f6286216"
|
||||
|
@ -113,6 +113,7 @@ duo-client = "*"
|
||||
fido2 = "*"
|
||||
flower = "*"
|
||||
geoip2 = "*"
|
||||
geopy = "*"
|
||||
google-api-python-client = "*"
|
||||
gunicorn = "*"
|
||||
gssapi = "*"
|
||||
|
1233
schema.yml
1233
schema.yml
File diff suppressed because it is too large
Load Diff
@ -74,7 +74,7 @@ const interfaces = [
|
||||
["user/UserInterface.ts", "user"],
|
||||
["flow/FlowInterface.ts", "flow"],
|
||||
["standalone/api-browser/index.ts", "standalone/api-browser"],
|
||||
["enterprise/rac/index.ts", "enterprise/rac"],
|
||||
["rac/index.ts", "rac"],
|
||||
["standalone/loading/index.ts", "standalone/loading"],
|
||||
["polyfill/poly.ts", "."],
|
||||
];
|
||||
|
350
web/package-lock.json
generated
350
web/package-lock.json
generated
@ -23,7 +23,7 @@
|
||||
"@floating-ui/dom": "^1.6.11",
|
||||
"@formatjs/intl-listformat": "^7.5.7",
|
||||
"@fortawesome/fontawesome-free": "^6.6.0",
|
||||
"@goauthentik/api": "^2024.12.3-1739449824",
|
||||
"@goauthentik/api": "^2024.12.3-1739965710",
|
||||
"@lit-labs/ssr": "^3.2.2",
|
||||
"@lit/context": "^1.1.2",
|
||||
"@lit/localize": "^0.12.2",
|
||||
@ -42,12 +42,12 @@
|
||||
"construct-style-sheets-polyfill": "^3.1.0",
|
||||
"core-js": "^3.38.1",
|
||||
"country-flag-icons": "^1.5.13",
|
||||
"dompurify": "^3.1.7",
|
||||
"dompurify": "^3.2.4",
|
||||
"fuse.js": "^7.0.0",
|
||||
"guacamole-common-js": "^1.5.0",
|
||||
"lit": "^3.2.0",
|
||||
"md-front-matter": "^1.0.4",
|
||||
"mermaid": "^11.2.1",
|
||||
"mermaid": "^11.4.1",
|
||||
"rapidoc": "^9.3.7",
|
||||
"showdown": "^2.1.0",
|
||||
"style-mod": "^4.1.2",
|
||||
@ -1814,9 +1814,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@goauthentik/api": {
|
||||
"version": "2024.12.3-1739449824",
|
||||
"resolved": "https://registry.npmjs.org/@goauthentik/api/-/api-2024.12.3-1739449824.tgz",
|
||||
"integrity": "sha512-0M2SkvqpdjYgWOtaRLO41gTTyo43WPXlWbcfqCxfCJUoi1c3VGT5mozFCgRM21mY6+a3tKPHh4O28qDuz5gthw=="
|
||||
"version": "2024.12.3-1739965710",
|
||||
"resolved": "https://registry.npmjs.org/@goauthentik/api/-/api-2024.12.3-1739965710.tgz",
|
||||
"integrity": "sha512-16zoQWeJhAFSwttvqLRoXoQA43tMW1ZXDEihW6r8rtWtlxqPh7n36RtcWYraYiLcjmJskI90zdgz6k1kmY5AXw=="
|
||||
},
|
||||
"node_modules/@goauthentik/web": {
|
||||
"resolved": "",
|
||||
@ -5728,6 +5728,259 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3": {
|
||||
"version": "7.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz",
|
||||
"integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-array": "*",
|
||||
"@types/d3-axis": "*",
|
||||
"@types/d3-brush": "*",
|
||||
"@types/d3-chord": "*",
|
||||
"@types/d3-color": "*",
|
||||
"@types/d3-contour": "*",
|
||||
"@types/d3-delaunay": "*",
|
||||
"@types/d3-dispatch": "*",
|
||||
"@types/d3-drag": "*",
|
||||
"@types/d3-dsv": "*",
|
||||
"@types/d3-ease": "*",
|
||||
"@types/d3-fetch": "*",
|
||||
"@types/d3-force": "*",
|
||||
"@types/d3-format": "*",
|
||||
"@types/d3-geo": "*",
|
||||
"@types/d3-hierarchy": "*",
|
||||
"@types/d3-interpolate": "*",
|
||||
"@types/d3-path": "*",
|
||||
"@types/d3-polygon": "*",
|
||||
"@types/d3-quadtree": "*",
|
||||
"@types/d3-random": "*",
|
||||
"@types/d3-scale": "*",
|
||||
"@types/d3-scale-chromatic": "*",
|
||||
"@types/d3-selection": "*",
|
||||
"@types/d3-shape": "*",
|
||||
"@types/d3-time": "*",
|
||||
"@types/d3-time-format": "*",
|
||||
"@types/d3-timer": "*",
|
||||
"@types/d3-transition": "*",
|
||||
"@types/d3-zoom": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-array": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz",
|
||||
"integrity": "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-axis": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz",
|
||||
"integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-brush": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz",
|
||||
"integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-chord": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz",
|
||||
"integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-color": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-contour": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz",
|
||||
"integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-array": "*",
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-delaunay": {
|
||||
"version": "6.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
|
||||
"integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-dispatch": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.6.tgz",
|
||||
"integrity": "sha512-4fvZhzMeeuBJYZXRXrRIQnvUYfyXwYmLsdiN7XXmVNQKKw1cM8a5WdID0g1hVFZDqT9ZqZEY5pD44p24VS7iZQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-drag": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
|
||||
"integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-dsv": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz",
|
||||
"integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-ease": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-fetch": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz",
|
||||
"integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-dsv": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-force": {
|
||||
"version": "3.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz",
|
||||
"integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-format": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz",
|
||||
"integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-geo": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz",
|
||||
"integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-hierarchy": {
|
||||
"version": "3.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz",
|
||||
"integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-interpolate": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-color": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-path": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-polygon": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz",
|
||||
"integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-quadtree": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz",
|
||||
"integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-random": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz",
|
||||
"integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-scale": {
|
||||
"version": "4.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-time": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-scale-chromatic": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
|
||||
"integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-selection": {
|
||||
"version": "3.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
|
||||
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-shape": {
|
||||
"version": "3.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz",
|
||||
"integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-path": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-time": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-time-format": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz",
|
||||
"integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-timer": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-transition": {
|
||||
"version": "3.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
|
||||
"integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-zoom": {
|
||||
"version": "3.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
|
||||
"integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-interpolate": "*",
|
||||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/dompurify": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz",
|
||||
@ -5802,6 +6055,12 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/geojson": {
|
||||
"version": "7946.0.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/glob": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/glob/-/glob-7.2.0.tgz",
|
||||
@ -9982,6 +10241,7 @@
|
||||
"version": "7.9.0",
|
||||
"resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz",
|
||||
"integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "3",
|
||||
"d3-axis": "3",
|
||||
@ -10022,6 +10282,7 @@
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"internmap": "1 - 2"
|
||||
},
|
||||
@ -10033,6 +10294,7 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz",
|
||||
"integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
@ -10041,6 +10303,7 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz",
|
||||
"integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-drag": "2 - 3",
|
||||
@ -10056,6 +10319,7 @@
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz",
|
||||
"integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-path": "1 - 3"
|
||||
},
|
||||
@ -10067,6 +10331,7 @@
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
@ -10075,6 +10340,7 @@
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz",
|
||||
"integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "^3.2.0"
|
||||
},
|
||||
@ -10086,6 +10352,7 @@
|
||||
"version": "6.0.4",
|
||||
"resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
|
||||
"integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"delaunator": "5"
|
||||
},
|
||||
@ -10097,6 +10364,7 @@
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
|
||||
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
@ -10105,6 +10373,7 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
|
||||
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-selection": "3"
|
||||
@ -10117,6 +10386,7 @@
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz",
|
||||
"integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"commander": "7",
|
||||
"iconv-lite": "0.6",
|
||||
@ -10141,6 +10411,7 @@
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
|
||||
"integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
@ -10149,6 +10420,7 @@
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
@ -10160,6 +10432,7 @@
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
@ -10168,6 +10441,7 @@
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz",
|
||||
"integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-dsv": "1 - 3"
|
||||
},
|
||||
@ -10179,6 +10453,7 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz",
|
||||
"integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-quadtree": "1 - 3",
|
||||
@ -10192,6 +10467,7 @@
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
|
||||
"integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
@ -10200,6 +10476,7 @@
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz",
|
||||
"integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2.5.0 - 3"
|
||||
},
|
||||
@ -10211,6 +10488,7 @@
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz",
|
||||
"integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
@ -10219,6 +10497,7 @@
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3"
|
||||
},
|
||||
@ -10230,6 +10509,7 @@
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
@ -10238,6 +10518,7 @@
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz",
|
||||
"integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
@ -10246,6 +10527,7 @@
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz",
|
||||
"integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
@ -10254,6 +10536,7 @@
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz",
|
||||
"integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
@ -10297,6 +10580,7 @@
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2.10.0 - 3",
|
||||
"d3-format": "1 - 3",
|
||||
@ -10312,6 +10596,7 @@
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
|
||||
"integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3",
|
||||
"d3-interpolate": "1 - 3"
|
||||
@ -10324,6 +10609,7 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
@ -10332,6 +10618,7 @@
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-path": "^3.1.0"
|
||||
},
|
||||
@ -10343,6 +10630,7 @@
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2 - 3"
|
||||
},
|
||||
@ -10354,6 +10642,7 @@
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-time": "1 - 3"
|
||||
},
|
||||
@ -10365,6 +10654,7 @@
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
@ -10373,6 +10663,7 @@
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
|
||||
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3",
|
||||
"d3-dispatch": "1 - 3",
|
||||
@ -10391,6 +10682,7 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
|
||||
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-drag": "2 - 3",
|
||||
@ -10403,11 +10695,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/dagre-d3-es": {
|
||||
"version": "7.0.10",
|
||||
"resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.10.tgz",
|
||||
"integrity": "sha512-qTCQmEhcynucuaZgY5/+ti3X/rnszKZhEQH/ZdWdtP1tA/y3VoHJzcVrO9pjjJCNpigfscAtoUB5ONcd2wNn0A==",
|
||||
"version": "7.0.11",
|
||||
"resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.11.tgz",
|
||||
"integrity": "sha512-tvlJLyQf834SylNKax8Wkzco/1ias1OPw8DcUMDE7oUIoSEW25riQVuiu/0OWEFqT0cxHT3Pa9/D82Jr47IONw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"d3": "^7.8.2",
|
||||
"d3": "^7.9.0",
|
||||
"lodash-es": "^4.17.21"
|
||||
}
|
||||
},
|
||||
@ -10646,6 +10939,7 @@
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz",
|
||||
"integrity": "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"robust-predicates": "^3.0.2"
|
||||
}
|
||||
@ -10797,10 +11091,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.1.7",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.1.7.tgz",
|
||||
"integrity": "sha512-VaTstWtsneJY8xzy7DekmYWEOZcmzIe3Qb3zPd4STve1OBTa+e+WmS1ITQec1fZYXI3HCsOZZiSMpG6oxoWMWQ==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)"
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.4.tgz",
|
||||
"integrity": "sha512-ysFSFEDVduQpyhzAob/kkuJjf5zWkZD8/A9ywSp1byueyuCfHamrCBa14/Oc2iiB0e51B+NpxSl5gmzn+Ms/mg==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
},
|
||||
"node_modules/domutils": {
|
||||
"version": "3.1.0",
|
||||
@ -13658,6 +13955,7 @@
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
@ -15782,21 +16080,23 @@
|
||||
}
|
||||
},
|
||||
"node_modules/mermaid": {
|
||||
"version": "11.3.0",
|
||||
"resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.3.0.tgz",
|
||||
"integrity": "sha512-fFmf2gRXLtlGzug4wpIGN+rQdZ30M8IZEB1D3eZkXNqC7puhqeURBcD/9tbwXsqBO+A6Nzzo3MSSepmnw5xSeg==",
|
||||
"version": "11.4.1",
|
||||
"resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.4.1.tgz",
|
||||
"integrity": "sha512-Mb01JT/x6CKDWaxigwfZYuYmDZ6xtrNwNlidKZwkSrDaY9n90tdrJTV5Umk+wP1fZscGptmKFXHsXMDEVZ+Q6A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@braintree/sanitize-url": "^7.0.1",
|
||||
"@iconify/utils": "^2.1.32",
|
||||
"@mermaid-js/parser": "^0.3.0",
|
||||
"@types/d3": "^7.4.3",
|
||||
"cytoscape": "^3.29.2",
|
||||
"cytoscape-cose-bilkent": "^4.1.0",
|
||||
"cytoscape-fcose": "^2.2.0",
|
||||
"d3": "^7.9.0",
|
||||
"d3-sankey": "^0.12.3",
|
||||
"dagre-d3-es": "7.0.10",
|
||||
"dagre-d3-es": "7.0.11",
|
||||
"dayjs": "^1.11.10",
|
||||
"dompurify": "^3.0.11 <3.1.7",
|
||||
"dompurify": "^3.2.1",
|
||||
"katex": "^0.16.9",
|
||||
"khroma": "^2.1.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
@ -15807,12 +16107,6 @@
|
||||
"uuid": "^9.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/mermaid/node_modules/dompurify": {
|
||||
"version": "3.1.6",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.1.6.tgz",
|
||||
"integrity": "sha512-cTOAhc36AalkjtBpfG6O8JimdTMWNXjiePT2xQH/ppBGi/4uIpmj8eKyIkMJErXWARyINV/sB38yf8JCLF5pbQ==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)"
|
||||
},
|
||||
"node_modules/methods": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
|
||||
@ -18984,7 +19278,8 @@
|
||||
"node_modules/robust-predicates": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz",
|
||||
"integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg=="
|
||||
"integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==",
|
||||
"license": "Unlicense"
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.24.0",
|
||||
@ -19254,7 +19549,8 @@
|
||||
"node_modules/rw": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz",
|
||||
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="
|
||||
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/rxjs": {
|
||||
"version": "7.8.1",
|
||||
|
@ -11,7 +11,7 @@
|
||||
"@floating-ui/dom": "^1.6.11",
|
||||
"@formatjs/intl-listformat": "^7.5.7",
|
||||
"@fortawesome/fontawesome-free": "^6.6.0",
|
||||
"@goauthentik/api": "^2024.12.3-1739449824",
|
||||
"@goauthentik/api": "^2024.12.3-1739965710",
|
||||
"@lit-labs/ssr": "^3.2.2",
|
||||
"@lit/context": "^1.1.2",
|
||||
"@lit/localize": "^0.12.2",
|
||||
@ -30,12 +30,12 @@
|
||||
"construct-style-sheets-polyfill": "^3.1.0",
|
||||
"core-js": "^3.38.1",
|
||||
"country-flag-icons": "^1.5.13",
|
||||
"dompurify": "^3.1.7",
|
||||
"dompurify": "^3.2.4",
|
||||
"fuse.js": "^7.0.0",
|
||||
"guacamole-common-js": "^1.5.0",
|
||||
"lit": "^3.2.0",
|
||||
"md-front-matter": "^1.0.4",
|
||||
"mermaid": "^11.2.1",
|
||||
"mermaid": "^11.4.1",
|
||||
"rapidoc": "^9.3.7",
|
||||
"showdown": "^2.1.0",
|
||||
"style-mod": "^4.1.2",
|
||||
|
@ -6,7 +6,7 @@ const config: KnipConfig = {
|
||||
"./src/user/UserInterface.ts",
|
||||
"./src/flow/FlowInterface.ts",
|
||||
"./src/standalone/api-browser/index.ts",
|
||||
"./src/enterprise/rac/index.ts",
|
||||
"./src/rac/index.ts",
|
||||
"./src/standalone/loading/index.ts",
|
||||
"./src/polyfill/poly.ts",
|
||||
],
|
||||
|
@ -7,6 +7,7 @@ import "@goauthentik/components/ak-radio-input";
|
||||
import "@goauthentik/components/ak-switch-input";
|
||||
import "@goauthentik/components/ak-text-input";
|
||||
import "@goauthentik/components/ak-textarea-input";
|
||||
import "@goauthentik/elements/Alert.js";
|
||||
import {
|
||||
CapabilitiesEnum,
|
||||
WithCapabilitiesConfig,
|
||||
@ -120,7 +121,12 @@ export class ApplicationForm extends WithCapabilitiesConfig(ModelForm<Applicatio
|
||||
}
|
||||
|
||||
renderForm(): TemplateResult {
|
||||
const alertMsg = msg(
|
||||
"Using this form will only create an Application. In order to authenticate with the application, you will have to manually pair it with a Provider.",
|
||||
);
|
||||
|
||||
return html`<form class="pf-c-form pf-m-horizontal">
|
||||
<ak-alert level="pf-m-info">${alertMsg}</ak-alert>
|
||||
<ak-text-input
|
||||
name="name"
|
||||
value=${ifDefined(this.instance?.name)}
|
||||
|
@ -50,7 +50,7 @@ export class ApplicationListPage extends WithBrandConfig(TablePage<Application>)
|
||||
}
|
||||
pageDescription(): string {
|
||||
return msg(
|
||||
str`External applications that use ${this.brand.brandingTitle || "authentik"} as an identity provider via protocols like OAuth2 and SAML. All applications are shown here, even ones you cannot access.`,
|
||||
str`External applications that use ${this.brand?.brandingTitle ?? "authentik"} as an identity provider via protocols like OAuth2 and SAML. All applications are shown here, even ones you cannot access.`,
|
||||
);
|
||||
}
|
||||
pageIcon(): string {
|
||||
@ -85,10 +85,6 @@ export class ApplicationListPage extends WithBrandConfig(TablePage<Application>)
|
||||
];
|
||||
}
|
||||
|
||||
renderSectionBefore(): TemplateResult {
|
||||
return html`<ak-application-wizard-hint></ak-application-wizard-hint>`;
|
||||
}
|
||||
|
||||
renderSidebarAfter(): TemplateResult {
|
||||
return html`<div class="pf-c-sidebar__panel pf-m-width-25">
|
||||
<div class="pf-c-card">
|
||||
@ -160,12 +156,21 @@ export class ApplicationListPage extends WithBrandConfig(TablePage<Application>)
|
||||
}
|
||||
|
||||
renderObjectCreate(): TemplateResult {
|
||||
return html`<ak-forms-modal .open=${getURLParam("createForm", false)}>
|
||||
<span slot="submit"> ${msg("Create")} </span>
|
||||
<span slot="header"> ${msg("Create Application")} </span>
|
||||
<ak-application-form slot="form"> </ak-application-form>
|
||||
<button slot="trigger" class="pf-c-button pf-m-primary">${msg("Create")}</button>
|
||||
</ak-forms-modal>`;
|
||||
return html` <ak-application-wizard .open=${getURLParam("createWizard", false)}>
|
||||
<button
|
||||
slot="trigger"
|
||||
class="pf-c-button pf-m-primary"
|
||||
data-ouia-component-id="start-application-wizard"
|
||||
>
|
||||
${msg("Create with Provider")}
|
||||
</button>
|
||||
</ak-application-wizard>
|
||||
<ak-forms-modal .open=${getURLParam("createForm", false)}>
|
||||
<span slot="submit"> ${msg("Create")} </span>
|
||||
<span slot="header"> ${msg("Create Application")} </span>
|
||||
<ak-application-form slot="form"> </ak-application-form>
|
||||
<button slot="trigger" class="pf-c-button pf-m-primary">${msg("Create")}</button>
|
||||
</ak-forms-modal>`;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -30,7 +30,7 @@ export class ApplicationWizardStep extends WizardStep {
|
||||
// As recommended in [WizardStep](../../../components/ak-wizard/WizardStep.ts), we override
|
||||
// these fields and provide them to all the child classes.
|
||||
wizardTitle = msg("New application");
|
||||
wizardDescription = msg("Create a new application");
|
||||
wizardDescription = msg("Create a new application and configure a provider for it.");
|
||||
canCancel = true;
|
||||
|
||||
// This should be overridden in the children for more precise targeting.
|
||||
|
@ -2,11 +2,14 @@ import "@goauthentik/admin/users/ServiceAccountForm";
|
||||
import "@goauthentik/admin/users/UserActiveForm";
|
||||
import "@goauthentik/admin/users/UserForm";
|
||||
import "@goauthentik/admin/users/UserImpersonateForm";
|
||||
import {
|
||||
renderRecoveryEmailRequest,
|
||||
renderRecoveryLinkRequest,
|
||||
} from "@goauthentik/admin/users/UserListPage";
|
||||
import "@goauthentik/admin/users/UserPasswordForm";
|
||||
import "@goauthentik/admin/users/UserResetEmailForm";
|
||||
import "@goauthentik/admin/users/UserRecoveryLinkForm";
|
||||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
||||
import { PFSize } from "@goauthentik/common/enums.js";
|
||||
import { MessageLevel } from "@goauthentik/common/messages";
|
||||
import { me } from "@goauthentik/common/users";
|
||||
import { getRelativeTime } from "@goauthentik/common/utils";
|
||||
import "@goauthentik/components/ak-status-label";
|
||||
@ -21,7 +24,6 @@ import "@goauthentik/elements/forms/DeleteBulkForm";
|
||||
import { Form } from "@goauthentik/elements/forms/Form";
|
||||
import "@goauthentik/elements/forms/HorizontalFormElement";
|
||||
import "@goauthentik/elements/forms/ModalForm";
|
||||
import { showMessage } from "@goauthentik/elements/messages/MessageContainer";
|
||||
import { getURLParam, updateURLParams } from "@goauthentik/elements/router/RouteMatch";
|
||||
import { PaginatedResponse } from "@goauthentik/elements/table/Table";
|
||||
import { Table, TableColumn } from "@goauthentik/elements/table/Table";
|
||||
@ -37,14 +39,7 @@ import PFAlert from "@patternfly/patternfly/components/Alert/alert.css";
|
||||
import PFBanner from "@patternfly/patternfly/components/Banner/banner.css";
|
||||
import PFDescriptionList from "@patternfly/patternfly/components/DescriptionList/description-list.css";
|
||||
|
||||
import {
|
||||
CoreApi,
|
||||
CoreUsersListTypeEnum,
|
||||
Group,
|
||||
ResponseError,
|
||||
SessionUser,
|
||||
User,
|
||||
} from "@goauthentik/api";
|
||||
import { CoreApi, CoreUsersListTypeEnum, Group, SessionUser, User } from "@goauthentik/api";
|
||||
|
||||
@customElement("ak-user-related-add")
|
||||
export class RelatedUserAdd extends Form<{ users: number[] }> {
|
||||
@ -301,60 +296,11 @@ export class RelatedUserList extends WithBrandConfig(WithCapabilitiesConfig(Tabl
|
||||
${msg("Set password")}
|
||||
</button>
|
||||
</ak-forms-modal>
|
||||
${this.brand?.flowRecovery
|
||||
${this.brand.flowRecovery
|
||||
? html`
|
||||
<ak-action-button
|
||||
class="pf-m-secondary"
|
||||
.apiRequest=${() => {
|
||||
return new CoreApi(DEFAULT_CONFIG)
|
||||
.coreUsersRecoveryCreate({
|
||||
id: item.pk,
|
||||
})
|
||||
.then((rec) => {
|
||||
showMessage({
|
||||
level: MessageLevel.success,
|
||||
message: msg(
|
||||
"Successfully generated recovery link",
|
||||
),
|
||||
description: rec.link,
|
||||
});
|
||||
})
|
||||
.catch((ex: ResponseError) => {
|
||||
ex.response.json().then(() => {
|
||||
showMessage({
|
||||
level: MessageLevel.error,
|
||||
message: msg(
|
||||
"No recovery flow is configured.",
|
||||
),
|
||||
});
|
||||
});
|
||||
});
|
||||
}}
|
||||
>
|
||||
${msg("Copy recovery link")}
|
||||
</ak-action-button>
|
||||
${renderRecoveryLinkRequest(item)}
|
||||
${item.email
|
||||
? html`<ak-forms-modal
|
||||
.closeAfterSuccessfulSubmit=${false}
|
||||
>
|
||||
<span slot="submit">
|
||||
${msg("Send link")}
|
||||
</span>
|
||||
<span slot="header">
|
||||
${msg("Send recovery link to user")}
|
||||
</span>
|
||||
<ak-user-reset-email-form
|
||||
slot="form"
|
||||
.user=${item}
|
||||
>
|
||||
</ak-user-reset-email-form>
|
||||
<button
|
||||
slot="trigger"
|
||||
class="pf-c-button pf-m-secondary"
|
||||
>
|
||||
${msg("Email recovery link")}
|
||||
</button>
|
||||
</ak-forms-modal>`
|
||||
? renderRecoveryEmailRequest(item)
|
||||
: html`<span
|
||||
>${msg(
|
||||
"Recovery link cannot be emailed, user has no email address saved.",
|
||||
@ -363,7 +309,7 @@ export class RelatedUserList extends WithBrandConfig(WithCapabilitiesConfig(Tabl
|
||||
`
|
||||
: html` <p>
|
||||
${msg(
|
||||
"To let a user directly reset a their password, configure a recovery flow on the currently active brand.",
|
||||
"To let a user directly reset their password, configure a recovery flow on the currently active brand.",
|
||||
)}
|
||||
</p>`}
|
||||
</div>
|
||||
|
@ -73,9 +73,6 @@ const radiusListFetch = async (page: number, search = "") =>
|
||||
const racListProvider = async (page: number, search = "") =>
|
||||
provisionMaker(await api().providersRacList(providerListArgs(page, search)));
|
||||
|
||||
const scimListProvider = async (page: number, search = "") =>
|
||||
provisionMaker(await api().providersScimList(providerListArgs(page, search)));
|
||||
|
||||
function providerProvider(type: OutpostTypeEnum): DataProvider {
|
||||
switch (type) {
|
||||
case OutpostTypeEnum.Proxy:
|
||||
@ -86,8 +83,6 @@ function providerProvider(type: OutpostTypeEnum): DataProvider {
|
||||
return radiusListFetch;
|
||||
case OutpostTypeEnum.Rac:
|
||||
return racListProvider;
|
||||
case OutpostTypeEnum.Scim:
|
||||
return scimListProvider;
|
||||
default:
|
||||
throw new Error(`Unrecognized OutputType: ${type}`);
|
||||
}
|
||||
@ -147,7 +142,6 @@ export class OutpostForm extends ModelForm<Outpost, string> {
|
||||
[OutpostTypeEnum.Ldap, msg("LDAP")],
|
||||
[OutpostTypeEnum.Radius, msg("Radius")],
|
||||
[OutpostTypeEnum.Rac, msg("RAC")],
|
||||
[OutpostTypeEnum.Scim, msg("SCIM")],
|
||||
];
|
||||
|
||||
return html` <ak-form-element-horizontal label=${msg("Name")} ?required=${true} name="name">
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user