Files
PacketStation/server/packetstation/auth/ldap.py
T

380 lines
14 KiB
Python
Raw Normal View History

"""Read-only access to Active Directory with ldap3: search with the service account, bind to verify a password.
Every operation opens its own connection and unbinds when done. A user's password is used for one bind and
never stored or logged.
"""
from __future__ import annotations
import contextlib
import logging
import ssl
import uuid
from collections.abc import Iterator
from dataclasses import dataclass, field
from typing import Any, Protocol
from ..settings import SecretFileError, Settings
log = logging.getLogger(__name__)
IN_CHAIN = "1.2.840.113556.1.4.1941"
USER_ATTRS = (
"objectGUID",
"sAMAccountName",
"userPrincipalName",
"displayName",
"employeeID",
"mail",
"userAccountControl",
"distinguishedName",
)
ACCOUNTDISABLE = 2
INVALID_CREDENTIALS = 49
PAGE_SIZE = 500
GUID_BATCH = 50 # objectGUID terms in one OR filter
SEARCH_GROUPS_MAX = 100
class LdapUnavailable(Exception):
"""No domain controller answered, TLS failed, or the service bind was refused. Never holds a password."""
@dataclass
class AdUser:
guid: uuid.UUID
dn: str
sam_account: str
upn: str | None
display_name: str
emp_no: str | None
email: str | None
disabled: bool
groups: list[str] = field(default_factory=list)
class DirectoryProtocol(Protocol):
"""What the app needs from AD. `Directory` talks to a domain controller; tests use a fake."""
def find_user(self, login: str) -> AdUser | None: ...
def get_user_by_guid(self, guid: uuid.UUID) -> AdUser | None: ...
def verify_password(self, dn: str, password: str) -> bool: ...
def groups_of(self, dn: str) -> list[str]: ...
def list_groups(self) -> list[str]: ...
def members_of(self, group_cn: str) -> list[AdUser]: ...
def list_groups_detail(self) -> list[tuple[str, str]]: ...
def members_of_dn(self, group_dn: str) -> list[AdUser]: ...
def search_groups(self, q: str, limit: int = 25) -> list[dict[str, str]]: ...
def users_by_guid(self, guids: list[uuid.UUID]) -> dict[uuid.UUID, AdUser | None]: ...
# ---------------------------------------------------------------- attribute helpers
def parse_guid(value: Any) -> uuid.UUID:
"""objectGUID as ldap3 returns it: 16 raw bytes (little-endian fields) or a "{...}" string."""
if isinstance(value, list | tuple):
if not value:
raise ValueError("empty objectGUID")
value = value[0]
if isinstance(value, bytes | bytearray):
if len(value) != 16:
raise ValueError("objectGUID must be 16 bytes")
return uuid.UUID(bytes_le=bytes(value))
if isinstance(value, uuid.UUID):
return value
return uuid.UUID(str(value).strip().strip("{}"))
def guid_filter(guid: uuid.UUID) -> str:
"""`(objectGUID=\\xx...)` with the bytes escaped for a search filter."""
from ldap3.utils.conv import escape_bytes
return f"(objectGUID={escape_bytes(guid.bytes_le)})"
def _first(attrs: dict[str, Any], name: str) -> Any:
v = attrs.get(name)
if isinstance(v, list | tuple):
return v[0] if v else None
return v
def _text(attrs: dict[str, Any], name: str) -> str | None:
v = _first(attrs, name)
if v is None:
return None
if isinstance(v, bytes | bytearray):
v = bytes(v).decode("utf-8", "replace")
text = str(v).strip()
return text or None
def user_from_entry(entry: dict[str, Any]) -> AdUser:
"""An AdUser from one ldap3 search response entry (`attributes` plus `raw_attributes`)."""
attrs = entry.get("attributes") or {}
raw = entry.get("raw_attributes") or {}
guid_raw = _first(raw, "objectGUID") or _first(attrs, "objectGUID")
if guid_raw is None:
raise ValueError("entry has no objectGUID")
uac_text = _text(attrs, "userAccountControl") or "0"
try:
uac = int(uac_text)
except ValueError:
uac = 0
sam = _text(attrs, "sAMAccountName") or ""
return AdUser(
guid=parse_guid(guid_raw),
dn=_text(attrs, "distinguishedName") or str(entry.get("dn") or ""),
sam_account=sam,
upn=_text(attrs, "userPrincipalName"),
display_name=_text(attrs, "displayName") or sam,
emp_no=_text(attrs, "employeeID"),
email=_text(attrs, "mail"),
disabled=bool(uac & ACCOUNTDISABLE),
)
def _entries(response: Any) -> list[dict[str, Any]]:
return [e for e in (response or []) if e.get("type") == "searchResEntry"]
# ---------------------------------------------------------------- the real directory
class Directory:
"""AD over ldaps:// (or ldap:// with STARTTLS). Built from Settings; every method is synchronous."""
def __init__(self, settings: Settings):
self.settings = settings
self._pool: Any = None
# ------------------------------------------------ connections
def _servers(self) -> Any:
import ldap3
if self._pool is not None:
return self._pool
s = self.settings
urls = s.ldap_urls
if not urls:
raise LdapUnavailable("LDAP_URL is not set")
servers = []
for url in urls:
ldaps = url.lower().startswith("ldaps://")
if not ldaps and not s.ldap_starttls and not s.ldap_allow_insecure:
raise LdapUnavailable(f"{url} is plain LDAP without STARTTLS; refused")
tls = ldap3.Tls(
validate=ssl.CERT_REQUIRED,
version=ssl.PROTOCOL_TLS_CLIENT,
ca_certs_file=str(s.ldap_ca_bundle) if s.ldap_ca_bundle else None,
)
servers.append(ldap3.Server(url, use_ssl=ldaps, tls=tls, get_info=ldap3.NONE, connect_timeout=10))
# FIRST: the first healthy controller is used. active=1 tries the list once (True would loop
# forever when every controller is down); exhaust=60 skips a dead controller for a minute.
self._pool = ldap3.ServerPool(servers, ldap3.FIRST, active=1, exhaust=60)
return self._pool
def _open(self, user: str, password: str) -> Any:
"""An open, TLS-protected connection, not yet bound."""
import ldap3
conn = ldap3.Connection(
self._servers(),
user=user,
password=password,
authentication=ldap3.SIMPLE,
receive_timeout=15,
raise_exceptions=False,
auto_referrals=False,
read_only=True,
)
conn.open()
url_is_ldaps = bool(getattr(conn.server, "ssl", False))
if self.settings.ldap_starttls and not url_is_ldaps and not conn.start_tls():
with contextlib.suppress(Exception):
conn.unbind()
raise LdapUnavailable(f"STARTTLS failed: {conn.result.get('description')}")
return conn
@contextlib.contextmanager
def _service(self) -> Iterator[Any]:
"""A connection bound as the service account; always unbound afterwards."""
from ldap3.core.exceptions import LDAPException
s = self.settings
if not s.ldap_bind_dn:
raise LdapUnavailable("LDAP_BIND_DN is not set")
try:
password = s.ldap_password()
except SecretFileError as e:
raise LdapUnavailable(str(e)) from None
conn = None
try:
conn = self._open(s.ldap_bind_dn, password)
if not conn.bind():
raise LdapUnavailable(f"service bind failed: {conn.result.get('description')}")
yield conn
except LDAPException as e:
raise LdapUnavailable(f"{type(e).__name__}: {e}") from None
finally:
if conn is not None:
with contextlib.suppress(Exception):
conn.unbind()
def _search(self, conn: Any, base: str, flt: str, attrs: tuple[str, ...] | list[str]) -> list[dict[str, Any]]:
import ldap3
results = conn.extend.standard.paged_search(
base, flt, ldap3.SUBTREE, attributes=list(attrs), paged_size=PAGE_SIZE, generator=False
)
desc = conn.result.get("description") if conn.result else "success"
if desc not in ("success", None, "noSuchObject"):
raise LdapUnavailable(f"search failed: {desc}")
return _entries(results)
# ------------------------------------------------ protocol
def find_user(self, login: str) -> AdUser | None:
from ldap3.utils.conv import escape_filter_chars
login = login.strip()
if not login:
return None
flt = self.settings.ldap_user_filter.replace("{0}", escape_filter_chars(login))
with self._service() as conn:
found = self._search(conn, self.settings.ldap_base_dn, flt, USER_ATTRS)
users = [user_from_entry(e) for e in found]
if len(users) != 1:
if len(users) > 1:
log.warning("LDAP_USER_FILTER matched %d entries for one login; refusing", len(users))
return None
return users[0]
def get_user_by_guid(self, guid: uuid.UUID) -> AdUser | None:
with self._service() as conn:
found = self._search(conn, self.settings.ldap_base_dn, guid_filter(guid), USER_ATTRS)
return user_from_entry(found[0]) if found else None
def verify_password(self, dn: str, password: str) -> bool:
"""Simple bind as the user. An empty password is refused here: AD treats it as an anonymous bind."""
from ldap3.core.exceptions import LDAPException
if not password or not dn:
return False
conn = None
try:
conn = self._open(dn, password)
if conn.bind():
return True
code = conn.result.get("result") if conn.result else None
if code == INVALID_CREDENTIALS:
return False
raise LdapUnavailable(f"user bind failed: {conn.result.get('description')}")
except LDAPException as e:
raise LdapUnavailable(f"{type(e).__name__}") from None
finally:
if conn is not None:
with contextlib.suppress(Exception):
conn.unbind()
def groups_of(self, dn: str) -> list[str]:
from ldap3.utils.conv import escape_filter_chars
flt = f"(&(objectClass=group)(member:{IN_CHAIN}:={escape_filter_chars(dn)}))"
with self._service() as conn:
found = self._search(conn, self.settings.ldap_group_base_dn, flt, ["cn"])
return sorted({cn for e in found if (cn := _text(e.get("attributes") or {}, "cn"))})
def list_groups(self) -> list[str]:
with self._service() as conn:
found = self._search(conn, self.settings.ldap_group_base_dn, "(objectClass=group)", ["cn"])
return sorted({cn for e in found if (cn := _text(e.get("attributes") or {}, "cn"))})
def members_of(self, group_cn: str) -> list[AdUser]:
"""Every account in the group under LDAP_GROUP_BASE_DN, nested included (IN_CHAIN, paged)."""
from ldap3.utils.conv import escape_filter_chars
with self._service() as conn:
groups = self._search(
conn,
self.settings.ldap_group_base_dn,
f"(&(objectClass=group)(cn={escape_filter_chars(group_cn)}))",
["cn", "distinguishedName"],
)
if not groups:
return []
gdn = _text(groups[0].get("attributes") or {}, "distinguishedName") or str(groups[0].get("dn") or "")
return self._members(conn, gdn)
def _members(self, conn: Any, group_dn: str) -> list[AdUser]:
from ldap3.utils.conv import escape_filter_chars
flt = f"(&(objectClass=user)(objectCategory=person)(memberOf:{IN_CHAIN}:={escape_filter_chars(group_dn)}))"
found = self._search(conn, self.settings.ldap_base_dn, flt, USER_ATTRS)
return [user_from_entry(e) for e in found]
def list_groups_detail(self) -> list[tuple[str, str]]:
"""(cn, dn) of every group in LDAP_GROUP_BASE_DN, sorted by cn."""
with self._service() as conn:
found = self._search(
conn, self.settings.ldap_group_base_dn, "(objectClass=group)", ["cn", "distinguishedName"]
)
out: dict[str, tuple[str, str]] = {}
for e in found:
attrs = e.get("attributes") or {}
cn = _text(attrs, "cn")
if cn:
out[cn.lower()] = (cn, _text(attrs, "distinguishedName") or str(e.get("dn") or ""))
return sorted(out.values(), key=lambda t: t[0].lower())
def members_of_dn(self, group_dn: str) -> list[AdUser]:
"""Every account in the group by DN, nested included, disabled accounts too. Paged at 500."""
with self._service() as conn:
return self._members(conn, group_dn)
def search_groups(self, q: str, limit: int = 25) -> list[dict[str, str]]:
"""Groups anywhere under LDAP_BASE_DN whose name or description contains q: [{dn, name, description}]."""
from ldap3.utils.conv import escape_filter_chars
q = q.strip()
if not q:
return []
limit = max(1, min(int(limit), SEARCH_GROUPS_MAX))
term = escape_filter_chars(q)
flt = f"(&(objectClass=group)(|(cn=*{term}*)(description=*{term}*)))"
with self._service() as conn:
found = self._search(conn, self.settings.ldap_base_dn, flt, ["cn", "distinguishedName", "description"])
out = []
for e in found:
attrs = e.get("attributes") or {}
name = _text(attrs, "cn")
if not name:
continue
out.append(
{
"dn": _text(attrs, "distinguishedName") or str(e.get("dn") or ""),
"name": name,
"description": _text(attrs, "description") or "",
}
)
out.sort(key=lambda g: (not g["name"].lower().startswith(q.lower()), g["name"].lower()))
return out[:limit]
def users_by_guid(self, guids: list[uuid.UUID]) -> dict[uuid.UUID, AdUser | None]:
"""The accounts for these objectGUIDs, read in batches. None for a GUID AD no longer has."""
wanted = list(dict.fromkeys(guids))
out: dict[uuid.UUID, AdUser | None] = dict.fromkeys(wanted)
if not wanted:
return out
with self._service() as conn:
for i in range(0, len(wanted), GUID_BATCH):
part = wanted[i : i + GUID_BATCH]
flt = "(|" + "".join(guid_filter(g) for g in part) + ")"
for e in self._search(conn, self.settings.ldap_base_dn, flt, USER_ATTRS):
u = user_from_entry(e)
if u.guid in out:
out[u.guid] = u
return out