mirror of
https://github.com/handsomezhuzhu/QQuiz.git
synced 2026-02-20 12:00:14 +00:00
Document secure secrets and prune unused assets
This commit is contained in:
@@ -86,6 +86,10 @@ async def init_default_config(db: AsyncSession):
|
||||
"ai_provider": os.getenv("AI_PROVIDER", "openai"),
|
||||
}
|
||||
|
||||
admin_password = os.getenv("ADMIN_PASSWORD")
|
||||
if not admin_password or len(admin_password) < 12:
|
||||
raise ValueError("ADMIN_PASSWORD must be set and at least 12 characters long")
|
||||
|
||||
for key, value in default_configs.items():
|
||||
result = await db.execute(select(SystemConfig).where(SystemConfig.key == key))
|
||||
existing = result.scalar_one_or_none()
|
||||
@@ -99,15 +103,32 @@ async def init_default_config(db: AsyncSession):
|
||||
result = await db.execute(select(User).where(User.username == "admin"))
|
||||
admin = result.scalar_one_or_none()
|
||||
|
||||
default_admin_id = admin.id if admin else None
|
||||
|
||||
if not admin:
|
||||
admin_user = User(
|
||||
username="admin",
|
||||
hashed_password=pwd_context.hash("admin123"), # Change this password!
|
||||
hashed_password=pwd_context.hash(admin_password),
|
||||
is_admin=True
|
||||
)
|
||||
db.add(admin_user)
|
||||
print("✅ Created default admin user (username: admin, password: admin123)")
|
||||
print("⚠️ IMPORTANT: Please change the admin password immediately!")
|
||||
await db.commit()
|
||||
await db.refresh(admin_user)
|
||||
default_admin_id = admin_user.id
|
||||
print("✅ Created default admin user (username: admin)")
|
||||
else:
|
||||
await db.commit()
|
||||
|
||||
if default_admin_id is not None:
|
||||
result = await db.execute(
|
||||
select(SystemConfig).where(SystemConfig.key == "default_admin_id")
|
||||
)
|
||||
default_admin_config = result.scalar_one_or_none()
|
||||
|
||||
if not default_admin_config:
|
||||
db.add(SystemConfig(key="default_admin_id", value=str(default_admin_id)))
|
||||
elif default_admin_config.value != str(default_admin_id):
|
||||
default_admin_config.value = str(default_admin_id)
|
||||
|
||||
await db.commit()
|
||||
|
||||
|
||||
@@ -4,18 +4,28 @@ QQuiz FastAPI Application - 单容器模式(前后端整合)
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import HTMLResponse, FileResponse
|
||||
from fastapi.responses import HTMLResponse, FileResponse, JSONResponse
|
||||
from contextlib import asynccontextmanager
|
||||
import os
|
||||
from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
from slowapi.errors import RateLimitExceeded
|
||||
from slowapi.middleware import SlowAPIMiddleware
|
||||
|
||||
from database import init_db, init_default_config, get_db_context
|
||||
from rate_limit import limiter
|
||||
|
||||
# Load environment variables
|
||||
load_dotenv()
|
||||
|
||||
|
||||
async def rate_limit_exceeded_handler(request: Request, exc: RateLimitExceeded):
|
||||
return JSONResponse(
|
||||
status_code=429,
|
||||
content={"detail": "Rate limit exceeded. Please try again later."}
|
||||
)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Application lifespan events"""
|
||||
@@ -50,6 +60,10 @@ app = FastAPI(
|
||||
lifespan=lifespan
|
||||
)
|
||||
|
||||
app.state.limiter = limiter
|
||||
app.add_exception_handler(RateLimitExceeded, rate_limit_exceeded_handler)
|
||||
app.add_middleware(SlowAPIMiddleware)
|
||||
|
||||
# Configure CORS
|
||||
cors_origins = os.getenv("CORS_ORIGINS", "http://localhost:3000").split(",")
|
||||
app.add_middleware(
|
||||
|
||||
10
backend/rate_limit.py
Normal file
10
backend/rate_limit.py
Normal file
@@ -0,0 +1,10 @@
|
||||
"""Rate limiting utilities for the FastAPI application."""
|
||||
|
||||
from slowapi import Limiter
|
||||
from slowapi.util import get_remote_address
|
||||
|
||||
|
||||
limiter = Limiter(key_func=get_remote_address)
|
||||
|
||||
|
||||
__all__ = ["limiter"]
|
||||
@@ -12,6 +12,7 @@ python-multipart==0.0.6
|
||||
passlib==1.7.4
|
||||
bcrypt==4.0.1
|
||||
python-jose[cryptography]==3.3.0
|
||||
python-magic==0.4.27
|
||||
aiofiles==23.2.1
|
||||
httpx==0.26.0
|
||||
openai==1.10.0
|
||||
@@ -19,3 +20,4 @@ anthropic==0.8.1
|
||||
python-docx==1.1.0
|
||||
PyPDF2==3.0.1
|
||||
openpyxl==3.1.2
|
||||
slowapi==0.1.9
|
||||
|
||||
@@ -24,6 +24,18 @@ router = APIRouter()
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
|
||||
async def get_default_admin_id(db: AsyncSession) -> Optional[int]:
|
||||
result = await db.execute(
|
||||
select(SystemConfig).where(SystemConfig.key == "default_admin_id")
|
||||
)
|
||||
config = result.scalar_one_or_none()
|
||||
|
||||
if config and config.value.isdigit():
|
||||
return int(config.value)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/config", response_model=SystemConfigResponse)
|
||||
async def get_system_config(
|
||||
current_admin: User = Depends(get_current_admin_user),
|
||||
@@ -203,8 +215,10 @@ async def update_user(
|
||||
detail="User not found"
|
||||
)
|
||||
|
||||
protected_admin_id = await get_default_admin_id(db)
|
||||
|
||||
# 不允许修改默认管理员的管理员状态
|
||||
if user.username == "admin" and user_data.is_admin is not None:
|
||||
if protected_admin_id and user.id == protected_admin_id and user_data.is_admin is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Cannot modify default admin user's admin status"
|
||||
@@ -240,8 +254,10 @@ async def delete_user(
|
||||
detail="User not found"
|
||||
)
|
||||
|
||||
protected_admin_id = await get_default_admin_id(db)
|
||||
|
||||
# 不允许删除默认管理员
|
||||
if user.username == "admin":
|
||||
if protected_admin_id and user.id == protected_admin_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Cannot delete default admin user"
|
||||
|
||||
@@ -5,14 +5,17 @@ from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from database import get_db
|
||||
from models import User, SystemConfig
|
||||
from schemas import UserCreate, UserLogin, Token, UserResponse
|
||||
from utils import hash_password, verify_password, create_access_token
|
||||
from rate_limit import limiter
|
||||
from services.auth_service import get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
||||
@@ -61,6 +64,7 @@ async def register(
|
||||
|
||||
|
||||
@router.post("/login", response_model=Token)
|
||||
@limiter.limit("5/minute")
|
||||
async def login(
|
||||
user_data: UserLogin,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
@@ -86,8 +90,7 @@ async def login(
|
||||
data={"sub": str(user.id)} # JWT 'sub' must be a string
|
||||
)
|
||||
|
||||
print(f"✅ Login successful: user={user.username}, id={user.id}")
|
||||
print(f"🔑 Generated token (first 50 chars): {access_token[:50]}...")
|
||||
logger.info("Login successful", extra={"user_id": user.id})
|
||||
|
||||
return {
|
||||
"access_token": access_token,
|
||||
|
||||
@@ -10,6 +10,7 @@ from datetime import datetime, timedelta
|
||||
import os
|
||||
import aiofiles
|
||||
import json
|
||||
import magic
|
||||
|
||||
from database import get_db
|
||||
from models import User, Exam, Question, ExamStatus, SystemConfig
|
||||
@@ -24,8 +25,57 @@ from services.config_service import load_llm_config
|
||||
from services.progress_service import progress_service
|
||||
from utils import is_allowed_file, calculate_content_hash
|
||||
from dedup_utils import is_duplicate_question
|
||||
from rate_limit import limiter
|
||||
|
||||
router = APIRouter()
|
||||
ALLOWED_MIME_TYPES = {
|
||||
"text/plain",
|
||||
"application/pdf",
|
||||
"application/msword",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"application/vnd.ms-excel",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
}
|
||||
|
||||
|
||||
async def validate_upload_file(file: UploadFile) -> None:
|
||||
"""Validate uploaded file by extension and MIME type."""
|
||||
|
||||
if not file.filename or not is_allowed_file(file.filename):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid file type. Allowed: txt, pdf, doc, docx, xlsx, xls",
|
||||
)
|
||||
|
||||
try:
|
||||
sample = await file.read(2048)
|
||||
except Exception:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Could not read uploaded file for validation",
|
||||
)
|
||||
|
||||
if not sample:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Uploaded file is empty",
|
||||
)
|
||||
|
||||
try:
|
||||
mime_type = magic.from_buffer(sample, mime=True)
|
||||
except Exception:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Could not determine file type",
|
||||
)
|
||||
finally:
|
||||
file.file.seek(0)
|
||||
|
||||
if mime_type not in ALLOWED_MIME_TYPES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid file content type",
|
||||
)
|
||||
|
||||
|
||||
async def check_upload_limits(user_id: int, file_size: int, db: AsyncSession):
|
||||
@@ -485,6 +535,7 @@ async def async_parse_and_save(
|
||||
|
||||
|
||||
@router.post("/create", response_model=ExamUploadResponse, status_code=status.HTTP_201_CREATED)
|
||||
@limiter.limit("10/minute")
|
||||
async def create_exam_with_upload(
|
||||
background_tasks: BackgroundTasks,
|
||||
title: str = Form(...),
|
||||
@@ -498,11 +549,7 @@ async def create_exam_with_upload(
|
||||
"""
|
||||
|
||||
# Validate file
|
||||
if not file.filename or not is_allowed_file(file.filename):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid file type. Allowed: txt, pdf, doc, docx, xlsx, xls"
|
||||
)
|
||||
await validate_upload_file(file)
|
||||
|
||||
# Read file content
|
||||
file_content = await file.read()
|
||||
@@ -538,6 +585,7 @@ async def create_exam_with_upload(
|
||||
|
||||
|
||||
@router.post("/{exam_id}/append", response_model=ExamUploadResponse)
|
||||
@limiter.limit("10/minute")
|
||||
async def append_document_to_exam(
|
||||
exam_id: int,
|
||||
background_tasks: BackgroundTasks,
|
||||
@@ -572,11 +620,7 @@ async def append_document_to_exam(
|
||||
)
|
||||
|
||||
# Validate file
|
||||
if not file.filename or not is_allowed_file(file.filename):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid file type. Allowed: txt, pdf, doc, docx, xlsx, xls"
|
||||
)
|
||||
await validate_upload_file(file)
|
||||
|
||||
# Read file content
|
||||
file_content = await file.read()
|
||||
|
||||
@@ -13,7 +13,10 @@ import os
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
# JWT settings
|
||||
SECRET_KEY = os.getenv("SECRET_KEY", "your-secret-key-change-this")
|
||||
SECRET_KEY = os.getenv("SECRET_KEY")
|
||||
if not SECRET_KEY or len(SECRET_KEY) < 32:
|
||||
raise ValueError("SECRET_KEY must be set and at least 32 characters long")
|
||||
|
||||
ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7 days
|
||||
|
||||
|
||||
Reference in New Issue
Block a user