From 8533cbe27c4769c2abd6a31a77250598c94074cd Mon Sep 17 00:00:00 2001 From: NguyenND Date: Wed, 22 Jul 2026 16:41:08 -0400 Subject: [PATCH] Jul 22 - Update protect admin page with fail2ban --- .env.example | 2 + .gitignore | 1 + CLAUDE.md | 115 ++++++++++++++++++++++++ README.md | 49 ++++++++++ admin.py | 13 +++ app.py | 35 ++++++++ config.py | 31 ++----- deploy/fail2ban/filter.d/jqc-admin.conf | 15 ++++ deploy/fail2ban/jail.d/jqc-admin.local | 16 ++++ 9 files changed, 254 insertions(+), 23 deletions(-) create mode 100644 CLAUDE.md create mode 100644 deploy/fail2ban/filter.d/jqc-admin.conf create mode 100644 deploy/fail2ban/jail.d/jqc-admin.local diff --git a/.env.example b/.env.example index 0bb325b..d579144 100644 --- a/.env.example +++ b/.env.example @@ -18,3 +18,5 @@ ADMIN_USERNAME=admin ADMIN_PASSWORD_HASH= # 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 +# Where the auth log fail2ban watches is written. Blank -> /logs/auth.log +AUTH_LOG_PATH= diff --git a/.gitignore b/.gitignore index 3aebd66..03845ee 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ instance/ *.db static/img/ static/vid/ +logs/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..2b6712e --- /dev/null +++ b/CLAUDE.md @@ -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: ` jqc.auth WARNING FAILED LOGIN user= from `. +- 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 \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'))"` diff --git a/README.md b/README.md index 27afd86..94b2cf8 100644 --- a/README.md +++ b/README.md @@ -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. - The admin routes live under `/admin`; the public page and `schema.sql` SQL 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`. diff --git a/admin.py b/admin.py index 85b1d58..301e70a 100644 --- a/admin.py +++ b/admin.py @@ -1,4 +1,5 @@ import hmac +import logging import re from functools import wraps @@ -12,6 +13,8 @@ from app import db, log_action, Section, Topic admin_bp = Blueprint("admin", __name__, url_prefix="/admin") +auth_log = logging.getLogger("jqc.auth") + MEDIA_TYPES = ("none", "image", "video", "embed") @@ -39,14 +42,24 @@ def login(): user_ok = hmac.compare_digest(username, expected_user) 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 ' + # 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: session.clear() session["admin"] = username + auth_log.info("LOGIN OK user=%s from %s", safe_user, client_ip) dest = request.args.get("next", "") # only allow local admin redirects if not dest.startswith("/admin"): dest = url_for("admin.dashboard") return redirect(dest) + auth_log.warning("FAILED LOGIN user=%s from %s", safe_user, client_ip) flash("Incorrect username or password.", "error") return render_template("admin/login.html") diff --git a/app.py b/app.py index 0f8a6b7..db81162 100644 --- a/app.py +++ b/app.py @@ -1,9 +1,13 @@ +import logging +import os from datetime import datetime +from logging.handlers import RotatingFileHandler from flask import Flask, render_template from flask_sqlalchemy import SQLAlchemy from flask_wtf import CSRFProtect from markupsafe import Markup +from werkzeug.middleware.proxy_fix import ProxyFix from config import Config @@ -75,11 +79,42 @@ def log_action(actor, action, entity, entity_id=None, detail=None): 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(): app = Flask(__name__) 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) csrf.init_app(app) + _configure_auth_logger(app) # 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. diff --git a/config.py b/config.py index 8a05c08..b2608e0 100644 --- a/config.py +++ b/config.py @@ -1,28 +1,6 @@ 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: # Build the SQLAlchemy URI from discrete env vars, or accept a full DATABASE_URL. 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") # --- 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") + # 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_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_SAMESITE = "Lax" - SESSION_COOKIE_SECURE = os.environ.get("SESSION_COOKIE_SECURE", "1") == "1" \ No newline at end of file + SESSION_COOKIE_SECURE = os.environ.get("SESSION_COOKIE_SECURE", "1") == "1" + + # Path to the auth log fail2ban watches. Empty -> /logs/auth.log + AUTH_LOG_PATH = os.environ.get("AUTH_LOG_PATH", "") diff --git a/deploy/fail2ban/filter.d/jqc-admin.conf b/deploy/fail2ban/filter.d/jqc-admin.conf new file mode 100644 index 0000000..8d9570d --- /dev/null +++ b/deploy/fail2ban/filter.d/jqc-admin.conf @@ -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 capture. + +[Definition] +failregex = ^.*\bFAILED LOGIN\b.* from \s*$ +ignoreregex = + +# Timestamp the app writes ("YYYY-MM-DD HH:MM:SS"); trailing ,millis is ignored. +datepattern = ^%%Y-%%m-%%d %%H:%%M:%%S diff --git a/deploy/fail2ban/jail.d/jqc-admin.local b/deploy/fail2ban/jail.d/jqc-admin.local new file mode 100644 index 0000000..79f3c56 --- /dev/null +++ b/deploy/fail2ban/jail.d/jqc-admin.local @@ -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