refactor: 修复9项中等架构问题
安全修复: - WebSocket 端点添加认证(cookie/token),防止未授权窃听日志 - SPA serve_spa 添加路径遍历防护(resolve + relative_to 检查) - Token 改用 httpOnly Cookie 存储,移除前端 localStorage token(防 XSS 窃取) - 添加安全响应头中间件(X-Content-Type-Options/X-Frame-Options/Referrer-Policy) - HTTP 请求日志脱敏请求体中的 password/secret/token 等敏感字段 - 权限检查统一使用 user_has_permission(考虑自定义权限,修复 has_permission 忽略 custom_permissions 的缺陷) 性能与稳定性: - cookies.py 列表接口修复 N+1 查询(改为批量查询 Account) - login_service.py run() 结束时关闭 DB Session(防止连接泄漏) - _active_batches/_active_tests 全局字典添加 threading.Lock(防止并发竞态) 配置优化: - CORS 源支持环境变量 CORS_ORIGINS 配置 - Uvicorn reload 支持环境变量 UVICORN_RELOAD 控制(生产环境默认关闭) - Cookie 安全标志支持环境变量 COOKIE_SECURE 配置(HTTPS 部署时启用) - logs.py 权限不足返回 HTTP 403(原来返回 200 + message)
This commit is contained in:
@@ -9,7 +9,7 @@ from ..database import get_db
|
||||
from ..models import User, Account, AuditLog, LoginTask
|
||||
from ..schemas import AccountImport, AccountAssign, AccountTag, AccountOut, BatchAssign
|
||||
from ..deps import get_current_user, require_permission
|
||||
from ..permissions import has_permission
|
||||
from ..permissions import user_has_permission
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
@@ -53,13 +53,13 @@ def list_accounts(
|
||||
query = query.filter(Account.id.in_(_cookie_account_ids_query(db)))
|
||||
|
||||
# 权限控制:客服只能看分配给自己的
|
||||
if not has_permission(current.role, "account:view_all"):
|
||||
if has_permission(current.role, "account:view_assigned"):
|
||||
if not user_has_permission(current, "account:view_all"):
|
||||
if user_has_permission(current, "account:view_assigned"):
|
||||
query = query.filter(Account.assigned_to == current.id)
|
||||
else:
|
||||
raise HTTPException(status_code=403, detail="无权查看账号")
|
||||
|
||||
if assigned_only and has_permission(current.role, "account:view_all"):
|
||||
if assigned_only and user_has_permission(current, "account:view_all"):
|
||||
query = query.filter(Account.assigned_to.isnot(None))
|
||||
|
||||
if tag:
|
||||
@@ -76,7 +76,7 @@ def list_accounts(
|
||||
created_at=acc.created_at,
|
||||
)
|
||||
# 运营+超管可看完整字段
|
||||
if has_permission(current.role, "account:view_all"):
|
||||
if user_has_permission(current, "account:view_all"):
|
||||
item.password = acc.password
|
||||
item.email = acc.email
|
||||
item.email_password = acc.email_password
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
"""认证路由"""
|
||||
|
||||
import os
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..database import get_db
|
||||
from ..models import User, AuditLog
|
||||
from ..security import verify_password, create_access_token
|
||||
from ..security import verify_password, create_access_token, ACCESS_TOKEN_EXPIRE_HOURS
|
||||
from ..permissions import get_user_permissions, ROLE_LABELS
|
||||
from ..schemas import LoginRequest, TokenResponse
|
||||
from ..deps import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["认证"])
|
||||
|
||||
# Cookie 安全配置(生产环境 HTTPS 部署时设置 COOKIE_SECURE=true)
|
||||
_COOKIE_SECURE = os.getenv("COOKIE_SECURE", "false").lower() == "true"
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
def login(req: LoginRequest, db: Session = Depends(get_db)):
|
||||
def login(req: LoginRequest, response: Response, db: Session = Depends(get_db)):
|
||||
user = db.query(User).filter(User.username == req.username).first()
|
||||
if not user or not verify_password(req.password, user.password_hash):
|
||||
raise HTTPException(status_code=401, detail="用户名或密码错误")
|
||||
@@ -25,6 +29,17 @@ def login(req: LoginRequest, db: Session = Depends(get_db)):
|
||||
token = create_access_token({"sub": str(user.id), "role": user.role})
|
||||
perms = get_user_permissions(user)
|
||||
|
||||
# 设置 httpOnly cookie(前端无需 JS 读取 token,防 XSS 窃取)
|
||||
response.set_cookie(
|
||||
key="access_token",
|
||||
value=token,
|
||||
httponly=True,
|
||||
secure=_COOKIE_SECURE,
|
||||
samesite="lax",
|
||||
max_age=ACCESS_TOKEN_EXPIRE_HOURS * 3600,
|
||||
path="/",
|
||||
)
|
||||
|
||||
# 审计
|
||||
db.add(AuditLog(user_id=user.id, username=user.username, action="login", target="auth"))
|
||||
db.commit()
|
||||
@@ -51,7 +66,8 @@ def me(current_user: User = Depends(get_current_user)):
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
def logout(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
def logout(response: Response, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
response.delete_cookie(key="access_token", path="/")
|
||||
db.add(AuditLog(user_id=current_user.id, username=current_user.username, action="logout", target="auth"))
|
||||
db.commit()
|
||||
return {"message": "已登出"}
|
||||
|
||||
@@ -10,7 +10,7 @@ import csv
|
||||
from ..database import get_db
|
||||
from ..models import User, LoginTask, Account
|
||||
from ..deps import get_current_user, require_permission
|
||||
from ..permissions import has_permission
|
||||
from ..permissions import user_has_permission
|
||||
|
||||
|
||||
def _fmt_dt(dt) -> str | None:
|
||||
@@ -33,15 +33,23 @@ def list_cookies(
|
||||
query = db.query(LoginTask).filter(LoginTask.status == "success")
|
||||
|
||||
# 客服只能看自己账号的
|
||||
if not has_permission(current.role, "login:view_all"):
|
||||
if not user_has_permission(current, "login:view_all"):
|
||||
query = query.join(Account, LoginTask.account_id == Account.id).filter(
|
||||
Account.assigned_to == current.id
|
||||
)
|
||||
|
||||
tasks = query.order_by(LoginTask.finished_at.desc()).all()
|
||||
|
||||
# 批量查账号,避免 N+1 查询
|
||||
account_ids = [t.account_id for t in tasks]
|
||||
accounts_map = {}
|
||||
if account_ids:
|
||||
accs = db.query(Account).filter(Account.id.in_(account_ids)).all()
|
||||
accounts_map = {a.id: a for a in accs}
|
||||
|
||||
result = []
|
||||
for t in tasks:
|
||||
acc = db.query(Account).filter(Account.id == t.account_id).first()
|
||||
acc = accounts_map.get(t.account_id)
|
||||
item = {
|
||||
"id": t.id,
|
||||
"batch_id": t.batch_id,
|
||||
@@ -52,7 +60,7 @@ def list_cookies(
|
||||
"created_at": _fmt_dt(t.finished_at),
|
||||
}
|
||||
# 只有有 cookie:view 权限才返回 cookie 内容
|
||||
if has_permission(current.role, "cookie:view"):
|
||||
if user_has_permission(current, "cookie:view"):
|
||||
cookie = t.cookie or ""
|
||||
item["cookie"] = cookie
|
||||
item["cookie_preview"] = cookie[:50] + "..." if len(cookie) > 50 else cookie
|
||||
|
||||
@@ -9,14 +9,15 @@ from sqlalchemy.orm import Session
|
||||
from ..database import get_db, SessionLocal
|
||||
from ..models import User, Account, LoginTask, ProxyConfig as ProxyConfigModel
|
||||
from ..schemas import LoginBatchRequest, LoginTaskOut
|
||||
from ..deps import get_current_user, require_permission
|
||||
from ..permissions import has_permission
|
||||
from ..deps import get_current_user, require_permission, authenticate_websocket
|
||||
from ..permissions import user_has_permission, get_user_permissions
|
||||
from ..services.login_service import LoginBatchRunner
|
||||
|
||||
router = APIRouter(prefix="/api/login", tags=["登录任务"])
|
||||
|
||||
# 运行中的批次: batch_id -> {log_queue, loop, runner}
|
||||
_active_batches: dict[str, dict] = {}
|
||||
_active_batches_lock = threading.Lock()
|
||||
|
||||
|
||||
@router.post("/batch")
|
||||
@@ -38,7 +39,7 @@ async def create_batch(
|
||||
acc = db.query(Account).filter(Account.id == aid).first()
|
||||
if not acc:
|
||||
continue
|
||||
if not has_permission(current.role, "login:view_all"):
|
||||
if not user_has_permission(current, "login:view_all"):
|
||||
if acc.assigned_to != current.id:
|
||||
continue
|
||||
valid_ids.append(aid)
|
||||
@@ -56,7 +57,7 @@ async def create_batch(
|
||||
db=thread_db,
|
||||
account_ids=valid_ids,
|
||||
created_by=current.id,
|
||||
creator_role=current.role,
|
||||
creator_permissions=get_user_permissions(current),
|
||||
max_geetest_retries=req.max_geetest_retries,
|
||||
max_proxy_retries=req.max_proxy_retries,
|
||||
proxy_config=proxy,
|
||||
@@ -68,11 +69,12 @@ async def create_batch(
|
||||
batch_id = runner.batch_id
|
||||
|
||||
# 先注册到全局,再启动线程,确保 WebSocket 连接时能找到
|
||||
_active_batches[batch_id] = {
|
||||
"log_queue": log_queue,
|
||||
"loop": loop,
|
||||
"runner": runner,
|
||||
}
|
||||
with _active_batches_lock:
|
||||
_active_batches[batch_id] = {
|
||||
"log_queue": log_queue,
|
||||
"loop": loop,
|
||||
"runner": runner,
|
||||
}
|
||||
|
||||
# 启动线程
|
||||
thread = threading.Thread(target=runner.run, daemon=True)
|
||||
@@ -91,7 +93,7 @@ def list_tasks(
|
||||
query = db.query(LoginTask).join(Account, LoginTask.account_id == Account.id)
|
||||
|
||||
# 客服只能看自己账号的任务
|
||||
if not has_permission(current.role, "login:view_all"):
|
||||
if not user_has_permission(current, "login:view_all"):
|
||||
query = query.filter(Account.assigned_to == current.id)
|
||||
|
||||
if batch_id:
|
||||
@@ -153,7 +155,8 @@ def stop_batch(
|
||||
batch_id: str,
|
||||
current: User = Depends(require_permission("login:batch")),
|
||||
):
|
||||
batch = _active_batches.get(batch_id)
|
||||
with _active_batches_lock:
|
||||
batch = _active_batches.get(batch_id)
|
||||
if batch:
|
||||
batch["runner"].stop()
|
||||
return {"message": "已发送停止信号", "success": True}
|
||||
@@ -162,11 +165,18 @@ def stop_batch(
|
||||
|
||||
@router.websocket("/ws/login/{batch_id}")
|
||||
async def ws_login_logs(websocket: WebSocket, batch_id: str):
|
||||
"""WebSocket 推送登录实时日志。"""
|
||||
"""WebSocket 推送登录实时日志(需认证)。"""
|
||||
# 认证:从 cookie 或 token query param 验证用户身份
|
||||
user = authenticate_websocket(websocket)
|
||||
if not user:
|
||||
await websocket.close(code=1008, reason="未授权")
|
||||
return
|
||||
|
||||
await websocket.accept()
|
||||
|
||||
# 从已注册的批次中获取 log_queue(由 create_batch 创建)
|
||||
batch = _active_batches.get(batch_id)
|
||||
with _active_batches_lock:
|
||||
batch = _active_batches.get(batch_id)
|
||||
if not batch:
|
||||
await websocket.send_json({"level": "error", "message": "批次不存在或已结束"})
|
||||
await websocket.close()
|
||||
@@ -188,4 +198,5 @@ async def ws_login_logs(websocket: WebSocket, batch_id: str):
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
_active_batches.pop(batch_id, None)
|
||||
with _active_batches_lock:
|
||||
_active_batches.pop(batch_id, None)
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"""请求日志路由 - 查看 HTTP 请求/响应详情日志"""
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from typing import Optional
|
||||
|
||||
from ..deps import get_current_user
|
||||
from ..permissions import has_permission
|
||||
from ..permissions import user_has_permission
|
||||
from utils.http_logger import read_http_logs, clear_http_logs
|
||||
|
||||
router = APIRouter(prefix="/api/logs", tags=["日志"])
|
||||
@@ -20,10 +20,10 @@ def list_http_logs(
|
||||
current=Depends(get_current_user),
|
||||
):
|
||||
"""查看 HTTP 请求/响应详情日志(需要审计日志查看权限)"""
|
||||
if not has_permission(current.role, "audit:view"):
|
||||
if not user_has_permission(current, "audit:view"):
|
||||
# 运营也可以查看请求日志(用于排查登录问题)
|
||||
if not has_permission(current.role, "login:batch"):
|
||||
return {"items": [], "total": 0, "message": "无权限"}
|
||||
if not user_has_permission(current, "login:batch"):
|
||||
raise HTTPException(status_code=403, detail="无权限")
|
||||
|
||||
items, total = read_http_logs(
|
||||
limit=limit,
|
||||
@@ -38,9 +38,9 @@ def list_http_logs(
|
||||
@router.delete("/http")
|
||||
def clear_http_logs_api(current=Depends(get_current_user)):
|
||||
"""清空 HTTP 请求/响应详情日志"""
|
||||
if not has_permission(current.role, "audit:view"):
|
||||
if not has_permission(current.role, "login:batch"):
|
||||
return {"success": False, "message": "无权限"}
|
||||
if not user_has_permission(current, "audit:view"):
|
||||
if not user_has_permission(current, "login:batch"):
|
||||
raise HTTPException(status_code=403, detail="无权限")
|
||||
|
||||
count = clear_http_logs()
|
||||
return {"success": True, "cleared": count}
|
||||
|
||||
@@ -9,13 +9,14 @@ from sqlalchemy.orm import Session
|
||||
from ..database import get_db
|
||||
from ..models import User, ProxyConfig as ProxyConfigModel, AuditLog
|
||||
from ..schemas import ProxyConfigOut, ProxyConfigUpdate
|
||||
from ..deps import require_permission
|
||||
from ..deps import require_permission, authenticate_websocket
|
||||
from core.douyu.proxy import resolve_working_proxy, verify_proxy_url, parse_proxy_response
|
||||
|
||||
router = APIRouter(prefix="/api/proxy", tags=["代理与白名单"])
|
||||
|
||||
# 运行中的测试: test_id -> {log_queue, loop, result}
|
||||
_active_tests: dict[str, dict] = {}
|
||||
_active_tests_lock = threading.Lock()
|
||||
|
||||
|
||||
def _get_or_create(db: Session) -> ProxyConfigModel:
|
||||
@@ -63,10 +64,17 @@ def update_proxy_config(
|
||||
|
||||
@router.websocket("/ws/test/{test_id}")
|
||||
async def ws_test_logs(websocket: WebSocket, test_id: str):
|
||||
"""WebSocket 推送代理/白名单测试实时日志。"""
|
||||
"""WebSocket 推送代理/白名单测试实时日志(需认证)。"""
|
||||
# 认证:从 cookie 或 token query param 验证用户身份
|
||||
user = authenticate_websocket(websocket)
|
||||
if not user:
|
||||
await websocket.close(code=1008, reason="未授权")
|
||||
return
|
||||
|
||||
await websocket.accept()
|
||||
|
||||
test = _active_tests.get(test_id)
|
||||
with _active_tests_lock:
|
||||
test = _active_tests.get(test_id)
|
||||
if not test:
|
||||
await websocket.send_json({"level": "error", "message": "测试任务不存在"})
|
||||
await websocket.close()
|
||||
@@ -88,7 +96,8 @@ async def ws_test_logs(websocket: WebSocket, test_id: str):
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
_active_tests.pop(test_id, None)
|
||||
with _active_tests_lock:
|
||||
_active_tests.pop(test_id, None)
|
||||
|
||||
|
||||
# ---- 异步测试执行 ----
|
||||
@@ -263,7 +272,8 @@ async def test_proxy(
|
||||
log_queue = asyncio.Queue()
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
_active_tests[test_id] = {"log_queue": log_queue, "loop": loop}
|
||||
with _active_tests_lock:
|
||||
_active_tests[test_id] = {"log_queue": log_queue, "loop": loop}
|
||||
|
||||
thread = threading.Thread(target=_run_proxy_test, args=(cfg, log_queue, loop), daemon=True)
|
||||
thread.start()
|
||||
@@ -283,7 +293,8 @@ async def test_whitelist(
|
||||
log_queue = asyncio.Queue()
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
_active_tests[test_id] = {"log_queue": log_queue, "loop": loop}
|
||||
with _active_tests_lock:
|
||||
_active_tests[test_id] = {"log_queue": log_queue, "loop": loop}
|
||||
|
||||
thread = threading.Thread(target=_run_whitelist_test, args=(cfg, log_queue, loop), daemon=True)
|
||||
thread.start()
|
||||
|
||||
Reference in New Issue
Block a user