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.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)
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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.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,
|
||||||
|
|||||||
@@ -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="更新时间")
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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 {
|
||||||
|
"success": False,
|
||||||
|
"message": "密码错误次数过多,账户已锁定15分钟"
|
||||||
|
}
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
remaining_attempts = 5 - user.failed_login_attempts
|
||||||
|
logger.warning(f"别名登录失败:用户 {alias} 密码错误,剩余尝试次数: {remaining_attempts}")
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "用户名或密码错误"
|
"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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user