feat: 新增备份恢复与公网加固
- 新增备份管理页与 API:在线快照、自动周期备份、按份数清理、下载、一键恢复(恢复前自动兜底) - 新增 /profile/export,普通用户可导出本人全部数据(不含 Cookie 明文) - 修复 X-Forwarded-For 可伪造导致三道 IP 防线失效,统一走 client_ip() 取客户端地址 - 取消 admin123 硬编码默认口令,留空则生成随机初始口令并仅打印一次 - .dockerignore 排除 backups/ 并加构建期断言,防止密钥随镜像分发 - 新增会话版本号,改密/停用/删除及恢复备份后其他会话立即失效 - 新增容器资源上限、采集跨度硬顶 31 天、重操作最小间隔与并发 409 - 新增访问日志、HSTS 条件下发、口令黑名单、验证码抗模板匹配、instance.json 0600 - 版本号升至 1.4.0,同步更新 README、SECURITY、.env.example 与 compose 配置
这个提交包含在:
+76
-8
@@ -26,13 +26,17 @@
|
||||
* `slot:*` 调度簿记键是**个人级**(每个账号各自记「今天这个槽位跑过没」),
|
||||
虽然时刻本身是实例级的 —— 这两个千万别一起改。
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import sqlite3
|
||||
import threading
|
||||
from datetime import datetime
|
||||
|
||||
from . import config, crypto
|
||||
|
||||
log = logging.getLogger("wb.db")
|
||||
|
||||
_local = threading.local()
|
||||
_init_lock = threading.Lock()
|
||||
_initialized = False
|
||||
@@ -41,11 +45,22 @@ _initialized = False
|
||||
# 1 -> 单用户布局(settings 以 key 为主键,usage_records 以 request_id 为主键)
|
||||
# 2 -> 多用户布局(见 schema.sql 顶部说明)
|
||||
# 3 -> 调度与采集参数从个人级提升为实例级(普通用户只读;见 config.GLOBAL_KEYS)
|
||||
DB_SCHEMA_VERSION = 3
|
||||
# 4 -> users.session_ver(改密/重置/停用后旧会话立即失效)+ backups 备份索引表
|
||||
DB_SCHEMA_VERSION = 4
|
||||
|
||||
# 这些键即使个人作用域没有值,也**不**回落到实例级
|
||||
NO_FALLBACK_KEYS = {"cookie", "user_agent"}
|
||||
|
||||
# 首次初始化时若没有提供管理员口令,生成的随机口令只留在这里,
|
||||
# **绝不写进数据库或审计**(审计里出现口令等于把它永久留档)。
|
||||
# `manage.py init` / entrypoint 负责把它打印到启动日志。
|
||||
_generated_admin_password = None
|
||||
|
||||
|
||||
def generated_admin_password():
|
||||
"""本次进程启动时生成的管理员口令(没有生成过则为 None)。"""
|
||||
return _generated_admin_password
|
||||
|
||||
|
||||
class SecretUnreadable(Exception):
|
||||
"""密文解不开 —— 通常是 data/instance.json 里的 cookie_key 被换过。"""
|
||||
@@ -167,7 +182,8 @@ def _migrate(conn):
|
||||
for col, ddl in (("email", "TEXT"),
|
||||
("status", "TEXT NOT NULL DEFAULT 'active'"),
|
||||
("register_ip", "TEXT"),
|
||||
("last_login_ip", "TEXT")):
|
||||
("last_login_ip", "TEXT"),
|
||||
("session_ver", "INTEGER NOT NULL DEFAULT 0")):
|
||||
if col not in tusers:
|
||||
conn.execute("ALTER TABLE users ADD COLUMN %s %s" % (col, ddl))
|
||||
done.append("users 增加 %s" % col)
|
||||
@@ -264,6 +280,38 @@ def _promote_personal_to_global(conn):
|
||||
return promoted, cleaned
|
||||
|
||||
|
||||
def _create_first_admin(conn, admin_user, admin_password, ts):
|
||||
"""建库后的第一个管理员。
|
||||
|
||||
口令优先级:显式参数 -> 环境变量 `WB_ADMIN_PASSWORD` -> **随机生成**。
|
||||
这里刻意**不再兜底 `admin123`**:对外提供服务时,一个硬编码的默认口令
|
||||
等于「所有按默认配置部署的实例共用同一把钥匙」,而扫描器恰好就在扫它。
|
||||
生成的口令只在启动日志里打印一次,并被提示立即修改。
|
||||
"""
|
||||
global _generated_admin_password
|
||||
from .security import hash_password
|
||||
pwd = (admin_password or config.ADMIN_PASSWORD or "").strip()
|
||||
generated = False
|
||||
if not pwd:
|
||||
pwd = secrets.token_urlsafe(12)
|
||||
generated = True
|
||||
conn.execute(
|
||||
"INSERT INTO users(username,password_hash,display_name,is_admin,status,"
|
||||
" created_at) VALUES(?,?,?,1,'active',?)",
|
||||
(admin_user, hash_password(pwd), "管理员", ts))
|
||||
if generated:
|
||||
_generated_admin_password = pwd
|
||||
# 只进日志,**不进数据库**:审计表里出现口令等于把它永久留档。
|
||||
log.warning("=" * 68)
|
||||
log.warning("首次初始化:已为管理员 %s 生成随机口令 —— 请立即抄走并登录修改", admin_user)
|
||||
log.warning(" 用户名:%s", admin_user)
|
||||
log.warning(" 口 令:%s", pwd)
|
||||
log.warning(" 该口令只在这里显示一次,不会写入数据库、日志文件之外的任何地方。")
|
||||
log.warning(" 下次启动不会再显示(账号已存在)。忘了就用 manage.py passwd 重置。")
|
||||
log.warning("=" * 68)
|
||||
return generated
|
||||
|
||||
|
||||
def init_db(conn=None, create_admin=True, admin_user="admin", admin_password=None):
|
||||
"""建表 / 迁移 / 灌默认配置。可重复执行(幂等)。返回迁移说明列表。"""
|
||||
global _initialized
|
||||
@@ -316,12 +364,7 @@ def init_db(conn=None, create_admin=True, admin_user="admin", admin_password=Non
|
||||
if create_admin:
|
||||
n = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
|
||||
if n == 0:
|
||||
from .security import hash_password
|
||||
pwd = admin_password or "admin123"
|
||||
conn.execute(
|
||||
"INSERT INTO users(username,password_hash,display_name,is_admin,status,"
|
||||
" created_at) VALUES(?,?,?,1,'active',?)",
|
||||
(admin_user, hash_password(pwd), "管理员", ts))
|
||||
_create_first_admin(conn, admin_user, admin_password, ts)
|
||||
_initialized = True
|
||||
return migrated
|
||||
finally:
|
||||
@@ -458,6 +501,31 @@ def user_count(conn):
|
||||
return conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
|
||||
|
||||
|
||||
def session_ver_of(row):
|
||||
"""安全读取 session_ver。
|
||||
|
||||
取不到就返回 0 —— 迁移中途 / 老库尚未 ALTER 时不该因此抛异常,
|
||||
那会把「一次可恢复的登录失效」变成「整站 500」。
|
||||
"""
|
||||
try:
|
||||
if "session_ver" not in row.keys():
|
||||
return 0
|
||||
except AttributeError: # 不是 Row(dict 等)
|
||||
return 0
|
||||
return int(row["session_ver"] or 0)
|
||||
|
||||
|
||||
def bump_session_ver(conn, uid):
|
||||
"""把该账号所有既有会话立即作废(改密 / 管理员重置 / 停用 / 删除前)。
|
||||
|
||||
会话里记着签发时的 session_ver,每个请求回查一次;不等就丢弃会话。
|
||||
少了这一步,「我怀疑会话泄漏了所以改密码」会变成一个假的安心动作 ——
|
||||
旧会话依然有效到 12 小时之后。
|
||||
"""
|
||||
conn.execute("UPDATE users SET session_ver=COALESCE(session_ver,0)+1 WHERE id=?",
|
||||
(int(uid or 0),))
|
||||
|
||||
|
||||
# ---------------- 审计 ----------------
|
||||
def audit(conn, action, actor=None, detail=None, ip=None, uid=0):
|
||||
conn.execute("INSERT INTO audit_log(user_id,at,actor,action,detail,ip)"
|
||||
|
||||
在新工单中引用
屏蔽一个用户