Files
PacketStation/server/packetstation/auth/ldap.py
T
spencermandClaude Fable 5.1 97b0973130 Phase 1: identity, sessions, audit, frontend shell
AD sign-in over LDAPS (search with the service account, bind as the user,
nested groups through LDAP_MATCHING_RULE_IN_CHAIN, disabled accounts refused),
roles as the union of PS-* groups mapped by a table derived from the seeded
departments. Server-side sessions with idle, lock and absolute timeouts, Origin
and X-Requested-With check, step-up unlock. Optional Argon2id PIN set with the
domain password; PIN sign-in by employee ID on registered devices only; five
wrong attempts disable the PIN, three wrong at unlock or signature end the
session. Rate limits per account and per address before any bind. Devices,
signatures table and sign helper, audit rows for every identity event, SSE over
LISTEN/NOTIFY with presence. Account settings and Admin APIs and screens
(AD view, devices, PIN clearing, group map with missing-group flags, refresh
from AD now). CLI ad-groups --list and ad-refresh. Migration 0002.

Frontend: real sign-in (password tab, PIN keypad on registered devices), lock
screen, role switcher, Account settings, Admin shell, screen placeholders.

CI: Samba AD DC container (PSTEST.LOCAL) with a generated CA, 49 of the 50
groups plus one extra, 17 test users; wired into the CI compose overlay and
the Gitea workflow; Playwright sign-in scenario.

Verified on docker-1: 171 server tests green inside the CI stack against
PostgreSQL and the Samba DC, 48 frontend tests, 8 Playwright scenarios against
the live stack (password and PIN sign-in, lock and unlock, Admin group map).
Findings recorded in the docs: the DC needs CAP_SYS_ADMIN, a hidden tab locks
only after a minute as the old build did,
and the app uses the alias ps-app for Playwright because "app" is on
Chromium's HSTS preload list.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-10-16 07:40:29 -06:00

305 lines
11 KiB
Python

"""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
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]: ...
# ---------------------------------------------------------------- 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]:
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 "")
flt = f"(&(objectClass=user)(objectCategory=person)(memberOf:{IN_CHAIN}:={escape_filter_chars(gdn)}))"
found = self._search(conn, self.settings.ldap_base_dn, flt, USER_ATTRS)
return [user_from_entry(e) for e in found]