T10.1 D13 - the LDAPS client, verified against the live domain

server/ldap_auth.py: simple bind to ldaps://prime.local:636 as
sAMAccountName@prime.local, nested-group membership via the
LDAP_MATCHING_RULE_IN_CHAIN extensible match, and a selftest() that validates
the DC certificate without binding so it can never contribute to a lockout.

Verified against the live domain, not just reasoned about:

  selftest() to prime.local        -> ok, "certificate validates"
  selftest() to 192.168.3.37       -> refused, untrusted (no IP SAN)
  empty / whitespace password      -> empty_input, with Connection nulled out
                                      so any call to bind() would have raised
  missing CA file                  -> unconfigured, is_config_problem=True
  Tls.validate                     -> ssl.CERT_REQUIRED, explicit ca_certs_file

Three things here are load-bearing and commented as such at the call site:

- The empty-password guard runs BEFORE bind(). An LDAP simple bind with an
  empty password is an anonymous bind and it SUCCEEDS, so without the guard a
  blank password authenticates as whatever username was submitted.
- No `version=` pin on Tls. An earlier draft of this file pinned
  PROTOCOL_TLSv1_2, which would have silently downgraded every connection from
  the TLS 1.3 these DCs actually negotiate.
- Retries cover connect failures only. A rejected credential returns
  immediately, because every failed bind counts against the domain lockout
  policy and this endpoint must not become a way to lock people out of Windows.

The trust anchor is server/certs/prime-ca-chain.pem - PRIME CONTROLS ROOT CA
plus ISSUING CA 1, public certificates with no private key, checked in because
they are public and long-lived (2051 / 2036). The system trust store is
deliberately not used: it currently trusts five other self-signed CAs on this
estate. LDAP_CA_FILE overrides the path for a mounted bundle.

docker-compose.yml: the `outbound` network is no longer optional. Its comment
said to detach it if you were not using the Micron asset picker; doing that
now breaks every sign-in, since `internal` has no default gateway and
therefore no route to prime.local:636.

Not yet verified, and called out rather than assumed: the nested-group case
needs a real group with a nested member, and the in-container
`openssl s_client -CAfile` check needs the stack. Both are T10.1 done-when
boxes still open.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-21 14:06:49 -05:00
parent 3c9343dfc8
commit 0577660c86
7 changed files with 572 additions and 19 deletions

View File

@@ -21,6 +21,40 @@ AUTH_SECRET_KEY=CHANGE_ME_run_the_command_above
# How long a login lasts before re-authentication (hours). Default 12.
# AUTH_SESSION_HOURS=12
# ── Domain authentication, D13 (REQUIRED — there is no fallback) ───────────────
# The suite stores no passwords. Sign-in is an LDAPS simple bind against the
# domain, so if this is misconfigured NOBODY CAN SIGN IN, admins included. There
# is deliberately no local break-glass account (decided Aug 21 2026 — see
# docs/waves/decisions-2026-08-21.md). Check `docker compose logs api` on startup:
# the API logs one line saying whether LDAP is configured and reachable.
#
# Connect to the DOMAIN NAME, never a DC hostname and never an IP. Every DC's
# certificate carries `prime.local` in its SAN, so the domain name both passes
# hostname validation and round-robins across all six DCs. An IP fails with
# `hostname mismatch` (there is no IP SAN) and the only way to force it through is
# to disable validation, which must not happen — domain passwords cross this link.
# LDAP_DOMAIN=prime.local
# LDAP_HOST=prime.local
# LDAP_PORT=636
# Trust anchor: PRIME CONTROLS ROOT CA + PRIME CONTROLS ISSUING CA 1 as a PEM
# bundle. These are PUBLIC certificates — no private key, nothing issued to this
# app, nothing to request from IT. The repo ships a verified copy and the default
# points at it, so you only set this to override with a mounted file.
# LDAP_CA_FILE=/app/server/certs/prime-ca-chain.pem
# An AD group required to sign in. Empty means every domain account may sign in.
# This is the INITIAL value and the fallback; the live value is set in the Admin
# console, which refuses to save a group that does not resolve or that the saving
# admin is not a member of. Nested groups count.
# LDAP_REQUIRED_GROUP=WP-Suite-Users
# Bind/connect timeout, and how many extra CONNECT attempts to make. Retries never
# apply to a rejected password — each failed bind counts against the domain lockout
# policy, so guessing would lock real accounts out of Windows.
# LDAP_TIMEOUT_SECONDS=8
# LDAP_CONNECT_RETRIES=2
# ── Email notifications (optional) ─────────────────────────────────────────────
# WP-assignment emails are OFF by default and are turned on from the Admin
# console (Notifications & email card), where the SMTP host/port/from-address

