Compare commits

...

6 Commits

Author SHA1 Message Date
Jens Langhammer
2005651723 Merge branch 'main' into providers/ldap/sasl-tls
Signed-off-by: Jens Langhammer <jens@goauthentik.io>

# Conflicts:
#	tests/e2e/test_provider_ldap.py
2026-04-07 14:49:33 +02:00
Jens Langhammer
46b2a1e88f Merge branch 'main' into providers/ldap/sasl-tls
Signed-off-by: Jens Langhammer <jens@goauthentik.io>

# Conflicts:
#	internal/outpost/ldap/bind/memory/memory.go
2026-04-02 17:14:50 +02:00
Jens Langhammer
cce1d92cda Merge branch 'main' into providers/ldap/sasl-tls
Signed-off-by: Jens Langhammer <jens@goauthentik.io>

# Conflicts:
#	tests/e2e/test_provider_ldap.py
2026-03-28 22:31:57 +01:00
Jens Langhammer
3cae85fb52 ensure bind DN is always lowercased during bind request
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2026-03-28 21:48:52 +01:00
Jens Langhammer
514e73c3aa add tests
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2026-03-28 20:58:04 +01:00
Jens Langhammer
cb036e73c2 providers/ldap: add SASL EXTERNAL support
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2026-03-28 20:58:03 +01:00
9 changed files with 145 additions and 27 deletions

View File

@@ -94,7 +94,12 @@ class LDAPProvider(OutpostModel, BackchannelProvider):
return f"LDAP Provider {self.name}"
def get_required_objects(self) -> Iterable[models.Model | str | tuple[str, models.Model]]:
required = [self, "authentik_core.view_user", "authentik_core.view_group"]
required = [
self,
"authentik_core.view_user",
"authentik_core.view_group",
"authentik_stages_mtls.pass_outpost_certificate",
]
if self.certificate is not None:
required.append(("authentik_crypto.view_certificatekeypair", self.certificate))
required.append(

View File

@@ -12,6 +12,7 @@ const (
)
const (
HeaderAuthentikRemoteIP = "X-authentik-remote-ip"
HeaderAuthentikOutpostToken = "X-authentik-outpost-token"
HeaderAuthentikRemoteIP = "X-authentik-remote-ip"
HeaderAuthentikOutpostToken = "X-authentik-outpost-token"
HeaderAuthentikOutpostCertificate = "X-authentik-outpost-certificate"
)

View File

@@ -2,6 +2,8 @@ package direct
import (
"context"
"encoding/pem"
"net/url"
"beryju.io/ldap"
"github.com/getsentry/sentry-go"
@@ -21,6 +23,13 @@ func (db *DirectBinder) Bind(username string, req *bind.Request) (ldap.LDAPResul
})
fe.DelegateClientIP(req.RemoteAddr())
fe.Params.Add("goauthentik.io/outpost/ldap", "true")
if req.TLS != nil && len(req.TLS.PeerCertificates) > 0 {
pem := pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE",
Bytes: req.TLS.PeerCertificates[0].Raw,
})
fe.AddHeader(flow.HeaderAuthentikOutpostCertificate, url.QueryEscape(string(pem)))
}
fe.Answers[flow.StageIdentification] = username
fe.SetSecrets(req.Password, db.si.GetMFASupport())

View File

