# 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: ```bash nc -zv 636 # LDAPS (preferred) nc -zv 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 `` 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.