security: 全面安全加固 v2.1

严重修复:
- C1: JWT密钥生成64字节随机字符串替代硬编码占位符
- C2: 数据库密码改为随机强密码,移除宿主机端口映射
- C3: 管理员默认密码改为强密码

高危修复:
- H1: 移除数据库54326端口映射,仅内部网络访问
- H3: 添加X-Frame-Options/CSP/XSS-Protection等安全头
- H4: 前端所有innerHTML拼接处添加escapeHtml转义防XSS
- H5: SMTP密码在API响应中脱敏显示
- H6: 债务列表接口添加用户隔离,普通用户只能看自己的数据
- H7: 债务详情/计划/记录接口添加归属校验
- H8: 账户级暴力破解锁定(5次失败锁定5分钟)
- H9: 速率限制数据增加过期清理机制防内存泄漏

中危修复:
- M1: 注册/重置密码接口添加后端密码强度校验(最少8位)
- M4: 添加全局异常处理器,500错误不再暴露堆栈
- M5: Docker容器改为非root用户运行
- M6: 添加.dockerignore排除pyc和敏感文件
- M7: 管理员操作写入审计日志
- M8: CORS限制为实际使用的HTTP方法和头

其他:
- Nginx隐藏版本号(server_tokens off)
- 管理员角色修改添加枚举校验
- 密码重置验证码接口返回统一错误信息
This commit is contained in:
OP
2026-06-28 22:46:24 +08:00
parent ead41e2b8e
commit 498d29dfa9
13 changed files with 103 additions and 31 deletions

View File

@@ -7,12 +7,12 @@ class Settings(BaseSettings):
API_V1_PREFIX: str = "/api/v1"
DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@db:5432/debt_manager"
REDIS_URL: str = "redis://redis:6379/0"
SECRET_KEY: str = "your-secret-key-change-in-production"
SECRET_KEY: str = "562fa55e5e0cea1e00ff645be1da4f7c932439d97de7ffacd3d95156d9ccd7af"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
CORS_ORIGINS: list[str] = ["http://localhost", "http://localhost:80", "http://localhost:806"]
ADMIN_EMAIL: str = "admin@example.com"
ADMIN_PASSWORD: str = "admin123"
ADMIN_PASSWORD: str = "Adm1n!2026#Str0ng"
SMTP_HOST: str = ""
SMTP_PORT: int = 465

View File

@@ -3,21 +3,41 @@ from collections import defaultdict
from fastapi import Request, HTTPException
MAX_FAILURES = 10
ACCOUNT_MAX_FAILURES = 5
WINDOW_SECONDS = 300
ALERT_THRESHOLDS = [3, 5, 10]
_login_attempts: dict[str, list[float]] = defaultdict(list)
_account_failures: dict[str, list[float]] = defaultdict(list)
_account_notified: dict[str, set[int]] = defaultdict(set)
_account_blocked: dict[str, float] = {}
def _cleanup():
now = time.time()
expired_ips = [ip for ip, times in _login_attempts.items() if not times or now - times[-1] > WINDOW_SECONDS]
for ip in expired_ips:
del _login_attempts[ip]
expired_accounts = [acc for acc, times in _account_failures.items() if not times or now - times[-1] > WINDOW_SECONDS]
for acc in expired_accounts:
_account_failures.pop(acc, None)
_account_notified.pop(acc, None)
expired_blocked = [acc for acc, until in _account_blocked.items() if now > until]
for acc in expired_blocked:
del _account_blocked[acc]
def record_login_failure(ip: str, account: str = ""):
_cleanup()
now = time.time()
_login_attempts[ip] = [t for t in _login_attempts[ip] if now - t < WINDOW_SECONDS]
_login_attempts[ip].append(now)
if account:
_account_failures[account] = [t for t in _account_failures[account] if now - t < WINDOW_SECONDS]
_account_failures[account].append(now)
count = len(_account_failures[account])
if count >= ACCOUNT_MAX_FAILURES:
_account_blocked[account] = now + WINDOW_SECONDS
def clear_login_failures(ip: str, account: str = ""):
@@ -25,6 +45,7 @@ def clear_login_failures(ip: str, account: str = ""):
if account:
_account_failures.pop(account, None)
_account_notified.pop(account, None)
_account_blocked.pop(account, None)
def get_remaining_seconds(ip: str) -> int:
@@ -37,6 +58,16 @@ def get_remaining_seconds(ip: str) -> int:
return max(remaining, 0)
def get_account_remaining_seconds(account: str) -> int:
now = time.time()
if account in _account_blocked:
remaining = int(_account_blocked[account] - now)
if remaining > 0:
return remaining
del _account_blocked[account]
return 0
def get_account_failure_count(account: str) -> int:
now = time.time()
_account_failures[account] = [t for t in _account_failures[account] if now - t < WINDOW_SECONDS]
@@ -59,6 +90,10 @@ def is_ip_blocked(ip: str) -> bool:
return get_remaining_seconds(ip) > 0 and len(_login_attempts[ip]) >= MAX_FAILURES
def is_account_blocked(account: str) -> bool:
return get_account_remaining_seconds(account) > 0
async def ip_rate_limit_middleware(request: Request, call_next):
ip = request.client.host if request.client else "unknown"
if is_ip_blocked(ip):