Reorganise docs: move historical files to archive/, merge overlapping docs

- Move 12 historical/superseded docs + 1 session log + 1 PDF + 1 HTML plan to archive/
- Merge 4 VM-crash docs into archive/vm-crash-incident.md
- Merge LDAP plan + spec into ldap.md
- Merge FilePond race investigation into filepond-crash-analysis.md
- Merge SPECS.md client notes into spec-sheet.md appendix
- Update README index and security.md cross-reference
This commit is contained in:
Pontoporeia
2026-08-24 11:36:02 +02:00
parent e4b48867aa
commit 55c714a35d
27 changed files with 501 additions and 1602 deletions
+166
View File
@@ -0,0 +1,166 @@
# 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 <ldap-host> 636 # LDAPS (preferred)
nc -zv <ldap-host> 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 `<input name="username">` 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.