4a1d949e41
完整 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
212 lines
7.4 KiB
Python
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]
|