Cody Schaefer 2a3c4a83fa T10.2 D13 - login binds against the domain instead of comparing a hash
server/app.py: login() now calls ldap_auth.verify(). No password is read from
the database on this path any more; the column is still there and unused until
T10.3 drops it, which keeps this commit revertible on its own.

THE THROTTLE IS THE POINT OF THIS COMMIT, not the bind.

AUTH_MAX_ATTEMPTS defaulted to 5, which is exactly this estate's AD lockout
threshold. Failures are now domain binds, and the API runs 2 gunicorn workers,
so that default would have let up to 10 binds reach a DC - locking the account
out of Windows twice over before the app's own lockout engaged. Anyone could
have done it to anyone, unauthenticated, from the login form. Now 2, so the
worst case is 4 against a threshold of 5, with the arithmetic written down at
the constant so the next person to raise it sees the constraint.

The persistent counter on `users` cannot cover this alone: under D13 accounts
are created on first login, so a real domain account can be attacked before it
has a local row. Added a username-keyed pre-account window for exactly that gap
- keyed by username rather than client IP, because it is the domain account
being protected, not this endpoint's capacity, and IP rotation would walk
straight past an IP-keyed limit.

Both throttles are checked BEFORE the directory is touched.

503 is now distinct from 401. LDAP unconfigured, unreachable, untrusted, or a
required group that does not resolve are OUR faults and return 503, because
D13 left no password fallback: reporting "invalid username or password" would
send people hunting for a password they no longer have while the real problem
is a broken deploy. A rejected credential and a missing group membership both
stay a single generic 401 - the useful detail goes to the log, never the
response, so account existence is not disclosed.

Also added a logger to app.py, which had none. An earlier draft of this commit
called log.warning() in a module where `log` was undefined; compileall does not
catch a NameError, so it would have surfaced as a 500 on the first failed
sign-in. Named wpsuite.api to match wpsuite.auth / .ldap / .notify.

Verified: app imports, the route is registered, LOGIN_MAX_ATTEMPTS is 2.
An authenticated user with no local row is refused with 403 until T10.4 adds
provisioning - deliberately explicit rather than silently admitted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 15:09:00 -05:00
Description
No description provided
8.9 MiB
Languages
Python 49.3%
JavaScript 32.6%
CSS 8.9%
HTML 8.7%
Shell 0.4%