From 747c5de88fb07499b801da23a4215675efd8c8ec Mon Sep 17 00:00:00 2001 From: Wenjie Zhang Date: Wed, 17 Sep 2025 03:59:51 +0800 Subject: [PATCH] =?UTF-8?q?feat(auth):=20=E5=AE=9E=E7=8E=B0=E8=B4=A6?= =?UTF-8?q?=E6=88=B7=E7=99=BB=E5=BD=95=E5=A4=B1=E8=B4=A5=E6=AC=A1=E6=95=B0?= =?UTF-8?q?=E9=99=90=E5=88=B6=E5=92=8C=E9=94=81=E5=AE=9A=E6=9C=BA=E5=88=B6?= =?UTF-8?q?=EF=BC=88=E6=B3=A8=E6=84=8F=EF=BC=8C=E9=9C=80=E8=A6=81=E8=BF=81?= =?UTF-8?q?=E7=A7=BB=E6=95=B0=E6=8D=AE=E5=BA=93=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 数据库用户模型新增登录失败限制相关字段(login_failed_count, last_failed_login, login_locked_until) - 增加用户方法判断是否处于登录锁定状态及计算锁定持续时间 - 登录接口新增登录锁定检测,密码错误时增加失败计数及设置锁定时间 - 登录成功时重置失败计数和锁定状态 - 新增数据库迁移模块,自动检测并执行数据库结构更新,确保新增字段同步 - 前端登录页添加账户锁定状态显示及倒计时功能 - 前端请求中捕获423锁定状态,展示对应锁定提示,禁用登录按钮 - 优化本地状态管理,支持登录锁定错误抛出与异常处理机制 --- server/db_manager.py | 18 +- server/models/user_model.py | 51 ++++ server/routers/auth_router.py | 46 +++- server/utils/migrate.py | 268 ++++++++++++++++++++++ web/src/components/AgentChatComponent.vue | 2 +- web/src/stores/user.js | 9 + web/src/views/LoginView.vue | 103 ++++++++- 7 files changed, 489 insertions(+), 8 deletions(-) create mode 100644 server/utils/migrate.py 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 @@