8.1 KiB
LDAP Authentication — Migration Plan
Context
The admin panel currently uses a two-layer auth stack:
- nginx
auth_basic— browser password prompt, credentials stored in/etc/nginx/.htpasswd-xamxam, managed manually withhtpasswd. - 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 (
uidon OpenLDAP,sAMAccountNameon AD). - Confirm whether a group membership check is required (i.e. only members of
cn=xamxam-admins,ou=groups,dc=erg,dc=bemay log in), or whether any valid staff account is sufficient.
Verify TCP reachability from the VM before writing any code:
# On the XAMXAM server
nc -zv <ldap-host> 636 # LDAPS (preferred)
nc -zv <ldap-host> 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:
sudo apt install php8.4-ldap sudo systemctl restart php8.4-fpm - Verify the extension loaded:
php -m | grep ldap - Store LDAP connection parameters in the database (
site_settingstable) or in a server-side env file — never in the repository:ldap_host— e.g.ldaps://ldap.erg.beldap_port—636ldap_bind_dn— service-account DNldap_bind_password— service-account passwordldap_base_dn— search base for user accountsldap_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:
- Load connection parameters from
Database::getSetting(). - Open connection:
ldap_connect($host, $port). - Set options:
LDAP_OPT_PROTOCOL_VERSION = 3,LDAP_OPT_REFERRALS = 0,LDAP_OPT_NETWORK_TIMEOUT = 3(fail fast — don't stall page loads). - Service-account bind:
ldap_bind($conn, $bind_dn, $bind_password). - Search for the user:
ldap_search($conn, $base_dn, "($attr=$username)", ['dn']). - Extract the user DN from search results.
- If group check is configured: verify membership with a second search against the group DN before proceeding.
- Attempt user bind with the supplied password:
ldap_bind($conn, $user_dn, $password)— this is the actual credential verification; LDAP does the password check. ldap_unbind($conn).- Return
trueon success,falseon 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
<input type="text" name="username">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/:
# 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:
sudo rm /etc/nginx/.htpasswd-xamxam
Phase 6 — Admin UI: retire password management page
- Remove or repurpose
app/public/admin/account.phpandapp/public/admin/actions/account.php. - Remove the "Compte" nav link from the admin header.
- The
site_settingsrowsadmin_password_hashcan 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-xamxamno 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-usersrecipe 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_connector 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'sldap_escape()withLDAP_ESCAPE_FILTERflag handles this.