security: 每人一把口令(口令即身份) + 随机会话token + 无配置拒绝启动 + 爆破节流

- STORY_WEB_PASSWORD(默认story) 废弃 → STORY_WEB_USERS=名字1:口令1,名字2:口令2;
  未配置/口令<8位/口令或用户名重复 → 启动即退出,杜绝弱默认口令裸奔
- cookie 不再存口令原文:登录发 secrets.token_urlsafe(32) 随机token,
  会话存 SQLite sessions 表(30天);登出删token;从 USERS 移除某人=吊销其全部会话
- updated_by 改由服务端按会话身份填写,前端自报 by 不再可信;登录框去掉昵称字段
- 登录失败全局递增节流(最多sleep 5s),口令比较用 secrets.compare_digest
- Dockerfile/compose 移除一切口令默认值;compose 未设 STORY_WEB_USERS 直接报错
- 顺手修 playtest.js 走位/动画/out_ref 行未转义的存储型XSS(esc补齐)
This commit is contained in:
2026-06-10 17:34:50 +08:00
parent 0f42fa13f1
commit 90402c4a17
9 changed files with 152 additions and 34 deletions

View File

@ -17,10 +17,11 @@ COPY ir_core ./ir_core
COPY ir_dictionary.json ./ir_dictionary.json
COPY web ./web
# SQLite 持久化目录(挂卷到此);点位集挂载到 /pointsets只读
# SQLite 持久化目录(挂卷到此);点位集挂载到 /pointsets只读
# 注意镜像里不埋任何口令默认值。STORY_WEB_USERS名字:口令,…)必须运行时注入,
# 未配置时 app 启动即退出(拒绝弱口令裸奔)。
ENV STORY_DB_PATH=/data/story_events.db \
STORY_POINTSETS_DIR=/pointsets \
STORY_WEB_PASSWORD=story
STORY_POINTSETS_DIR=/pointsets
RUN mkdir -p /data /pointsets
EXPOSE 8787

View File

