mirror of
https://github.com/Cccc-owo/CheckInApp.git
synced 2026-06-17 05:56:29 +00:00
feat: add account locking and rate limit
This commit is contained in:
@@ -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)
|
||||
):
|
||||
"""
|
||||
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
|
||||
@@ -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="更新时间")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user