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]>
305 lines
11 KiB
Python
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]
|