# LDAP Authentication — Migration Plan ## Context The admin panel currently uses a two-layer auth stack: 1. **nginx `auth_basic`** — browser password prompt, credentials stored in `/etc/nginx/.htpasswd-xamxam`, managed manually with `htpasswd`. 2. **PHP `AdminAuth`** — session guard with bcrypt hash stored in the SQLite database (`site_settings.admin_password_hash`). The client runs an org-wide LDAP service already used for other internal tools. The goal is to replace both layers with a single LDAP-backed PHP login, so that staff use their existing org credentials and account lifecycle (onboarding, offboarding, password resets) is handled centrally. **Chosen approach: Option 3 — PHP LDAP auth, nginx `auth_basic` removed.** No nginx module compilation required. The existing `AdminAuth` session architecture stays intact; only the credential-verification back-end changes. --- ## Network prerequisite (blocker) XAMXAM runs in a VM that may not have direct TCP access to the LDAP server (port 389 plain / port 636 LDAPS). This must be confirmed before any implementation work starts. **Action required (client):** - Confirm the LDAP server hostname / IP and port (prefer 636 LDAPS). - Open a firewall rule from the XAMXAM VM to the LDAP server on that port. - Provide a **read-only service-account** DN and password for the bind (e.g. `cn=xamxam-svc,ou=services,dc=erg,dc=be`). This account only needs permission to search the directory — never to write. - Confirm the LDAP server type (OpenLDAP / Active Directory / 389-DS / other) and the base DN for staff accounts (e.g. `ou=staff,dc=erg,dc=be`). - Confirm the attribute that holds the login name (`uid` on OpenLDAP, `sAMAccountName` on AD). - Confirm whether a group membership check is required (i.e. only members of `cn=xamxam-admins,ou=groups,dc=erg,dc=be` may log in), or whether any valid staff account is sufficient. **Verify TCP reachability from the VM before writing any code:** ```bash # On the XAMXAM server nc -zv 636 # LDAPS (preferred) nc -zv 389 # plain LDAP (fallback, only on a trusted LAN) ``` --- ## TODO ### Phase 1 — Server preparation - [ ] Confirm network access (see blocker above). - [ ] Install the PHP LDAP extension on the server: ```bash sudo apt install php8.4-ldap sudo systemctl restart php8.4-fpm ``` - [ ] Verify the extension loaded: ```bash php -m | grep ldap ``` - [ ] Store LDAP connection parameters in the database (`site_settings` table) or in a server-side env file — **never in the repository**: - `ldap_host` — e.g. `ldaps://ldap.erg.be` - `ldap_port` — `636` - `ldap_bind_dn` — service-account DN - `ldap_bind_password` — service-account password - `ldap_base_dn` — search base for user accounts - `ldap_user_attr` — login attribute (`uid` / `sAMAccountName`) - `ldap_group_dn` — (optional) required group DN; empty = no group check ### Phase 2 — New `LdapAuth` class Create `app/src/LdapAuth.php`: ``` LdapAuth::verify(string $username, string $password): bool ``` Internal steps: 1. Load connection parameters from `Database::getSetting()`. 2. Open connection: `ldap_connect($host, $port)`. 3. Set options: `LDAP_OPT_PROTOCOL_VERSION = 3`, `LDAP_OPT_REFERRALS = 0`, `LDAP_OPT_NETWORK_TIMEOUT = 3` (fail fast — don't stall page loads). 4. Service-account bind: `ldap_bind($conn, $bind_dn, $bind_password)`. 5. Search for the user: `ldap_search($conn, $base_dn, "($attr=$username)", ['dn'])`. 6. Extract the user DN from search results. 7. If group check is configured: verify membership with a second search against the group DN before proceeding. 8. Attempt user bind with the supplied password: `ldap_bind($conn, $user_dn, $password)` — this is the actual credential verification; LDAP does the password check. 9. `ldap_unbind($conn)`. 10. Return `true` on success, `false` on any failure. Error handling: - Catch `ldap_error()` / `ldap_errno()` on every step. - Log failures to the PHP error log (never expose LDAP error strings to the browser). - On LDAP server unreachable: fail **closed** (deny access, show a "service temporarily unavailable" message — do not fall through to a bypass). ### Phase 3 — Modify `AdminAuth` `AdminAuth` currently verifies credentials in two places: | Location | Change | |---|---| | `AdminAuth::login()` | Replace `password_verify($password, $hash)` with `LdapAuth::verify($username, $password)` | | `AdminAuth::requireLogin()` — nginx Basic Auth passthrough (`$_SERVER['PHP_AUTH_PW']`) | Remove entirely (nginx `auth_basic` will be gone) | | `AdminAuth::getStoredHash()` | Can be removed or kept as dead code path | | `AdminAuth::setPasswordHash()` / `removePasswordHash()` | Retire (no longer used) | The session logic (`SESSION_KEY`, `session_regenerate_id`, cookie hardening, `logout()`) is unchanged — it is auth-method-agnostic. The login form (`/admin/login.php`) gains a `username` field alongside `password`. The `account.php` password-change page is retired (password management happens in the LDAP directory, not here). ### Phase 4 — Modify the login form `app/public/admin/login.php` and `app/templates/admin/login.php`: - Add `` before the password field. - Remove the "change password" link (password is managed in LDAP). - POST handler calls `AdminAuth::login($username, $password)` with both args. ### Phase 5 — Remove nginx `auth_basic` In `nginx/xamxam.conf`, inside `location ^~ /admin/`: ```nginx # Remove these two lines: auth_basic "Admin Access - XAMXAM"; auth_basic_user_file /etc/nginx/.htpasswd-xamxam; ``` The rate-limiting zone (`limit_req zone=admin`) stays — it still guards against brute-force on the PHP login form. Update `scripts/deploy-server.sh` and `scripts/manage-admin-users.sh` to note that htpasswd management is no longer required. Clean up the server: ```bash sudo rm /etc/nginx/.htpasswd-xamxam ``` ### Phase 6 — Admin UI: retire password management page - Remove or repurpose `app/public/admin/account.php` and `app/public/admin/actions/account.php`. - Remove the "Compte" nav link from the admin header. - The `site_settings` rows `admin_password_hash` can be left in the DB (harmless) or cleared with a migration. ### Phase 7 — Testing - [ ] LDAP server reachable from VM (Phase 1 smoke test). - [ ] Valid staff credentials → session created, redirected to `/admin/`. - [ ] Invalid password → denied, error shown, no session. - [ ] Unknown username → denied (same error message — no username enumeration). - [ ] LDAP server unreachable → denied with "service unavailable", not a PHP fatal. - [ ] Group check (if configured): non-member staff → denied. - [ ] Session expiry / logout → redirected to login form. - [ ] Brute-force: 20+ rapid login attempts → nginx rate limit kicks in (429). - [ ] Verify `/etc/nginx/.htpasswd-xamxam` no longer exists on server. --- ## What does NOT change - The PHP session layer (`AdminAuth::startSession`, `isAuthenticated`, `logout`, cookie parameters) — untouched. - The CSRF protection on all action handlers. - The nginx rate-limiting zone for `/admin/`. - All other nginx security rules (file blocking, security headers, etc.). - The `just manage-admin-users` recipe can be removed from the justfile. --- ## Security notes - **Use LDAPS (port 636) exclusively.** Plain LDAP on port 389 transmits the user's password in cleartext on the wire. Even on a trusted LAN this is not acceptable. - **Service account must be read-only.** It must not have write permission to any part of the directory. - **Do not store the service-account password in the repository.** Use `Database::setSetting()` (already encrypted at rest via filesystem permissions) or an env variable set in the server environment. - **Never log the user's password or the service-account password.** - **Fail closed.** If `ldap_connect` or the service-account bind fails, deny access. Do not fall back to a local password. - **Sanitise the username** before using it in the LDAP filter: escape special characters per RFC 4515 to prevent LDAP injection (`(uid=*)(|(uid=*))`-style attacks). PHP's `ldap_escape()` with `LDAP_ESCAPE_FILTER` flag handles this.