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:
parent
759a590022
commit
b636d0584a
3 changed files with 89 additions and 1 deletions
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
54
bober_bbq/utils/login_throttle.py
Normal file
54
bober_bbq/utils/login_throttle.py
Normal 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)
|
||||
26
run_web.py
26
run_web.py
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue