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:
@@ -35,6 +35,18 @@ services:
|
|||||||
# default and enabled from the Admin console; this is the only email
|
# default and enabled from the Admin console; this is the only email
|
||||||
# secret and it is never stored in the DB. Leave unset until configured.
|
# secret and it is never stored in the DB. Leave unset until configured.
|
||||||
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
|
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
|
||||||
|
# D13 — domain authentication. REQUIRED: the suite stores no passwords and
|
||||||
|
# has no local fallback, so a wrong value here means nobody can sign in.
|
||||||
|
# Connect to the DOMAIN NAME, never a DC or an IP (SAN + DNS round-robin
|
||||||
|
# across six DCs). See server/ldap_auth.py and DEPLOYMENT.md.
|
||||||
|
LDAP_DOMAIN: ${LDAP_DOMAIN:-prime.local}
|
||||||
|
LDAP_HOST: ${LDAP_HOST:-prime.local}
|
||||||
|
# Trust anchor for the DC certificate — public CA certs, baked into the image
|
||||||
|
# at server/certs/. Override only to point at a mounted bundle.
|
||||||
|
LDAP_CA_FILE: ${LDAP_CA_FILE:-/app/server/certs/prime-ca-chain.pem}
|
||||||
|
# AD group required to sign in. Empty = any domain account. This is the
|
||||||
|
# initial value; the live one is set in the Admin console (T10.5).
|
||||||
|
LDAP_REQUIRED_GROUP: ${LDAP_REQUIRED_GROUP:-}
|
||||||
# Optional — read-only SQL Server connection to the Micron asset catalog,
|
# Optional — read-only SQL Server connection to the Micron asset catalog,
|
||||||
# which backs the asset picker in the work package creator. Leave unset and
|
# which backs the asset picker in the work package creator. Leave unset and
|
||||||
# the picker cleanly falls back to manual entry (see server/assets_db.py).
|
# the picker cleanly falls back to manual entry (see server/assets_db.py).
|
||||||
@@ -46,10 +58,15 @@ services:
|
|||||||
condition: service_healthy # waits for postgres to accept connections
|
condition: service_healthy # waits for postgres to accept connections
|
||||||
networks:
|
networks:
|
||||||
- internal
|
- internal
|
||||||
# Reaching the Micron database means leaving this compose project, and
|
# Reaching the Micron database — and, since D13, the domain controllers —
|
||||||
# `internal` is deliberately egress-free. `outbound` is attached to the api
|
# means leaving this compose project, and `internal` is deliberately
|
||||||
# container ONLY — the database and backup containers stay sealed. Detach it
|
# egress-free. `outbound` is attached to the api container ONLY; the database
|
||||||
# again if you are not using the Micron asset picker.
|
# and backup containers stay sealed.
|
||||||
|
#
|
||||||
|
# DO NOT DETACH THIS. It used to be optional ("detach it if you are not using
|
||||||
|
# the Micron asset picker"), but authentication now needs a route to
|
||||||
|
# prime.local:636. Without it every sign-in fails and there is no local
|
||||||
|
# password fallback to fall back to.
|
||||||
- outbound
|
- outbound
|
||||||
|
|
||||||
db:
|
db:
|
||||||
@@ -113,7 +130,7 @@ networks:
|
|||||||
# An ordinary bridge network, i.e. one that HAS a default gateway. `internal`
|
# An ordinary bridge network, i.e. one that HAS a default gateway. `internal`
|
||||||
# above removes the gateway entirely, which blocks not just the internet but
|
# above removes the gateway entirely, which blocks not just the internet but
|
||||||
# the LAN and the VPN too — so the api container needs this second network to
|
# the LAN and the VPN too — so the api container needs this second network to
|
||||||
# reach the Micron asset database. Attached to `api` alone: `db` and `backup`
|
# reach the domain controllers (LDAPS, D13) and the Micron asset database.
|
||||||
# remain on `internal` only and still have no way off the host.
|
# Attached to `api` alone: `db` and `backup` remain on `internal` only and
|
||||||
# Detach it from api if you are not using the Micron asset picker.
|
# still have no way off the host. Required — see the note on the api service.
|
||||||
driver: bridge
|
driver: bridge
|
||||||
@@ -107,17 +107,45 @@ These are the ways this change goes wrong, and each has a done-when check in wav
|
|||||||
`532` password expired, `533` disabled, `775` locked) are useful in the log and must not
|
`532` password expired, `533` disabled, `775` locked) are useful in the log and must not
|
||||||
reach the response body.
|
reach the response body.
|
||||||
|
|
||||||
### Open, to confirm in the PR rather than decide alone
|
### Answered August 21, 2026 — both were raised as open and both were decided
|
||||||
|
|
||||||
- **Break-glass.** Removing `password_hash` means an unreachable DC locks out *everyone*,
|
**Break-glass: none. LDAPS is the only way in.** Asked and reaffirmed after the lockout risk
|
||||||
admins included. See `T10.2`.
|
was stated. There is no emergency local account, no env-var bypass, and no CLI-minted
|
||||||
- **Username ↔ `sAMAccountName` mapping.** Existing accounts were created by
|
session. The consequence is explicit and belongs in the runbook rather than being discovered:
|
||||||
`manage_users.py` with hand-typed usernames. Criterion 4 holds only where those match the
|
**if the domain is unreachable, or `LDAP_CA_FILE` is wrong, or the required group is
|
||||||
directory's `sAMAccountName`. The production account list must be checked against AD
|
misconfigured, nobody can sign in — including admins — and no amount of shell access fixes
|
||||||
before this deploys; a mismatch means an existing admin gets a *second*, JIT-provisioned
|
it except correcting the configuration and restarting.** `T10.5`'s validate-on-save guard is
|
||||||
account at the default role instead of keeping their admin. `auth.find_user` already
|
therefore not a nicety; with no fallback it is the only thing standing between a typo in the
|
||||||
matches on username **or** email case-insensitively, which covers some of the gap but not
|
group field and a total outage.
|
||||||
all of it.
|
|
||||||
|
Three things follow, and they are done-when checks in wave 10 rather than advice:
|
||||||
|
|
||||||
|
- The startup log must state whether LDAP is configured and reachable, so a broken deploy is
|
||||||
|
visible in `docker compose logs api` and not only at the login box.
|
||||||
|
- `/api/health` stays exempt from auth (it already is) so the outage is diagnosable.
|
||||||
|
- The group setting cannot be saved without proving the saving admin is a member.
|
||||||
|
|
||||||
|
**Identity: bind on `sAMAccountName`, match on `sAMAccountName` *or* `mail`.** A simple bind
|
||||||
|
can only carry one identifier, and AD accepts the UPN form — so the bind is
|
||||||
|
`sAMAccountName@prime.local` and that is what the login box takes. Matching an existing local
|
||||||
|
row is a separate question, and it uses **both**: after a successful bind the directory's
|
||||||
|
`sAMAccountName` and `mail` are both read, and `auth.find_user` is extended to match a local
|
||||||
|
row on either, case-insensitively. That is what keeps an existing admin's role whether their
|
||||||
|
hand-typed username was `c.schaefer` or `c.schaefer@prime-controls.com`.
|
||||||
|
|
||||||
|
Two consequences worth knowing:
|
||||||
|
|
||||||
|
- The mail domain (`prime-controls.com`) is not the AD domain (`prime.local`), so `mail` is
|
||||||
|
never a valid bind string. It is a matching key only.
|
||||||
|
- If someone types an address at the login box, the local part is used as the
|
||||||
|
`sAMAccountName` — **one** bind attempt, never several, because each failed bind counts
|
||||||
|
against the domain lockout policy. That assumes the mail local part equals the
|
||||||
|
`sAMAccountName`. Where it does not, the person must type their short logon name; this is
|
||||||
|
logged when it happens and documented in `T10.8`.
|
||||||
|
- The production `users` table should still be compared against AD before this deploys. A
|
||||||
|
row matching on neither key gets a *second*, JIT-provisioned account at the default role
|
||||||
|
rather than keeping its admin. Matching on two keys narrows that risk; it does not remove
|
||||||
|
it.
|
||||||
|
|
||||||
### Explicitly out of scope
|
### Explicitly out of scope
|
||||||
|
|
||||||
|
|||||||
@@ -82,8 +82,10 @@ account, `last_login_at`, the session cookie. Rework the throttle so the local c
|
|||||||
failure mode to avoid is this endpoint being usable to lock domain accounts out of Windows.
|
failure mode to avoid is this endpoint being usable to lock domain accounts out of Windows.
|
||||||
Log directory error-49 sub-codes for diagnosis; return the same generic message regardless.
|
Log directory error-49 sub-codes for diagnosis; return the same generic message regardless.
|
||||||
|
|
||||||
Decide the break-glass question here (see **Open** in the decision doc) and record the answer
|
**Break-glass: none — decided Aug 21, see the decision doc.** LDAPS is the only way in, so
|
||||||
in this file before implementing, because it changes this task's shape.
|
this task adds no fallback path. What it must add instead is *visibility*: a startup log line
|
||||||
|
stating whether LDAP is configured and whether the DC answered, because with no fallback a
|
||||||
|
misconfigured deploy is indistinguishable from a forgotten password at the login box.
|
||||||
|
|
||||||
**Done when:**
|
**Done when:**
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,40 @@ AUTH_SECRET_KEY=CHANGE_ME_run_the_command_above
|
|||||||
# How long a login lasts before re-authentication (hours). Default 12.
|
# How long a login lasts before re-authentication (hours). Default 12.
|
||||||
# AUTH_SESSION_HOURS=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) ─────────────────────────────────────────────
|
# ── Email notifications (optional) ─────────────────────────────────────────────
|
||||||
# WP-assignment emails are OFF by default and are turned on from the Admin
|
# 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
|
# 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-----
|
||||||
|
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
|
||||||
|
/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
|
python-dotenv==1.2.2
|
||||||
bcrypt==5.0.0 # password hashing
|
bcrypt==5.0.0 # password hashing
|
||||||
PyJWT==2.13.0 # signed session tokens
|
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)
|
starlette==1.3.1 # pinned transitive (cookie / CORS handling — security-relevant)
|
||||||
|
|||||||
Reference in New Issue
Block a user