Merge branch 'master' into version-2021.12

This commit is contained in:
Jens Langhammer 2021-12-04 19:55:37 +01:00
commit 639c2f5c2e
93 changed files with 1406 additions and 552 deletions

View file

@ -28,6 +28,7 @@ jobs:
- isort
- bandit
- pyright
- pending-migrations
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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():

View file

@ -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(

View file

@ -13,3 +13,4 @@ class AuthentikCryptoConfig(AppConfig):
def ready(self):
import_module("authentik.crypto.managed")
import_module("authentik.crypto.tasks")

View file

@ -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"},
},
}

67
authentik/crypto/tasks.py Normal file
View file

@ -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})],
)
)

View file

@ -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()
)

View file

@ -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()

View file

@ -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"

View file

@ -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

View file

@ -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)

View file

@ -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()

View file

@ -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

View file

@ -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()

View file

@ -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.",

View file

@ -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():

View file

@ -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(),

View file

@ -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",
]

View file

@ -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,
)
),

View file

@ -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:
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)

View file

@ -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:

View file

@ -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),
)

View file

@ -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",

View file

@ -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,

View file

@ -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",
),
),
]

View file

@ -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,
connection = Connection(
self.server,
raise_exceptions=True,
user=self.bind_cn,
password=self.bind_password,
receive_timeout=LDAP_TIMEOUT,
)
self._connection.bind()
connection.bind()
if self.start_tls:
self._connection.start_tls()
return self._connection
connection.start_tls()
return connection
class Meta:

View file

@ -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=(

View file

@ -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=(

View file

@ -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()

View file

@ -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)

View file

@ -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()

View file

@ -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 = []

View file

@ -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,
),
),
]

View file

@ -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):

View file

@ -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

3
go.mod
View file

@ -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

6
go.sum
View file

@ -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=

View file

@ -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,
}
}

View file

@ -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
}

View file

@ -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())
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
}
}
}()
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
}
for _, u := range users.Results {
uEntries = append(uEntries, group.FromAPIUser(u, ds.si).Entry())
groups = &g.Results
return nil
})
}
}()
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
}
users, _, err := searchReq.Execute()
uapisp.Finish()
err = errs.Wait()
if err != nil {
return ldap.ServerSearchResult{ResultCode: ldap.LDAPResultOperationsError}, fmt.Errorf("API Error: %s", err)
return ldap.ServerSearchResult{ResultCode: ldap.LDAPResultOperationsError}, err
}
for _, u := range users.Results {
entries = append(entries, ds.si.UserEntry(u))
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
}
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)
}
}
}
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
}

View file

@ -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
}

View file

@ -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:
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())
}
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 {
entries = append(entries, group.FromAPIGroup(g, ms.si).Entry())
}
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))
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
}

View file

@ -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)

View file

@ -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)
}

View file

@ -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
}

View file

@ -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)

View file

@ -13,4 +13,6 @@ type Claims struct {
Name string `json:"name"`
PreferredUsername string `json:"preferred_username"`
Groups []string `json:"groups"`
RawToken string
}

View file

@ -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))
}
}
}

View file

@ -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")

View file

@ -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)

View file

@ -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
}

View file

@ -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
}

View file

@ -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,

View file

@ -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)

View file

@ -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
}

View file

@ -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" ]

View file

@ -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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

BIN
web/icons/icon_discord.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

14
web/package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

@ -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

View file

