feat: 强化打包配置与服务安全
This commit is contained in:
+20
-1
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "密码已更新"}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user