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:
yml2213
2026-06-23 06:51:01 +08:00
parent db384701a9
commit 453a637480
18 changed files with 317 additions and 153 deletions
+9
View File
@@ -9,3 +9,12 @@ JWT_SECRET_KEY=
# 默认管理员账号(仅首次启动建库时生效) # 默认管理员账号(仅首次启动建库时生效)
ADMIN_USERNAME=admin ADMIN_USERNAME=admin
ADMIN_PASSWORD=admin123 ADMIN_PASSWORD=admin123
# Cookie 安全标志(生产环境 HTTPS 部署时设为 true)
COOKIE_SECURE=false
# CORS 允许的源(逗号分隔,不设则默认开发环境)
# CORS_ORIGINS=https://example.com,https://www.example.com
# Uvicorn reload(开发模式设为 true,生产环境保持 false)
UVICORN_RELOAD=false
+6
View File
@@ -16,6 +16,12 @@ services:
# 默认管理员账号密码(仅首次启动建库时生效) # 默认管理员账号密码(仅首次启动建库时生效)
- ADMIN_USERNAME=${ADMIN_USERNAME:-admin} - ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123} - ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123}
# Cookie 安全标志(HTTPS 部署时设为 true)
- COOKIE_SECURE=${COOKIE_SECURE:-false}
# CORS 允许的源(逗号分隔)
- CORS_ORIGINS=${CORS_ORIGINS:-}
# Uvicorn reload(生产环境保持 false
- UVICORN_RELOAD=${UVICORN_RELOAD:-false}
healthcheck: healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')"] test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')"]
interval: 30s interval: 30s
+5 -1
View File
@@ -45,7 +45,11 @@ trap cleanup EXIT INT TERM
# 启动后端 # 启动后端
echo "启动后端 (FastAPI :$BACKEND_PORT)..." echo "启动后端 (FastAPI :$BACKEND_PORT)..."
"$PYTHON" -m uvicorn web.backend.main:app --host 0.0.0.0 --port $BACKEND_PORT --reload & RELOAD_FLAG=""
if [ "${UVICORN_RELOAD:-true}" = "true" ]; then
RELOAD_FLAG="--reload"
fi
"$PYTHON" -m uvicorn web.backend.main:app --host 0.0.0.0 --port $BACKEND_PORT $RELOAD_FLAG &
BACKEND_PID=$! BACKEND_PID=$!
# 等后端就绪 # 等后端就绪
+42 -1
View File
@@ -51,6 +51,47 @@ def _safe_headers(headers: Any) -> dict:
return safe return safe
# 请求体中需要脱敏的字段名(小写匹配,包含即遮罩)
_SENSITIVE_BODY_KEYS = {
'password', 'pwd', 'passwd', 'secret', 'token', 'apikey', 'api_key',
'email_password', 'mm', 'authorization', 'credential',
}
def _safe_body(body: Any) -> str:
"""清理请求体中的敏感字段值。
支持 dict、JSON 字符串、其他类型。
敏感字段的值会被替换为 ***,其余内容保留(仍受 _truncate 限制)。
"""
if body is None:
return ""
# 尝试解析 JSON 字符串
parsed = body
if isinstance(body, str):
try:
parsed = json.loads(body)
except (json.JSONDecodeError, ValueError):
# 非 JSON 字符串,直接截断
return _truncate(body)
# dict 类型:遮罩敏感字段
if isinstance(parsed, dict):
safe = {}
for k, v in parsed.items():
if any(s in k.lower() for s in _SENSITIVE_BODY_KEYS):
safe[k] = '***'
elif isinstance(v, (dict, list)):
safe[k] = _safe_body(v) if isinstance(v, dict) else _truncate(str(v))
else:
safe[k] = v
return _truncate(json.dumps(safe, ensure_ascii=False))
# 其他类型:直接截断
return _truncate(str(body))
def log_http( def log_http(
category: str, category: str,
method: str, method: str,
@@ -93,7 +134,7 @@ def log_http(
"proxy": proxy, "proxy": proxy,
"request": { "request": {
"headers": _safe_headers(request_headers), "headers": _safe_headers(request_headers),
"body": _truncate(request_body), "body": _safe_body(request_body),
}, },
"response": { "response": {
"status_code": status_code, "status_code": status_code,
+34 -4
View File
@@ -1,20 +1,23 @@
"""FastAPI 依赖注入""" """FastAPI 依赖注入"""
from fastapi import Depends, HTTPException, status from typing import Optional
from fastapi import Depends, HTTPException, Request, status, WebSocket
from fastapi.security import OAuth2PasswordBearer from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from jose import JWTError from jose import JWTError
from .database import get_db from .database import get_db, SessionLocal
from .security import decode_access_token from .security import decode_access_token
from .models import User from .models import User
from .permissions import get_user_permissions from .permissions import get_user_permissions
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login") # auto_error=False: 允许 token 为空(后续从 cookie 读取)
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login", auto_error=False)
def get_current_user( def get_current_user(
token: str = Depends(oauth2_scheme), request: Request,
token: Optional[str] = Depends(oauth2_scheme),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> User: ) -> User:
credentials_exc = HTTPException( credentials_exc = HTTPException(
@@ -22,6 +25,11 @@ def get_current_user(
detail="无效的认证凭据", detail="无效的认证凭据",
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) )
# 优先从 Authorization Bearer header 读取,回退到 httpOnly cookie
if not token:
token = request.cookies.get("access_token")
if not token:
raise credentials_exc
try: try:
payload = decode_access_token(token) payload = decode_access_token(token)
if payload is None: if payload is None:
@@ -48,3 +56,25 @@ def require_permission(permission: str):
raise HTTPException(status_code=403, detail=f"无权限: {permission}") raise HTTPException(status_code=403, detail=f"无权限: {permission}")
return current_user return current_user
return checker return checker
def authenticate_websocket(websocket: WebSocket) -> Optional[User]:
"""WebSocket 认证:从 cookie 或 query param token 中验证用户身份。
Returns:
User 如果认证成功,None 如果失败。
"""
token = websocket.cookies.get("access_token") or websocket.query_params.get("token")
if not token:
return None
payload = decode_access_token(token)
if not payload or not payload.get("sub"):
return None
db = SessionLocal()
try:
user = db.query(User).filter(User.id == int(payload["sub"])).first()
if user and user.is_active:
return user
return None
finally:
db.close()
+31 -5
View File
@@ -8,6 +8,7 @@ from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from starlette.middleware.base import BaseHTTPMiddleware
from .database import init_db from .database import init_db
from .routers import auth, users, accounts, login, proxy, cookies, logs from .routers import auth, users, accounts, login, proxy, cookies, logs
@@ -25,15 +26,34 @@ app = FastAPI(
lifespan=lifespan, lifespan=lifespan,
) )
# CORS开发期允许前端 localhost:5173 # CORS支持通过环境变量配置,逗号分隔;默认开发环境
_cors_env = os.getenv("CORS_ORIGINS", "")
if _cors_env:
_cors_origins = [o.strip() for o in _cors_env.split(",") if o.strip()]
else:
_cors_origins = ["http://localhost:5173", "http://localhost:3000"]
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["http://localhost:5173", "http://localhost:3000"], allow_origins=_cors_origins,
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
) )
# 安全响应头中间件
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
response = await call_next(request)
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
return response
app.add_middleware(SecurityHeadersMiddleware)
# 注册路由 # 注册路由
app.include_router(auth.router) app.include_router(auth.router)
app.include_router(users.router) app.include_router(users.router)
@@ -66,8 +86,13 @@ if _INDEX_HTML.exists():
# 排除 API 路径 # 排除 API 路径
if full_path.startswith("api"): if full_path.startswith("api"):
return {"detail": "Not Found"} return {"detail": "Not Found"}
# 尝试返回静态文件 # 尝试返回静态文件(防护路径遍历)
file_path = _FRONTEND_DIST / full_path file_path = (_FRONTEND_DIST / full_path).resolve()
try:
file_path.relative_to(_FRONTEND_DIST.resolve())
except ValueError:
# 路径逃逸出 dist 目录,拒绝访问
return {"detail": "Not Found"}
if file_path.is_file(): if file_path.is_file():
return FileResponse(str(file_path)) return FileResponse(str(file_path))
# SPA fallback 到 index.html # SPA fallback 到 index.html
@@ -75,7 +100,8 @@ if _INDEX_HTML.exists():
def run(): def run():
uvicorn.run("web.backend.main:app", host="0.0.0.0", port=8000, reload=True) _reload = os.getenv("UVICORN_RELOAD", "false").lower() == "true"
uvicorn.run("web.backend.main:app", host="0.0.0.0", port=8000, reload=_reload)
if __name__ == "__main__": if __name__ == "__main__":
+12 -1
View File
@@ -75,5 +75,16 @@ def get_user_permissions(user) -> list[str]:
def has_permission(role: str, permission: str) -> bool: def has_permission(role: str, permission: str) -> bool:
"""检查角色是否拥有某权限""" """检查角色是否拥有某权限(仅检查角色默认权限,不考虑自定义权限)。
注意:对于需要考虑用户自定义权限的场景,请使用 user_has_permission()。
"""
return permission in get_role_permissions(role) return permission in get_role_permissions(role)
def user_has_permission(user, permission: str) -> bool:
"""检查用户是否拥有某权限(考虑自定义权限)。
优先使用用户的 custom_permissions,若为 None 则回退到角色默认权限。
"""
return permission in get_user_permissions(user)
+5 -5
View File
@@ -9,7 +9,7 @@ from ..database import get_db
from ..models import User, Account, AuditLog, LoginTask from ..models import User, Account, AuditLog, LoginTask
from ..schemas import AccountImport, AccountAssign, AccountTag, AccountOut, BatchAssign from ..schemas import AccountImport, AccountAssign, AccountTag, AccountOut, BatchAssign
from ..deps import get_current_user, require_permission 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 import func
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload
@@ -53,13 +53,13 @@ def list_accounts(
query = query.filter(Account.id.in_(_cookie_account_ids_query(db))) query = query.filter(Account.id.in_(_cookie_account_ids_query(db)))
# 权限控制:客服只能看分配给自己的 # 权限控制:客服只能看分配给自己的
if not has_permission(current.role, "account:view_all"): if not user_has_permission(current, "account:view_all"):
if has_permission(current.role, "account:view_assigned"): if user_has_permission(current, "account:view_assigned"):
query = query.filter(Account.assigned_to == current.id) query = query.filter(Account.assigned_to == current.id)
else: else:
raise HTTPException(status_code=403, detail="无权查看账号") 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)) query = query.filter(Account.assigned_to.isnot(None))
if tag: if tag:
@@ -76,7 +76,7 @@ def list_accounts(
created_at=acc.created_at, 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.password = acc.password
item.email = acc.email item.email = acc.email
item.email_password = acc.email_password item.email_password = acc.email_password
+20 -4
View File
@@ -1,21 +1,25 @@
"""认证路由""" """认证路由"""
import os
from datetime import datetime from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException, Response
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from ..database import get_db from ..database import get_db
from ..models import User, AuditLog 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 ..permissions import get_user_permissions, ROLE_LABELS
from ..schemas import LoginRequest, TokenResponse from ..schemas import LoginRequest, TokenResponse
from ..deps import get_current_user from ..deps import get_current_user
router = APIRouter(prefix="/api/auth", tags=["认证"]) 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) @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() user = db.query(User).filter(User.username == req.username).first()
if not user or not verify_password(req.password, user.password_hash): if not user or not verify_password(req.password, user.password_hash):
raise HTTPException(status_code=401, detail="用户名或密码错误") 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}) token = create_access_token({"sub": str(user.id), "role": user.role})
perms = get_user_permissions(user) 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.add(AuditLog(user_id=user.id, username=user.username, action="login", target="auth"))
db.commit() db.commit()
@@ -51,7 +66,8 @@ def me(current_user: User = Depends(get_current_user)):
@router.post("/logout") @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.add(AuditLog(user_id=current_user.id, username=current_user.username, action="logout", target="auth"))
db.commit() db.commit()
return {"message": "已登出"} return {"message": "已登出"}
+12 -4
View File
@@ -10,7 +10,7 @@ import csv
from ..database import get_db from ..database import get_db
from ..models import User, LoginTask, Account from ..models import User, LoginTask, Account
from ..deps import get_current_user, require_permission 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: def _fmt_dt(dt) -> str | None:
@@ -33,15 +33,23 @@ def list_cookies(
query = db.query(LoginTask).filter(LoginTask.status == "success") 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( query = query.join(Account, LoginTask.account_id == Account.id).filter(
Account.assigned_to == current.id Account.assigned_to == current.id
) )
tasks = query.order_by(LoginTask.finished_at.desc()).all() 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 = [] result = []
for t in tasks: for t in tasks:
acc = db.query(Account).filter(Account.id == t.account_id).first() acc = accounts_map.get(t.account_id)
item = { item = {
"id": t.id, "id": t.id,
"batch_id": t.batch_id, "batch_id": t.batch_id,
@@ -52,7 +60,7 @@ def list_cookies(
"created_at": _fmt_dt(t.finished_at), "created_at": _fmt_dt(t.finished_at),
} }
# 只有有 cookie:view 权限才返回 cookie 内容 # 只有有 cookie:view 权限才返回 cookie 内容
if has_permission(current.role, "cookie:view"): if user_has_permission(current, "cookie:view"):
cookie = t.cookie or "" cookie = t.cookie or ""
item["cookie"] = cookie item["cookie"] = cookie
item["cookie_preview"] = cookie[:50] + "..." if len(cookie) > 50 else cookie item["cookie_preview"] = cookie[:50] + "..." if len(cookie) > 50 else cookie
+25 -14
View File
@@ -9,14 +9,15 @@ from sqlalchemy.orm import Session
from ..database import get_db, SessionLocal from ..database import get_db, SessionLocal
from ..models import User, Account, LoginTask, ProxyConfig as ProxyConfigModel from ..models import User, Account, LoginTask, ProxyConfig as ProxyConfigModel
from ..schemas import LoginBatchRequest, LoginTaskOut from ..schemas import LoginBatchRequest, LoginTaskOut
from ..deps import get_current_user, require_permission from ..deps import get_current_user, require_permission, authenticate_websocket
from ..permissions import has_permission from ..permissions import user_has_permission, get_user_permissions
from ..services.login_service import LoginBatchRunner from ..services.login_service import LoginBatchRunner
router = APIRouter(prefix="/api/login", tags=["登录任务"]) router = APIRouter(prefix="/api/login", tags=["登录任务"])
# 运行中的批次: batch_id -> {log_queue, loop, runner} # 运行中的批次: batch_id -> {log_queue, loop, runner}
_active_batches: dict[str, dict] = {} _active_batches: dict[str, dict] = {}
_active_batches_lock = threading.Lock()
@router.post("/batch") @router.post("/batch")
@@ -38,7 +39,7 @@ async def create_batch(
acc = db.query(Account).filter(Account.id == aid).first() acc = db.query(Account).filter(Account.id == aid).first()
if not acc: if not acc:
continue 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: if acc.assigned_to != current.id:
continue continue
valid_ids.append(aid) valid_ids.append(aid)
@@ -56,7 +57,7 @@ async def create_batch(
db=thread_db, db=thread_db,
account_ids=valid_ids, account_ids=valid_ids,
created_by=current.id, created_by=current.id,
creator_role=current.role, creator_permissions=get_user_permissions(current),
max_geetest_retries=req.max_geetest_retries, max_geetest_retries=req.max_geetest_retries,
max_proxy_retries=req.max_proxy_retries, max_proxy_retries=req.max_proxy_retries,
proxy_config=proxy, proxy_config=proxy,
@@ -68,11 +69,12 @@ async def create_batch(
batch_id = runner.batch_id batch_id = runner.batch_id
# 先注册到全局,再启动线程,确保 WebSocket 连接时能找到 # 先注册到全局,再启动线程,确保 WebSocket 连接时能找到
_active_batches[batch_id] = { with _active_batches_lock:
"log_queue": log_queue, _active_batches[batch_id] = {
"loop": loop, "log_queue": log_queue,
"runner": runner, "loop": loop,
} "runner": runner,
}
# 启动线程 # 启动线程
thread = threading.Thread(target=runner.run, daemon=True) 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) 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) query = query.filter(Account.assigned_to == current.id)
if batch_id: if batch_id:
@@ -153,7 +155,8 @@ def stop_batch(
batch_id: str, batch_id: str,
current: User = Depends(require_permission("login:batch")), 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: if batch:
batch["runner"].stop() batch["runner"].stop()
return {"message": "已发送停止信号", "success": True} return {"message": "已发送停止信号", "success": True}
@@ -162,11 +165,18 @@ def stop_batch(
@router.websocket("/ws/login/{batch_id}") @router.websocket("/ws/login/{batch_id}")
async def ws_login_logs(websocket: WebSocket, batch_id: str): 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() await websocket.accept()
# 从已注册的批次中获取 log_queue(由 create_batch 创建) # 从已注册的批次中获取 log_queue(由 create_batch 创建)
batch = _active_batches.get(batch_id) with _active_batches_lock:
batch = _active_batches.get(batch_id)
if not batch: if not batch:
await websocket.send_json({"level": "error", "message": "批次不存在或已结束"}) await websocket.send_json({"level": "error", "message": "批次不存在或已结束"})
await websocket.close() await websocket.close()
@@ -188,4 +198,5 @@ async def ws_login_logs(websocket: WebSocket, batch_id: str):
except WebSocketDisconnect: except WebSocketDisconnect:
pass pass
finally: finally:
_active_batches.pop(batch_id, None) with _active_batches_lock:
_active_batches.pop(batch_id, None)
+8 -8
View File
@@ -1,10 +1,10 @@
"""请求日志路由 - 查看 HTTP 请求/响应详情日志""" """请求日志路由 - 查看 HTTP 请求/响应详情日志"""
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, HTTPException, Query
from typing import Optional from typing import Optional
from ..deps import get_current_user 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 from utils.http_logger import read_http_logs, clear_http_logs
router = APIRouter(prefix="/api/logs", tags=["日志"]) router = APIRouter(prefix="/api/logs", tags=["日志"])
@@ -20,10 +20,10 @@ def list_http_logs(
current=Depends(get_current_user), current=Depends(get_current_user),
): ):
"""查看 HTTP 请求/响应详情日志(需要审计日志查看权限)""" """查看 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"): if not user_has_permission(current, "login:batch"):
return {"items": [], "total": 0, "message": "无权限"} raise HTTPException(status_code=403, detail="无权限")
items, total = read_http_logs( items, total = read_http_logs(
limit=limit, limit=limit,
@@ -38,9 +38,9 @@ def list_http_logs(
@router.delete("/http") @router.delete("/http")
def clear_http_logs_api(current=Depends(get_current_user)): def clear_http_logs_api(current=Depends(get_current_user)):
"""清空 HTTP 请求/响应详情日志""" """清空 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"): if not user_has_permission(current, "login:batch"):
return {"success": False, "message": "无权限"} raise HTTPException(status_code=403, detail="无权限")
count = clear_http_logs() count = clear_http_logs()
return {"success": True, "cleared": count} return {"success": True, "cleared": count}
+17 -6
View File
@@ -9,13 +9,14 @@ from sqlalchemy.orm import Session
from ..database import get_db from ..database import get_db
from ..models import User, ProxyConfig as ProxyConfigModel, AuditLog from ..models import User, ProxyConfig as ProxyConfigModel, AuditLog
from ..schemas import ProxyConfigOut, ProxyConfigUpdate 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 from core.douyu.proxy import resolve_working_proxy, verify_proxy_url, parse_proxy_response
router = APIRouter(prefix="/api/proxy", tags=["代理与白名单"]) router = APIRouter(prefix="/api/proxy", tags=["代理与白名单"])
# 运行中的测试: test_id -> {log_queue, loop, result} # 运行中的测试: test_id -> {log_queue, loop, result}
_active_tests: dict[str, dict] = {} _active_tests: dict[str, dict] = {}
_active_tests_lock = threading.Lock()
def _get_or_create(db: Session) -> ProxyConfigModel: def _get_or_create(db: Session) -> ProxyConfigModel:
@@ -63,10 +64,17 @@ def update_proxy_config(
@router.websocket("/ws/test/{test_id}") @router.websocket("/ws/test/{test_id}")
async def ws_test_logs(websocket: WebSocket, test_id: str): 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() await websocket.accept()
test = _active_tests.get(test_id) with _active_tests_lock:
test = _active_tests.get(test_id)
if not test: if not test:
await websocket.send_json({"level": "error", "message": "测试任务不存在"}) await websocket.send_json({"level": "error", "message": "测试任务不存在"})
await websocket.close() await websocket.close()
@@ -88,7 +96,8 @@ async def ws_test_logs(websocket: WebSocket, test_id: str):
except WebSocketDisconnect: except WebSocketDisconnect:
pass pass
finally: 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() log_queue = asyncio.Queue()
loop = asyncio.get_running_loop() 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 = threading.Thread(target=_run_proxy_test, args=(cfg, log_queue, loop), daemon=True)
thread.start() thread.start()
@@ -283,7 +293,8 @@ async def test_whitelist(
log_queue = asyncio.Queue() log_queue = asyncio.Queue()
loop = asyncio.get_running_loop() 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 = threading.Thread(target=_run_whitelist_test, args=(cfg, log_queue, loop), daemon=True)
thread.start() thread.start()
+80 -77
View File
@@ -13,7 +13,6 @@ from core.douyu import DouyuLogin
from core.models import Account, ProxyConfig as DouyuProxyConfig from core.models import Account, ProxyConfig as DouyuProxyConfig
from core.douyu.proxy import resolve_working_proxy, get_proxy_manager from core.douyu.proxy import resolve_working_proxy, get_proxy_manager
from ..models import Account as AccountModel, LoginTask, ProxyConfig as ProxyConfigModel from ..models import Account as AccountModel, LoginTask, ProxyConfig as ProxyConfigModel
from ..permissions import has_permission
class LoginBatchRunner: class LoginBatchRunner:
@@ -24,7 +23,7 @@ class LoginBatchRunner:
db: Session, db: Session,
account_ids: list[int], account_ids: list[int],
created_by: int, created_by: int,
creator_role: str, creator_permissions: list[str],
max_geetest_retries: int = 5, max_geetest_retries: int = 5,
max_proxy_retries: int = 10, max_proxy_retries: int = 10,
proxy_config: Optional[ProxyConfigModel] = None, proxy_config: Optional[ProxyConfigModel] = None,
@@ -35,7 +34,7 @@ class LoginBatchRunner:
self.db = db self.db = db
self.account_ids = account_ids self.account_ids = account_ids
self.created_by = created_by self.created_by = created_by
self.creator_role = creator_role self.creator_permissions = creator_permissions
self.max_geetest_retries = max_geetest_retries self.max_geetest_retries = max_geetest_retries
self.max_proxy_retries = max_proxy_retries self.max_proxy_retries = max_proxy_retries
self.proxy_config = proxy_config self.proxy_config = proxy_config
@@ -175,87 +174,91 @@ class LoginBatchRunner:
concurrency = self.concurrency concurrency = self.concurrency
self._push_log("info", f"批量登录任务 {batch_id} 开始,共 {len(self.account_ids)} 个账号,并发数: {concurrency}") self._push_log("info", f"批量登录任务 {batch_id} 开始,共 {len(self.account_ids)} 个账号,并发数: {concurrency}")
# 创建或复用任务记录(顺序执行,线程安全) try:
task_infos: list[dict] = [] # {task_id, acc_info} # 创建或复用任务记录(顺序执行,线程安全)
for aid in self.account_ids: task_infos: list[dict] = [] # {task_id, acc_info}
acc = self.db.query(AccountModel).filter(AccountModel.id == aid).first() for aid in self.account_ids:
if not acc: acc = self.db.query(AccountModel).filter(AccountModel.id == aid).first()
continue if not acc:
# 权限检查:客服只能跑分配给自己的
if not has_permission(self.creator_role, "login:view_all"):
if acc.assigned_to != self.created_by:
self._push_log("warning", f"跳过无权账号: {acc.username}")
continue continue
# 权限检查:客服只能跑分配给自己的
if "login:view_all" not in self.creator_permissions:
if acc.assigned_to != self.created_by:
self._push_log("warning", f"跳过无权账号: {acc.username}")
continue
# 复用该账号最近一条失败任务记录,避免重复产生多条 # 复用该账号最近一条失败任务记录,避免重复产生多条
existing_task = ( existing_task = (
self.db.query(LoginTask) self.db.query(LoginTask)
.filter(LoginTask.account_id == aid, LoginTask.status.in_(["failed", "error"])) .filter(LoginTask.account_id == aid, LoginTask.status.in_(["failed", "error"]))
.order_by(LoginTask.id.desc()) .order_by(LoginTask.id.desc())
.first() .first()
)
if existing_task:
existing_task.batch_id = batch_id
existing_task.status = "pending"
existing_task.cookie = ""
existing_task.message = ""
existing_task.finished_at = None
task = existing_task
else:
task = LoginTask(
batch_id=batch_id,
account_id=aid,
status="pending",
created_by=self.created_by,
) )
self.db.add(task) if existing_task:
existing_task.batch_id = batch_id
existing_task.status = "pending"
existing_task.cookie = ""
existing_task.message = ""
existing_task.finished_at = None
task = existing_task
else:
task = LoginTask(
batch_id=batch_id,
account_id=aid,
status="pending",
created_by=self.created_by,
)
self.db.add(task)
self.db.flush() # 获取 task.id self.db.flush() # 获取 task.id
task_infos.append({ task_infos.append({
"task_id": task.id, "task_id": task.id,
"acc_info": { "acc_info": {
"username": acc.username, "username": acc.username,
"password": acc.password, "password": acc.password,
"email": acc.email, "email": acc.email,
"email_password": acc.email_password, "email_password": acc.email_password,
"email_imap_server": acc.email_imap_server or "", "email_imap_server": acc.email_imap_server or "",
"email_imap_port": acc.email_imap_port or 993, "email_imap_port": acc.email_imap_port or 993,
"email_imap_ssl": acc.email_imap_ssl if acc.email_imap_ssl is not None else True, "email_imap_ssl": acc.email_imap_ssl if acc.email_imap_ssl is not None else True,
}, },
}) })
self.db.commit() self.db.commit()
total = len(task_infos) total = len(task_infos)
if total == 0: if total == 0:
self._push_log("warning", "没有可执行的账号") self._push_log("warning", "没有可执行的账号")
self._push_log("result", "")
return
# 并发执行登录,每个账号独立获取代理
with ThreadPoolExecutor(max_workers=concurrency) as executor:
futures = []
for item in task_infos:
if self._stop.is_set():
self._push_log("warning", "任务已停止,跳过剩余账号")
break
future = executor.submit(
self._execute_one,
item["task_id"],
item["acc_info"],
total,
)
futures.append(future)
# 等待所有任务完成
for future in as_completed(futures):
try:
future.result()
except Exception as e:
self._push_log("error", f"Worker 异常: {e}")
self._push_log("info", f"批量登录任务 {batch_id} 完成")
self._push_log("result", "") self._push_log("result", "")
return finally:
# 确保 DB Session 被关闭,避免连接泄漏
# 并发执行登录,每个账号独立获取代理 self.db.close()
with ThreadPoolExecutor(max_workers=concurrency) as executor:
futures = []
for item in task_infos:
if self._stop.is_set():
self._push_log("warning", "任务已停止,跳过剩余账号")
break
future = executor.submit(
self._execute_one,
item["task_id"],
item["acc_info"],
total,
)
futures.append(future)
# 等待所有任务完成
for future in as_completed(futures):
try:
future.result()
except Exception as e:
self._push_log("error", f"Worker 异常: {e}")
self._push_log("info", f"批量登录任务 {batch_id} 完成")
self._push_log("result", "")
# 在模块末尾导入 SessionLocal(避免循环导入) # 在模块末尾导入 SessionLocal(避免循环导入)
+2 -2
View File
@@ -12,14 +12,14 @@ import ProxyPage from './pages/ProxyPage';
import UsersPage from './pages/UsersPage'; import UsersPage from './pages/UsersPage';
import CookiePage from './pages/CookiePage'; import CookiePage from './pages/CookiePage';
import HttpLogsPage from './pages/HttpLogsPage'; import HttpLogsPage from './pages/HttpLogsPage';
import { getToken } from './store/auth'; import { getUser } from './store/auth';
import { ThemeProvider, useTheme } from './store/theme'; import { ThemeProvider, useTheme } from './store/theme';
function AppContent() { function AppContent() {
// 用 state 驱动重渲染,登录/登出时调 refreshAuth() // 用 state 驱动重渲染,登录/登出时调 refreshAuth()
const [authVersion, setAuthVersion] = useState(0); const [authVersion, setAuthVersion] = useState(0);
const refreshAuth = useCallback(() => setAuthVersion((v) => v + 1), []); const refreshAuth = useCallback(() => setAuthVersion((v) => v + 1), []);
const isLoggedIn = !!getToken(); const isLoggedIn = !!getUser();
const { isDark } = useTheme(); const { isDark } = useTheme();
return ( return (
+1 -10
View File
@@ -3,15 +3,7 @@ import axios from 'axios';
const api = axios.create({ const api = axios.create({
baseURL: '/api', baseURL: '/api',
timeout: 30000, timeout: 30000,
}); withCredentials: true, // 携带 httpOnly cookie
// 请求拦截:携带 token
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
}); });
// 响应拦截:统一错误处理 // 响应拦截:统一错误处理
@@ -19,7 +11,6 @@ api.interceptors.response.use(
(response) => response.data, (response) => response.data,
(error) => { (error) => {
if (error.response?.status === 401) { if (error.response?.status === 401) {
localStorage.removeItem('token');
localStorage.removeItem('user'); localStorage.removeItem('user');
window.location.href = '/login'; window.location.href = '/login';
} }
+1 -1
View File
@@ -23,7 +23,7 @@ export default function LoginPage({ onLogin }: { onLogin?: () => void }) {
role: result.role, role: result.role,
permissions: result.permissions, permissions: result.permissions,
}; };
setAuth(result.access_token, user); setAuth(user);
message.success('登录成功'); message.success('登录成功');
onLogin?.(); // 触发 App 重渲染 onLogin?.(); // 触发 App 重渲染
navigate('/', { replace: true }); navigate('/', { replace: true });
+7 -10
View File
@@ -8,12 +8,10 @@ export interface AuthUser {
permissions: string[]; permissions: string[];
} }
const TOKEN_KEY = 'token';
const USER_KEY = 'user'; const USER_KEY = 'user';
export function getToken(): string | null { // Token 存储在 httpOnly cookie 中,JS 无法读取,防 XSS 窃取
return localStorage.getItem(TOKEN_KEY); // 前端仅用 localStorage 存储用户信息(非敏感),用于判断登录状态和显示
}
export function getUser(): AuthUser | null { export function getUser(): AuthUser | null {
const raw = localStorage.getItem(USER_KEY); const raw = localStorage.getItem(USER_KEY);
@@ -25,28 +23,27 @@ export function getUser(): AuthUser | null {
} }
} }
export function setAuth(token: string, user: AuthUser) { export function setAuth(user: AuthUser) {
localStorage.setItem(TOKEN_KEY, token);
localStorage.setItem(USER_KEY, JSON.stringify(user)); localStorage.setItem(USER_KEY, JSON.stringify(user));
} }
export function clearAuth() { export function clearAuth() {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_KEY); localStorage.removeItem(USER_KEY);
} }
export async function fetchCurrentUser(): Promise<AuthUser | null> { export async function fetchCurrentUser(): Promise<AuthUser | null> {
const token = getToken(); // 通过 /me 接口验证 cookie 是否有效
if (!token) return null;
try { try {
const data = await authApi.me(); const data = await authApi.me();
return { const user: AuthUser = {
id: data.id, id: data.id,
username: data.username, username: data.username,
role: data.role, role: data.role,
role_label: data.role_label, role_label: data.role_label,
permissions: data.permissions, permissions: data.permissions,
}; };
setAuth(user);
return user;
} catch { } catch {
clearAuth(); clearAuth();
return null; return null;