feat: 添加安全模块 + Dockerfile添加curl支持健康检查
主要更新: - 新增 security/ 安全模块 (风险评估、威胁检测、蜜罐等) - Dockerfile 添加 curl 以支持 Docker 健康检查 - 前端页面更新 (管理后台、用户端) - 数据库迁移和 schema 更新 - 新增 kdocs 上传服务 - 添加安全相关测试用例 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -140,6 +140,36 @@ def get_account_status(account_id):
|
||||
return cursor.fetchone()
|
||||
|
||||
|
||||
def get_account_status_batch(account_ids):
|
||||
"""批量获取账号状态信息"""
|
||||
account_ids = [str(account_id) for account_id in (account_ids or []) if account_id]
|
||||
if not account_ids:
|
||||
return {}
|
||||
|
||||
results = {}
|
||||
chunk_size = 900 # 避免触发 SQLite 绑定参数上限
|
||||
with db_pool.get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
for idx in range(0, len(account_ids), chunk_size):
|
||||
chunk = account_ids[idx : idx + chunk_size]
|
||||
placeholders = ",".join("?" for _ in chunk)
|
||||
cursor.execute(
|
||||
f"""
|
||||
SELECT id, status, login_fail_count, last_login_error
|
||||
FROM accounts
|
||||
WHERE id IN ({placeholders})
|
||||
""",
|
||||
chunk,
|
||||
)
|
||||
for row in cursor.fetchall():
|
||||
row_dict = dict(row)
|
||||
account_id = str(row_dict.pop("id", ""))
|
||||
if account_id:
|
||||
results[account_id] = row_dict
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def delete_user_accounts(user_id):
|
||||
"""删除用户的所有账号"""
|
||||
with db_pool.get_db() as conn:
|
||||
@@ -147,4 +177,3 @@ def delete_user_accounts(user_id):
|
||||
cursor.execute("DELETE FROM accounts WHERE user_id = ?", (user_id,))
|
||||
conn.commit()
|
||||
return cursor.rowcount
|
||||
|
||||
|
||||
173
db/admin.py
173
db/admin.py
@@ -172,6 +172,17 @@ def get_system_config_raw() -> dict:
|
||||
"auto_approve_enabled": 0,
|
||||
"auto_approve_hourly_limit": 10,
|
||||
"auto_approve_vip_days": 7,
|
||||
"kdocs_enabled": 0,
|
||||
"kdocs_doc_url": "",
|
||||
"kdocs_default_unit": "",
|
||||
"kdocs_sheet_name": "",
|
||||
"kdocs_sheet_index": 0,
|
||||
"kdocs_unit_column": "A",
|
||||
"kdocs_image_column": "D",
|
||||
"kdocs_admin_notify_enabled": 0,
|
||||
"kdocs_admin_notify_email": "",
|
||||
"kdocs_row_start": 0,
|
||||
"kdocs_row_end": 0,
|
||||
}
|
||||
|
||||
|
||||
@@ -184,12 +195,24 @@ def update_system_config(
|
||||
schedule_weekdays=None,
|
||||
max_concurrent_per_account=None,
|
||||
max_screenshot_concurrent=None,
|
||||
enable_screenshot=None,
|
||||
proxy_enabled=None,
|
||||
proxy_api_url=None,
|
||||
proxy_expire_minutes=None,
|
||||
auto_approve_enabled=None,
|
||||
auto_approve_hourly_limit=None,
|
||||
auto_approve_vip_days=None,
|
||||
kdocs_enabled=None,
|
||||
kdocs_doc_url=None,
|
||||
kdocs_default_unit=None,
|
||||
kdocs_sheet_name=None,
|
||||
kdocs_sheet_index=None,
|
||||
kdocs_unit_column=None,
|
||||
kdocs_image_column=None,
|
||||
kdocs_admin_notify_enabled=None,
|
||||
kdocs_admin_notify_email=None,
|
||||
kdocs_row_start=None,
|
||||
kdocs_row_end=None,
|
||||
) -> bool:
|
||||
"""更新系统配置(仅更新DB,不做缓存处理)。"""
|
||||
allowed_fields = {
|
||||
@@ -200,12 +223,24 @@ def update_system_config(
|
||||
"schedule_weekdays",
|
||||
"max_concurrent_per_account",
|
||||
"max_screenshot_concurrent",
|
||||
"enable_screenshot",
|
||||
"proxy_enabled",
|
||||
"proxy_api_url",
|
||||
"proxy_expire_minutes",
|
||||
"auto_approve_enabled",
|
||||
"auto_approve_hourly_limit",
|
||||
"auto_approve_vip_days",
|
||||
"kdocs_enabled",
|
||||
"kdocs_doc_url",
|
||||
"kdocs_default_unit",
|
||||
"kdocs_sheet_name",
|
||||
"kdocs_sheet_index",
|
||||
"kdocs_unit_column",
|
||||
"kdocs_image_column",
|
||||
"kdocs_admin_notify_enabled",
|
||||
"kdocs_admin_notify_email",
|
||||
"kdocs_row_start",
|
||||
"kdocs_row_end",
|
||||
"updated_at",
|
||||
}
|
||||
|
||||
@@ -232,6 +267,9 @@ def update_system_config(
|
||||
if max_screenshot_concurrent is not None:
|
||||
updates.append("max_screenshot_concurrent = ?")
|
||||
params.append(max_screenshot_concurrent)
|
||||
if enable_screenshot is not None:
|
||||
updates.append("enable_screenshot = ?")
|
||||
params.append(enable_screenshot)
|
||||
if schedule_weekdays is not None:
|
||||
updates.append("schedule_weekdays = ?")
|
||||
params.append(schedule_weekdays)
|
||||
@@ -253,6 +291,39 @@ def update_system_config(
|
||||
if auto_approve_vip_days is not None:
|
||||
updates.append("auto_approve_vip_days = ?")
|
||||
params.append(auto_approve_vip_days)
|
||||
if kdocs_enabled is not None:
|
||||
updates.append("kdocs_enabled = ?")
|
||||
params.append(kdocs_enabled)
|
||||
if kdocs_doc_url is not None:
|
||||
updates.append("kdocs_doc_url = ?")
|
||||
params.append(kdocs_doc_url)
|
||||
if kdocs_default_unit is not None:
|
||||
updates.append("kdocs_default_unit = ?")
|
||||
params.append(kdocs_default_unit)
|
||||
if kdocs_sheet_name is not None:
|
||||
updates.append("kdocs_sheet_name = ?")
|
||||
params.append(kdocs_sheet_name)
|
||||
if kdocs_sheet_index is not None:
|
||||
updates.append("kdocs_sheet_index = ?")
|
||||
params.append(kdocs_sheet_index)
|
||||
if kdocs_unit_column is not None:
|
||||
updates.append("kdocs_unit_column = ?")
|
||||
params.append(kdocs_unit_column)
|
||||
if kdocs_image_column is not None:
|
||||
updates.append("kdocs_image_column = ?")
|
||||
params.append(kdocs_image_column)
|
||||
if kdocs_admin_notify_enabled is not None:
|
||||
updates.append("kdocs_admin_notify_enabled = ?")
|
||||
params.append(kdocs_admin_notify_enabled)
|
||||
if kdocs_admin_notify_email is not None:
|
||||
updates.append("kdocs_admin_notify_email = ?")
|
||||
params.append(kdocs_admin_notify_email)
|
||||
if kdocs_row_start is not None:
|
||||
updates.append("kdocs_row_start = ?")
|
||||
params.append(kdocs_row_start)
|
||||
if kdocs_row_end is not None:
|
||||
updates.append("kdocs_row_end = ?")
|
||||
params.append(kdocs_row_end)
|
||||
|
||||
if not updates:
|
||||
return False
|
||||
@@ -287,108 +358,6 @@ def get_hourly_registration_count() -> int:
|
||||
# ==================== 密码重置(管理员) ====================
|
||||
|
||||
|
||||
def create_password_reset_request(user_id: int, new_password: str):
|
||||
"""创建密码重置申请(存储哈希)"""
|
||||
with db_pool.get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
password_hash = hash_password_bcrypt(new_password)
|
||||
cst_time = get_cst_now_str()
|
||||
|
||||
try:
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO password_reset_requests (user_id, new_password_hash, status, created_at)
|
||||
VALUES (?, ?, 'pending', ?)
|
||||
""",
|
||||
(user_id, password_hash, cst_time),
|
||||
)
|
||||
conn.commit()
|
||||
return cursor.lastrowid
|
||||
except Exception as e:
|
||||
print(f"创建密码重置申请失败: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def get_pending_password_resets():
|
||||
"""获取待审核的密码重置申请列表"""
|
||||
with db_pool.get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT r.id, r.user_id, r.created_at, r.status,
|
||||
u.username, u.email
|
||||
FROM password_reset_requests r
|
||||
JOIN users u ON r.user_id = u.id
|
||||
WHERE r.status = 'pending'
|
||||
ORDER BY r.created_at DESC
|
||||
"""
|
||||
)
|
||||
return [dict(row) for row in cursor.fetchall()]
|
||||
|
||||
|
||||
def approve_password_reset(request_id: int) -> bool:
|
||||
"""批准密码重置申请"""
|
||||
with db_pool.get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
cst_time = get_cst_now_str()
|
||||
|
||||
try:
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT user_id, new_password_hash
|
||||
FROM password_reset_requests
|
||||
WHERE id = ? AND status = 'pending'
|
||||
""",
|
||||
(request_id,),
|
||||
)
|
||||
|
||||
result = cursor.fetchone()
|
||||
if not result:
|
||||
return False
|
||||
|
||||
user_id = result["user_id"]
|
||||
new_password_hash = result["new_password_hash"]
|
||||
|
||||
cursor.execute("UPDATE users SET password_hash = ? WHERE id = ?", (new_password_hash, user_id))
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
UPDATE password_reset_requests
|
||||
SET status = 'approved', processed_at = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(cst_time, request_id),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"批准密码重置失败: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def reject_password_reset(request_id: int) -> bool:
|
||||
"""拒绝密码重置申请"""
|
||||
with db_pool.get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
cst_time = get_cst_now_str()
|
||||
|
||||
try:
|
||||
cursor.execute(
|
||||
"""
|
||||
UPDATE password_reset_requests
|
||||
SET status = 'rejected', processed_at = ?
|
||||
WHERE id = ? AND status = 'pending'
|
||||
""",
|
||||
(cst_time, request_id),
|
||||
)
|
||||
conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
except Exception as e:
|
||||
print(f"拒绝密码重置失败: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def admin_reset_user_password(user_id: int, new_password: str) -> bool:
|
||||
"""管理员直接重置用户密码"""
|
||||
with db_pool.get_db() as conn:
|
||||
|
||||
@@ -6,10 +6,12 @@ import db_pool
|
||||
from db.utils import get_cst_now_str
|
||||
|
||||
|
||||
def create_announcement(title, content, is_active=True):
|
||||
def create_announcement(title, content, image_url=None, is_active=True):
|
||||
"""创建公告(默认启用;启用时会自动停用其他公告)"""
|
||||
title = (title or "").strip()
|
||||
content = (content or "").strip()
|
||||
image_url = (image_url or "").strip()
|
||||
image_url = image_url or None
|
||||
if not title or not content:
|
||||
return None
|
||||
|
||||
@@ -22,10 +24,10 @@ def create_announcement(title, content, is_active=True):
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO announcements (title, content, is_active, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
INSERT INTO announcements (title, content, image_url, is_active, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(title, content, 1 if is_active else 0, cst_time, cst_time),
|
||||
(title, content, image_url, 1 if is_active else 0, cst_time, cst_time),
|
||||
)
|
||||
conn.commit()
|
||||
return cursor.lastrowid
|
||||
@@ -129,4 +131,3 @@ def dismiss_announcement_for_user(user_id, announcement_id):
|
||||
)
|
||||
conn.commit()
|
||||
return cursor.rowcount >= 0
|
||||
|
||||
|
||||
230
db/migrations.py
230
db/migrations.py
@@ -72,6 +72,24 @@ def migrate_database(conn, target_version: int) -> None:
|
||||
if current_version < 12:
|
||||
_migrate_to_v12(conn)
|
||||
current_version = 12
|
||||
if current_version < 13:
|
||||
_migrate_to_v13(conn)
|
||||
current_version = 13
|
||||
if current_version < 14:
|
||||
_migrate_to_v14(conn)
|
||||
current_version = 14
|
||||
if current_version < 15:
|
||||
_migrate_to_v15(conn)
|
||||
current_version = 15
|
||||
if current_version < 16:
|
||||
_migrate_to_v16(conn)
|
||||
current_version = 16
|
||||
if current_version < 17:
|
||||
_migrate_to_v17(conn)
|
||||
current_version = 17
|
||||
if current_version < 18:
|
||||
_migrate_to_v18(conn)
|
||||
current_version = 18
|
||||
|
||||
if current_version != int(target_version):
|
||||
set_current_version(conn, int(target_version))
|
||||
@@ -519,3 +537,215 @@ def _migrate_to_v12(conn):
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_login_ips_user ON login_ips(user_id)")
|
||||
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _migrate_to_v13(conn):
|
||||
"""迁移到版本13 - 安全防护:威胁检测相关表"""
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS threat_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
threat_type TEXT NOT NULL,
|
||||
score INTEGER NOT NULL DEFAULT 0,
|
||||
rule TEXT,
|
||||
field_name TEXT,
|
||||
matched TEXT,
|
||||
value_preview TEXT,
|
||||
ip TEXT,
|
||||
user_id INTEGER,
|
||||
request_method TEXT,
|
||||
request_path TEXT,
|
||||
user_agent TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
)
|
||||
"""
|
||||
)
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_threat_events_created_at ON threat_events(created_at)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_threat_events_ip ON threat_events(ip)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_threat_events_user_id ON threat_events(user_id)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_threat_events_type ON threat_events(threat_type)")
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS ip_risk_scores (
|
||||
ip TEXT PRIMARY KEY,
|
||||
risk_score INTEGER NOT NULL DEFAULT 0,
|
||||
last_seen TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_ip_risk_scores_score ON ip_risk_scores(risk_score)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_ip_risk_scores_updated_at ON ip_risk_scores(updated_at)")
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS user_risk_scores (
|
||||
user_id INTEGER PRIMARY KEY,
|
||||
risk_score INTEGER NOT NULL DEFAULT 0,
|
||||
last_seen TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
)
|
||||
"""
|
||||
)
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_risk_scores_score ON user_risk_scores(risk_score)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_risk_scores_updated_at ON user_risk_scores(updated_at)")
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS ip_blacklist (
|
||||
ip TEXT PRIMARY KEY,
|
||||
reason TEXT,
|
||||
is_active INTEGER DEFAULT 1,
|
||||
added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_ip_blacklist_active ON ip_blacklist(is_active)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_ip_blacklist_expires ON ip_blacklist(expires_at)")
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS threat_signatures (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT UNIQUE NOT NULL,
|
||||
threat_type TEXT NOT NULL,
|
||||
pattern TEXT NOT NULL,
|
||||
pattern_type TEXT DEFAULT 'regex',
|
||||
score INTEGER DEFAULT 0,
|
||||
is_active INTEGER DEFAULT 1,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_threat_signatures_type ON threat_signatures(threat_type)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_threat_signatures_active ON threat_signatures(is_active)")
|
||||
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _migrate_to_v14(conn):
|
||||
"""迁移到版本14 - 安全防护:用户黑名单表"""
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS user_blacklist (
|
||||
user_id INTEGER PRIMARY KEY,
|
||||
reason TEXT,
|
||||
is_active INTEGER DEFAULT 1,
|
||||
added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_blacklist_active ON user_blacklist(is_active)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_blacklist_expires ON user_blacklist(expires_at)")
|
||||
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _migrate_to_v15(conn):
|
||||
"""迁移到版本15 - 邮件设置:新设备登录提醒全局开关"""
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='email_settings'")
|
||||
if not cursor.fetchone():
|
||||
# 邮件表由 email_service.init_email_tables 创建;此处仅做增量字段迁移
|
||||
return
|
||||
|
||||
cursor.execute("PRAGMA table_info(email_settings)")
|
||||
columns = [col[1] for col in cursor.fetchall()]
|
||||
|
||||
changed = False
|
||||
if "login_alert_enabled" not in columns:
|
||||
cursor.execute("ALTER TABLE email_settings ADD COLUMN login_alert_enabled INTEGER DEFAULT 1")
|
||||
print(" ✓ 添加 email_settings.login_alert_enabled 字段")
|
||||
changed = True
|
||||
|
||||
try:
|
||||
cursor.execute("UPDATE email_settings SET login_alert_enabled = 1 WHERE login_alert_enabled IS NULL")
|
||||
if cursor.rowcount:
|
||||
changed = True
|
||||
except sqlite3.OperationalError:
|
||||
# 列不存在等情况由上方迁移兜底;不阻断主流程
|
||||
pass
|
||||
|
||||
if changed:
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _migrate_to_v16(conn):
|
||||
"""迁移到版本16 - 公告支持图片字段"""
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("PRAGMA table_info(announcements)")
|
||||
columns = [col[1] for col in cursor.fetchall()]
|
||||
|
||||
if "image_url" not in columns:
|
||||
cursor.execute("ALTER TABLE announcements ADD COLUMN image_url TEXT")
|
||||
conn.commit()
|
||||
print(" ✓ 添加 announcements.image_url 字段")
|
||||
|
||||
|
||||
def _migrate_to_v17(conn):
|
||||
"""迁移到版本17 - 金山文档上传配置与用户开关"""
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("PRAGMA table_info(system_config)")
|
||||
columns = [col[1] for col in cursor.fetchall()]
|
||||
|
||||
system_fields = [
|
||||
("kdocs_enabled", "INTEGER DEFAULT 0"),
|
||||
("kdocs_doc_url", "TEXT DEFAULT ''"),
|
||||
("kdocs_default_unit", "TEXT DEFAULT ''"),
|
||||
("kdocs_sheet_name", "TEXT DEFAULT ''"),
|
||||
("kdocs_sheet_index", "INTEGER DEFAULT 0"),
|
||||
("kdocs_unit_column", "TEXT DEFAULT 'A'"),
|
||||
("kdocs_image_column", "TEXT DEFAULT 'D'"),
|
||||
("kdocs_admin_notify_enabled", "INTEGER DEFAULT 0"),
|
||||
("kdocs_admin_notify_email", "TEXT DEFAULT ''"),
|
||||
]
|
||||
for field, ddl in system_fields:
|
||||
if field not in columns:
|
||||
cursor.execute(f"ALTER TABLE system_config ADD COLUMN {field} {ddl}")
|
||||
print(f" ✓ 添加 system_config.{field} 字段")
|
||||
|
||||
cursor.execute("PRAGMA table_info(users)")
|
||||
columns = [col[1] for col in cursor.fetchall()]
|
||||
|
||||
user_fields = [
|
||||
("kdocs_unit", "TEXT DEFAULT ''"),
|
||||
("kdocs_auto_upload", "INTEGER DEFAULT 0"),
|
||||
]
|
||||
for field, ddl in user_fields:
|
||||
if field not in columns:
|
||||
cursor.execute(f"ALTER TABLE users ADD COLUMN {field} {ddl}")
|
||||
print(f" ✓ 添加 users.{field} 字段")
|
||||
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _migrate_to_v18(conn):
|
||||
"""迁移到版本18 - 金山文档上传:有效行范围配置"""
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("PRAGMA table_info(system_config)")
|
||||
columns = [col[1] for col in cursor.fetchall()]
|
||||
|
||||
if "kdocs_row_start" not in columns:
|
||||
cursor.execute("ALTER TABLE system_config ADD COLUMN kdocs_row_start INTEGER DEFAULT 0")
|
||||
print(" ✓ 添加 system_config.kdocs_row_start 字段")
|
||||
|
||||
if "kdocs_row_end" not in columns:
|
||||
cursor.execute("ALTER TABLE system_config ADD COLUMN kdocs_row_end INTEGER DEFAULT 0")
|
||||
print(" ✓ 添加 system_config.kdocs_row_end 字段")
|
||||
|
||||
conn.commit()
|
||||
|
||||
147
db/schema.py
147
db/schema.py
@@ -33,6 +33,8 @@ def ensure_schema(conn) -> None:
|
||||
email TEXT,
|
||||
email_verified INTEGER DEFAULT 0,
|
||||
email_notify_enabled INTEGER DEFAULT 1,
|
||||
kdocs_unit TEXT DEFAULT '',
|
||||
kdocs_auto_upload INTEGER DEFAULT 0,
|
||||
status TEXT DEFAULT 'approved',
|
||||
vip_expire_time TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
@@ -72,6 +74,101 @@ def ensure_schema(conn) -> None:
|
||||
"""
|
||||
)
|
||||
|
||||
# ==================== 安全防护:威胁检测相关表 ====================
|
||||
|
||||
# 威胁事件日志表
|
||||
cursor.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS threat_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
threat_type TEXT NOT NULL,
|
||||
score INTEGER NOT NULL DEFAULT 0,
|
||||
rule TEXT,
|
||||
field_name TEXT,
|
||||
matched TEXT,
|
||||
value_preview TEXT,
|
||||
ip TEXT,
|
||||
user_id INTEGER,
|
||||
request_method TEXT,
|
||||
request_path TEXT,
|
||||
user_agent TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
# IP风险评分表
|
||||
cursor.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS ip_risk_scores (
|
||||
ip TEXT PRIMARY KEY,
|
||||
risk_score INTEGER NOT NULL DEFAULT 0,
|
||||
last_seen TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
# 用户风险评分表
|
||||
cursor.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS user_risk_scores (
|
||||
user_id INTEGER PRIMARY KEY,
|
||||
risk_score INTEGER NOT NULL DEFAULT 0,
|
||||
last_seen TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
# IP黑名单表
|
||||
cursor.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS ip_blacklist (
|
||||
ip TEXT PRIMARY KEY,
|
||||
reason TEXT,
|
||||
is_active INTEGER DEFAULT 1,
|
||||
added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
# 用户黑名单表
|
||||
cursor.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS user_blacklist (
|
||||
user_id INTEGER PRIMARY KEY,
|
||||
reason TEXT,
|
||||
is_active INTEGER DEFAULT 1,
|
||||
added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
# 威胁特征库表
|
||||
cursor.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS threat_signatures (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT UNIQUE NOT NULL,
|
||||
threat_type TEXT NOT NULL,
|
||||
pattern TEXT NOT NULL,
|
||||
pattern_type TEXT DEFAULT 'regex',
|
||||
score INTEGER DEFAULT 0,
|
||||
is_active INTEGER DEFAULT 1,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
# 账号表(关联用户)
|
||||
cursor.execute(
|
||||
"""
|
||||
@@ -118,6 +215,17 @@ def ensure_schema(conn) -> None:
|
||||
auto_approve_enabled INTEGER DEFAULT 0,
|
||||
auto_approve_hourly_limit INTEGER DEFAULT 10,
|
||||
auto_approve_vip_days INTEGER DEFAULT 7,
|
||||
kdocs_enabled INTEGER DEFAULT 0,
|
||||
kdocs_doc_url TEXT DEFAULT '',
|
||||
kdocs_default_unit TEXT DEFAULT '',
|
||||
kdocs_sheet_name TEXT DEFAULT '',
|
||||
kdocs_sheet_index INTEGER DEFAULT 0,
|
||||
kdocs_unit_column TEXT DEFAULT 'A',
|
||||
kdocs_image_column TEXT DEFAULT 'D',
|
||||
kdocs_admin_notify_enabled INTEGER DEFAULT 0,
|
||||
kdocs_admin_notify_email TEXT DEFAULT '',
|
||||
kdocs_row_start INTEGER DEFAULT 0,
|
||||
kdocs_row_end INTEGER DEFAULT 0,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
@@ -144,21 +252,6 @@ def ensure_schema(conn) -> None:
|
||||
"""
|
||||
)
|
||||
|
||||
# 密码重置申请表
|
||||
cursor.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS password_reset_requests (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
new_password_hash TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
processed_at TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
# 数据库版本表
|
||||
cursor.execute(
|
||||
"""
|
||||
@@ -196,6 +289,7 @@ def ensure_schema(conn) -> None:
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
image_url TEXT,
|
||||
is_active INTEGER DEFAULT 1,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
@@ -271,6 +365,26 @@ def ensure_schema(conn) -> None:
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_login_fingerprints_user ON login_fingerprints(user_id)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_login_ips_user ON login_ips(user_id)")
|
||||
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_threat_events_created_at ON threat_events(created_at)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_threat_events_ip ON threat_events(ip)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_threat_events_user_id ON threat_events(user_id)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_threat_events_type ON threat_events(threat_type)")
|
||||
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_ip_risk_scores_score ON ip_risk_scores(risk_score)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_ip_risk_scores_updated_at ON ip_risk_scores(updated_at)")
|
||||
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_risk_scores_score ON user_risk_scores(risk_score)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_risk_scores_updated_at ON user_risk_scores(updated_at)")
|
||||
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_ip_blacklist_active ON ip_blacklist(is_active)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_ip_blacklist_expires ON ip_blacklist(expires_at)")
|
||||
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_blacklist_active ON user_blacklist(is_active)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_blacklist_expires ON user_blacklist(expires_at)")
|
||||
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_threat_signatures_type ON threat_signatures(threat_type)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_threat_signatures_active ON threat_signatures(is_active)")
|
||||
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_accounts_user_id ON accounts(user_id)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_accounts_username ON accounts(username)")
|
||||
|
||||
@@ -279,9 +393,6 @@ def ensure_schema(conn) -> None:
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_task_logs_created_at ON task_logs(created_at)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_task_logs_user_date ON task_logs(user_id, created_at)")
|
||||
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_password_reset_status ON password_reset_requests(status)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_password_reset_user_id ON password_reset_requests(user_id)")
|
||||
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_bug_feedbacks_user_id ON bug_feedbacks(user_id)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_bug_feedbacks_status ON bug_feedbacks(status)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_bug_feedbacks_created_at ON bug_feedbacks(created_at)")
|
||||
|
||||
218
db/security.py
218
db/security.py
@@ -2,10 +2,12 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
from typing import Any, Optional
|
||||
from typing import Dict
|
||||
|
||||
import db_pool
|
||||
from db.utils import get_cst_now_str
|
||||
from db.utils import get_cst_now, get_cst_now_str
|
||||
|
||||
|
||||
def record_login_context(user_id: int, ip_address: str, user_agent: str) -> Dict[str, bool]:
|
||||
@@ -74,3 +76,217 @@ def record_login_context(user_id: int, ip_address: str, user_agent: str) -> Dict
|
||||
conn.commit()
|
||||
|
||||
return {"new_device": new_device, "new_ip": new_ip}
|
||||
|
||||
|
||||
def get_threat_events_count(hours: int = 24) -> int:
|
||||
"""获取指定时间内的威胁事件数。"""
|
||||
try:
|
||||
hours_int = max(0, int(hours))
|
||||
except Exception:
|
||||
hours_int = 24
|
||||
|
||||
if hours_int <= 0:
|
||||
return 0
|
||||
|
||||
start_time = (get_cst_now() - timedelta(hours=hours_int)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
with db_pool.get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT COUNT(*) AS cnt FROM threat_events WHERE created_at >= ?", (start_time,))
|
||||
row = cursor.fetchone()
|
||||
try:
|
||||
return int(row["cnt"] if row else 0)
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def _build_threat_events_where_clause(filters: Optional[dict]) -> tuple[str, list[Any]]:
|
||||
clauses: list[str] = []
|
||||
params: list[Any] = []
|
||||
|
||||
if not isinstance(filters, dict):
|
||||
return "", []
|
||||
|
||||
event_type = filters.get("event_type") or filters.get("threat_type")
|
||||
if event_type:
|
||||
raw = str(event_type).strip()
|
||||
types = [t.strip()[:64] for t in raw.split(",") if t.strip()]
|
||||
if len(types) == 1:
|
||||
clauses.append("threat_type = ?")
|
||||
params.append(types[0])
|
||||
elif types:
|
||||
placeholders = ", ".join(["?"] * len(types))
|
||||
clauses.append(f"threat_type IN ({placeholders})")
|
||||
params.extend(types)
|
||||
|
||||
severity = filters.get("severity")
|
||||
if severity is not None and str(severity).strip():
|
||||
sev = str(severity).strip().lower()
|
||||
if "-" in sev:
|
||||
parts = [p.strip() for p in sev.split("-", 1)]
|
||||
try:
|
||||
min_score = int(parts[0])
|
||||
max_score = int(parts[1])
|
||||
clauses.append("score >= ? AND score <= ?")
|
||||
params.extend([min_score, max_score])
|
||||
except Exception:
|
||||
pass
|
||||
elif sev.isdigit():
|
||||
clauses.append("score >= ?")
|
||||
params.append(int(sev))
|
||||
elif sev in {"high", "critical"}:
|
||||
clauses.append("score >= ?")
|
||||
params.append(80)
|
||||
elif sev in {"medium", "med"}:
|
||||
clauses.append("score >= ? AND score < ?")
|
||||
params.extend([50, 80])
|
||||
elif sev in {"low", "info"}:
|
||||
clauses.append("score < ?")
|
||||
params.append(50)
|
||||
|
||||
ip = filters.get("ip")
|
||||
if ip is not None and str(ip).strip():
|
||||
ip_text = str(ip).strip()[:64]
|
||||
clauses.append("ip = ?")
|
||||
params.append(ip_text)
|
||||
|
||||
user_id = filters.get("user_id")
|
||||
if user_id is not None and str(user_id).strip():
|
||||
try:
|
||||
user_id_int = int(user_id)
|
||||
except Exception:
|
||||
user_id_int = None
|
||||
if user_id_int is not None:
|
||||
clauses.append("user_id = ?")
|
||||
params.append(user_id_int)
|
||||
|
||||
if not clauses:
|
||||
return "", []
|
||||
return " WHERE " + " AND ".join(clauses), params
|
||||
|
||||
|
||||
def get_threat_events_list(page: int, per_page: int, filters: Optional[dict] = None) -> dict:
|
||||
"""分页获取威胁事件。"""
|
||||
try:
|
||||
page_i = max(1, int(page))
|
||||
except Exception:
|
||||
page_i = 1
|
||||
try:
|
||||
per_page_i = int(per_page)
|
||||
except Exception:
|
||||
per_page_i = 20
|
||||
per_page_i = max(1, min(200, per_page_i))
|
||||
|
||||
where_sql, params = _build_threat_events_where_clause(filters)
|
||||
offset = (page_i - 1) * per_page_i
|
||||
|
||||
with db_pool.get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(f"SELECT COUNT(*) AS cnt FROM threat_events{where_sql}", tuple(params))
|
||||
row = cursor.fetchone()
|
||||
total = int(row["cnt"]) if row else 0
|
||||
|
||||
cursor.execute(
|
||||
f"""
|
||||
SELECT
|
||||
id,
|
||||
threat_type,
|
||||
score,
|
||||
rule,
|
||||
field_name,
|
||||
matched,
|
||||
value_preview,
|
||||
ip,
|
||||
user_id,
|
||||
request_method,
|
||||
request_path,
|
||||
user_agent,
|
||||
created_at
|
||||
FROM threat_events
|
||||
{where_sql}
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT ? OFFSET ?
|
||||
""",
|
||||
tuple(params + [per_page_i, offset]),
|
||||
)
|
||||
items = [dict(r) for r in cursor.fetchall()]
|
||||
|
||||
return {"page": page_i, "per_page": per_page_i, "total": total, "items": items, "filters": filters or {}}
|
||||
|
||||
|
||||
def get_ip_threat_history(ip: str, limit: int = 50) -> list[dict]:
|
||||
"""获取IP的威胁历史(最近limit条)。"""
|
||||
ip_text = str(ip or "").strip()[:64]
|
||||
if not ip_text:
|
||||
return []
|
||||
try:
|
||||
limit_i = max(1, min(200, int(limit)))
|
||||
except Exception:
|
||||
limit_i = 50
|
||||
|
||||
with db_pool.get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT
|
||||
id,
|
||||
threat_type,
|
||||
score,
|
||||
rule,
|
||||
field_name,
|
||||
matched,
|
||||
value_preview,
|
||||
ip,
|
||||
user_id,
|
||||
request_method,
|
||||
request_path,
|
||||
user_agent,
|
||||
created_at
|
||||
FROM threat_events
|
||||
WHERE ip = ?
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(ip_text, limit_i),
|
||||
)
|
||||
return [dict(r) for r in cursor.fetchall()]
|
||||
|
||||
|
||||
def get_user_threat_history(user_id: int, limit: int = 50) -> list[dict]:
|
||||
"""获取用户的威胁历史(最近limit条)。"""
|
||||
if user_id is None:
|
||||
return []
|
||||
try:
|
||||
user_id_int = int(user_id)
|
||||
except Exception:
|
||||
return []
|
||||
try:
|
||||
limit_i = max(1, min(200, int(limit)))
|
||||
except Exception:
|
||||
limit_i = 50
|
||||
|
||||
with db_pool.get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT
|
||||
id,
|
||||
threat_type,
|
||||
score,
|
||||
rule,
|
||||
field_name,
|
||||
matched,
|
||||
value_preview,
|
||||
ip,
|
||||
user_id,
|
||||
request_method,
|
||||
request_path,
|
||||
user_agent,
|
||||
created_at
|
||||
FROM threat_events
|
||||
WHERE user_id = ?
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(user_id_int, limit_i),
|
||||
)
|
||||
return [dict(r) for r in cursor.fetchall()]
|
||||
|
||||
33
db/users.py
33
db/users.py
@@ -217,6 +217,39 @@ def get_user_by_id(user_id):
|
||||
return dict(user) if user else None
|
||||
|
||||
|
||||
def get_user_kdocs_settings(user_id):
|
||||
"""获取用户的金山文档配置"""
|
||||
user = get_user_by_id(user_id)
|
||||
if not user:
|
||||
return None
|
||||
return {
|
||||
"kdocs_unit": user.get("kdocs_unit") or "",
|
||||
"kdocs_auto_upload": 1 if user.get("kdocs_auto_upload") else 0,
|
||||
}
|
||||
|
||||
|
||||
def update_user_kdocs_settings(user_id, *, kdocs_unit=None, kdocs_auto_upload=None) -> bool:
|
||||
"""更新用户的金山文档配置"""
|
||||
updates = []
|
||||
params = []
|
||||
if kdocs_unit is not None:
|
||||
updates.append("kdocs_unit = ?")
|
||||
params.append(kdocs_unit)
|
||||
if kdocs_auto_upload is not None:
|
||||
updates.append("kdocs_auto_upload = ?")
|
||||
params.append(kdocs_auto_upload)
|
||||
|
||||
if not updates:
|
||||
return False
|
||||
|
||||
params.append(user_id)
|
||||
with db_pool.get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(f"UPDATE users SET {', '.join(updates)} WHERE id = ?", params)
|
||||
conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
|
||||
def get_user_by_username(username):
|
||||
"""根据用户名获取用户"""
|
||||
with db_pool.get_db() as conn:
|
||||
|
||||
Reference in New Issue
Block a user