diff --git a/.github/workflows/ci-main.yml b/.github/workflows/ci-main.yml index 7d4a9433fd..baf0430c9f 100644 --- a/.github/workflows/ci-main.yml +++ b/.github/workflows/ci-main.yml @@ -28,6 +28,7 @@ jobs: - isort - bandit - pyright + - pending-migrations runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 diff --git a/Dockerfile b/Dockerfile index cb1a3f55ef..82de5c25a7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -34,13 +34,9 @@ WORKDIR /work COPY --from=web-builder /work/web/robots.txt /work/web/robots.txt COPY --from=web-builder /work/web/security.txt /work/web/security.txt -COPY --from=web-builder /work/web/dist/ /work/web/dist/ -COPY --from=web-builder /work/web/authentik/ /work/web/authentik/ -COPY --from=website-builder /work/website/help/ /work/website/help/ COPY ./cmd /work/cmd COPY ./web/static.go /work/web/static.go -COPY ./website/static.go /work/website/static.go COPY ./internal /work/internal COPY ./go.mod /work/go.mod COPY ./go.sum /work/go.sum @@ -78,6 +74,9 @@ COPY ./tests /tests COPY ./manage.py / COPY ./lifecycle/ /lifecycle COPY --from=builder /work/authentik /authentik-proxy +COPY --from=web-builder /work/web/dist/ /web/dist/ +COPY --from=web-builder /work/web/authentik/ /web/authentik/ +COPY --from=website-builder /work/website/help/ /website/help/ USER authentik diff --git a/Makefile b/Makefile index 572bc094e6..b2792c9490 100644 --- a/Makefile +++ b/Makefile @@ -68,7 +68,7 @@ gen-outpost: docker run \ --rm -v ${PWD}:/local \ --user ${UID}:${GID} \ - openapitools/openapi-generator-cli generate \ + openapitools/openapi-generator-cli:v5.2.1 generate \ -i /local/schema.yml \ -g go \ -o /local/api \ @@ -113,3 +113,6 @@ ci-bandit: ci-pyright: pyright e2e lifecycle + +ci-pending-migrations: + ./manage.py makemigrations --check diff --git a/authentik/admin/tasks.py b/authentik/admin/tasks.py index 3602a24e44..e5672a1320 100644 --- a/authentik/admin/tasks.py +++ b/authentik/admin/tasks.py @@ -11,12 +11,7 @@ from structlog.stdlib import get_logger from authentik import ENV_GIT_HASH_KEY, __version__ from authentik.events.models import Event, EventAction, Notification -from authentik.events.monitored_tasks import ( - MonitoredTask, - TaskResult, - TaskResultStatus, - prefill_task, -) +from authentik.events.monitored_tasks import PrefilledMonitoredTask, TaskResult, TaskResultStatus from authentik.lib.config import CONFIG from authentik.lib.utils.http import get_http_session from authentik.root.celery import CELERY_APP @@ -53,9 +48,8 @@ def clear_update_notifications(): notification.delete() -@CELERY_APP.task(bind=True, base=MonitoredTask) -@prefill_task() -def update_latest_version(self: MonitoredTask): +@CELERY_APP.task(bind=True, base=PrefilledMonitoredTask) +def update_latest_version(self: PrefilledMonitoredTask): """Update latest version info""" if CONFIG.y_bool("disable_update_check"): cache.set(VERSION_CACHE_KEY, "0.0.0", VERSION_CACHE_TIMEOUT) diff --git a/authentik/core/tasks.py b/authentik/core/tasks.py index df45b4d4ba..79e250202a 100644 --- a/authentik/core/tasks.py +++ b/authentik/core/tasks.py @@ -16,21 +16,15 @@ from kubernetes.config.incluster_config import SERVICE_HOST_ENV_NAME from structlog.stdlib import get_logger from authentik.core.models import AuthenticatedSession, ExpiringModel -from authentik.events.monitored_tasks import ( - MonitoredTask, - TaskResult, - TaskResultStatus, - prefill_task, -) +from authentik.events.monitored_tasks import PrefilledMonitoredTask, TaskResult, TaskResultStatus from authentik.lib.config import CONFIG from authentik.root.celery import CELERY_APP LOGGER = get_logger() -@CELERY_APP.task(bind=True, base=MonitoredTask) -@prefill_task() -def clean_expired_models(self: MonitoredTask): +@CELERY_APP.task(bind=True, base=PrefilledMonitoredTask) +def clean_expired_models(self: PrefilledMonitoredTask): """Remove expired objects""" messages = [] for cls in ExpiringModel.__subclasses__(): @@ -68,9 +62,8 @@ def should_backup() -> bool: return True -@CELERY_APP.task(bind=True, base=MonitoredTask) -@prefill_task() -def backup_database(self: MonitoredTask): # pragma: no cover +@CELERY_APP.task(bind=True, base=PrefilledMonitoredTask) +def backup_database(self: PrefilledMonitoredTask): # pragma: no cover """Database backup""" self.result_timeout_hours = 25 if not should_backup(): diff --git a/authentik/crypto/api.py b/authentik/crypto/api.py index 2c9a0baaa9..fc62285fa9 100644 --- a/authentik/crypto/api.py +++ b/authentik/crypto/api.py @@ -20,6 +20,7 @@ from authentik.api.decorators import permission_required from authentik.core.api.used_by import UsedByMixin from authentik.core.api.utils import PassiveSerializer from authentik.crypto.builder import CertificateBuilder +from authentik.crypto.managed import MANAGED_KEY from authentik.crypto.models import CertificateKeyPair from authentik.events.models import Event, EventAction @@ -141,9 +142,11 @@ class CertificateKeyPairFilter(FilterSet): class CertificateKeyPairViewSet(UsedByMixin, ModelViewSet): """CertificateKeyPair Viewset""" - queryset = CertificateKeyPair.objects.exclude(managed__isnull=False) + queryset = CertificateKeyPair.objects.exclude(managed=MANAGED_KEY) serializer_class = CertificateKeyPairSerializer filterset_class = CertificateKeyPairFilter + ordering = ["name"] + search_fields = ["name"] @permission_required(None, ["authentik_crypto.add_certificatekeypair"]) @extend_schema( diff --git a/authentik/crypto/apps.py b/authentik/crypto/apps.py index 8f84f08390..17da6d2cce 100644 --- a/authentik/crypto/apps.py +++ b/authentik/crypto/apps.py @@ -13,3 +13,4 @@ class AuthentikCryptoConfig(AppConfig): def ready(self): import_module("authentik.crypto.managed") + import_module("authentik.crypto.tasks") diff --git a/authentik/crypto/settings.py b/authentik/crypto/settings.py new file mode 100644 index 0000000000..598576d481 --- /dev/null +++ b/authentik/crypto/settings.py @@ -0,0 +1,10 @@ +"""Crypto task Settings""" +from celery.schedules import crontab + +CELERY_BEAT_SCHEDULE = { + "crypto_certificate_discovery": { + "task": "authentik.crypto.tasks.certificate_discovery", + "schedule": crontab(minute="*/5"), + "options": {"queue": "authentik_scheduled"}, + }, +} diff --git a/authentik/crypto/tasks.py b/authentik/crypto/tasks.py new file mode 100644 index 0000000000..06d6b02eec --- /dev/null +++ b/authentik/crypto/tasks.py @@ -0,0 +1,67 @@ +"""Crypto tasks""" +from glob import glob +from pathlib import Path + +from django.utils.translation import gettext_lazy as _ +from structlog.stdlib import get_logger + +from authentik.crypto.models import CertificateKeyPair +from authentik.events.monitored_tasks import PrefilledMonitoredTask, TaskResult, TaskResultStatus +from authentik.lib.config import CONFIG +from authentik.root.celery import CELERY_APP + +LOGGER = get_logger() + +MANAGED_DISCOVERED = "goauthentik.io/crypto/discovered/%s" + + +@CELERY_APP.task(bind=True, base=PrefilledMonitoredTask) +def certificate_discovery(self: PrefilledMonitoredTask): + """Discover and update certificates form the filesystem""" + certs = {} + private_keys = {} + discovered = 0 + for file in glob(CONFIG.y("cert_discovery_dir") + "/**", recursive=True): + path = Path(file) + if not path.exists(): + continue + if path.is_dir(): + continue + # Support certbot's directory structure + if path.name in ["fullchain.pem", "privkey.pem"]: + cert_name = path.parent.name + else: + cert_name = path.name.replace(path.suffix, "") + try: + with open(path, "r+", encoding="utf-8") as _file: + body = _file.read() + if "BEGIN RSA PRIVATE KEY" in body: + private_keys[cert_name] = body + else: + certs[cert_name] = body + except OSError as exc: + LOGGER.warning("Failed to open file", exc=exc, file=path) + discovered += 1 + for name, cert_data in certs.items(): + cert = CertificateKeyPair.objects.filter(managed=MANAGED_DISCOVERED % name).first() + if not cert: + cert = CertificateKeyPair( + name=name, + managed=MANAGED_DISCOVERED % name, + ) + dirty = False + if cert.certificate_data != cert_data: + cert.certificate_data = cert_data + dirty = True + if name in private_keys: + if cert.key_data == private_keys[name]: + cert.key_data = private_keys[name] + dirty = True + if dirty: + cert.save() + self.set_status( + TaskResult( + TaskResultStatus.SUCCESSFUL, + messages=[_("Successfully imported %(count)d files." % {"count": discovered})], + ) + ) diff --git a/authentik/crypto/tests.py b/authentik/crypto/tests.py index f621b0f0d9..1120836984 100644 --- a/authentik/crypto/tests.py +++ b/authentik/crypto/tests.py @@ -1,5 +1,7 @@ """Crypto tests""" import datetime +from os import makedirs +from tempfile import TemporaryDirectory from django.urls import reverse from rest_framework.test import APITestCase @@ -9,6 +11,8 @@ from authentik.core.tests.utils import create_test_admin_user, create_test_cert, from authentik.crypto.api import CertificateKeyPairSerializer from authentik.crypto.builder import CertificateBuilder from authentik.crypto.models import CertificateKeyPair +from authentik.crypto.tasks import MANAGED_DISCOVERED, certificate_discovery +from authentik.lib.config import CONFIG from authentik.lib.generators import generate_key from authentik.providers.oauth2.models import OAuth2Provider @@ -163,3 +167,33 @@ class TestCrypto(APITestCase): } ], ) + + def test_discovery(self): + """Test certificate discovery""" + builder = CertificateBuilder() + builder.common_name = "test-cert" + with self.assertRaises(ValueError): + builder.save() + builder.build( + subject_alt_names=[], + validity_days=3, + ) + with TemporaryDirectory() as temp_dir: + with open(f"{temp_dir}/foo.pem", "w+", encoding="utf-8") as _cert: + _cert.write(builder.certificate) + with open(f"{temp_dir}/foo.key", "w+", encoding="utf-8") as _key: + _key.write(builder.private_key) + makedirs(f"{temp_dir}/foo.bar", exist_ok=True) + with open(f"{temp_dir}/foo.bar/fullchain.pem", "w+", encoding="utf-8") as _cert: + _cert.write(builder.certificate) + with open(f"{temp_dir}/foo.bar/privkey.pem", "w+", encoding="utf-8") as _key: + _key.write(builder.private_key) + with CONFIG.patch("cert_discovery_dir", temp_dir): + # pyright: reportGeneralTypeIssues=false + certificate_discovery() # pylint: disable=no-value-for-parameter + self.assertTrue( + CertificateKeyPair.objects.filter(managed=MANAGED_DISCOVERED % "foo").exists() + ) + self.assertTrue( + CertificateKeyPair.objects.filter(managed=MANAGED_DISCOVERED % "foo.bar").exists() + ) diff --git a/authentik/events/monitored_tasks.py b/authentik/events/monitored_tasks.py index 707aa0d3c6..138b4070cb 100644 --- a/authentik/events/monitored_tasks.py +++ b/authentik/events/monitored_tasks.py @@ -112,30 +112,6 @@ class TaskInfo: cache.set(key, self, timeout=timeout_hours * 60 * 60) -def prefill_task(): - """Ensure a task's details are always in cache, so it can always be triggered via API""" - - def inner_wrap(func): - status = TaskInfo.by_name(func.__name__) - if status: - return func - TaskInfo( - task_name=func.__name__, - task_description=func.__doc__, - result=TaskResult(TaskResultStatus.UNKNOWN, messages=[_("Task has not been run yet.")]), - task_call_module=func.__module__, - task_call_func=func.__name__, - # We don't have real values for these attributes but they cannot be null - start_timestamp=default_timer(), - finish_timestamp=default_timer(), - finish_time=datetime.now(), - ).save(86400) - LOGGER.debug("prefilled task", task_name=func.__name__) - return func - - return inner_wrap - - class MonitoredTask(Task): """Task which can save its state to the cache""" @@ -210,5 +186,31 @@ class MonitoredTask(Task): raise NotImplementedError +class PrefilledMonitoredTask(MonitoredTask): + """Subclass of MonitoredTask, but create entry in cache if task hasn't been run + Does not support UID""" + + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + status = TaskInfo.by_name(self.__name__) + if status: + return + TaskInfo( + task_name=self.__name__, + task_description=self.__doc__, + result=TaskResult(TaskResultStatus.UNKNOWN, messages=[_("Task has not been run yet.")]), + task_call_module=self.__module__, + task_call_func=self.__name__, + # We don't have real values for these attributes but they cannot be null + start_timestamp=default_timer(), + finish_timestamp=default_timer(), + finish_time=datetime.now(), + ).save(86400) + LOGGER.debug("prefilled task", task_name=self.__name__) + + def run(self, *args, **kwargs): + raise NotImplementedError + + for task in TaskInfo.all().values(): task.set_prom_metrics() diff --git a/authentik/flows/views/executor.py b/authentik/flows/views/executor.py index a1a4691ae9..82707e9fd2 100644 --- a/authentik/flows/views/executor.py +++ b/authentik/flows/views/executor.py @@ -53,6 +53,7 @@ NEXT_ARG_NAME = "next" SESSION_KEY_PLAN = "authentik_flows_plan" SESSION_KEY_APPLICATION_PRE = "authentik_flows_application_pre" SESSION_KEY_GET = "authentik_flows_get" +SESSION_KEY_POST = "authentik_flows_post" SESSION_KEY_HISTORY = "authentik_flows_history" diff --git a/authentik/lib/default.yml b/authentik/lib/default.yml index 1e4986785f..28549d7dc6 100644 --- a/authentik/lib/default.yml +++ b/authentik/lib/default.yml @@ -47,6 +47,7 @@ error_reporting: enabled: false environment: customer send_pii: false + sample_rate: 0.5 # Global email settings email: @@ -82,3 +83,4 @@ default_user_change_email: true default_user_change_username: true gdpr_compliance: true +cert_discovery_dir: /certs diff --git a/authentik/lib/models.py b/authentik/lib/models.py index 795cf8c4bd..3c7ba9c653 100644 --- a/authentik/lib/models.py +++ b/authentik/lib/models.py @@ -68,9 +68,9 @@ class DomainlessURLValidator(URLValidator): ) self.schemes = ["http", "https", "blank"] + list(self.schemes) - def __call__(self, value): + def __call__(self, value: str): # Check if the scheme is valid. scheme = value.split("://")[0].lower() if scheme not in self.schemes: value = "default" + value - return super().__call__(value) + super().__call__(value) diff --git a/authentik/managed/tasks.py b/authentik/managed/tasks.py index 5d2ebd9961..118b9c3707 100644 --- a/authentik/managed/tasks.py +++ b/authentik/managed/tasks.py @@ -2,18 +2,12 @@ from django.db import DatabaseError from authentik.core.tasks import CELERY_APP -from authentik.events.monitored_tasks import ( - MonitoredTask, - TaskResult, - TaskResultStatus, - prefill_task, -) +from authentik.events.monitored_tasks import PrefilledMonitoredTask, TaskResult, TaskResultStatus from authentik.managed.manager import ObjectManager -@CELERY_APP.task(bind=True, base=MonitoredTask) -@prefill_task() -def managed_reconcile(self: MonitoredTask): +@CELERY_APP.task(bind=True, base=PrefilledMonitoredTask) +def managed_reconcile(self: PrefilledMonitoredTask): """Run ObjectManager to ensure objects are up-to-date""" try: ObjectManager().run() diff --git a/authentik/outposts/apps.py b/authentik/outposts/apps.py index 0ee7aa9d18..c4e9554ec9 100644 --- a/authentik/outposts/apps.py +++ b/authentik/outposts/apps.py @@ -19,8 +19,9 @@ class AuthentikOutpostConfig(AppConfig): import_module("authentik.outposts.signals") import_module("authentik.outposts.managed") try: - from authentik.outposts.tasks import outpost_local_connection + from authentik.outposts.tasks import outpost_controller_all, outpost_local_connection outpost_local_connection.delay() + outpost_controller_all.delay() except ProgrammingError: pass diff --git a/authentik/outposts/tasks.py b/authentik/outposts/tasks.py index 63d7f4370e..820f585f6e 100644 --- a/authentik/outposts/tasks.py +++ b/authentik/outposts/tasks.py @@ -19,9 +19,9 @@ from structlog.stdlib import get_logger from authentik.events.monitored_tasks import ( MonitoredTask, + PrefilledMonitoredTask, TaskResult, TaskResultStatus, - prefill_task, ) from authentik.lib.utils.reflection import path_to_class from authentik.outposts.controllers.base import BaseController, ControllerException @@ -75,9 +75,8 @@ def outpost_service_connection_state(connection_pk: Any): cache.set(connection.state_key, state, timeout=None) -@CELERY_APP.task(bind=True, base=MonitoredTask) -@prefill_task() -def outpost_service_connection_monitor(self: MonitoredTask): +@CELERY_APP.task(bind=True, base=PrefilledMonitoredTask) +def outpost_service_connection_monitor(self: PrefilledMonitoredTask): """Regularly check the state of Outpost Service Connections""" connections = OutpostServiceConnection.objects.all() for connection in connections.iterator(): @@ -125,9 +124,8 @@ def outpost_controller( self.set_status(TaskResult(TaskResultStatus.SUCCESSFUL, logs)) -@CELERY_APP.task(bind=True, base=MonitoredTask) -@prefill_task() -def outpost_token_ensurer(self: MonitoredTask): +@CELERY_APP.task(bind=True, base=PrefilledMonitoredTask) +def outpost_token_ensurer(self: PrefilledMonitoredTask): """Periodically ensure that all Outposts have valid Service Accounts and Tokens""" all_outposts = Outpost.objects.all() diff --git a/authentik/policies/event_matcher/migrations/0019_alter_eventmatcherpolicy_app.py b/authentik/policies/event_matcher/migrations/0019_alter_eventmatcherpolicy_app.py index b5403e033d..fddc5bbab4 100644 --- a/authentik/policies/event_matcher/migrations/0019_alter_eventmatcherpolicy_app.py +++ b/authentik/policies/event_matcher/migrations/0019_alter_eventmatcherpolicy_app.py @@ -69,8 +69,8 @@ class Migration(migrations.Migration): ("authentik.stages.user_logout", "authentik Stages.User Logout"), ("authentik.stages.user_write", "authentik Stages.User Write"), ("authentik.tenants", "authentik Tenants"), - ("authentik.core", "authentik Core"), ("authentik.managed", "authentik Managed"), + ("authentik.core", "authentik Core"), ], default="", help_text="Match events created by selected application. When left empty, all applications are matched.", diff --git a/authentik/policies/reputation/tasks.py b/authentik/policies/reputation/tasks.py index eb6dcd6e3a..49b1590d12 100644 --- a/authentik/policies/reputation/tasks.py +++ b/authentik/policies/reputation/tasks.py @@ -2,12 +2,7 @@ from django.core.cache import cache from structlog.stdlib import get_logger -from authentik.events.monitored_tasks import ( - MonitoredTask, - TaskResult, - TaskResultStatus, - prefill_task, -) +from authentik.events.monitored_tasks import PrefilledMonitoredTask, TaskResult, TaskResultStatus from authentik.policies.reputation.models import IPReputation, UserReputation from authentik.policies.reputation.signals import CACHE_KEY_IP_PREFIX, CACHE_KEY_USER_PREFIX from authentik.root.celery import CELERY_APP @@ -15,9 +10,8 @@ from authentik.root.celery import CELERY_APP LOGGER = get_logger() -@CELERY_APP.task(bind=True, base=MonitoredTask) -@prefill_task() -def save_ip_reputation(self: MonitoredTask): +@CELERY_APP.task(bind=True, base=PrefilledMonitoredTask) +def save_ip_reputation(self: PrefilledMonitoredTask): """Save currently cached reputation to database""" objects_to_update = [] for key, score in cache.get_many(cache.keys(CACHE_KEY_IP_PREFIX + "*")).items(): @@ -29,9 +23,8 @@ def save_ip_reputation(self: MonitoredTask): self.set_status(TaskResult(TaskResultStatus.SUCCESSFUL, ["Successfully updated IP Reputation"])) -@CELERY_APP.task(bind=True, base=MonitoredTask) -@prefill_task() -def save_user_reputation(self: MonitoredTask): +@CELERY_APP.task(bind=True, base=PrefilledMonitoredTask) +def save_user_reputation(self: PrefilledMonitoredTask): """Save currently cached reputation to database""" objects_to_update = [] for key, score in cache.get_many(cache.keys(CACHE_KEY_USER_PREFIX + "*")).items(): diff --git a/authentik/policies/views.py b/authentik/policies/views.py index c45cc9943c..1921830175 100644 --- a/authentik/policies/views.py +++ b/authentik/policies/views.py @@ -10,7 +10,7 @@ from django.views.generic.base import View from structlog.stdlib import get_logger from authentik.core.models import Application, Provider, User -from authentik.flows.views.executor import SESSION_KEY_APPLICATION_PRE +from authentik.flows.views.executor import SESSION_KEY_APPLICATION_PRE, SESSION_KEY_POST from authentik.lib.sentry import SentryIgnoredException from authentik.policies.denied import AccessDeniedResponse from authentik.policies.engine import PolicyEngine @@ -84,6 +84,10 @@ class PolicyAccessView(AccessMixin, View): a hint on the Identification Stage what the user should login for.""" if self.application: self.request.session[SESSION_KEY_APPLICATION_PRE] = self.application + # Because this view might get hit with a POST request, we need to preserve that data + # since later views might need it (mostly SAML) + if self.request.method.lower() == "post": + self.request.session[SESSION_KEY_POST] = self.request.POST return redirect_to_login( self.request.get_full_path(), self.get_login_url(), diff --git a/authentik/providers/proxy/api.py b/authentik/providers/proxy/api.py index af9b84630f..3ca998614d 100644 --- a/authentik/providers/proxy/api.py +++ b/authentik/providers/proxy/api.py @@ -3,7 +3,7 @@ from typing import Any, Optional from drf_spectacular.utils import extend_schema_field from rest_framework.exceptions import ValidationError -from rest_framework.fields import CharField, ListField, SerializerMethodField +from rest_framework.fields import CharField, ListField, ReadOnlyField, SerializerMethodField from rest_framework.serializers import ModelSerializer from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet @@ -109,6 +109,9 @@ class ProxyProviderViewSet(UsedByMixin, ModelViewSet): class ProxyOutpostConfigSerializer(ModelSerializer): """Proxy provider serializer for outposts""" + assigned_application_slug = ReadOnlyField(source="application.slug") + assigned_application_name = ReadOnlyField(source="application.name") + oidc_configuration = SerializerMethodField() token_validity = SerializerMethodField() scopes_to_request = SerializerMethodField() @@ -152,6 +155,8 @@ class ProxyOutpostConfigSerializer(ModelSerializer): "cookie_domain", "token_validity", "scopes_to_request", + "assigned_application_slug", + "assigned_application_name", ] diff --git a/authentik/providers/proxy/controllers/k8s/traefik.py b/authentik/providers/proxy/controllers/k8s/traefik.py index 623c343a8e..9a0602ff30 100644 --- a/authentik/providers/proxy/controllers/k8s/traefik.py +++ b/authentik/providers/proxy/controllers/k8s/traefik.py @@ -20,9 +20,11 @@ class TraefikMiddlewareSpecForwardAuth: address: str # pylint: disable=invalid-name - authResponseHeaders: list[str] + authResponseHeadersRegex: str = field(default="") # pylint: disable=invalid-name - trustForwardHeader: bool + authResponseHeaders: list[str] = field(default_factory=list) + # pylint: disable=invalid-name + trustForwardHeader: bool = field(default=True) @dataclass @@ -108,21 +110,8 @@ class TraefikMiddlewareReconciler(KubernetesObjectReconciler[TraefikMiddleware]) spec=TraefikMiddlewareSpec( forwardAuth=TraefikMiddlewareSpecForwardAuth( address=f"http://{self.name}.{self.namespace}:9000/akprox/auth/traefik", - authResponseHeaders=[ - "Set-Cookie", - # Legacy headers, remove after 2022.1 - "X-Auth-Username", - "X-Auth-Groups", - "X-Forwarded-Email", - "X-Forwarded-Preferred-Username", - "X-Forwarded-User", - # New headers, unique prefix - "X-authentik-username", - "X-authentik-groups", - "X-authentik-email", - "X-authentik-name", - "X-authentik-uid", - ], + authResponseHeaders=[], + authResponseHeadersRegex="^.*$", trustForwardHeader=True, ) ), diff --git a/authentik/providers/saml/processors/request_parser.py b/authentik/providers/saml/processors/request_parser.py index f33f17a1be..6965766cf2 100644 --- a/authentik/providers/saml/processors/request_parser.py +++ b/authentik/providers/saml/processors/request_parser.py @@ -100,14 +100,13 @@ class AuthNRequestParser: xmlsec.tree.add_ids(root, ["ID"]) signature_nodes = root.xpath("/samlp:AuthnRequest/ds:Signature", namespaces=NS_MAP) # No signatures, no verifier configured -> decode xml directly - if len(signature_nodes) < 1 and not verifier: - return self._parse_xml(decoded_xml, relay_state) + if len(signature_nodes) < 1: + if not verifier: + return self._parse_xml(decoded_xml, relay_state) + raise CannotHandleAssertion(ERROR_SIGNATURE_REQUIRED_BUT_ABSENT) signature_node = signature_nodes[0] - if verifier and signature_node is None: - raise CannotHandleAssertion(ERROR_SIGNATURE_REQUIRED_BUT_ABSENT) - if signature_node is not None: if not verifier: raise CannotHandleAssertion(ERROR_SIGNATURE_EXISTS_BUT_NO_VERIFIER) diff --git a/authentik/providers/saml/views/sso.py b/authentik/providers/saml/views/sso.py index 3e470b0879..4a534c09d8 100644 --- a/authentik/providers/saml/views/sso.py +++ b/authentik/providers/saml/views/sso.py @@ -13,7 +13,7 @@ from authentik.core.models import Application from authentik.events.models import Event, EventAction from authentik.flows.models import in_memory_stage from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, PLAN_CONTEXT_SSO, FlowPlanner -from authentik.flows.views.executor import SESSION_KEY_PLAN +from authentik.flows.views.executor import SESSION_KEY_PLAN, SESSION_KEY_POST from authentik.lib.utils.urls import redirect_with_qs from authentik.lib.views import bad_request_message from authentik.policies.views import PolicyAccessView @@ -37,7 +37,7 @@ LOGGER = get_logger() class SAMLSSOView(PolicyAccessView): - """ "SAML SSO Base View, which plans a flow and injects our final stage. + """SAML SSO Base View, which plans a flow and injects our final stage. Calls get/post handler.""" def resolve_provider_application(self): @@ -120,14 +120,20 @@ class SAMLSSOBindingPOSTView(SAMLSSOView): def check_saml_request(self) -> Optional[HttpRequest]: """Handle POST bindings""" - if REQUEST_KEY_SAML_REQUEST not in self.request.POST: + payload = self.request.POST + # Restore the post body from the session + # This happens when using POST bindings but the user isn't logged in + # (user gets redirected and POST body is 'lost') + if SESSION_KEY_POST in self.request.session: + payload = self.request.session[SESSION_KEY_POST] + if REQUEST_KEY_SAML_REQUEST not in payload: LOGGER.info("check_saml_request: SAML payload missing") return bad_request_message(self.request, "The SAML request payload is missing.") try: auth_n_request = AuthNRequestParser(self.provider).parse( - self.request.POST[REQUEST_KEY_SAML_REQUEST], - self.request.POST.get(REQUEST_KEY_RELAY_STATE), + payload[REQUEST_KEY_SAML_REQUEST], + payload.get(REQUEST_KEY_RELAY_STATE), ) self.request.session[SESSION_KEY_AUTH_N_REQUEST] = auth_n_request except CannotHandleAssertion as exc: diff --git a/authentik/root/settings.py b/authentik/root/settings.py index 4099cc7578..8feb335d70 100644 --- a/authentik/root/settings.py +++ b/authentik/root/settings.py @@ -424,7 +424,7 @@ if _ERROR_REPORTING: ], before_send=before_send, release=f"authentik@{__version__}", - traces_sample_rate=float(CONFIG.y("error_reporting.sample_rate", 0.4)), + traces_sample_rate=float(CONFIG.y("error_reporting.sample_rate", 0.5)), environment=CONFIG.y("error_reporting.environment", "customer"), send_default_pii=CONFIG.y_bool("error_reporting.send_pii", False), ) diff --git a/authentik/sources/ldap/api.py b/authentik/sources/ldap/api.py index aae8e643ac..7b1413c4b0 100644 --- a/authentik/sources/ldap/api.py +++ b/authentik/sources/ldap/api.py @@ -43,6 +43,7 @@ class LDAPSourceSerializer(SourceSerializer): model = LDAPSource fields = SourceSerializer.Meta.fields + [ "server_uri", + "peer_certificate", "bind_cn", "bind_password", "start_tls", @@ -73,11 +74,9 @@ class LDAPSourceViewSet(UsedByMixin, ModelViewSet): "name", "slug", "enabled", - "authentication_flow", - "enrollment_flow", - "policy_engine_mode", "server_uri", "bind_cn", + "peer_certificate", "start_tls", "base_dn", "additional_user_dn", diff --git a/authentik/sources/ldap/auth.py b/authentik/sources/ldap/auth.py index 7b8233c7f0..608a279746 100644 --- a/authentik/sources/ldap/auth.py +++ b/authentik/sources/ldap/auth.py @@ -58,7 +58,7 @@ class LDAPBackend(InbuiltBackend): LOGGER.debug("Attempting Binding as user", user=user) try: temp_connection = ldap3.Connection( - source.connection.server, + source.server, user=user.attributes.get(LDAP_DISTINGUISHED_NAME), password=password, raise_exceptions=True, diff --git a/authentik/sources/ldap/migrations/0002_auto_20211203_0900.py b/authentik/sources/ldap/migrations/0002_auto_20211203_0900.py new file mode 100644 index 0000000000..2161b7cbf8 --- /dev/null +++ b/authentik/sources/ldap/migrations/0002_auto_20211203_0900.py @@ -0,0 +1,38 @@ +# Generated by Django 3.2.9 on 2021-12-03 09:00 + +import django.db.models.deletion +from django.db import migrations, models + +import authentik.sources.ldap.models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_crypto", "0003_certificatekeypair_managed"), + ("authentik_sources_ldap", "0001_squashed_0012_auto_20210812_1703"), + ] + + operations = [ + migrations.AddField( + model_name="ldapsource", + name="peer_certificate", + field=models.ForeignKey( + default=None, + help_text="Optionally verify the LDAP Server's Certificate against the CA Chain in this keypair.", + null=True, + on_delete=django.db.models.deletion.SET_DEFAULT, + to="authentik_crypto.certificatekeypair", + ), + ), + migrations.AlterField( + model_name="ldapsource", + name="server_uri", + field=models.TextField( + validators=[ + authentik.sources.ldap.models.MultiURLValidator(schemes=["ldap", "ldaps"]) + ], + verbose_name="Server URI", + ), + ), + ] diff --git a/authentik/sources/ldap/models.py b/authentik/sources/ldap/models.py index c5a6c123ae..ade91978e0 100644 --- a/authentik/sources/ldap/models.py +++ b/authentik/sources/ldap/models.py @@ -1,24 +1,48 @@ """authentik LDAP Models""" -from typing import Optional, Type +from ssl import CERT_REQUIRED +from typing import Type from django.db import models from django.utils.translation import gettext_lazy as _ -from ldap3 import ALL, Connection, Server +from ldap3 import ALL, RANDOM, Connection, Server, ServerPool, Tls from rest_framework.serializers import Serializer from authentik.core.models import Group, PropertyMapping, Source +from authentik.crypto.models import CertificateKeyPair from authentik.lib.models import DomainlessURLValidator LDAP_TIMEOUT = 15 +class MultiURLValidator(DomainlessURLValidator): + """Same as DomainlessURLValidator but supports multiple URLs separated with a comma.""" + + def __call__(self, value: str): + if "," in value: + for url in value.split(","): + super().__call__(url) + else: + super().__call__(value) + + class LDAPSource(Source): """Federate LDAP Directory with authentik, or create new accounts in LDAP.""" server_uri = models.TextField( - validators=[DomainlessURLValidator(schemes=["ldap", "ldaps"])], + validators=[MultiURLValidator(schemes=["ldap", "ldaps"])], verbose_name=_("Server URI"), ) + peer_certificate = models.ForeignKey( + CertificateKeyPair, + on_delete=models.SET_DEFAULT, + default=None, + null=True, + help_text=_( + "Optionally verify the LDAP Server's Certificate " + "against the CA Chain in this keypair." + ), + ) + bind_cn = models.TextField(verbose_name=_("Bind CN"), blank=True) bind_password = models.TextField(blank=True) start_tls = models.BooleanField(default=False, verbose_name=_("Enable Start TLS")) @@ -82,25 +106,40 @@ class LDAPSource(Source): return LDAPSourceSerializer - _connection: Optional[Connection] = None + @property + def server(self) -> Server: + """Get LDAP Server/ServerPool""" + servers = [] + tls = Tls() + if self.peer_certificate: + tls = Tls(ca_certs_data=self.peer_certificate.certificate_data, validate=CERT_REQUIRED) + kwargs = { + "get_info": ALL, + "connect_timeout": LDAP_TIMEOUT, + "tls": tls, + } + if "," in self.server_uri: + for server in self.server_uri.split(","): + servers.append(Server(server, **kwargs)) + else: + servers = [Server(self.server_uri, **kwargs)] + return ServerPool(servers, RANDOM, active=True, exhaust=True) @property def connection(self) -> Connection: """Get a fully connected and bound LDAP Connection""" - if not self._connection: - server = Server(self.server_uri, get_info=ALL, connect_timeout=LDAP_TIMEOUT) - self._connection = Connection( - server, - raise_exceptions=True, - user=self.bind_cn, - password=self.bind_password, - receive_timeout=LDAP_TIMEOUT, - ) + connection = Connection( + self.server, + raise_exceptions=True, + user=self.bind_cn, + password=self.bind_password, + receive_timeout=LDAP_TIMEOUT, + ) - self._connection.bind() - if self.start_tls: - self._connection.start_tls() - return self._connection + connection.bind() + if self.start_tls: + connection.start_tls() + return connection class Meta: diff --git a/authentik/sources/ldap/sync/groups.py b/authentik/sources/ldap/sync/groups.py index 318038a14a..99c1793ad5 100644 --- a/authentik/sources/ldap/sync/groups.py +++ b/authentik/sources/ldap/sync/groups.py @@ -51,7 +51,7 @@ class GroupLDAPSynchronizer(BaseLDAPSynchronizer): }, defaults, ) - except (IntegrityError, FieldError) as exc: + except (IntegrityError, FieldError, TypeError) as exc: Event.new( EventAction.CONFIGURATION_ERROR, message=( diff --git a/authentik/sources/ldap/sync/users.py b/authentik/sources/ldap/sync/users.py index 00a1574b79..8038303dab 100644 --- a/authentik/sources/ldap/sync/users.py +++ b/authentik/sources/ldap/sync/users.py @@ -45,7 +45,7 @@ class UserLDAPSynchronizer(BaseLDAPSynchronizer): ak_user, created = self.update_or_create_attributes( User, {f"attributes__{LDAP_UNIQUENESS}": uniq}, defaults ) - except (IntegrityError, FieldError) as exc: + except (IntegrityError, FieldError, TypeError) as exc: Event.new( EventAction.CONFIGURATION_ERROR, message=( diff --git a/authentik/sources/ldap/tasks.py b/authentik/sources/ldap/tasks.py index b431c4a987..d83786e2f6 100644 --- a/authentik/sources/ldap/tasks.py +++ b/authentik/sources/ldap/tasks.py @@ -39,7 +39,7 @@ def ldap_sync(self: MonitoredTask, source_pk: str, sync_class: str): # to set the state with return sync = path_to_class(sync_class) - self.set_uid(f"{slugify(source.name)}-{sync.__name__}") + self.set_uid(f"{slugify(source.name)}_{sync.__name__.replace('LDAPSynchronizer', '').lower()}") try: sync_inst = sync(source) count = sync_inst.sync() diff --git a/authentik/sources/ldap/tests/test_sync.py b/authentik/sources/ldap/tests/test_sync.py index a26f483785..af8e9d71b2 100644 --- a/authentik/sources/ldap/tests/test_sync.py +++ b/authentik/sources/ldap/tests/test_sync.py @@ -120,9 +120,9 @@ class LDAPSyncTests(TestCase): self.source.property_mappings_group.set( LDAPPropertyMapping.objects.filter(managed="goauthentik.io/sources/ldap/default-name") ) - self.source.save() connection = PropertyMock(return_value=mock_ad_connection(LDAP_PASSWORD)) with patch("authentik.sources.ldap.models.LDAPSource.connection", connection): + self.source.save() group_sync = GroupLDAPSynchronizer(self.source) group_sync.sync() membership_sync = MembershipLDAPSynchronizer(self.source) @@ -143,9 +143,9 @@ class LDAPSyncTests(TestCase): self.source.property_mappings_group.set( LDAPPropertyMapping.objects.filter(managed="goauthentik.io/sources/ldap/openldap-cn") ) - self.source.save() connection = PropertyMock(return_value=mock_slapd_connection(LDAP_PASSWORD)) with patch("authentik.sources.ldap.models.LDAPSource.connection", connection): + self.source.save() group_sync = GroupLDAPSynchronizer(self.source) group_sync.sync() membership_sync = MembershipLDAPSynchronizer(self.source) @@ -168,9 +168,9 @@ class LDAPSyncTests(TestCase): self.source.property_mappings_group.set( LDAPPropertyMapping.objects.filter(managed="goauthentik.io/sources/ldap/openldap-cn") ) - self.source.save() connection = PropertyMock(return_value=mock_slapd_connection(LDAP_PASSWORD)) with patch("authentik.sources.ldap.models.LDAPSource.connection", connection): + self.source.save() user_sync = UserLDAPSynchronizer(self.source) user_sync.sync() group_sync = GroupLDAPSynchronizer(self.source) diff --git a/authentik/sources/plex/tasks.py b/authentik/sources/plex/tasks.py index fa86f29e16..59fc4594ed 100644 --- a/authentik/sources/plex/tasks.py +++ b/authentik/sources/plex/tasks.py @@ -29,14 +29,15 @@ def check_plex_token(self: MonitoredTask, source_slug: int): auth.get_user_info() self.set_status(TaskResult(TaskResultStatus.SUCCESSFUL, ["Plex token is valid."])) except RequestException as exc: + error = exception_to_string(exc).replace(source.plex_token, "$PLEX_TOKEN") self.set_status( TaskResult( TaskResultStatus.ERROR, - ["Plex token is invalid/an error occurred:", exception_to_string(exc)], + ["Plex token is invalid/an error occurred:", error], ) ) Event.new( EventAction.CONFIGURATION_ERROR, - message=f"Plex token invalid, please re-authenticate source.\n{str(exc)}", + message=f"Plex token invalid, please re-authenticate source.\n{error}", source=source, ).save() diff --git a/authentik/sources/saml/tasks.py b/authentik/sources/saml/tasks.py index 5c495956fd..fb85b3088a 100644 --- a/authentik/sources/saml/tasks.py +++ b/authentik/sources/saml/tasks.py @@ -3,12 +3,7 @@ from django.utils.timezone import now from structlog.stdlib import get_logger from authentik.core.models import AuthenticatedSession, User -from authentik.events.monitored_tasks import ( - MonitoredTask, - TaskResult, - TaskResultStatus, - prefill_task, -) +from authentik.events.monitored_tasks import PrefilledMonitoredTask, TaskResult, TaskResultStatus from authentik.lib.utils.time import timedelta_from_string from authentik.root.celery import CELERY_APP from authentik.sources.saml.models import SAMLSource @@ -16,9 +11,8 @@ from authentik.sources.saml.models import SAMLSource LOGGER = get_logger() -@CELERY_APP.task(bind=True, base=MonitoredTask) -@prefill_task() -def clean_temporary_users(self: MonitoredTask): +@CELERY_APP.task(bind=True, base=PrefilledMonitoredTask) +def clean_temporary_users(self: PrefilledMonitoredTask): """Remove temporary users created by SAML Sources""" _now = now() messages = [] diff --git a/authentik/stages/prompt/migrations/0006_alter_prompt_type.py b/authentik/stages/prompt/migrations/0006_alter_prompt_type.py new file mode 100644 index 0000000000..eeeaf522bd --- /dev/null +++ b/authentik/stages/prompt/migrations/0006_alter_prompt_type.py @@ -0,0 +1,43 @@ +# Generated by Django 3.2.9 on 2021-12-03 09:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_stages_prompt", "0005_alter_prompt_field_key"), + ] + + operations = [ + migrations.AlterField( + model_name="prompt", + name="type", + field=models.CharField( + choices=[ + ("text", "Text: Simple Text input"), + ( + "text_read_only", + "Text (read-only): Simple Text input, but cannot be edited.", + ), + ( + "username", + "Username: Same as Text input, but checks for and prevents duplicate usernames.", + ), + ("email", "Email: Text field with Email type."), + ( + "password", + "Password: Masked input, password is validated against sources. Policies still have to be applied to this Stage. If two of these are used in the same stage, they are ensured to be identical.", + ), + ("number", "Number"), + ("checkbox", "Checkbox"), + ("date", "Date"), + ("date-time", "Date Time"), + ("separator", "Separator: Static Separator Line"), + ("hidden", "Hidden: Hidden field, can be used to insert data into form."), + ("static", "Static: Static value, displayed as-is."), + ], + max_length=100, + ), + ), + ] diff --git a/authentik/stages/prompt/models.py b/authentik/stages/prompt/models.py index d5ca18bfff..bd6381d366 100644 --- a/authentik/stages/prompt/models.py +++ b/authentik/stages/prompt/models.py @@ -113,6 +113,9 @@ class Prompt(SerializerModel): kwargs["label"] = "" if default: kwargs["default"] = default + # May not set both `required` and `default` + if "default" in kwargs: + kwargs.pop("required", None) return field_class(**kwargs) def save(self, *args, **kwargs): diff --git a/docker-compose.yml b/docker-compose.yml index 93cd34223d..1db46a5c29 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -55,6 +55,7 @@ services: volumes: - ./backups:/backups - ./media:/media + - ./certs:/certs - /var/run/docker.sock:/var/run/docker.sock - ./custom-templates:/templates - geoip:/geoip diff --git a/go.mod b/go.mod index 5fafe72451..66d5e72d32 100644 --- a/go.mod +++ b/go.mod @@ -29,10 +29,11 @@ require ( github.com/prometheus/client_golang v1.11.0 github.com/recws-org/recws v1.3.1 github.com/sirupsen/logrus v1.8.1 - goauthentik.io/api v0.2021104.6 + goauthentik.io/api v0.2021104.7 golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2 // indirect golang.org/x/net v0.0.0-20210510120150-4163338589ed // indirect golang.org/x/oauth2 v0.0.0-20210323180902-22b0adad7558 + golang.org/x/sync v0.0.0-20210220032951-036812b2e83c // indirect google.golang.org/appengine v1.6.7 // indirect gopkg.in/boj/redistore.v1 v1.0.0-20160128113310-fc113767cd6b gopkg.in/square/go-jose.v2 v2.5.1 // indirect diff --git a/go.sum b/go.sum index 93a7fe65c6..0279435df9 100644 --- a/go.sum +++ b/go.sum @@ -561,8 +561,8 @@ go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= -goauthentik.io/api v0.2021104.6 h1:1Vyw1gnVm9D7htUXWTcy7Gg7ldU0V0vIhT8RFo9G/Iw= -goauthentik.io/api v0.2021104.6/go.mod h1:02nnD4FRd8lu8A1+ZuzqownBgvAhdCKzqkKX8v7JMTE= +goauthentik.io/api v0.2021104.7 h1:JWKypuvYWWPqq8c8xLN8qVv5ny8TqsfmLdqNwJM9bZk= +goauthentik.io/api v0.2021104.7/go.mod h1:02nnD4FRd8lu8A1+ZuzqownBgvAhdCKzqkKX8v7JMTE= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= @@ -672,6 +672,8 @@ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= diff --git a/internal/outpost/ldap/constants/constants.go b/internal/outpost/ldap/constants/constants.go index d791544d95..f4a5a612d0 100644 --- a/internal/outpost/ldap/constants/constants.go +++ b/internal/outpost/ldap/constants/constants.go @@ -1,5 +1,11 @@ package constants +const ( + OCTop = "top" + OCDomain = "domain" + OCNSContainer = "nsContainer" +) + const ( OCGroup = "group" OCGroupOfUniqueNames = "groupOfUniqueNames" @@ -19,3 +25,42 @@ const ( OUGroups = "groups" OUVirtualGroups = "virtual-groups" ) + +func GetDomainOCs() map[string]bool { + return map[string]bool{ + OCTop: true, + OCDomain: true, + } +} + +func GetContainerOCs() map[string]bool { + return map[string]bool{ + OCTop: true, + OCNSContainer: true, + } +} + +func GetUserOCs() map[string]bool { + return map[string]bool{ + OCUser: true, + OCOrgPerson: true, + OCInetOrgPerson: true, + OCAKUser: true, + } +} + +func GetGroupOCs() map[string]bool { + return map[string]bool{ + OCGroup: true, + OCGroupOfUniqueNames: true, + OCAKGroup: true, + } +} + +func GetVirtualGroupOCs() map[string]bool { + return map[string]bool{ + OCGroup: true, + OCGroupOfUniqueNames: true, + OCAKVirtualGroup: true, + } +} diff --git a/internal/outpost/ldap/instance.go b/internal/outpost/ldap/instance.go index 21352518f4..1e1d40a4b9 100644 --- a/internal/outpost/ldap/instance.go +++ b/internal/outpost/ldap/instance.go @@ -2,14 +2,20 @@ package ldap import ( "crypto/tls" + "fmt" + "strings" "sync" "github.com/go-openapi/strfmt" + "github.com/nmcclain/ldap" log "github.com/sirupsen/logrus" "goauthentik.io/api" + "goauthentik.io/internal/constants" "goauthentik.io/internal/outpost/ldap/bind" + ldapConstants "goauthentik.io/internal/outpost/ldap/constants" "goauthentik.io/internal/outpost/ldap/flags" "goauthentik.io/internal/outpost/ldap/search" + "goauthentik.io/internal/outpost/ldap/utils" ) type ProviderInstance struct { @@ -50,6 +56,10 @@ func (pi *ProviderInstance) GetBaseGroupDN() string { return pi.GroupDN } +func (pi *ProviderInstance) GetBaseVirtualGroupDN() string { + return pi.VirtualGroupDN +} + func (pi *ProviderInstance) GetBaseUserDN() string { return pi.UserDN } @@ -82,3 +92,77 @@ func (pi *ProviderInstance) GetFlowSlug() string { func (pi *ProviderInstance) GetSearchAllowedGroups() []*strfmt.UUID { return pi.searchAllowedGroups } + +func (pi *ProviderInstance) GetBaseEntry() *ldap.Entry { + return &ldap.Entry{ + DN: pi.GetBaseDN(), + Attributes: []*ldap.EntryAttribute{ + { + Name: "distinguishedName", + Values: []string{pi.GetBaseDN()}, + }, + { + Name: "objectClass", + Values: []string{ldapConstants.OCTop, ldapConstants.OCDomain}, + }, + { + Name: "supportedLDAPVersion", + Values: []string{"3"}, + }, + { + Name: "namingContexts", + Values: []string{ + pi.GetBaseDN(), + pi.GetBaseUserDN(), + pi.GetBaseGroupDN(), + pi.GetBaseVirtualGroupDN(), + }, + }, + { + Name: "vendorName", + Values: []string{"goauthentik.io"}, + }, + { + Name: "vendorVersion", + Values: []string{fmt.Sprintf("authentik LDAP Outpost Version %s (build %s)", constants.VERSION, constants.BUILD())}, + }, + }, + } +} + +func (pi *ProviderInstance) GetNeededObjects(scope int, baseDN string, filterOC string) (bool, bool) { + needUsers := false + needGroups := false + + // We only want to load users/groups if we're actually going to be asked + // for at least one user or group based on the search's base DN and scope. + // + // If our requested base DN doesn't match any of the container DNs, then + // we're probably loading a user or group. If it does, then make sure our + // scope will eventually take us to users or groups. + if (baseDN == pi.BaseDN || strings.HasSuffix(baseDN, pi.UserDN)) && utils.IncludeObjectClass(filterOC, ldapConstants.GetUserOCs()) { + if baseDN != pi.UserDN && baseDN != pi.BaseDN || + baseDN == pi.BaseDN && scope > 1 || + baseDN == pi.UserDN && scope > 0 { + needUsers = true + } + } + + if (baseDN == pi.BaseDN || strings.HasSuffix(baseDN, pi.GroupDN)) && utils.IncludeObjectClass(filterOC, ldapConstants.GetGroupOCs()) { + if baseDN != pi.GroupDN && baseDN != pi.BaseDN || + baseDN == pi.BaseDN && scope > 1 || + baseDN == pi.GroupDN && scope > 0 { + needGroups = true + } + } + + if (baseDN == pi.BaseDN || strings.HasSuffix(baseDN, pi.VirtualGroupDN)) && utils.IncludeObjectClass(filterOC, ldapConstants.GetVirtualGroupOCs()) { + if baseDN != pi.VirtualGroupDN && baseDN != pi.BaseDN || + baseDN == pi.BaseDN && scope > 1 || + baseDN == pi.VirtualGroupDN && scope > 0 { + needUsers = true + } + } + + return needUsers, needGroups +} diff --git a/internal/outpost/ldap/search/direct/direct.go b/internal/outpost/ldap/search/direct/direct.go index eda11b2bb6..40efcee65c 100644 --- a/internal/outpost/ldap/search/direct/direct.go +++ b/internal/outpost/ldap/search/direct/direct.go @@ -4,16 +4,15 @@ import ( "errors" "fmt" "strings" - "sync" log "github.com/sirupsen/logrus" + "golang.org/x/sync/errgroup" "github.com/getsentry/sentry-go" "github.com/nmcclain/ldap" "github.com/prometheus/client_golang/prometheus" "goauthentik.io/api" "goauthentik.io/internal/outpost/ldap/constants" - "goauthentik.io/internal/outpost/ldap/flags" "goauthentik.io/internal/outpost/ldap/group" "goauthentik.io/internal/outpost/ldap/metrics" "goauthentik.io/internal/outpost/ldap/search" @@ -35,26 +34,11 @@ func NewDirectSearcher(si server.LDAPServerInstance) *DirectSearcher { return ds } -func (ds *DirectSearcher) SearchMe(req *search.Request, f flags.UserFlags) (ldap.ServerSearchResult, error) { - if f.UserInfo == nil { - u, _, err := ds.si.GetAPIClient().CoreApi.CoreUsersRetrieve(req.Context(), f.UserPk).Execute() - if err != nil { - req.Log().WithError(err).Warning("Failed to get user info") - return ldap.ServerSearchResult{ResultCode: ldap.LDAPResultOperationsError}, fmt.Errorf("failed to get userinfo") - } - f.UserInfo = &u - } - entries := make([]*ldap.Entry, 1) - entries[0] = ds.si.UserEntry(*f.UserInfo) - return ldap.ServerSearchResult{Entries: entries, Referrals: []string{}, Controls: []ldap.Control{}, ResultCode: ldap.LDAPResultSuccess}, nil -} - func (ds *DirectSearcher) Search(req *search.Request) (ldap.ServerSearchResult, error) { accsp := sentry.StartSpan(req.Context(), "authentik.providers.ldap.search.check_access") - baseDN := strings.ToLower("," + ds.si.GetBaseDN()) + baseDN := strings.ToLower(ds.si.GetBaseDN()) - entries := []*ldap.Entry{} - filterEntity, err := ldap.GetFilterObjectClass(req.Filter) + filterOC, err := ldap.GetFilterObjectClass(req.Filter) if err != nil { metrics.RequestsRejected.With(prometheus.Labels{ "outpost_name": ds.si.GetOutpostName(), @@ -75,7 +59,7 @@ func (ds *DirectSearcher) Search(req *search.Request) (ldap.ServerSearchResult, }).Inc() return ldap.ServerSearchResult{ResultCode: ldap.LDAPResultInsufficientAccessRights}, fmt.Errorf("Search Error: Anonymous BindDN not allowed %s", req.BindDN) } - if !strings.HasSuffix(req.BindDN, baseDN) { + if !strings.HasSuffix(req.BindDN, ","+baseDN) { metrics.RequestsRejected.With(prometheus.Labels{ "outpost_name": ds.si.GetOutpostName(), "type": "search", @@ -98,15 +82,6 @@ func (ds *DirectSearcher) Search(req *search.Request) (ldap.ServerSearchResult, }).Inc() return ldap.ServerSearchResult{ResultCode: ldap.LDAPResultInsufficientAccessRights}, errors.New("access denied") } - - if req.Scope == ldap.ScopeBaseObject { - req.Log().Debug("base scope, showing domain info") - return ds.SearchBase(req, flags.CanSearch) - } - if !flags.CanSearch { - req.Log().Debug("User can't search, showing info about user") - return ds.SearchMe(req, flags) - } accsp.Finish() parsedFilter, err := ldap.CompileFilter(req.Filter) @@ -121,99 +96,176 @@ func (ds *DirectSearcher) Search(req *search.Request) (ldap.ServerSearchResult, return ldap.ServerSearchResult{ResultCode: ldap.LDAPResultOperationsError}, fmt.Errorf("Search Error: error parsing filter: %s", req.Filter) } + entries := make([]*ldap.Entry, 0) + // Create a custom client to set additional headers c := api.NewAPIClient(ds.si.GetAPIClient().GetConfig()) c.GetConfig().AddDefaultHeader("X-authentik-outpost-ldap-query", req.Filter) - switch filterEntity { - default: - metrics.RequestsRejected.With(prometheus.Labels{ - "outpost_name": ds.si.GetOutpostName(), - "type": "search", - "reason": "unhandled_filter_type", - "dn": req.BindDN, - "client": req.RemoteAddr(), - }).Inc() - return ldap.ServerSearchResult{ResultCode: ldap.LDAPResultOperationsError}, fmt.Errorf("Search Error: unhandled filter type: %s [%s]", filterEntity, req.Filter) - case constants.OCGroupOfUniqueNames: - fallthrough - case constants.OCAKGroup: - fallthrough - case constants.OCAKVirtualGroup: - fallthrough - case constants.OCGroup: - wg := sync.WaitGroup{} - wg.Add(2) + scope := req.SearchRequest.Scope + needUsers, needGroups := ds.si.GetNeededObjects(scope, req.BaseDN, filterOC) - gEntries := make([]*ldap.Entry, 0) - uEntries := make([]*ldap.Entry, 0) + if scope >= 0 && req.BaseDN == baseDN { + if utils.IncludeObjectClass(filterOC, constants.GetDomainOCs()) { + entries = append(entries, ds.si.GetBaseEntry()) + } - go func() { - defer wg.Done() + scope -= 1 // Bring it from WholeSubtree to SingleLevel and so on + } + + var users *[]api.User + var groups *[]api.Group + + errs, _ := errgroup.WithContext(req.Context()) + + if needUsers { + errs.Go(func() error { + if flags.CanSearch { + uapisp := sentry.StartSpan(req.Context(), "authentik.providers.ldap.search.api_user") + searchReq, skip := utils.ParseFilterForUser(c.CoreApi.CoreUsersList(uapisp.Context()), parsedFilter, false) + + if skip { + req.Log().Trace("Skip backend request") + return nil + } + + u, _, e := searchReq.Execute() + uapisp.Finish() + + if err != nil { + req.Log().WithError(err).Warning("failed to get users") + return e + } + + users = &u.Results + } else { + if flags.UserInfo == nil { + uapisp := sentry.StartSpan(req.Context(), "authentik.providers.ldap.search.api_user") + u, _, err := c.CoreApi.CoreUsersRetrieve(req.Context(), flags.UserPk).Execute() + uapisp.Finish() + + if err != nil { + req.Log().WithError(err).Warning("Failed to get user info") + return fmt.Errorf("failed to get userinfo") + } + + flags.UserInfo = &u + } + + u := make([]api.User, 1) + u[0] = *flags.UserInfo + + users = &u + } + + return nil + }) + } + + if needGroups { + errs.Go(func() error { gapisp := sentry.StartSpan(req.Context(), "authentik.providers.ldap.search.api_group") searchReq, skip := utils.ParseFilterForGroup(c.CoreApi.CoreGroupsList(gapisp.Context()), parsedFilter, false) if skip { req.Log().Trace("Skip backend request") - return + return nil } - groups, _, err := searchReq.Execute() + + if !flags.CanSearch { + // If they can't search, filter all groups by those they're a member of + searchReq = searchReq.MembersByPk([]int32{flags.UserPk}) + } + + g, _, err := searchReq.Execute() gapisp.Finish() if err != nil { req.Log().WithError(err).Warning("failed to get groups") - return + return err } - req.Log().WithField("count", len(groups.Results)).Trace("Got results from API") + req.Log().WithField("count", len(g.Results)).Trace("Got results from API") - for _, g := range groups.Results { - gEntries = append(gEntries, group.FromAPIGroup(g, ds.si).Entry()) - } - }() - - go func() { - defer wg.Done() - uapisp := sentry.StartSpan(req.Context(), "authentik.providers.ldap.search.api_user") - searchReq, skip := utils.ParseFilterForUser(c.CoreApi.CoreUsersList(uapisp.Context()), parsedFilter, false) - if skip { - req.Log().Trace("Skip backend request") - return - } - users, _, err := searchReq.Execute() - uapisp.Finish() - if err != nil { - req.Log().WithError(err).Warning("failed to get users") - return + if !flags.CanSearch { + for i, results := range g.Results { + // If they can't search, remove any users from the group results except the one we're looking for. + g.Results[i].Users = []int32{flags.UserPk} + for _, u := range results.UsersObj { + if u.Pk == flags.UserPk { + g.Results[i].UsersObj = []api.GroupMember{u} + break + } + } + } } - for _, u := range users.Results { - uEntries = append(uEntries, group.FromAPIUser(u, ds.si).Entry()) - } - }() - wg.Wait() - entries = append(gEntries, uEntries...) - case "": - fallthrough - case constants.OCOrgPerson: - fallthrough - case constants.OCInetOrgPerson: - fallthrough - case constants.OCAKUser: - fallthrough - case constants.OCUser: - uapisp := sentry.StartSpan(req.Context(), "authentik.providers.ldap.search.api_user") - searchReq, skip := utils.ParseFilterForUser(c.CoreApi.CoreUsersList(uapisp.Context()), parsedFilter, false) - if skip { - req.Log().Trace("Skip backend request") - return ldap.ServerSearchResult{Entries: entries, Referrals: []string{}, Controls: []ldap.Control{}, ResultCode: ldap.LDAPResultSuccess}, nil + groups = &g.Results + + return nil + }) + } + + err = errs.Wait() + + if err != nil { + return ldap.ServerSearchResult{ResultCode: ldap.LDAPResultOperationsError}, err + } + + if scope >= 0 && (req.BaseDN == ds.si.GetBaseDN() || strings.HasSuffix(req.BaseDN, ds.si.GetBaseUserDN())) { + singleu := strings.HasSuffix(req.BaseDN, ","+ds.si.GetBaseUserDN()) + + if !singleu && utils.IncludeObjectClass(filterOC, constants.GetContainerOCs()) { + entries = append(entries, utils.GetContainerEntry(filterOC, ds.si.GetBaseUserDN(), constants.OUUsers)) + scope -= 1 } - users, _, err := searchReq.Execute() - uapisp.Finish() - if err != nil { - return ldap.ServerSearchResult{ResultCode: ldap.LDAPResultOperationsError}, fmt.Errorf("API Error: %s", err) + if scope >= 0 && users != nil && utils.IncludeObjectClass(filterOC, constants.GetUserOCs()) { + for _, u := range *users { + entry := ds.si.UserEntry(u) + if req.BaseDN == entry.DN || !singleu { + entries = append(entries, entry) + } + } } - for _, u := range users.Results { - entries = append(entries, ds.si.UserEntry(u)) + + scope += 1 // Return the scope to what it was before we descended + } + + if scope >= 0 && (req.BaseDN == ds.si.GetBaseDN() || strings.HasSuffix(req.BaseDN, ds.si.GetBaseGroupDN())) { + singleg := strings.HasSuffix(req.BaseDN, ","+ds.si.GetBaseGroupDN()) + + if !singleg && utils.IncludeObjectClass(filterOC, constants.GetContainerOCs()) { + entries = append(entries, utils.GetContainerEntry(filterOC, ds.si.GetBaseGroupDN(), constants.OUGroups)) + scope -= 1 + } + + if scope >= 0 && groups != nil && utils.IncludeObjectClass(filterOC, constants.GetGroupOCs()) { + for _, g := range *groups { + entry := group.FromAPIGroup(g, ds.si).Entry() + if req.BaseDN == entry.DN || !singleg { + entries = append(entries, entry) + } + } + } + + scope += 1 // Return the scope to what it was before we descended + } + + if scope >= 0 && (req.BaseDN == ds.si.GetBaseDN() || strings.HasSuffix(req.BaseDN, ds.si.GetBaseVirtualGroupDN())) { + singlevg := strings.HasSuffix(req.BaseDN, ","+ds.si.GetBaseVirtualGroupDN()) + + if !singlevg || utils.IncludeObjectClass(filterOC, constants.GetContainerOCs()) { + entries = append(entries, utils.GetContainerEntry(filterOC, ds.si.GetBaseVirtualGroupDN(), constants.OUVirtualGroups)) + scope -= 1 + } + + if scope >= 0 && users != nil && utils.IncludeObjectClass(filterOC, constants.GetVirtualGroupOCs()) { + for _, u := range *users { + entry := group.FromAPIUser(u, ds.si).Entry() + if req.BaseDN == entry.DN || !singlevg { + entries = append(entries, entry) + } + } } } + return ldap.ServerSearchResult{Entries: entries, Referrals: []string{}, Controls: []ldap.Control{}, ResultCode: ldap.LDAPResultSuccess}, nil } diff --git a/internal/outpost/ldap/search/memory/base.go b/internal/outpost/ldap/search/memory/base.go deleted file mode 100644 index 123d4a7add..0000000000 --- a/internal/outpost/ldap/search/memory/base.go +++ /dev/null @@ -1,54 +0,0 @@ -package memory - -import ( - "fmt" - - "github.com/nmcclain/ldap" - "goauthentik.io/internal/constants" - "goauthentik.io/internal/outpost/ldap/search" -) - -func (ms *MemorySearcher) SearchBase(req *search.Request, authz bool) (ldap.ServerSearchResult, error) { - dn := "" - if authz { - dn = req.SearchRequest.BaseDN - } - return ldap.ServerSearchResult{ - Entries: []*ldap.Entry{ - { - DN: dn, - Attributes: []*ldap.EntryAttribute{ - { - Name: "distinguishedName", - Values: []string{ms.si.GetBaseDN()}, - }, - { - Name: "objectClass", - Values: []string{"top", "domain"}, - }, - { - Name: "supportedLDAPVersion", - Values: []string{"3"}, - }, - { - Name: "namingContexts", - Values: []string{ - ms.si.GetBaseDN(), - ms.si.GetBaseUserDN(), - ms.si.GetBaseGroupDN(), - }, - }, - { - Name: "vendorName", - Values: []string{"goauthentik.io"}, - }, - { - Name: "vendorVersion", - Values: []string{fmt.Sprintf("authentik LDAP Outpost Version %s (build %s)", constants.VERSION, constants.BUILD())}, - }, - }, - }, - }, - Referrals: []string{}, Controls: []ldap.Control{}, ResultCode: ldap.LDAPResultSuccess, - }, nil -} diff --git a/internal/outpost/ldap/search/memory/memory.go b/internal/outpost/ldap/search/memory/memory.go index bb77b56105..c58bd9f40e 100644 --- a/internal/outpost/ldap/search/memory/memory.go +++ b/internal/outpost/ldap/search/memory/memory.go @@ -11,11 +11,11 @@ import ( log "github.com/sirupsen/logrus" "goauthentik.io/api" "goauthentik.io/internal/outpost/ldap/constants" - "goauthentik.io/internal/outpost/ldap/flags" "goauthentik.io/internal/outpost/ldap/group" "goauthentik.io/internal/outpost/ldap/metrics" "goauthentik.io/internal/outpost/ldap/search" "goauthentik.io/internal/outpost/ldap/server" + "goauthentik.io/internal/outpost/ldap/utils" ) type MemorySearcher struct { @@ -37,29 +37,11 @@ func NewMemorySearcher(si server.LDAPServerInstance) *MemorySearcher { return ms } -func (ms *MemorySearcher) SearchMe(req *search.Request, f flags.UserFlags) (ldap.ServerSearchResult, error) { - if f.UserInfo == nil { - for _, u := range ms.users { - if u.Pk == f.UserPk { - f.UserInfo = &u - } - } - if f.UserInfo == nil { - req.Log().WithField("pk", f.UserPk).Warning("User with pk is not in local cache") - return ldap.ServerSearchResult{ResultCode: ldap.LDAPResultOperationsError}, fmt.Errorf("failed to get userinfo") - } - } - entries := make([]*ldap.Entry, 1) - entries[0] = ms.si.UserEntry(*f.UserInfo) - return ldap.ServerSearchResult{Entries: entries, Referrals: []string{}, Controls: []ldap.Control{}, ResultCode: ldap.LDAPResultSuccess}, nil -} - func (ms *MemorySearcher) Search(req *search.Request) (ldap.ServerSearchResult, error) { accsp := sentry.StartSpan(req.Context(), "authentik.providers.ldap.search.check_access") - baseDN := strings.ToLower("," + ms.si.GetBaseDN()) + baseDN := strings.ToLower(ms.si.GetBaseDN()) - entries := []*ldap.Entry{} - filterEntity, err := ldap.GetFilterObjectClass(req.Filter) + filterOC, err := ldap.GetFilterObjectClass(req.Filter) if err != nil { metrics.RequestsRejected.With(prometheus.Labels{ "outpost_name": ms.si.GetOutpostName(), @@ -80,7 +62,7 @@ func (ms *MemorySearcher) Search(req *search.Request) (ldap.ServerSearchResult, }).Inc() return ldap.ServerSearchResult{ResultCode: ldap.LDAPResultInsufficientAccessRights}, fmt.Errorf("Search Error: Anonymous BindDN not allowed %s", req.BindDN) } - if !strings.HasSuffix(req.BindDN, baseDN) { + if !strings.HasSuffix(req.BindDN, ","+baseDN) { metrics.RequestsRejected.With(prometheus.Labels{ "outpost_name": ms.si.GetOutpostName(), "type": "search", @@ -103,52 +85,132 @@ func (ms *MemorySearcher) Search(req *search.Request) (ldap.ServerSearchResult, }).Inc() return ldap.ServerSearchResult{ResultCode: ldap.LDAPResultInsufficientAccessRights}, errors.New("access denied") } - - if req.Scope == ldap.ScopeBaseObject { - req.Log().Debug("base scope, showing domain info") - return ms.SearchBase(req, flags.CanSearch) - } - if !flags.CanSearch { - req.Log().Debug("User can't search, showing info about user") - return ms.SearchMe(req, flags) - } accsp.Finish() - switch filterEntity { - default: - metrics.RequestsRejected.With(prometheus.Labels{ - "outpost_name": ms.si.GetOutpostName(), - "type": "search", - "reason": "unhandled_filter_type", - "dn": req.BindDN, - "client": req.RemoteAddr(), - }).Inc() - return ldap.ServerSearchResult{ResultCode: ldap.LDAPResultOperationsError}, fmt.Errorf("Search Error: unhandled filter type: %s [%s]", filterEntity, req.Filter) - case constants.OCGroupOfUniqueNames: - fallthrough - case constants.OCAKGroup: - fallthrough - case constants.OCAKVirtualGroup: - fallthrough - case constants.OCGroup: - for _, g := range ms.groups { - entries = append(entries, group.FromAPIGroup(g, ms.si).Entry()) + entries := make([]*ldap.Entry, 0) + + scope := req.SearchRequest.Scope + needUsers, needGroups := ms.si.GetNeededObjects(scope, req.BaseDN, filterOC) + + if scope >= 0 && req.BaseDN == baseDN { + if utils.IncludeObjectClass(filterOC, constants.GetDomainOCs()) { + entries = append(entries, ms.si.GetBaseEntry()) } - for _, u := range ms.users { - entries = append(entries, group.FromAPIUser(u, ms.si).Entry()) - } - case "": - fallthrough - case constants.OCOrgPerson: - fallthrough - case constants.OCInetOrgPerson: - fallthrough - case constants.OCAKUser: - fallthrough - case constants.OCUser: - for _, u := range ms.users { - entries = append(entries, ms.si.UserEntry(u)) + + scope -= 1 // Bring it from WholeSubtree to SingleLevel and so on + } + + var users *[]api.User + var groups []*group.LDAPGroup + + if needUsers { + if flags.CanSearch { + users = &ms.users + } else { + if flags.UserInfo == nil { + for i, u := range ms.users { + if u.Pk == flags.UserPk { + flags.UserInfo = &ms.users[i] + } + } + + if flags.UserInfo == nil { + req.Log().WithField("pk", flags.UserPk).Warning("User with pk is not in local cache") + err = fmt.Errorf("failed to get userinfo") + } + } + + u := make([]api.User, 1) + u[0] = *flags.UserInfo + + users = &u } } + + if needGroups { + groups = make([]*group.LDAPGroup, 0) + + for _, g := range ms.groups { + if flags.CanSearch { + groups = append(groups, group.FromAPIGroup(g, ms.si)) + } else { + // If the user cannot search, we're going to only return + // the groups they're in _and_ only return themselves + // as a member. + for _, u := range g.UsersObj { + if flags.UserPk == u.Pk { + // TODO: Is there a better way to clone this object? + fg := api.NewGroup(g.Pk, g.Name, g.Parent, g.ParentName, []int32{flags.UserPk}, []api.GroupMember{u}) + fg.SetAttributes(*g.Attributes) + fg.SetIsSuperuser(*g.IsSuperuser) + groups = append(groups, group.FromAPIGroup(*fg, ms.si)) + break + } + } + } + } + } + + if err != nil { + return ldap.ServerSearchResult{ResultCode: ldap.LDAPResultOperationsError}, err + } + + if scope >= 0 && (req.BaseDN == ms.si.GetBaseDN() || strings.HasSuffix(req.BaseDN, ms.si.GetBaseUserDN())) { + singleu := strings.HasSuffix(req.BaseDN, ","+ms.si.GetBaseUserDN()) + + if !singleu && utils.IncludeObjectClass(filterOC, constants.GetContainerOCs()) { + entries = append(entries, utils.GetContainerEntry(filterOC, ms.si.GetBaseUserDN(), constants.OUUsers)) + scope -= 1 + } + + if scope >= 0 && users != nil && utils.IncludeObjectClass(filterOC, constants.GetUserOCs()) { + for _, u := range *users { + entry := ms.si.UserEntry(u) + if req.BaseDN == entry.DN || !singleu { + entries = append(entries, entry) + } + } + } + + scope += 1 // Return the scope to what it was before we descended + } + + if scope >= 0 && (req.BaseDN == ms.si.GetBaseDN() || strings.HasSuffix(req.BaseDN, ms.si.GetBaseGroupDN())) { + singleg := strings.HasSuffix(req.BaseDN, ","+ms.si.GetBaseGroupDN()) + + if !singleg && utils.IncludeObjectClass(filterOC, constants.GetContainerOCs()) { + entries = append(entries, utils.GetContainerEntry(filterOC, ms.si.GetBaseGroupDN(), constants.OUGroups)) + scope -= 1 + } + + if scope >= 0 && groups != nil && utils.IncludeObjectClass(filterOC, constants.GetGroupOCs()) { + for _, g := range groups { + if req.BaseDN == g.DN || !singleg { + entries = append(entries, g.Entry()) + } + } + } + + scope += 1 // Return the scope to what it was before we descended + } + + if scope >= 0 && (req.BaseDN == ms.si.GetBaseDN() || strings.HasSuffix(req.BaseDN, ms.si.GetBaseVirtualGroupDN())) { + singlevg := strings.HasSuffix(req.BaseDN, ","+ms.si.GetBaseVirtualGroupDN()) + + if !singlevg && utils.IncludeObjectClass(filterOC, constants.GetContainerOCs()) { + entries = append(entries, utils.GetContainerEntry(filterOC, ms.si.GetBaseVirtualGroupDN(), constants.OUVirtualGroups)) + scope -= 1 + } + + if scope >= 0 && users != nil && utils.IncludeObjectClass(filterOC, constants.GetVirtualGroupOCs()) { + for _, u := range *users { + entry := group.FromAPIUser(u, ms.si).Entry() + if req.BaseDN == entry.DN || !singlevg { + entries = append(entries, entry) + } + } + } + } + return ldap.ServerSearchResult{Entries: entries, Referrals: []string{}, Controls: []ldap.Control{}, ResultCode: ldap.LDAPResultSuccess}, nil } diff --git a/internal/outpost/ldap/search/searcher.go b/internal/outpost/ldap/search/searcher.go index 5adb6d2f52..b9394a2123 100644 --- a/internal/outpost/ldap/search/searcher.go +++ b/internal/outpost/ldap/search/searcher.go @@ -1,6 +1,8 @@ package search -import "github.com/nmcclain/ldap" +import ( + "github.com/nmcclain/ldap" +) type Searcher interface { Search(req *Request) (ldap.ServerSearchResult, error) diff --git a/internal/outpost/ldap/server/base.go b/internal/outpost/ldap/server/base.go index 6237964417..4317a383d7 100644 --- a/internal/outpost/ldap/server/base.go +++ b/internal/outpost/ldap/server/base.go @@ -19,6 +19,7 @@ type LDAPServerInstance interface { GetBaseDN() string GetBaseGroupDN() string + GetBaseVirtualGroupDN() string GetBaseUserDN() string GetUserDN(string) string @@ -32,4 +33,7 @@ type LDAPServerInstance interface { GetFlags(string) (flags.UserFlags, bool) SetFlags(string, flags.UserFlags) + + GetBaseEntry() *ldap.Entry + GetNeededObjects(int, string, string) (bool, bool) } diff --git a/internal/outpost/ldap/utils/utils.go b/internal/outpost/ldap/utils/utils.go index ad725b42ff..7bcba5f02d 100644 --- a/internal/outpost/ldap/utils/utils.go +++ b/internal/outpost/ldap/utils/utils.go @@ -5,6 +5,7 @@ import ( "github.com/nmcclain/ldap" log "github.com/sirupsen/logrus" + ldapConstants "goauthentik.io/internal/outpost/ldap/constants" ) func BoolToString(in bool) string { @@ -84,3 +85,35 @@ func MustHaveAttribute(attrs []*ldap.EntryAttribute, name string, value []string } return attrs } + +func IncludeObjectClass(searchOC string, ocs map[string]bool) bool { + if searchOC == "" { + return true + } + + return ocs[searchOC] +} + +func GetContainerEntry(filterOC string, dn string, ou string) *ldap.Entry { + if IncludeObjectClass(filterOC, ldapConstants.GetContainerOCs()) { + return &ldap.Entry{ + DN: dn, + Attributes: []*ldap.EntryAttribute{ + { + Name: "distinguishedName", + Values: []string{dn}, + }, + { + Name: "objectClass", + Values: []string{"top", "nsContainer"}, + }, + { + Name: "commonName", + Values: []string{ou}, + }, + }, + } + } + + return nil +} diff --git a/internal/outpost/proxyv2/application/application.go b/internal/outpost/proxyv2/application/application.go index 24dc153ab1..c88bc9b543 100644 --- a/internal/outpost/proxyv2/application/application.go +++ b/internal/outpost/proxyv2/application/application.go @@ -1,6 +1,7 @@ package application import ( + "context" "crypto/tls" "encoding/gob" "fmt" @@ -52,11 +53,17 @@ func NewApplication(p api.ProxyOutpostConfig, c *http.Client, cs *ak.CryptoStore return nil, fmt.Errorf("failed to parse URL, skipping provider") } - ks := hs256.NewKeySet(*p.ClientSecret) + var ks oidc.KeySet + if contains(p.OidcConfiguration.IdTokenSigningAlgValuesSupported, "HS256") { + ks = hs256.NewKeySet(*p.ClientSecret) + } else { + ctx := context.WithValue(context.Background(), oauth2.HTTPClient, c) + ks = oidc.NewRemoteKeySet(ctx, p.OidcConfiguration.JwksUri) + } var verifier = oidc.NewVerifier(p.OidcConfiguration.Issuer, ks, &oidc.Config{ ClientID: *p.ClientId, - SupportedSigningAlgs: []string{"HS256"}, + SupportedSigningAlgs: []string{"RS256", "HS256"}, }) // Configure an OpenID Connect aware OAuth2 client. @@ -94,14 +101,14 @@ func NewApplication(p api.ProxyOutpostConfig, c *http.Client, cs *ak.CryptoStore if !ok { return l } - return l.WithField("request_username", c.Email) + return l.WithField("request_username", c.PreferredUsername) })) mux.Use(func(inner http.Handler) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { c, _ := a.getClaims(r) user := "" if c != nil { - user = c.Email + user = c.PreferredUsername } before := time.Now() inner.ServeHTTP(rw, r) diff --git a/internal/outpost/proxyv2/application/claims.go b/internal/outpost/proxyv2/application/claims.go index 40cd148a24..4ff89dbffd 100644 --- a/internal/outpost/proxyv2/application/claims.go +++ b/internal/outpost/proxyv2/application/claims.go @@ -13,4 +13,6 @@ type Claims struct { Name string `json:"name"` PreferredUsername string `json:"preferred_username"` Groups []string `json:"groups"` + + RawToken string } diff --git a/internal/outpost/proxyv2/application/mode_common.go b/internal/outpost/proxyv2/application/mode_common.go index b49438e20a..a1430ad1e9 100644 --- a/internal/outpost/proxyv2/application/mode_common.go +++ b/internal/outpost/proxyv2/application/mode_common.go @@ -5,24 +5,34 @@ import ( "fmt" "net/http" "strings" + + "goauthentik.io/internal/constants" ) -func (a *Application) addHeaders(r *http.Request, c *Claims) { +func (a *Application) addHeaders(headers http.Header, c *Claims) { // https://goauthentik.io/docs/providers/proxy/proxy // Legacy headers, remove after 2022.1 - r.Header.Set("X-Auth-Username", c.PreferredUsername) - r.Header.Set("X-Auth-Groups", strings.Join(c.Groups, "|")) - r.Header.Set("X-Forwarded-Email", c.Email) - r.Header.Set("X-Forwarded-Preferred-Username", c.PreferredUsername) - r.Header.Set("X-Forwarded-User", c.Sub) + headers.Set("X-Auth-Username", c.PreferredUsername) + headers.Set("X-Auth-Groups", strings.Join(c.Groups, "|")) + headers.Set("X-Forwarded-Email", c.Email) + headers.Set("X-Forwarded-Preferred-Username", c.PreferredUsername) + headers.Set("X-Forwarded-User", c.Sub) // New headers, unique prefix - r.Header.Set("X-authentik-username", c.PreferredUsername) - r.Header.Set("X-authentik-groups", strings.Join(c.Groups, "|")) - r.Header.Set("X-authentik-email", c.Email) - r.Header.Set("X-authentik-name", c.Name) - r.Header.Set("X-authentik-uid", c.Sub) + headers.Set("X-authentik-username", c.PreferredUsername) + headers.Set("X-authentik-groups", strings.Join(c.Groups, "|")) + headers.Set("X-authentik-email", c.Email) + headers.Set("X-authentik-name", c.Name) + headers.Set("X-authentik-uid", c.Sub) + headers.Set("X-authentik-jwt", c.RawToken) + + // System headers + headers.Set("X-authentik-meta-jwks", a.proxyConfig.OidcConfiguration.JwksUri) + headers.Set("X-authentik-meta-outpost", a.outpostName) + headers.Set("X-authentik-meta-provider", a.proxyConfig.Name) + headers.Set("X-authentik-meta-app", a.proxyConfig.AssignedApplicationSlug) + headers.Set("X-authentik-meta-version", constants.OutpostUserAgent()) userAttributes := c.Proxy.UserAttributes // Attempt to set basic auth based on user's attributes @@ -39,7 +49,7 @@ func (a *Application) addHeaders(r *http.Request, c *Claims) { } authVal := base64.StdEncoding.EncodeToString([]byte(username + ":" + password)) a.log.WithField("username", username).Trace("setting http basic auth") - r.Header["Authorization"] = []string{fmt.Sprintf("Basic %s", authVal)} + headers.Set("Authorization", fmt.Sprintf("Basic %s", authVal)) } // Check if user has additional headers set that we should sent if additionalHeaders, ok := userAttributes["additionalHeaders"].(map[string]interface{}); ok { @@ -48,15 +58,7 @@ func (a *Application) addHeaders(r *http.Request, c *Claims) { return } for key, value := range additionalHeaders { - r.Header.Set(key, toString(value)) - } - } -} - -func copyHeadersToResponse(rw http.ResponseWriter, r *http.Request) { - for headerKey, headers := range r.Header { - for _, value := range headers { - rw.Header().Set(headerKey, value) + headers.Set(key, toString(value)) } } } diff --git a/internal/outpost/proxyv2/application/mode_forward.go b/internal/outpost/proxyv2/application/mode_forward.go index c5e25cb55a..889e115c27 100644 --- a/internal/outpost/proxyv2/application/mode_forward.go +++ b/internal/outpost/proxyv2/application/mode_forward.go @@ -26,8 +26,9 @@ func (a *Application) configureForward() error { func (a *Application) forwardHandleTraefik(rw http.ResponseWriter, r *http.Request) { claims, err := a.getClaims(r) if claims != nil && err == nil { - a.addHeaders(r, claims) - copyHeadersToResponse(rw, r) + a.addHeaders(rw.Header(), claims) + rw.Header().Set("User-Agent", r.Header.Get("User-Agent")) + a.log.WithField("headers", rw.Header()).Trace("headers written to forward_auth") return } else if claims == nil && a.IsAllowlisted(r) { a.log.Trace("path can be accessed without authentication") @@ -69,9 +70,10 @@ func (a *Application) forwardHandleTraefik(rw http.ResponseWriter, r *http.Reque func (a *Application) forwardHandleNginx(rw http.ResponseWriter, r *http.Request) { claims, err := a.getClaims(r) if claims != nil && err == nil { - a.addHeaders(r, claims) - copyHeadersToResponse(rw, r) + a.addHeaders(rw.Header(), claims) + rw.Header().Set("User-Agent", r.Header.Get("User-Agent")) rw.WriteHeader(200) + a.log.WithField("headers", rw.Header()).Trace("headers written to forward_auth") return } else if claims == nil && a.IsAllowlisted(r) { a.log.Trace("path can be accessed without authentication") diff --git a/internal/outpost/proxyv2/application/mode_proxy.go b/internal/outpost/proxyv2/application/mode_proxy.go index 7c25f09324..72d8311571 100644 --- a/internal/outpost/proxyv2/application/mode_proxy.go +++ b/internal/outpost/proxyv2/application/mode_proxy.go @@ -39,7 +39,7 @@ func (a *Application) configureProxy() error { a.redirectToStart(rw, r) return } else { - a.addHeaders(r, claims) + a.addHeaders(r.Header, claims) } before := time.Now() rp.ServeHTTP(rw, r) diff --git a/internal/outpost/proxyv2/application/oauth_callback.go b/internal/outpost/proxyv2/application/oauth_callback.go index 7f29371840..acd66cf313 100644 --- a/internal/outpost/proxyv2/application/oauth_callback.go +++ b/internal/outpost/proxyv2/application/oauth_callback.go @@ -45,5 +45,6 @@ func (a *Application) redeemCallback(r *http.Request, shouldState string) (*Clai if err := idToken.Claims(&claims); err != nil { return nil, err } + claims.RawToken = rawIDToken return claims, nil } diff --git a/internal/outpost/proxyv2/application/utils.go b/internal/outpost/proxyv2/application/utils.go index fad5846204..d2423e125b 100644 --- a/internal/outpost/proxyv2/application/utils.go +++ b/internal/outpost/proxyv2/application/utils.go @@ -56,3 +56,12 @@ func toString(in interface{}) string { } return "" } + +func contains(s []string, e string) bool { + for _, a := range s { + if a == e { + return true + } + } + return false +} diff --git a/internal/outpost/proxyv2/handlers.go b/internal/outpost/proxyv2/handlers.go index 95045cc9ca..2fee7e2268 100644 --- a/internal/outpost/proxyv2/handlers.go +++ b/internal/outpost/proxyv2/handlers.go @@ -10,7 +10,6 @@ import ( "github.com/prometheus/client_golang/prometheus" "goauthentik.io/internal/outpost/proxyv2/metrics" "goauthentik.io/internal/utils/web" - staticWeb "goauthentik.io/web" ) func (ps *ProxyServer) HandlePing(rw http.ResponseWriter, r *http.Request) { @@ -29,9 +28,9 @@ func (ps *ProxyServer) HandlePing(rw http.ResponseWriter, r *http.Request) { } func (ps *ProxyServer) HandleStatic(rw http.ResponseWriter, r *http.Request) { - staticFs := http.FileServer(http.FS(staticWeb.StaticDist)) + staticFs := http.FileServer(http.Dir("./web/dist/")) before := time.Now() - web.DisableIndex(http.StripPrefix("/akprox/static", staticFs)).ServeHTTP(rw, r) + web.DisableIndex(http.StripPrefix("/akprox/static/dist", staticFs)).ServeHTTP(rw, r) after := time.Since(before) metrics.Requests.With(prometheus.Labels{ "outpost_name": ps.akAPI.Outpost.Name, diff --git a/internal/web/static.go b/internal/web/static.go index 0cb0346966..7e7cf67725 100644 --- a/internal/web/static.go +++ b/internal/web/static.go @@ -9,33 +9,19 @@ import ( "goauthentik.io/internal/constants" "goauthentik.io/internal/utils/web" staticWeb "goauthentik.io/web" - staticDocs "goauthentik.io/website" ) func (ws *WebServer) configureStatic() { statRouter := ws.lh.NewRoute().Subrouter() + statRouter.Use(ws.staticHeaderMiddleware) indexLessRouter := statRouter.NewRoute().Subrouter() indexLessRouter.Use(web.DisableIndex) // Media files, always local fs := http.FileServer(http.Dir(config.G.Paths.Media)) - var distHandler http.Handler - var distFs http.Handler - var authentikHandler http.Handler - var helpHandler http.Handler - if config.G.Debug || config.G.Web.LoadLocalFiles { - ws.log.Debug("Using local static files") - distFs = http.FileServer(http.Dir("./web/dist")) - distHandler = http.StripPrefix("/static/dist/", distFs) - authentikHandler = http.StripPrefix("/static/authentik/", http.FileServer(http.Dir("./web/authentik"))) - helpHandler = http.FileServer(http.Dir("./website/help/")) - } else { - statRouter.Use(ws.staticHeaderMiddleware) - ws.log.Debug("Using packaged static files with aggressive caching") - distFs = http.FileServer(http.FS(staticWeb.StaticDist)) - distHandler = http.StripPrefix("/static", distFs) - authentikHandler = http.StripPrefix("/static", http.FileServer(http.FS(staticWeb.StaticAuthentik))) - helpHandler = http.FileServer(http.FS(staticDocs.Help)) - } + distFs := http.FileServer(http.Dir("./web/dist")) + distHandler := http.StripPrefix("/static/dist/", distFs) + authentikHandler := http.StripPrefix("/static/authentik/", http.FileServer(http.Dir("./web/authentik"))) + helpHandler := http.FileServer(http.Dir("./website/help/")) indexLessRouter.PathPrefix("/static/dist/").Handler(distHandler) indexLessRouter.PathPrefix("/static/authentik/").Handler(authentikHandler) diff --git a/lifecycle/ak b/lifecycle/ak index d1d3f50256..accd86db8e 100755 --- a/lifecycle/ak +++ b/lifecycle/ak @@ -28,7 +28,7 @@ function check_if_root { GROUP="authentik:${GROUP_NAME}" fi # Fix permissions of backups and media - chown -R authentik:authentik /media /backups + chown -R authentik:authentik /media /backups /certs chpst -u authentik:$GROUP env HOME=/authentik $1 } diff --git a/proxy.Dockerfile b/proxy.Dockerfile index 1c4c76a074..2c626eef2d 100644 --- a/proxy.Dockerfile +++ b/proxy.Dockerfile @@ -12,10 +12,6 @@ FROM docker.io/golang:1.17.3-bullseye AS builder WORKDIR /go/src/goauthentik.io COPY . . -COPY --from=web-builder /static/robots.txt /work/web/robots.txt -COPY --from=web-builder /static/security.txt /work/web/security.txt -COPY --from=web-builder /static/dist/ /work/web/dist/ -COPY --from=web-builder /static/authentik/ /work/web/authentik/ ENV CGO_ENABLED=0 RUN go build -o /go/proxy ./cmd/proxy @@ -27,6 +23,10 @@ ARG GIT_BUILD_HASH ENV GIT_BUILD_HASH=$GIT_BUILD_HASH COPY --from=builder /go/proxy / +COPY --from=web-builder /static/robots.txt /web/robots.txt +COPY --from=web-builder /static/security.txt /web/security.txt +COPY --from=web-builder /static/dist/ /web/dist/ +COPY --from=web-builder /static/authentik/ /web/authentik/ HEALTHCHECK CMD [ "wget", "--spider", "http://localhost:9300/akprox/ping" ] diff --git a/schema.yml b/schema.yml index 02dd12ca7a..85f8075162 100644 --- a/schema.yml +++ b/schema.yml @@ -12058,11 +12058,6 @@ paths: name: additional_user_dn schema: type: string - - in: query - name: authentication_flow - schema: - type: string - format: uuid - in: query name: base_dn schema: @@ -12075,11 +12070,6 @@ paths: name: enabled schema: type: boolean - - in: query - name: enrollment_flow - schema: - type: string - format: uuid - in: query name: group_membership_field schema: @@ -12115,12 +12105,10 @@ paths: schema: type: integer - in: query - name: policy_engine_mode + name: peer_certificate schema: type: string - enum: - - all - - any + format: uuid - in: query name: property_mappings schema: @@ -22461,6 +22449,12 @@ components: server_uri: type: string format: uri + peer_certificate: + type: string + format: uuid + nullable: true + description: Optionally verify the LDAP Server's Certificate against the + CA Chain in this keypair. bind_cn: type: string start_tls: @@ -22558,6 +22552,12 @@ components: type: string minLength: 1 format: uri + peer_certificate: + type: string + format: uuid + nullable: true + description: Optionally verify the LDAP Server's Certificate against the + CA Chain in this keypair. bind_cn: type: string bind_password: @@ -27181,6 +27181,12 @@ components: type: string minLength: 1 format: uri + peer_certificate: + type: string + format: uuid + nullable: true + description: Optionally verify the LDAP Server's Certificate against the + CA Chain in this keypair. bind_cn: type: string bind_password: @@ -28984,7 +28990,17 @@ components: items: type: string readOnly: true + assigned_application_slug: + type: string + description: Internal application name, used in URLs. + readOnly: true + assigned_application_name: + type: string + description: Application's display Name. + readOnly: true required: + - assigned_application_name + - assigned_application_slug - external_host - name - oidc_configuration diff --git a/web/icons/icon_centeed.png b/web/icons/icon_centeed.png deleted file mode 100644 index 37c2662e90..0000000000 Binary files a/web/icons/icon_centeed.png and /dev/null differ diff --git a/web/icons/icon_discord.png b/web/icons/icon_discord.png new file mode 100644 index 0000000000..d2a58af6a5 Binary files /dev/null and b/web/icons/icon_discord.png differ diff --git a/web/icons/icon_discord_christmas.png b/web/icons/icon_discord_christmas.png new file mode 100644 index 0000000000..a43a8280c5 Binary files /dev/null and b/web/icons/icon_discord_christmas.png differ diff --git a/web/package-lock.json b/web/package-lock.json index 006bc6495b..30283ea8ba 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -15,7 +15,7 @@ "@babel/preset-env": "^7.16.4", "@babel/preset-typescript": "^7.16.0", "@fortawesome/fontawesome-free": "^5.15.4", - "@goauthentik/api": "^2021.10.4-1638190705", + "@goauthentik/api": "^2021.10.4-1638522576", "@jackfranklin/rollup-plugin-markdown": "^0.3.0", "@lingui/cli": "^3.13.0", "@lingui/core": "^3.13.0", @@ -1708,9 +1708,9 @@ } }, "node_modules/@goauthentik/api": { - "version": "2021.10.4-1638190705", - "resolved": "https://registry.npmjs.org/@goauthentik/api/-/api-2021.10.4-1638190705.tgz", - "integrity": "sha512-fEtKGX8F9BDnYWIF9vTxLEkqGkABRl+0M2sgCOd4XqiflNveDEQYMVZAK5yvNzCK8L4wIcbn7y8s/lCncEKJ2Q==" + "version": "2021.10.4-1638522576", + "resolved": "https://registry.npmjs.org/@goauthentik/api/-/api-2021.10.4-1638522576.tgz", + "integrity": "sha512-ojnhGFPnEHXPeMULMtRUBoRVB8k0B73l3O5UL8NSipaY2ZC7jSscIQKDZWz7yvvx9NPMV34kKJ9NK8N+/jzfgw==" }, "node_modules/@humanwhocodes/config-array": { "version": "0.6.0", @@ -9895,9 +9895,9 @@ "integrity": "sha512-eYm8vijH/hpzr/6/1CJ/V/Eb1xQFW2nnUKArb3z+yUWv7HTwj6M7SP957oMjfZjAHU6qpoNc2wQvIxBLWYa/Jg==" }, "@goauthentik/api": { - "version": "2021.10.4-1638190705", - "resolved": "https://registry.npmjs.org/@goauthentik/api/-/api-2021.10.4-1638190705.tgz", - "integrity": "sha512-fEtKGX8F9BDnYWIF9vTxLEkqGkABRl+0M2sgCOd4XqiflNveDEQYMVZAK5yvNzCK8L4wIcbn7y8s/lCncEKJ2Q==" + "version": "2021.10.4-1638522576", + "resolved": "https://registry.npmjs.org/@goauthentik/api/-/api-2021.10.4-1638522576.tgz", + "integrity": "sha512-ojnhGFPnEHXPeMULMtRUBoRVB8k0B73l3O5UL8NSipaY2ZC7jSscIQKDZWz7yvvx9NPMV34kKJ9NK8N+/jzfgw==" }, "@humanwhocodes/config-array": { "version": "0.6.0", diff --git a/web/package.json b/web/package.json index eb0e089b19..1198f78d52 100644 --- a/web/package.json +++ b/web/package.json @@ -51,7 +51,7 @@ "@babel/preset-env": "^7.16.4", "@babel/preset-typescript": "^7.16.0", "@fortawesome/fontawesome-free": "^5.15.4", - "@goauthentik/api": "^2021.10.4-1638190705", + "@goauthentik/api": "^2021.10.4-1638522576", "@jackfranklin/rollup-plugin-markdown": "^0.3.0", "@lingui/cli": "^3.13.0", "@lingui/core": "^3.13.0", diff --git a/web/security.txt b/web/security.txt index 8e62db8c16..32a8825430 100644 --- a/web/security.txt +++ b/web/security.txt @@ -1,4 +1,4 @@ Contact: mailto:security@beryju.org -Expires: Sat, 1 Jan 2022 00:00 +0200 +Expires: Sat, 1 Jan 2023 00:00 +0200 Preferred-Languages: en, de Policy: https://github.com/goauthentik/authentik/blob/master/SECURITY.md diff --git a/web/src/elements/table/Table.ts b/web/src/elements/table/Table.ts index 7753f3f62f..7a73b6ba64 100644 --- a/web/src/elements/table/Table.ts +++ b/web/src/elements/table/Table.ts @@ -17,6 +17,7 @@ import { AKResponse } from "../../api/Client"; import { EVENT_REFRESH } from "../../constants"; import { groupBy } from "../../utils"; import "../EmptyState"; +import "../buttons/SpinnerButton"; import "../chips/Chip"; import "../chips/ChipGroup"; import { getURLParam, updateURLParams } from "../router/RouteMatch"; @@ -162,12 +163,12 @@ export abstract class Table extends LitElement { }); } - public fetch(): void { + public async fetch(): Promise { if (this.isLoading) { return; } this.isLoading = true; - this.apiEndpoint(this.page) + return this.apiEndpoint(this.page) .then((r) => { this.data = r; this.page = r.pagination.current; @@ -319,19 +320,14 @@ export abstract class Table extends LitElement { } renderToolbar(): TemplateResult { - return html``; + ${t`Refresh`}`; } renderToolbarSelected(): TemplateResult { @@ -350,12 +346,7 @@ export abstract class Table extends LitElement { value=${ifDefined(this.search)} .onSearch=${(value: string) => { this.search = value; - this.dispatchEvent( - new CustomEvent(EVENT_REFRESH, { - bubbles: true, - composed: true, - }), - ); + this.fetch(); updateURLParams({ search: value, }); @@ -382,12 +373,7 @@ export abstract class Table extends LitElement { .pages=${this.data?.pagination} .pageChangeHandler=${(page: number) => { this.page = page; - this.dispatchEvent( - new CustomEvent(EVENT_REFRESH, { - bubbles: true, - composed: true, - }), - ); + this.fetch(); }} > ` @@ -442,12 +428,7 @@ export abstract class Table extends LitElement { .pages=${this.data?.pagination} .pageChangeHandler=${(page: number) => { this.page = page; - this.dispatchEvent( - new CustomEvent(EVENT_REFRESH, { - bubbles: true, - composed: true, - }), - ); + this.fetch(); }} > diff --git a/web/src/flows/stages/authenticator_validate/AuthenticatorValidateStage.ts b/web/src/flows/stages/authenticator_validate/AuthenticatorValidateStage.ts index 7418f6a10a..aadd5e6687 100644 --- a/web/src/flows/stages/authenticator_validate/AuthenticatorValidateStage.ts +++ b/web/src/flows/stages/authenticator_validate/AuthenticatorValidateStage.ts @@ -100,9 +100,7 @@ export class AuthenticatorValidateStage return html`

