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:
@@ -281,6 +281,8 @@ async def update_user(
|
||||
if req.real_email is not None:
|
||||
user.real_email = req.real_email
|
||||
if req.role is not None:
|
||||
if req.role not in ("user", "admin"):
|
||||
raise HTTPException(status_code=400, detail="角色值无效")
|
||||
user.role = req.role
|
||||
if req.group_id is not None or req.group_id == "":
|
||||
user.group_id = req.group_id if req.group_id else None
|
||||
@@ -338,7 +340,7 @@ async def get_email_config(
|
||||
smtp_host=settings.get("smtp_host", ""),
|
||||
smtp_port=int(settings.get("smtp_port", "465")),
|
||||
smtp_user=settings.get("smtp_user", ""),
|
||||
smtp_password=settings.get("smtp_password", ""),
|
||||
smtp_password="***" if settings.get("smtp_password") else "",
|
||||
smtp_from=settings.get("smtp_from", ""),
|
||||
smtp_use_tls=settings.get("smtp_use_tls", "true") == "true",
|
||||
)
|
||||
|
||||
@@ -41,9 +41,14 @@ async def login(
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
from app.core.rate_limit import record_login_failure, clear_login_failures, get_account_failure_count, get_unnotified_threshold
|
||||
from app.core.rate_limit import record_login_failure, clear_login_failures, get_account_failure_count, get_unnotified_threshold, is_account_blocked, get_account_remaining_seconds
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
account = req.account.strip().lower()
|
||||
|
||||
if is_account_blocked(account):
|
||||
remaining = get_account_remaining_seconds(account)
|
||||
raise HTTPException(status_code=429, detail=f"该账户已被锁定,请 {remaining} 秒后再试")
|
||||
|
||||
result = await db.execute(select(User).where(User.email == account))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user or not verify_password(req.password, user.password_hash):
|
||||
|
||||
@@ -42,6 +42,8 @@ async def list_debts(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
q = select(Debt)
|
||||
if user.role != "admin":
|
||||
q = q.where(Debt.user_id == user.id)
|
||||
if status:
|
||||
q = q.where(Debt.status == status)
|
||||
if creditor:
|
||||
@@ -89,6 +91,8 @@ async def get_debt(
|
||||
debt = result.scalar_one_or_none()
|
||||
if not debt:
|
||||
raise HTTPException(status_code=404, detail="债务不存在")
|
||||
if debt.user_id != user.id and user.role != "admin":
|
||||
raise HTTPException(status_code=403, detail="无权访问此债务")
|
||||
return await _enrich(db, debt)
|
||||
|
||||
|
||||
@@ -153,8 +157,11 @@ async def get_plans(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(Debt).where(Debt.id == debt_id))
|
||||
if not result.scalar_one_or_none():
|
||||
debt = result.scalar_one_or_none()
|
||||
if not debt:
|
||||
raise HTTPException(status_code=404, detail="债务不存在")
|
||||
if debt.user_id != user.id and user.role != "admin":
|
||||
raise HTTPException(status_code=403, detail="无权访问此债务")
|
||||
pr = await db.execute(select(RepaymentPlan).where(RepaymentPlan.debt_id == debt_id).order_by(RepaymentPlan.due_date))
|
||||
return pr.scalars().all()
|
||||
|
||||
@@ -166,7 +173,10 @@ async def get_records(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(Debt).where(Debt.id == debt_id))
|
||||
if not result.scalar_one_or_none():
|
||||
debt = result.scalar_one_or_none()
|
||||
if not debt:
|
||||
raise HTTPException(status_code=404, detail="债务不存在")
|
||||
if debt.user_id != user.id and user.role != "admin":
|
||||
raise HTTPException(status_code=403, detail="无权访问此债务")
|
||||
rr = await db.execute(select(RepaymentRecord).where(RepaymentRecord.debt_id == debt_id).order_by(RepaymentRecord.paid_date.desc()))
|
||||
return rr.scalars().all()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -124,8 +124,8 @@ app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.CORS_ORIGINS,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH"],
|
||||
allow_headers=["Authorization", "Content-Type"],
|
||||
)
|
||||
|
||||
|
||||
@@ -138,6 +138,11 @@ async def rate_limit_middleware(request: Request, call_next):
|
||||
return JSONResponse(status_code=429, content={"detail": f"登录失败次数过多,请 {remaining} 秒后再试", "retry_after": remaining})
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def global_exception_handler(request: Request, exc: Exception):
|
||||
return JSONResponse(status_code=500, content={"detail": "服务器内部错误"})
|
||||
|
||||
app.include_router(auth.router, prefix=settings.API_V1_PREFIX)
|
||||
app.include_router(loans.router, prefix=settings.API_V1_PREFIX)
|
||||
app.include_router(debts.router, prefix=settings.API_V1_PREFIX)
|
||||
|
||||
Reference in New Issue
Block a user