Files
2026-07-19 12:02:41 -03:00

159 lines
5.4 KiB
Python

"""LDAP / Active Directory authentication.
Enabled and configured via ``settings.ldap_*`` (see ``app/core/config.py``).
When enabled, ``authenticate_ldap`` validates a login by binding to the
directory. On success it returns the directory attributes needed to provision
or update a local ``User`` row; the local row is what RBAC, tokens, and the
admin UI operate on.
``ldap3`` is synchronous, so the blocking bind/search runs in a worker thread
via ``run_in_threadpool`` to avoid stalling the async event loop. ``ldap3`` is
imported lazily so the rest of the app runs even when it is not installed and
LDAP is disabled.
"""
from dataclasses import dataclass
from starlette.concurrency import run_in_threadpool
from app.core.config import settings
@dataclass(frozen=True)
class LdapUser:
"""Identity resolved from the directory for a successfully-bound user."""
email: str
full_name: str | None = None
async def authenticate_ldap(username: str, password: str) -> LdapUser | None:
"""Return the directory identity if ``username``/``password`` bind, else None.
Never raises for an ordinary auth failure (bad credentials, user not found,
server unreachable) — those all return ``None``.
"""
if not settings.ldap_enabled:
return None
if not username or not password:
# An empty password would trigger an anonymous bind that "succeeds"
# without proving anything — treat it as a failed login.
return None
return await run_in_threadpool(_authenticate_sync, username, password)
def _build_server():
from ldap3 import Server, Tls
tls = None
if settings.ldap_use_ssl or settings.ldap_start_tls:
import ssl
validate = ssl.CERT_REQUIRED if settings.ldap_tls_validate else ssl.CERT_NONE
tls = Tls(
validate=validate,
ca_certs_file=settings.ldap_ca_certs_file or None,
)
return Server(
settings.ldap_server_uri,
use_ssl=settings.ldap_use_ssl,
tls=tls,
get_info="NONE",
)
def _authenticate_sync(username: str, password: str) -> LdapUser | None:
"""Blocking bind + attribute lookup. Runs in a worker thread."""
from ldap3 import Connection
from ldap3.core.exceptions import LDAPException
from ldap3.utils.conv import escape_filter_chars
from ldap3.utils.dn import escape_rdn
server = _build_server()
try:
if settings.ldap_bind_dn_template:
# Direct bind: construct the DN and bind straight as the user.
user_dn = settings.ldap_bind_dn_template.format(
username=escape_rdn(username)
)
return _bind_and_read(server, user_dn, password, username)
# Search + bind: locate the entry first, then bind as its DN.
search_filter = settings.ldap_user_search_filter.format(
username=escape_filter_chars(username)
)
finder = Connection(
server,
user=settings.ldap_bind_dn or None,
password=settings.ldap_bind_password or None,
auto_bind=True,
)
try:
attrs = [settings.ldap_attr_email, settings.ldap_attr_full_name]
found = finder.search(
search_base=settings.ldap_user_search_base,
search_filter=search_filter,
attributes=attrs,
)
if not found or not finder.entries:
return None
entry = finder.entries[0]
user_dn = entry.entry_dn
finally:
finder.unbind()
return _bind_and_read(server, user_dn, password, username, entry=entry)
except LDAPException:
# Network error, invalid DN template, etc. Fail closed.
return None
except (KeyError, IndexError, ValueError):
# e.g. a bad ``{username}`` template placeholder.
return None
def _bind_and_read(server, user_dn, password, username, entry=None):
"""Bind as ``user_dn`` to verify the password, then extract attributes."""
from ldap3 import Connection
from ldap3.core.exceptions import LDAPException
try:
conn = Connection(server, user=user_dn, password=password, auto_bind=True)
except LDAPException:
# Wrong password / bind rejected.
return None
try:
# Direct-bind mode has no prior search entry; read the attributes now.
if entry is None:
conn.search(
search_base=user_dn,
search_filter="(objectClass=*)",
search_scope="BASE",
attributes=[settings.ldap_attr_email, settings.ldap_attr_full_name],
)
entry = conn.entries[0] if conn.entries else None
finally:
conn.unbind()
email = _read_attr(entry, settings.ldap_attr_email) or _default_email(username)
full_name = _read_attr(entry, settings.ldap_attr_full_name)
return LdapUser(email=email.lower(), full_name=full_name)
def _read_attr(entry, name: str) -> str | None:
if entry is None:
return None
try:
value = entry[name].value
except (KeyError, LookupError):
return None
if isinstance(value, (list, tuple)):
value = value[0] if value else None
return str(value) if value else None
def _default_email(username: str) -> str:
"""Fallback identity when the directory entry exposes no email attribute."""
return username if "@" in username else f"{username}@ldap.local"