diff --git a/.github/workflows/ci-main.yml b/.github/workflows/ci-main.yml index 7d4a9433f..baf0430c9 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 cb1a3f55e..82de5c25a 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 572bc094e..b2792c949 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 3602a24e4..e5672a132 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 df45b4d4b..79e250202 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 2c9a0baaa..fc62285fa 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 8f84f0839..17da6d2cc 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 000000000..598576d48 --- /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 000000000..06d6b02ee --- /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 f621b0f0d..112083698 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 707aa0d3c..138b4070c 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 a1a4691ae..82707e9fd 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 1e4986785..28549d7dc 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 795cf8c4b..3c7ba9c65 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 5d2ebd996..118b9c370 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 0ee7aa9d1..c4e9554ec 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 63d7f4370..820f585f6 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 b5403e033..fddc5bbab 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 eb6dcd6e3..49b1590d1 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 c45cc9943..192183017 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 af9b84630..3ca998614 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 623c343a8..9a0602ff3 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 f33f17a1b..6965766cf 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 3e470b087..4a534c09d 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 4099cc757..8feb335d7 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 aae8e643a..7b1413c4b 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 7b8233c7f..608a27974 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 000000000..2161b7cbf --- /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 c5a6c123a..ade91978e 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 318038a14..99c1793ad 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 00a1574b7..8038303da 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 b431c4a98..d83786e2f 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 a26f48378..af8e9d71b 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 fa86f29e1..59fc4594e 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 5c495956f..fb85b3088 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 000000000..eeeaf522b --- /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 d5ca18bff..bd6381d36 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 93cd34223..1db46a5c2 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 5fafe7245..66d5e72d3 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 93a7fe65c..0279435df 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 d791544d9..f4a5a612d 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 21352518f..1e1d40a4b 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 eda11b2bb..40efcee65 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 123d4a7ad..000000000 --- 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 bb77b5610..c58bd9f40 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 5adb6d2f5..b9394a212 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 623796441..4317a383d 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 ad725b42f..7bcba5f02 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 24dc153ab..c88bc9b54 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 40cd148a2..4ff89dbff 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 b49438e20..a1430ad1e 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 c5e25cb55..889e115c2 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 7c25f0932..72d831157 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 7f2937184..acd66cf31 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 fad584620..d2423e125 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 95045cc9c..2fee7e226 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 0cb034696..7e7cf6772 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 d1d3f5025..accd86db8 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 1c4c76a07..2c626eef2 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 02dd12ca7..85f807516 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 37c2662e9..000000000 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 000000000..d2a58af6a 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 000000000..a43a8280c 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 006bc6495..30283ea8b 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 eb0e089b1..1198f78d5 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 8e62db8c1..32a882543 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 7753f3f62..7a73b6ba6 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 7418f6a10..aadd5e668 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 ba30103b8..f76436c1d 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 9c8dc5b1d..caccfeeab 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 c7636c6de..20cb14f2f 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 844626a4c..c773d30dc 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 e48904039..a4f4fd126 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 aae9c6f68..8165419d1 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 3eccb6051..59729d5ab 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 146109d0f..193521c33 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 a4eef3086..d373af381 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 610b70438..7d6b9fd0d 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 3aae6f1e5..52a3f4191 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 1c44b1d66..000000000 --- a/website/static.go +++ /dev/null @@ -1,6 +0,0 @@ -package web - -import "embed" - -//go:embed help/* -var Help embed.FS