View File

@@ -0,0 +1,66 @@
# CN=PRIME CONTROLS ROOT CA
-----BEGIN CERTIFICATE-----
MIIFHzCCAwegAwIBAgIQOnMIcdMiP6pELiXfh2ZmLzANBgkqhkiG9w0BAQsFADAhMR8wHQYDVQQD
ExZQUklNRSBDT05UUk9MUyBST09UIENBMCAXDTIxMDkwOTEzMjM1OFoYDzIwNTEwOTA5MTMzMzU2
WjAhMR8wHQYDVQQDExZQUklNRSBDT05UUk9MUyBST09UIENBMIICIjANBgkqhkiG9w0BAQEFAAOC
Ag8AMIICCgKCAgEAri9Vkf+l3bnjGXWMEX15BYRnQTSKUStctG2NBppJ/lwj2LbHAvHf6HdkJAxx
2lqDiG0R+D9NcGEi427XOQ78Nc9aJe4jOwip5u5Md6Szwmu4QDRjUy11dDBvRoftD550052O1WOV
0OY5hxcZIo7bOfqDLesHG/Y74GJvrYai/4xq440uN6iTaMmsfzbIahIXP39NhuW4i4dgkQkRSfqX
y8i3AGS8WhpViVvIlMgGXRrCcBW8MnVp32OvKE2MqQnjZI2i5f8wMT4L0J0DXlSPbV6FPLdpBXl5
+OX+9qcEH6hI+Mv8sC/xGLAt/wwBP6E2kM2lovGGIUhBsay0UM03PJsh4r6q3HV5gpH6uYZlOgOY
UW59pNT/VyESdZb4kfEGHNHrHy3uNb8Q71UzQxrq/UVKXnBUMN/1PszV4YA08ZaYf2EGItZoNM3v
uTOJIifgtAo1DxaTygmglgk8CHlaN4IU8jotrzdksLYQ9MQaD5xnYADCnIf1eZ5VCF+fhpgQOCCV
4TfJ20rR14+jy6L4yKYGgtvg8H+hvhucOOnktpV/zLv4H9pItIzbQPuxNdrEmco2i7zrsMuaNYD/
tmbGf4mhy20zFOkdOjXGeb4cTF4HIBh3aue2T7o08vi1pkVN+8RXa7lZNbLJnpgtfTr9DKBpW2Fd
LqAb1zN2Azorg3kCAwEAAaNRME8wCwYDVR0PBAQDAgGGMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0O
BBYEFMoWYtRcmyQQmB3AD4DPiau0r9XRMBAGCSsGAQQBgjcVAQQDAgEAMA0GCSqGSIb3DQEBCwUA
A4ICAQB0XhWlrK81ZKW9bBlbQe/i6js7ciDhwObgKB0FGZJH8rqK5HFwM/wZbh9h0t+YPk/ev6oG
BXU0rJUr1YVMERBb37c/rHBU4Bnv9r6cLhsfiTTcVGqZTM7JE9JfdmJQ10V1awSFoxU1cYMSzot9
TyrspkZJ6JFJH2wdhZirIySQHH5WuqEOdG6g9/bDW2X27B0S62s8WNIfP+gugkTDkiHQvoUDi8GY
/DA29lZTKPI8PhmKYZvw+fi1weqIYsFLk9pvHW21nIv6qAT7nfuGvuUR5siB7sFbw80XEX6Em8O0
0lnP6u+vecBqLK7D34X+aupDlkZlZdPoa0EaTwvTO8pkecZCdMLcTkB2quc/fcyCmVdj4CGn3yND
cUUR5wZbHtuCU27Rc4d3rY0gxPNpK3EXkTSOQL7BgR6EkwwNwUqeYmFb/SyXYeSqpdChvsWKgRrP
+8n27SeJ01ezk8GMDC0YcOJAAHxXqqOAJo1IwxVvpRkoljKIAprQwHAGUNTmKy+SJXT73/LpPTX+
RnbNTG27UfYONh5/DdkGc1wwmIp1X1a1tAb3MNRasiBGlYJ3NGDHVUwgEtmcVeVXWsj1ZhaXq2YX
TFsDl9m1IMhFD1n8pLJTLd7AT8Exxga+OzFjMzDu0uzKw3KoAVIX/IgfybdKexVT4Z+nBCY9N+n9
/vpujg==
-----END CERTIFICATE-----
# CN=PRIME CONTROLS ISSUING CA 1, DC=prime, DC=local
-----BEGIN CERTIFICATE-----
MIIH/jCCBeagAwIBAgITdQAAAAI3j8Wt9kAshQAAAAAAAjANBgkqhkiG9w0BAQsFADAhMR8wHQYD
VQQDExZQUklNRSBDT05UUk9MUyBST09UIENBMB4XDTIxMDkwOTE0NDUxM1oXDTM2MDkwOTE0NTUx
M1owVDEVMBMGCgmSJomT8ixkARkWBWxvY2FsMRUwEwYKCZImiZPyLGQBGRYFcHJpbWUxJDAiBgNV
BAMTG1BSSU1FIENPTlRST0xTIElTU1VJTkcgQ0EgMTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCC
AgoCggIBANvhsMj7RooZ+FzAExgWLex++F1QsdDqcGkwqjaUI5AziI4yb/FMqnO93Sus60hiqql+
hfxW/a55huBfo54kOyp5oWAjd0akfJQvFY8sXsWIGz07wiy0msvZDtHdBXy561zb0SSNpJS1Vceb
ihyDvM5Tyc6QAzO1u76QJPkegn+v6lpWmodba0kWVvRg/P3SLCnq1LdU8oA1VhIiCkCbdry0Syop
bVX0+evzINGmv81VbHct8ptZTBLctM11C16IwPEmntvOqVKpT166/aUO8FMqyHQQZ32ZHBp6ORNu
WlCN/EDdgT2s7Vy9/7Hr6gy1IPjEGNYq1yYxcpth+6/RgvxFnt9SWr7B9qKtYe1rN8r+6qYVw4ne
c0L7vRvw8HTjOJ9EQnPfID9+34Y8OQkqIHnnF80yjMGCdvh/tR/tXsUlz+Byt9m5MFdhPE22MX1x
jyCJFug1Ufso8toAVZcavsRfX0ygeUkv+zZDZKHmF910rB8Cdb980cpKtFvx/v0BNWnzgAhgGkqO
ttUf0PKUeGDIW6DMUKhs5JM7ED5rkepvNG5vePDVy/YidhbM4x1ph+HFVkHgT6rd1M4NeCm818C0
MVsofXdOOdbjmkQyxPGukl9EG1ukYMfUIQpFozTbbHKNUDfqcdHu8Qm39K7opwxMOiLprXGRSgx2
vMHI82mtAgMBAAGjggL6MIIC9jAQBgkrBgEEAYI3FQEEAwIBADAdBgNVHQ4EFgQU94Bw+7t8JXHx
VZoWN/pFMo005ZwwGQYJKwYBBAGCNxQCBAweCgBTAHUAYgBDAEEwCwYDVR0PBAQDAgGGMA8GA1Ud
EwEB/wQFMAMBAf8wHwYDVR0jBBgwFoAUyhZi1FybJBCYHcAPgM+Jq7Sv1dEwggEvBgNVHR8EggEm
MIIBIjCCAR6gggEaoIIBFoaByGxkYXA6Ly8vQ049UFJJTUUlMjBDT05UUk9MUyUyMFJPT1QlMjBD
QSxDTj1QUklNRS1ST09UQ0EsQ049Q0RQLENOPVB1YmxpYyUyMEtleSUyMFNlcnZpY2VzLENOPVNl
cnZpY2VzLENOPUNvbmZpZ3VyYXRpb24sREM9cHJpbWUsREM9bG9jYWw/Y2VydGlmaWNhdGVSZXZv
Y2F0aW9uTGlzdD9iYXNlP29iamVjdENsYXNzPWNSTERpc3RyaWJ1dGlvblBvaW50hklodHRwOi8v
Y3JsLnByaW1lLWNvbnRyb2xzLmNvbS9DZXJ0RW5yb2xsL1BSSU1FJTIwQ09OVFJPTFMlMjBST09U
JTIwQ0EuY3JsMIIBNAYIKwYBBQUHAQEEggEmMIIBIjCBuwYIKwYBBQUHMAKGga5sZGFwOi8vL0NO
PVBSSU1FJTIwQ09OVFJPTFMlMjBST09UJTIwQ0EsQ049QUlBLENOPVB1YmxpYyUyMEtleSUyMFNl
cnZpY2VzLENOPVNlcnZpY2VzLENOPUNvbmZpZ3VyYXRpb24sREM9cHJpbWUsREM9bG9jYWw/Y0FD
ZXJ0aWZpY2F0ZT9iYXNlP29iamVjdENsYXNzPWNlcnRpZmljYXRpb25BdXRob3JpdHkwYgYIKwYB
BQUHMAKGVmh0dHA6Ly9jcmwucHJpbWUtY29udHJvbHMuY29tL0NlcnRFbnJvbGwvUFJJTUUtUk9P
VENBX1BSSU1FJTIwQ09OVFJPTFMlMjBST09UJTIwQ0EuY3J0MA0GCSqGSIb3DQEBCwUAA4ICAQAw
2SIuBMB8JWC/YGbh3LJDt9T/z1BwEniLwEKu4SyBMfW3qJoLR0Zps8xHlCIlUjisZBSilHDNW4uW
4891yqg104OR0dx94dQV2Y6Aw9V4tvlw+GGnWpHbUNwP3/JizlndR6ZdhdnJlIt6g7xCy4LwyXrw
f22CibC1EWUVnsYJ8ez+LxSprWiUaEME8+bEaVuajXRitYyoG0asvtESeBhR3lwaxuzBqyAuGEfc
P8pO/fgjigripPScXnp6opQRzZ12VSNsN8BUmtCfsqX8DK7iRsn/QLZtOKVf6kapPkorovetKwai
wasE2mpKJweKYhGwWXdHrXZfCp6biTzG0oX4DGke7tHxdA92ArgmPR4SsNnpekUGcbUgkdpOc0sC
mt+LXzRvFKJNd205u/FvpZlBzRZl+NQ++9OGAywyzOb4Lxli49G0yFQ5Fpq0UElqDKiLzUqUR2Ye
qDT9nMJNTZEyP6hEjQQ+tSD9XFIlFWD83AyBHhRNKKZfPiu4mJxNYtW1ltVu5Z1EvBfbcRwc0lQa
VKGQS23sSIx6heq2q5FqhnYZ15zSXqaAH680Up5mOkeGIfm45rMIFl/aMAJ2Fntl+Taebosf2rv5
M/QOJs494ePbXxCJ1kzCLaqgucDXtadJ7ZV2AKhu3B9OiDrS8sv990Yn95ItlpthpVetiXxLHg==
-----END CERTIFICATE-----

