mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-05-06 19:19:19 +02:00
208 lines
8.1 KiB
Markdown
208 lines
8.1 KiB
Markdown
# 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.
|