feat(multi-user): 多用户化 + 凭证加密 + 自助注册与图形验证码
数据隔离
- settings / usage_records 主键改为 (user_id, key) / (user_id, request_id),
索引一律以 user_id 打头;collect_runs / audit_log 增加 user_id
- query / collect / scheduler 全链路把 uid 作为 conn 之后的第一个位置参数且无默认值
(漏传直接 TypeError,不会退化成「返回全量」)
- 配置三级回落 个人→实例→DEFAULTS;NO_FALLBACK_KEYS={cookie,user_agent} 不回落
凭证保密
- 新增 workbuddy_portal/crypto.py:手写 ChaCha20(RFC8439 §2.3) + HMAC-SHA256
encrypt-then-MAC,零第三方依赖;主密钥 cookie_key 与 SECRET_KEY 分键位存放
- get_secret() 是取明文的唯一通道;get_settings() 把加密键置空;
secret_state() 只回 {set,chars,tail,broken};升级时自动加密历史明文
注册与验证码
- 新增 /register 与 workbuddy_portal/captcha.py(手写 PNG + 点阵字模 + 干扰线)
- 验证码答案只存服务端表、不进 session,一次性、5 分钟过期、按 purpose 隔离
- allow_register / register_max_per_ip / captcha_policy / captcha_length 四个实例级开关
- 失败限速改为 IP + 用户名双维度;停用账号每请求回查、立即失效
页面
- 新增 /profile(个人中心)与注册页;登录页加验证码与自助注册入口
- /config 增加凭证状态、cookie_broken 告警、实例级设置区;/users 增加邮箱/状态与启停
修复
- base.html 顶层 {% set me %} 覆盖子模板同名变量,导致个人中心「注册于」渲染为空
- WB_COOKIE_SECURE 未写进 compose 的 environment,在 .env 里设了不生效
- 「修改登录密码」提示写「至少 6 位」,与实际策略(≥8 位 + 两类字符)不符
- 「用户管理」删除说明写「可勾选保留」,与页面实际行为不符
- 注册页与 flash 文案里的 **强调** Markdown 字面量
验证与文档
- smoke.py 99 → 165 项断言(多用户隔离 / 凭证保密 / 注册与验证码 / 3 条防回归)
- check_live.py 56 → 83 项断言(新增注册 / 验证码 / 安全响应头一节)
- demo_data.py 造两个账号;shots.py 自动过验证码、重出 11 张截图
- README / SECURITY / ARCHITECTURE / API / DEPLOYMENT / USER-GUIDE / FAQ / CHANGELOG / CONTRIBUTING 同步
这个提交包含在:
+253
-105
@@ -9,23 +9,39 @@
|
||||
* 参数 from/to 为 'YYYY-MM-DD';缺省则不限(即全量)
|
||||
* 列表类接口默认不返回 prompt 全文(占传输量约 80%),只有 /api/top 与
|
||||
/api/records/<request_id> 会带
|
||||
|
||||
**多用户约定**
|
||||
每个接口都只操作 `current_user()["id"]` 那份数据。查询函数要求显式传 uid,
|
||||
所以这里漏传会直接 TypeError(而不是静默返回全量)。
|
||||
`/api/settings` 是唯一的例外:它会回传实例级配置供非管理员只读展示,
|
||||
但**拒绝**非管理员写入实例级键。
|
||||
"""
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
from .. import collect, config, db, query, scheduler
|
||||
from ..security import admin_required, current_user, login_required
|
||||
from .. import collect, config, db, query, scheduler, security
|
||||
from ..security import admin_required, current_user, is_admin, login_required
|
||||
|
||||
bp = Blueprint("api", __name__, url_prefix="/api")
|
||||
|
||||
|
||||
def _uid():
|
||||
u = current_user()
|
||||
return u["id"] if u else 0
|
||||
|
||||
|
||||
def _arg(name, default=None):
|
||||
v = request.args.get(name)
|
||||
return v if v not in (None, "") else default
|
||||
|
||||
|
||||
def _json_body():
|
||||
body = request.get_json(silent=True)
|
||||
return body if isinstance(body, dict) else {}
|
||||
|
||||
|
||||
class BadParam(ValueError):
|
||||
"""查询参数不合法 -> 由 __init__ 的 ValueError 处理器统一转成 400。"""
|
||||
|
||||
@@ -60,7 +76,7 @@ def _int(name, default, lo=1, hi=2000):
|
||||
@bp.get("/manifest")
|
||||
@login_required
|
||||
def api_manifest():
|
||||
return jsonify(query.manifest(db.get_db()))
|
||||
return jsonify(query.manifest(db.get_db(), _uid()))
|
||||
|
||||
|
||||
@bp.get("/bundle")
|
||||
@@ -68,31 +84,33 @@ def api_manifest():
|
||||
def api_bundle():
|
||||
"""大屏页一次拿齐:全量 daily + 窗口 dims/top/records。"""
|
||||
frm, to = _win()
|
||||
return jsonify(query.bundle(db.get_db(), frm, to, top_n=_int("topN", query.DEFAULT_TOP_N, 1, 1000)))
|
||||
return jsonify(query.bundle(db.get_db(), _uid(), frm, to,
|
||||
top_n=_int("topN", query.DEFAULT_TOP_N, 1, 1000)))
|
||||
|
||||
|
||||
@bp.get("/summary")
|
||||
@login_required
|
||||
def api_summary():
|
||||
conn = db.get_db()
|
||||
uid = _uid()
|
||||
frm, to = _win()
|
||||
if not frm or not to:
|
||||
t = query.totals(conn)
|
||||
t = query.totals(conn, uid)
|
||||
frm, to = t["firstDay"], t["lastDay"]
|
||||
return jsonify(query.summary(conn, frm, to))
|
||||
return jsonify(query.summary(conn, uid, frm, to))
|
||||
|
||||
|
||||
@bp.get("/daily")
|
||||
@login_required
|
||||
def api_daily():
|
||||
return jsonify({"days": query.daily(db.get_db(), *_win())})
|
||||
return jsonify({"days": query.daily(db.get_db(), _uid(), *_win())})
|
||||
|
||||
|
||||
@bp.get("/dims")
|
||||
@login_required
|
||||
def api_dims():
|
||||
conn = db.get_db()
|
||||
d = query.dims(conn, *_win())
|
||||
d = query.dims(conn, _uid(), *_win())
|
||||
dim = _arg("dim")
|
||||
if dim in d:
|
||||
return jsonify({dim: d[dim]})
|
||||
@@ -103,17 +121,18 @@ def api_dims():
|
||||
@login_required
|
||||
def api_top():
|
||||
conn = db.get_db()
|
||||
return jsonify(query.top(conn, *_win(), n=_int("n", 50, 1, 1000)))
|
||||
return jsonify(query.top(conn, _uid(), *_win(), n=_int("n", 50, 1, 1000)))
|
||||
|
||||
|
||||
@bp.get("/records")
|
||||
@login_required
|
||||
def api_records():
|
||||
conn = db.get_db()
|
||||
uid = _uid()
|
||||
frm, to = _win()
|
||||
page = _int("page", 1, 1, 100000)
|
||||
size = _int("size", 50, 1, 500)
|
||||
r = query.records_page(conn, frm, to, model=_arg("model"), client=_arg("client"),
|
||||
r = query.records_page(conn, uid, frm, to, model=_arg("model"), client=_arg("client"),
|
||||
q=_arg("q"), page=page, size=size, order=_arg("order", "ts_desc"),
|
||||
with_prompt=False if _arg("lean") == "1" else True)
|
||||
return jsonify(r)
|
||||
@@ -122,8 +141,9 @@ def api_records():
|
||||
@bp.get("/records/<request_id>")
|
||||
@login_required
|
||||
def api_record(request_id):
|
||||
row = db.get_db().execute(
|
||||
"SELECT * FROM usage_records WHERE request_id=?", (request_id,)).fetchone()
|
||||
# user_id 必须进 WHERE:否则改一个 URL 就能读到别人的 Prompt 全文
|
||||
row = db.get_db().execute("SELECT * FROM usage_records WHERE user_id=? AND request_id=?",
|
||||
(_uid(), request_id)).fetchone()
|
||||
if row is None:
|
||||
return jsonify({"ok": False, "message": "记录不存在"}), 404
|
||||
return jsonify(dict(row))
|
||||
@@ -135,14 +155,16 @@ def api_runs():
|
||||
conn = db.get_db()
|
||||
rows = conn.execute("SELECT id,trigger,status,started_at,finished_at,duration_ms,win_from,"
|
||||
"win_to,fetched,added,dup,total,conflicts,exit_code,message"
|
||||
" FROM collect_runs ORDER BY id DESC LIMIT ?", (_int("limit", 50, 1, 500),))
|
||||
" FROM collect_runs WHERE user_id=? ORDER BY id DESC LIMIT ?",
|
||||
(_uid(), _int("limit", 50, 1, 500)))
|
||||
return jsonify({"items": [dict(r) for r in rows]})
|
||||
|
||||
|
||||
@bp.get("/runs/<int:run_id>")
|
||||
@login_required
|
||||
def api_run(run_id):
|
||||
row = db.get_db().execute("SELECT * FROM collect_runs WHERE id=?", (run_id,)).fetchone()
|
||||
row = db.get_db().execute("SELECT * FROM collect_runs WHERE id=? AND user_id=?",
|
||||
(run_id, _uid())).fetchone()
|
||||
if row is None:
|
||||
return jsonify({"ok": False, "message": "运行记录不存在"}), 404
|
||||
return jsonify(dict(row))
|
||||
@@ -152,23 +174,30 @@ def api_run(run_id):
|
||||
@login_required
|
||||
def api_status():
|
||||
conn = db.get_db()
|
||||
uid = _uid()
|
||||
sch = scheduler.get_scheduler()
|
||||
nxt = scheduler.next_run_at(conn)
|
||||
last = conn.execute("SELECT * FROM collect_runs ORDER BY id DESC LIMIT 1").fetchone()
|
||||
running = conn.execute("SELECT COUNT(*) FROM collect_runs WHERE status='running'").fetchone()[0]
|
||||
nxt = scheduler.next_run_at(conn, uid)
|
||||
last = conn.execute("SELECT * FROM collect_runs WHERE user_id=? ORDER BY id DESC LIMIT 1",
|
||||
(uid,)).fetchone()
|
||||
running = conn.execute("SELECT COUNT(*) FROM collect_runs WHERE user_id=? AND status='running'",
|
||||
(uid,)).fetchone()[0]
|
||||
cred = db.secret_state(conn, "cookie", uid)
|
||||
return jsonify({
|
||||
"server_time": db.now_str(),
|
||||
"scheduler": {
|
||||
"running": sch.running,
|
||||
"enabled": db.get_bool(conn, "schedule_enabled", True),
|
||||
"times": scheduler.slots(conn),
|
||||
"enabled": db.get_bool(conn, "schedule_enabled", True, uid),
|
||||
"times": scheduler.slots(conn, uid),
|
||||
"next_run": nxt.strftime("%Y-%m-%d %H:%M:%S") if nxt else None,
|
||||
"catch_up": db.get_bool(conn, "catch_up", True),
|
||||
"catch_up": db.get_bool(conn, "catch_up", True, uid),
|
||||
"lockfile": os.path.exists(collect.LOCK_PATH),
|
||||
},
|
||||
"running_runs": running,
|
||||
"last_run": dict(last) if last else None,
|
||||
"cookie_set": bool((db.get_setting(conn, "cookie") or "").strip()),
|
||||
# 只回「有没有配」与字符数,绝不回凭证内容
|
||||
"cookie_set": bool(cred["set"] and not cred["broken"]),
|
||||
"cookie_chars": cred["chars"],
|
||||
"cookie_broken": cred["broken"],
|
||||
})
|
||||
|
||||
|
||||
@@ -176,9 +205,8 @@ def api_status():
|
||||
@login_required
|
||||
def api_collect():
|
||||
"""手动触发一次采集(后台线程之外同步执行,页面等待结果)。"""
|
||||
body = request.get_json(silent=True) or {}
|
||||
if not isinstance(body, dict):
|
||||
return jsonify({"ok": False, "message": "请求体必须是对象"}), 400
|
||||
u = current_user()
|
||||
body = _json_body()
|
||||
frm, to = body.get("from"), body.get("to")
|
||||
try:
|
||||
kw = {}
|
||||
@@ -194,40 +222,55 @@ def api_collect():
|
||||
kw["to_dt"] = datetime.strptime(d, "%Y-%m-%d").replace(hour=23, minute=59, second=59)
|
||||
if kw.get("from_dt") and kw.get("to_dt") and kw["from_dt"] > kw["to_dt"]:
|
||||
raise BadParam("起始日期不能晚于结束日期")
|
||||
r = collect.run_sync(trigger="manual", **kw)
|
||||
r = collect.run_sync(trigger="manual", uid=u["id"], **kw)
|
||||
except BadParam as e:
|
||||
return jsonify({"ok": False, "error": "bad_request", "message": str(e)}), 400
|
||||
except collect.Busy as e:
|
||||
return jsonify({"ok": False, "error": "busy", "message": str(e)}), 409
|
||||
except collect.NotReady as e:
|
||||
return jsonify({"ok": False, "error": "no_cookie", "message": str(e)}), 409
|
||||
except db.SecretUnreadable as e:
|
||||
return jsonify({"ok": False, "error": "cookie_broken",
|
||||
"message": "已保存的 Cookie 无法解密(实例密钥被更换过):%s。"
|
||||
"请到「配置管理」重新粘贴。" % e}), 409
|
||||
except collect.ApiError as e:
|
||||
code = 401 if e.cookie_expired else 502
|
||||
return jsonify({"ok": False, "error": "cookie_expired" if e.cookie_expired else "api",
|
||||
"message": str(e)}), code
|
||||
except Exception as e: # noqa: BLE001
|
||||
return jsonify({"ok": False, "error": "internal", "message": str(e)}), 500
|
||||
db.audit(db.get_db(), "collect", (current_user() or {}).get("username"), r["message"],
|
||||
request.remote_addr)
|
||||
db.audit(db.get_db(), "collect", u["username"], r["message"], request.remote_addr, u["id"])
|
||||
return jsonify({"ok": True, "result": r})
|
||||
|
||||
|
||||
@bp.get("/audit")
|
||||
@login_required
|
||||
def api_audit():
|
||||
"""操作审计分页(日志管理页用;原来只能看最近 40 条)。"""
|
||||
"""操作审计分页。管理员看全部(便于追责),普通用户只看自己触发的。"""
|
||||
conn = db.get_db()
|
||||
u = current_user()
|
||||
action = _arg("action")
|
||||
page = _int("page", 1, 1, 100000)
|
||||
size = _int("size", 50, 1, 500)
|
||||
w, p = "", []
|
||||
w, p = [], []
|
||||
if not u["is_admin"]:
|
||||
w.append("user_id = ?")
|
||||
p.append(u["id"])
|
||||
if action:
|
||||
w, p = "WHERE action = ?", [action]
|
||||
total = conn.execute("SELECT COUNT(*) FROM audit_log %s" % w, p).fetchone()[0]
|
||||
rows = conn.execute("SELECT * FROM audit_log %s ORDER BY id DESC LIMIT ? OFFSET ?" % w,
|
||||
w.append("action = ?")
|
||||
p.append(action)
|
||||
ws = ("WHERE " + " AND ".join(w)) if w else ""
|
||||
total = conn.execute("SELECT COUNT(*) FROM audit_log %s" % ws, p).fetchone()[0]
|
||||
rows = conn.execute("SELECT * FROM audit_log %s ORDER BY id DESC LIMIT ? OFFSET ?" % ws,
|
||||
p + [size, (page - 1) * size])
|
||||
# 动作清单不带 action 条件,否则只剩下当前那一个动作可选
|
||||
base_p = [u["id"]] if not u["is_admin"] else []
|
||||
base = "WHERE user_id = ?" if not u["is_admin"] else ""
|
||||
actions = [r[0] for r in conn.execute(
|
||||
"SELECT DISTINCT action FROM audit_log ORDER BY action")]
|
||||
"SELECT DISTINCT action FROM audit_log %s ORDER BY action" % base, base_p)]
|
||||
return jsonify({"total": total, "page": page, "size": size,
|
||||
"pages": max(1, (total + size - 1) // size),
|
||||
"scope": "all" if u["is_admin"] else "self",
|
||||
"actions": actions,
|
||||
"items": [dict(r) for r in rows]})
|
||||
|
||||
@@ -236,13 +279,26 @@ def api_audit():
|
||||
@bp.post("/maintenance/<action>")
|
||||
@login_required
|
||||
def api_maintenance(action):
|
||||
"""把 CLI 里的维护动作搬到页面上:补全 prompt / VACUUM / 导出 CSV。"""
|
||||
"""把 CLI 里的维护动作搬到页面上。
|
||||
|
||||
只有 `vacuum` 是**实例级**动作(整个库一起整理),所以它仅管理员可用;
|
||||
其余三个都只作用于当前账号自己的数据。
|
||||
"""
|
||||
conn = db.get_db()
|
||||
user = (current_user() or {}).get("username")
|
||||
u = current_user()
|
||||
uid = u["id"]
|
||||
if action == "vacuum" and not u["is_admin"]:
|
||||
return jsonify({"ok": False, "error": "forbidden",
|
||||
"message": "数据库整理是整库操作,仅管理员可执行"}), 403
|
||||
try:
|
||||
if action == "fill-prompt":
|
||||
try:
|
||||
n = collect.fill_prompt(conn, log=lambda m: None)
|
||||
n = collect.fill_prompt(conn, uid, log=lambda m: None)
|
||||
except collect.NotReady as e:
|
||||
return jsonify({"ok": False, "error": "no_cookie", "message": str(e)}), 409
|
||||
except db.SecretUnreadable as e:
|
||||
return jsonify({"ok": False, "error": "cookie_broken",
|
||||
"message": "已保存的 Cookie 无法解密,请重新粘贴:%s" % e}), 409
|
||||
except collect.ApiError as e:
|
||||
return jsonify({"ok": False, "error": "api", "message": str(e)}), 502
|
||||
msg = "补全 %d 条 User Prompt" % n
|
||||
@@ -250,19 +306,20 @@ def api_maintenance(action):
|
||||
before = os.path.getsize(config.SQLITE_PATH) if os.path.exists(config.SQLITE_PATH) else 0
|
||||
conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
||||
conn.execute("VACUUM")
|
||||
conn.execute("PRAGMA optimize")
|
||||
after = os.path.getsize(config.SQLITE_PATH) if os.path.exists(config.SQLITE_PATH) else 0
|
||||
msg = "数据库整理完成:%s → %s" % (_human(before), _human(after))
|
||||
elif action == "export-csv":
|
||||
path, n = collect.export_csv(conn)
|
||||
path, n = collect.export_csv(conn, uid, username=u["username"])
|
||||
msg = "已导出 %d 条到 %s" % (n, os.path.relpath(path, config.BASE_DIR))
|
||||
elif action == "recount":
|
||||
n = collect.record_count(conn)
|
||||
n = collect.record_count(conn, uid)
|
||||
msg = "存档当前 %d 条记录" % n
|
||||
else:
|
||||
return jsonify({"ok": False, "error": "unknown", "message": "未知维护动作"}), 404
|
||||
except Exception as e: # noqa: BLE001
|
||||
return jsonify({"ok": False, "error": "internal", "message": str(e)}), 500
|
||||
db.audit(conn, "maintenance:" + action, user, msg, request.remote_addr)
|
||||
db.audit(conn, "maintenance:" + action, u["username"], msg, request.remote_addr, uid)
|
||||
return jsonify({"ok": True, "message": msg})
|
||||
|
||||
|
||||
@@ -276,16 +333,18 @@ def _human(n):
|
||||
@bp.get("/settings")
|
||||
@login_required
|
||||
def api_settings_get():
|
||||
"""当前账号的**有效配置**(不含任何凭证明文)。"""
|
||||
conn = db.get_db()
|
||||
s = db.get_settings(conn)
|
||||
if (s.get("cookie") or "").strip():
|
||||
s["cookie_hint"] = "%d 字符,…%s" % (len(s["cookie"]), s["cookie"][-12:])
|
||||
else:
|
||||
s["cookie_hint"] = ""
|
||||
s.pop("cookie", None) # 不回传明文凭证
|
||||
u = current_user()
|
||||
s = db.get_settings(conn, uid=u["id"])
|
||||
st = db.secret_state(conn, "cookie", u["id"])
|
||||
s["cookie_hint"] = ("%d 字符,…%s" % (st["chars"], st["tail"])) if st["set"] else ""
|
||||
s["cookie_broken"] = st["broken"]
|
||||
# 内部簿记键(slot:09:00 这类调度槽位标记)不属于配置项,绝不外泄
|
||||
for k in [k for k in list(s) if config.is_internal_key(k)]:
|
||||
s.pop(k, None)
|
||||
s["_globalKeys"] = sorted(config.GLOBAL_KEYS)
|
||||
s["_canEditGlobal"] = bool(u["is_admin"])
|
||||
return jsonify(s)
|
||||
|
||||
|
||||
@@ -293,137 +352,200 @@ def api_settings_get():
|
||||
@login_required
|
||||
def api_settings_post():
|
||||
conn = db.get_db()
|
||||
body = request.get_json(silent=True) or {}
|
||||
if not isinstance(body, dict):
|
||||
return jsonify({"ok": False, "message": "请求体必须是对象"}), 400
|
||||
changed, errors, ignored = [], [], []
|
||||
u = current_user()
|
||||
uid = u["id"]
|
||||
body = _json_body()
|
||||
changed, errors, ignored, denied = [], [], [], []
|
||||
for k, v in body.items():
|
||||
if config.is_internal_key(k):
|
||||
ignored.append(k)
|
||||
continue # slot:* 是调度簿记,不允许前台写
|
||||
if k not in config.DEFAULTS:
|
||||
errors.append("未知配置项:%s" % k)
|
||||
continue
|
||||
if config.is_global_key(k) and not u["is_admin"]:
|
||||
# 实例级配置(接口基址、注册开关)只有管理员能改 ——
|
||||
# 否则任意注册用户都能把大家的数据采集指向别的服务器
|
||||
denied.append(k)
|
||||
continue
|
||||
if k == "cookie":
|
||||
if not str(v).strip():
|
||||
raw = str(v).strip()
|
||||
if not raw:
|
||||
continue # 空值不动,避免误清
|
||||
if str(v).strip().lower() in ("__clear__", "-"):
|
||||
db.set_setting(conn, "cookie", "")
|
||||
if raw.lower() in ("__clear__", "-"):
|
||||
db.set_secret(conn, "cookie", "", uid)
|
||||
changed.append(k)
|
||||
continue
|
||||
val, err = config.normalize_setting(k, v)
|
||||
if err:
|
||||
errors.append(err)
|
||||
continue
|
||||
db.set_setting(conn, k, val)
|
||||
if k in config.ENCRYPTED_KEYS:
|
||||
db.set_secret(conn, k, val, uid)
|
||||
else:
|
||||
db.set_setting(conn, k, val, uid)
|
||||
changed.append(k)
|
||||
if denied:
|
||||
errors.append("以下为实例级配置,仅管理员可修改:%s" % "、".join(sorted(denied)))
|
||||
if errors:
|
||||
db.audit(conn, "settings_rejected", (current_user() or {}).get("username"),
|
||||
";".join(errors)[:500], request.remote_addr)
|
||||
db.audit(conn, "settings_rejected", u["username"], ";".join(errors)[:500],
|
||||
request.remote_addr, uid)
|
||||
return jsonify({"ok": False, "error": "invalid", "message": ";".join(errors),
|
||||
"errors": errors, "changed": sorted(changed)}), 400
|
||||
# 调整调度配置后清掉槽位标记,让新时刻立即生效
|
||||
# 调整调度配置后清掉槽位标记,让新时刻立即生效(只清自己的)
|
||||
if {"schedule_times", "schedule_enabled"} & set(changed):
|
||||
conn.execute("DELETE FROM settings WHERE key LIKE ?", (scheduler.SLOT_PREFIX + "%",))
|
||||
db.audit(conn, "settings", (current_user() or {}).get("username"),
|
||||
"修改:" + (",".join(sorted(changed)) or "(无变化)"), request.remote_addr)
|
||||
conn.execute("DELETE FROM settings WHERE user_id=? AND key LIKE ?",
|
||||
(uid, scheduler.SLOT_PREFIX + "%"))
|
||||
db.audit(conn, "settings", u["username"],
|
||||
"修改:" + (",".join(sorted(changed)) or "(无变化)"), request.remote_addr, uid)
|
||||
return jsonify({"ok": True, "changed": sorted(changed), "ignored": sorted(ignored)})
|
||||
|
||||
|
||||
@bp.post("/password")
|
||||
@login_required
|
||||
def api_password():
|
||||
from ..security import hash_password, verify_password
|
||||
conn = db.get_db()
|
||||
body = request.get_json(silent=True) or {}
|
||||
u = current_user()
|
||||
body = _json_body()
|
||||
row = conn.execute("SELECT * FROM users WHERE id=?", (u["id"],)).fetchone()
|
||||
if row is None or not verify_password(row["password_hash"], body.get("old") or ""):
|
||||
if row is None or not security.verify_password(row["password_hash"], body.get("old") or ""):
|
||||
return jsonify({"ok": False, "message": "原密码不正确"}), 400
|
||||
new = (body.get("new") or "").strip()
|
||||
err = _check_password(new, body.get("new2"))
|
||||
err = security.password_problem(new, body.get("new2"), u["username"])
|
||||
if err:
|
||||
return jsonify({"ok": False, "message": err}), 400
|
||||
conn.execute("UPDATE users SET password_hash=? WHERE id=?", (hash_password(new), u["id"]))
|
||||
db.audit(conn, "password", u["username"], "修改登录密码", request.remote_addr)
|
||||
conn.execute("UPDATE users SET password_hash=? WHERE id=?",
|
||||
(security.hash_password(new), u["id"]))
|
||||
db.audit(conn, "password", u["username"], "修改登录密码", request.remote_addr, u["id"])
|
||||
return jsonify({"ok": True, "message": "密码已更新"})
|
||||
|
||||
|
||||
# ---------------- 用户管理(原来只有 CLI passwd) ----------------
|
||||
def _check_password(new, new2=None):
|
||||
if len(new or "") < 6:
|
||||
return "密码至少 6 位"
|
||||
if len(new) > 128:
|
||||
return "密码过长(上限 128 位)"
|
||||
if new2 is not None and new2 != new:
|
||||
return "两次输入的新密码不一致"
|
||||
return None
|
||||
@bp.post("/profile")
|
||||
@login_required
|
||||
def api_profile():
|
||||
"""自助修改个人资料(显示名 / 邮箱)。用户名不可改 —— 它是审计里的主键。"""
|
||||
conn = db.get_db()
|
||||
u = current_user()
|
||||
body = _json_body()
|
||||
changed = []
|
||||
if "display_name" in body:
|
||||
name = (body.get("display_name") or "").strip()[:64] or u["username"]
|
||||
conn.execute("UPDATE users SET display_name=? WHERE id=?", (name, u["id"]))
|
||||
changed.append("显示名")
|
||||
if "email" in body:
|
||||
email = (body.get("email") or "").strip()[:config.PROFILE_EMAIL_MAX]
|
||||
if email and ("@" not in email or " " in email):
|
||||
return jsonify({"ok": False, "message": "邮箱格式不正确"}), 400
|
||||
conn.execute("UPDATE users SET email=? WHERE id=?", (email or None, u["id"]))
|
||||
changed.append("邮箱")
|
||||
if not changed:
|
||||
return jsonify({"ok": False, "message": "没有要修改的内容"}), 400
|
||||
db.audit(conn, "profile", u["username"], "修改:" + "、".join(changed),
|
||||
request.remote_addr, u["id"])
|
||||
return jsonify({"ok": True, "message": "已更新:" + "、".join(changed)})
|
||||
|
||||
|
||||
# ---------------- 用户管理(管理员) ----------------
|
||||
def _user_public(r):
|
||||
"""用户行 -> 可下发结构。**绝不包含口令散列,也不包含任何凭证。**"""
|
||||
return {"id": r["id"], "username": r["username"], "display_name": r["display_name"],
|
||||
"email": r["email"], "is_admin": bool(r["is_admin"]),
|
||||
"status": r["status"] or "active", "created_at": r["created_at"],
|
||||
"register_ip": r["register_ip"], "last_login_at": r["last_login_at"],
|
||||
"last_login_ip": r["last_login_ip"], "login_count": r["login_count"]}
|
||||
|
||||
|
||||
@bp.get("/users")
|
||||
@admin_required
|
||||
def api_users():
|
||||
rows = db.get_db().execute(
|
||||
"SELECT id,username,display_name,is_admin,created_at,last_login_at,login_count"
|
||||
" FROM users ORDER BY id").fetchall()
|
||||
return jsonify({"items": [dict(r) for r in rows]})
|
||||
rows = db.get_db().execute("SELECT * FROM users ORDER BY id").fetchall()
|
||||
return jsonify({"items": [_user_public(r) for r in rows]})
|
||||
|
||||
|
||||
@bp.post("/users")
|
||||
@admin_required
|
||||
def api_user_create():
|
||||
from ..security import hash_password
|
||||
conn = db.get_db()
|
||||
body = request.get_json(silent=True) or {}
|
||||
me = current_user()
|
||||
body = _json_body()
|
||||
name = (body.get("username") or "").strip()
|
||||
pwd = (body.get("password") or "").strip()
|
||||
if not name or len(name) > 32:
|
||||
return jsonify({"ok": False, "message": "用户名必填且不超过 32 字符"}), 400
|
||||
err = _check_password(pwd, body.get("password2"))
|
||||
err = security.username_problem(name) or security.password_problem(
|
||||
pwd, body.get("password2"), name)
|
||||
if err:
|
||||
return jsonify({"ok": False, "message": err}), 400
|
||||
exist = conn.execute("SELECT id FROM users WHERE username=?", (name,)).fetchone()
|
||||
if exist:
|
||||
if db.user_by_name(conn, name):
|
||||
return jsonify({"ok": False, "message": "用户名已存在"}), 400
|
||||
conn.execute("INSERT INTO users(username,password_hash,display_name,is_admin,created_at)"
|
||||
" VALUES(?,?,?,?,?)",
|
||||
(name, hash_password(pwd), (body.get("display_name") or name).strip()[:64],
|
||||
1 if str(body.get("is_admin", "1")) in ("1", "true", "on") else 0,
|
||||
db.now_str()))
|
||||
db.audit(conn, "user_create", (current_user() or {}).get("username"), "新建用户 " + name,
|
||||
request.remote_addr)
|
||||
return jsonify({"ok": True, "message": "已创建用户 " + name})
|
||||
# 默认建**普通账号**:多用户系统里「默认给管理员」是最常见的越权起点
|
||||
adm = 1 if str(body.get("is_admin", "0")) in ("1", "true", "on") else 0
|
||||
cur = conn.execute(
|
||||
"INSERT INTO users(username,password_hash,display_name,email,is_admin,status,created_at)"
|
||||
" VALUES(?,?,?,?,?, 'active', ?)",
|
||||
(name, security.hash_password(pwd),
|
||||
(body.get("display_name") or name).strip()[:64],
|
||||
(body.get("email") or "").strip()[:128] or None, adm, db.now_str()))
|
||||
db.audit(conn, "user_create", me["username"],
|
||||
"新建用户 %s(%s)" % (name, "管理员" if adm else "普通"), request.remote_addr, me["id"])
|
||||
return jsonify({"ok": True, "message": "已创建用户 %s" % name, "id": cur.lastrowid})
|
||||
|
||||
|
||||
@bp.post("/users/<int:uid>")
|
||||
@admin_required
|
||||
def api_user_update(uid):
|
||||
from ..security import hash_password
|
||||
conn = db.get_db()
|
||||
row = conn.execute("SELECT * FROM users WHERE id=?", (uid,)).fetchone()
|
||||
me = current_user()
|
||||
row = db.user_by_id(conn, uid)
|
||||
if row is None:
|
||||
return jsonify({"ok": False, "message": "用户不存在"}), 404
|
||||
body = request.get_json(silent=True) or {}
|
||||
me = current_user()
|
||||
body = _json_body()
|
||||
changed = []
|
||||
if "display_name" in body:
|
||||
conn.execute("UPDATE users SET display_name=? WHERE id=?",
|
||||
((body.get("display_name") or "").strip()[:64], uid))
|
||||
changed.append("显示名")
|
||||
if "email" in body:
|
||||
email = (body.get("email") or "").strip()[:config.PROFILE_EMAIL_MAX]
|
||||
if email and ("@" not in email or " " in email):
|
||||
return jsonify({"ok": False, "message": "邮箱格式不正确"}), 400
|
||||
conn.execute("UPDATE users SET email=? WHERE id=?", (email or None, uid))
|
||||
changed.append("邮箱")
|
||||
if "is_admin" in body:
|
||||
v = 1 if str(body.get("is_admin")) in ("1", "true", "on") else 0
|
||||
if uid == me["id"] and not v:
|
||||
return jsonify({"ok": False, "message": "不能取消自己的管理员身份"}), 400
|
||||
if not v and row["is_admin"]:
|
||||
left = conn.execute("SELECT COUNT(*) FROM users WHERE is_admin=1 AND status='active'"
|
||||
" AND id<>?", (uid,)).fetchone()[0]
|
||||
if left == 0:
|
||||
return jsonify({"ok": False,
|
||||
"message": "至少要保留一个启用状态的管理员"}), 400
|
||||
conn.execute("UPDATE users SET is_admin=? WHERE id=?", (v, uid))
|
||||
changed.append("管理员")
|
||||
if "status" in body:
|
||||
v = "active" if str(body.get("status")) in ("active", "1", "true", "on") else "disabled"
|
||||
if uid == me["id"] and v != "active":
|
||||
return jsonify({"ok": False, "message": "不能停用自己的账号"}), 400
|
||||
if v != "active":
|
||||
left = conn.execute("SELECT COUNT(*) FROM users WHERE is_admin=1 AND status='active'"
|
||||
" AND id<>?", (uid,)).fetchone()[0]
|
||||
if row["is_admin"] and left == 0:
|
||||
return jsonify({"ok": False,
|
||||
"message": "至少要保留一个启用状态的管理员"}), 400
|
||||
conn.execute("UPDATE users SET status=? WHERE id=?", (v, uid))
|
||||
changed.append("状态→" + ("启用" if v == "active" else "停用"))
|
||||
pwd = (body.get("password") or "").strip()
|
||||
if pwd:
|
||||
err = _check_password(pwd, body.get("password2"))
|
||||
err = security.password_problem(pwd, body.get("password2"), row["username"])
|
||||
if err:
|
||||
return jsonify({"ok": False, "message": err}), 400
|
||||
conn.execute("UPDATE users SET password_hash=? WHERE id=?", (hash_password(pwd), uid))
|
||||
conn.execute("UPDATE users SET password_hash=? WHERE id=?",
|
||||
(security.hash_password(pwd), uid))
|
||||
changed.append("密码")
|
||||
if not changed:
|
||||
return jsonify({"ok": False, "message": "没有要修改的内容"}), 400
|
||||
db.audit(conn, "user_update", me["username"],
|
||||
"修改用户 %s:%s" % (row["username"], "、".join(changed)), request.remote_addr)
|
||||
"修改用户 %s:%s" % (row["username"], "、".join(changed)),
|
||||
request.remote_addr, me["id"])
|
||||
return jsonify({"ok": True, "message": "已更新:" + "、".join(changed)})
|
||||
|
||||
|
||||
@@ -432,15 +554,41 @@ def api_user_update(uid):
|
||||
def api_user_delete(uid):
|
||||
conn = db.get_db()
|
||||
me = current_user()
|
||||
row = conn.execute("SELECT * FROM users WHERE id=?", (uid,)).fetchone()
|
||||
row = db.user_by_id(conn, uid)
|
||||
if row is None:
|
||||
return jsonify({"ok": False, "message": "用户不存在"}), 404
|
||||
if uid == me["id"]:
|
||||
return jsonify({"ok": False, "message": "不能删除当前登录的自己"}), 400
|
||||
n = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
|
||||
if n <= 1:
|
||||
if conn.execute("SELECT COUNT(*) FROM users").fetchone()[0] <= 1:
|
||||
return jsonify({"ok": False, "message": "至少要保留一个账号"}), 400
|
||||
if row["is_admin"]:
|
||||
left = conn.execute("SELECT COUNT(*) FROM users WHERE is_admin=1 AND status='active'"
|
||||
" AND id<>?", (uid,)).fetchone()[0]
|
||||
if left == 0:
|
||||
return jsonify({"ok": False, "message": "至少要保留一个启用状态的管理员"}), 400
|
||||
keep = str(_json_body().get("keep_data", "")).strip() in ("1", "true", "on", "yes")
|
||||
if not keep:
|
||||
# 默认连同数据一起删 —— 留下孤儿数据既占空间,也会在重新注册
|
||||
# 同名用户时被新用户看到(历史遗留 user_id 复用风险)
|
||||
conn.execute("DELETE FROM usage_records WHERE user_id=?", (uid,))
|
||||
conn.execute("DELETE FROM settings WHERE user_id=?", (uid,))
|
||||
conn.execute("DELETE FROM collect_runs WHERE user_id=?", (uid,))
|
||||
conn.execute("DELETE FROM users WHERE id=?", (uid,))
|
||||
db.audit(conn, "user_delete", me["username"], "删除用户 " + row["username"],
|
||||
request.remote_addr)
|
||||
db.audit(conn, "user_delete", me["username"],
|
||||
"删除用户 %s(%s)" % (row["username"], "保留其数据" if keep else "连同数据一并删除"),
|
||||
request.remote_addr, me["id"])
|
||||
return jsonify({"ok": True, "message": "已删除 " + row["username"]})
|
||||
|
||||
|
||||
@bp.post("/captcha")
|
||||
@login_required
|
||||
def api_captcha_note():
|
||||
"""给前端一个「验证码怎么工作」的自述,便于排障时自检。"""
|
||||
conn = db.get_db()
|
||||
return jsonify({
|
||||
"policy": db.get_setting(conn, "captcha_policy", "always"),
|
||||
"length": db.get_int(conn, "captcha_length", 4),
|
||||
"ttl_seconds": 300,
|
||||
"image_url": "/captcha.png",
|
||||
"note": "答案只存在服务端 captchas 表;一次性使用,校验后立即删除。",
|
||||
})
|
||||
|
||||
在新工单中引用
屏蔽一个用户