ldap_service.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595
  1. """LDAP authentication service for BamBuddy (#794).
  2. Supports:
  3. - LDAP bind authentication (simple bind with user's credentials)
  4. - StartTLS, LDAPS, and plaintext connections
  5. - User search with configurable filter
  6. - Group membership resolution for role mapping
  7. """
  8. from __future__ import annotations
  9. import json
  10. import logging
  11. from dataclasses import dataclass
  12. from ldap3 import ALL, SUBTREE, Connection, Server, Tls
  13. from ldap3.core.exceptions import LDAPException, LDAPObjectClassError, LDAPOperationResult
  14. logger = logging.getLogger(__name__)
  15. @dataclass
  16. class LDAPUserInfo:
  17. """User information retrieved from LDAP after successful authentication."""
  18. username: str
  19. email: str | None
  20. display_name: str | None
  21. groups: list[str] # List of group DNs the user belongs to
  22. @dataclass
  23. class LDAPSearchResult:
  24. """A directory user returned by the admin search endpoint (no auth performed)."""
  25. username: str
  26. email: str | None
  27. display_name: str | None
  28. dn: str
  29. @dataclass
  30. class LDAPConfig:
  31. """LDAP configuration parsed from settings."""
  32. server_url: str
  33. bind_dn: str
  34. bind_password: str
  35. search_base: str
  36. user_filter: str # e.g. "(sAMAccountName={username})"
  37. security: str # "none", "starttls", "ldaps"
  38. group_mapping: dict[str, str] # LDAP group DN -> BamBuddy group name
  39. auto_provision: bool
  40. ca_cert_path: str # Path to CA certificate file (empty = skip verification)
  41. default_group: str # Fallback BamBuddy group assigned when user has no mapped groups (empty = no fallback)
  42. def parse_ldap_config(settings: dict[str, str]) -> LDAPConfig | None:
  43. """Parse LDAP config from settings key-value pairs. Returns None if LDAP not enabled."""
  44. if settings.get("ldap_enabled", "false").lower() != "true":
  45. return None
  46. server_url = settings.get("ldap_server_url", "").strip()
  47. if not server_url:
  48. return None
  49. group_mapping_raw = settings.get("ldap_group_mapping", "")
  50. try:
  51. group_mapping = json.loads(group_mapping_raw) if group_mapping_raw else {}
  52. except json.JSONDecodeError:
  53. group_mapping = {}
  54. return LDAPConfig(
  55. server_url=server_url,
  56. bind_dn=settings.get("ldap_bind_dn", "").strip(),
  57. bind_password=settings.get("ldap_bind_password", ""),
  58. search_base=settings.get("ldap_search_base", "").strip(),
  59. user_filter=settings.get("ldap_user_filter", "(sAMAccountName={username})").strip(),
  60. security=settings.get("ldap_security", "starttls").strip(),
  61. group_mapping=group_mapping if isinstance(group_mapping, dict) else {},
  62. auto_provision=settings.get("ldap_auto_provision", "false").lower() == "true",
  63. ca_cert_path=settings.get("ldap_ca_cert_path", "").strip(),
  64. default_group=settings.get("ldap_default_group", "").strip(),
  65. )
  66. def _create_server(config: LDAPConfig) -> Server:
  67. """Create an ldap3 Server instance from config.
  68. Always uses TLS — either LDAPS (TLS from start) or StartTLS (upgrade after connect).
  69. Plaintext LDAP is not supported.
  70. """
  71. import ssl
  72. use_ssl = config.security == "ldaps" or config.server_url.startswith("ldaps://")
  73. if config.ca_cert_path:
  74. tls = Tls(validate=ssl.CERT_REQUIRED, ca_certs_file=config.ca_cert_path)
  75. else:
  76. tls = Tls(validate=ssl.CERT_NONE)
  77. return Server(config.server_url, use_ssl=use_ssl, tls=tls, get_info=ALL, connect_timeout=10)
  78. class LDAPStartTLSRefusedError(Exception):
  79. """The server answered the StartTLS request with an error result."""
  80. def _start_tls_if_configured(conn: Connection, config: LDAPConfig) -> None:
  81. """Upgrade `conn` with StartTLS when the config asks for it.
  82. A server that doesn't offer StartTLS (lldap, for one, only does LDAPS)
  83. rejects the request with a bare "Unsupported extended operation" and the
  84. StartTLS OID, which doesn't tell the admin what to change (#3197). Only a
  85. rejection by the server is reworded; TLS handshake and certificate failures
  86. keep their own messages.
  87. ldap3 re-reads the server's info and schema straight after StartTLS by
  88. default, still unauthenticated. A directory that refuses anonymous
  89. searches (Active Directory, Samba AD) answers that read with
  90. "operationsError", which failed every StartTLS connection to it and would
  91. otherwise be reported as a refused StartTLS here. bind() reads the same
  92. info once authenticated, so the early read is skipped.
  93. """
  94. if config.security != "starttls" or config.server_url.startswith("ldaps://"):
  95. return
  96. try:
  97. conn.start_tls(read_server_info=False)
  98. except LDAPOperationResult as e:
  99. raise LDAPStartTLSRefusedError(
  100. f"the server refused StartTLS ({e.description}). If it only offers LDAPS, "
  101. "choose LDAPS and use its ldaps:// URL and port"
  102. ) from e
  103. def _open_service_connection(config: LDAPConfig, server: Server, *, check_names: bool = True) -> Connection:
  104. """Open and bind a service-account LDAP connection. Raises on failure.
  105. `check_names` toggles ldap3's client-side attribute-name validation. The
  106. default keeps it on so typos in `user_filter` fail loudly. The fuzzy
  107. directory search disables it because its fixed OR filter spans both AD-only
  108. (sAMAccountName, displayName) and OpenLDAP-only attribute names — without
  109. this bypass ldap3 throws `LDAPAttributeError` before any request is sent
  110. on a directory whose schema doesn't define one of the names.
  111. """
  112. conn = Connection(
  113. server,
  114. user=config.bind_dn,
  115. password=config.bind_password,
  116. auto_bind=False,
  117. raise_exceptions=True,
  118. read_only=True,
  119. check_names=check_names,
  120. )
  121. conn.open()
  122. _start_tls_if_configured(conn, config)
  123. conn.bind()
  124. return conn
  125. # Group classes that list their members by DN, and the attribute each uses.
  126. _MEMBER_DN_GROUP_CLASSES = (("groupOfNames", "member"), ("groupOfUniqueNames", "uniqueMember"))
  127. def _schema_defines(names, name: str) -> bool | None:
  128. """Whether a schema dict (attribute types or object classes) has `name`.
  129. None when the server published no schema. ldap3 then skips its client-side
  130. name checks, so the caller can request the name without it raising.
  131. """
  132. if not names:
  133. return None
  134. return name in names
  135. def _user_search_attributes(server: Server) -> list[str]:
  136. """Attributes to request for a user entry: all user attributes plus memberOf.
  137. `*` alone does not return memberOf on every directory. OpenLDAP's memberof
  138. overlay makes it operational and lldap only returns it when asked by name
  139. (#3197), so it has to be listed. But ldap3 rejects a requested name that the
  140. server's schema doesn't define before anything is sent, even with
  141. check_names off, so it is only listed when the schema has it (or publishes
  142. no schema at all).
  143. """
  144. schema = server.schema
  145. has_member_of = _schema_defines(schema.attribute_types if schema else None, "memberOf")
  146. return ["*"] if has_member_of is False else ["*", "memberOf"]
  147. def _groups_base(server: Server, search_base: str) -> str:
  148. """The naming context that contains `search_base`, else `search_base`.
  149. Groups usually live beside the users rather than under them (ou=groups next
  150. to ou=people), so a group search from the user search base finds nothing.
  151. """
  152. base_lower = search_base.lower()
  153. contexts = server.info.naming_contexts if server.info and server.info.naming_contexts else []
  154. for context in contexts:
  155. context_lower = str(context).lower()
  156. if base_lower == context_lower or base_lower.endswith("," + context_lower):
  157. return str(context)
  158. return search_base
  159. # Root DSE capability that Active Directory (and Samba AD) advertises.
  160. _ACTIVE_DIRECTORY_CAPABILITY = "1.2.840.113556.1.4.800"
  161. def _is_active_directory(server: Server) -> bool:
  162. info = server.info
  163. features = info.supported_features if info and info.supported_features else []
  164. return any(feature[0] == _ACTIVE_DIRECTORY_CAPABILITY for feature in features)
  165. def _member_dn_groups(service_conn: Connection, config: LDAPConfig, user_dn: str) -> list[str]:
  166. """Find groupOfNames / groupOfUniqueNames entries that list `user_dn` as a member.
  167. memberOf alone is not enough outside Active Directory. Plain OpenLDAP has
  168. none without the memberof overlay; with it, the overlay tracks only the
  169. group class it was configured for (osixia's image: groupOfUniqueNames, so
  170. groupOfNames groups are missing) and only groups changed after it was
  171. loaded. The groups themselves are the authority, so they are asked too.
  172. Active Directory is skipped: it keeps memberOf complete itself, and its
  173. groups are objectClass=group, not either class searched here, so the
  174. search would cost a subtree walk from the domain root and find nothing.
  175. Only the classes the schema defines go into the filter, for the same
  176. client-side check the POSIX lookup trips over.
  177. """
  178. schema = service_conn.server.schema
  179. object_classes = schema.object_classes if schema else None
  180. clauses = [
  181. f"(&(objectClass={object_class})({attribute}={_ldap_escape(user_dn)}))"
  182. for object_class, attribute in _MEMBER_DN_GROUP_CLASSES
  183. if _schema_defines(object_classes, object_class) is not False
  184. ]
  185. if not clauses:
  186. return []
  187. search_filter = clauses[0] if len(clauses) == 1 else f"(|{''.join(clauses)})"
  188. try:
  189. service_conn.search(
  190. search_base=_groups_base(service_conn.server, config.search_base),
  191. search_filter=search_filter,
  192. search_scope=SUBTREE,
  193. attributes=["cn"],
  194. )
  195. except LDAPException as e:
  196. # The exception text can carry the user's DN (PII, #2681), so only its
  197. # type is logged. The user still logs in, with memberOf and POSIX groups.
  198. logger.warning("LDAP group membership lookup failed (%s); mapping without it", type(e).__name__)
  199. return []
  200. return [str(entry.entry_dn) for entry in service_conn.entries]
  201. def _pick_canonical_username(entry, fallback: str) -> str:
  202. """Prefer sAMAccountName, then uid, then the supplied fallback."""
  203. if hasattr(entry, "sAMAccountName") and entry.sAMAccountName:
  204. return str(entry.sAMAccountName)
  205. if hasattr(entry, "uid") and entry.uid:
  206. return str(entry.uid)
  207. return fallback
  208. def _extract_user_info(
  209. service_conn: Connection, config: LDAPConfig, user_entry, fallback_username: str
  210. ) -> LDAPUserInfo:
  211. """Build an LDAPUserInfo from an already-fetched directory entry.
  212. Collects memberOf groups, the groupOfNames / groupOfUniqueNames entries
  213. that list the user (except on Active Directory), POSIX
  214. memberUid groups, and the primary gidNumber group; dedups DNs
  215. case-insensitively. Uses the supplied service-bound connection for the
  216. group searches. The entry must have been fetched with
  217. `_user_search_attributes`, or memberOf may be missing from it.
  218. """
  219. email = str(user_entry.mail) if hasattr(user_entry, "mail") and user_entry.mail else None
  220. display_name = (
  221. str(user_entry.displayName) if hasattr(user_entry, "displayName") and user_entry.displayName else None
  222. )
  223. # Collect groups from the memberOf attribute (Active Directory, lldap,
  224. # OpenLDAP with the memberof overlay, 389-DS), then ask the groups too.
  225. groups = [str(g) for g in user_entry.memberOf] if hasattr(user_entry, "memberOf") and user_entry.memberOf else []
  226. if not _is_active_directory(service_conn.server):
  227. groups.extend(_member_dn_groups(service_conn, config, str(user_entry.entry_dn)))
  228. canonical_username = _pick_canonical_username(user_entry, fallback_username)
  229. # Also search for POSIX groups, both the memberUid kind and the primary
  230. # gidNumber kind. Both filters name the posixGroup object class, and ldap3
  231. # validates that name against the schema it fetched at connect time
  232. # (get_info=ALL) before it builds the request — so on a directory that
  233. # publishes a schema without posixGroup it raises client-side and nothing is
  234. # ever sent. A directory with no posixGroup class has no posixGroup entries,
  235. # which is exactly the answer the searches would have returned, so the
  236. # correct response is to carry on with the memberOf groups collected above.
  237. #
  238. # Left uncaught, that exception escaped authenticate_ldap_user, and the login
  239. # route reports any LDAP error as "Incorrect username or password" — so an
  240. # lldap user, whose accounts carry posixAccount but whose directory defines
  241. # no group classes beyond groupOfNames, could never log in and had nothing
  242. # but a wrong-password message to go on (#2769). This predates the primary
  243. # gidNumber lookup: the memberUid filter has named the class since #794.
  244. try:
  245. posix_filter = f"(&(objectClass=posixGroup)(memberUid={_ldap_escape(canonical_username)}))"
  246. service_conn.search(
  247. search_base=config.search_base,
  248. search_filter=posix_filter,
  249. search_scope=SUBTREE,
  250. attributes=["cn"],
  251. )
  252. for entry in service_conn.entries:
  253. groups.append(str(entry.entry_dn))
  254. # POSIX primary group: user's gidNumber matches a posixGroup's gidNumber.
  255. # Standard Unix semantics treat this as full group membership, so we need
  256. # to resolve it to a group DN alongside the memberUid results.
  257. if hasattr(user_entry, "gidNumber") and user_entry.gidNumber:
  258. primary_gid = str(user_entry.gidNumber)
  259. primary_filter = f"(&(objectClass=posixGroup)(gidNumber={_ldap_escape(primary_gid)}))"
  260. service_conn.search(
  261. search_base=config.search_base,
  262. search_filter=primary_filter,
  263. search_scope=SUBTREE,
  264. attributes=["cn"],
  265. )
  266. for entry in service_conn.entries:
  267. groups.append(str(entry.entry_dn))
  268. except LDAPObjectClassError:
  269. # Logged once per authentication, at info: it is the explanation for a
  270. # user's POSIX groups being absent from their mapping, and it is not an
  271. # error the operator can or should act on.
  272. logger.info(
  273. "Directory publishes no posixGroup object class; skipping POSIX group lookup "
  274. "(memberOf and groupOfNames groups are unaffected)"
  275. )
  276. # Dedupe group DNs (user may be in a group via both memberUid and primary gidNumber).
  277. # Case-insensitive comparison — LDAP DNs are case-insensitive by spec.
  278. seen_lower: set[str] = set()
  279. deduped_groups: list[str] = []
  280. for g in groups:
  281. key = g.lower()
  282. if key not in seen_lower:
  283. seen_lower.add(key)
  284. deduped_groups.append(g)
  285. return LDAPUserInfo(
  286. username=canonical_username,
  287. email=email,
  288. display_name=display_name,
  289. groups=deduped_groups,
  290. )
  291. def authenticate_ldap_user(config: LDAPConfig, username: str, password: str) -> LDAPUserInfo | None:
  292. """Authenticate a user via LDAP bind.
  293. 1. Bind with service account to search for the user DN
  294. 2. Attempt bind with the user's DN and provided password
  295. 3. On success, retrieve user attributes and group memberships
  296. Returns LDAPUserInfo on success, None on failure.
  297. """
  298. if not password:
  299. return None
  300. server = _create_server(config)
  301. try:
  302. service_conn = _open_service_connection(config, server)
  303. except Exception as e:
  304. logger.warning("LDAP service account bind failed: %s", e)
  305. return None
  306. try:
  307. # Search for the user
  308. search_filter = config.user_filter.replace("{username}", _ldap_escape(username))
  309. service_conn.search(
  310. search_base=config.search_base,
  311. search_filter=search_filter,
  312. search_scope=SUBTREE,
  313. attributes=_user_search_attributes(server),
  314. )
  315. if not service_conn.entries:
  316. logger.info("LDAP user not found: %s", username)
  317. return None
  318. user_entry = service_conn.entries[0]
  319. user_dn = str(user_entry.entry_dn)
  320. # Step 2: Bind as the user to verify password
  321. try:
  322. user_conn = Connection(
  323. server,
  324. user=user_dn,
  325. password=password,
  326. auto_bind=False,
  327. raise_exceptions=True,
  328. read_only=True,
  329. )
  330. user_conn.open()
  331. _start_tls_if_configured(user_conn, config)
  332. user_conn.bind()
  333. user_conn.unbind()
  334. except Exception as e:
  335. logger.info("LDAP bind failed for user %s: %s", username, e)
  336. return None
  337. info = _extract_user_info(service_conn, config, user_entry, username)
  338. # Don't log the raw DN — its leaf CN is the user's real name (PII, #2681).
  339. # The username + group count is enough to confirm a successful auth; the
  340. # support-bundle sanitizer also redacts any DN that slips through (e.g. an
  341. # ldap3 exception string), but keeping it out of the log at the source is
  342. # the primary hygiene per the "no private data in logs" rule.
  343. logger.info(
  344. "LDAP authentication successful for user: %s (groups: %d)",
  345. info.username,
  346. len(info.groups),
  347. )
  348. return info
  349. finally:
  350. service_conn.unbind()
  351. def lookup_ldap_user(config: LDAPConfig, username: str) -> LDAPUserInfo | None:
  352. """Look up a directory user by exact username via the service-account bind.
  353. Performs no password verification — intended for the admin manual-provision
  354. flow, where the caller has already been authenticated as a BamBuddy admin
  355. and now needs the directory attributes (email, display name, group DNs)
  356. to create the user.
  357. Uses the same `user_filter` template that the login path uses, so anything
  358. that logs in successfully via auto-provision is also resolvable here.
  359. """
  360. server = _create_server(config)
  361. try:
  362. service_conn = _open_service_connection(config, server)
  363. except Exception as e:
  364. logger.warning("LDAP service account bind failed during lookup: %s", e)
  365. raise
  366. try:
  367. search_filter = config.user_filter.replace("{username}", _ldap_escape(username))
  368. service_conn.search(
  369. search_base=config.search_base,
  370. search_filter=search_filter,
  371. search_scope=SUBTREE,
  372. attributes=_user_search_attributes(server),
  373. )
  374. if not service_conn.entries:
  375. logger.info("LDAP lookup: user not found: %s", username)
  376. return None
  377. return _extract_user_info(service_conn, config, service_conn.entries[0], username)
  378. finally:
  379. service_conn.unbind()
  380. def search_ldap_users(config: LDAPConfig, query: str, limit: int = 25) -> list[LDAPSearchResult]:
  381. """Fuzzy search the directory for users matching `query`.
  382. Uses a fixed OR filter across sAMAccountName, uid, mail, displayName, and
  383. cn — covering both Active Directory and OpenLDAP layouts. The query is
  384. RFC-4515 escaped so a typed `*` doesn't enumerate the whole directory.
  385. Returns up to `limit` results (default 25). Service-bind failures raise so
  386. the caller can surface a 503; "no matches" returns an empty list.
  387. Callers should enforce a minimum query length (≥2 chars) — short queries
  388. against a large directory are wasteful and effectively unbounded.
  389. """
  390. query = query.strip()
  391. if len(query) < 2:
  392. return []
  393. escaped = _ldap_escape(query)
  394. search_filter = (
  395. f"(|(sAMAccountName=*{escaped}*)(uid=*{escaped}*)(mail=*{escaped}*)(displayName=*{escaped}*)(cn=*{escaped}*))"
  396. )
  397. server = _create_server(config)
  398. try:
  399. # check_names=False so OpenLDAP directories (no sAMAccountName/displayName
  400. # in schema) don't reject the cross-schema OR filter — see helper docstring.
  401. service_conn = _open_service_connection(config, server, check_names=False)
  402. except Exception as e:
  403. logger.warning("LDAP service account bind failed during search: %s", e)
  404. raise
  405. try:
  406. # attributes=["*"] requests all user attributes. We can't enumerate the
  407. # AD/OpenLDAP-specific names (sAMAccountName, displayName) explicitly
  408. # because ldap3 validates the attribute list against the server schema
  409. # even with check_names=False — and OpenLDAP rejects the AD names. The
  410. # `*` wildcard is hardcoded in ldap3's ATTRIBUTES_EXCLUDED_FROM_CHECK so
  411. # it bypasses that validation, and the server returns whatever it has.
  412. service_conn.search(
  413. search_base=config.search_base,
  414. search_filter=search_filter,
  415. search_scope=SUBTREE,
  416. attributes=["*"],
  417. size_limit=limit,
  418. )
  419. results: list[LDAPSearchResult] = []
  420. for entry in service_conn.entries:
  421. username = _pick_canonical_username(entry, "")
  422. if not username and hasattr(entry, "cn") and entry.cn:
  423. # Last resort — some OpenLDAP layouts only have cn
  424. username = str(entry.cn)
  425. if not username:
  426. continue
  427. email = str(entry.mail) if hasattr(entry, "mail") and entry.mail else None
  428. display_name = str(entry.displayName) if hasattr(entry, "displayName") and entry.displayName else None
  429. results.append(
  430. LDAPSearchResult(
  431. username=username,
  432. email=email,
  433. display_name=display_name,
  434. dn=str(entry.entry_dn),
  435. )
  436. )
  437. logger.info("LDAP directory search for %r returned %d result(s)", query, len(results))
  438. return results
  439. finally:
  440. service_conn.unbind()
  441. def resolve_group_mapping(ldap_groups: list[str], group_mapping: dict[str, str]) -> list[str]:
  442. """Map LDAP group DNs to BamBuddy group names.
  443. Returns list of BamBuddy group names that the user should be added to.
  444. Comparison is case-insensitive on the LDAP group DN.
  445. """
  446. if not group_mapping:
  447. return []
  448. # Build case-insensitive lookup
  449. mapping_lower = {k.lower(): v for k, v in group_mapping.items()}
  450. result = []
  451. for ldap_group in ldap_groups:
  452. bambuddy_group = mapping_lower.get(ldap_group.lower())
  453. if bambuddy_group:
  454. result.append(bambuddy_group)
  455. return result
  456. def test_ldap_connection(config: LDAPConfig) -> tuple[bool, str]:
  457. """Test LDAP connection and service account bind.
  458. Returns (success, message).
  459. """
  460. try:
  461. server = _create_server(config)
  462. conn = Connection(
  463. server,
  464. user=config.bind_dn,
  465. password=config.bind_password,
  466. auto_bind=False,
  467. raise_exceptions=True,
  468. read_only=True,
  469. )
  470. conn.open()
  471. _start_tls_if_configured(conn, config)
  472. conn.bind()
  473. # Try a search to verify search base
  474. conn.search(
  475. search_base=config.search_base,
  476. search_filter="(objectClass=*)",
  477. search_scope=SUBTREE,
  478. size_limit=1,
  479. )
  480. conn.unbind()
  481. return True, "LDAP connection successful"
  482. except Exception as e:
  483. return False, f"LDAP connection failed: {e}"
  484. def _ldap_escape(value: str) -> str:
  485. """Escape special characters in LDAP search filter values (RFC 4515)."""
  486. replacements = {
  487. "\\": "\\5c",
  488. "*": "\\2a",
  489. "(": "\\28",
  490. ")": "\\29",
  491. "\x00": "\\00",
  492. }
  493. for char, escaped in replacements.items():
  494. value = value.replace(char, escaped)
  495. return value