Jul 22 - Update protect admin page with fail2ban

This commit is contained in:
2026-07-22 16:41:08 -04:00
parent ade7c5b33c
commit 8533cbe27c
9 changed files with 254 additions and 23 deletions
+115
View File
@@ -0,0 +1,115 @@
# CLAUDE.md — JQC Features Site
Canonical rules and context for the **jqc-features** project. Update at the end
of every session.
## Purpose
Public marketing page for LT Services' JQC program, plus a small admin panel to
edit its content. Separate codebase from the main JQC app (`janitorial_qc`).
Content = numbered sections, each with expandable topics (text / link / photo /
video). Served at `jqcfeatures.ltservicesinc.com`.
## Stack
Flask · SQLAlchemy · MySQL 8 · Flask-WTF (CSRF) · Gunicorn · systemd · Nginx ·
Ubuntu 24.04. Fonts via Google Fonts (Bricolage Grotesque, IBM Plex Sans/Mono).
## Layout (deployment)
- App dir: `/home/jqc/jqc_features` (user `jqc`)
- venv: `/home/jqc/jqc_features/venv`
- `.env` beside `config.py` (app loads it itself — see learnings)
- systemd service: `jqc-features`
- Auth log: `/home/jqc/jqc_features/logs/auth.log`
## Structure
- `app.py` — factory, models (`Section`, `Topic`, `AuditLog`), `log_action()`,
ProxyFix wrap, `_configure_auth_logger()`, public routes `/`, `/healthz`.
- `admin.py` — blueprint `/admin`: session login, section/topic CRUD.
- `config.py` — env-driven config + `_load_dotenv()` (no-expansion loader).
- `templates/` — public `index.html`; `admin/` base+login+dashboard+forms.
- `static/css/style.css` (public), `static/css/admin.css`, `static/js/main.js`.
- `schema.sql` — DDL + idempotent seed. `add_admin.sql` — additive audit_log.
- `deploy/``jqc-features.service`, `nginx.conf`, `fail2ban/`.
## Data model
- `section(id, num UNIQUE, title, subtitle, sort_order)`
- `topic(id, section_id FK cascade, slug UNIQUE, title, body_html, link_url,
link_label, media_type[none|image|video|embed], media_url, media_caption,
sort_order)`
- `audit_log(id, actor, action, entity, entity_id, detail, created_at)`
Public page orders sections by `sort_order, num`; topics by `sort_order`.
## Conventions (follow every change)
- Surgical, additive patches. Preserve route/function/variable names.
- `log_action()` is called ONLY AFTER `db.session.commit()` of the change.
- Migrations are idempotent: `CREATE TABLE IF NOT EXISTS`; seed uses
`INSERT ... ON DUPLICATE KEY UPDATE`. Safe to re-run.
- Every POST form includes `{{ csrf_token() }}`; Flask-WTF `CSRFProtect` is global.
- Content `body_html` is admin-authored and trusted → rendered via `Markup`.
- Read files on disk before editing; verify the patch after applying.
## Auth / admin design
- Single admin account. Password stored ONLY as a Werkzeug hash in
`ADMIN_PASSWORD_HASH`; empty hash rejects all logins by design.
- Session flag `session['admin']`; `login_required` decorator gates all CRUD.
- `next` redirect is restricted to paths starting `/admin` (open-redirect guard).
- Username compared with `hmac.compare_digest`; password with
`check_password_hash` (constant-time).
## Security learnings (this session)
- **`.env` is not read automatically.** The app only sees process env. systemd
`EnvironmentFile=` OR the in-app `_load_dotenv()` must supply vars. We added
`_load_dotenv()` so the `.env` beside `config.py` is authoritative regardless
of systemd. systemd-injected vars still win (`os.environ.setdefault`).
- **No variable expansion when loading `.env`.** Werkzeug hashes contain `$`;
shell/dotenv interpolation corrupts them. `_load_dotenv()` does a plain split,
strips matched surrounding quotes, no expansion.
- **`SESSION_COOKIE_SECURE=1` breaks login over plain HTTP.** The session cookie
(which holds the CSRF token) is marked `Secure`, so the browser drops it on
HTTP → "CSRF session token is missing." Serve HTTPS in prod; only set `0` for
local HTTP testing.
- **Empty `SECRET_KEY` also kills sessions** → same CSRF error. Must be set,
stable, secret. Changing it logs everyone out.
- **systemd reads `EnvironmentFile` only at start** → `systemctl restart` after
any `.env` edit; `daemon-reload` after unit edits.
- **Real client IP behind nginx:** `request.remote_addr` is 127.0.0.1 without
`ProxyFix`. We wrap `app.wsgi_app = ProxyFix(..., x_for=1, x_proto=1, x_host=1)`
and nginx sets `X-Forwarded-For`/`-Proto`. gunicorn binds 127.0.0.1, so headers
can't be spoofed externally. `x_proto=1` also fixes https redirects.
## fail2ban
- App writes `logs/auth.log` via the `jqc.auth` logger (RotatingFileHandler,
1 MB × 5, own handler, `propagate=False`).
- Line format: `<ts> jqc.auth WARNING FAILED LOGIN user=<u> from <ip>`.
- Username is sanitized before logging (`\s+`→space, cap 64) to kill CR/LF
log-injection; the real IP is the LAST token and the filter regex anchors
`from <HOST>\s*$`, so a crafted username can't spoof the ban target.
- Filter `deploy/fail2ban/filter.d/jqc-admin.conf` (datepattern uses `%%` —
ConfigParser escaping). Jail `deploy/fail2ban/jail.d/jqc-admin.local`:
5 fails / 10 min → 1 h ban.
- CSRF-less POST floods get HTTP 400 before the view, so they don't reach the
auth log — add nginx `limit_req` on `/admin/login` if that traffic matters.
## Deploy delta cheatsheet
```bash
sudo mysql < add_admin.sql # audit_log (existing DBs)
# .env: SECRET_KEY, ADMIN_USERNAME, ADMIN_PASSWORD_HASH, SESSION_COOKIE_SECURE=1
sudo ./venv/bin/pip install -r requirements.txt
sudo systemctl restart jqc-features
# fail2ban:
sudo cp deploy/fail2ban/filter.d/jqc-admin.conf /etc/fail2ban/filter.d/
sudo cp deploy/fail2ban/jail.d/jqc-admin.local /etc/fail2ban/jail.d/
sudo systemctl restart fail2ban
```
Password hash: `./venv/bin/python -c "from werkzeug.security import generate_password_hash as g; print(g('PW'))"`