@ -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<T> extends LitElement {
});
}
public fetch(): void {
public async fetch(): Promise<void> {
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<T> extends LitElement {
}
renderToolbar(): TemplateResult {
return html`<button
@click=${() => {
this.dispatchEvent(
new CustomEvent(EVENT_REFRESH, {
bubbles: true,
composed: true,
}),
);
return html` <ak-spinner-button
.callAction=${() => {
return this.fetch();
}}
class="pf-c-button pf-m-secondary"
class="pf-m-secondary"
>
${t`Refresh`}
</button>`;
${t`Refresh`}</ak-spinner-button
>`;
}
renderToolbarSelected(): TemplateResult {
@ -350,12 +346,7 @@ export abstract class Table<T> 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<T> extends LitElement {
.pages=${this.data?.pagination}
.pageChangeHandler=${(page: number) => {
this.page = page;
this.dispatchEvent(
new CustomEvent(EVENT_REFRESH, {
bubbles: true,
composed: true,
}),
);
this.fetch();
}}
>
</ak-table-pagination>`
@ -442,12 +428,7 @@ export abstract class Table<T> extends LitElement {
.pages=${this.data?.pagination}
.pageChangeHandler=${(page: number) => {
this.page = page;
this.dispatchEvent(
new CustomEvent(EVENT_REFRESH, {
bubbles: true,
composed: true,
}),
);
this.fetch();
}}
>
</ak-table-pagination>

View file

@ -100,9 +100,7 @@ export class AuthenticatorValidateStage
return html`<i class="fas fa-mobile-alt"></i>
<div class="right">
<p>${t`Duo push-notifications`}</p>
<small
>${t`Receive a push notification on your phone to prove your identity.`}</small
>
<small>${t`Receive a push notification on your device.`}</small>
</div>`;
case DeviceClassesEnum.Webauthn:
return html`<i class="fas fa-mobile-alt"></i>

View file

@ -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."

View file

@ -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."

View file

@ -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 ""

View file

@ -91,8 +91,13 @@ export class CertificateKeyPairListPage extends TablePage<CertificateKeyPair> {
}
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`<div>${item.name}</div>
${item.managed ? html`<small>${managedSubText}</small>` : html``}`,
html`<ak-label color=${item.privateKeyAvailable ? PFColor.Green : PFColor.Grey}>
${item.privateKeyAvailable ? t`Yes` : t`No`}
</ak-label>`,

View file

@ -47,8 +47,12 @@ export class ReputationPolicyForm extends ModelForm<ReputationPolicy, string> {
${t`Allows/denys requests based on the users and/or the IPs reputation.`}
</div>
<div class="form-help-text">
${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.`}
</div>
<div class="form-help-text">
${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.`}
</div>
<ak-form-element-horizontal label=${t`Name`} ?required=${true} name="name">

View file

@ -176,7 +176,7 @@ export class ProxyProviderViewPage extends LitElement {
<ak-label
color=${this.provider.basicAuthEnabled
? PFColor.Green
: PFColor.Red}
: PFColor.Grey}
>
${this.provider.basicAuthEnabled ? t`Yes` : t`No`}
</ak-label>

View file

@ -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<LDAPSource, string> {
class="pf-c-form-control"
required
/>
<p class="pf-c-form__helper-text">
${t`Specify multiple server URIs by separating them with a comma.`}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal name="startTls">
<div class="pf-c-check">
@ -138,6 +142,44 @@ export class LDAPSourceForm extends ModelForm<LDAPSource, string> {
${t`To use SSL instead, use 'ldaps://' and disable this option.`}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${t`TLS Verification Certificate`}
name="peerCertificate"
>
<select class="pf-c-form-control">
<option
value=""
?selected=${this.instance?.peerCertificate === undefined}
>
---------
</option>
${until(
new CryptoApi(DEFAULT_CONFIG)
.cryptoCertificatekeypairsList({
ordering: "name",
})
.then((keys) => {
return keys.results.map((key) => {
let selected =
this.instance?.peerCertificate === key.pk;
if (keys.results.length === 1) {
selected = true;
}
return html`<option
value=${ifDefined(key.pk)}
?selected=${selected}
>
${key.name}
</option>`;
});
}),
html`<option>${t`Loading...`}</option>`,
)}
</select>
<p class="pf-c-form__helper-text">
${t`When connecting to an LDAP Server with TLS, certificates are not checked by default. Specify a keypair to validate the remote certificate.`}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${t`Bind CN`} name="bindCn">
<input
type="text"

View file

@ -1,12 +1,6 @@
package web
import "embed"
//go:embed dist/*
var StaticDist embed.FS
//go:embed authentik
var StaticAuthentik embed.FS
import _ "embed"
//go:embed robots.txt
var RobotsTxt []byte

View file

@ -1,5 +1,6 @@
---
title: Applications
slug: /applications
---
Applications in authentik are the counterpart of providers. They exist in a 1-to-1 relationship, each application needs a provider and every provider can be used with one application.

View file

@ -0,0 +1,57 @@
---
title: Certificates
---
Certificates in authentik are used for the following use cases:
- Signing and verifying SAML Requests and Responses
- Signing JSON Web Tokens for OAuth and OIDC
- Connecting to remote docker hosts using the Docker integration
- Verifying LDAP Servers' certificates
- Encrypting outposts's endpoints
## Default certificate
Every authentik install generates a self-signed certificate on the first start. The certificate is called *authentik Self-signed Certificate* and is valid for 1 year.
This certificate is generated to be used as a default for all OAuth2/OIDC providers, as these don't require the certificate to be configured on both sides (the signature of a JWT is validated using the [JWKS](https://auth0.com/docs/security/tokens/json-web-tokens/json-web-key-sets) URL).
This certificate can also be used for SAML Providers/Sources, just keep in mind that the certificate is only valid for a year. Some SAML applications require the certificate to be valid, so they might need to be rotated regularly.
For SAML use-cases, you can generate a Certificate that's valid for longer than 1 year, on your own risk.
## External certificates
To use externally managed certificates, for example generated with certbot or HashiCorp Vault, you can use the discovery feature.
The docker-compose installation maps a `certs` directory to `/certs`, you can simply use this as an output directory for certbot.
For Kubernetes, you can map custom secrets/volumes under `/certs`.
You can also bind mount single files into the folder, as long as they fall under this naming schema.
- Files in the root directory will be imported based on their filename.
`/foo.pem` Will be imported as the keypair `foo`. Based on its content its either imported as certificate or private key.
Currently, only RSA Keys are supported, so if the file contains `BEGIN RSA PRIVATE KEY` it will imported as private key.
Otherwise it will be imported as certificate.
- If the file is called `fullchain.pem` or `privkey.pem` (the output naming of certbot), they will get the name of the parent folder.
- Files can be in any arbitrary file structure, and can have extension.
```
certs/
├── baz
│   └── bar.baz
│   ├── fullchain.pem
│   └── privkey.key
├── foo.bar
│   ├── fullchain.pem
│   └── privkey.key
├── foo.key
└── foo.pem
```
Files are checked every 5 minutes, and will trigger an Outpost refresh if the files differ.

View file

@ -1,5 +1,6 @@
---
title: Tenants
slug: /tenants
---
authentik support soft multi-tenancy. This means that you can configure several options depending on domain, but all the objects like applications, providers, etc, are still global. This can be handy to use the same authentik instance, but branded differently for different domains.

View file

@ -1,6 +1,7 @@
---
id: terminology
title: Terminology
slug: /terminology
---
![](/img/authentik_objects.svg)

View file

@ -3,3 +3,27 @@ title: Password stage
---
This is a generic password prompt which authenticates the current `pending_user`. This stage allows the selection of the source the user is authenticated against.
## Passwordless login
To achieve a "passwordless" experience; authenticating users based only on TOTP/WebAuthn/Duo, create an expression policy and optionally skip the password stage.
Depending on what kind of device you want to require the user to have:
#### WebAuthn
```python
from authentik.stages.authenticator_webauthn.models import WebAuthnDevice
return WebAuthnDevice.objects.filter(user=request.user, active=True).exists()
```
#### Duo
```python
from authentik.stages.authenticator_duo.models import DuoDevice
return DuoDevice.objects.filter(user=request.user, active=True).exists()
```
Afterwards, bind the policy you've created to the stage binding of the password stage.
Make sure to uncheck *Evaluate on plan* and check *Re-evaluate policies*, otherwise an invalid result will be cached.

View file

@ -73,3 +73,8 @@ Starting with 2021.9.1, custom attributes will override the inbuilt attributes.
You can also configure SSL for your LDAP Providers by selecting a certificate and a server name in the provider settings.
This enables you to bind on port 636 using LDAPS, StartTLS is not supported.
## Integrations
See the integration guide for [sssd](../../integrations/services/sssd/index) for
an example guide.

View file

@ -50,7 +50,7 @@ services:
traefik.http.routers.authentik.tls: true
traefik.http.middlewares.authentik.forwardauth.address: http://outpost.company:9000/akprox/auth/traefik
traefik.http.middlewares.authentik.forwardauth.trustForwardHeader: true
traefik.http.middlewares.authentik.forwardauth.authResponseHeaders: Set-Cookie,X-authentik-username,X-authentik-groups,X-authentik-email,X-authentik-name,X-authentik-uid
traefik.http.middlewares.authentik.forwardauth.authResponseHeadersRegex: ^.*$
restart: unless-stopped
whoami:

View file

@ -9,13 +9,7 @@ spec:
forwardAuth:
address: http://outpost.company:9000/akprox/auth/traefik
trustForwardHeader: true
authResponseHeaders:
- Set-Cookie
- X-authentik-username
- X-authentik-groups
- X-authentik-email
- X-authentik-name
- X-authentik-uid
authResponseHeadersRegex: ^.*$
```
Add the following settings to your IngressRoute

View file

@ -5,13 +5,7 @@ http:
forwardAuth:
address: http://outpost.company:9000/akprox/auth/traefik
trustForwardHeader: true
authResponseHeaders:
- Set-Cookie
- X-authentik-username
- X-authentik-groups
- X-authentik-email
- X-authentik-name
- X-authentik-uid
authResponseHeadersRegex: ^.*$
routers:
default-router:
rule: "Host(`app.company`)"

View file

@ -2,20 +2,50 @@
title: Overview
---
The proxy outpost sets the following headers:
The proxy outpost sets the following user-specific headers:
```
X-authentik-username: akadmin # The username of the currently logged in user
X-authentik-groups: foo|bar|baz # The groups the user is member of, separated by a pipe
X-authentik-email: root@localhost # The email address of the currently logged in user
X-authentik-name: authentik Default Admin # Full name of the current user
X-authentik-uid: 900347b8a29876b45ca6f75722635ecfedf0e931c6022e3a29a8aa13fb5516fb # The hashed identifier of the currently logged in user.
```
- X-authentik-username: `akadmin`
The username of the currently logged in user
- X-authentik-groups: `foo|bar|baz`
The groups the user is member of, separated by a pipe
- X-authentik-email: `root@localhost`
The email address of the currently logged in user
- X-authentik-name: `authentik Default Admin`
Full name of the current user
- X-authentik-uid: `900347b8a29876b45ca6f75722635ecfedf0e931c6022e3a29a8aa13fb5516fb`
The hashed identifier of the currently logged in user.
Additionally, you can set `additionalHeaders` on groups or users to set additional headers.
If you enable *Set HTTP-Basic Authentication* option, the HTTP Authorization header is being set.
Besides these user-specific headers, some application specific headers are also set:
- X-authentik-meta-outpost: `authentik Embedded Outpost`
The authentik outpost's name.
- X-authentik-meta-provider: `test`
The authentik provider's name.
- X-authentik-meta-app: `test`
The authentik application's slug.
- X-authentik-meta-version: `authentik-outpost@1.2.3 (build=tagged)`
The authentik outpost's version.
# HTTPS
The outpost listens on both 9000 for HTTP and 9443 for HTTPS.

View file

@ -0,0 +1,141 @@
---
title: sssd
---
:::info
This feature is still in technical preview, so please report any
Bugs you run into on [GitHub](https://github.com/goauthentik/authentik/issues)
:::
## What is sssd
From https://sssd.io/
:::note
**SSSD** is an acronym for System Security Services Daemon. It is the client component of centralized identity management solutions such as FreeIPA, 389 Directory Server, Microsoft Active Directory, OpenLDAP and other directory servers. The client serves and caches the information stored in the remote directory server and provides identity, authentication and authorization services to the host machine.
:::
Note that Authentik supports _only_ user and group objects. As
a consequence, it cannot be used to provide automount or sudo
configuration nor can it provide netgroups or services to `nss`.
Kerberos is also not supported.
## Preperation
The following placeholders will be used:
- `authentik.company` is the FQDN of the authentik install.
- `ldap.baseDN` is the Base DN you configure in the LDAP provider.
- `ldap.domain` is (typically) an FQDN for your domain. Usually
it is just the components of your base DN. For example, if
`ldap.baseDN` is `dc=ldap,dc=goauthentik,dc=io` then the domain
might be `ldap.goauthentik.io`.
- `ldap.searchGroup` is the "Search Group" that can can see all
users and groups in Authentik.
- `sssd.serviceAccount` is a service account created in Authentik
- `sssd.serviceAccountToken` is the service account token generated
by Authentik.
Create an LDAP Provider if you don't already have one setup.
This guide assumes you will be running with TLS and that you've
correctly setup certificates both in Authentik and on the host
running sssd. See the [ldap provider docs](../../../docs/providers/ldap) for setting up SSL on the Authentik side.
Remember the Base DN you have configured for the provider as you'll
need it in the sssd configuration.
Create a new service account for all of your hosts to use to connect
to LDAP and perform searches. Make sure this service account is added
to `ldap.searchGroup`.
## Deployment
Create an outpost deployment for the provider you've created above, as described [here](../../../docs/outposts/outposts). Deploy this Outpost either on the same host or a different host that your
host(s) running sssd can access.
The outpost will connect to authentik and configure itself.
## Client Configuration
First, install the necessary sssd packages on your host. Very likely
the package is just `sssd`.
:::note
This guide well help you configure the `sssd.conf` for LDAP only. You
will likely need to perform other tasks for a usable setup
like setting up automounted or autocreated home directories that
are beyond the scope of this guide. See the "additional resources"
section for some help.
:::
Create a file at `/etc/sssd/sssd.conf` with contents similar to
the following:
```ini
[nss]
filter_groups = root
filter_users = root
reconnection_retries = 3
[sssd]
config_file_version = 2
reconnection_retries = 3
sbus_timeout = 30
domains = ${ldap.domain}
services = nss, pam, ssh
[pam]
reconnection_retries = 3
[domain/${ldap.domain}]
cache_credentials = True
id_provider = ldap
chpass_provider = ldap
auth_provider = ldap
access_provider = ldap
ldap_uri = ldaps://${authentik.company}:636
ldap_schema = rfc2307bis
ldap_search_base = ${ldap.baseDN}
ldap_user_search_base = ou=users,${ldap.baseDN}
ldap_group_search_base = ${ldap.baseDN}
ldap_user_object_class = user
ldap_user_name = cn
ldap_group_object_class = group
ldap_group_name = cn
# Optionally, filter logins to only a specific group
#ldap_access_order = filter
#ldap_access_filter = memberOf=cn=authentik Admins,ou=groups,${ldap.baseDN}
ldap_default_bind_dn = cn=${sssd.serviceAccount},ou=users,${ldap.baseDN}
ldap_default_authtok = ${sssd.serviceAccountToken}
```
You should now be able to start sssd; however, the system may not
yet be setup to use it. Depending on your platform, you may need to
use `authconfig` or `pam-auth-update` to configure your system. See
the additional resources section for details.
:::note
You can store SSH authorized keys in LDAP by adding the
`sshPublicKey` attribute to any user with their public key as
the value.
:::
## Additional Resources
The setup of sssd may vary based on Linux distribution and version,
here are some resources that can help you get this setup:
:::note
Authentik is providing a simple LDAP server, not an Active Directory
domain. Be sure you're looking at the correct sections in these guides.
:::
- https://sssd.io/docs/quick-start.html#quick-start-ldap
- https://access.redhat.com/documentation/en-us/red_hat_enterprise_linux/7/html/system-level_authentication_guide/configuring_services
- https://ubuntu.com/server/docs/service-sssd
- https://manpages.debian.org/unstable/sssd-ldap/sssd-ldap.5.en.html
- https://wiki.archlinux.org/title/LDAP_authentication

View file

@ -43,6 +43,10 @@ Use these settings:
For authentik to be able to write passwords back to Active Directory, make sure to use `ldaps://`
You can specify multiple servers by separating URIs with a comma, like `ldap://dc1.ad.company,ldap://dc2.ad.company`.
When using a DNS entry with multiple Records, authentik will select a random entry when first connecting.
- Bind CN: `<name of your service user>@ad.company`
- Bind Password: The password you've given the user above
- Base DN: The base DN which you want authentik to sync

View file

@ -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`

View file

@ -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`.

View file

@ -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",

View file

@ -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",

View file

@ -1,6 +0,0 @@
package web
import "embed"
//go:embed help/*
var Help embed.FS