Files
abot/admin/dashboard/server.py
Liu 342b4c0065 修复后台弱密码提示误判并恢复server.py编码
1. 修复数据库账号存在时仍回退 config.toml 判断,导致每次登录重复提示弱密码的问题。
2. 补齐默认管理员密码从旧配置迁移到数据库的同步逻辑,兼容历史部署。
3. 恢复 server.py 为可读 UTF-8 中文版本,并补充后台登录与弱密码判定的回归测试。
2026-05-01 10:49:38 +08:00

457 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""
统计看板服务器。
这里负责:
1. 读取 Dashboard 配置并创建 Flask 应用;
2. 初始化后台依赖对象、蓝图和静态资源路由;
3. 提供后台登录限流、弱密码提醒等通用安全能力。
"""
import os
import secrets
import sys
import threading
import time
from datetime import timedelta
import toml
from flask import Flask, send_from_directory
from loguru import logger
from db.admin_account_db import AdminAccountDBOperator
from db.contacts_db import ContactsDBOperator
from db.emoji_asset_db import EmojiAssetDB
from db.fun_command_rule_db import FunCommandRuleDBOperator
from db.member_context_db import MemberContextDBOperator
from db.message_storage import MessageStorageDB
from db.stats_db import StatsDBOperator
from db.task_db import TaskDBOperator
from utils.fun_command_rule_service import FunCommandRuleService
from wechat_ipad import WechatAPIClient
# 添加项目根目录到系统路径,确保可以导入项目模块。
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")))
class DashboardServer:
"""统计看板服务器。"""
def __init__(
self,
host: str = None,
port: int = None,
username: str = None,
password: str = None,
robot_instance=None,
):
# 先加载配置文件,再用传入参数覆盖,保证 CLI/外部调用具备最高优先级。
self.config = self._load_dashboard_config()
self.host = host or self.config.get("server", {}).get("host", "0.0.0.0")
self.port = port or self.config.get("server", {}).get("port", 8888)
self.username = username or self.config.get("auth", {}).get("username", "admin")
self.password = password or self.config.get("auth", {}).get("password", "admin123")
self.LOG = logger
self.LOG.info(f"Dashboard配置加载完成: 服务器将运行在 {self.host}:{self.port}")
# 登录失败限流的进程内兜底缓存:
# 1. 优先用 Redis 做共享限流;
# 2. 如果 Redis 暂时不可用,至少本进程内仍有基本防护;
# 3. 这里会被并发访问,因此需要配合线程锁使用。
self._auth_runtime_lock = threading.Lock()
self._auth_failures = {}
if not robot_instance:
self.LOG.error("未提供Robot实例Dashboard无法正常工作")
raise ValueError("必须提供Robot实例")
self.db_manager = robot_instance.db_manager
self.stats_db = StatsDBOperator(self.db_manager)
# Dashboard 启动可能早于 iPad 登录完成:
# 1. 此时 Robot 上的 message_storage 可能尚未绑定完成;
# 2. 但后台页面仍然依赖消息存储与表情资产库;
# 3. 因此优先复用 Robot 现成对象,没有时再安全回退到 DB 层对象。
self.message_storage = getattr(robot_instance, "message_storage", None) or MessageStorageDB(self.db_manager)
self.emoji_asset_db = getattr(self.message_storage, "emoji_asset_db", None) or EmojiAssetDB(self.db_manager)
self.contact_db: ContactsDBOperator = ContactsDBOperator(self.db_manager)
self.member_context_db = MemberContextDBOperator(self.db_manager)
self.task_db: TaskDBOperator = TaskDBOperator(self.db_manager)
# 后台管理员账号数据层:用于登录鉴权、弱密码判断和在线改密。
self.admin_account_db = AdminAccountDBOperator(self.db_manager)
self.system_job_db = robot_instance.system_job_db
self.system_job_loader = robot_instance.system_job_loader
self.plugin_schedule_db = robot_instance.plugin_schedule_db
self.plugin_schedule_manager = robot_instance.plugin_schedule_manager
self.group_plugin_config_db = robot_instance.group_plugin_config_db
self.llm_catalog_db = robot_instance.llm_catalog_db
self.group_plugin_config_service = robot_instance.group_plugin_config_service
# 趣味指令规则服务:用于“文案/事件触发多媒体玩法回复”的后台配置与缓存。
self.fun_command_rule_db = FunCommandRuleDBOperator(self.db_manager)
self.fun_command_rule_service = FunCommandRuleService(
db_operator=self.fun_command_rule_db,
redis_client=self.db_manager.get_redis_connection(),
local_ttl_seconds=30,
)
self.fun_command_rule_service.init_tables()
self.fun_command_rule_service.refresh_cache()
# 其余运行时对象直接复用 Robot 已初始化实例,避免重复构造。
self.contact_manager = robot_instance.contact_manager
self.plugin_manager = robot_instance.plugin_manager
self.plugin_registry = robot_instance.plugin_registry
self.client: WechatAPIClient = robot_instance.ipad_bot
self.robot = robot_instance
self.member_context_plugin = self.plugin_manager.plugins.get("成员交互摘要")
self.member_context_service = getattr(self.member_context_plugin, "service", None)
self.LOG.info("使用Robot实例的对象进行初始化")
# 初始化后台管理员账号表,并将旧配置中的默认账号平滑迁移进数据库。
try:
table_ok = self.admin_account_db.init_tables()
if not table_ok:
self.LOG.warning("初始化后台账号表失败,将回退旧配置账号模式")
else:
seed_ok = self.admin_account_db.ensure_default_admin(self.username, self.password, "系统管理员")
if seed_ok:
self.LOG.info("后台账号体系初始化完成(数据库账号模式已可用)")
else:
self.LOG.warning("后台账号种子初始化失败,请检查配置中的默认账号信息")
except Exception as e:
self.LOG.error(f"初始化后台账号体系失败,将回退旧配置账号模式: {e}")
self.app = self._create_app()
self._stop_event = threading.Event()
self._server = None # Werkzeug 服务实例会在 run() 时写入这里。
def _load_dashboard_config(self):
"""加载 Dashboard 配置文件。"""
try:
config_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.toml")
if os.path.exists(config_path):
with open(config_path, "r", encoding="utf-8") as f:
return toml.load(f)
# 如果配置文件不存在,则创建默认配置,方便本地快速启动。
default_config = {
"server": {"host": "0.0.0.0", "port": 8888},
"auth": {"username": "admin", "password": "admin123"},
}
with open(config_path, "w", encoding="utf-8") as f:
toml.dump(default_config, f)
return default_config
except Exception as e:
self.LOG.error(f"加载Dashboard配置文件失败: {e}")
return {
"server": {"host": "0.0.0.0", "port": 8888},
"auth": {"username": "admin", "password": "admin123"},
}
def _create_app(self) -> Flask:
"""创建 Flask 应用。"""
template_folder = os.path.join(os.path.dirname(os.path.abspath(__file__)), "templates")
app = Flask(__name__, template_folder=template_folder)
auth_config = self.config.get("auth", {}) or {}
session_timeout_minutes = int(auth_config.get("session_timeout_minutes", 480) or 480)
cookie_secure = bool(auth_config.get("cookie_secure", False))
configured_secret = str(
os.environ.get("ABOT_DASHBOARD_SECRET_KEY")
or auth_config.get("secret_key", "")
or ""
).strip()
if configured_secret:
app.secret_key = configured_secret
else:
# 未显式配置 secret_key 时,用进程级随机密钥兜底:
# 1. 安全性优于硬编码固定值;
# 2. 服务重启后旧 session 会失效,但这是可接受的安全代价;
# 3. 同时输出 warning提醒后续通过配置或环境变量固定注入。
app.secret_key = secrets.token_hex(32)
self.LOG.warning("未配置 Dashboard secret_key已使用进程级随机密钥重启后现有登录会失效")
# 关闭模板缓存,便于开发时实时看到页面修改结果。
app.config["TEMPLATES_AUTO_RELOAD"] = True
app.config["SESSION_COOKIE_HTTPONLY"] = True
app.config["SESSION_COOKIE_SAMESITE"] = str(auth_config.get("cookie_samesite", "Lax") or "Lax")
app.config["SESSION_COOKIE_SECURE"] = cookie_secure
app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(minutes=max(15, session_timeout_minutes))
import logging
logging.getLogger("werkzeug").setLevel(logging.ERROR)
# 将 DashboardServer 实例挂到 app 上,方便蓝图在请求期取用。
app.dashboard_server = self
static_folder = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static")
@app.route("/static/<path:filename>")
def serve_static(filename):
return send_from_directory(static_folder, filename)
project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
images_dir = os.path.join(project_root, "static", "images")
os.makedirs(images_dir, exist_ok=True)
@app.route("/static/images/<path:filename>")
def serve_images(filename):
return send_from_directory(images_dir, filename)
@app.route("/favicon.ico")
def favicon():
return send_from_directory(
os.path.join(app.root_path, "static"),
"favicon.ico",
mimetype="image/vnd.microsoft.icon",
)
self._register_blueprints(app)
return app
def get_auth_policy(self) -> dict:
"""读取后台认证策略配置。"""
auth_config = self.config.get("auth", {}) or {}
return {
"max_failed_attempts": max(3, int(auth_config.get("max_failed_attempts", 5) or 5)),
"lock_seconds": max(60, int(auth_config.get("lock_seconds", 900) or 900)),
"session_timeout_minutes": max(15, int(auth_config.get("session_timeout_minutes", 480) or 480)),
}
@staticmethod
def _build_login_guard_key(username: str, remote_ip: str) -> str:
"""构建登录限流键。"""
return f"{str(username or '').strip().lower()}::{str(remote_ip or '').strip() or 'unknown'}"
def _get_login_failure_record(self, guard_key: str) -> dict:
"""读取登录失败记录,优先 Redis失败时回退到进程内缓存。"""
try:
redis_conn = self.db_manager.get_redis_connection()
raw_value = redis_conn.get(f"dashboard:auth:fail:{guard_key}")
if raw_value:
parts = str(raw_value).split("|", 1)
return {
"count": int(parts[0] or 0),
"first_failed_at": float(parts[1] or 0) if len(parts) > 1 else 0.0,
}
except Exception:
pass
with self._auth_runtime_lock:
record = dict(self._auth_failures.get(guard_key, {}) or {})
expire_at = float(record.get("expire_at") or 0.0)
if expire_at > 0 and expire_at <= time.time():
self._auth_failures.pop(guard_key, None)
record = {}
return {
"count": int(record.get("count") or 0),
"first_failed_at": float(record.get("first_failed_at") or 0.0),
}
def _save_login_failure_record(
self,
guard_key: str,
count: int,
first_failed_at: float,
ttl_seconds: int,
) -> None:
"""保存登录失败记录,优先 Redis失败时回退到进程内缓存。"""
payload = f"{int(count)}|{float(first_failed_at)}"
try:
redis_conn = self.db_manager.get_redis_connection()
redis_conn.setex(f"dashboard:auth:fail:{guard_key}", ttl_seconds, payload)
return
except Exception:
pass
expire_at = time.time() + ttl_seconds
with self._auth_runtime_lock:
self._auth_failures[guard_key] = {
"count": int(count),
"first_failed_at": float(first_failed_at),
"expire_at": expire_at,
}
def clear_login_failures(self, username: str, remote_ip: str) -> None:
"""清理登录失败记录。"""
guard_key = self._build_login_guard_key(username, remote_ip)
try:
redis_conn = self.db_manager.get_redis_connection()
redis_conn.delete(f"dashboard:auth:fail:{guard_key}")
except Exception:
pass
with self._auth_runtime_lock:
self._auth_failures.pop(guard_key, None)
def get_login_lock_status(self, username: str, remote_ip: str) -> dict:
"""获取当前账号/IP 组合的登录锁定状态。"""
policy = self.get_auth_policy()
record = self._get_login_failure_record(self._build_login_guard_key(username, remote_ip))
count = int(record.get("count") or 0)
first_failed_at = float(record.get("first_failed_at") or 0.0)
if count < policy["max_failed_attempts"] or first_failed_at <= 0:
return {"locked": False, "remaining_seconds": 0, "failed_count": count}
elapsed = max(0, int(time.time() - first_failed_at))
remaining_seconds = max(0, policy["lock_seconds"] - elapsed)
if remaining_seconds <= 0:
self.clear_login_failures(username, remote_ip)
return {"locked": False, "remaining_seconds": 0, "failed_count": 0}
return {"locked": True, "remaining_seconds": remaining_seconds, "failed_count": count}
def mark_login_failure(self, username: str, remote_ip: str) -> dict:
"""记录一次登录失败,并返回最新锁定状态。"""
policy = self.get_auth_policy()
guard_key = self._build_login_guard_key(username, remote_ip)
record = self._get_login_failure_record(guard_key)
count = int(record.get("count") or 0)
first_failed_at = float(record.get("first_failed_at") or 0.0)
now_ts = time.time()
if first_failed_at <= 0:
first_failed_at = now_ts
count += 1
self._save_login_failure_record(guard_key, count, first_failed_at, policy["lock_seconds"])
return self.get_login_lock_status(username, remote_ip)
def should_force_password_change(self, username: str) -> bool:
"""判断当前管理员是否应该被强制提示修改密码。"""
normalized_username = str(username or "").strip()
admin_db = getattr(self, "admin_account_db", None)
if admin_db:
try:
admin_row = admin_db.get_admin_by_username(normalized_username)
# 只要数据库里已经存在该账号,就完全以数据库中的当前密码状态为准。
# 这样可以避免用户已经在后台把密码改强后,仍被 config.toml 中的旧默认密码反复误判。
if admin_row:
return bool(admin_db.is_using_risky_password(normalized_username))
except Exception as e:
self.LOG.error(f"判断后台弱密码状态时出现异常,将回退配置判断: {e}")
# 数据库体系不可用或该账号尚未迁移进数据库时,再回退配置值判断。
fallback_username = str(self.username or "").strip()
fallback_password = str(self.password or "").strip()
return (
normalized_username == fallback_username
and fallback_password in getattr(admin_db, "RISKY_PASSWORDS", {"admin123", "admin"})
)
def _register_blueprints(self, app):
"""注册所有蓝图。"""
# 在函数内部导入蓝图,避免循环导入。
from admin.dashboard.blueprints.auth import auth_bp
from admin.dashboard.blueprints.contacts import contacts_bp
from admin.dashboard.blueprints.file_browser import file_browser_bp
from admin.dashboard.blueprints.friend_circle import friend_circle_bp
from admin.dashboard.blueprints.fun_command_rules import fun_command_rules_bp
from admin.dashboard.blueprints.group_plugin_config import group_plugin_config_bp
from admin.dashboard.blueprints.main import main_bp
from admin.dashboard.blueprints.message_push import message_push_bp
from admin.dashboard.blueprints.messages import messages_bp
from admin.dashboard.blueprints.plugin_routes import plugin_routes
from admin.dashboard.blueprints.plugin_schedules import plugin_schedules_bp
from admin.dashboard.blueprints.robot import robot_bp
from admin.dashboard.blueprints.stats import stats_bp
from admin.dashboard.blueprints.system import system_bp
from admin.dashboard.blueprints.system_jobs import system_jobs_bp
from admin.dashboard.blueprints.trendradar_webhook import trendradar_webhook_bp
from admin.dashboard.blueprints.virtual_group import virtual_group_bp
app.register_blueprint(virtual_group_bp, url_prefix="/virtual_group")
app.register_blueprint(auth_bp)
app.register_blueprint(main_bp)
app.register_blueprint(contacts_bp)
app.register_blueprint(robot_bp)
app.register_blueprint(messages_bp)
app.register_blueprint(stats_bp)
app.register_blueprint(system_bp)
app.register_blueprint(plugin_routes)
app.register_blueprint(file_browser_bp)
app.register_blueprint(message_push_bp)
app.register_blueprint(friend_circle_bp)
app.register_blueprint(system_jobs_bp)
app.register_blueprint(plugin_schedules_bp)
app.register_blueprint(group_plugin_config_bp)
app.register_blueprint(fun_command_rules_bp)
app.register_blueprint(trendradar_webhook_bp)
self.LOG.info("所有蓝图已注册")
def run(self):
"""运行服务器。"""
from werkzeug.serving import make_server
import logging
logging.getLogger("werkzeug").setLevel(logging.ERROR)
self.LOG.info(f"启动服务器: {self.host}:{self.port}")
try:
# Dashboard 存在文件浏览、统计查询等慢请求,单线程 WSGI 一旦被占住会拖死整个后台。
# 改为 threaded server 可以避免某个接口阻塞时所有页面一起无响应。
self._server = make_server(self.host, self.port, self.app, threaded=True)
self._server.serve_forever()
except Exception as e:
self.LOG.error(f"服务器运行失败: {e}")
self._stop_event.set()
def stop(self):
"""停止服务器。"""
self.LOG.info("正在停止服务器...")
self._stop_event.set()
if self._server:
self._server.shutdown()
self.LOG.info("服务器已停止")
def get_current_user_info(self):
"""获取当前登录的微信用户信息。"""
try:
if not self.client:
self.LOG.error("client实例不可用无法获取当前用户信息")
return {"success": False, "message": "实例不可用"}
try:
if self.robot is None:
raise ValueError("机器人对象未初始化")
user_data = {
"wxid": getattr(self.robot, "wxid", ""),
"nickName": getattr(self.robot, "nickname", ""),
"mobile": getattr(self.robot, "phone", ""),
"smallHeadImgUrl": getattr(self.robot, "head_image", ""),
"signature": getattr(self.robot, "signature", ""),
}
except (AttributeError, ValueError) as e:
print(f"获取用户信息出错: {str(e)}")
user_data = {
"wxid": self.robot.wxid,
"nickName": self.robot.nickname,
"mobile": self.robot.phone,
"smallHeadImgUrl": self.robot.head_image,
"signature": self.robot.signature,
}
if not user_data:
return {"success": False, "message": "未获取到用户数据"}
return {
"success": True,
"data": {
"wx_id": user_data.get("wxid", ""),
"nickname": user_data.get("nickName", "未知用户"),
"avatar": user_data.get("smallHeadImgUrl", "logo.png"), # 使用小头像 URL。
"mobile": user_data.get("mobile", ""),
"home": f"{user_data.get('province', '')}-{user_data.get('city', '')}", # 组合省市信息。
"signature": user_data.get("signature", "")[:10],
},
}
except Exception as e:
self.LOG.error(f"获取当前用户信息失败: {e}")
return {"success": False, "message": f"获取用户信息出错: {str(e)}"}