@@ -1,6 +1,7 @@
package memory
import (
"crypto/x509"
"time"
"beryju.io/ldap"
@@ -14,6 +15,7 @@ import (
type Credentials struct {
DN string
Password string
Cert *x509.Certificate
}
type SessionBinder struct {
@@ -43,10 +45,18 @@ func NewSessionBinder(si server.LDAPServerInstance, oldBinder bind.Binder) *Sess
return sb
}
func certOrNil(req *bind.Request) *x509.Certificate {
if req.TLS != nil && len(req.TLS.PeerCertificates) > 0 {
return req.TLS.PeerCertificates[0]
}
return nil
}
func (sb *SessionBinder) Bind(username string, req *bind.Request) (ldap.LDAPResultCode, error) {
item := sb.sessions.Get(Credentials{
DN: req.BindDN,
Password: req.Password,
Cert: certOrNil(req),
})
if item != nil {
sb.log.WithField("bindDN", req.BindDN).Info("authenticated from session")
@@ -64,6 +74,7 @@ func (sb *SessionBinder) Bind(username string, req *bind.Request) (ldap.LDAPResu
sb.sessions.Set(Credentials{
DN: req.BindDN,
Password: req.Password,
Cert: certOrNil(req),
}, result, time.Until(flags.Session.Expires))
}
return result, err

View File

@@ -39,7 +39,6 @@ func NewRequest(req ldap.BindRequest, conn net.Conn) (*Request, *sentry.Span) {
IPAddress: utils.GetIP(conn.RemoteAddr()),
})
bindDN = strings.ToLower(bindDN)
return &Request{
BindRequest: req,
conn: conn,

View File

@@ -42,6 +42,7 @@ func NewServer(ac *ak.APIController) ak.Outpost {
tlsConfig := utils.GetTLSConfig()
tlsConfig.GetCertificate = ls.getCertificates
tlsConfig.ClientAuth = tls.RequestClientCert
s.StartTLS = tlsConfig
ls.s = s

View File

@@ -38,6 +38,7 @@ func (ls *LDAPServer) getCertificates(info *tls.ClientHelloInfo) (*tls.Certifica
func (ls *LDAPServer) StartLDAPTLSServer(listen string) error {
tlsConfig := utils.GetTLSConfig()
tlsConfig.ClientAuth = tls.RequestClientCert
tlsConfig.GetCertificate = ls.getCertificates
ln, err := net.Listen("tcp", listen)

View File

@@ -86,7 +86,7 @@ func (pi *ProviderInstance) GetEAPSettings() protocol.Settings {
fe.Answers[flow.StageIdentification] = ident
fe.DelegateClientIP(utils.GetIP(ctx.Packet().RemoteAddr))
fe.Params.Add("goauthentik.io/outpost/radius", "true")
fe.AddHeader("X-Authentik-Outpost-Certificate", url.QueryEscape(string(pem)))
fe.AddHeader(flow.HeaderAuthentikOutpostCertificate, url.QueryEscape(string(pem)))
passed, err := fe.Execute()
if err != nil {

View File

@@ -1,20 +1,36 @@
"""LDAP and Outpost e2e tests"""
from dataclasses import asdict
from pathlib import Path
from tempfile import gettempdir
from time import sleep
from unittest.mock import MagicMock, patch
from ldap3 import ALL, ALL_ATTRIBUTES, ALL_OPERATIONAL_ATTRIBUTES, SUBTREE, Connection, Server
from ldap3 import (
ALL,
ALL_ATTRIBUTES,
ALL_OPERATIONAL_ATTRIBUTES,
EXTERNAL,
SASL,
SUBTREE,
Connection,
Server,
Tls,
)
from ldap3.core.exceptions import LDAPInvalidCredentialsResult, LDAPSessionTerminatedByServerError
from authentik.blueprints.tests import apply_blueprint, reconcile_app
from authentik.core.models import Application, AuthenticatedSession, User
from authentik.core.tests.utils import create_test_user
from authentik.core.tests.utils import create_test_cert, create_test_flow, create_test_user
from authentik.endpoints.models import StageMode
from authentik.enterprise.stages.mtls.models import MutualTLSStage
from authentik.events.models import Event, EventAction
from authentik.flows.models import Flow
from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding
from authentik.lib.generators import generate_id
from authentik.outposts.apps import MANAGED_OUTPOST
from authentik.outposts.models import Outpost, OutpostConfig, OutpostType
from authentik.providers.ldap.models import APIAccessMode, LDAPProvider
from authentik.stages.user_login.models import UserLoginStage
from tests.decorators import retry
from tests.live import ChannelsE2ETestCase
@@ -32,6 +48,16 @@ def clean_response(response):
class TestProviderLDAP(ChannelsE2ETestCase):
"""LDAP and Outpost e2e tests"""
def setUp(self):
super().setUp()
self.kp = create_test_cert()
self.cert = Path(gettempdir()) / generate_id()
with open(self.cert, "w") as _cert:
_cert.write(self.kp.certificate_data)
self.key = Path(gettempdir()) / generate_id()
with open(self.key, "w") as _key:
_key.write(self.kp.key_data)
def assert_list_dict_equal(self, expected: list[dict], actual: list[dict], match_key="dn"):
"""Assert a list of dictionaries is identical, ignoring the ordering of items"""
self.assertEqual(len(expected), len(actual))
@@ -54,15 +80,17 @@ class TestProviderLDAP(ChannelsE2ETestCase):
},
)
def _prepare(self) -> User:
def _prepare(self, **kwargs) -> User:
"""prepare user, provider, app and container"""
self.user.attributes["extraAttribute"] = "bar"
self.user.save()
ldap: LDAPProvider = LDAPProvider.objects.create(
name=generate_id(),
authorization_flow=Flow.objects.get(slug="default-authentication-flow"),
search_mode=APIAccessMode.CACHED,
kwargs.setdefault(
"authorization_flow", Flow.objects.get(slug="default-authentication-flow")
)
ldap = LDAPProvider.objects.create(
name=generate_id(), search_mode=APIAccessMode.CACHED, **kwargs
)
self.user.assign_perms_to_managed_role(
"authentik_providers_ldap.search_full_directory", ldap
@@ -95,7 +123,7 @@ class TestProviderLDAP(ChannelsE2ETestCase):
password=self.user.username,
)
_connection.bind()
self.assertTrue(
self.assertIsNotNone(
Event.objects.filter(
action=EventAction.LOGIN,
user={
@@ -103,7 +131,7 @@ class TestProviderLDAP(ChannelsE2ETestCase):
"email": self.user.email,
"username": self.user.username,
},
)
).first()
)
@retry()
@@ -122,7 +150,7 @@ class TestProviderLDAP(ChannelsE2ETestCase):
password=self.user.username,
)
_connection.bind()
self.assertTrue(
self.assertIsNotNone(
Event.objects.filter(
action=EventAction.LOGIN,
user={
@@ -130,7 +158,7 @@ class TestProviderLDAP(ChannelsE2ETestCase):
"email": self.user.email,
"username": self.user.username,
},
)
).first()
)
@retry()
@@ -150,7 +178,7 @@ class TestProviderLDAP(ChannelsE2ETestCase):
)
_connection.start_tls()
_connection.bind()
self.assertTrue(
self.assertIsNotNone(
Event.objects.filter(
action=EventAction.LOGIN,
user={
@@ -158,7 +186,70 @@ class TestProviderLDAP(ChannelsE2ETestCase):
"email": self.user.email,
"username": self.user.username,
},
)
).first()
)
@retry()
@apply_blueprint(
"default/flow-default-authentication-flow.yaml",
"default/flow-default-invalidation-flow.yaml",
)
def test_ldap_bind_success_starttls_sasl(self):
"""Test SASL bind with ssl"""
# Create flow with MTLS Stage
flow = create_test_flow(FlowDesignation.AUTHENTICATION)
mtls_stage = MutualTLSStage.objects.create(
name=generate_id(),
mode=StageMode.REQUIRED,
)
mtls_stage.certificate_authorities.add(self.kp)
login_stage = UserLoginStage.objects.create(
name=generate_id(),
)
FlowStageBinding.objects.create(target=flow, stage=mtls_stage, order=0)
FlowStageBinding.objects.create(target=flow, stage=login_stage, order=1)
self._prepare(authorization_flow=flow, certificate=self.kp)
tls = Tls(
local_private_key_file=self.key,
local_certificate_file=self.cert,
)
server = Server("ldap://localhost:3389", tls=tls)
_connection = Connection(
server,
version=3,
raise_exceptions=True,
authentication=SASL,
sasl_mechanism=EXTERNAL,
sasl_credentials=f"dn:cn={self.user.username},ou=users,DC=ldap,DC=goauthentik,DC=io",
)
_connection.start_tls()
with (
patch(
"authentik.enterprise.stages.mtls.stage.MTLSStageView.validate_cert",
MagicMock(return_value=self.kp.certificate),
),
patch(
"authentik.enterprise.stages.mtls.stage.MTLSStageView.check_if_user",
MagicMock(return_value=self.user),
) as check_if_user,
):
_connection.bind()
check_if_user.assert_called_once_with(self.kp.certificate)
event = Event.objects.filter(
action=EventAction.LOGIN,
user={
"pk": self.user.pk,
"email": self.user.email,
"username": self.user.username,
},
).first()
self.assertIsNotNone(event)
self.assertEqual(event.context["auth_method"], "mtls")
self.assertEqual(
event.context["auth_method_args"]["certificate"]["fingerprint_sha256"],
self.kp.fingerprint_sha256,
)
@retry()
@@ -178,7 +269,7 @@ class TestProviderLDAP(ChannelsE2ETestCase):
)
with self.assertRaises(LDAPInvalidCredentialsResult):
_connection.bind()
self.assertTrue(
self.assertIsNotNone(
Event.objects.filter(
action=EventAction.LOGIN_FAILED,
user={
@@ -186,7 +277,7 @@ class TestProviderLDAP(ChannelsE2ETestCase):
"email": self.user.email,
"username": self.user.username,
},
).exists(),
).first(),
)
@retry()
@@ -211,7 +302,7 @@ class TestProviderLDAP(ChannelsE2ETestCase):
password=self.user.username,
)
_connection.bind()
self.assertTrue(
self.assertIsNotNone(
Event.objects.filter(
action=EventAction.LOGIN,
user={
@@ -219,7 +310,7 @@ class TestProviderLDAP(ChannelsE2ETestCase):
"email": self.user.email,
"username": self.user.username,
},
)
).first()
)
embedded_account = Outpost.objects.filter(managed=MANAGED_OUTPOST).first().user
@@ -349,7 +440,7 @@ class TestProviderLDAP(ChannelsE2ETestCase):
password=user.username,
)
_connection.bind()
self.assertTrue(
self.assertIsNotNone(
Event.objects.filter(
action=EventAction.LOGIN,
user={
@@ -357,7 +448,7 @@ class TestProviderLDAP(ChannelsE2ETestCase):
"email": user.email,
"username": user.username,
},
)
).first()
)
_connection.search(
@@ -448,7 +539,7 @@ class TestProviderLDAP(ChannelsE2ETestCase):
password=self.user.username,
)
_connection.bind()
self.assertTrue(
self.assertIsNotNone(
Event.objects.filter(
action=EventAction.LOGIN,
user={
@@ -456,7 +547,7 @@ class TestProviderLDAP(ChannelsE2ETestCase):
"email": self.user.email,
"username": self.user.username,
},
)
).first()
)
embedded_account = Outpost.objects.filter(managed=MANAGED_OUTPOST).first().user