Files
squid-manager/security.py
T
Hermes Agent 4a1d949e41 feat: 完整 Squid Web Manager 平台 - P0-P3 全功能
完整 Web 管理平台,涵盖:

P0 生产加固:
- 会话超时(空闲+绝对) + CSRF 防护 + 登录失败限流
- HTTPS/TLS 证书管理(自签生成/上传/删除/过期提醒)
- SSL Bump (HTTPS 透明代理) 可视化配置
- 配置文件 diff 预览 + 二次确认才写入
- 服务控制二次确认 + 操作期间按钮锁定

P1 运维能力:
- 日志轮转管理(logrotate) + 日志状态监控
- 告警规则(5xx/命中率/磁盘/客户端流量)
- 多 Squid 实例管理 + 一键切换
- squidclient mgr 实时性能指标
- 流量异常检测(突增/大文件/高频小包/新客户端)

P2 日志分析增强:
- 日志持久化到 SQLite + 历史时间范围查询
- 导出 CSV/JSON(日志/KPI/异常/审计)
- GeoIP 地图(Leaflet + ip-api.com 离线可选 GeoLite2)
- 每客户端 24h 趋势图
- 自定义 Squid logformat 解析器

P3 体验锦上添花:
- 暗/亮主题切换(cookie 持久化)
- WebSSH 终端(xterm.js + paramiko)
- 完整审计日志 + 用户管理

技术栈: Flask 3.0 + SQLAlchemy 2.0 + SQLite + Chart.js + Leaflet
总代码量: ~16500 行 (15 Python 模块 + 34 模板 + CSS)
路由数: 73
2026-07-15 10:19:21 +08:00

212 lines
7.4 KiB
Python

