feat: 强化打包配置与服务安全

This commit is contained in:
shen
2026-07-18 13:30:56 +08:00
parent d1f070b251
commit a48909f3bc
26 changed files with 779 additions and 203 deletions
+20 -1
View File
@@ -5,12 +5,31 @@ from .config import load_config
router = APIRouter(prefix="/api", tags=["apps"])
# 每个 App 都只能使用明确允许的 Scheme。
_APP_SCHEME_RULES = {
"申学": ("readoorShenXue",),
"申学APP": ("readoorShenXue",),
"英汉大词典测试": ("readoorDict",),
"英汉大词典": ("readoorDict",),
}
_DEFAULT_SCHEME_NAMES = ("readoor31", "readoor31OtherPay")
def get_allowed_scheme_names(app: dict) -> tuple[str, ...]:
"""返回 App 可使用的 Scheme 名称。"""
return _APP_SCHEME_RULES.get(app.get("name", ""), _DEFAULT_SCHEME_NAMES)
@router.get("/apps")
async def get_apps_for_build():
"""获取 apps 列表(供打包选择)"""
config = load_config()
return config.get("apps", {})
apps = {}
for app_id, app in config.get("apps", {}).items():
app_data = app.copy()
app_data["allowed_scheme_names"] = list(get_allowed_scheme_names(app))
apps[app_id] = app_data
return apps
@router.get("/schemes")
+47 -9
View File
@@ -1,16 +1,24 @@
"""认证 API"""
import jwt
import threading
import time
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.orm import Session
from ..schemas import LoginRequest, LoginResponse
from ..config import JWT_SECRET, JWT_ALGORITHM, JWT_EXPIRE_HOURS
from ..config import (
JWT_SECRET, JWT_ALGORITHM, JWT_EXPIRE_HOURS,
LOGIN_RATE_LIMIT_MAX_ATTEMPTS, LOGIN_RATE_LIMIT_WINDOW_SECONDS, LOGIN_RATE_LIMIT_LOCKOUT_SECONDS,
)
from ..deps import get_current_user
from ..database import get_db
from ..models import User
from ..security import hash_password, verify_password
router = APIRouter(prefix="/api/auth", tags=["auth"])
_login_attempts: dict[str, tuple[int, float, float]] = {}
_login_attempts_lock = threading.Lock()
def _create_token(username: str, is_admin: bool) -> str:
@@ -23,19 +31,49 @@ def _create_token(username: str, is_admin: bool) -> str:
return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
def _verify_password(password: str, password_hash: str) -> bool:
"""校验密码"""
import hashlib
return hashlib.sha256(password.encode()).hexdigest() == password_hash
def _check_login_rate_limit(client_ip: str) -> None:
"""按来源 IP 限制登录失败次数,降低在线暴力破解风险。"""
now = time.monotonic()
with _login_attempts_lock:
attempts, first_failure, locked_until = _login_attempts.get(client_ip, (0, now, 0))
if locked_until > now:
raise HTTPException(status_code=429, detail="登录失败次数过多,请稍后再试")
if now - first_failure > LOGIN_RATE_LIMIT_WINDOW_SECONDS:
_login_attempts.pop(client_ip, None)
def _record_login_failure(client_ip: str) -> None:
now = time.monotonic()
with _login_attempts_lock:
attempts, first_failure, _ = _login_attempts.get(client_ip, (0, now, 0))
if now - first_failure > LOGIN_RATE_LIMIT_WINDOW_SECONDS:
attempts, first_failure = 0, now
attempts += 1
locked_until = now + LOGIN_RATE_LIMIT_LOCKOUT_SECONDS if attempts >= LOGIN_RATE_LIMIT_MAX_ATTEMPTS else 0
_login_attempts[client_ip] = (attempts, first_failure, locked_until)
def _clear_login_failures(client_ip: str) -> None:
with _login_attempts_lock:
_login_attempts.pop(client_ip, None)
@router.post("/login", response_model=LoginResponse)
async def login(request: LoginRequest, db: Session = Depends(get_db)):
async def login(credentials: LoginRequest, request: Request, db: Session = Depends(get_db)):
"""用户登录"""
user = db.query(User).filter(User.username == request.username).first()
if not user or not _verify_password(request.password, user.password_hash):
client_ip = request.client.host if request.client else "unknown"
_check_login_rate_limit(client_ip)
user = db.query(User).filter(User.username == credentials.username).first()
valid, needs_upgrade = verify_password(credentials.password, user.password_hash) if user else (False, False)
if not user or not valid:
_record_login_failure(client_ip)
raise HTTPException(status_code=401, detail="用户名或密码错误")
if needs_upgrade:
user.password_hash = hash_password(credentials.password)
db.commit()
_clear_login_failures(client_ip)
token = _create_token(user.username, user.is_admin)
return LoginResponse(token=token, username=user.username, is_admin=user.is_admin)
+12 -3
View File
@@ -6,7 +6,7 @@ import shutil
import zipfile
from datetime import datetime
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, File
from sqlalchemy.orm import Session
from ..database import get_db
@@ -21,9 +21,18 @@ from ..config import (
DEFAULT_BUILD_DIR_RETENTION_HOURS,
SKINS_DIR,
)
from ..deps import get_current_user
from ..deps import get_current_user, require_admin
router = APIRouter(prefix="/api/config", tags=["config"], dependencies=[Depends(get_current_user)])
def _require_config_permission(request: Request, user: dict = Depends(get_current_user)) -> dict:
"""普通用户仅可管理 Apps;其余配置及凭据只允许管理员访问。"""
path = request.url.path.rstrip("/")
if path == "/api/config/apps" or path.startswith("/api/config/apps/"):
return user
return require_admin(user)
router = APIRouter(prefix="/api/config", tags=["config"], dependencies=[Depends(_require_config_permission)])
_config_lock = asyncio.Lock()
+15 -1
View File
@@ -34,6 +34,20 @@ async def create_task(task: TaskCreate, db: Session = Depends(get_db)):
app = apps[task.app_id]
scheme = schemes[task.scheme_id]
scheme_name = scheme.get("name", "")
if task.build_type not in {"Ad_Hoc", "App_Store"}:
raise HTTPException(status_code=400, detail="不支持的打包类型")
if not app.get("certificates", {}).get(task.build_type):
raise HTTPException(status_code=400, detail=f"该 App 未配置 {task.build_type} 证书")
from .apps import get_allowed_scheme_names
allowed_scheme_names = get_allowed_scheme_names(app)
if allowed_scheme_names and scheme_name not in allowed_scheme_names:
raise HTTPException(
status_code=400,
detail=f"该 App 只能使用 Scheme: {', '.join(allowed_scheme_names)}",
)
task_id = str(uuid.uuid4())
db_task = Task(
@@ -42,7 +56,7 @@ async def create_task(task: TaskCreate, db: Session = Depends(get_db)):
app_name=app.get("name", ""),
build_type=task.build_type,
scheme_id=task.scheme_id,
scheme_name=scheme.get("displayName") or scheme.get("name", ""),
scheme_name=scheme.get("displayName") or scheme_name,
obfuscation=task.obfuscation,
branch=task.branch,
status="pending",
+7 -11
View File
@@ -1,12 +1,12 @@
"""用户管理 API(仅管理员)"""
import uuid
import hashlib
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from ..database import get_db
from ..models import User
from ..deps import get_current_user
from ..security import hash_password
router = APIRouter(prefix="/api/users", tags=["users"], dependencies=[Depends(get_current_user)])
@@ -16,10 +16,6 @@ def _require_admin(user: dict):
raise HTTPException(status_code=403, detail="需要管理员权限")
def _hash_password(password: str) -> str:
return hashlib.sha256(password.encode()).hexdigest()
@router.get("")
async def list_users(user: dict = Depends(get_current_user), db: Session = Depends(get_db)):
"""获取用户列表"""
@@ -39,15 +35,15 @@ async def create_user(data: dict, user: dict = Depends(get_current_user), db: Se
if not username or not password:
raise HTTPException(status_code=400, detail="用户名和密码不能为空")
if len(password) < 6:
raise HTTPException(status_code=400, detail="密码至少 6")
if len(password) < 12:
raise HTTPException(status_code=400, detail="密码至少 12")
if db.query(User).filter(User.username == username).first():
raise HTTPException(status_code=400, detail="用户名已存在")
new_user = User(
id=str(uuid.uuid4()),
username=username,
password_hash=_hash_password(password),
password_hash=hash_password(password),
is_admin=is_admin,
)
db.add(new_user)
@@ -65,10 +61,10 @@ async def change_password(user_id: str, data: dict, user: dict = Depends(get_cur
raise HTTPException(status_code=404, detail="用户不存在")
new_password = data.get("password", "").strip()
if not new_password or len(new_password) < 6:
raise HTTPException(status_code=400, detail="密码至少 6")
if not new_password or len(new_password) < 12:
raise HTTPException(status_code=400, detail="密码至少 12")
target.password_hash = _hash_password(new_password)
target.password_hash = hash_password(new_password)
db.commit()
return {"message": "密码已更新"}