${t`Duo push-notifications`}

- ${t`Receive a push notification on your phone to prove your identity.`} + ${t`Receive a push notification on your device.`}
`; case DeviceClassesEnum.Webauthn: return html` diff --git a/web/src/locales/en.po b/web/src/locales/en.po index ba30103b82..f76436c1d4 100644 --- a/web/src/locales/en.po +++ b/web/src/locales/en.po @@ -2387,6 +2387,14 @@ msgstr "Internal host" msgid "Internal host SSL Validation" msgstr "Internal host SSL Validation" +#: src/pages/policies/reputation/ReputationPolicyForm.ts +msgid "" +"Invalid login attempts will decrease the score for the client's IP, and the\n" +"username they are attempting to login as, by one." +msgstr "" +"Invalid login attempts will decrease the score for the client's IP, and the\n" +"username they are attempting to login as, by one." + #: src/pages/flows/StageBindingForm.ts msgid "Invalid response action" msgstr "Invalid response action" @@ -2608,6 +2616,7 @@ msgstr "Loading" #: src/pages/sources/ldap/LDAPSourceForm.ts #: src/pages/sources/ldap/LDAPSourceForm.ts #: src/pages/sources/ldap/LDAPSourceForm.ts +#: src/pages/sources/ldap/LDAPSourceForm.ts #: src/pages/sources/oauth/OAuthSourceForm.ts #: src/pages/sources/oauth/OAuthSourceForm.ts #: src/pages/sources/plex/PlexSourceForm.ts @@ -2704,6 +2713,14 @@ msgstr "MFA Devices" msgid "Make sure to keep these tokens in a safe place." msgstr "Make sure to keep these tokens in a safe place." +#: src/pages/crypto/CertificateKeyPairListPage.ts +msgid "Managed by authentik" +msgstr "Managed by authentik" + +#: src/pages/crypto/CertificateKeyPairListPage.ts +msgid "Managed by authentik (Discovered)" +msgstr "Managed by authentik (Discovered)" + #: src/pages/stages/user_write/UserWriteStageForm.ts msgid "Mark newly created users as inactive." msgstr "Mark newly created users as inactive." @@ -3612,8 +3629,8 @@ msgid "Re-evaluate policies" msgstr "Re-evaluate policies" #: src/flows/stages/authenticator_validate/AuthenticatorValidateStage.ts -msgid "Receive a push notification on your phone to prove your identity." -msgstr "Receive a push notification on your phone to prove your identity." +msgid "Receive a push notification on your device." +msgstr "Receive a push notification on your device." #: src/pages/flows/utils.ts #: src/pages/tokens/TokenListPage.ts @@ -4199,6 +4216,10 @@ msgstr "Sources" msgid "Sources of identities, which can either be synced into authentik's database, or can be used by users to authenticate and enroll themselves." msgstr "Sources of identities, which can either be synced into authentik's database, or can be used by users to authenticate and enroll themselves." +#: src/pages/sources/ldap/LDAPSourceForm.ts +msgid "Specify multiple server URIs by separating them with a comma." +msgstr "Specify multiple server URIs by separating them with a comma." + #: src/pages/flows/BoundStagesList.ts #: src/pages/flows/StageBindingForm.ts msgid "Stage" @@ -4739,6 +4760,7 @@ msgstr "TLS Authentication Certificate" #~ msgstr "TLS Server name" #: src/pages/outposts/ServiceConnectionDockerForm.ts +#: src/pages/sources/ldap/LDAPSourceForm.ts msgid "TLS Verification Certificate" msgstr "TLS Verification Certificate" @@ -4830,14 +4852,24 @@ msgstr "The external URL you'll authenticate at. Can be the same domain as authe msgid "The following objects use {objName}" msgstr "The following objects use {objName}" +#: src/pages/policies/reputation/ReputationPolicyForm.ts +#~ msgid "" +#~ "The policy passes when the reputation score is above the threshold, and\n" +#~ "doesn't pass when either or both of the selected options are equal or less than the\n" +#~ "threshold." +#~ msgstr "" +#~ "The policy passes when the reputation score is above the threshold, and\n" +#~ "doesn't pass when either or both of the selected options are equal or less than the\n" +#~ "threshold." + #: src/pages/policies/reputation/ReputationPolicyForm.ts msgid "" -"The policy passes when the reputation score is above the threshold, and\n" -"doesn't pass when either or both of the selected options are equal or less than the\n" +"The policy passes when the reputation score is below the threshold, and\n" +"doesn't pass when either or both of the selected options are equal or above the\n" "threshold." msgstr "" -"The policy passes when the reputation score is above the threshold, and\n" -"doesn't pass when either or both of the selected options are equal or less than the\n" +"The policy passes when the reputation score is below the threshold, and\n" +"doesn't pass when either or both of the selected options are equal or above the\n" "threshold." #: src/pages/policies/dummy/DummyPolicyForm.ts @@ -5647,6 +5679,10 @@ msgstr "When a user returns from the email successfully, their account will be a msgid "When a valid username/email has been entered, and this option is enabled, the user's username and avatar will be shown. Otherwise, the text that the user entered will be shown." msgstr "When a valid username/email has been entered, and this option is enabled, the user's username and avatar will be shown. Otherwise, the text that the user entered will be shown." +#: src/pages/sources/ldap/LDAPSourceForm.ts +msgid "When connecting to an LDAP Server with TLS, certificates are not checked by default. Specify a keypair to validate the remote certificate." +msgstr "When connecting to an LDAP Server with TLS, certificates are not checked by default. Specify a keypair to validate the remote certificate." + #: src/pages/stages/email/EmailStageForm.ts msgid "When enabled, global Email connection settings will be used and connection settings below will be ignored." msgstr "When enabled, global Email connection settings will be used and connection settings below will be ignored." diff --git a/web/src/locales/fr_FR.po b/web/src/locales/fr_FR.po index 9c8dc5b1d1..caccfeeabe 100644 --- a/web/src/locales/fr_FR.po +++ b/web/src/locales/fr_FR.po @@ -2370,6 +2370,12 @@ msgstr "Hôte interne" msgid "Internal host SSL Validation" msgstr "Validation SSL de l'hôte interne" +#: src/pages/policies/reputation/ReputationPolicyForm.ts +msgid "" +"Invalid login attempts will decrease the score for the client's IP, and the\n" +"username they are attempting to login as, by one." +msgstr "" + #: src/pages/flows/StageBindingForm.ts msgid "Invalid response action" msgstr "Action de réponse invalide" @@ -2589,6 +2595,7 @@ msgstr "Chargement en cours" #: src/pages/sources/ldap/LDAPSourceForm.ts #: src/pages/sources/ldap/LDAPSourceForm.ts #: src/pages/sources/ldap/LDAPSourceForm.ts +#: src/pages/sources/ldap/LDAPSourceForm.ts #: src/pages/sources/oauth/OAuthSourceForm.ts #: src/pages/sources/oauth/OAuthSourceForm.ts #: src/pages/sources/plex/PlexSourceForm.ts @@ -2685,6 +2692,14 @@ msgstr "" msgid "Make sure to keep these tokens in a safe place." msgstr "" +#: src/pages/crypto/CertificateKeyPairListPage.ts +msgid "Managed by authentik" +msgstr "" + +#: src/pages/crypto/CertificateKeyPairListPage.ts +msgid "Managed by authentik (Discovered)" +msgstr "" + #: src/pages/stages/user_write/UserWriteStageForm.ts msgid "Mark newly created users as inactive." msgstr "Marquer les utilisateurs nouvellements créés comme inactifs." @@ -3582,8 +3597,12 @@ msgid "Re-evaluate policies" msgstr "Ré-évaluer les politiques" #: src/flows/stages/authenticator_validate/AuthenticatorValidateStage.ts -msgid "Receive a push notification on your phone to prove your identity." -msgstr "Recevez une notification push sur votre téléphone pour prouver votre identité." +msgid "Receive a push notification on your device." +msgstr "" + +#: src/flows/stages/authenticator_validate/AuthenticatorValidateStage.ts +#~ msgid "Receive a push notification on your phone to prove your identity." +#~ msgstr "Recevez une notification push sur votre téléphone pour prouver votre identité." #: src/pages/flows/utils.ts #: src/pages/tokens/TokenListPage.ts @@ -4158,6 +4177,10 @@ msgstr "Sources" msgid "Sources of identities, which can either be synced into authentik's database, or can be used by users to authenticate and enroll themselves." msgstr "Sources d'identités, qui peuvent soit être synchronisées dans la base de données d'Authentik, soit être utilisées par les utilisateurs pour s'authentifier et s'inscrire." +#: src/pages/sources/ldap/LDAPSourceForm.ts +msgid "Specify multiple server URIs by separating them with a comma." +msgstr "" + #: src/pages/flows/BoundStagesList.ts #: src/pages/flows/StageBindingForm.ts msgid "Stage" @@ -4691,6 +4714,7 @@ msgstr "Certificat TLS d'authentification" #~ msgstr "Nom TLS du serveur" #: src/pages/outposts/ServiceConnectionDockerForm.ts +#: src/pages/sources/ldap/LDAPSourceForm.ts msgid "TLS Verification Certificate" msgstr "Certificat de vérification TLS" @@ -4781,12 +4805,19 @@ msgstr "L'URL externe sur laquelle vous vous authentifierez. Cela peut être le msgid "The following objects use {objName}" msgstr "Les objets suivants utilisent {objName}" +#: src/pages/policies/reputation/ReputationPolicyForm.ts +#~ msgid "" +#~ "The policy passes when the reputation score is above the threshold, and\n" +#~ "doesn't pass when either or both of the selected options are equal or less than the\n" +#~ "threshold." +#~ msgstr "La politique est réussie si la note de réputation est au-dessus du seuil, et échoue si au moins l'une des options sélectionnées sont inférieures ou égales au seuil." + #: src/pages/policies/reputation/ReputationPolicyForm.ts msgid "" -"The policy passes when the reputation score is above the threshold, and\n" -"doesn't pass when either or both of the selected options are equal or less than the\n" +"The policy passes when the reputation score is below the threshold, and\n" +"doesn't pass when either or both of the selected options are equal or above the\n" "threshold." -msgstr "La politique est réussie si la note de réputation est au-dessus du seuil, et échoue si au moins l'une des options sélectionnées sont inférieures ou égales au seuil." +msgstr "" #: src/pages/policies/dummy/DummyPolicyForm.ts msgid "The policy takes a random time to execute. This controls the minimum time it will take." @@ -5586,6 +5617,10 @@ msgstr "Lorsqu'un utilisateur revient de l'e-mail avec succès, son compte sera msgid "When a valid username/email has been entered, and this option is enabled, the user's username and avatar will be shown. Otherwise, the text that the user entered will be shown." msgstr "Lorsqu'un nom d'utilisateur/email valide a été saisi, et si cette option est active, le nom d'utilisateur et l'avatar de l'utilisateur seront affichés. Sinon, le texte que l'utilisateur a saisi sera affiché." +#: src/pages/sources/ldap/LDAPSourceForm.ts +msgid "When connecting to an LDAP Server with TLS, certificates are not checked by default. Specify a keypair to validate the remote certificate." +msgstr "" + #: src/pages/stages/email/EmailStageForm.ts msgid "When enabled, global Email connection settings will be used and connection settings below will be ignored." msgstr "Si activé, les paramètres globaux de connexion courriel seront utilisés et les paramètres de connexion ci-dessous seront ignorés." diff --git a/web/src/locales/pseudo-LOCALE.po b/web/src/locales/pseudo-LOCALE.po index c7636c6de5..20cb14f2f2 100644 --- a/web/src/locales/pseudo-LOCALE.po +++ b/web/src/locales/pseudo-LOCALE.po @@ -2379,6 +2379,12 @@ msgstr "" msgid "Internal host SSL Validation" msgstr "" +#: src/pages/policies/reputation/ReputationPolicyForm.ts +msgid "" +"Invalid login attempts will decrease the score for the client's IP, and the\n" +"username they are attempting to login as, by one." +msgstr "" + #: src/pages/flows/StageBindingForm.ts msgid "Invalid response action" msgstr "" @@ -2600,6 +2606,7 @@ msgstr "" #: src/pages/sources/ldap/LDAPSourceForm.ts #: src/pages/sources/ldap/LDAPSourceForm.ts #: src/pages/sources/ldap/LDAPSourceForm.ts +#: src/pages/sources/ldap/LDAPSourceForm.ts #: src/pages/sources/oauth/OAuthSourceForm.ts #: src/pages/sources/oauth/OAuthSourceForm.ts #: src/pages/sources/plex/PlexSourceForm.ts @@ -2696,6 +2703,14 @@ msgstr "" msgid "Make sure to keep these tokens in a safe place." msgstr "" +#: src/pages/crypto/CertificateKeyPairListPage.ts +msgid "Managed by authentik" +msgstr "" + +#: src/pages/crypto/CertificateKeyPairListPage.ts +msgid "Managed by authentik (Discovered)" +msgstr "" + #: src/pages/stages/user_write/UserWriteStageForm.ts msgid "Mark newly created users as inactive." msgstr "" @@ -3604,7 +3619,7 @@ msgid "Re-evaluate policies" msgstr "" #: src/flows/stages/authenticator_validate/AuthenticatorValidateStage.ts -msgid "Receive a push notification on your phone to prove your identity." +msgid "Receive a push notification on your device." msgstr "" #: src/pages/flows/utils.ts @@ -4191,6 +4206,10 @@ msgstr "" msgid "Sources of identities, which can either be synced into authentik's database, or can be used by users to authenticate and enroll themselves." msgstr "" +#: src/pages/sources/ldap/LDAPSourceForm.ts +msgid "Specify multiple server URIs by separating them with a comma." +msgstr "" + #: src/pages/flows/BoundStagesList.ts #: src/pages/flows/StageBindingForm.ts msgid "Stage" @@ -4731,6 +4750,7 @@ msgstr "" #~ msgstr "" #: src/pages/outposts/ServiceConnectionDockerForm.ts +#: src/pages/sources/ldap/LDAPSourceForm.ts msgid "TLS Verification Certificate" msgstr "" @@ -4822,10 +4842,17 @@ msgstr "" msgid "The following objects use {objName}" msgstr "" +#: src/pages/policies/reputation/ReputationPolicyForm.ts +#~ msgid "" +#~ "The policy passes when the reputation score is above the threshold, and\n" +#~ "doesn't pass when either or both of the selected options are equal or less than the\n" +#~ "threshold." +#~ msgstr "" + #: src/pages/policies/reputation/ReputationPolicyForm.ts msgid "" -"The policy passes when the reputation score is above the threshold, and\n" -"doesn't pass when either or both of the selected options are equal or less than the\n" +"The policy passes when the reputation score is below the threshold, and\n" +"doesn't pass when either or both of the selected options are equal or above the\n" "threshold." msgstr "" @@ -5632,6 +5659,10 @@ msgstr "" msgid "When a valid username/email has been entered, and this option is enabled, the user's username and avatar will be shown. Otherwise, the text that the user entered will be shown." msgstr "" +#: src/pages/sources/ldap/LDAPSourceForm.ts +msgid "When connecting to an LDAP Server with TLS, certificates are not checked by default. Specify a keypair to validate the remote certificate." +msgstr "" + #: src/pages/stages/email/EmailStageForm.ts msgid "When enabled, global Email connection settings will be used and connection settings below will be ignored." msgstr "" diff --git a/web/src/pages/crypto/CertificateKeyPairListPage.ts b/web/src/pages/crypto/CertificateKeyPairListPage.ts index 844626a4c5..c773d30dcc 100644 --- a/web/src/pages/crypto/CertificateKeyPairListPage.ts +++ b/web/src/pages/crypto/CertificateKeyPairListPage.ts @@ -91,8 +91,13 @@ export class CertificateKeyPairListPage extends TablePage { } row(item: CertificateKeyPair): TemplateResult[] { + let managedSubText = t`Managed by authentik`; + if (item.managed && item.managed.startsWith("goauthentik.io/crypto/discovered")) { + managedSubText = t`Managed by authentik (Discovered)`; + } return [ - html`${item.name}`, + html`
${item.name}
+ ${item.managed ? html`${managedSubText}` : html``}`, html` ${item.privateKeyAvailable ? t`Yes` : t`No`} `, diff --git a/web/src/pages/policies/reputation/ReputationPolicyForm.ts b/web/src/pages/policies/reputation/ReputationPolicyForm.ts index e48904039a..a4f4fd1264 100644 --- a/web/src/pages/policies/reputation/ReputationPolicyForm.ts +++ b/web/src/pages/policies/reputation/ReputationPolicyForm.ts @@ -47,8 +47,12 @@ export class ReputationPolicyForm extends ModelForm { ${t`Allows/denys requests based on the users and/or the IPs reputation.`}
- ${t`The policy passes when the reputation score is above the threshold, and - doesn't pass when either or both of the selected options are equal or less than the + ${t`Invalid login attempts will decrease the score for the client's IP, and the + username they are attempting to login as, by one.`} +
+
+ ${t`The policy passes when the reputation score is below the threshold, and + doesn't pass when either or both of the selected options are equal or above the threshold.`}
diff --git a/web/src/pages/providers/proxy/ProxyProviderViewPage.ts b/web/src/pages/providers/proxy/ProxyProviderViewPage.ts index aae9c6f68a..8165419d1a 100644 --- a/web/src/pages/providers/proxy/ProxyProviderViewPage.ts +++ b/web/src/pages/providers/proxy/ProxyProviderViewPage.ts @@ -176,7 +176,7 @@ export class ProxyProviderViewPage extends LitElement { ${this.provider.basicAuthEnabled ? t`Yes` : t`No`} diff --git a/web/src/pages/sources/ldap/LDAPSourceForm.ts b/web/src/pages/sources/ldap/LDAPSourceForm.ts index 3eccb60513..59729d5ab4 100644 --- a/web/src/pages/sources/ldap/LDAPSourceForm.ts +++ b/web/src/pages/sources/ldap/LDAPSourceForm.ts @@ -7,6 +7,7 @@ import { until } from "lit/directives/until.js"; import { CoreApi, + CryptoApi, LDAPSource, LDAPSourceRequest, PropertymappingsApi, @@ -124,6 +125,9 @@ export class LDAPSourceForm extends ModelForm { class="pf-c-form-control" required /> +

+ ${t`Specify multiple server URIs by separating them with a comma.`} +

@@ -138,6 +142,44 @@ export class LDAPSourceForm extends ModelForm { ${t`To use SSL instead, use 'ldaps://' and disable this option.`}

+ + +

+ ${t`When connecting to an LDAP Server with TLS, certificates are not checked by default. Specify a keypair to validate the remote certificate.`} +

+
@ad.company` - Bind Password: The password you've given the user above - Base DN: The base DN which you want authentik to sync diff --git a/website/integrations/sources/freeipa/index.md b/website/integrations/sources/freeipa/index.md index 146109d0f1..193521c330 100644 --- a/website/integrations/sources/freeipa/index.md +++ b/website/integrations/sources/freeipa/index.md @@ -30,7 +30,7 @@ The following placeholders will be used: ``` $ ldapmodify -x -D "cn=Directory Manager" -W -h ipa1.freeipa.company -p 389 - + dn: cn=ipa_pwd_extop,cn=plugins,cn=config changetype: modify add: passSyncManagersDNs @@ -45,6 +45,11 @@ In authentik, create a new LDAP Source in Resources -> Sources. Use these settings: - Server URI: `ldaps://ipa1.freeipa.company` + + You can specify multiple servers by separating URIs with a comma, like `ldap://ipa1.freeipa.company,ldap://ipa2.freeipa.company`. + + When using a DNS entry with multiple Records, authentik will select a random entry when first connecting. + - Bind CN: `uid=svc_authentik,cn=users,cn=accounts,dc=freeipa,dc=company` - Bind Password: The password you've given the user above - Base DN: `dc=freeipa,dc=company` diff --git a/website/integrations/sources/ldap/index.md b/website/integrations/sources/ldap/index.md index a4eef3086a..d373af3816 100644 --- a/website/integrations/sources/ldap/index.md +++ b/website/integrations/sources/ldap/index.md @@ -15,6 +15,11 @@ For FreeIPA, follow the [FreeIPA Integration](../freeipa/index.md) ::: - Server URI: URI to your LDAP server/Domain Controller. + + You can specify multiple servers by separating URIs with a comma, like `ldap://ldap1.company,ldap://ldap2.company`. + + When using a DNS entry with multiple Records, authentik will select a random entry when first connecting. + - Bind CN: CN of the bind user. This can also be a UPN in the format of `user@domain.tld`. - Bind password: Password used during the bind process. - Enable StartTLS: Enables StartTLS functionality. To use LDAPS instead, use port `636`. diff --git a/website/sidebars.js b/website/sidebars.js index 610b704384..7d6b9fd0de 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -4,10 +4,6 @@ module.exports = { type: "doc", id: "index", }, - { - type: "doc", - id: "terminology", - }, { type: "category", label: "Installation", @@ -23,8 +19,15 @@ module.exports = { ], }, { - type: "doc", - id: "applications", + type: "category", + label: "Core Concepts", + collapsed: false, + items: [ + "core/terminology", + "core/applications", + "core/tenants", + "core/certificates", + ], }, { type: "category", @@ -121,10 +124,6 @@ module.exports = { label: "Users & Groups", items: ["user-group/user", "user-group/group"], }, - { - type: "doc", - id: "tenants", - }, { type: "category", label: "Maintenance", diff --git a/website/sidebarsIntegrations.js b/website/sidebarsIntegrations.js index 3aae6f1e54..52a3f41919 100644 --- a/website/sidebarsIntegrations.js +++ b/website/sidebarsIntegrations.js @@ -47,6 +47,7 @@ module.exports = { "services/proxmox-ve/index", "services/rancher/index", "services/sentry/index", + "services/sssd/index", "services/sonarr/index", "services/tautulli/index", "services/ubuntu-landscape/index", diff --git a/website/static.go b/website/static.go deleted file mode 100644 index 1c44b1d665..0000000000 --- a/website/static.go +++ /dev/null @@ -1,6 +0,0 @@ -package web - -import "embed" - -//go:embed help/* -var Help embed.FS