mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-09-25 01:53:03 +02:00
- 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
167 lines
6.7 KiB
Markdown
167 lines
6.7 KiB
Markdown
# 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.
|