feat(auth): 实现账户登录失败次数限制和锁定机制(注意,需要迁移数据库)
- 数据库用户模型新增登录失败限制相关字段(login_failed_count, last_failed_login, login_locked_until) - 增加用户方法判断是否处于登录锁定状态及计算锁定持续时间 - 登录接口新增登录锁定检测,密码错误时增加失败计数及设置锁定时间 - 登录成功时重置失败计数和锁定状态 - 新增数据库迁移模块,自动检测并执行数据库结构更新,确保新增字段同步 - 前端登录页添加账户锁定状态显示及倒计时功能 - 前端请求中捕获423锁定状态,展示对应锁定提示,禁用登录按钮 - 优化本地状态管理,支持登录锁定错误抛出与异常处理机制
This commit is contained in:
parent
166990bc81
commit
747c5de88f
@ -7,6 +7,7 @@ from sqlalchemy.orm import sessionmaker
|
|||||||
|
|
||||||
from server.models import Base
|
from server.models import Base
|
||||||
from server.models.user_model import User
|
from server.models.user_model import User
|
||||||
|
from server.utils.migrate import check_and_migrate
|
||||||
from src import config
|
from src import config
|
||||||
from src.utils import logger
|
from src.utils import logger
|
||||||
|
|
||||||
@ -24,9 +25,12 @@ class DBManager:
|
|||||||
# 创建会话工厂
|
# 创建会话工厂
|
||||||
self.Session = sessionmaker(bind=self.engine)
|
self.Session = sessionmaker(bind=self.engine)
|
||||||
|
|
||||||
# 确保表存在
|
# 首先创建基本表结构
|
||||||
self.create_tables()
|
self.create_tables()
|
||||||
|
|
||||||
|
# 然后检查并执行数据库迁移
|
||||||
|
self.run_migrations()
|
||||||
|
|
||||||
def ensure_db_dir(self):
|
def ensure_db_dir(self):
|
||||||
"""确保数据库目录存在"""
|
"""确保数据库目录存在"""
|
||||||
db_dir = os.path.dirname(self.db_path)
|
db_dir = os.path.dirname(self.db_path)
|
||||||
@ -38,6 +42,18 @@ class DBManager:
|
|||||||
Base.metadata.create_all(self.engine)
|
Base.metadata.create_all(self.engine)
|
||||||
logger.info("Database tables created/checked")
|
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):
|
def get_session(self):
|
||||||
"""获取数据库会话"""
|
"""获取数据库会话"""
|
||||||
return self.Session()
|
return self.Session()
|
||||||
|
|||||||
@ -17,6 +17,11 @@ class User(Base):
|
|||||||
created_at = Column(DateTime, default=func.now())
|
created_at = Column(DateTime, default=func.now())
|
||||||
last_login = Column(DateTime, nullable=True)
|
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")
|
operation_logs = relationship("OperationLog", back_populates="user")
|
||||||
|
|
||||||
@ -27,11 +32,57 @@ class User(Base):
|
|||||||
"role": self.role,
|
"role": self.role,
|
||||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||||
"last_login": self.last_login.isoformat() if self.last_login 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:
|
if include_password:
|
||||||
result["password_hash"] = self.password_hash
|
result["password_hash"] = self.password_hash
|
||||||
return result
|
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):
|
class OperationLog(Base):
|
||||||
"""操作日志模型"""
|
"""操作日志模型"""
|
||||||
|
|||||||
@ -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()
|
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(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="用户名或密码错误",
|
detail="用户名或密码错误",
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
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()
|
user.last_login = datetime.now()
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|||||||
268
server/utils/migrate.py
Normal file
268
server/utils/migrate.py
Normal 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
|
||||||
@ -206,7 +206,7 @@ const threadMessages = ref({});
|
|||||||
|
|
||||||
const uiState = reactive({
|
const uiState = reactive({
|
||||||
...props.state,
|
...props.state,
|
||||||
isSidebarOpen: localStorage.getItem('chat_sidebar_open', 'true') === 'true',
|
isSidebarOpen: localStorage.getItem('chat_sidebar_open') !== 'false',
|
||||||
isInitialRender: true,
|
isInitialRender: true,
|
||||||
showRenameButton: false,
|
showRenameButton: false,
|
||||||
containerWidth: 0,
|
containerWidth: 0,
|
||||||
|
|||||||
@ -27,6 +27,15 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = await response.json()
|
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 || '登录失败')
|
throw new Error(error.detail || '登录失败')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -119,7 +119,16 @@
|
|||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
|
||||||
<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>
|
</a-form-item>
|
||||||
|
|
||||||
<!-- 第三方登录选项 -->
|
<!-- 第三方登录选项 -->
|
||||||
@ -166,7 +175,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, onMounted, computed } from 'vue';
|
import { ref, reactive, onMounted, onUnmounted, computed } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { useUserStore } from '@/stores/user';
|
import { useUserStore } from '@/stores/user';
|
||||||
import { useInfoStore } from '@/stores/info';
|
import { useInfoStore } from '@/stores/info';
|
||||||
@ -193,6 +202,11 @@ const serverStatus = ref('loading');
|
|||||||
const serverError = ref('');
|
const serverError = ref('');
|
||||||
const healthChecking = ref(false);
|
const healthChecking = ref(false);
|
||||||
|
|
||||||
|
// 登录锁定相关状态
|
||||||
|
const isLocked = ref(false);
|
||||||
|
const lockRemainingTime = ref(0);
|
||||||
|
const lockCountdown = ref(null);
|
||||||
|
|
||||||
// 登录表单
|
// 登录表单
|
||||||
const loginForm = reactive({
|
const loginForm = reactive({
|
||||||
username: '',
|
username: '',
|
||||||
@ -211,6 +225,49 @@ const showDevMessage = () => {
|
|||||||
message.info('该功能正在开发中,敬请期待!');
|
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) => {
|
const validateConfirmPassword = (rule, value) => {
|
||||||
if (value === '') {
|
if (value === '') {
|
||||||
@ -224,9 +281,16 @@ const validateConfirmPassword = (rule, value) => {
|
|||||||
|
|
||||||
// 处理登录
|
// 处理登录
|
||||||
const handleLogin = async () => {
|
const handleLogin = async () => {
|
||||||
|
// 如果当前被锁定,不允许登录
|
||||||
|
if (isLocked.value) {
|
||||||
|
message.warning(`账户被锁定,请等待 ${formatTime(lockRemainingTime.value)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
errorMessage.value = '';
|
errorMessage.value = '';
|
||||||
|
clearLockCountdown();
|
||||||
|
|
||||||
await userStore.login({
|
await userStore.login({
|
||||||
username: loginForm.username,
|
username: loginForm.username,
|
||||||
@ -278,7 +342,35 @@ const handleLogin = async () => {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('登录失败:', error);
|
console.error('登录失败:', error);
|
||||||
|
|
||||||
|
// 检查是否是锁定错误(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 || '登录失败,请检查用户名和密码';
|
errorMessage.value = error.message || '登录失败,请检查用户名和密码';
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
@ -358,6 +450,11 @@ onMounted(async () => {
|
|||||||
// 检查是否是首次运行
|
// 检查是否是首次运行
|
||||||
await checkFirstRunStatus();
|
await checkFirstRunStatus();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 组件卸载时清理定时器
|
||||||
|
onUnmounted(() => {
|
||||||
|
clearLockCountdown();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user