6.7 KiB
LDAP Authentication for XAMXAM Admin
Merged from
LDAP_AUTH_PLAN.mdandLDAP_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_basicreplacement using nginxauth_request; a Python daemon at127.0.0.1:8888binds 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()doesldap_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; verifyphp -m | grep ldap.- Store connection params in
site_settingsor 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:
- Load params from
Database::getSetting(). ldap_connect($host, $port); setLDAP_OPT_PROTOCOL_VERSION=3,LDAP_OPT_REFERRALS=0,LDAP_OPT_NETWORK_TIMEOUT=3(fail fast).- Service-account bind
ldap_bind($conn, $bind_dn, $bind_password). - Search user:
ldap_search($conn, $base_dn, "($attr=$username)", ['dn']), extract user DN. - Optional group check: verify membership against
ldap_group_dn. - User bind
ldap_bind($conn, $user_dn, $password)— the actual credential check. ldap_unbind($conn); returntrue/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-xamxamremoved 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 AdminAuthremains 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.