# 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 <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:
  ```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 `<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/`:

```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.
