Plugin: authenticate against LDAP server (#287)

* Yakifo/amqtt#261 : plugin for authenticating a user with ldap.
* adding openldap dependencies
* updating ldap custom schema to include three ACL attributes, retrieve the correct topic list and check if topic is allowed
pull/289/head^2
Andrew Mirsky 2025-08-09 15:27:44 -04:00 zatwierdzone przez GitHub
rodzic e6b0f1d002
commit beb8672116
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: B5690EEEBB952194
12 zmienionych plików z 415 dodań i 8 usunięć

Wyświetl plik

@ -32,6 +32,9 @@ jobs:
cache-local-path: ${{ env.UV_CACHE_DIR }}
python-version: "3.13"
- name: install openldap dependencies
run: sudo apt-get install -y libldap2-dev libsasl2-dev
- name: 🏗 Install the project
run: uv sync --locked --dev --all-extras
@ -68,6 +71,9 @@ jobs:
cache-local-path: ${{ env.UV_CACHE_DIR }}
python-version: ${{ matrix.python-version }}
- name: install openldap dependencies
run: sudo apt-get install -y libldap2-dev libsasl2-dev
- name: 🏗 Install the project
run: uv sync --locked --dev --all-extras

Wyświetl plik

@ -0,0 +1,138 @@
from dataclasses import dataclass
import logging
from typing import ClassVar
import ldap
from amqtt.broker import BrokerContext
from amqtt.contexts import Action
from amqtt.errors import PluginInitError
from amqtt.plugins import TopicMatcher
from amqtt.plugins.base import BaseAuthPlugin, BaseTopicPlugin, BasePlugin
from amqtt.session import Session
logger = logging.getLogger(__name__)
@dataclass
class LdapConfig:
"""Configuration for the LDAP Plugins."""
server: str
"""uri formatted server location. e.g `ldap://localhost:389`"""
base_dn: str
"""distinguished name (dn) of the ldap server. e.g. `dc=amqtt,dc=io`"""
user_attribute: str
"""attribute in ldap entry to match the username against"""
bind_dn: str
"""distinguished name (dn) of known, preferably read-only, user. e.g. `cn=admin,dc=amqtt,dc=io`"""
bind_password: str
"""password for known, preferably read-only, user"""
class AuthLdapPlugin(BasePlugin[BrokerContext]):
def __init__(self, context: BrokerContext) -> None:
super().__init__(context)
self.conn = ldap.initialize(self.config.server)
self.conn.protocol_version = ldap.VERSION3 # pylint: disable=E1101
try:
self.conn.simple_bind_s(self.config.bind_dn, self.config.bind_password)
except ldap.INVALID_CREDENTIALS as e: # pylint: disable=E1101
raise PluginInitError(self.__class__) from e
class UserAuthLdapPlugin(AuthLdapPlugin, BaseAuthPlugin):
"""Plugin to authenticate a user with an LDAP directory server."""
async def authenticate(self, *, session: Session) -> bool | None:
# use our initial creds to see if the user exists
search_filter = f"({self.config.user_attribute}={session.username})"
result = self.conn.search_s(self.config.base_dn, ldap.SCOPE_SUBTREE, search_filter, ["dn"]) # pylint: disable=E1101
if not result:
logger.debug(f"user not found: {session.username}")
return False
try:
# `search_s` responds with list of tuples: (dn, entry); first in list is our match
user_dn = result[0][0]
except IndexError:
return False
try:
user_conn = ldap.initialize(self.config.server)
user_conn.simple_bind_s(user_dn, session.password)
except ldap.INVALID_CREDENTIALS: # pylint: disable=E1101
logger.debug(f"invalid credentials for '{session.username}'")
return False
except ldap.LDAPError as e: # pylint: disable=E1101
logger.debug(f"LDAP error during user bind: {e}")
return False
return True
@dataclass
class Config(LdapConfig):
"""Configuration for the User Auth LDAP Plugin."""
class TopicAuthLdapPlugin(AuthLdapPlugin, BaseTopicPlugin):
"""Plugin to authenticate a user with an LDAP directory server."""
_action_attr_map: ClassVar = {
Action.PUBLISH: "publish_attribute",
Action.SUBSCRIBE: "subscribe_attribute",
Action.RECEIVE: "receive_attribute"
}
def __init__(self, context: BrokerContext) -> None:
super().__init__(context)
self.topic_matcher = TopicMatcher()
async def topic_filtering(
self, *, session: Session | None = None, topic: str | None = None, action: Action | None = None
) -> bool | None:
# if not provided needed criteria, can't properly evaluate topic filtering
if not session or not action or not topic:
return None
search_filter = f"({self.config.user_attribute}={session.username})"
attrs = [
"cn",
self.config.publish_attribute,
self.config.subscribe_attribute,
self.config.receive_attribute
]
results = self.conn.search_s(self.config.base_dn, ldap.SCOPE_SUBTREE, search_filter, attrs) # pylint: disable=E1101
if not results:
logger.debug(f"user not found: {session.username}")
return False
if len(results) > 1:
found_users = [dn for dn, _ in results]
logger.debug(f"multiple users found: {', '.join(found_users)}")
return False
dn, entry = results[0]
ldap_attribute = getattr(self.config, self._action_attr_map[action])
topic_filters = [t.decode("utf-8") for t in entry.get(ldap_attribute, [])]
logger.debug(f"DN: {dn} - {ldap_attribute}={topic_filters}")
return self.topic_matcher.are_topics_allowed(topic, topic_filters)
@dataclass
class Config(LdapConfig):
"""Configuration for the LDAPAuthPlugin."""
publish_attribute: str
"""LDAP attribute which contains a list of permissible publish topics."""
subscribe_attribute: str
"""LDAP attribute which contains a list of permissible subscribe topics."""
receive_attribute: str
"""LDAP attribute which contains a list of permissible receive topics."""

Wyświetl plik

@ -32,3 +32,7 @@ class TopicMatcher:
.lstrip("?"))
match_pattern = self._topic_filter_matchers[a_filter]
return bool(match_pattern.fullmatch(topic))
def are_topics_allowed(self, topic: str, many_filters: list[str]) -> bool:
return any(self.is_topic_allowed(topic, a_filter) for a_filter in many_filters)

