feat: add account locking and rate limit

This commit is contained in:
2026-01-06 22:02:58 +08:00
parent 105029a9f4
commit 9295e7c7be
7 changed files with 180 additions and 2 deletions
+4
View File
@@ -13,11 +13,13 @@ from backend.schemas.auth import (
) )
from backend.services.auth_service import AuthService from backend.services.auth_service import AuthService
from backend.exceptions import BusinessLogicError from backend.exceptions import BusinessLogicError
from backend.limiter import limiter
router = APIRouter() router = APIRouter()
@router.post("/request_qrcode", response_model=dict, summary="请求 QQ 扫码二维码") @router.post("/request_qrcode", response_model=dict, summary="请求 QQ 扫码二维码")
@limiter.limit("10/minute") # 每分钟最多10次请求
async def request_qrcode( async def request_qrcode(
request_obj: QRCodeRequest, request_obj: QRCodeRequest,
req: Request, req: Request,
@@ -156,8 +158,10 @@ async def verify_token(
@router.post("/alias_login", response_model=dict, summary="别名+密码登录") @router.post("/alias_login", response_model=dict, summary="别名+密码登录")
@limiter.limit("5/minute") # 每分钟最多5次登录尝试
async def alias_login( async def alias_login(
request: AliasLoginRequest, request: AliasLoginRequest,
req: Request,
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
""" """
+45
View File
@@ -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)
+4
View File
@@ -11,6 +11,7 @@ from backend.config import settings
from backend.models import init_db from backend.models import init_db
from backend.exceptions import BaseAPIException from backend.exceptions import BaseAPIException
from backend.schemas.response import ErrorResponse, ErrorDetail from backend.schemas.response import ErrorResponse, ErrorDetail
from backend.limiter import limiter
# 配置日志 # 配置日志
settings.LOG_FILE.parent.mkdir(parents=True, exist_ok=True) settings.LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
@@ -64,6 +65,9 @@ app = FastAPI(
lifespan=lifespan, lifespan=lifespan,
) )
# 绑定速率限制器到应用
app.state.limiter = limiter
# 配置 CORS # 配置 CORS
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
+6
View File
@@ -20,6 +20,12 @@ class User(Base):
token_expired_notified = Column(Boolean, default=False, nullable=False, comment="Token 已过期提醒是否已发送(过期后30分钟内)") token_expired_notified = Column(Boolean, default=False, nullable=False, comment="Token 已过期提醒是否已发送(过期后30分钟内)")
role = Column(String(20), default="user", index=True, comment="角色: user/admin") role = Column(String(20), default="user", index=True, comment="角色: user/admin")
is_approved = Column(Boolean, default=False, index=True, comment="是否已通过管理员审批") 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="创建时间") created_at = Column(DateTime(timezone=True), server_default=func.now(), comment="创建时间")
updated_at = Column(DateTime(timezone=True), onupdate=func.now(), comment="更新时间") updated_at = Column(DateTime(timezone=True), onupdate=func.now(), comment="更新时间")
+1
View File
@@ -13,6 +13,7 @@ python-dotenv>=1.0.1
# Authentication & Security # Authentication & Security
pyjwt>=2.10.1 pyjwt>=2.10.1
bcrypt>=4.2.2 bcrypt>=4.2.2
slowapi>=0.1.9
# Task Scheduling # Task Scheduling
apscheduler>=3.10.4 apscheduler>=3.10.4
@@ -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)
+42 -2
View File
@@ -458,6 +458,24 @@ class AuthService:
"message": "用户名或密码错误" "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: if not user.password_hash:
logger.warning(f"别名登录失败:用户 {alias} 未设置密码") logger.warning(f"别名登录失败:用户 {alias} 未设置密码")
@@ -472,10 +490,26 @@ class AuthService:
hash_bytes = user.password_hash.encode('utf-8') hash_bytes = user.password_hash.encode('utf-8')
if not bcrypt.checkpw(password_bytes, hash_bytes): 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 { return {
"success": False, "success": False,
"message": "用户名或密码错误" "message": "密码错误次数过多,账户已锁定15分钟"
}
db.commit()
remaining_attempts = 5 - user.failed_login_attempts
logger.warning(f"别名登录失败:用户 {alias} 密码错误,剩余尝试次数: {remaining_attempts}")
return {
"success": False,
"message": f"用户名或密码错误,剩余尝试次数: {remaining_attempts}"
} }
except Exception as e: except Exception as e:
logger.error(f"密码验证异常:{e}") logger.error(f"密码验证异常:{e}")
@@ -484,6 +518,12 @@ class AuthService:
"message": "登录失败,请稍后重试" "message": "登录失败,请稍后重试"
} }
# 密码正确,重置失败次数
user.failed_login_attempts = 0
user.locked_until = None
user.last_failed_login = None
db.commit()
# 检查 Token 状态(仅作提示,不阻止登录) # 检查 Token 状态(仅作提示,不阻止登录)
token_warning = None token_warning = None