Files
xamxam/docs/archive/ldap.md
T

6.7 KiB

LDAP Authentication for XAMXAM Admin

Merged from LDAP_AUTH_PLAN.md and LDAP_SPEC.md. Status: not implemented. Network access to the LDAP server is an unresolved blocker.


Current state

Two-layer authentication guards the /admin/ area:

Layer Mechanism Where
1 (nginx) auth_basic against /etc/nginx/.htpasswd-xamxam nginx/xamxam.conf
2 (PHP) AdminAuth — bcrypt hash in site_settings.admin_password_hash app/src/AdminAuth.php + login/account handlers

Layer 1 controls the browser's Basic Auth dialog; layer 2 provides a PHP session gate. When both share the same password the user is authenticated transparently (nginx passes PHP_AUTH_PW, AdminAuth verifies it against the DB hash).

Goal

Replace both layers with LDAP so staff use their existing org credentials and account lifecycle is handled centrally.


Required information from IT

# Item Example / format
1 LDAP server URL ldaps://ldap.erg.be:636 or ldap://ldap.erg.be:389
2 Base DN dc=erg,dc=be
3 Bind DN (read-only service/search account) cn=xamxam-svc,ou=services,dc=erg,dc=be
4 Bind password (secret — read-only is sufficient)
5 User search filter (&(uid=%s)(memberOf=cn=xamxam-admins,ou=groups,dc=erg,dc=be))
6 Group membership mechanism memberOf (AD) or member/uniqueMember (OpenLDAP)
7 Username attribute uid (OpenLDAP) or sAMAccountName (AD)
8 TLS certificate self-signed? provide CA PEM. Otherwise confirm publicly-trusted cert
9 Admin group DN/CN cn=xamxam-admins,ou=groups,dc=erg,dc=be (or decide on a name)

Network prerequisite (blocker)

XAMXAM's VM may not have direct TCP access to the LDAP server. Confirm before writing any code:

nc -zv <ldap-host> 636    # LDAPS (preferred)
nc -zv <ldap-host> 389    # plain LDAP (fallback, trusted LAN only)

Chosen approach: PHP-only LDAP (no nginx layer)

Decision: replace both layers with a single LDAP-backed PHP login. No nginx module compilation; the existing AdminAuth session architecture stays intact — only the credential-verification back-end changes.

This is "Option C" below (the earlier "Option A" nginx-ldap-auth daemon was evaluated but not chosen).


Architecture options (evaluated)

Option A — nginx-ldap-auth daemon

  • Drop-in auth_basic replacement using nginx auth_request; a Python daemon at 127.0.0.1:8888 binds to LDAP and returns 200/403.
  • Keeps the nginx-level gate; needs python3-ldap + the daemon.

Option B — ngx_http_auth_ldap_module

  • Native nginx module, requires recompiling nginx. Simpler config, less flexible.

Option C — PHP-only LDAP (chosen)

  • Remove nginx auth entirely; AdminAuth::requireLogin() does ldap_bind() + group check directly in PHP. Simpler nginx config, no separate daemon.

Implementation plan (Option C)

Phase 1 — Server preparation

  • Confirm network access (see blocker).
  • sudo apt install php8.4-ldap + sudo systemctl restart php8.4-fpm; verify php -m | grep ldap.
  • Store connection params in site_settings or a server-side env file (never in repo): ldap_host, ldap_port, ldap_bind_dn, ldap_bind_password, ldap_base_dn, ldap_user_attr, ldap_group_dn (optional).

Phase 2 — New LdapAuth class

app/src/LdapAuth.php → LdapAuth::verify(string $username, string $password): bool:

  1. Load params from Database::getSetting().
  2. ldap_connect($host, $port); set LDAP_OPT_PROTOCOL_VERSION=3, LDAP_OPT_REFERRALS=0, LDAP_OPT_NETWORK_TIMEOUT=3 (fail fast).
  3. Service-account bind ldap_bind($conn, $bind_dn, $bind_password).
  4. Search user: ldap_search($conn, $base_dn, "($attr=$username)", ['dn']), extract user DN.
  5. Optional group check: verify membership against ldap_group_dn.
  6. User bind ldap_bind($conn, $user_dn, $password) — the actual credential check.
  7. ldap_unbind($conn); return true/false.

Error handling: catch ldap_error()/ldap_errno() on each step; log failures (never expose LDAP error strings to the browser); fail closed when the LDAP server is unreachable.

Phase 3 — Modify AdminAuth

Location Change
AdminAuth::login() Replace password_verify() with LdapAuth::verify($username, $password)
requireLogin() nginx passthrough ($_SERVER['PHP_AUTH_PW']) Remove (nginx auth_basic gone)
getStoredHash() / setPasswordHash() / removePasswordHash() Retire

Session logic (SESSION_KEY, session_regenerate_id, cookie hardening, logout()) is unchanged — auth-method-agnostic. The login form gains a username field; the password-change page is retired.

Phase 4 — Modify the login form

Add <input name="username"> before password; remove "change password" link; POST handler calls AdminAuth::login($username, $password).

Phase 5 — Remove nginx auth_basic

In nginx/xamxam.conf (inside location ^~ /admin/), remove auth_basic and auth_basic_user_file. Keep the limit_req zone=admin rate limit. Update scripts/deploy-server.sh / manage-admin-users.sh; sudo rm /etc/nginx/.htpasswd-xamxam.

Phase 6 — Retire password-management UI

Remove/repurpose account.php handlers + templates; remove the "Compte" nav link; optionally clear admin_password_hash from site_settings.

Phase 7 — Testing

  • LDAP reachable from VM (Phase 1 smoke test)
  • Valid staff credentials → session created, redirected to /admin/
  • Invalid password / unknown username → denied (same error message — no enum)
  • LDAP unreachable → "service unavailable", not a PHP fatal
  • Group check: non-member staff → denied
  • Session expiry/logout → redirected to login
  • 20+ rapid logins → nginx rate limit (429)
  • /etc/nginx/.htpasswd-xamxam removed on server

What does NOT change

  • PHP session layer (AdminAuth::startSession, isAuthenticated, logout, cookie params)
  • CSRF protection on all action handlers
  • nginx rate-limiting zone for /admin/ and all other nginx security rules
  • AdminAuth remains for session management (persistence, logout, CSRF, audit identity); LDAP handles authentication only

Security notes

  • Use LDAPS (port 636) exclusively — plain LDAP transmits passwords in cleartext.
  • Service account must be read-only.
  • Never store the service-account password in the repo. Use Database::setSetting() or a server env var.
  • Never log user/service passwords.
  • Fail closed on connect/bind failure.
  • Escape the username per RFC 4515 (ldap_escape(..., LDAP_ESCAPE_FILTER)) to prevent LDAP injection.