"""Security helpers: session timeout, CSRF protection, login throttling.
This module is intentionally self-contained so it can be wired into
the existing app without breaking the current auth flow.
Features:
- Session lifetime: configurable idle + absolute timeout
- CSRF token: per-session token, validated on all state-changing requests
- Login throttle: rate-limit failed login attempts by client IP+username
- Audit helpers
"""
from __future__ import annotations
import hashlib
import hmac
import os
import secrets
import time
from collections import defaultdict
from functools import wraps
from threading import Lock
from flask import abort, current_app, flash, redirect, request, session, url_for
# ---- Session lifetime config -------------------------------------------------
DEFAULTS = {
"session_idle_timeout": 1800, # 30 min - logout after this much inactivity
"session_absolute_timeout": 28800, # 8 hours - hard logout no matter what
"login_throttle_window": 300, # 5 min - window for counting failed attempts
"login_throttle_max": 5, # 5 failures within window triggers throttle
"login_throttle_lockout": 900, # 15 min - lockout duration after threshold
"csrf_enabled": True,
}
# Module-level state for throttle (per-process; for multi-worker use Redis or DB)
_throttle_lock = Lock()
_failed_logins: dict[tuple[str, str], list[float]] = defaultdict(list)
_locked_until: dict[tuple[str, str], float] = {}
def get_security_config(key: str):
"""Read security config from Flask app config with fallback to DEFAULTS."""
try:
return current_app.config.get(f"SECURITY_{key.upper()}", DEFAULTS[key])
except Exception:
return DEFAULTS.get(key, "")
# ---- CSRF --------------------------------------------------------------------
def generate_csrf_token() -> str:
"""Return the current session's CSRF token, creating one if missing."""
token = session.get("_csrf")
if not token:
token = secrets.token_urlsafe(32)
session["_csrf"] = token
return token
def csrf_token_input() -> str:
"""Jinja helper: render <input type=hidden name=_csrf value=...>."""
return f'<input type="hidden" name="_csrf" value="{generate_csrf_token()}">'
def csrf_protect():
"""Flask before_request handler that validates CSRF on unsafe methods."""
if not get_security_config("csrf_enabled"):
return None
# skip safe methods
if request.method in ("GET", "HEAD", "OPTIONS"):
return None
# skip login (no session yet)
if request.endpoint in ("login", "static"):
return None
# require logged in
if not session.get("user_id"):
return None
sent = (request.form.get("_csrf")
or (request.headers.get("X-CSRF-Token", ""))
or (request.headers.get("X-CSRFToken", "")))
expected = session.get("_csrf", "")
if not expected or not sent or not hmac.compare_digest(sent, expected):
abort(400, "CSRF token missing or invalid")
# ---- Session lifetime --------------------------------------------------------
def enforce_session_lifetime():
"""Flask before_request handler that enforces idle + absolute timeout."""
if not session.get("user_id"):
return None
now = time.time()
last_seen = session.get("_last_seen", now)
logged_in_at = session.get("_logged_in_at", now)
idle_to = get_security_config("session_idle_timeout")
abs_to = get_security_config("session_absolute_timeout")
if (now - last_seen) > idle_to:
# idle timeout
session.clear()
flash("会话因长时间无操作已过期,请重新登录", "error")
return redirect(url_for("login", next=request.path))
if (now - logged_in_at) > abs_to:
session.clear()
flash("会话已达最大时长,请重新登录", "error")
return redirect(url_for("login", next=request.path))
session["_last_seen"] = now
return None
# ---- Login throttle ----------------------------------------------------------
def _throttle_key() -> tuple[str, str]:
ip = request.headers.get("X-Forwarded-For", request.remote_addr or "0.0.0.0")
if "," in ip:
ip = ip.split(",")[0].strip()
username = (request.form.get("username") or "").strip().lower()
return (ip, username)
def is_throttled() -> tuple[bool, int]:
"""Return (is_locked, seconds_remaining)."""
key = _throttle_key()
with _throttle_lock:
locked = _locked_until.get(key, 0)
now = time.time()
if locked > now:
return True, int(locked - now)
# expired lockout - clean up
if locked and locked <= now:
_locked_until.pop(key, None)
_failed_logins.pop(key, None)
return False, 0
def record_failed_login():
key = _throttle_key()
now = time.time()
with _throttle_lock:
window = get_security_config("login_throttle_window")
max_fail = get_security_config("login_throttle_max")
lockout = get_security_config("login_throttle_lockout")
# trim old
_failed_logins[key] = [t for t in _failed_logins[key] if now - t < window]
_failed_logins[key].append(now)
if len(_failed_logins[key]) >= max_fail:
_locked_until[key] = now + lockout
return True, lockout
return False, 0
def record_successful_login():
"""Clear any throttle state on success."""
key = _throttle_key()
with _throttle_lock:
_failed_logins.pop(key, None)
_locked_until.pop(key, None)
# ---- Decorator that requires login + CSRF (already done globally) ------------
def login_required_v2(func):
"""Same as login_required but also ensures CSRF token is generated for forms."""
@wraps(func)
def wrapper(*a, **kw):
if not session.get("user_id"):
return redirect(url_for("login", next=request.path))
return func(*a, **kw)
return wrapper
# ---- Password strength scoring (used by P3-2 user mgmt) ----------------------
def password_strength(pw: str) -> dict:
"""Score password 0-4 (very weak -> very strong)."""
if not pw:
return {"score": 0, "label": "", "hint": "密码不能为空"}
score = 0
if len(pw) >= 8: score += 1
if len(pw) >= 12: score += 1
if any(c.isupper() for c in pw) and any(c.islower() for c in pw): score += 1
if any(c.isdigit() for c in pw): score += 1
if any(not c.isalnum() for c in pw): score += 1
# special-case: simple/common penalties
if pw.lower() in ("admin", "password", "123456", "squid", "changeme"):
score = max(0, score - 2)
score = min(4, max(0, score))
labels = {0: "极弱", 1: "", 2: "", 3: "", 4: "很强"}
hints = {
0: "请使用 8+ 字符,含大小写+数字+符号",
1: "建议加长到 12+ 字符",
2: "可加入特殊字符进一步提升",
3: "密码强度良好",
4: "密码强度优秀",
}
return {"score": score, "label": labels[score], "hint": hints[score]}
# ---- Audit log helpers --------------------------------------------------------
def client_ip() -> str:
"""Best-effort client IP, honoring X-Forwarded-For."""
ip = request.headers.get("X-Forwarded-For", request.remote_addr or "0.0.0.0")
if "," in ip:
ip = ip.split(",")[0].strip()
return ip
def hash_password_for_audit(pw: str) -> str:
"""Stable short hash to record password changes without leaking the password."""
return hashlib.sha256(pw.encode("utf-8")).hexdigest()[:12]