Wyświetl plik

@ -18,6 +18,10 @@ These are fully supported plugins but require additional dependencies to be inst
Determine client authentication and authorization based on response from a separate HTTP server.<br/>
`amqtt.contrib.http.HttpAuthTopicPlugin`
- [LDAP Auth](ldap.md)<br/>
Authenticate a user with an LDAP directory server.<br/>
`amqtt.contrib.ldap.LDAPAuthPlugin`
- [Shadows](shadows.md)<br/>
Device shadows provide a persistent, cloud-based representation of the state of a device,
even when the device is offline. This plugin tracks the desired and reported state of a client

Wyświetl plik

@ -0,0 +1,25 @@
# Authentication with LDAP Server
If clients accessing the broker are managed by an LDAP server, this plugin can verify credentials
for client authentication and/or topic-level authorization.
- `amqtt.contrib.ldap.UserAuthLdapPlugin` (client authentication)
- `amqtt.contrib.ldap.TopicAuthLdapPlugin` (topic authorization)
Authenticate a user with an LDAP directory server.
## User Auth
::: amqtt.contrib.ldap.UserAuthLdapPlugin.Config
options:
heading_level: 4
extra:
class_style: "simple"
## Topic Auth (ACL)
::: amqtt.contrib.ldap.TopicAuthLdapPlugin.Config
options:
heading_level: 4
extra:
class_style: "simple"

Wyświetl plik

@ -47,6 +47,7 @@ nav:
- plugins/contrib.md
- Database Auth: plugins/auth_db.md
- HTTP Auth: plugins/http.md
- LDAP Auth: plugins/ldap.md
- Shadows: plugins/shadows.md
- Certificate Auth: plugins/cert.md
- Reference:

Wyświetl plik

@ -39,9 +39,6 @@ dependencies = [
]
[dependency-groups]
contrib = [
"pyopenssl>=25.1.0",
]
dev = [
"aiosqlite>=0.21.0",
"greenlet>=3.2.3",
@ -57,6 +54,7 @@ dev = [
"pyopenssl>=25.1.0",
"pytest-asyncio>=0.26.0", # https://pypi.org/project/pytest-asyncio
"pytest-cov>=6.1.0", # https://pypi.org/project/pytest-cov
"pytest-docker>=3.2.3",
"pytest-logdog>=0.1.0", # https://pypi.org/project/pytest-logdog
"pytest-timeout>=2.3.1", # https://pypi.org/project/pytest-timeout
"pytest>=8.3.5", # https://pypi.org/project/pytest
@ -96,8 +94,10 @@ contrib = [
"sqlalchemy[asyncio]>=2.0.41",
"argon2-cffi>=25.1.0",
"aiohttp>=3.12.13",
"python-ldap>=3.4.4",
"mergedeep>=1.3.4",
"jsonschema>=4.25.0",
"pyopenssl>=25.1.0"
]
@ -221,7 +221,7 @@ max-returns = 10
addopts = ["--cov=amqtt", "--cov-report=term-missing", "--cov-report=html"]
testpaths = ["tests"]
asyncio_mode = "auto"
timeout = 10
timeout = 15
asyncio_default_fixture_loop_scope = "function"
#addopts = ["--tb=short", "--capture=tee-sys"]
#log_cli = true

Wyświetl plik

@ -0,0 +1,136 @@
import asyncio
import time
from pathlib import Path
import pytest
from amqtt.broker import BrokerContext, Broker
from amqtt.client import MQTTClient
from amqtt.contexts import BrokerConfig, ListenerConfig, ClientConfig, Action
from amqtt.contrib.auth_db.user_mgr_cli import user_app
from amqtt.contrib.ldap import UserAuthLdapPlugin, TopicAuthLdapPlugin
from amqtt.errors import ConnectError
from amqtt.session import Session
from tests.test_cli import broker
# Pin the project name to avoid creating multiple stacks
@pytest.fixture(scope="session")
def docker_compose_project_name() -> str:
return "openldap"
# Stop the stack before starting a new one
@pytest.fixture(scope="session")
def docker_setup():
return ["down -v", "up --build -d"]
@pytest.fixture(scope="session")
def docker_compose_file(pytestconfig):
return Path(pytestconfig.rootdir) / "tests/fixtures/ldap" / "docker-compose.yml"
@pytest.fixture(scope="session")
def ldap_service(docker_ip, docker_services):
"""Ensure that HTTP service is up and responsive."""
# `port_for` takes a container port and returns the corresponding host port
port = docker_services.port_for("openldap", 389)
url = "ldap://{}:{}".format(docker_ip, port)
time.sleep(2)
return url
@pytest.mark.asyncio
async def test_ldap_user_plugin(ldap_service):
ctx = BrokerContext(Broker())
ctx.config = UserAuthLdapPlugin.Config(
server=ldap_service,
base_dn="dc=amqtt,dc=io",
user_attribute="uid",
bind_dn="cn=admin,dc=amqtt,dc=io",
bind_password="adminpassword",
)
ldap_plugin = UserAuthLdapPlugin(context=ctx)
s = Session()
s.username = "jdoe"
s.password = "johndoepassword"
assert await ldap_plugin.authenticate(session=s), "could not authenticate user"
@pytest.mark.asyncio
async def test_ldap_user(ldap_service):
cfg = BrokerConfig(
listeners={ 'default' : ListenerConfig() },
plugins={
'amqtt.contrib.ldap.UserAuthLdapPlugin': {
'server': ldap_service,
'base_dn': 'dc=amqtt,dc=io',
'user_attribute': 'uid',
'bind_dn': 'cn=admin,dc=amqtt,dc=io',
'bind_password': 'adminpassword',
},
}
)
broker = Broker(config=cfg)
await broker.start()
await asyncio.sleep(0.1)
client = MQTTClient(config=ClientConfig(auto_reconnect=False))
await client.connect('mqtt://jdoe:johndoepassword@127.0.0.1:1883')
await asyncio.sleep(0.1)
await client.publish('my/topic', b'my message')
await asyncio.sleep(0.1)
await client.disconnect()
await broker.shutdown()
@pytest.mark.asyncio
async def test_ldap_user_invalid_creds(ldap_service):
cfg = BrokerConfig(
listeners={ 'default' : ListenerConfig() },
plugins={
'amqtt.contrib.ldap.UserAuthLdapPlugin': {
'server': ldap_service,
'base_dn': 'dc=amqtt,dc=io',
'user_attribute': 'uid',
'bind_dn': 'cn=admin,dc=amqtt,dc=io',
'bind_password': 'adminpassword',
},
}
)
broker = Broker(config=cfg)
await broker.start()
await asyncio.sleep(0.1)
client = MQTTClient(config=ClientConfig(auto_reconnect=False))
with pytest.raises(ConnectError):
await client.connect('mqtt://jdoe:wrongpassword@127.0.0.1:1883')
await broker.shutdown()
@pytest.mark.asyncio
async def test_ldap_topic_plugin(ldap_service):
ctx = BrokerContext(Broker())
ctx.config = TopicAuthLdapPlugin.Config(
server=ldap_service,
base_dn="dc=amqtt,dc=io",
user_attribute="uid",
bind_dn="cn=admin,dc=amqtt,dc=io",
bind_password="adminpassword",
publish_attribute="publishACL",
subscribe_attribute="subscribeACL",
receive_attribute="receiveACL"
)
ldap_plugin = TopicAuthLdapPlugin(context=ctx)
s = Session()
s.username = "jdoe"
s.password = "wrongpassword"
assert await ldap_plugin.topic_filtering(session=s, topic='my/topic/one', action=Action.PUBLISH), "access not granted"

Wyświetl plik

@ -0,0 +1,7 @@
attributetype ( 1.3.6.1.4.1.4203.666.1.1 NAME 'publishACL' DESC 'topics for publishing' EQUALITY caseIgnoreMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15)
attributetype ( 1.3.6.1.4.1.4203.666.1.2 NAME 'subscribeACL' DESC 'topics for subscribing' EQUALITY caseIgnoreMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15)
attributetype ( 1.3.6.1.4.1.4203.666.1.3 NAME 'receiveACL' DESC 'topics for receiving' EQUALITY caseIgnoreMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15)
objectclass ( 1.3.6.1.4.1.4203.666.2.1 NAME 'customuserinfo' DESC 'User info with custom attributes' SUP inetOrgPerson STRUCTURAL MUST ( cn $ sn ) MAY ( publishACL $ subscribeACL $ receiveACL ) )

Wyświetl plik

@ -0,0 +1,18 @@
dn: ou=users,dc=amqtt,dc=io
objectClass: organizationalUnit
ou: users
description: Organizational Unit for storing user entries
dn: uid=jdoe,ou=users,dc=amqtt,dc=io
objectClass: inetOrgPerson
objectClass: customuserinfo
cn: John Doe
sn: Doe
uid: jdoe
mail: jdoe@amqtt.io
# `slappasswd -s johndoepassword`
userPassword: {SSHA}ANVSnjfMu85vXHNS5XW7i4EHGJ8VjMtu
publishACL: my/topic/#
publishACL: other/+/topic
subscribeACL: my/topic/two
receiveACL: my/topic/three

Wyświetl plik

@ -0,0 +1,22 @@
version: '3'
services:
openldap:
image: osixia/openldap:latest
container_name: openldap
command: [--copy-service]
restart: always
volumes:
- ./customuser.schema:/container/service/slapd/assets/config/bootstrap/schema/customuser.schema
- ./customusers.ldif:/container/service/slapd/assets/config/bootstrap/ldif/50-customusers.ldif
- ldap-data:/var/lib/ldap
- ldap-config:/etc/ldap/slapd.d
ports:
- "1389:389"
- "1636:636"
environment:
- LDAP_ADMIN_PASSWORD=adminpassword
- LDAP_DOMAIN=amqtt.io
volumes:
ldap-data:
ldap-config:

54
uv.lock
Wyświetl plik

@ -154,13 +154,12 @@ contrib = [
{ name = "greenlet" },
{ name = "jsonschema" },
{ name = "mergedeep" },
{ name = "pyopenssl" },
{ name = "python-ldap" },
{ name = "sqlalchemy", extra = ["asyncio"] },
]
[package.dev-dependencies]
contrib = [
{ name = "pyopenssl" },
]
dev = [
{ name = "aiosqlite" },
{ name = "greenlet" },
@ -177,6 +176,7 @@ dev = [
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-cov" },
{ name = "pytest-docker" },
{ name = "pytest-logdog" },
{ name = "pytest-timeout" },
{ name = "ruff" },
@ -218,6 +218,8 @@ requires-dist = [
{ name = "mergedeep", marker = "extra == 'contrib'", specifier = ">=1.3.4" },
{ name = "passlib", specifier = "==1.7.4" },
{ name = "psutil", specifier = ">=7.0.0" },
{ name = "pyopenssl", marker = "extra == 'contrib'", specifier = ">=25.1.0" },
{ name = "python-ldap", marker = "extra == 'contrib'", specifier = ">=3.4.4" },
{ name = "pyyaml", specifier = "==6.0.2" },
{ name = "sqlalchemy", extras = ["asyncio"], marker = "extra == 'contrib'", specifier = ">=2.0.41" },
{ name = "transitions", specifier = "==0.9.2" },
@ -227,7 +229,6 @@ requires-dist = [
provides-extras = ["ci", "contrib"]
[package.metadata.requires-dev]
contrib = [{ name = "pyopenssl", specifier = ">=25.1.0" }]
dev = [
{ name = "aiosqlite", specifier = ">=0.21.0" },
{ name = "greenlet", specifier = ">=3.2.3" },
@ -244,6 +245,7 @@ dev = [
{ name = "pytest", specifier = ">=8.3.5" },
{ name = "pytest-asyncio", specifier = ">=0.26.0" },
{ name = "pytest-cov", specifier = ">=6.1.0" },
{ name = "pytest-docker", specifier = ">=3.2.3" },
{ name = "pytest-logdog", specifier = ">=0.1.0" },
{ name = "pytest-timeout", specifier = ">=2.3.1" },
{ name = "ruff", specifier = ">=0.11.3" },
@ -1993,6 +1995,27 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" },
]
[[package]]
name = "pyasn1"
version = "0.6.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ba/e9/01f1a64245b89f039897cb0130016d79f77d52669aae6ee7b159a6c4c018/pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034", size = 145322, upload-time = "2024-09-10T22:41:42.55Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/f1/d6a797abb14f6283c0ddff96bbdd46937f64122b8c925cab503dd37f8214/pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629", size = 83135, upload-time = "2024-09-11T16:00:36.122Z" },
]
[[package]]
name = "pyasn1-modules"
version = "0.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyasn1" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" },
]
[[package]]
name = "pycparser"
version = "2.22"
@ -2209,6 +2232,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/28/d0/def53b4a790cfb21483016430ed828f64830dd981ebe1089971cd10cab25/pytest_cov-6.1.1-py3-none-any.whl", hash = "sha256:bddf29ed2d0ab6f4df17b4c55b0a657287db8684af9c42ea546b21b1041b3dde", size = 23841, upload-time = "2025-04-05T14:07:49.641Z" },
]
[[package]]
name = "pytest-docker"
version = "3.2.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "attrs" },
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/79/75/285187953062ebe38108e77a7919c75e157943fa3513371c88e27d3df7b2/pytest_docker-3.2.3.tar.gz", hash = "sha256:26a1c711d99ef01e86e7c9c007f69641552c1554df4fccb065b35581cca24206", size = 13452, upload-time = "2025-07-04T07:46:17.647Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c5/c7/e057e0d1de611ce1bbb26cccf07ddf56eb30a6f6a03aa512a09dac356e03/pytest_docker-3.2.3-py3-none-any.whl", hash = "sha256:f973c35e6f2b674c8fc87e8b3354b02c15866a21994c0841a338c240a05de1eb", size = 8585, upload-time = "2025-07-04T07:46:16.439Z" },
]
[[package]]
name = "pytest-logdog"
version = "0.1.0"
@ -2245,6 +2281,16 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
]
[[package]]
name = "python-ldap"
version = "3.4.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyasn1" },
{ name = "pyasn1-modules" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fd/8b/1eeb4025dc1d3ac2f16678f38dec9ebdde6271c74955b72db5ce7a4dbfbd/python-ldap-3.4.4.tar.gz", hash = "sha256:7edb0accec4e037797705f3a05cbf36a9fde50d08c8f67f2aef99a2628fab828", size = 377889, upload-time = "2023-11-17T21:14:16.32Z" }
[[package]]
name = "pytz"
version = "2025.2"