feat(auth): 实现账户登录失败次数限制和锁定机制(注意,需要迁移数据库)

- 数据库用户模型新增登录失败限制相关字段(login_failed_count, last_failed_login, login_locked_until)
- 增加用户方法判断是否处于登录锁定状态及计算锁定持续时间
- 登录接口新增登录锁定检测,密码错误时增加失败计数及设置锁定时间
- 登录成功时重置失败计数和锁定状态
- 新增数据库迁移模块,自动检测并执行数据库结构更新,确保新增字段同步
- 前端登录页添加账户锁定状态显示及倒计时功能
- 前端请求中捕获423锁定状态,展示对应锁定提示,禁用登录按钮
- 优化本地状态管理,支持登录锁定错误抛出与异常处理机制
This commit is contained in:
Wenjie Zhang 2025-09-17 03:59:51 +08:00
parent 166990bc81
commit 747c5de88f
7 changed files with 489 additions and 8 deletions

View File

@ -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):
"""获取数据库会话"""

View File

@ -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):

View File

@ -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()

268
server/utils/migrate.py Normal file
View File

@ -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

View File

@ -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,

View File

@ -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 || '登录失败')
}

View File

@ -119,7 +119,16 @@
</a-form-item>
<a-form-item>
<a-button type="primary" html-type="submit" :loading="loading" block>登录</a-button>
<a-button
type="primary"
html-type="submit"
:loading="loading"
:disabled="isLocked"
block
>
<span v-if="isLocked">账户已锁定 {{ formatTime(lockRemainingTime) }}</span>
<span v-else>登录</span>
</a-button>
</a-form-item>
<!-- 第三方登录选项 -->
@ -166,7 +175,7 @@
</template>
<script setup>
import { ref, reactive, onMounted, computed } from 'vue';
import { ref, reactive, onMounted, onUnmounted, computed } from 'vue';
import { useRouter } from 'vue-router';
import { useUserStore } from '@/stores/user';
import { useInfoStore } from '@/stores/info';
@ -193,6 +202,11 @@ const serverStatus = ref('loading');
const serverError = ref('');
const healthChecking = ref(false);
//
const isLocked = ref(false);
const lockRemainingTime = ref(0);
const lockCountdown = ref(null);
//
const loginForm = reactive({
username: '',
@ -211,6 +225,49 @@ const showDevMessage = () => {
message.info('该功能正在开发中,敬请期待!');
};
//
const clearLockCountdown = () => {
if (lockCountdown.value) {
clearInterval(lockCountdown.value);
lockCountdown.value = null;
}
};
//
const startLockCountdown = (remainingSeconds) => {
clearLockCountdown();
isLocked.value = true;
lockRemainingTime.value = remainingSeconds;
lockCountdown.value = setInterval(() => {
lockRemainingTime.value--;
if (lockRemainingTime.value <= 0) {
clearLockCountdown();
isLocked.value = false;
errorMessage.value = '';
}
}, 1000);
};
//
const formatTime = (seconds) => {
if (seconds < 60) {
return `${seconds}`;
} else if (seconds < 3600) {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${minutes}${remainingSeconds}`;
} else if (seconds < 86400) {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
return `${hours}小时${minutes}分钟`;
} else {
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
return `${days}${hours}小时`;
}
};
//
const validateConfirmPassword = (rule, value) => {
if (value === '') {
@ -224,9 +281,16 @@ const validateConfirmPassword = (rule, value) => {
//
const handleLogin = async () => {
//
if (isLocked.value) {
message.warning(`账户被锁定,请等待 ${formatTime(lockRemainingTime.value)}`);
return;
}
try {
loading.value = true;
errorMessage.value = '';
clearLockCountdown();
await userStore.login({
username: loginForm.username,
@ -278,7 +342,35 @@ const handleLogin = async () => {
}
} catch (error) {
console.error('登录失败:', error);
errorMessage.value = error.message || '登录失败,请检查用户名和密码';
// HTTP 423
if (error.status === 423) {
//
let remainingTime = 0;
if (error.headers && error.headers.get) {
const lockRemainingHeader = error.headers.get('X-Lock-Remaining');
if (lockRemainingHeader) {
remainingTime = parseInt(lockRemainingHeader);
}
}
//
if (remainingTime === 0) {
const lockTimeMatch = error.message.match(/(\d+)\s*秒/);
if (lockTimeMatch) {
remainingTime = parseInt(lockTimeMatch[1]);
}
}
if (remainingTime > 0) {
startLockCountdown(remainingTime);
errorMessage.value = `由于多次登录失败,账户已被锁定 ${formatTime(remainingTime)}`;
} else {
errorMessage.value = error.message || '账户被锁定,请稍后再试';
}
} else {
errorMessage.value = error.message || '登录失败,请检查用户名和密码';
}
} finally {
loading.value = false;
}
@ -358,6 +450,11 @@ onMounted(async () => {
//
await checkFirstRunStatus();
});
//
onUnmounted(() => {
clearLockCountdown();
});
</script>
<style lang="less" scoped>