402
server/ldap_auth.py Normal file
View File

@@ -0,0 +1,402 @@
"""LDAPS authentication against the Windows domain — D13, built in T10.1.
The suite stores no passwords. A sign-in is a **simple bind** to
`ldaps://prime.local:636` as `<sAMAccountName>@prime.local` using the password the
person typed; a successful bind IS the authentication. This module owns that
conversation and nothing else — it does not touch the database, does not issue
sessions, and does not decide what anyone is allowed to do. `server/auth.py` and
the `login` route in `server/app.py` do that.
WHY `prime.local` AND NOT A DC NAME OR AN IP (this is load-bearing, do not "fix" it):
every domain controller's certificate carries `prime.local` in its SAN alongside its
own hostname, and the domain name round-robins in DNS across all six DCs published
in `_ldap._tcp.prime.local`. So connecting to the domain name both passes hostname
validation and gives failover in one move. Connecting to `192.168.3.37` instead
fails with `hostname mismatch` — the DC certificates carry no IP SAN — and the only
way to "fix" that is to disable the check, which is the one thing that must not
happen here (see below).
THE CLIENT PRESENTS NO CERTIFICATE. This module is the TLS *client*; clients verify,
they do not present. What it needs is a trust anchor: `PRIME CONTROLS ROOT CA` plus
`PRIME CONTROLS ISSUING CA 1`, shipped as a PEM bundle at `server/certs/`. Those are
public certificates — no private key, nothing secret, nothing issued to this app.
TWO WAYS THIS GOES CATASTROPHICALLY WRONG, both guarded here:
1. An EMPTY PASSWORD. In LDAP a simple bind with an empty password is an
*anonymous* bind, and it SUCCEEDS. Without an explicit guard a blank password
authenticates as whatever username was submitted. `verify()` therefore rejects
an empty or whitespace-only password BEFORE `bind()` is ever called. If you are
refactoring this file and that check looks redundant, it is not.
2. `validate=ssl.CERT_NONE`. It still encrypts, so it fails silently — what it
loses is the ability to tell the real DC from someone who terminates the TLS
session, harvests the domain password and relays the bind onward. Domain
credentials cross this channel, so that turns an app compromise into a Windows
compromise. `CERT_REQUIRED` with an explicit CA file is the only mode here, and
the system trust store is deliberately NOT used: it currently trusts five other
self-signed CAs on this estate, any of which could issue a DC-shaped cert.
FAILED BINDS COUNT AGAINST THE DOMAIN LOCKOUT POLICY. That is why nothing in here
retries a rejected credential — only genuine network failures are retried, and a
`bind()` that returns False is final. The caller throttles before it gets this far
(T10.2); this module's job is not to make the problem worse.
Unconfigured is a first-class state, as it is for `MICRON_DB_URL` in `assets_db.py`:
with no CA bundle or no `ldap3` installed, `is_configured()` is False and `verify()`
returns `UNCONFIGURED` rather than raising. Since D13 leaves no local password
fallback, the caller must surface that state loudly — an unconfigured deploy and a
mistyped password look identical at the login box otherwise.
"""
import logging
import os
import re
import ssl
from dataclasses import dataclass
from typing import Optional
log = logging.getLogger("wpsuite.ldap")
# ldap3 is pinned in requirements.txt. The import is guarded rather than assumed so
# that this module — and the test suite that imports it — still loads on a checkout
# where the dependency has not been installed. `is_configured()` reports the truth,
# and the startup line from `describe()` says so out loud.
try:
from ldap3 import Server, Connection, Tls, SIMPLE, SUBTREE, NONE
from ldap3.core.exceptions import (
LDAPException,
LDAPSocketOpenError,
LDAPSessionTerminatedByServerError,
LDAPCertificateError,
)
from ldap3.utils.conv import escape_filter_chars
HAVE_LDAP3 = True
except ImportError: # pragma: no cover
HAVE_LDAP3 = False
LDAPException = LDAPSocketOpenError = Exception
LDAPSessionTerminatedByServerError = LDAPCertificateError = Exception
def escape_filter_chars(x, encoding=None): # type: ignore[misc]
raise RuntimeError("ldap3 is not installed")
# ── configuration ─────────────────────────────────────────────────────────────
# Module level, matching how server/auth.py reads AUTH_SESSION_HOURS. Tests patch
# these attributes directly rather than re-importing.
DOMAIN = os.getenv("LDAP_DOMAIN", "prime.local")
# Defaults to the domain name on purpose — see the module docstring. Override only
# if you have a reason, and never with an IP address.
HOST = os.getenv("LDAP_HOST", "") or DOMAIN
PORT = int(os.getenv("LDAP_PORT", "636"))
CA_FILE = os.getenv("LDAP_CA_FILE", "") or os.path.join(
os.path.dirname(os.path.abspath(__file__)), "certs", "prime-ca-chain.pem"
)
TIMEOUT_SECONDS = int(os.getenv("LDAP_TIMEOUT_SECONDS", "8"))
# Retries apply to CONNECT failures only, never to a rejected credential. Two extra
# attempts covers the realistic case: DNS round-robin handed out a DC that is
# rebooting for patching.
CONNECT_RETRIES = int(os.getenv("LDAP_CONNECT_RETRIES", "2"))
# The initial value and the fallback for the admin-console setting added in T10.5.
REQUIRED_GROUP = os.getenv("LDAP_REQUIRED_GROUP", "")
# AD's "member of, transitively" extensible match. Plain `memberOf` is DIRECT
# membership only and would wrongly refuse anyone who is in a nested child group,
# which is how most estates actually organise access.
NESTED_MEMBER_RULE = "1.2.840.113556.1.4.1941"
_USER_ATTRS = ["sAMAccountName", "mail", "displayName", "userPrincipalName"]
# Reason codes. Machine-readable, for logs and for the caller's branching — never
# for a response body, because several of them would leak whether an account exists.
OK = "ok"
EMPTY_INPUT = "empty_input"
UNCONFIGURED = "unconfigured"
UNREACHABLE = "unreachable"
UNTRUSTED = "untrusted"
BAD_CREDENTIALS = "bad_credentials"
NOT_IN_GROUP = "not_in_group"
NO_DIRECTORY_ENTRY = "no_directory_entry"
GROUP_NOT_FOUND = "group_not_found"
# AD returns these as `data <code>` inside an error-49 message. Kept for the log
# only: telling an unauthenticated caller "your password expired" confirms the
# account exists, which `login()` goes out of its way not to do.
_ERR49 = {
"525": "no such user",
"52e": "bad password",
"530": "not permitted at this time",
"531": "not permitted at this workstation",
"532": "password expired",
"533": "account disabled",
"701": "account expired",
"773": "must change password",
"775": "account locked out",
}
_ERR49_RE = re.compile(r"data\s+([0-9a-fA-F]{3})")
@dataclass(frozen=True)
class LdapResult:
"""Outcome of a bind attempt.
`ok` is the only field the caller should branch on for allow/deny. `reason` and
`detail` are for logs. `sam`/`mail`/`full_name` are populated only on success and
are what T10.4 uses to match or provision the local account.
"""
ok: bool
reason: str = ""
detail: str = ""
sam: str = ""
mail: str = ""
full_name: str = ""
upn: str = ""
@property
def is_config_problem(self) -> bool:
"""True when the failure is ours, not the user's. With no local password
fallback (D13) these must be logged as errors and surfaced to an operator —
otherwise a broken deploy is indistinguishable from a forgotten password."""
return self.reason in (UNCONFIGURED, UNREACHABLE, UNTRUSTED, GROUP_NOT_FOUND)
def base_dn(domain: Optional[str] = None) -> str:
"""`prime.local` -> `DC=prime,DC=local`."""
d = (domain or DOMAIN or "").strip().strip(".")
return ",".join(f"DC={part}" for part in d.split(".") if part)
def is_configured() -> bool:
"""Whether a bind could even be attempted. Deliberately does not touch the
network — `selftest()` does that."""
return bool(HAVE_LDAP3 and HOST and CA_FILE and os.path.isfile(CA_FILE))
def describe() -> str:
"""One line for the startup log. D13 removed the local password path, so an
operator needs to see this in `docker compose logs api` rather than discovering
it when the first person cannot sign in."""
if not HAVE_LDAP3:
return "LDAP auth DISABLED — ldap3 is not installed. No one can sign in."
if not CA_FILE or not os.path.isfile(CA_FILE):
return (f"LDAP auth DISABLED — CA bundle not found at {CA_FILE!r}. "
f"No one can sign in. Set LDAP_CA_FILE.")
group = REQUIRED_GROUP or "(none configured — every domain account may sign in)"
return (f"LDAP auth enabled — ldaps://{HOST}:{PORT}, domain {DOMAIN}, "
f"CA {CA_FILE}, required group: {group}")
def _tls() -> "Tls":
"""The only TLS configuration in this module.
`CERT_REQUIRED` plus an explicit `ca_certs_file`. ldap3 performs the hostname
check against the certificate's SAN itself whenever validate is not CERT_NONE,
which is what makes connecting by IP fail instead of quietly succeeding.
"""
# No `version=` pin: ldap3's default negotiates the highest version both ends
# support, which against these DCs is TLS 1.3. Pinning PROTOCOL_TLSv1_2 here
# would silently DOWNGRADE every connection to 1.2.
return Tls(
ca_certs_file=CA_FILE,
validate=ssl.CERT_REQUIRED,
)
def _server() -> "Server":
return Server(
HOST, port=PORT, use_ssl=True, tls=_tls(),
connect_timeout=TIMEOUT_SECONDS, get_info=NONE,
)
def _err49(result: Optional[dict]) -> str:
"""Human-readable AD sub-code from a bind failure, for the log only."""
msg = ((result or {}).get("message") or "")
m = _ERR49_RE.search(msg)
if not m:
return ((result or {}).get("description") or "invalid credentials")
code = m.group(1).lower()
return f"{code} ({_ERR49.get(code, 'unrecognised sub-code')})"
def normalize_username(raw: str) -> str:
"""Reduce whatever was typed in the login box to a `sAMAccountName`.
People type their email address. The mail domain here (`prime-controls.com`) is
not the AD domain (`prime.local`), so an address is never a valid bind string —
the local part is used instead. This assumes the mail local part equals the
sAMAccountName, which is the norm but not guaranteed; where it differs the person
must type their short logon name, and we log it so that is diagnosable.
ONE candidate is produced, never a list to try in turn: every rejected bind
counts against the domain lockout policy, so guessing would let a handful of
login attempts lock a real account out of Windows.
"""
name = (raw or "").strip()
if not name:
return ""
# DOMAIN\user, as typed by anyone used to a Windows logon prompt.
if "\\" in name:
name = name.rsplit("\\", 1)[1].strip()
if "@" in name:
local, _, dom = name.partition("@")
log.info("login input %r looks like an address; binding as sAMAccountName %r "
"(mail domain %r is not the AD domain)", name, local.strip(), dom)
name = local.strip()
return name
def _resolve_group_dn(conn, group: str) -> Optional[str]:
"""Accept either a distinguished name or a plain group name, return a DN."""
g = (group or "").strip()
if not g:
return None
if "," in g and "=" in g:
return g # already a DN
esc = escape_filter_chars(g)
conn.search(base_dn(), f"(&(objectClass=group)(|(cn={esc})(sAMAccountName={esc})))",
search_scope=SUBTREE, attributes=["cn"], size_limit=2)
if not conn.entries:
return None
if len(conn.entries) > 1:
log.warning("group %r is ambiguous in the directory (%d matches); using %s",
g, len(conn.entries), conn.entries[0].entry_dn)
return conn.entries[0].entry_dn
def member_of(conn, sam: str, group: str) -> bool:
"""Nested-group-aware membership test for an already-bound connection."""
dn = _resolve_group_dn(conn, group)
if not dn:
log.error("required group %r does not resolve in %s — refusing the sign-in. "
"This is a configuration fault, not a bad password.", group, base_dn())
raise LookupError(GROUP_NOT_FOUND)
filt = (f"(&(sAMAccountName={escape_filter_chars(sam)})"
f"(memberOf:{NESTED_MEMBER_RULE}:={escape_filter_chars(dn)}))")
conn.search(base_dn(), filt, search_scope=SUBTREE,
attributes=["sAMAccountName"], size_limit=1)
return bool(conn.entries)
def verify(username: str, password: str, required_group: Optional[str] = None) -> LdapResult:
"""Authenticate against the domain. The only entry point the app should call.
`required_group` overrides the `LDAP_REQUIRED_GROUP` default so the admin-console
setting (T10.5) wins. Pass an empty string to mean "no group gate"; pass None to
use the configured default.
"""
sam = normalize_username(username)
# ── guard 1: no bind on empty input ──────────────────────────────────────
# An empty password makes the bind below an ANONYMOUS bind, which SUCCEEDS and
# would authenticate `sam` without proving anything at all. Must stay before
# every return path that reaches bind().
if not sam or not (password or "").strip():
return LdapResult(False, EMPTY_INPUT, "empty username or password")
if not is_configured():
return LdapResult(False, UNCONFIGURED, describe())
group = REQUIRED_GROUP if required_group is None else required_group
bind_user = f"{sam}@{DOMAIN}"
last_network_error = ""
# Retries cover CONNECT failures only: DNS round-robin across six DCs will
# eventually hand out one that is rebooting. A rejected credential returns
# immediately and is never retried — each attempt counts against AD lockout.
for attempt in range(1, max(1, CONNECT_RETRIES + 1) + 1):
conn = None
try:
conn = Connection(
_server(), user=bind_user, password=password,
authentication=SIMPLE, read_only=True,
receive_timeout=TIMEOUT_SECONDS, raise_exceptions=False,
)
if not conn.bind():
detail = _err49(conn.result)
log.info("bind refused for %r: %s", sam, detail)
return LdapResult(False, BAD_CREDENTIALS, detail)
# Bound as the user. AD lets an account read its own object, so no
# service account is needed for either of the next two steps.
conn.search(base_dn(),
f"(&(objectClass=user)(sAMAccountName={escape_filter_chars(sam)}))",
search_scope=SUBTREE, attributes=_USER_ATTRS, size_limit=1)
if not conn.entries:
log.error("bind succeeded for %r but the account has no readable "
"directory entry under %s", sam, base_dn())
return LdapResult(False, NO_DIRECTORY_ENTRY, "no readable directory entry")
e = conn.entries[0]
def one(attr: str) -> str:
v = getattr(e, attr, None)
return str(v.value) if v is not None and v.value else ""
if group:
try:
if not member_of(conn, sam, group):
log.info("bind succeeded for %r but the account is not in %r",
sam, group)
return LdapResult(False, NOT_IN_GROUP, f"not in {group}")
except LookupError:
return LdapResult(False, GROUP_NOT_FOUND, f"group {group!r} not found")
return LdapResult(
True, OK,
sam=one("sAMAccountName") or sam,
mail=one("mail"),
full_name=one("displayName"),
upn=one("userPrincipalName"),
)
except LDAPCertificateError as exc:
# NOT retried and NOT downgraded. Either the CA bundle is wrong or
# something is impersonating a DC; both need a human, and retrying with
# relaxed validation is exactly the wrong instinct.
log.error("LDAPS certificate validation FAILED against %s: %s. Refusing "
"to continue — check LDAP_CA_FILE, and never set CERT_NONE.",
HOST, exc)
return LdapResult(False, UNTRUSTED, str(exc))
except (LDAPSocketOpenError, LDAPSessionTerminatedByServerError) as exc:
last_network_error = str(exc)
log.warning("LDAPS connect to %s:%s failed (attempt %d): %s",
HOST, PORT, attempt, exc)
continue
except LDAPException as exc:
log.error("LDAP error for %r: %s", sam, exc)
return LdapResult(False, UNREACHABLE, str(exc))
finally:
if conn is not None:
try:
conn.unbind()
except Exception:
pass
return LdapResult(False, UNREACHABLE,
last_network_error or f"no domain controller answered on {HOST}:{PORT}")
def selftest() -> LdapResult:
"""Open a TLS session to the domain and validate the certificate, WITHOUT binding.
Used by the startup check and by the admin console's diagnostics. Touches no
account, so it cannot contribute to a lockout. Proves the three things that
actually break a deploy: DNS resolves, a DC answers on 636, and the presented
certificate validates against our CA bundle.
"""
if not is_configured():
return LdapResult(False, UNCONFIGURED, describe())
conn = None
try:
conn = Connection(_server(), receive_timeout=TIMEOUT_SECONDS, raise_exceptions=True)
conn.open()
return LdapResult(True, OK, f"ldaps://{HOST}:{PORT} certificate validates")
except LDAPCertificateError as exc:
return LdapResult(False, UNTRUSTED, str(exc))
except LDAPException as exc:
return LdapResult(False, UNREACHABLE, str(exc))
finally:
if conn is not None:
try:
conn.unbind()
except Exception:
pass

View File

@@ -19,4 +19,8 @@ pydantic==2.13.4
python-dotenv==1.2.2
bcrypt==5.0.0 # password hashing
PyJWT==2.13.0 # signed session tokens
ldap3==2.9.1 # D13: LDAPS simple bind against prime.local. Pure Python,
# so no system libldap/OpenLDAP headers in the image. The
# trust anchor is server/certs/prime-ca-chain.pem, NOT the
# system store — see server/ldap_auth.py.
starlette==1.3.1 # pinned transitive (cookie / CORS handling — security-relevant)