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
+2
View File
@@ -18,3 +18,5 @@ ADMIN_USERNAME=admin
ADMIN_PASSWORD_HASH= ADMIN_PASSWORD_HASH=
# Set to 1 once served over HTTPS (required for the login cookie to send). Use 0 for plain-HTTP local testing. # Set to 1 once served over HTTPS (required for the login cookie to send). Use 0 for plain-HTTP local testing.
SESSION_COOKIE_SECURE=1 SESSION_COOKIE_SECURE=1
# Where the auth log fail2ban watches is written. Blank -> <appdir>/logs/auth.log
AUTH_LOG_PATH=
+1
View File
@@ -6,3 +6,4 @@ instance/
*.db *.db
static/img/ static/img/
static/vid/ static/vid/
logs/
+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'))"`
+49
View File
@@ -179,3 +179,52 @@ Then visit `https://your-domain/admin`, sign in, and manage content.
- `SECRET_KEY` must be stable and secret; changing it logs everyone out. - `SECRET_KEY` must be stable and secret; changing it logs everyone out.
- The admin routes live under `/admin`; the public page and `schema.sql` SQL - The admin routes live under `/admin`; the public page and `schema.sql` SQL
workflow above still work unchanged. workflow above still work unchanged.
---
## Brute-force protection (fail2ban)
Every login attempt is written to `logs/auth.log` (rotating, 1 MB × 5) with the
real client IP:
```
2026-07-22 12:00:00,123 jqc.auth WARNING FAILED LOGIN user=admin from 203.0.113.5
2026-07-22 12:00:05,456 jqc.auth INFO LOGIN OK user=admin from 203.0.113.5
```
The real IP comes from `ProxyFix` reading nginx's `X-Forwarded-For` (gunicorn
binds 127.0.0.1, so the header can't be spoofed from outside). Config ships in
`deploy/fail2ban/`.
### Install
```bash
sudo apt-get install -y 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/
# edit logpath in the jail file if your app dir differs from /home/jqc/jqc_features
sudo systemctl enable --now fail2ban
sudo systemctl restart fail2ban
```
Default policy: **5 failures in 10 min → 1 h ban** (`maxretry`/`findtime`/`bantime`
in the jail file).
### Verify
```bash
# regex matches the log lines:
sudo fail2ban-regex logs/auth.log deploy/fail2ban/filter.d/jqc-admin.conf
# jail is live:
sudo fail2ban-client status jqc-admin
```
`fail2ban-regex` should report matches equal to the number of `FAILED LOGIN`
lines. `status` shows currently banned IPs.
### Scope note
This jail bans credential-guessing that reaches the password check (a real
browser session with a valid CSRF token). Dumb bots that POST without a CSRF
token get an HTTP 400 and never reach the check — they can't guess a password
anyway. To also throttle those, add an nginx `limit_req` on `/admin/login`.
+13
View File
@@ -1,4 +1,5 @@
import hmac import hmac
import logging
import re import re
from functools import wraps from functools import wraps
@@ -12,6 +13,8 @@ from app import db, log_action, Section, Topic
admin_bp = Blueprint("admin", __name__, url_prefix="/admin") admin_bp = Blueprint("admin", __name__, url_prefix="/admin")
auth_log = logging.getLogger("jqc.auth")
MEDIA_TYPES = ("none", "image", "video", "embed") MEDIA_TYPES = ("none", "image", "video", "embed")
@@ -39,14 +42,24 @@ def login():
user_ok = hmac.compare_digest(username, expected_user) user_ok = hmac.compare_digest(username, expected_user)
pass_ok = bool(pw_hash) and check_password_hash(pw_hash, password) pass_ok = bool(pw_hash) and check_password_hash(pw_hash, password)
# Sanitize the attacker-controlled username before logging: collapse all
# whitespace (kills CR/LF log-injection) and cap length. The real client
# IP is logged LAST so a crafted username can't spoof the '... from <ip>'
# token the fail2ban filter anchors on at end-of-line.
safe_user = re.sub(r"\s+", " ", username).strip()[:64] or "-"
client_ip = request.remote_addr or "-"
if user_ok and pass_ok: if user_ok and pass_ok:
session.clear() session.clear()
session["admin"] = username session["admin"] = username
auth_log.info("LOGIN OK user=%s from %s", safe_user, client_ip)
dest = request.args.get("next", "") dest = request.args.get("next", "")
# only allow local admin redirects # only allow local admin redirects
if not dest.startswith("/admin"): if not dest.startswith("/admin"):
dest = url_for("admin.dashboard") dest = url_for("admin.dashboard")
return redirect(dest) return redirect(dest)
auth_log.warning("FAILED LOGIN user=%s from %s", safe_user, client_ip)
flash("Incorrect username or password.", "error") flash("Incorrect username or password.", "error")
return render_template("admin/login.html") return render_template("admin/login.html")
+35
View File
@@ -1,9 +1,13 @@
import logging
import os
from datetime import datetime from datetime import datetime
from logging.handlers import RotatingFileHandler
from flask import Flask, render_template from flask import Flask, render_template
from flask_sqlalchemy import SQLAlchemy from flask_sqlalchemy import SQLAlchemy
from flask_wtf import CSRFProtect from flask_wtf import CSRFProtect
from markupsafe import Markup from markupsafe import Markup
from werkzeug.middleware.proxy_fix import ProxyFix
from config import Config from config import Config
@@ -75,11 +79,42 @@ def log_action(actor, action, entity, entity_id=None, detail=None):
db.session.commit() db.session.commit()
def _configure_auth_logger(app):
"""A dedicated 'jqc.auth' logger writing one line per login attempt to a
file fail2ban watches. Kept separate from the app log so the filter regex
stays tight and rotation is self-contained (no logrotate needed)."""
log_path = app.config.get("AUTH_LOG_PATH") or os.path.join(
os.path.dirname(os.path.abspath(__file__)), "logs", "auth.log"
)
os.makedirs(os.path.dirname(log_path), exist_ok=True)
auth_log = logging.getLogger("jqc.auth")
auth_log.setLevel(logging.INFO)
auth_log.propagate = False
# Guard against duplicate handlers if create_app runs more than once.
if not any(isinstance(h, RotatingFileHandler) for h in auth_log.handlers):
handler = RotatingFileHandler(
log_path, maxBytes=1_000_000, backupCount=5, encoding="utf-8"
)
handler.setFormatter(
logging.Formatter("%(asctime)s %(name)s %(levelname)s %(message)s")
)
auth_log.addHandler(handler)
return auth_log
def create_app(): def create_app():
app = Flask(__name__) app = Flask(__name__)
app.config.from_object(Config) app.config.from_object(Config)
# Behind nginx: trust ONE proxy hop so request.remote_addr / scheme reflect
# the real client (nginx sets X-Forwarded-For / -Proto). gunicorn binds
# 127.0.0.1 only, so these headers can't be spoofed from outside.
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
db.init_app(app) db.init_app(app)
csrf.init_app(app) csrf.init_app(app)
_configure_auth_logger(app)
# Deferred import avoids a circular import: admin.py imports the models and # Deferred import avoids a circular import: admin.py imports the models and
# log_action defined above, which are ready by the time create_app() runs. # log_action defined above, which are ready by the time create_app() runs.
+8 -23
View File
@@ -1,28 +1,6 @@
import os import os
def _load_dotenv():
"""Load KEY=value lines from a .env beside this file into the environment,
with NO variable expansion. Werkzeug password hashes contain '$', which
shell-style interpolation corrupts. Vars already set (e.g. by systemd) win."""
path = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env")
if not os.path.exists(path):
return
with open(path, encoding="utf-8") as fh:
for raw in fh:
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, val = line.split("=", 1)
key, val = key.strip(), val.strip()
if len(val) >= 2 and val[0] == val[-1] and val[0] in ("'", '"'):
val = val[1:-1]
os.environ.setdefault(key, val)
_load_dotenv()
class Config: class Config:
# Build the SQLAlchemy URI from discrete env vars, or accept a full DATABASE_URL. # Build the SQLAlchemy URI from discrete env vars, or accept a full DATABASE_URL.
DB_USER = os.environ.get("DB_USER", "jqc_features") DB_USER = os.environ.get("DB_USER", "jqc_features")
@@ -42,11 +20,18 @@ class Config:
DEMO_CONTACT_URL = os.environ.get("DEMO_CONTACT_URL", "mailto:info@ltservicesinc.com") DEMO_CONTACT_URL = os.environ.get("DEMO_CONTACT_URL", "mailto:info@ltservicesinc.com")
# --- Admin / session --- # --- Admin / session ---
# SECRET_KEY signs session cookies and CSRF tokens. MUST be set in production.
SECRET_KEY = os.environ.get("SECRET_KEY", "dev-only-insecure-change-me") SECRET_KEY = os.environ.get("SECRET_KEY", "dev-only-insecure-change-me")
# Single admin account. Password is stored ONLY as a Werkzeug hash — never plaintext.
# Generate a hash: python3 -c "from werkzeug.security import generate_password_hash as g; print(g('yourpassword'))"
ADMIN_USERNAME = os.environ.get("ADMIN_USERNAME", "admin") ADMIN_USERNAME = os.environ.get("ADMIN_USERNAME", "admin")
ADMIN_PASSWORD_HASH = os.environ.get("ADMIN_PASSWORD_HASH", "") ADMIN_PASSWORD_HASH = os.environ.get("ADMIN_PASSWORD_HASH", "")
# Cookie hardening. SESSION_COOKIE_SECURE must be true once served over HTTPS.
SESSION_COOKIE_HTTPONLY = True SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = "Lax" SESSION_COOKIE_SAMESITE = "Lax"
SESSION_COOKIE_SECURE = os.environ.get("SESSION_COOKIE_SECURE", "1") == "1" SESSION_COOKIE_SECURE = os.environ.get("SESSION_COOKIE_SECURE", "1") == "1"
# Path to the auth log fail2ban watches. Empty -> <appdir>/logs/auth.log
AUTH_LOG_PATH = os.environ.get("AUTH_LOG_PATH", "")
+15
View File
@@ -0,0 +1,15 @@
# fail2ban filter for JQC Features admin login failures.
# Install to: /etc/fail2ban/filter.d/jqc-admin.conf
#
# Matches lines written by the app's "jqc.auth" logger, e.g.:
# 2026-07-22 12:00:00,123 jqc.auth WARNING FAILED LOGIN user=admin from 203.0.113.5
#
# The client IP is the LAST token on the line and the regex is anchored to
# end-of-line, so a crafted username cannot spoof the <HOST> capture.
[Definition]
failregex = ^.*\bFAILED LOGIN\b.* from <HOST>\s*$
ignoreregex =
# Timestamp the app writes ("YYYY-MM-DD HH:MM:SS"); trailing ,millis is ignored.
datepattern = ^%%Y-%%m-%%d %%H:%%M:%%S
+16
View File
@@ -0,0 +1,16 @@
# fail2ban jail for JQC Features admin.
# Install to: /etc/fail2ban/jail.d/jqc-admin.local
#
# Bans an IP after `maxretry` failed admin logins within `findtime` seconds.
# Adjust logpath to your deployment directory.
[jqc-admin]
enabled = true
filter = jqc-admin
port = http,https
logpath = /home/jqc/jqc_features/logs/auth.log
maxretry = 5
findtime = 600
bantime = 3600
# Optional: escalate repeat offenders (uncomment if using fail2ban >= 0.11)
# bantime.increment = true