diff --git a/server/db_manager.py b/server/db_manager.py
index d9f1dac7..7b835d51 100644
--- a/server/db_manager.py
+++ b/server/db_manager.py
@@ -7,6 +7,7 @@ from sqlalchemy.orm import sessionmaker
from server.models import Base
from server.models.user_model import User
+from server.utils.migrate import check_and_migrate
from src import config
from src.utils import logger
@@ -24,8 +25,11 @@ class DBManager:
# 创建会话工厂
self.Session = sessionmaker(bind=self.engine)
- # 确保表存在
+ # 首先创建基本表结构
self.create_tables()
+
+ # 然后检查并执行数据库迁移
+ self.run_migrations()
def ensure_db_dir(self):
"""确保数据库目录存在"""
@@ -37,6 +41,18 @@ class DBManager:
# 确保所有表都会被创建
Base.metadata.create_all(self.engine)
logger.info("Database tables created/checked")
+
+ def run_migrations(self):
+ """运行数据库迁移"""
+ try:
+ success = check_and_migrate(self.db_path)
+ if success:
+ logger.info("数据库迁移检查完成")
+ else:
+ logger.warning("数据库迁移过程中出现问题")
+ except Exception as e:
+ logger.error(f"数据库迁移失败: {e}")
+ # 不抛出异常,以防止阻断应用启动
def get_session(self):
"""获取数据库会话"""
diff --git a/server/models/user_model.py b/server/models/user_model.py
index fc937787..97ea1f9e 100644
--- a/server/models/user_model.py
+++ b/server/models/user_model.py
@@ -16,6 +16,11 @@ class User(Base):
role = Column(String, nullable=False, default="user") # 角色: superadmin, admin, user
created_at = Column(DateTime, default=func.now())
last_login = Column(DateTime, nullable=True)
+
+ # 登录失败限制相关字段
+ login_failed_count = Column(Integer, nullable=False, default=0) # 登录失败次数
+ last_failed_login = Column(DateTime, nullable=True) # 最后一次登录失败时间
+ login_locked_until = Column(DateTime, nullable=True) # 锁定到什么时候
# 关联操作日志
operation_logs = relationship("OperationLog", back_populates="user")
@@ -27,10 +32,56 @@ class User(Base):
"role": self.role,
"created_at": self.created_at.isoformat() if self.created_at else None,
"last_login": self.last_login.isoformat() if self.last_login else None,
+ "login_failed_count": self.login_failed_count,
+ "last_failed_login": self.last_failed_login.isoformat() if self.last_failed_login else None,
+ "login_locked_until": self.login_locked_until.isoformat() if self.login_locked_until else None,
}
if include_password:
result["password_hash"] = self.password_hash
return result
+
+ def is_login_locked(self):
+ """检查用户是否处于登录锁定状态"""
+ if self.login_locked_until is None:
+ return False
+ from datetime import datetime
+ return datetime.now() < self.login_locked_until
+
+ def get_remaining_lock_time(self):
+ """获取剩余锁定时间(秒)"""
+ if not self.is_login_locked():
+ return 0
+ from datetime import datetime
+ return int((self.login_locked_until - datetime.now()).total_seconds())
+
+ def calculate_lock_duration(self):
+ """根据失败次数计算锁定时长(秒)"""
+ if self.login_failed_count < 10:
+ return 0
+
+ # 从第10次失败开始,等待时间从1秒开始,每次翻倍
+ wait_seconds = 2 ** (self.login_failed_count - 10)
+
+ # 最大锁定时间:365天
+ max_seconds = 365 * 24 * 60 * 60
+ return min(wait_seconds, max_seconds)
+
+ def increment_failed_login(self):
+ """增加登录失败次数并设置锁定时间"""
+ from datetime import datetime, timedelta
+
+ self.login_failed_count += 1
+ self.last_failed_login = datetime.now()
+
+ lock_duration = self.calculate_lock_duration()
+ if lock_duration > 0:
+ self.login_locked_until = datetime.now() + timedelta(seconds=lock_duration)
+
+ def reset_failed_login(self):
+ """重置登录失败相关字段"""
+ self.login_failed_count = 0
+ self.last_failed_login = None
+ self.login_locked_until = None
class OperationLog(Base):
diff --git a/server/routers/auth_router.py b/server/routers/auth_router.py
index ed1db113..34389661 100644
--- a/server/routers/auth_router.py
+++ b/server/routers/auth_router.py
@@ -65,15 +65,55 @@ async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends(
# 查找用户
user = db.query(User).filter(User.username == form_data.username).first()
- # 验证用户存在且密码正确
- if not user or not AuthUtils.verify_password(user.password_hash, form_data.password):
+ # 如果用户不存在,为防止用户名枚举攻击,返回通用错误信息
+ if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="用户名或密码错误",
headers={"WWW-Authenticate": "Bearer"},
)
+
+ # 检查用户是否处于登录锁定状态
+ if user.is_login_locked():
+ remaining_time = user.get_remaining_lock_time()
+ raise HTTPException(
+ status_code=status.HTTP_423_LOCKED,
+ detail=f"登录被锁定,请等待 {remaining_time} 秒后再试",
+ headers={
+ "WWW-Authenticate": "Bearer",
+ "X-Lock-Remaining": str(remaining_time)
+ },
+ )
- # 更新最后登录时间
+ # 验证密码
+ if not AuthUtils.verify_password(user.password_hash, form_data.password):
+ # 密码错误,增加失败次数
+ user.increment_failed_login()
+ db.commit()
+
+ # 记录失败操作
+ log_operation(db, user.id if user else None, "登录失败", f"密码错误,失败次数: {user.login_failed_count}")
+
+ # 检查是否需要锁定
+ if user.is_login_locked():
+ remaining_time = user.get_remaining_lock_time()
+ raise HTTPException(
+ status_code=status.HTTP_423_LOCKED,
+ detail=f"由于多次登录失败,账户已被锁定 {remaining_time} 秒",
+ headers={
+ "WWW-Authenticate": "Bearer",
+ "X-Lock-Remaining": str(remaining_time)
+ },
+ )
+ else:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="用户名或密码错误",
+ headers={"WWW-Authenticate": "Bearer"},
+ )
+
+ # 登录成功,重置失败计数器
+ user.reset_failed_login()
user.last_login = datetime.now()
db.commit()
diff --git a/server/utils/migrate.py b/server/utils/migrate.py
new file mode 100644
index 00000000..9a1ea624
--- /dev/null
+++ b/server/utils/migrate.py
@@ -0,0 +1,268 @@
+"""
+数据库迁移系统
+"""
+import os
+import shutil
+import sqlite3
+from datetime import datetime
+from pathlib import Path
+from typing import Dict, List, Tuple
+
+from src.utils import logger
+
+
+class DatabaseMigrator:
+ """数据库迁移器"""
+
+ def __init__(self, db_path: str):
+ self.db_path = db_path
+ self.backup_dir = os.path.join(os.path.dirname(db_path), "backups")
+ self.migration_version_key = "migration_version"
+
+ def ensure_backup_dir(self):
+ """确保备份目录存在"""
+ Path(self.backup_dir).mkdir(parents=True, exist_ok=True)
+
+ def backup_database(self) -> str:
+ """备份数据库文件"""
+ if not os.path.exists(self.db_path):
+ logger.info("数据库文件不存在,无需备份")
+ return ""
+
+ self.ensure_backup_dir()
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
+ backup_filename = f"server_backup_{timestamp}.db"
+ backup_path = os.path.join(self.backup_dir, backup_filename)
+
+ try:
+ shutil.copy2(self.db_path, backup_path)
+ logger.info(f"数据库已备份到: {backup_path}")
+ return backup_path
+ except Exception as e:
+ logger.error(f"数据库备份失败: {e}")
+ raise
+
+ def get_current_version(self) -> int:
+ """获取当前数据库版本"""
+ if not os.path.exists(self.db_path):
+ return 0
+
+ try:
+ conn = sqlite3.connect(self.db_path)
+ cursor = conn.cursor()
+
+ # 检查版本表是否存在
+ cursor.execute("""
+ SELECT name FROM sqlite_master
+ WHERE type='table' AND name='migration_versions'
+ """)
+
+ if not cursor.fetchone():
+ # 版本表不存在,检查是否为旧版本数据库
+ cursor.execute("""
+ SELECT name FROM sqlite_master
+ WHERE type='table' AND name='users'
+ """)
+ if cursor.fetchone():
+ # 用户表存在但版本表不存在,说明是旧版本
+ return 0
+ else:
+ # 全新数据库
+ return 0
+
+ # 获取当前版本
+ cursor.execute("SELECT version FROM migration_versions ORDER BY version DESC LIMIT 1")
+ result = cursor.fetchone()
+ return result[0] if result else 0
+
+ except Exception as e:
+ logger.error(f"获取数据库版本失败: {e}")
+ return 0
+ finally:
+ if 'conn' in locals():
+ conn.close()
+
+ def set_version(self, version: int):
+ """设置数据库版本"""
+ try:
+ conn = sqlite3.connect(self.db_path)
+ cursor = conn.cursor()
+
+ # 创建版本表
+ cursor.execute("""
+ CREATE TABLE IF NOT EXISTS migration_versions (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ version INTEGER NOT NULL,
+ applied_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ description TEXT
+ )
+ """)
+
+ # 插入版本记录
+ cursor.execute("""
+ INSERT INTO migration_versions (version, description)
+ VALUES (?, ?)
+ """, (version, f"Migration to version {version}"))
+
+ conn.commit()
+ logger.info(f"数据库版本设置为: {version}")
+
+ except Exception as e:
+ logger.error(f"设置数据库版本失败: {e}")
+ raise
+ finally:
+ if 'conn' in locals():
+ conn.close()
+
+ def execute_migration(self, version: int, description: str, sql_commands: List[str]):
+ """执行迁移"""
+ logger.info(f"执行迁移 v{version}: {description}")
+
+ try:
+ conn = sqlite3.connect(self.db_path)
+ cursor = conn.cursor()
+
+ # 执行迁移SQL命令
+ for sql in sql_commands:
+ if sql.strip(): # 跳过空命令
+ logger.info(f"执行SQL: {sql}")
+ cursor.execute(sql)
+
+ conn.commit()
+ logger.info(f"迁移 v{version} 执行成功")
+
+ except Exception as e:
+ logger.error(f"迁移 v{version} 执行失败: {e}")
+ raise
+ finally:
+ if 'conn' in locals():
+ conn.close()
+
+ def check_column_exists(self, table_name: str, column_name: str) -> bool:
+ """检查列是否存在"""
+ try:
+ conn = sqlite3.connect(self.db_path)
+ cursor = conn.cursor()
+
+ cursor.execute(f"PRAGMA table_info({table_name})")
+ columns = [column[1] for column in cursor.fetchall()]
+ return column_name in columns
+
+ except Exception:
+ return False
+ finally:
+ if 'conn' in locals():
+ conn.close()
+
+ def run_migrations(self):
+ """运行所有待执行的迁移"""
+ current_version = self.get_current_version()
+ latest_version = self.get_latest_migration_version()
+
+ # 如果数据库已存在但没有版本表,创建版本表并设置为最新版本
+ if current_version == 0 and latest_version > 0 and os.path.exists(self.db_path):
+ # 检查users表是否已有新字段,如果有,说明是通过SQLAlchemy创建的
+ if (self.check_column_exists('users', 'login_failed_count') and
+ self.check_column_exists('users', 'last_failed_login') and
+ self.check_column_exists('users', 'login_locked_until')):
+ # 字段已存在,直接设置为最新版本
+ logger.info(f"检测到现有数据库已包含最新字段,设置版本为 v{latest_version}")
+ self.set_version(latest_version)
+ return
+
+ if current_version >= latest_version:
+ logger.info(f"数据库已是最新版本 v{current_version}")
+ return
+
+ logger.info(f"开始数据库迁移: v{current_version} -> v{latest_version}")
+
+ # 备份数据库
+ backup_path = self.backup_database()
+
+ try:
+ # 执行迁移
+ migrations = self.get_migrations()
+ has_executed_migrations = False
+
+ for version, description, sql_commands in migrations:
+ if version > current_version:
+ if sql_commands: # 只有当有SQL命令时才执行迁移
+ self.execute_migration(version, description, sql_commands)
+ has_executed_migrations = True
+ else:
+ logger.info(f"迁移 v{version}: {description} - 无需执行,字段已存在")
+
+ # 无论是否有SQL命令,都设置版本
+ self.set_version(version)
+
+ if has_executed_migrations:
+ logger.info("数据库迁移完成")
+ else:
+ logger.info("数据库结构已是最新,仅更新版本记录")
+
+ except Exception as e:
+ logger.error(f"数据库迁移失败: {e}")
+ if backup_path and os.path.exists(backup_path):
+ logger.info(f"尝试从备份恢复: {backup_path}")
+ try:
+ shutil.copy2(backup_path, self.db_path)
+ logger.info("数据库已从备份恢复")
+ except Exception as restore_error:
+ logger.error(f"数据库恢复失败: {restore_error}")
+ raise
+
+ def get_latest_migration_version(self) -> int:
+ """获取最新迁移版本号"""
+ # 这里返回硬编码的最新版本号,不依赖迁移定义
+ # 因为迁移定义可能为空(字段已存在)
+ return 1 # 当前最新版本是 v1
+
+ def get_migrations(self) -> List[Tuple[int, str, List[str]]]:
+ """获取所有迁移定义
+ 返回格式: [(version, description, [sql_commands])]
+ """
+ migrations = []
+
+ # 迁移 v1: 为 users 表添加登录失败限制字段
+ # 使用条件检查来避免重复添加字段
+ v1_commands = []
+
+ # 检查并添加 login_failed_count 字段
+ if not self.check_column_exists('users', 'login_failed_count'):
+ v1_commands.append("ALTER TABLE users ADD COLUMN login_failed_count INTEGER NOT NULL DEFAULT 0")
+
+ # 检查并添加 last_failed_login 字段
+ if not self.check_column_exists('users', 'last_failed_login'):
+ v1_commands.append("ALTER TABLE users ADD COLUMN last_failed_login DATETIME")
+
+ # 检查并添加 login_locked_until 字段
+ if not self.check_column_exists('users', 'login_locked_until'):
+ v1_commands.append("ALTER TABLE users ADD COLUMN login_locked_until DATETIME")
+
+ # 如果有命令需要执行,才添加迁移
+ if v1_commands:
+ migrations.append((1, "为用户表添加登录失败限制字段", v1_commands))
+
+ # 未来的迁移可以在这里添加
+ # migrations.append((
+ # 2,
+ # "添加新功能相关表",
+ # [
+ # "CREATE TABLE new_feature (...)",
+ # "ALTER TABLE existing_table ADD COLUMN new_field ..."
+ # ]
+ # ))
+
+ return migrations
+
+
+def check_and_migrate(db_path: str):
+ """检查并执行数据库迁移"""
+ migrator = DatabaseMigrator(db_path)
+
+ try:
+ migrator.run_migrations()
+ return True
+ except Exception as e:
+ logger.error(f"数据库迁移过程中发生错误: {e}")
+ return False
\ No newline at end of file
diff --git a/web/src/components/AgentChatComponent.vue b/web/src/components/AgentChatComponent.vue
index 08ef9f71..2758703e 100644
--- a/web/src/components/AgentChatComponent.vue
+++ b/web/src/components/AgentChatComponent.vue
@@ -206,7 +206,7 @@ const threadMessages = ref({});
const uiState = reactive({
...props.state,
- isSidebarOpen: localStorage.getItem('chat_sidebar_open', 'true') === 'true',
+ isSidebarOpen: localStorage.getItem('chat_sidebar_open') !== 'false',
isInitialRender: true,
showRenameButton: false,
containerWidth: 0,
diff --git a/web/src/stores/user.js b/web/src/stores/user.js
index 57faaf64..456db8e3 100644
--- a/web/src/stores/user.js
+++ b/web/src/stores/user.js
@@ -27,6 +27,15 @@ export const useUserStore = defineStore('user', () => {
if (!response.ok) {
const error = await response.json()
+
+ // 如果是423锁定状态码,抛出包含状态码的错误
+ if (response.status === 423) {
+ const lockError = new Error(error.detail || '账户被锁定')
+ lockError.status = 423
+ lockError.headers = response.headers
+ throw lockError
+ }
+
throw new Error(error.detail || '登录失败')
}
diff --git a/web/src/views/LoginView.vue b/web/src/views/LoginView.vue
index e785790a..3a9c4eb6 100644
--- a/web/src/views/LoginView.vue
+++ b/web/src/views/LoginView.vue
@@ -119,7 +119,16 @@
- 登录
+
+ 账户已锁定 {{ formatTime(lockRemainingTime) }}
+ 登录
+
@@ -166,7 +175,7 @@