Rate-limit /admin/login and alert on insecure default credentials

No brute-force protection existed on the admin login form. Added an
in-memory per-IP lockout (5 failed attempts / 15 min -> 15 min lockout),
alerting the owner via Telegram when a lockout triggers.

Also warn loudly at startup (log + owner Telegram alert, not a hard
refusal) if SECRET_KEY or the admin password are still the config.py
defaults — previously a skipped .env line would silently run production
with session-forgeable SECRET_KEY or an admin/admin login.
This commit is contained in:
byrsapty 2026-08-27 13:07:09 +03:00
parent 759a590022
commit b636d0584a
3 changed files with 89 additions and 1 deletions

View file

@ -31,7 +31,7 @@ from bober_bbq.models import (
Setting,
StockMovement,
)
from bober_bbq.utils import checkbox_prro
from bober_bbq.utils import checkbox_prro, login_throttle
from bober_bbq.utils.audit import log_action
from bober_bbq.utils.inventory import deduct_for_order, inventory_enabled, restore_for_order
from bober_bbq.utils.notify_customer import notify_customer_status_change
@ -82,14 +82,22 @@ def inject_admin_globals():
@admin_bp.route("/login", methods=["GET", "POST"])
def login():
if request.method == "POST":
ip = request.remote_addr or "unknown"
locked_for = login_throttle.seconds_locked(ip)
if locked_for is not None:
flash(f"Забагато невдалих спроб входу. Спробуйте ще раз через {locked_for // 60 + 1} хв.", "error")
return render_template("admin/login.html")
username = request.form.get("username", "")
password = request.form.get("password", "")
user = AdminUser.query.filter_by(username=username).first()
if user and user.check_password(password):
login_throttle.record_success(ip)
user.last_login_at = datetime.now(timezone.utc)
db.session.commit()
login_user(user)
return redirect(url_for("admin.dashboard"))
login_throttle.record_failure(ip)
flash("Невірний логін або пароль", "error")
return render_template("admin/login.html")

View file

@ -0,0 +1,54 @@
"""Brute-force throttle for /admin/login.
The web app runs as a single waitress process, multi-threaded (see
run_web.py) not multiple workers so a module-level dict is safely
shared across every request thread, same pattern as
error_alerts._last_sent and reconciliation._last_token_ok.
"""
import logging
import time
from bober_bbq.utils import error_alerts
logger = logging.getLogger(__name__)
MAX_ATTEMPTS = 5
WINDOW_SECONDS = 15 * 60 # failed attempts older than this stop counting toward a lockout
LOCKOUT_SECONDS = 15 * 60
_failures: dict[str, list[float]] = {}
_locked_until: dict[str, float] = {}
def seconds_locked(ip: str) -> int | None:
"""Returns remaining lockout seconds for `ip`, or None if not locked."""
until = _locked_until.get(ip)
if until is None:
return None
remaining = until - time.monotonic()
if remaining <= 0:
_locked_until.pop(ip, None)
return None
return int(remaining) + 1
def record_failure(ip: str) -> None:
now = time.monotonic()
attempts = [t for t in _failures.get(ip, []) if now - t < WINDOW_SECONDS]
attempts.append(now)
if len(attempts) >= MAX_ATTEMPTS:
_locked_until[ip] = now + LOCKOUT_SECONDS
_failures.pop(ip, None)
logger.warning("Login throttle: locking out %s after %d failed attempts", ip, len(attempts))
error_alerts.notify(
f"вхід в адмінку з {ip}",
RuntimeError(f"{MAX_ATTEMPTS} невдалих спроб входу підряд — можливий підбір пароля"),
)
else:
_failures[ip] = attempts
def record_success(ip: str) -> None:
_failures.pop(ip, None)
_locked_until.pop(ip, None)

View file

@ -31,6 +31,32 @@ with app.app_context():
seed()
def _warn_on_insecure_defaults() -> None:
# config.py silently falls back to these if .env is missing a line or a
# client's provisioning skipped provision_client.sh (which generates a
# real SECRET_KEY and asks for a real admin password). Left unnoticed,
# SECRET_KEY="dev-secret-change-me" lets anyone forge session cookies,
# and admin/admin is guessable in one try. Don't refuse to start (that
# could brick a legitimate first boot before the owner sets a real
# password) — just make it impossible to miss.
problems = []
if config.SECRET_KEY == "dev-secret-change-me":
problems.append("SECRET_KEY все ще стандартний (dev-secret-change-me)")
if config.FLASK_ADMIN_PASSWORD == "admin":
problems.append("пароль адміна все ще стандартний (admin/admin)")
if not problems:
return
message = "; ".join(problems)
logger.error("INSECURE DEFAULTS IN PRODUCTION: %s — виправте .env і перезапустіть сервіс", message)
error_alerts.notify(
"перевірка налаштувань безпеки при старті",
RuntimeError(f"Використовуються стандартні небезпечні значення: {message}"),
)
_warn_on_insecure_defaults()
def _run_in_app_context(fn, job_name):
# APScheduler's default executor only logs a job's exception itself —
# nothing here previously caught one, so a crashing job (like