Files
xamxam/docs/LDAP_AUTH_PLAN.md

8.1 KiB

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:

# 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_settings table) or in a server-side env file — never in the repository:
    • ldap_host — e.g. ldaps://ldap.erg.be
    • ldap_port636
    • 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 <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.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.