Harden auth risk controls and admin reauth

This commit is contained in:
2025-12-26 21:07:47 +08:00
parent f90b0a4f11
commit e3b0c35da6
32 changed files with 741 additions and 92 deletions

View File

@@ -1,5 +1,5 @@
import axios from 'axios' import axios from 'axios'
import { ElMessage } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
let lastToastKey = '' let lastToastKey = ''
let lastToastAt = 0 let lastToastAt = 0
@@ -24,6 +24,29 @@ export const api = axios.create({
withCredentials: true, withCredentials: true,
}) })
let reauthPromise = null
async function ensureReauth() {
if (reauthPromise) return reauthPromise
reauthPromise = ElMessageBox.prompt('请输入管理员密码进行二次确认', '安全确认', {
inputType: 'password',
inputPlaceholder: '管理员密码',
confirmButtonText: '确认',
cancelButtonText: '取消',
inputValidator: (v) => Boolean(String(v || '').trim()),
inputErrorMessage: '密码不能为空',
})
.then(async (res) => {
const password = String(res.value || '').trim()
await api.post('/admin/reauth', { password })
ElMessage.success('已通过安全确认')
})
.finally(() => {
reauthPromise = null
})
return reauthPromise
}
api.interceptors.request.use((config) => { api.interceptors.request.use((config) => {
const method = String(config?.method || 'GET').toUpperCase() const method = String(config?.method || 'GET').toUpperCase()
if (!['GET', 'HEAD', 'OPTIONS'].includes(method)) { if (!['GET', 'HEAD', 'OPTIONS'].includes(method)) {
@@ -38,11 +61,21 @@ api.interceptors.request.use((config) => {
api.interceptors.response.use( api.interceptors.response.use(
(response) => response, (response) => response,
(error) => { async (error) => {
const status = error?.response?.status const status = error?.response?.status
const payload = error?.response?.data const payload = error?.response?.data
const message = payload?.error || payload?.message || error?.message || '请求失败' const message = payload?.error || payload?.message || error?.message || '请求失败'
if (payload?.code === 'reauth_required' && error?.config && !error.config.__reauth_retry) {
try {
error.config.__reauth_retry = true
await ensureReauth()
return api.request(error.config)
} catch {
return Promise.reject(error)
}
}
if (status === 401) { if (status === 401) {
toastErrorOnce('401', message || '登录已过期,请重新登录', 3000) toastErrorOnce('401', message || '登录已过期,请重新登录', 3000)
const pathname = window.location?.pathname || '' const pathname = window.location?.pathname || ''

View File

@@ -473,6 +473,7 @@ function emailTypeLabel(type) {
reset: '密码重置', reset: '密码重置',
bind: '邮箱绑定', bind: '邮箱绑定',
task_complete: '任务完成', task_complete: '任务完成',
security_alert: '安全告警',
} }
return map[type] || type return map[type] || type
} }
@@ -701,6 +702,7 @@ onMounted(refreshAll)
<el-option label="密码重置" value="reset" /> <el-option label="密码重置" value="reset" />
<el-option label="邮箱绑定" value="bind" /> <el-option label="邮箱绑定" value="bind" />
<el-option label="任务完成" value="task_complete" /> <el-option label="任务完成" value="task_complete" />
<el-option label="安全告警" value="security_alert" />
</el-select> </el-select>
<el-select v-model="emailLogStatusFilter" style="width: 120px" @change="loadEmailLogs(1)"> <el-select v-model="emailLogStatusFilter" style="width: 120px" @change="loadEmailLogs(1)">
<el-option label="全部状态" value="" /> <el-option label="全部状态" value="" />

View File

@@ -189,6 +189,23 @@ class Config:
MAX_SCREENSHOT_SIZE = int(os.environ.get('MAX_SCREENSHOT_SIZE', '10485760')) # 10MB MAX_SCREENSHOT_SIZE = int(os.environ.get('MAX_SCREENSHOT_SIZE', '10485760')) # 10MB
LOGIN_CAPTCHA_AFTER_FAILURES = int(os.environ.get('LOGIN_CAPTCHA_AFTER_FAILURES', '3')) LOGIN_CAPTCHA_AFTER_FAILURES = int(os.environ.get('LOGIN_CAPTCHA_AFTER_FAILURES', '3'))
LOGIN_CAPTCHA_WINDOW_SECONDS = int(os.environ.get('LOGIN_CAPTCHA_WINDOW_SECONDS', '900')) LOGIN_CAPTCHA_WINDOW_SECONDS = int(os.environ.get('LOGIN_CAPTCHA_WINDOW_SECONDS', '900'))
LOGIN_RATE_LIMIT_WINDOW_SECONDS = int(os.environ.get('LOGIN_RATE_LIMIT_WINDOW_SECONDS', '900'))
LOGIN_IP_MAX_ATTEMPTS = int(os.environ.get('LOGIN_IP_MAX_ATTEMPTS', '60'))
LOGIN_USERNAME_MAX_ATTEMPTS = int(os.environ.get('LOGIN_USERNAME_MAX_ATTEMPTS', '30'))
LOGIN_IP_USERNAME_MAX_ATTEMPTS = int(os.environ.get('LOGIN_IP_USERNAME_MAX_ATTEMPTS', '12'))
LOGIN_FAIL_DELAY_BASE_MS = int(os.environ.get('LOGIN_FAIL_DELAY_BASE_MS', '200'))
LOGIN_FAIL_DELAY_MAX_MS = int(os.environ.get('LOGIN_FAIL_DELAY_MAX_MS', '1200'))
LOGIN_ACCOUNT_LOCK_FAILURES = int(os.environ.get('LOGIN_ACCOUNT_LOCK_FAILURES', '6'))
LOGIN_ACCOUNT_LOCK_WINDOW_SECONDS = int(os.environ.get('LOGIN_ACCOUNT_LOCK_WINDOW_SECONDS', '900'))
LOGIN_ACCOUNT_LOCK_SECONDS = int(os.environ.get('LOGIN_ACCOUNT_LOCK_SECONDS', '600'))
LOGIN_SCAN_UNIQUE_USERNAME_THRESHOLD = int(os.environ.get('LOGIN_SCAN_UNIQUE_USERNAME_THRESHOLD', '8'))
LOGIN_SCAN_WINDOW_SECONDS = int(os.environ.get('LOGIN_SCAN_WINDOW_SECONDS', '600'))
LOGIN_SCAN_COOLDOWN_SECONDS = int(os.environ.get('LOGIN_SCAN_COOLDOWN_SECONDS', '600'))
EMAIL_RATE_LIMIT_MAX = int(os.environ.get('EMAIL_RATE_LIMIT_MAX', '6'))
EMAIL_RATE_LIMIT_WINDOW_SECONDS = int(os.environ.get('EMAIL_RATE_LIMIT_WINDOW_SECONDS', '3600'))
LOGIN_ALERT_ENABLED = os.environ.get('LOGIN_ALERT_ENABLED', 'true').lower() == 'true'
LOGIN_ALERT_MIN_INTERVAL_SECONDS = int(os.environ.get('LOGIN_ALERT_MIN_INTERVAL_SECONDS', '3600'))
ADMIN_REAUTH_WINDOW_SECONDS = int(os.environ.get('ADMIN_REAUTH_WINDOW_SECONDS', '600'))
@classmethod @classmethod
def validate(cls): def validate(cls):

View File

@@ -113,6 +113,7 @@ from db.users import (
set_user_vip, set_user_vip,
verify_user, verify_user,
) )
from db.security import record_login_context
config = get_config() config = get_config()
@@ -120,7 +121,7 @@ config = get_config()
DB_FILE = config.DB_FILE DB_FILE = config.DB_FILE
# 数据库版本 (用于迁移管理) # 数据库版本 (用于迁移管理)
DB_VERSION = 11 DB_VERSION = 12
# ==================== 系统配置缓存P1 / O-03 ==================== # ==================== 系统配置缓存P1 / O-03 ====================

View File

@@ -69,6 +69,9 @@ def migrate_database(conn, target_version: int) -> None:
if current_version < 11: if current_version < 11:
_migrate_to_v11(conn) _migrate_to_v11(conn)
current_version = 11 current_version = 11
if current_version < 12:
_migrate_to_v12(conn)
current_version = 12
if current_version != int(target_version): if current_version != int(target_version):
set_current_version(conn, int(target_version)) set_current_version(conn, int(target_version))
@@ -472,7 +475,47 @@ def _migrate_to_v11(conn):
) )
updated = cursor.rowcount updated = cursor.rowcount
conn.commit() conn.commit()
if updated: if updated:
print(f" ✓ 已将 {updated} 个 pending 用户迁移为 approved") print(f" ✓ 已将 {updated} 个 pending 用户迁移为 approved")
except sqlite3.OperationalError as e: except sqlite3.OperationalError as e:
print(f" ⚠️ v11 迁移跳过: {e}") print(f" ⚠️ v11 迁移跳过: {e}")
def _migrate_to_v12(conn):
"""迁移到版本12 - 登录设备/IP记录表"""
cursor = conn.cursor()
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS login_fingerprints (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
user_agent TEXT NOT NULL,
first_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_ip TEXT DEFAULT '',
UNIQUE (user_id, user_agent),
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
)
"""
)
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS login_ips (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
ip TEXT NOT NULL,
first_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE (user_id, ip),
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
)
"""
)
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)")
conn.commit()

View File

@@ -41,6 +41,37 @@ def ensure_schema(conn) -> None:
""" """
) )
# 登录设备指纹表
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS login_fingerprints (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
user_agent TEXT NOT NULL,
first_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_ip TEXT DEFAULT '',
UNIQUE (user_id, user_agent),
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
)
"""
)
# 登录IP记录表
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS login_ips (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
ip TEXT NOT NULL,
first_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE (user_id, ip),
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
)
"""
)
# 账号表(关联用户) # 账号表(关联用户)
cursor.execute( cursor.execute(
""" """
@@ -237,6 +268,8 @@ def ensure_schema(conn) -> None:
cursor.execute("CREATE INDEX IF NOT EXISTS idx_users_username ON users(username)") cursor.execute("CREATE INDEX IF NOT EXISTS idx_users_username ON users(username)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_users_status ON users(status)") cursor.execute("CREATE INDEX IF NOT EXISTS idx_users_status ON users(status)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_users_vip_expire ON users(vip_expire_time)") cursor.execute("CREATE INDEX IF NOT EXISTS idx_users_vip_expire ON users(vip_expire_time)")
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_accounts_user_id ON accounts(user_id)") 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)") cursor.execute("CREATE INDEX IF NOT EXISTS idx_accounts_username ON accounts(username)")

76
db/security.py Normal file
View File

@@ -0,0 +1,76 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from __future__ import annotations
from typing import Dict
import db_pool
from db.utils import get_cst_now_str
def record_login_context(user_id: int, ip_address: str, user_agent: str) -> Dict[str, bool]:
"""记录登录环境信息,返回是否新设备/新IP。"""
user_id = int(user_id)
ip_text = str(ip_address or "").strip()[:64]
ua_text = str(user_agent or "").strip()[:512]
now_str = get_cst_now_str()
new_device = False
new_ip = False
with db_pool.get_db() as conn:
cursor = conn.cursor()
if ua_text:
cursor.execute(
"SELECT id FROM login_fingerprints WHERE user_id = ? AND user_agent = ?",
(user_id, ua_text),
)
row = cursor.fetchone()
if row:
cursor.execute(
"""
UPDATE login_fingerprints
SET last_seen = ?, last_ip = ?
WHERE id = ?
""",
(now_str, ip_text, row["id"] if isinstance(row, dict) else row[0]),
)
else:
cursor.execute(
"""
INSERT INTO login_fingerprints (user_id, user_agent, first_seen, last_seen, last_ip)
VALUES (?, ?, ?, ?, ?)
""",
(user_id, ua_text, now_str, now_str, ip_text),
)
new_device = True
if ip_text:
cursor.execute(
"SELECT id FROM login_ips WHERE user_id = ? AND ip = ?",
(user_id, ip_text),
)
row = cursor.fetchone()
if row:
cursor.execute(
"""
UPDATE login_ips
SET last_seen = ?
WHERE id = ?
""",
(now_str, row["id"] if isinstance(row, dict) else row[0]),
)
else:
cursor.execute(
"""
INSERT INTO login_ips (user_id, ip, first_seen, last_seen)
VALUES (?, ?, ?, ?)
""",
(user_id, ip_text, now_str, now_str),
)
new_ip = True
conn.commit()
return {"new_device": new_device, "new_ip": new_ip}

View File

@@ -80,6 +80,7 @@ EMAIL_TYPE_REGISTER = 'register' # 注册验证
EMAIL_TYPE_RESET = 'reset' # 密码重置 EMAIL_TYPE_RESET = 'reset' # 密码重置
EMAIL_TYPE_BIND = 'bind' # 邮箱绑定 EMAIL_TYPE_BIND = 'bind' # 邮箱绑定
EMAIL_TYPE_TASK_COMPLETE = 'task_complete' # 任务完成通知 EMAIL_TYPE_TASK_COMPLETE = 'task_complete' # 任务完成通知
EMAIL_TYPE_SECURITY_ALERT = 'security_alert' # 安全告警
# Token有效期 # Token有效期
TOKEN_EXPIRE_REGISTER = 24 * 60 * 60 # 注册验证: 24小时 TOKEN_EXPIRE_REGISTER = 24 * 60 * 60 # 注册验证: 24小时
@@ -1620,6 +1621,67 @@ def verify_bind_email_token(token: str) -> Optional[Dict[str, Any]]:
return verify_email_token(token, EMAIL_TYPE_BIND) return verify_email_token(token, EMAIL_TYPE_BIND)
def send_security_alert_email(
email: str,
username: str,
ip_address: str,
user_agent: str,
new_ip: bool,
new_device: bool,
user_id: int = None,
) -> Dict[str, Any]:
"""发送登录安全提醒邮件(低侵入,不影响登录)。"""
settings = get_email_settings()
if not settings.get("enabled", False):
return {"success": False, "error": "邮件功能未启用"}
reason_parts = []
if new_ip:
reason_parts.append("新的登录IP")
if new_device:
reason_parts.append("新的登录设备")
reason_text = "".join(reason_parts) if reason_parts else "异常登录"
subject = "账号安全提醒"
now_str = get_beijing_now_str()
ip_text = ip_address or "未知"
ua_text = user_agent or "未知"
text_body = (
f"您好,{username}\n\n"
f"我们检测到 {reason_text}\n"
f"时间:{now_str}\n"
f"IP{ip_text}\n"
f"设备信息:{ua_text}\n\n"
"如果这不是您本人操作,请尽快修改密码并联系管理员。\n"
)
html_body = f"""
<html>
<body>
<h2>账号安全提醒</h2>
<p>您好,{username}</p>
<p>我们检测到 <strong>{reason_text}</strong>。</p>
<ul>
<li>时间:{now_str}</li>
<li>IP{ip_text}</li>
<li>设备信息:{ua_text}</li>
</ul>
<p>如果这不是您本人操作,请尽快修改密码并联系管理员。</p>
</body>
</html>
"""
return send_email(
to_email=email,
subject=subject,
body=text_body,
html_body=html_body,
email_type=EMAIL_TYPE_SECURITY_ALERT,
user_id=user_id,
)
# ============ 异步发送队列 ============ # ============ 异步发送队列 ============
class EmailQueue: class EmailQueue:

View File

@@ -10,6 +10,7 @@ from datetime import datetime
import database import database
import email_service import email_service
import requests import requests
from app_config import get_config
from app_logger import get_logger from app_logger import get_logger
from app_security import ( from app_security import (
get_rate_limit_ip, get_rate_limit_ip,
@@ -32,6 +33,10 @@ from services.state import (
safe_iter_task_status_items, safe_iter_task_status_items,
safe_remove_user_accounts, safe_remove_user_accounts,
safe_verify_and_consume_captcha, safe_verify_and_consume_captcha,
check_login_ip_user_locked,
check_login_rate_limits,
get_login_failure_delay_seconds,
record_login_username_attempt,
check_ip_request_rate, check_ip_request_rate,
check_login_captcha_required, check_login_captcha_required,
clear_login_failures, clear_login_failures,
@@ -41,6 +46,20 @@ from services.tasks import get_task_scheduler, submit_account_task
from services.time_utils import BEIJING_TZ, get_beijing_now from services.time_utils import BEIJING_TZ, get_beijing_now
logger = get_logger("app") logger = get_logger("app")
config = get_config()
def _admin_reauth_required() -> bool:
try:
return time.time() > float(session.get("admin_reauth_until", 0) or 0)
except Exception:
return True
def _require_admin_reauth():
if _admin_reauth_required():
return jsonify({"error": "需要二次确认", "code": "reauth_required"}), 401
return None
@admin_api_bp.route("/debug-config", methods=["GET"]) @admin_api_bp.route("/debug-config", methods=["GET"])
@@ -83,13 +102,29 @@ def admin_login():
need_captcha = data.get("need_captcha", False) need_captcha = data.get("need_captcha", False)
client_ip = get_rate_limit_ip() client_ip = get_rate_limit_ip()
username_key = username
scan_locked = record_login_username_attempt(client_ip, username_key)
is_locked, remaining = check_login_ip_user_locked(client_ip, username_key)
if is_locked:
wait_hint = f"{remaining // 60 + 1}分钟" if remaining >= 60 else f"{remaining}"
if request.is_json:
return jsonify({"error": f"账号短时锁定,请{wait_hint}后再试", "need_captcha": True}), 429
return redirect(url_for("pages.admin_login_page"))
allowed, error_msg = check_ip_request_rate(client_ip, "login") allowed, error_msg = check_ip_request_rate(client_ip, "login")
if not allowed: if not allowed:
if request.is_json: if request.is_json:
return jsonify({"error": error_msg, "need_captcha": True}), 429 return jsonify({"error": error_msg, "need_captcha": True}), 429
return redirect(url_for("pages.admin_login_page")) return redirect(url_for("pages.admin_login_page"))
captcha_required = check_login_captcha_required(client_ip) or bool(need_captcha) allowed, error_msg = check_login_rate_limits(client_ip, username_key)
if not allowed:
if request.is_json:
return jsonify({"error": error_msg, "need_captcha": True}), 429
return redirect(url_for("pages.admin_login_page"))
captcha_required = check_login_captcha_required(client_ip, username_key) or scan_locked or bool(need_captcha)
if captcha_required: if captcha_required:
if not captcha_session or not captcha_code: if not captcha_session or not captcha_code:
if request.is_json: if request.is_json:
@@ -97,18 +132,19 @@ def admin_login():
return redirect(url_for("pages.admin_login_page")) return redirect(url_for("pages.admin_login_page"))
success, message = safe_verify_and_consume_captcha(captcha_session, captcha_code) success, message = safe_verify_and_consume_captcha(captcha_session, captcha_code)
if not success: if not success:
record_login_failure(client_ip) record_login_failure(client_ip, username_key)
if request.is_json: if request.is_json:
return jsonify({"error": message, "need_captcha": True}), 400 return jsonify({"error": message, "need_captcha": True}), 400
return redirect(url_for("pages.admin_login_page")) return redirect(url_for("pages.admin_login_page"))
admin = database.verify_admin(username, password) admin = database.verify_admin(username, password)
if admin: if admin:
clear_login_failures(client_ip) clear_login_failures(client_ip, username_key)
session.pop("admin_id", None) session.pop("admin_id", None)
session.pop("admin_username", None) session.pop("admin_username", None)
session["admin_id"] = admin["id"] session["admin_id"] = admin["id"]
session["admin_username"] = admin["username"] session["admin_username"] = admin["username"]
session["admin_reauth_until"] = time.time() + int(config.ADMIN_REAUTH_WINDOW_SECONDS)
session.permanent = True session.permanent = True
session.modified = True session.modified = True
@@ -118,7 +154,10 @@ def admin_login():
return jsonify({"success": True, "redirect": "/yuyx/admin"}) return jsonify({"success": True, "redirect": "/yuyx/admin"})
return redirect(url_for("pages.admin_page")) return redirect(url_for("pages.admin_page"))
record_login_failure(client_ip) record_login_failure(client_ip, username_key)
delay = get_login_failure_delay_seconds(client_ip, username_key)
if delay > 0:
time.sleep(delay)
logger.warning(f"[admin_login] 管理员 {username} 登录失败 - 用户名或密码错误") logger.warning(f"[admin_login] 管理员 {username} 登录失败 - 用户名或密码错误")
if request.is_json: if request.is_json:
return jsonify({"error": "管理员用户名或密码错误", "need_captcha": check_login_captcha_required(client_ip)}), 401 return jsonify({"error": "管理员用户名或密码错误", "need_captcha": check_login_captcha_required(client_ip)}), 401
@@ -131,9 +170,32 @@ def admin_logout():
"""管理员登出""" """管理员登出"""
session.pop("admin_id", None) session.pop("admin_id", None)
session.pop("admin_username", None) session.pop("admin_username", None)
session.pop("admin_reauth_until", None)
return jsonify({"success": True}) return jsonify({"success": True})
@admin_api_bp.route("/admin/reauth", methods=["POST"])
@admin_required
def admin_reauth():
"""管理员敏感操作二次确认"""
data = request.json or {}
password = (data.get("password") or "").strip()
if not password:
return jsonify({"error": "密码不能为空"}), 400
username = session.get("admin_username")
if not username:
return jsonify({"error": "未登录"}), 401
admin = database.verify_admin(username, password)
if not admin:
return jsonify({"error": "密码错误"}), 401
session["admin_reauth_until"] = time.time() + int(config.ADMIN_REAUTH_WINDOW_SECONDS)
session.modified = True
return jsonify({"success": True, "expires_in": int(config.ADMIN_REAUTH_WINDOW_SECONDS)})
# ==================== 公告管理API管理员 ==================== # ==================== 公告管理API管理员 ====================
@@ -761,6 +823,9 @@ def restart_docker_container():
import subprocess import subprocess
try: try:
reauth_response = _require_admin_reauth()
if reauth_response:
return reauth_response
if not os.path.exists("/.dockerenv"): if not os.path.exists("/.dockerenv"):
return jsonify({"error": "当前不在Docker容器中运行"}), 400 return jsonify({"error": "当前不在Docker容器中运行"}), 400

View File

@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import os import os
import time
import uuid import uuid
from flask import jsonify, request, session from flask import jsonify, request, session
@@ -65,6 +66,13 @@ def _parse_bool_field(data: dict, key: str) -> bool | None:
raise ValueError(f"{key} 必须是 0/1 或 true/false") raise ValueError(f"{key} 必须是 0/1 或 true/false")
def _admin_reauth_required() -> bool:
try:
return time.time() > float(session.get("admin_reauth_until", 0) or 0)
except Exception:
return True
@admin_api_bp.route("/update/status", methods=["GET"]) @admin_api_bp.route("/update/status", methods=["GET"])
@admin_required @admin_required
def get_update_status_api(): def get_update_status_api():
@@ -146,6 +154,8 @@ def request_update_check_api():
def request_update_run_api(): def request_update_run_api():
"""请求宿主机 Update-Agent 执行一键更新并重启服务。""" """请求宿主机 Update-Agent 执行一键更新并重启服务。"""
ensure_update_dirs() ensure_update_dirs()
if _admin_reauth_required():
return jsonify({"error": "需要二次确认", "code": "reauth_required"}), 401
if _has_pending_request(): if _has_pending_request():
return jsonify({"error": "已有更新请求正在处理中,请稍后再试"}), 409 return jsonify({"error": "已有更新请求正在处理中,请稍后再试"}), 409

View File

@@ -8,6 +8,7 @@ import time
import database import database
import email_service import email_service
from app_config import get_config
from app_logger import get_logger from app_logger import get_logger
from app_security import get_rate_limit_ip, require_ip_not_locked, validate_email, validate_password, validate_username from app_security import get_rate_limit_ip, require_ip_not_locked, validate_email, validate_password, validate_username
from flask import Blueprint, jsonify, redirect, render_template, request, url_for from flask import Blueprint, jsonify, redirect, render_template, request, url_for
@@ -17,17 +18,24 @@ from services.accounts_service import load_user_accounts
from services.models import User from services.models import User
from services.state import ( from services.state import (
check_ip_request_rate, check_ip_request_rate,
check_email_rate_limit,
check_login_ip_user_locked,
check_login_rate_limits,
check_login_captcha_required, check_login_captcha_required,
clear_login_failures, clear_login_failures,
get_login_failure_delay_seconds,
record_failed_captcha, record_failed_captcha,
record_login_failure, record_login_failure,
record_login_username_attempt,
safe_cleanup_expired_captcha, safe_cleanup_expired_captcha,
safe_delete_captcha, safe_delete_captcha,
safe_set_captcha, safe_set_captcha,
safe_verify_and_consume_captcha, safe_verify_and_consume_captcha,
should_send_login_alert,
) )
logger = get_logger("app") logger = get_logger("app")
config = get_config()
api_auth_bp = Blueprint("api_auth", __name__) api_auth_bp = Blueprint("api_auth", __name__)
@@ -181,6 +189,9 @@ def resend_verify_email():
client_ip = get_rate_limit_ip() client_ip = get_rate_limit_ip()
allowed, error_msg = check_ip_request_rate(client_ip, "email") allowed, error_msg = check_ip_request_rate(client_ip, "email")
if not allowed:
return jsonify({"error": error_msg}), 429
allowed, error_msg = check_email_rate_limit(email, "resend_verify")
if not allowed: if not allowed:
return jsonify({"error": error_msg}), 429 return jsonify({"error": error_msg}), 429
@@ -238,6 +249,9 @@ def forgot_password():
client_ip = get_rate_limit_ip() client_ip = get_rate_limit_ip()
allowed, error_msg = check_ip_request_rate(client_ip, "email") allowed, error_msg = check_ip_request_rate(client_ip, "email")
if not allowed:
return jsonify({"error": error_msg}), 429
allowed, error_msg = check_email_rate_limit(email, "forgot_password")
if not allowed: if not allowed:
return jsonify({"error": error_msg}), 429 return jsonify({"error": error_msg}), 429
@@ -323,6 +337,15 @@ def request_password_reset():
if not is_valid: if not is_valid:
return jsonify({"error": error_msg}), 400 return jsonify({"error": error_msg}), 400
client_ip = get_rate_limit_ip()
allowed, error_msg = check_ip_request_rate(client_ip, "email")
if not allowed:
return jsonify({"error": error_msg}), 429
if email:
allowed, error_msg = check_email_rate_limit(email, "reset_request")
if not allowed:
return jsonify({"error": error_msg}), 429
user = database.get_user_by_username(username) user = database.get_user_by_username(username)
if user: if user:
@@ -416,31 +439,66 @@ def login():
need_captcha = data.get("need_captcha", False) need_captcha = data.get("need_captcha", False)
client_ip = get_rate_limit_ip() client_ip = get_rate_limit_ip()
username_key = username
scan_locked = record_login_username_attempt(client_ip, username_key)
is_locked, remaining = check_login_ip_user_locked(client_ip, username_key)
if is_locked:
wait_hint = f"{remaining // 60 + 1}分钟" if remaining >= 60 else f"{remaining}"
return jsonify({"error": f"账号短时锁定,请{wait_hint}后再试", "need_captcha": True}), 429
allowed, error_msg = check_ip_request_rate(client_ip, "login") allowed, error_msg = check_ip_request_rate(client_ip, "login")
if not allowed: if not allowed:
return jsonify({"error": error_msg, "need_captcha": True}), 429 return jsonify({"error": error_msg, "need_captcha": True}), 429
captcha_required = check_login_captcha_required(client_ip) or bool(need_captcha) allowed, error_msg = check_login_rate_limits(client_ip, username_key)
if not allowed:
return jsonify({"error": error_msg, "need_captcha": True}), 429
captcha_required = check_login_captcha_required(client_ip, username_key) or scan_locked or bool(need_captcha)
if captcha_required: if captcha_required:
if not captcha_session or not captcha_code: if not captcha_session or not captcha_code:
return jsonify({"error": "请填写验证码", "need_captcha": True}), 400 return jsonify({"error": "请填写验证码", "need_captcha": True}), 400
success, message = safe_verify_and_consume_captcha(captcha_session, captcha_code) success, message = safe_verify_and_consume_captcha(captcha_session, captcha_code)
if not success: if not success:
record_login_failure(client_ip) record_login_failure(client_ip, username_key)
return jsonify({"error": message, "need_captcha": True}), 400 return jsonify({"error": message, "need_captcha": True}), 400
user = database.verify_user(username, password) user = database.verify_user(username, password)
if not user: if not user:
record_login_failure(client_ip) record_login_failure(client_ip, username_key)
return jsonify({"error": "用户名或密码错误", "need_captcha": check_login_captcha_required(client_ip)}), 401 delay = get_login_failure_delay_seconds(client_ip, username_key)
if delay > 0:
time.sleep(delay)
return jsonify({"error": "用户名或密码错误", "need_captcha": check_login_captcha_required(client_ip, username_key)}), 401
if user["status"] != "approved": if user["status"] != "approved":
return jsonify({"error": "账号未审核,请等待管理员审核", "need_captcha": False}), 401 return jsonify({"error": "账号未审核,请等待管理员审核", "need_captcha": False}), 401
clear_login_failures(client_ip) clear_login_failures(client_ip, username_key)
user_obj = User(user["id"]) user_obj = User(user["id"])
login_user(user_obj) login_user(user_obj)
load_user_accounts(user["id"]) load_user_accounts(user["id"])
try:
user_agent = request.headers.get("User-Agent", "")
context = database.record_login_context(user["id"], client_ip, user_agent)
if context and (context.get("new_ip") or context.get("new_device")):
if config.LOGIN_ALERT_ENABLED and should_send_login_alert(user["id"], client_ip):
user_info = database.get_user_by_id(user["id"]) or {}
if user_info.get("email") and user_info.get("email_verified"):
if database.get_user_email_notify(user["id"]):
email_service.send_security_alert_email(
email=user_info.get("email"),
username=user_info.get("username") or username,
ip_address=client_ip,
user_agent=user_agent,
new_ip=context.get("new_ip", False),
new_device=context.get("new_device", False),
user_id=user["id"],
)
except Exception:
pass
return jsonify({"success": True}) return jsonify({"success": True})

View File

@@ -9,7 +9,7 @@ from app_security import get_rate_limit_ip, require_ip_not_locked, validate_emai
from flask import Blueprint, jsonify, request from flask import Blueprint, jsonify, request
from flask_login import current_user, login_required from flask_login import current_user, login_required
from routes.pages import render_app_spa_or_legacy from routes.pages import render_app_spa_or_legacy
from services.state import check_ip_request_rate, safe_iter_task_status_items from services.state import check_email_rate_limit, check_ip_request_rate, safe_iter_task_status_items
logger = get_logger("app") logger = get_logger("app")
@@ -164,6 +164,9 @@ def bind_user_email():
client_ip = get_rate_limit_ip() client_ip = get_rate_limit_ip()
allowed, error_msg = check_ip_request_rate(client_ip, "email") allowed, error_msg = check_ip_request_rate(client_ip, "email")
if not allowed:
return jsonify({"error": error_msg}), 429
allowed, error_msg = check_email_rate_limit(email, "bind_email")
if not allowed: if not allowed:
return jsonify({"error": error_msg}), 429 return jsonify({"error": error_msg}), 429

View File

@@ -12,6 +12,7 @@ from __future__ import annotations
import threading import threading
import time import time
import random
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
from app_config import get_config from app_config import get_config
@@ -423,48 +424,291 @@ def cleanup_expired_ip_request_rates(now_ts: Optional[float] = None) -> int:
return removed return removed
# ==================== 登录失败追踪(触发验证码 ==================== # ==================== 登录风控(验证码/限流/延迟/锁定 ====================
_login_failures: Dict[str, Dict[str, Any]] = {} _login_failures: Dict[str, Dict[str, Any]] = {}
_login_failures_lock = threading.RLock() _login_failures_lock = threading.RLock()
_login_rate_limits: Dict[str, Dict[str, Any]] = {}
_login_rate_limits_lock = threading.RLock()
_login_scan_state: Dict[str, Dict[str, Any]] = {}
_login_scan_lock = threading.RLock()
_login_ip_user_locks: Dict[str, Dict[str, Any]] = {}
_login_ip_user_lock = threading.RLock()
_login_alert_state: Dict[int, Dict[str, Any]] = {}
_login_alert_lock = threading.RLock()
def _normalize_login_key(kind: str, ip_address: str, username: Optional[str] = None) -> str:
ip_key = str(ip_address or "")
user_key = str(username or "").strip().lower()
if kind == "ip":
return f"ip:{ip_key}"
if kind == "user":
return f"user:{user_key}" if user_key else ""
return f"ipuser:{ip_key}:{user_key}" if user_key else ""
def _get_login_captcha_config() -> Tuple[int, int]: def _get_login_captcha_config() -> Tuple[int, int]:
return int(config.LOGIN_CAPTCHA_AFTER_FAILURES), int(config.LOGIN_CAPTCHA_WINDOW_SECONDS) return int(config.LOGIN_CAPTCHA_AFTER_FAILURES), int(config.LOGIN_CAPTCHA_WINDOW_SECONDS)
def record_login_failure(ip_address: str) -> None: def _get_login_rate_limit_config() -> Tuple[int, int, int, int]:
return (
int(config.LOGIN_IP_MAX_ATTEMPTS),
int(config.LOGIN_USERNAME_MAX_ATTEMPTS),
int(config.LOGIN_IP_USERNAME_MAX_ATTEMPTS),
int(config.LOGIN_RATE_LIMIT_WINDOW_SECONDS),
)
def _get_login_lock_config() -> Tuple[int, int, int]:
return (
int(config.LOGIN_ACCOUNT_LOCK_FAILURES),
int(config.LOGIN_ACCOUNT_LOCK_WINDOW_SECONDS),
int(config.LOGIN_ACCOUNT_LOCK_SECONDS),
)
def _get_login_scan_config() -> Tuple[int, int, int]:
return (
int(config.LOGIN_SCAN_UNIQUE_USERNAME_THRESHOLD),
int(config.LOGIN_SCAN_WINDOW_SECONDS),
int(config.LOGIN_SCAN_COOLDOWN_SECONDS),
)
def _get_or_reset_bucket(data: Optional[Dict[str, Any]], now_ts: float, window_seconds: int) -> Dict[str, Any]:
if not data or (now_ts - float(data.get("window_start", 0) or 0)) > window_seconds:
return {"window_start": now_ts, "count": 0}
return data
def record_login_username_attempt(ip_address: str, username: str) -> bool:
now_ts = time.time() now_ts = time.time()
max_failures, window_seconds = _get_login_captcha_config() threshold, window_seconds, cooldown_seconds = _get_login_scan_config()
ip_key = str(ip_address or "") ip_key = str(ip_address or "")
with _login_failures_lock: user_key = str(username or "").strip().lower()
data = _login_failures.get(ip_key) if not ip_key or not user_key:
if not data or (now_ts - float(data.get("first_failed", 0) or 0)) > window_seconds: return False
data = {"first_failed": now_ts, "count": 0}
_login_failures[ip_key] = data with _login_scan_lock:
data["count"] = int(data.get("count", 0) or 0) + 1 data = _login_scan_state.get(ip_key)
if int(data["count"]) > max_failures * 5: if not data or (now_ts - float(data.get("first_seen", 0) or 0)) > window_seconds:
data["count"] = max_failures * 5 data = {"first_seen": now_ts, "usernames": set(), "scan_until": 0}
_login_scan_state[ip_key] = data
data["usernames"].add(user_key)
if len(data["usernames"]) >= threshold:
data["scan_until"] = max(float(data.get("scan_until", 0) or 0), now_ts + cooldown_seconds)
return now_ts < float(data.get("scan_until", 0) or 0)
def clear_login_failures(ip_address: str) -> None: def is_login_scan_locked(ip_address: str) -> bool:
ip_key = str(ip_address or "")
with _login_failures_lock:
_login_failures.pop(ip_key, None)
def check_login_captcha_required(ip_address: str) -> bool:
now_ts = time.time() now_ts = time.time()
max_failures, window_seconds = _get_login_captcha_config()
ip_key = str(ip_address or "") ip_key = str(ip_address or "")
with _login_failures_lock: with _login_scan_lock:
data = _login_failures.get(ip_key) data = _login_scan_state.get(ip_key)
if not data: if not data:
return False return False
if (now_ts - float(data.get("first_failed", 0) or 0)) > window_seconds: if now_ts >= float(data.get("scan_until", 0) or 0):
_login_failures.pop(ip_key, None)
return False return False
return int(data.get("count", 0) or 0) >= max_failures return True
def check_login_rate_limits(ip_address: str, username: str) -> Tuple[bool, Optional[str]]:
now_ts = time.time()
ip_max, user_max, ip_user_max, window_seconds = _get_login_rate_limit_config()
ip_key = _normalize_login_key("ip", ip_address)
user_key = _normalize_login_key("user", "", username)
ip_user_key = _normalize_login_key("ipuser", ip_address, username)
def _check(key: str, max_requests: int) -> Tuple[bool, Optional[str]]:
if not key or max_requests <= 0:
return True, None
data = _get_or_reset_bucket(_login_rate_limits.get(key), now_ts, window_seconds)
if int(data.get("count", 0) or 0) >= max_requests:
remaining = max(1, int(window_seconds - (now_ts - float(data.get("window_start", 0) or 0))))
wait_hint = f"{remaining // 60 + 1}分钟" if remaining >= 60 else f"{remaining}"
return False, f"请求过于频繁,请{wait_hint}后再试"
data["count"] = int(data.get("count", 0) or 0) + 1
_login_rate_limits[key] = data
return True, None
with _login_rate_limits_lock:
allowed, msg = _check(ip_key, ip_max)
if not allowed:
return False, msg
allowed, msg = _check(ip_user_key, ip_user_max)
if not allowed:
return False, msg
allowed, msg = _check(user_key, user_max)
if not allowed:
return False, msg
return True, None
def _update_login_failure(key: str, now_ts: float, window_seconds: int) -> int:
data = _login_failures.get(key)
if not data or (now_ts - float(data.get("first_failed", 0) or 0)) > window_seconds:
data = {"first_failed": now_ts, "count": 0}
_login_failures[key] = data
data["count"] = int(data.get("count", 0) or 0) + 1
return int(data["count"])
def record_login_failure(ip_address: str, username: Optional[str] = None) -> None:
now_ts = time.time()
max_failures, window_seconds = _get_login_captcha_config()
lock_failures, lock_window, lock_seconds = _get_login_lock_config()
ip_key = _normalize_login_key("ip", ip_address)
user_key = _normalize_login_key("user", "", username or "")
ip_user_key = _normalize_login_key("ipuser", ip_address, username or "")
with _login_failures_lock:
ip_count = _update_login_failure(ip_key, now_ts, window_seconds)
user_count = _update_login_failure(user_key, now_ts, window_seconds)
ip_user_count = _update_login_failure(ip_user_key, now_ts, window_seconds)
for key in (ip_key, user_key, ip_user_key):
data = _login_failures.get(key)
if data and int(data.get("count", 0) or 0) > max_failures * 5:
data["count"] = max_failures * 5
if username:
ip_user_lock_key = _normalize_login_key("ipuser", ip_address, username)
with _login_ip_user_lock:
if ip_user_count >= lock_failures:
_login_ip_user_locks[ip_user_lock_key] = {
"lock_until": now_ts + lock_seconds,
"first_failed": now_ts - lock_window,
}
def clear_login_failures(ip_address: str, username: Optional[str] = None) -> None:
ip_key = _normalize_login_key("ip", ip_address)
user_key = _normalize_login_key("user", "", username or "")
ip_user_key = _normalize_login_key("ipuser", ip_address, username or "")
with _login_failures_lock:
_login_failures.pop(ip_key, None)
_login_failures.pop(user_key, None)
_login_failures.pop(ip_user_key, None)
with _login_ip_user_lock:
_login_ip_user_locks.pop(ip_user_key, None)
def _get_login_failure_count(ip_address: str, username: Optional[str] = None) -> int:
now_ts = time.time()
_, window_seconds = _get_login_captcha_config()
ip_user_key = _normalize_login_key("ipuser", ip_address, username or "")
with _login_failures_lock:
data = _login_failures.get(ip_user_key)
if not data:
return 0
if (now_ts - float(data.get("first_failed", 0) or 0)) > window_seconds:
_login_failures.pop(ip_user_key, None)
return 0
return int(data.get("count", 0) or 0)
def check_login_captcha_required(ip_address: str, username: Optional[str] = None) -> bool:
now_ts = time.time()
max_failures, window_seconds = _get_login_captcha_config()
ip_key = _normalize_login_key("ip", ip_address)
ip_user_key = _normalize_login_key("ipuser", ip_address, username or "")
with _login_failures_lock:
ip_data = _login_failures.get(ip_key)
if ip_data and (now_ts - float(ip_data.get("first_failed", 0) or 0)) <= window_seconds:
if int(ip_data.get("count", 0) or 0) >= max_failures:
return True
ip_user_data = _login_failures.get(ip_user_key)
if ip_user_data and (now_ts - float(ip_user_data.get("first_failed", 0) or 0)) <= window_seconds:
if int(ip_user_data.get("count", 0) or 0) >= max_failures:
return True
if is_login_scan_locked(ip_address):
return True
return False
def check_login_ip_user_locked(ip_address: str, username: Optional[str]) -> Tuple[bool, int]:
now_ts = time.time()
if not username:
return False, 0
ip_user_key = _normalize_login_key("ipuser", ip_address, username)
with _login_ip_user_lock:
data = _login_ip_user_locks.get(ip_user_key)
if not data:
return False, 0
lock_until = float(data.get("lock_until", 0) or 0)
if now_ts >= lock_until:
_login_ip_user_locks.pop(ip_user_key, None)
return False, 0
remaining = int(lock_until - now_ts)
return True, max(1, remaining)
def get_login_failure_delay_seconds(ip_address: str, username: Optional[str]) -> float:
fail_count = _get_login_failure_count(ip_address, username)
if fail_count <= 0:
return 0.0
base_ms = max(0, int(config.LOGIN_FAIL_DELAY_BASE_MS))
max_ms = max(base_ms, int(config.LOGIN_FAIL_DELAY_MAX_MS))
delay_ms = min(max_ms, int(base_ms * (1.6 ** max(0, fail_count - 1))))
jitter = random.randint(0, max(50, int(base_ms * 0.3)))
return float(delay_ms + jitter) / 1000.0
def should_send_login_alert(user_id: int, ip_address: str) -> bool:
now_ts = time.time()
min_interval = int(config.LOGIN_ALERT_MIN_INTERVAL_SECONDS)
with _login_alert_lock:
data = _login_alert_state.get(int(user_id))
if not data:
_login_alert_state[int(user_id)] = {"last_sent": now_ts, "last_ip": ip_address}
return True
last_sent = float(data.get("last_sent", 0) or 0)
last_ip = str(data.get("last_ip", "") or "")
if ip_address and ip_address != last_ip:
_login_alert_state[int(user_id)] = {"last_sent": now_ts, "last_ip": ip_address}
return True
if (now_ts - last_sent) >= min_interval:
_login_alert_state[int(user_id)] = {"last_sent": now_ts, "last_ip": ip_address}
return True
return False
# ==================== 邮箱维度限流 ====================
_email_rate_limit: Dict[str, Dict[str, Any]] = {}
_email_rate_limit_lock = threading.RLock()
def check_email_rate_limit(email: str, action: str) -> Tuple[bool, Optional[str]]:
now_ts = time.time()
max_requests = int(config.EMAIL_RATE_LIMIT_MAX)
window_seconds = int(config.EMAIL_RATE_LIMIT_WINDOW_SECONDS)
email_key = str(email or "").strip().lower()
if not email_key:
return True, None
key = f"{action}:{email_key}"
with _email_rate_limit_lock:
data = _get_or_reset_bucket(_email_rate_limit.get(key), now_ts, window_seconds)
if int(data.get("count", 0) or 0) >= max_requests:
remaining = max(1, int(window_seconds - (now_ts - float(data.get("window_start", 0) or 0))))
wait_hint = f"{remaining // 60 + 1}分钟" if remaining >= 60 else f"{remaining}"
return False, f"请求过于频繁,请{wait_hint}后再试"
data["count"] = int(data.get("count", 0) or 0) + 1
_email_rate_limit[key] = data
return True, None
# ==================== Batch screenshots批次任务截图收集 ==================== # ==================== Batch screenshots批次任务截图收集 ====================

View File

@@ -1,34 +1,34 @@
{ {
"_email-BfqhxXOq.js": { "_email-BsKBHU5S.js": {
"file": "assets/email-BfqhxXOq.js", "file": "assets/email-BsKBHU5S.js",
"name": "email", "name": "email",
"imports": [ "imports": [
"index.html" "index.html"
] ]
}, },
"_tasks-BtWKY-g7.js": { "_tasks-DpslJtm_.js": {
"file": "assets/tasks-BtWKY-g7.js", "file": "assets/tasks-DpslJtm_.js",
"name": "tasks", "name": "tasks",
"imports": [ "imports": [
"index.html" "index.html"
] ]
}, },
"_update-BrAMPxiF.js": { "_update-DcFD-YxU.js": {
"file": "assets/update-BrAMPxiF.js", "file": "assets/update-DcFD-YxU.js",
"name": "update", "name": "update",
"imports": [ "imports": [
"index.html" "index.html"
] ]
}, },
"_users-CToznuvL.js": { "_users-CC9BckjT.js": {
"file": "assets/users-CToznuvL.js", "file": "assets/users-CC9BckjT.js",
"name": "users", "name": "users",
"imports": [ "imports": [
"index.html" "index.html"
] ]
}, },
"index.html": { "index.html": {
"file": "assets/index-Da0EvMWc.js", "file": "assets/index-CdjS44Uj.js",
"name": "index", "name": "index",
"src": "index.html", "src": "index.html",
"isEntry": true, "isEntry": true,
@@ -47,7 +47,7 @@
] ]
}, },
"src/pages/AnnouncementsPage.vue": { "src/pages/AnnouncementsPage.vue": {
"file": "assets/AnnouncementsPage-CbLi3NFK.js", "file": "assets/AnnouncementsPage-Djmq3Wb7.js",
"name": "AnnouncementsPage", "name": "AnnouncementsPage",
"src": "src/pages/AnnouncementsPage.vue", "src": "src/pages/AnnouncementsPage.vue",
"isDynamicEntry": true, "isDynamicEntry": true,
@@ -59,20 +59,20 @@
] ]
}, },
"src/pages/EmailPage.vue": { "src/pages/EmailPage.vue": {
"file": "assets/EmailPage-CaUZghxJ.js", "file": "assets/EmailPage-q6nJlTue.js",
"name": "EmailPage", "name": "EmailPage",
"src": "src/pages/EmailPage.vue", "src": "src/pages/EmailPage.vue",
"isDynamicEntry": true, "isDynamicEntry": true,
"imports": [ "imports": [
"_email-BfqhxXOq.js", "_email-BsKBHU5S.js",
"index.html" "index.html"
], ],
"css": [ "css": [
"assets/EmailPage-DD73oBux.css" "assets/EmailPage-BxzHc6tN.css"
] ]
}, },
"src/pages/FeedbacksPage.vue": { "src/pages/FeedbacksPage.vue": {
"file": "assets/FeedbacksPage-DCz_21CH.js", "file": "assets/FeedbacksPage-Drw6uvSR.js",
"name": "FeedbacksPage", "name": "FeedbacksPage",
"src": "src/pages/FeedbacksPage.vue", "src": "src/pages/FeedbacksPage.vue",
"isDynamicEntry": true, "isDynamicEntry": true,
@@ -84,13 +84,13 @@
] ]
}, },
"src/pages/LogsPage.vue": { "src/pages/LogsPage.vue": {
"file": "assets/LogsPage-k6AvTEc_.js", "file": "assets/LogsPage-DQd9IS3I.js",
"name": "LogsPage", "name": "LogsPage",
"src": "src/pages/LogsPage.vue", "src": "src/pages/LogsPage.vue",
"isDynamicEntry": true, "isDynamicEntry": true,
"imports": [ "imports": [
"_users-CToznuvL.js", "_users-CC9BckjT.js",
"_tasks-BtWKY-g7.js", "_tasks-DpslJtm_.js",
"index.html" "index.html"
], ],
"css": [ "css": [
@@ -98,22 +98,22 @@
] ]
}, },
"src/pages/ReportPage.vue": { "src/pages/ReportPage.vue": {
"file": "assets/ReportPage-BkB6FuHA.js", "file": "assets/ReportPage-Dnk3wsl3.js",
"name": "ReportPage", "name": "ReportPage",
"src": "src/pages/ReportPage.vue", "src": "src/pages/ReportPage.vue",
"isDynamicEntry": true, "isDynamicEntry": true,
"imports": [ "imports": [
"index.html", "index.html",
"_email-BfqhxXOq.js", "_email-BsKBHU5S.js",
"_tasks-BtWKY-g7.js", "_tasks-DpslJtm_.js",
"_update-BrAMPxiF.js" "_update-DcFD-YxU.js"
], ],
"css": [ "css": [
"assets/ReportPage-TpqQWWvU.css" "assets/ReportPage-TpqQWWvU.css"
] ]
}, },
"src/pages/SettingsPage.vue": { "src/pages/SettingsPage.vue": {
"file": "assets/SettingsPage-CeJoz6yA.js", "file": "assets/SettingsPage-YOW1Apwk.js",
"name": "SettingsPage", "name": "SettingsPage",
"src": "src/pages/SettingsPage.vue", "src": "src/pages/SettingsPage.vue",
"isDynamicEntry": true, "isDynamicEntry": true,
@@ -125,12 +125,12 @@
] ]
}, },
"src/pages/SystemPage.vue": { "src/pages/SystemPage.vue": {
"file": "assets/SystemPage-Dmtz_emI.js", "file": "assets/SystemPage-DCcH_SAQ.js",
"name": "SystemPage", "name": "SystemPage",
"src": "src/pages/SystemPage.vue", "src": "src/pages/SystemPage.vue",
"isDynamicEntry": true, "isDynamicEntry": true,
"imports": [ "imports": [
"_update-BrAMPxiF.js", "_update-DcFD-YxU.js",
"index.html" "index.html"
], ],
"css": [ "css": [
@@ -138,12 +138,12 @@
] ]
}, },
"src/pages/UsersPage.vue": { "src/pages/UsersPage.vue": {
"file": "assets/UsersPage-JTbL8-nm.js", "file": "assets/UsersPage-DhTO_5zp.js",
"name": "UsersPage", "name": "UsersPage",
"src": "src/pages/UsersPage.vue", "src": "src/pages/UsersPage.vue",
"isDynamicEntry": true, "isDynamicEntry": true,
"imports": [ "imports": [
"_users-CToznuvL.js", "_users-CC9BckjT.js",
"index.html" "index.html"
], ],
"css": [ "css": [

View File

@@ -0,0 +1 @@
.page-stack[data-v-ff849557]{display:flex;flex-direction:column;gap:12px}.toolbar[data-v-ff849557]{display:flex;gap:10px;align-items:center;flex-wrap:wrap}.card[data-v-ff849557]{border-radius:var(--app-radius);border:1px solid var(--app-border)}.section-head[data-v-ff849557]{display:flex;align-items:baseline;justify-content:space-between;gap:12px;margin-bottom:12px;flex-wrap:wrap}.section-title[data-v-ff849557]{margin:0;font-size:14px;font-weight:800}.help[data-v-ff849557]{margin-top:8px;font-size:12px;color:var(--app-muted)}.table-wrap[data-v-ff849557]{overflow-x:auto}.stat-card[data-v-ff849557]{border-radius:var(--app-radius);border:1px solid var(--app-border)}.stat-value[data-v-ff849557]{font-size:20px;font-weight:900;line-height:1.1}.stat-label[data-v-ff849557]{margin-top:6px;font-size:12px;color:var(--app-muted)}.ok[data-v-ff849557]{color:#047857}.err[data-v-ff849557]{color:#b91c1c}.sub-stats[data-v-ff849557]{display:flex;flex-wrap:wrap;gap:8px;margin-top:12px}.ellipsis[data-v-ff849557]{display:inline-block;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.pagination[data-v-ff849557]{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-top:14px;flex-wrap:wrap}.page-hint[data-v-ff849557]{font-size:12px}.dialog-actions[data-v-ff849557]{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.spacer[data-v-ff849557]{flex:1}

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
.page-stack[data-v-03fa4932]{display:flex;flex-direction:column;gap:12px}.toolbar[data-v-03fa4932]{display:flex;gap:10px;align-items:center;flex-wrap:wrap}.card[data-v-03fa4932]{border-radius:var(--app-radius);border:1px solid var(--app-border)}.section-head[data-v-03fa4932]{display:flex;align-items:baseline;justify-content:space-between;gap:12px;margin-bottom:12px;flex-wrap:wrap}.section-title[data-v-03fa4932]{margin:0;font-size:14px;font-weight:800}.help[data-v-03fa4932]{margin-top:8px;font-size:12px;color:var(--app-muted)}.table-wrap[data-v-03fa4932]{overflow-x:auto}.stat-card[data-v-03fa4932]{border-radius:var(--app-radius);border:1px solid var(--app-border)}.stat-value[data-v-03fa4932]{font-size:20px;font-weight:900;line-height:1.1}.stat-label[data-v-03fa4932]{margin-top:6px;font-size:12px;color:var(--app-muted)}.ok[data-v-03fa4932]{color:#047857}.err[data-v-03fa4932]{color:#b91c1c}.sub-stats[data-v-03fa4932]{display:flex;flex-wrap:wrap;gap:8px;margin-top:12px}.ellipsis[data-v-03fa4932]{display:inline-block;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.pagination[data-v-03fa4932]{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-top:14px;flex-wrap:wrap}.page-hint[data-v-03fa4932]{font-size:12px}.dialog-actions[data-v-03fa4932]{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.spacer[data-v-03fa4932]{flex:1}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
import{S as m,_ as T,r as p,e as h,f as r,g as a,w as s,n as u,x as k,y as x,L as i,K as b}from"./index-Da0EvMWc.js";async function C(o){const{data:t}=await m.put("/admin/username",{new_username:o});return t}async function P(o){const{data:t}=await m.put("/admin/password",{new_password:o});return t}async function U(){const{data:o}=await m.post("/logout");return o}const E={class:"page-stack"},N={__name:"SettingsPage",setup(o){const t=p(""),d=p(""),n=p(!1);async function f(){try{await U()}catch{}finally{window.location.href="/yuyx"}}async function V(){const l=t.value.trim();if(!l){i.error("请输入新用户名");return}try{await b.confirm(`确定将管理员用户名修改为「${l}」吗?修改后需要重新登录。`,"修改用户名",{confirmButtonText:"确认修改",cancelButtonText:"取消",type:"warning"})}catch{return}n.value=!0;try{await C(l),i.success("用户名修改成功,请重新登录"),t.value="",setTimeout(f,1200)}catch{}finally{n.value=!1}}async function B(){const l=d.value;if(!l){i.error("请输入新密码");return}if(l.length<6){i.error("密码至少6个字符");return}try{await b.confirm("确定修改管理员密码吗?修改后需要重新登录。","修改密码",{confirmButtonText:"确认修改",cancelButtonText:"取消",type:"warning"})}catch{return}n.value=!0;try{await P(l),i.success("密码修改成功,请重新登录"),d.value="",setTimeout(f,1200)}catch{}finally{n.value=!1}}return(l,e)=>{const w=u("el-input"),y=u("el-form-item"),v=u("el-form"),_=u("el-button"),g=u("el-card");return k(),h("div",E,[e[7]||(e[7]=r("div",{class:"app-page-title"},[r("h2",null,"设置"),r("span",{class:"app-muted"},"管理员账号设置")],-1)),a(g,{shadow:"never","body-style":{padding:"16px"},class:"card"},{default:s(()=>[e[3]||(e[3]=r("h3",{class:"section-title"},"修改管理员用户名",-1)),a(v,{"label-width":"120px"},{default:s(()=>[a(y,{label:"新用户名"},{default:s(()=>[a(w,{modelValue:t.value,"onUpdate:modelValue":e[0]||(e[0]=c=>t.value=c),placeholder:"输入新用户名",disabled:n.value},null,8,["modelValue","disabled"])]),_:1})]),_:1}),a(_,{type:"primary",loading:n.value,onClick:V},{default:s(()=>[...e[2]||(e[2]=[x("保存用户名",-1)])]),_:1},8,["loading"])]),_:1}),a(g,{shadow:"never","body-style":{padding:"16px"},class:"card"},{default:s(()=>[e[5]||(e[5]=r("h3",{class:"section-title"},"修改管理员密码",-1)),a(v,{"label-width":"120px"},{default:s(()=>[a(y,{label:"新密码"},{default:s(()=>[a(w,{modelValue:d.value,"onUpdate:modelValue":e[1]||(e[1]=c=>d.value=c),type:"password","show-password":"",placeholder:"输入新密码",disabled:n.value},null,8,["modelValue","disabled"])]),_:1})]),_:1}),a(_,{type:"primary",loading:n.value,onClick:B},{default:s(()=>[...e[4]||(e[4]=[x("保存密码",-1)])]),_:1},8,["loading"]),e[6]||(e[6]=r("div",{class:"help"},"建议使用更强密码至少8位且包含字母与数字。",-1))]),_:1})])}}},A=T(N,[["__scopeId","data-v-2f4b840f"]]);export{A as default}; import{S as m,_ as T,r as p,e as u,f as h,g as k,h as r,j as a,w as s,p as x,L as i,K as b}from"./index-CdjS44Uj.js";async function C(o){const{data:t}=await m.put("/admin/username",{new_username:o});return t}async function P(o){const{data:t}=await m.put("/admin/password",{new_password:o});return t}async function U(){const{data:o}=await m.post("/logout");return o}const E={class:"page-stack"},N={__name:"SettingsPage",setup(o){const t=p(""),d=p(""),n=p(!1);async function f(){try{await U()}catch{}finally{window.location.href="/yuyx"}}async function V(){const l=t.value.trim();if(!l){i.error("请输入新用户名");return}try{await b.confirm(`确定将管理员用户名修改为「${l}」吗?修改后需要重新登录。`,"修改用户名",{confirmButtonText:"确认修改",cancelButtonText:"取消",type:"warning"})}catch{return}n.value=!0;try{await C(l),i.success("用户名修改成功,请重新登录"),t.value="",setTimeout(f,1200)}catch{}finally{n.value=!1}}async function B(){const l=d.value;if(!l){i.error("请输入新密码");return}if(l.length<6){i.error("密码至少6个字符");return}try{await b.confirm("确定修改管理员密码吗?修改后需要重新登录。","修改密码",{confirmButtonText:"确认修改",cancelButtonText:"取消",type:"warning"})}catch{return}n.value=!0;try{await P(l),i.success("密码修改成功,请重新登录"),d.value="",setTimeout(f,1200)}catch{}finally{n.value=!1}}return(l,e)=>{const w=u("el-input"),v=u("el-form-item"),y=u("el-form"),_=u("el-button"),g=u("el-card");return k(),h("div",E,[e[7]||(e[7]=r("div",{class:"app-page-title"},[r("h2",null,"设置"),r("span",{class:"app-muted"},"管理员账号设置")],-1)),a(g,{shadow:"never","body-style":{padding:"16px"},class:"card"},{default:s(()=>[e[3]||(e[3]=r("h3",{class:"section-title"},"修改管理员用户名",-1)),a(y,{"label-width":"120px"},{default:s(()=>[a(v,{label:"新用户名"},{default:s(()=>[a(w,{modelValue:t.value,"onUpdate:modelValue":e[0]||(e[0]=c=>t.value=c),placeholder:"输入新用户名",disabled:n.value},null,8,["modelValue","disabled"])]),_:1})]),_:1}),a(_,{type:"primary",loading:n.value,onClick:V},{default:s(()=>[...e[2]||(e[2]=[x("保存用户名",-1)])]),_:1},8,["loading"])]),_:1}),a(g,{shadow:"never","body-style":{padding:"16px"},class:"card"},{default:s(()=>[e[5]||(e[5]=r("h3",{class:"section-title"},"修改管理员密码",-1)),a(y,{"label-width":"120px"},{default:s(()=>[a(v,{label:"新密码"},{default:s(()=>[a(w,{modelValue:d.value,"onUpdate:modelValue":e[1]||(e[1]=c=>d.value=c),type:"password","show-password":"",placeholder:"输入新密码",disabled:n.value},null,8,["modelValue","disabled"])]),_:1})]),_:1}),a(_,{type:"primary",loading:n.value,onClick:B},{default:s(()=>[...e[4]||(e[4]=[x("保存密码",-1)])]),_:1},8,["loading"]),e[6]||(e[6]=r("div",{class:"help"},"建议使用更强密码至少8位且包含字母与数字。",-1))]),_:1})])}}},A=T(N,[["__scopeId","data-v-2f4b840f"]]);export{A as default};

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
import{S as n}from"./index-Da0EvMWc.js";async function i(){const{data:a}=await n.get("/email/settings");return a}async function e(a){const{data:t}=await n.post("/email/settings",a);return t}async function c(){const{data:a}=await n.get("/email/stats");return a}async function o(a){const{data:t}=await n.get("/email/logs",{params:a});return t}async function l(a){const{data:t}=await n.post("/email/logs/cleanup",{days:a});return t}export{i as a,o as b,l as c,c as f,e as u}; import{S as n}from"./index-CdjS44Uj.js";async function i(){const{data:a}=await n.get("/email/settings");return a}async function e(a){const{data:t}=await n.post("/email/settings",a);return t}async function c(){const{data:a}=await n.get("/email/stats");return a}async function o(a){const{data:t}=await n.get("/email/logs",{params:a});return t}async function l(a){const{data:t}=await n.post("/email/logs/cleanup",{days:a});return t}export{o as a,i as b,l as c,c as f,e as u};

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
import{S as a}from"./index-Da0EvMWc.js";async function c(){const{data:t}=await a.get("/server/info");return t}async function e(){const{data:t}=await a.get("/docker_stats");return t}async function o(){const{data:t}=await a.get("/task/stats");return t}async function r(){const{data:t}=await a.get("/task/running");return t}async function i(t){const{data:s}=await a.get("/task/logs",{params:t});return s}async function f(t){const{data:s}=await a.post("/task/logs/clear",{days:t});return s}export{r as a,c as b,e as c,i as d,f as e,o as f}; import{S as a}from"./index-CdjS44Uj.js";async function c(){const{data:t}=await a.get("/server/info");return t}async function e(){const{data:t}=await a.get("/docker_stats");return t}async function o(){const{data:t}=await a.get("/task/stats");return t}async function r(){const{data:t}=await a.get("/task/running");return t}async function i(t){const{data:s}=await a.get("/task/logs",{params:t});return s}async function f(t){const{data:s}=await a.post("/task/logs/clear",{days:t});return s}export{r as a,c as b,e as c,i as d,f as e,o as f};

View File

@@ -1 +1 @@
import{S as a}from"./index-Da0EvMWc.js";async function s(){const{data:t}=await a.get("/system/config");return t}async function c(t){const{data:e}=await a.post("/system/config",t);return e}async function u(){const{data:t}=await a.post("/schedule/execute",{});return t}async function o(){const{data:t}=await a.get("/update/status");return t}async function r(){const{data:t}=await a.get("/update/result");return t}async function d(t={}){const{data:e}=await a.get("/update/log",{params:t});return e}async function i(){const{data:t}=await a.post("/update/check",{});return t}async function f(t={}){const{data:e}=await a.post("/update/run",t);return e}export{o as a,r as b,d as c,f as d,u as e,s as f,i as r,c as u}; import{S as a}from"./index-CdjS44Uj.js";async function s(){const{data:t}=await a.get("/system/config");return t}async function c(t){const{data:e}=await a.post("/system/config",t);return e}async function u(){const{data:t}=await a.post("/schedule/execute",{});return t}async function o(){const{data:t}=await a.get("/update/status");return t}async function r(){const{data:t}=await a.get("/update/result");return t}async function d(t={}){const{data:e}=await a.get("/update/log",{params:t});return e}async function i(){const{data:t}=await a.post("/update/check",{});return t}async function f(t={}){const{data:e}=await a.post("/update/run",t);return e}export{o as a,r as b,d as c,f as d,u as e,s as f,i as r,c as u};

View File

@@ -1 +1 @@
import{S as t}from"./index-Da0EvMWc.js";async function n(){const{data:s}=await t.get("/users");return s}async function o(s){const{data:a}=await t.post(`/users/${s}/approve`);return a}async function c(s){const{data:a}=await t.post(`/users/${s}/reject`);return a}async function i(s){const{data:a}=await t.delete(`/users/${s}`);return a}async function u(s,a){const{data:e}=await t.post(`/users/${s}/vip`,{days:a});return e}async function p(s){const{data:a}=await t.delete(`/users/${s}/vip`);return a}async function d(s,a){const{data:e}=await t.post(`/users/${s}/reset_password`,{new_password:a});return e}export{o as a,p as b,d as c,i as d,n as f,c as r,u as s}; import{S as t}from"./index-CdjS44Uj.js";async function n(){const{data:s}=await t.get("/users");return s}async function o(s){const{data:a}=await t.post(`/users/${s}/approve`);return a}async function c(s){const{data:a}=await t.post(`/users/${s}/reject`);return a}async function i(s){const{data:a}=await t.delete(`/users/${s}`);return a}async function u(s,a){const{data:e}=await t.post(`/users/${s}/vip`,{days:a});return e}async function p(s){const{data:a}=await t.delete(`/users/${s}/vip`);return a}async function d(s,a){const{data:e}=await t.post(`/users/${s}/reset_password`,{new_password:a});return e}export{o as a,p as b,d as c,i as d,n as f,c as r,u as s};

View File

@@ -5,7 +5,7 @@
<link rel="icon" type="image/svg+xml" href="./vite.svg" /> <link rel="icon" type="image/svg+xml" href="./vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>后台管理 - 知识管理平台</title> <title>后台管理 - 知识管理平台</title>
<script type="module" crossorigin src="./assets/index-Da0EvMWc.js"></script> <script type="module" crossorigin src="./assets/index-CdjS44Uj.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-EWm4DZW8.css"> <link rel="stylesheet" crossorigin href="./assets/index-EWm4DZW8.css">
</head> </head>
<body> <body>

View File

@@ -1355,6 +1355,7 @@
<option value="reset">密码重置</option> <option value="reset">密码重置</option>
<option value="bind">邮箱绑定</option> <option value="bind">邮箱绑定</option>
<option value="task_complete">任务完成</option> <option value="task_complete">任务完成</option>
<option value="security_alert">安全告警</option>
</select> </select>
<select id="emailLogStatusFilter" onchange="loadEmailLogs()" style="padding: 6px 10px; border: 1px solid #ddd; border-radius: 5px; font-size: 12px;"> <select id="emailLogStatusFilter" onchange="loadEmailLogs()" style="padding: 6px 10px; border: 1px solid #ddd; border-radius: 5px; font-size: 12px;">
<option value="">全部状态</option> <option value="">全部状态</option>
@@ -3424,7 +3425,8 @@
'register': '注册验证', 'register': '注册验证',
'reset': '密码重置', 'reset': '密码重置',
'bind': '邮箱绑定', 'bind': '邮箱绑定',
'task_complete': '任务完成' 'task_complete': '任务完成',
'security_alert': '安全告警'
}; };
let html = '<div class="table-container"><table style="width: 100%; font-size: 12px;"><thead><tr>'; let html = '<div class="table-container"><table style="width: 100%; font-size: 12px;"><thead><tr>';