diff --git a/backend/api/auth.py b/backend/api/auth.py index 5e5a0f9..ab211fd 100644 --- a/backend/api/auth.py +++ b/backend/api/auth.py @@ -13,11 +13,13 @@ from backend.schemas.auth import ( ) from backend.services.auth_service import AuthService from backend.exceptions import BusinessLogicError +from backend.limiter import limiter router = APIRouter() @router.post("/request_qrcode", response_model=dict, summary="请求 QQ 扫码二维码") +@limiter.limit("10/minute") # 每分钟最多10次请求 async def request_qrcode( request_obj: QRCodeRequest, req: Request, @@ -156,8 +158,10 @@ async def verify_token( @router.post("/alias_login", response_model=dict, summary="别名+密码登录") +@limiter.limit("5/minute") # 每分钟最多5次登录尝试 async def alias_login( request: AliasLoginRequest, + req: Request, db: Session = Depends(get_db) ): """ diff --git a/backend/limiter.py b/backend/limiter.py new file mode 100644 index 0000000..263f9a6 --- /dev/null +++ b/backend/limiter.py @@ -0,0 +1,45 @@ +""" +速率限制器配置 + +支持Cloudflare Tunnel和其他代理服务 +""" +from slowapi import Limiter +from fastapi import Request + + +def get_real_ip(request: Request) -> str: + """ + 获取用户真实IP地址(支持Cloudflare Tunnel) + + Cloudflare会设置以下请求头: + - CF-Connecting-IP: 用户真实IP (最可靠) + - X-Forwarded-For: 代理链中的IP列表 + - X-Real-IP: 原始请求IP + + 优先级: + 1. CF-Connecting-IP (Cloudflare专用,最可靠) + 2. X-Real-IP (Nginx/通用代理) + 3. X-Forwarded-For (标准代理头) + 4. request.client.host (直连) + """ + # Cloudflare Tunnel / Cloudflare CDN + cf_connecting_ip = request.headers.get("CF-Connecting-IP") + if cf_connecting_ip: + return cf_connecting_ip + + # Nginx或其他反向代理 + x_real_ip = request.headers.get("X-Real-IP") + if x_real_ip: + return x_real_ip + + # 标准代理头(取第一个IP) + x_forwarded_for = request.headers.get("X-Forwarded-For") + if x_forwarded_for: + return x_forwarded_for.split(",")[0].strip() + + # 直连(无代理) + return request.client.host if request.client else "unknown" + + +# 初始化速率限制器,使用自定义IP获取函数 +limiter = Limiter(key_func=get_real_ip) diff --git a/backend/main.py b/backend/main.py index d329d66..6a54ac4 100644 --- a/backend/main.py +++ b/backend/main.py @@ -11,6 +11,7 @@ from backend.config import settings from backend.models import init_db from backend.exceptions import BaseAPIException from backend.schemas.response import ErrorResponse, ErrorDetail +from backend.limiter import limiter # 配置日志 settings.LOG_FILE.parent.mkdir(parents=True, exist_ok=True) @@ -64,6 +65,9 @@ app = FastAPI( lifespan=lifespan, ) +# 绑定速率限制器到应用 +app.state.limiter = limiter + # 配置 CORS app.add_middleware( CORSMiddleware, diff --git a/backend/models/user.py b/backend/models/user.py index 614017b..f49ba52 100644 --- a/backend/models/user.py +++ b/backend/models/user.py @@ -20,6 +20,12 @@ class User(Base): token_expired_notified = Column(Boolean, default=False, nullable=False, comment="Token 已过期提醒是否已发送(过期后30分钟内)") role = Column(String(20), default="user", index=True, comment="角色: user/admin") is_approved = Column(Boolean, default=False, index=True, comment="是否已通过管理员审批") + + # 账户锁定相关字段 + failed_login_attempts = Column(Integer, default=0, nullable=False, comment="连续登录失败次数") + locked_until = Column(DateTime(timezone=True), nullable=True, comment="账户锁定到期时间") + last_failed_login = Column(DateTime(timezone=True), nullable=True, comment="最后一次登录失败时间") + created_at = Column(DateTime(timezone=True), server_default=func.now(), comment="创建时间") updated_at = Column(DateTime(timezone=True), onupdate=func.now(), comment="更新时间") diff --git a/backend/requirements.txt b/backend/requirements.txt index dd8e08d..cbc75bf 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -13,6 +13,7 @@ python-dotenv>=1.0.1 # Authentication & Security pyjwt>=2.10.1 bcrypt>=4.2.2 +slowapi>=0.1.9 # Task Scheduling apscheduler>=3.10.4 diff --git a/backend/scripts/migrate_add_account_lockout.py b/backend/scripts/migrate_add_account_lockout.py new file mode 100644 index 0000000..3c11efe --- /dev/null +++ b/backend/scripts/migrate_add_account_lockout.py @@ -0,0 +1,78 @@ +""" +数据库迁移脚本:添加账户锁定相关字段 + +添加字段: +- failed_login_attempts: 连续登录失败次数 +- locked_until: 账户锁定到期时间 +- last_failed_login: 最后一次登录失败时间 + +运行方式: + python -m backend.scripts.migrate_add_account_lockout +""" + +import sys +from pathlib import Path + +# 添加项目根目录到 Python 路径 +project_root = Path(__file__).resolve().parent.parent.parent +sys.path.insert(0, str(project_root)) + +from sqlalchemy import text +from backend.models.database import engine +import logging + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +def migrate(): + """执行迁移""" + logger.info("开始迁移:添加账户锁定相关字段...") + + with engine.connect() as conn: + # 检查字段是否已存在 + result = conn.execute(text("PRAGMA table_info(users)")) + columns = [row[1] for row in result] + + # 添加 failed_login_attempts 字段 + if 'failed_login_attempts' not in columns: + logger.info("添加 failed_login_attempts 字段...") + conn.execute(text( + "ALTER TABLE users ADD COLUMN failed_login_attempts INTEGER DEFAULT 0 NOT NULL" + )) + conn.commit() + logger.info("✓ failed_login_attempts 字段添加成功") + else: + logger.info("✓ failed_login_attempts 字段已存在,跳过") + + # 添加 locked_until 字段 + if 'locked_until' not in columns: + logger.info("添加 locked_until 字段...") + conn.execute(text( + "ALTER TABLE users ADD COLUMN locked_until DATETIME" + )) + conn.commit() + logger.info("✓ locked_until 字段添加成功") + else: + logger.info("✓ locked_until 字段已存在,跳过") + + # 添加 last_failed_login 字段 + if 'last_failed_login' not in columns: + logger.info("添加 last_failed_login 字段...") + conn.execute(text( + "ALTER TABLE users ADD COLUMN last_failed_login DATETIME" + )) + conn.commit() + logger.info("✓ last_failed_login 字段添加成功") + else: + logger.info("✓ last_failed_login 字段已存在,跳过") + + logger.info("✅ 迁移完成!账户锁定功能已启用") + + +if __name__ == "__main__": + try: + migrate() + except Exception as e: + logger.error(f"❌ 迁移失败: {e}") + sys.exit(1) diff --git a/backend/services/auth_service.py b/backend/services/auth_service.py index 114225e..65d488b 100644 --- a/backend/services/auth_service.py +++ b/backend/services/auth_service.py @@ -458,6 +458,24 @@ class AuthService: "message": "用户名或密码错误" } + # 检查账户是否被锁定 + if user.locked_until: + # 如果锁定时间还未到期 + if datetime.now() < user.locked_until: + remaining_seconds = (user.locked_until - datetime.now()).total_seconds() + remaining_minutes = int(remaining_seconds / 60) + 1 + logger.warning(f"别名登录失败:用户 {alias} 账户已锁定,剩余 {remaining_minutes} 分钟") + return { + "success": False, + "message": f"账户已锁定,请 {remaining_minutes} 分钟后再试" + } + else: + # 锁定时间已过,重置锁定状态 + user.locked_until = None + user.failed_login_attempts = 0 + db.commit() + logger.info(f"用户 {alias} 的账户锁定已自动解除") + # 检查用户是否设置了密码 if not user.password_hash: logger.warning(f"别名登录失败:用户 {alias} 未设置密码") @@ -472,10 +490,26 @@ class AuthService: hash_bytes = user.password_hash.encode('utf-8') if not bcrypt.checkpw(password_bytes, hash_bytes): - logger.warning(f"别名登录失败:用户 {alias} 密码错误") + # 密码错误,增加失败次数 + user.failed_login_attempts = (user.failed_login_attempts or 0) + 1 + user.last_failed_login = datetime.now() + + # 如果失败次数达到5次,锁定账户15分钟 + if user.failed_login_attempts >= 5: + user.locked_until = datetime.now() + timedelta(minutes=15) + db.commit() + logger.warning(f"别名登录失败:用户 {alias} 密码错误次数过多,账户已锁定15分钟") + return { + "success": False, + "message": "密码错误次数过多,账户已锁定15分钟" + } + + db.commit() + remaining_attempts = 5 - user.failed_login_attempts + logger.warning(f"别名登录失败:用户 {alias} 密码错误,剩余尝试次数: {remaining_attempts}") return { "success": False, - "message": "用户名或密码错误" + "message": f"用户名或密码错误,剩余尝试次数: {remaining_attempts}" } except Exception as e: logger.error(f"密码验证异常:{e}") @@ -484,6 +518,12 @@ class AuthService: "message": "登录失败,请稍后重试" } + # 密码正确,重置失败次数 + user.failed_login_attempts = 0 + user.locked_until = None + user.last_failed_login = None + db.commit() + # 检查 Token 状态(仅作提示,不阻止登录) token_warning = None