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:
@@ -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
|
||||
|
||||
66
server/certs/prime-ca-chain.pem
Normal file
66
server/certs/prime-ca-chain.pem
Normal 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
402
server/ldap_auth.py
Normal 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
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user