Skip to content

Commit

Permalink
sources/ldap: make schema optional (#5213)
Browse files Browse the repository at this point in the history
* sources/ldap: make schema optional

Signed-off-by: Jens Langhammer <[email protected]>

* create one connection and re-use it

Signed-off-by: Jens Langhammer <[email protected]>

* use magicmock

Signed-off-by: Jens Langhammer <[email protected]>

---------

Signed-off-by: Jens Langhammer <[email protected]>
  • Loading branch information
BeryJu authored Apr 10, 2023
1 parent c1615d0 commit 1ca8feb
Show file tree
Hide file tree
Showing 12 changed files with 63 additions and 47 deletions.
2 changes: 1 addition & 1 deletion authentik/blueprints/v1/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ def blueprints_find():
)
blueprint.meta = from_dict(BlueprintMetadata, metadata) if metadata else None
blueprints.append(blueprint)
LOGGER.info(
LOGGER.debug(
"parsed & loaded blueprint",
hash=file_hash,
path=str(path),
Expand Down
2 changes: 1 addition & 1 deletion authentik/root/test_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ def __init__(self, verbosity=1, failfast=False, keepdb=False, **kwargs):
self.failfast = failfast
self.keepdb = keepdb

self.args = ["-vv"]
self.args = ["-vv", "--full-trace"]
if self.failfast:
self.args.append("--exitfirst")
if self.keepdb:
Expand Down
14 changes: 6 additions & 8 deletions authentik/sources/ldap/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,12 @@
from typing import Optional

from django.http import HttpRequest
from ldap3 import Connection
from ldap3.core.exceptions import LDAPException, LDAPInvalidCredentialsResult
from structlog.stdlib import get_logger

from authentik.core.auth import InbuiltBackend
from authentik.core.models import User
from authentik.sources.ldap.models import LDAP_TIMEOUT, LDAPSource
from authentik.sources.ldap.models import LDAPSource

LOGGER = get_logger()
LDAP_DISTINGUISHED_NAME = "distinguishedName"
Expand Down Expand Up @@ -58,12 +57,11 @@ def auth_user_by_bind(self, source: LDAPSource, user: User, password: str) -> Op
# Try to bind as new user
LOGGER.debug("Attempting Binding as user", user=user)
try:
temp_connection = Connection(
source.server,
user=user.attributes.get(LDAP_DISTINGUISHED_NAME),
password=password,
raise_exceptions=True,
receive_timeout=LDAP_TIMEOUT,
temp_connection = source.connection(
connection_kwargs={
"user": user.attributes.get(LDAP_DISTINGUISHED_NAME),
"password": password,
}
)
temp_connection.bind()
return user
Expand Down
38 changes: 26 additions & 12 deletions authentik/sources/ldap/models.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
"""authentik LDAP Models"""
from ssl import CERT_REQUIRED
from typing import Optional

from django.db import models
from django.utils.translation import gettext_lazy as _
from ldap3 import ALL, RANDOM, Connection, Server, ServerPool, Tls
from ldap3 import ALL, NONE, RANDOM, Connection, Server, ServerPool, Tls
from ldap3.core.exceptions import LDAPSchemaError
from rest_framework.serializers import Serializer

from authentik.core.models import Group, PropertyMapping, Source
Expand Down Expand Up @@ -103,8 +105,7 @@ def serializer(self) -> type[Serializer]:

return LDAPSourceSerializer

@property
def server(self) -> Server:
def server(self, **kwargs) -> Server:
"""Get LDAP Server/ServerPool"""
servers = []
tls_kwargs = {}
Expand All @@ -113,32 +114,45 @@ def server(self) -> Server:
tls_kwargs["validate"] = CERT_REQUIRED
if ciphers := CONFIG.y("ldap.tls.ciphers", None):
tls_kwargs["ciphers"] = ciphers.strip()
kwargs = {
server_kwargs = {
"get_info": ALL,
"connect_timeout": LDAP_TIMEOUT,
"tls": Tls(**tls_kwargs),
}
server_kwargs.update(kwargs)
if "," in self.server_uri:
for server in self.server_uri.split(","):
servers.append(Server(server, **kwargs))
servers.append(Server(server, **server_kwargs))
else:
servers = [Server(self.server_uri, **kwargs)]
servers = [Server(self.server_uri, **server_kwargs)]
return ServerPool(servers, RANDOM, active=True, exhaust=True)

@property
def connection(self) -> Connection:
def connection(
self, server_kwargs: Optional[dict] = None, connection_kwargs: Optional[dict] = None
) -> Connection:
"""Get a fully connected and bound LDAP Connection"""
server_kwargs = server_kwargs or {}
connection_kwargs = connection_kwargs or {}
connection_kwargs.setdefault("user", self.bind_cn)
connection_kwargs.setdefault("password", self.bind_password)
connection = Connection(
self.server,
self.server(**server_kwargs),
raise_exceptions=True,
user=self.bind_cn,
password=self.bind_password,
receive_timeout=LDAP_TIMEOUT,
**connection_kwargs,
)

if self.start_tls:
connection.start_tls(read_server_info=False)
connection.bind()
try:
connection.bind()
except LDAPSchemaError as exc:
# Schema error, so try connecting without schema info
# See https://github.com/goauthentik/authentik/issues/4590
if server_kwargs.get("get_info", ALL) == NONE:
raise exc
server_kwargs["get_info"] = NONE
return self.connection(server_kwargs, connection_kwargs)
return connection

class Meta:
Expand Down
11 changes: 6 additions & 5 deletions authentik/sources/ldap/password.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,11 @@ class LDAPPasswordChanger:

def __init__(self, source: LDAPSource) -> None:
self._source = source
self._connection = source.connection()

def get_domain_root_dn(self) -> str:
"""Attempt to get root DN via MS specific fields or generic LDAP fields"""
info = self._source.connection.server.info
info = self._connection.server.info
if "rootDomainNamingContext" in info.other:
return info.other["rootDomainNamingContext"][0]
naming_contexts = info.naming_contexts
Expand All @@ -61,7 +62,7 @@ def check_ad_password_complexity_enabled(self) -> bool:
"""Check if DOMAIN_PASSWORD_COMPLEX is enabled"""
root_dn = self.get_domain_root_dn()
try:
root_attrs = self._source.connection.extend.standard.paged_search(
root_attrs = self._connection.extend.standard.paged_search(
search_base=root_dn,
search_filter="(objectClass=*)",
search_scope=BASE,
Expand Down Expand Up @@ -90,14 +91,14 @@ def change_password(self, user: User, password: str):
LOGGER.info(f"User has no {LDAP_DISTINGUISHED_NAME} set.")
return
try:
self._source.connection.extend.microsoft.modify_password(user_dn, password)
self._connection.extend.microsoft.modify_password(user_dn, password)
except LDAPAttributeError:
self._source.connection.extend.standard.modify_password(user_dn, new_password=password)
self._connection.extend.standard.modify_password(user_dn, new_password=password)

def _ad_check_password_existing(self, password: str, user_dn: str) -> bool:
"""Check if a password contains sAMAccount or displayName"""
users = list(
self._source.connection.extend.standard.paged_search(
self._connection.extend.standard.paged_search(
search_base=user_dn,
search_filter=self._source.user_object_filter,
search_scope=BASE,
Expand Down
3 changes: 3 additions & 0 deletions authentik/sources/ldap/sync/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

from django.db.models.base import Model
from django.db.models.query import QuerySet
from ldap3 import Connection
from structlog.stdlib import BoundLogger, get_logger

from authentik.core.exceptions import PropertyMappingExpressionException
Expand All @@ -19,10 +20,12 @@ class BaseLDAPSynchronizer:

_source: LDAPSource
_logger: BoundLogger
_connection: Connection
_messages: list[str]

def __init__(self, source: LDAPSource):
self._source = source
self._connection = source.connection()
self._messages = []
self._logger = get_logger().bind(source=source, syncer=self.__class__.__name__)

Expand Down
2 changes: 1 addition & 1 deletion authentik/sources/ldap/sync/groups.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ class GroupLDAPSynchronizer(BaseLDAPSynchronizer):
"""Sync LDAP Users and groups into authentik"""

def get_objects(self, **kwargs) -> Generator:
return self._source.connection.extend.standard.paged_search(
return self._connection.extend.standard.paged_search(
search_base=self.base_dn_groups,
search_filter=self._source.group_object_filter,
search_scope=SUBTREE,
Expand Down
2 changes: 1 addition & 1 deletion authentik/sources/ldap/sync/membership.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ def __init__(self, source: LDAPSource):
self.group_cache: dict[str, Group] = {}

def get_objects(self, **kwargs) -> Generator:
return self._source.connection.extend.standard.paged_search(
return self._connection.extend.standard.paged_search(
search_base=self.base_dn_groups,
search_filter=self._source.group_object_filter,
search_scope=SUBTREE,
Expand Down
2 changes: 1 addition & 1 deletion authentik/sources/ldap/sync/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ class UserLDAPSynchronizer(BaseLDAPSynchronizer):
"""Sync LDAP Users into authentik"""

def get_objects(self, **kwargs) -> Generator:
return self._source.connection.extend.standard.paged_search(
return self._connection.extend.standard.paged_search(
search_base=self.base_dn_users,
search_filter=self._source.user_object_filter,
search_scope=SUBTREE,
Expand Down
6 changes: 3 additions & 3 deletions authentik/sources/ldap/tests/test_auth.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
"""LDAP Source tests"""
from unittest.mock import Mock, PropertyMock, patch
from unittest.mock import MagicMock, Mock, patch

from django.db.models import Q
from django.test import TestCase
Expand Down Expand Up @@ -37,7 +37,7 @@ def test_auth_synced_user_ad(self):
| Q(managed__startswith="goauthentik.io/sources/ldap/ms-")
)
)
connection = PropertyMock(return_value=mock_ad_connection(LDAP_PASSWORD))
connection = MagicMock(return_value=mock_ad_connection(LDAP_PASSWORD))
with patch("authentik.sources.ldap.models.LDAPSource.connection", connection):
user_sync = UserLDAPSynchronizer(self.source)
user_sync.sync()
Expand All @@ -64,7 +64,7 @@ def test_auth_synced_user_openldap(self):
)
)
self.source.save()
connection = PropertyMock(return_value=mock_slapd_connection(LDAP_PASSWORD))
connection = MagicMock(return_value=mock_slapd_connection(LDAP_PASSWORD))
with patch("authentik.sources.ldap.models.LDAPSource.connection", connection):
user_sync = UserLDAPSynchronizer(self.source)
user_sync.sync()
Expand Down
4 changes: 2 additions & 2 deletions authentik/sources/ldap/tests/test_password.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
"""LDAP Source tests"""
from unittest.mock import PropertyMock, patch
from unittest.mock import MagicMock, patch

from django.test import TestCase

Expand All @@ -10,7 +10,7 @@
from authentik.sources.ldap.tests.mock_ad import mock_ad_connection

LDAP_PASSWORD = generate_key()
LDAP_CONNECTION_PATCH = PropertyMock(return_value=mock_ad_connection(LDAP_PASSWORD))
LDAP_CONNECTION_PATCH = MagicMock(return_value=mock_ad_connection(LDAP_PASSWORD))


class LDAPPasswordTests(TestCase):
Expand Down
24 changes: 12 additions & 12 deletions authentik/sources/ldap/tests/test_sync.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
"""LDAP Source tests"""
from unittest.mock import PropertyMock, patch
from unittest.mock import MagicMock, patch

from django.db.models import Q
from django.test import TestCase
Expand Down Expand Up @@ -48,7 +48,7 @@ def test_sync_error(self):
)
self.source.property_mappings.set([mapping])
self.source.save()
connection = PropertyMock(return_value=mock_ad_connection(LDAP_PASSWORD))
connection = MagicMock(return_value=mock_ad_connection(LDAP_PASSWORD))
with patch("authentik.sources.ldap.models.LDAPSource.connection", connection):
user_sync = UserLDAPSynchronizer(self.source)
user_sync.sync()
Expand All @@ -69,7 +69,7 @@ def test_sync_users_ad(self):
)
)
self.source.save()
connection = PropertyMock(return_value=mock_ad_connection(LDAP_PASSWORD))
connection = MagicMock(return_value=mock_ad_connection(LDAP_PASSWORD))

# Create the user beforehand so we can set attributes and check they aren't removed
user = User.objects.create(
Expand Down Expand Up @@ -103,7 +103,7 @@ def test_sync_users_openldap(self):
)
)
self.source.save()
connection = PropertyMock(return_value=mock_slapd_connection(LDAP_PASSWORD))
connection = MagicMock(return_value=mock_slapd_connection(LDAP_PASSWORD))
with patch("authentik.sources.ldap.models.LDAPSource.connection", connection):
user_sync = UserLDAPSynchronizer(self.source)
user_sync.sync()
Expand All @@ -121,11 +121,11 @@ def test_sync_groups_ad(self):
self.source.property_mappings_group.set(
LDAPPropertyMapping.objects.filter(managed="goauthentik.io/sources/ldap/default-name")
)
_user = create_test_admin_user()
parent_group = Group.objects.get(name=_user.username)
self.source.sync_parent_group = parent_group
connection = PropertyMock(return_value=mock_ad_connection(LDAP_PASSWORD))
connection = MagicMock(return_value=mock_ad_connection(LDAP_PASSWORD))
with patch("authentik.sources.ldap.models.LDAPSource.connection", connection):
_user = create_test_admin_user()
parent_group = Group.objects.get(name=_user.username)
self.source.sync_parent_group = parent_group
self.source.save()
group_sync = GroupLDAPSynchronizer(self.source)
group_sync.sync()
Expand All @@ -148,7 +148,7 @@ def test_sync_groups_openldap(self):
self.source.property_mappings_group.set(
LDAPPropertyMapping.objects.filter(managed="goauthentik.io/sources/ldap/openldap-cn")
)
connection = PropertyMock(return_value=mock_slapd_connection(LDAP_PASSWORD))
connection = MagicMock(return_value=mock_slapd_connection(LDAP_PASSWORD))
with patch("authentik.sources.ldap.models.LDAPSource.connection", connection):
self.source.save()
group_sync = GroupLDAPSynchronizer(self.source)
Expand All @@ -173,7 +173,7 @@ def test_sync_groups_openldap_posix_group(self):
self.source.property_mappings_group.set(
LDAPPropertyMapping.objects.filter(managed="goauthentik.io/sources/ldap/openldap-cn")
)
connection = PropertyMock(return_value=mock_slapd_connection(LDAP_PASSWORD))
connection = MagicMock(return_value=mock_slapd_connection(LDAP_PASSWORD))
with patch("authentik.sources.ldap.models.LDAPSource.connection", connection):
self.source.save()
user_sync = UserLDAPSynchronizer(self.source)
Expand All @@ -195,7 +195,7 @@ def test_tasks_ad(self):
)
)
self.source.save()
connection = PropertyMock(return_value=mock_ad_connection(LDAP_PASSWORD))
connection = MagicMock(return_value=mock_ad_connection(LDAP_PASSWORD))
with patch("authentik.sources.ldap.models.LDAPSource.connection", connection):
ldap_sync_all.delay().get()

Expand All @@ -210,6 +210,6 @@ def test_tasks_openldap(self):
)
)
self.source.save()
connection = PropertyMock(return_value=mock_slapd_connection(LDAP_PASSWORD))
connection = MagicMock(return_value=mock_slapd_connection(LDAP_PASSWORD))
with patch("authentik.sources.ldap.models.LDAPSource.connection", connection):
ldap_sync_all.delay().get()

0 comments on commit 1ca8feb

Please sign in to comment.