# LDAP Authentication Specification for XAMXAM Admin

## 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`, `app/public/admin/login.php`, `app/public/admin/actions/account.php` |

Layer 1 controls the browser's Basic Auth dialog. Layer 2 provides a PHP session gate and a
fallback login form. When both layers share the same password, the user is authenticated
transparently (nginx passes `PHP_AUTH_PW` to PHP, `AdminAuth` verifies it against the DB hash).

## Goal

Replace both layers with LDAP-based authentication while preserving the defence-in-depth
structure and the transparent user experience (single sign-on via the browser's Basic Auth
dialog, no PHP login form unless fallback).

## 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** (service / search account) | `cn=svc-xamxam,ou=services,dc=erg,dc=be` |
| 4 | **Bind password** | (secret — read-only account is sufficient) |
| 5 | **User search filter** | `(&(uid=%s)(memberOf=cn=admin-xamxam,ou=groups,dc=erg,dc=be))` — `%s` is the username entered in the Basic Auth dialog |
| 6 | **Group membership mechanism** | `memberOf` attribute (AD-style) **or** `member`/`uniqueMember` on the group entry (OpenLDAP-style) |
| 7 | **Username attribute** | Typically `uid` (OpenLDAP) or `sAMAccountName` (AD). What attribute should the user type in the auth dialog? |
| 8 | **TLS certificate** | If `ldaps://` is used and the certificate is self-signed, provide the CA certificate (PEM). Otherwise confirm it's a publicly-trusted cert. |
| 9 | **Admin group DN/CN** | The exact DN or CN that grants admin access (e.g. `cn=xamxam-admins,ou=groups,dc=erg,dc=be`). If there's no group yet, what should it be named? |

## Architecture

```
Browser                    Nginx                       LDAP daemon              LDAP server
  │                          │                              │                       │
  │─ GET /admin/ ──────────►│                              │                       │
  │◄── 401 WWW-Authenticate │                              │                       │
  │─ GET /admin/ + Basic ──►│                              │                       │
  │                         │─ POST /auth-ldap ───────────►│                       │
  │                         │   (proxy Authorization hdr)  │─ ldap_bind ──────────►│
  │                         │                              │◄── success ───────────│
  │                         │                              │─ ldap_search ────────►│
  │                         │                              │◄── group check OK ────│
  │                         │◄── 200 OK ──────────────────│                       │
  │                         │─ forward to PHP ────────────►                        │
  │                         │                              │                       │
  │◄── admin page ─────────│                                                       │
```

### Option A — `nginx-ldap-auth` daemon (preferred)

- Drop-in replacement for `auth_basic` / `.htpasswd` using nginx's `auth_request` module
- A small Python 3 daemon (`nginx-ldap-auth`) runs at `127.0.0.1:8888`
- Configured via `/etc/nginx-ldap-auth.conf` (JSON or YAML)
- Nginx proxies the `Authorization` header to the daemon; daemon binds to LDAP,
  checks group membership, returns 200 or 403
- **The PHP `AdminAuth` layer remains** — it receives `PHP_AUTH_PW` from nginx,
  can verify the username against LDAP group membership, and establish the PHP session

Nginx config (add to `location ^~ /admin/`):

```nginx
location ^~ /admin/ {
    # Replace auth_basic + auth_basic_user_file with:
    auth_request /auth-ldap;
    auth_request_set $saved_set_cookie $upstream_http_set_cookie;
    add_header Set-Cookie $saved_set_cookie;

    # Client-facing Basic Auth challenge (so the browser asks for credentials)
    satisfy any;
    # Fallback: if auth_request returns 401, challenge
    error_page 401 = @ldap_challenge;

    # Keep: rate limiting, CSP, PHP handling, security headers
    limit_req zone=admin burst=20 nodelay;
    # ... rest as-is ...
}

# Internal endpoint — delegates to LDAP daemon
location = /auth-ldap {
    internal;
    proxy_pass http://127.0.0.1:8888;
    proxy_pass_request_body off;
    proxy_set_header Content-Length "";
    proxy_set_header Authorization $http_authorization;
}

# Trigger browser Basic Auth dialog when LDAP returns 401
location @ldap_challenge {
    add_header WWW-Authenticate 'Basic realm="Admin Access - XAMXAM"';
    return 401;
}
```

### Option B — `ngx_http_auth_ldap_module` (native nginx module)

- Requires recompiling nginx with this third-party module
- Simpler config: `auth_ldap "XAMXAM Admin"; auth_ldap_servers { ... }`
- Less flexible; harder to debug

### Option C — PHP-only LDAP (no nginx layer)

- Remove nginx auth entirely
- `AdminAuth::requireLogin()` does `ldap_bind()` + group check directly in PHP
- Simpler nginx config, but no nginx-level gate
- Browser auth dialog still possible via PHP sending `WWW-Authenticate` header

## After LDAP is working: cleanup checklist

| Step | File(s) affected | Action |
|------|-----------------|--------|
| 1 | `app/src/AdminAuth.php` | Remove `getStoredHash()`, `setPasswordHash()`, `removePasswordHash()`, `hasPassword()`, `verifyHash()`. Keep `requireLogin()`, `isAuthenticated()`, `login()`, `logout()` — adapt them to LDAP group check. |
| 2 | `app/public/admin/login.php` | Remove entirely (no more PHP login form). |
| 3 | `app/public/admin/actions/account.php` | Remove entirely (no more password CRUD). |
| 4 | `app/templates/admin/login.php` | Remove template file. |
| 5 | `app/templates/admin/parametres.php` | Remove the "Compte administrateur" `<section>` (password set/change/delete UI). |
| 6 | `app/public/admin/parametres.php` | Remove `AdminAuth::hasPassword()` call and related variables. |
| 7 | `app/templates/admin/account.php` | Remove if only used for password management. |
| 8 | `nginx/xamxam.conf` | Remove `auth_basic` and `auth_basic_user_file` lines from the admin location block. |
| 9 | Database | Remove `admin_password_hash` row from `site_settings` table (manual or migration). |
| 10 | `app/bootstrap.php` | Remove legacy `ADMIN_PASSWORD_HASH` constant reference if present. |

## Dependencies to install

- **Option A**: Python 3, `python3-ldap` (or `pip install python-ldap`), `nginx-ldap-auth` daemon
- **Option B**: nginx recompiled with `ngx_http_auth_ldap_module`
- **Option C**: PHP `ldap` extension (`php8.4-ldap` or `apt install php-ldap`)

## Notes

- The `AdminAuth` PHP layer should remain even after LDAP is implemented — it provides
  session persistence, logout, CSRF integration, and the admin audit log identity.
- The LDAP daemon/nginx layer handles **authentication** (who are you?).
  The PHP `AdminAuth` layer handles **session management** (are you still you?).
- If IT provides a dedicated admin group, access control is centralised: adding/removing
  an admin is a single LDAP operation, no need to touch the server.