@ -3,7 +3,7 @@
设计:`docs/plans/2026-06-06-story-event-pipeline-design.md`§5.1/§6, D1D4/D8
计划:`docs/plans/2026-06-08-story-event-M5-web-editor-plan.md`
少数人凭共享口令在网页里审校/编辑剧情事件 → 静态校验 + 剧本试走(零引擎)→ 一键编译
少数人各凭专属口令(口令即身份,改动自动署名)在网页里审校/编辑剧情事件 → 静态校验 + 剧本试走(零引擎)→ 一键编译
所有 confirmed 事件成 `.events.json` + `.i18n.tsv` 打包下载。校验/编译走 `ir_core`,与 CLI
`ir_compile.py`)逐字节同口径。
@ -12,12 +12,14 @@
```bash
cd tools/event_authoring/web
pip install -r requirements.txt
# Windows PowerShell: $env:STORY_WEB_PASSWORD="your-pass"
set STORY_WEB_PASSWORD=your-pass # 默认 story
# Windows PowerShell: $env:STORY_WEB_USERS="bia:口令A,ljl:口令B"
set STORY_WEB_USERS=bia:口令A,ljl:口令B # 必填;未配置则拒绝启动
uvicorn app:app --host 0.0.0.0 --port 8787
```
浏览器打开 `http://<host>:8787`,输入共享口令进入。
浏览器打开 `http://<host>:8787`,输入自己的专属口令进入(服务端按口令认人,
`updated_by` 自动署名;口令要求 ≥8 位且互不相同cookie 只存随机会话 token 不存口令;
`STORY_WEB_USERS` 移除某人即吊销其所有会话)。
## 用法
@ -43,17 +45,18 @@ uvicorn app:app --host 0.0.0.0 --port 8787
```bash
cd tools/event_authoring/web
STORY_WEB_PASSWORD=your-pass docker compose up -d --build
STORY_WEB_USERS="bia:口令A,ljl:口令B" docker compose up -d --build
# 或不用 compose
# docker build -f web/Dockerfile -t story-event-web ..
# docker run -d -p 8787:8787 -e STORY_WEB_PASSWORD=your-pass \
# docker run -d -p 8787:8787 -e STORY_WEB_USERS="bia:口令A,ljl:口令B" \
# -v "$PWD/web/data:/data" \
# -v "$PWD/Assets/StreamingAssets/Story/PointSets:/pointsets:ro" story-event-web
```
- **卷**`./data:/data`SQLite 持久化,容器重建不丢事件,**勿删**
`…/PointSets:/pointsets:ro`(开发侧点位集只读;缺失时坐标校验降级为警告)。
- **环境变量**`STORY_WEB_PASSWORD`(口令)、`STORY_WEB_PORT`(宿主端口,默认 8787)、
- **环境变量**`STORY_WEB_USERS`(必填,`名字1:口令1,名字2:口令2`,未配置拒绝启动)、
`STORY_WEB_PORT`(宿主端口,默认 8787
`STORY_DB_PATH`(默认 `/data/story_events.db`)、`STORY_POINTSETS_DIR`(默认 `/pointsets`)、
可选 `TZ=Asia/Shanghai`(否则 `updated_at` 按 UTC 显示)。
- **NAS + VPS**NAS 跑容器VPS 用反代/frp/Cloudflare Tunnel 把 8787 映射出去。点位集更新只需

View File

@ -1,18 +1,21 @@
# -*- coding: utf-8 -*-
"""M5 协作 Web 编辑器后端FastAPI 单文件)。
少数人 + 共享口令;事件存 SQLite校验/编译走 ir_core与 CLI 同口径)。
少数人,每人一把专属口令(口令即身份);事件存 SQLite校验/编译走 ir_core与 CLI 同口径)。
起服务:
pip install -r requirements.txt
set STORY_WEB_PASSWORD=your-pass (默认 story)
set STORY_WEB_USERS=bia:口令A,ljl:口令B (未配置则拒绝启动)
uvicorn app:app --host 0.0.0.0 --port 8787
浏览器打开 http://<host>:8787 。
"""
import asyncio
import datetime
import io
import json
import os
import secrets
import sys
import time
import zipfile
from fastapi import FastAPI, Request, Response
@ -33,10 +36,49 @@ _POINTSETS_DIR = os.environ.get("STORY_POINTSETS_DIR") or \
os.path.join(_PROJ, "Assets", "StreamingAssets", "Story", "PointSets")
_STATIC_DIR = os.path.join(_HERE, "static")
PASSWORD = os.environ.get("STORY_WEB_PASSWORD", "story")
COOKIE = "story_auth"
SESSION_DAYS = 30
def _load_users():
"""解析 STORY_WEB_USERS="名字1:口令1,名字2:口令2"。返回 {口令: 名字}。
未配置/格式错/口令过短/口令重复 → 直接拒绝启动(宁可起不来,不可弱口令裸奔)。
口令即身份登录只输口令服务端按口令认人updated_by 由服务端填写。
"""
raw = (os.environ.get("STORY_WEB_USERS") or "").strip()
if not raw:
sys.exit('[story-web] 未配置 STORY_WEB_USERS拒绝启动。'
'格式: STORY_WEB_USERS="bia:口令A,ljl:口令B"每人口令≥8位且互不相同'
'旧的 STORY_WEB_PASSWORD 已废弃)')
users = {}
names = set()
for part in raw.split(","):
part = part.strip()
if not part:
continue
if ":" not in part:
sys.exit('[story-web] STORY_WEB_USERS 条目格式错误(应为 名字:口令): %r' % part)
name, pw = part.split(":", 1)
name, pw = name.strip(), pw.strip()
if not name or not pw:
sys.exit('[story-web] STORY_WEB_USERS 条目名字/口令为空: %r' % part)
if len(pw) < 8:
sys.exit('[story-web] 用户 %s 的口令不足 8 位,拒绝启动' % name)
if pw in users:
sys.exit('[story-web] 用户 %s%s 口令相同(口令即身份,必须唯一)'
% (name, users[pw]))
if name in names:
sys.exit('[story-web] 用户名重复: %s' % name)
users[pw] = name
names.add(name)
return users
USERS = _load_users()
db.init_db()
db.purge_sessions(time.time())
app = FastAPI(title="Story Event Web Editor (M5)")
@ -45,29 +87,60 @@ def _now():
# ---------- 鉴权中间件 ----------
def _session_user(request):
"""cookie 里的随机 token → 会话用户名;过期/不存在/已被移出口令表 → None。"""
tok = request.cookies.get(COOKIE)
if not tok:
return None
user = db.get_session_user(tok, time.time())
if user is not None and user not in USERS.values():
return None # 从 STORY_WEB_USERS 删掉的人,旧 token 立即失效(按人吊销)
return user
@app.middleware("http")
async def auth_guard(request: Request, call_next):
path = request.url.path
# 放行登录、静态资源、根
if path.startswith("/api/") and path != "/api/login":
if request.cookies.get(COOKIE) != PASSWORD:
user = _session_user(request)
if user is None:
return JSONResponse({"error": "未授权"}, status_code=401)
request.state.user = user
return await call_next(request)
# ---------- 鉴权 ----------
_fail_count = 0 # 连续失败计数(全局节流;内部小工具,无需按 IP 细分)
@app.post("/api/login")
async def login(request: Request):
global _fail_count
body = await request.json()
if body.get("password") != PASSWORD:
pw = str(body.get("password") or "")
user = None
for p, n in USERS.items():
if secrets.compare_digest(pw, p): # 常量时间比较
user = n
if user is None:
_fail_count += 1
await asyncio.sleep(min(_fail_count, 5)) # 爆破节流:连错越多等越久
return JSONResponse({"error": "口令错误"}, status_code=403)
resp = JSONResponse({"ok": True})
resp.set_cookie(COOKIE, PASSWORD, httponly=True, samesite="lax", max_age=30 * 86400)
_fail_count = 0
token = secrets.token_urlsafe(32)
db.create_session(token, user, time.time() + SESSION_DAYS * 86400)
resp = JSONResponse({"ok": True, "user": user})
resp.set_cookie(COOKIE, token, httponly=True, samesite="lax",
max_age=SESSION_DAYS * 86400)
return resp
@app.post("/api/logout")
async def logout():
async def logout(request: Request):
tok = request.cookies.get(COOKIE)
if tok:
db.delete_session(tok)
resp = JSONResponse({"ok": True})
resp.delete_cookie(COOKIE)
return resp
@ -116,7 +189,7 @@ async def event_detail(group: str):
@app.post("/api/import")
async def import_events(request: Request):
body = await request.json()
by = body.get("by", "匿名")
by = request.state.user # 改动者=会话身份,不信前端自报
items = body.get("events", [])
if isinstance(items, dict): # 容错:单个 IR
items = [items]
@ -136,14 +209,14 @@ async def update_event(group: str, request: Request):
ir = body.get("ir")
if not ir or ir.get("id") != group:
return JSONResponse({"error": "ir.id 与 group 不一致"}, status_code=400)
db.upsert_event(ir, body.get("by", "匿名"), _now(), notes=body.get("notes"))
db.upsert_event(ir, request.state.user, _now(), notes=body.get("notes"))
return {"ok": True, "updated_at": _now()}
@app.post("/api/events/{group}/status")
async def change_status(group: str, request: Request):
body = await request.json()
ok = db.set_status(group, body.get("status"), body.get("by", "匿名"), _now())
ok = db.set_status(group, body.get("status"), request.state.user, _now())
if not ok:
return JSONResponse({"error": "事件不存在"}, status_code=404)
return {"ok": True}

View File

@ -1,8 +1,9 @@
# -*- coding: utf-8 -*-
"""M5 Web 编辑器的 SQLite 存储层。
表 eventsgroup(PK)/title/theme/status/ir_json/updated_at/updated_by/notes。
表 eventsgroup(PK)/title/theme/status/ir_json/updated_at/updated_by/notes。
末次写入生效(设计接受),不做锁。
表 sessionstoken(PK)/user/expires_at——登录会话cookie 只存随机 token不存口令
"""
import json
import os
@ -38,6 +39,43 @@ def init_db(path=None):
notes TEXT
)"""
)
c.execute(
"""CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
user TEXT NOT NULL,
expires_at REAL NOT NULL
)"""
)
# ---------- 会话 ----------
def create_session(token, user, expires_at, path=None):
with _conn(path) as c:
c.execute("INSERT OR REPLACE INTO sessions (token, user, expires_at) "
"VALUES (?,?,?)", (token, user, expires_at))
def get_session_user(token, now, path=None):
"""token 有效返回用户名;过期则顺手删除并返回 None。"""
with _conn(path) as c:
r = c.execute("SELECT user, expires_at FROM sessions WHERE token=?",
(token,)).fetchone()
if not r:
return None
if r["expires_at"] < now:
c.execute("DELETE FROM sessions WHERE token=?", (token,))
return None
return r["user"]
def delete_session(token, path=None):
with _conn(path) as c:
c.execute("DELETE FROM sessions WHERE token=?", (token,))
def purge_sessions(now, path=None):
with _conn(path) as c:
c.execute("DELETE FROM sessions WHERE expires_at < ?", (now,))
def list_events(status=None, path=None):

View File

@ -1,6 +1,6 @@
# 极空间(x86/amd64) 部署用:导入 story-event-web.tar 后直接引用镜像起服务(不构建)。
# 在极空间 Docker 的「Compose」里新建项目粘贴本文件内容即可。
# 改两处STORY_WEB_PASSWORD口令)、按需放开点位集卷。
# 改两处STORY_WEB_USERS每人口令)、按需放开点位集卷。
services:
story-web:
image: story-event-web:latest # 由 docker save 导出的 tar 导入而来
@ -8,7 +8,9 @@ services:
ports:
- "8787:8787" # 宿主8787 -> 容器8787frpc/反代再对外
environment:
STORY_WEB_PASSWORD: "change-me" # ← 改成你的共享口令
# ← 改成实际口令每人一把口令即身份≥8位且互不相同
# 未配置/口令过短/重复时容器启动即退出(拒绝弱口令裸奔)。
STORY_WEB_USERS: "bia:把我改成口令A,ljl:把我改成口令B"
TZ: "Asia/Shanghai" # 否则 updated_at 按 UTC 显示
volumes:
- ./data:/data # SQLite 持久化(事件数据,勿删)

View File

@ -1,6 +1,6 @@
# Story 事件协作 Web 编辑器M6。NAS 跑容器VPS 端口映射到此。
# cd tools/event_authoring/web
# STORY_WEB_PASSWORD=your-pass docker compose up -d --build
# STORY_WEB_USERS="bia:口令A,ljl:口令B" docker compose up -d --build
services:
story-web:
build:
@ -15,7 +15,8 @@ services:
ports:
- "${STORY_WEB_PORT:-8787}:8787"
environment:
STORY_WEB_PASSWORD: "${STORY_WEB_PASSWORD:-story}"
# 必须显式提供(每人一把口令,口令即身份);未设置时 compose 直接报错
STORY_WEB_USERS: "${STORY_WEB_USERS:?必须设置 STORY_WEB_USERS=名字1:口令1,名字2:口令2}"
volumes:
# SQLite 持久化(事件数据;勿删)
- ./data:/data

View File

@ -25,10 +25,11 @@
function hideLogin() { $("login").style.display = "none"; }
$("login-btn").onclick = async () => {
const pass = $("login-pass").value, name = $("login-name").value.trim();
const pass = $("login-pass").value;
const r = await fetch("/api/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ password: pass }) });
if (!r.ok) { $("login-err").textContent = "口令错误"; return; }
if (name) { App.by = name; localStorage.setItem("story_by", name); }
const d = await r.json(); // 身份由服务端按口令认定(口令即身份)
App.by = d.user || "?"; localStorage.setItem("story_by", App.by);
hideLogin(); init();
};
$("login-pass").onkeydown = e => { if (e.key === "Enter") $("login-btn").click(); };

View File

@ -13,9 +13,8 @@
<div id="login" class="overlay">
<div class="login-box">
<h2>剧情事件协作编辑器</h2>
<p class="hint">输入共享口令进入</p>
<input id="login-pass" type="password" placeholder="共享口令" autocomplete="off">
<input id="login-name" type="text" placeholder="你的昵称(用于记录改动者)">
<p class="hint">输入你的专属口令进入(口令即身份,改动记录自动署名)</p>
<input id="login-pass" type="password" placeholder="专属口令" autocomplete="off">
<button id="login-btn">进入</button>
<div id="login-err" class="err"></div>
</div>

View File

@ -90,10 +90,10 @@
const k = n.kind;
if (k === "narration") { add("spk", "<b>" + esc(nameOf(n.speaker || "P1")) + "</b>" + esc(n.text)); loc = advance(loc); }
else if (k === "dialogue") { add("spk", "<b>" + esc(nameOf(n.speaker)) + "</b>" + esc(n.text)); loc = advance(loc); }
else if (k === "move") { add("sys", "〔走位〕" + nameOf(n.actor) + " → " + (n.to || "")); loc = advance(loc); }
else if (k === "anim") { add("sys", "〔动画〕" + nameOf(n.actor) + " " + (n.ani || "")); loc = advance(loc); }
else if (k === "move") { add("sys", "〔走位〕" + esc(nameOf(n.actor)) + " → " + esc(n.to || "")); loc = advance(loc); }
else if (k === "anim") { add("sys", "〔动画〕" + esc(nameOf(n.actor)) + " " + esc(n.ani || "")); loc = advance(loc); }
else if (k === "reward") { applyGrants(n.grants, "结算"); add("sys", "〔奖励结算〕"); loc = advance(loc); }
else if (k === "out_ref") { add("sys", "〔进入子序列 " + n.ref + ""); loc = enterSeq(n.ref, n.next); }
else if (k === "out_ref") { add("sys", "〔进入子序列 " + esc(n.ref) + ""); loc = enterSeq(n.ref, n.next); }
else if (k === "choice" || k === "choice_once") { renderChoice(n); return; }
else if (k === "random") { renderRandom(n); return; }
else if (k === "fight") { renderFight(n); return; }