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 ir_dictionary.json ./ir_dictionary.json
COPY web ./web COPY web ./web
# SQLite 持久化目录(挂卷到此);点位集挂载到 /pointsets只读 # SQLite 持久化目录(挂卷到此);点位集挂载到 /pointsets只读
# 注意镜像里不埋任何口令默认值。STORY_WEB_USERS名字:口令,…)必须运行时注入,
# 未配置时 app 启动即退出(拒绝弱口令裸奔)。
ENV STORY_DB_PATH=/data/story_events.db \ ENV STORY_DB_PATH=/data/story_events.db \
STORY_POINTSETS_DIR=/pointsets \ STORY_POINTSETS_DIR=/pointsets
STORY_WEB_PASSWORD=story
RUN mkdir -p /data /pointsets RUN mkdir -p /data /pointsets
EXPOSE 8787 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-06-story-event-pipeline-design.md`§5.1/§6, D1D4/D8
计划:`docs/plans/2026-06-08-story-event-M5-web-editor-plan.md` 计划:`docs/plans/2026-06-08-story-event-M5-web-editor-plan.md`
少数人凭共享口令在网页里审校/编辑剧情事件 → 静态校验 + 剧本试走(零引擎)→ 一键编译 少数人各凭专属口令(口令即身份,改动自动署名)在网页里审校/编辑剧情事件 → 静态校验 + 剧本试走(零引擎)→ 一键编译
所有 confirmed 事件成 `.events.json` + `.i18n.tsv` 打包下载。校验/编译走 `ir_core`,与 CLI 所有 confirmed 事件成 `.events.json` + `.i18n.tsv` 打包下载。校验/编译走 `ir_core`,与 CLI
`ir_compile.py`)逐字节同口径。 `ir_compile.py`)逐字节同口径。
@ -12,12 +12,14 @@
```bash ```bash
cd tools/event_authoring/web cd tools/event_authoring/web
pip install -r requirements.txt pip install -r requirements.txt
# Windows PowerShell: $env:STORY_WEB_PASSWORD="your-pass" # Windows PowerShell: $env:STORY_WEB_USERS="bia:口令A,ljl:口令B"
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 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 ```bash
cd tools/event_authoring/web 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 # 或不用 compose
# docker build -f web/Dockerfile -t story-event-web .. # 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/web/data:/data" \
# -v "$PWD/Assets/StreamingAssets/Story/PointSets:/pointsets:ro" story-event-web # -v "$PWD/Assets/StreamingAssets/Story/PointSets:/pointsets:ro" story-event-web
``` ```
- **卷**`./data:/data`SQLite 持久化,容器重建不丢事件,**勿删** - **卷**`./data:/data`SQLite 持久化,容器重建不丢事件,**勿删**
`…/PointSets:/pointsets:ro`(开发侧点位集只读;缺失时坐标校验降级为警告)。 `…/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`)、 `STORY_DB_PATH`(默认 `/data/story_events.db`)、`STORY_POINTSETS_DIR`(默认 `/pointsets`)、
可选 `TZ=Asia/Shanghai`(否则 `updated_at` 按 UTC 显示)。 可选 `TZ=Asia/Shanghai`(否则 `updated_at` 按 UTC 显示)。
- **NAS + VPS**NAS 跑容器VPS 用反代/frp/Cloudflare Tunnel 把 8787 映射出去。点位集更新只需 - **NAS + VPS**NAS 跑容器VPS 用反代/frp/Cloudflare Tunnel 把 8787 映射出去。点位集更新只需

View File

@ -1,18 +1,21 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""M5 协作 Web 编辑器后端FastAPI 单文件)。 """M5 协作 Web 编辑器后端FastAPI 单文件)。
少数人 + 共享口令;事件存 SQLite校验/编译走 ir_core与 CLI 同口径)。 少数人,每人一把专属口令(口令即身份);事件存 SQLite校验/编译走 ir_core与 CLI 同口径)。
起服务: 起服务:
pip install -r requirements.txt 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 uvicorn app:app --host 0.0.0.0 --port 8787
浏览器打开 http://<host>:8787 。 浏览器打开 http://<host>:8787 。
""" """
import asyncio
import datetime import datetime
import io import io
import json import json
import os import os
import secrets
import sys import sys
import time
import zipfile import zipfile
from fastapi import FastAPI, Request, Response 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") os.path.join(_PROJ, "Assets", "StreamingAssets", "Story", "PointSets")
_STATIC_DIR = os.path.join(_HERE, "static") _STATIC_DIR = os.path.join(_HERE, "static")
PASSWORD = os.environ.get("STORY_WEB_PASSWORD", "story")
COOKIE = "story_auth" 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.init_db()
db.purge_sessions(time.time())
app = FastAPI(title="Story Event Web Editor (M5)") 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") @app.middleware("http")
async def auth_guard(request: Request, call_next): async def auth_guard(request: Request, call_next):
path = request.url.path path = request.url.path
# 放行登录、静态资源、根 # 放行登录、静态资源、根
if path.startswith("/api/") and path != "/api/login": 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) return JSONResponse({"error": "未授权"}, status_code=401)
request.state.user = user
return await call_next(request) return await call_next(request)
# ---------- 鉴权 ---------- # ---------- 鉴权 ----------
_fail_count = 0 # 连续失败计数(全局节流;内部小工具,无需按 IP 细分)
@app.post("/api/login") @app.post("/api/login")
async def login(request: Request): async def login(request: Request):
global _fail_count
body = await request.json() 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) return JSONResponse({"error": "口令错误"}, status_code=403)
resp = JSONResponse({"ok": True}) _fail_count = 0
resp.set_cookie(COOKIE, PASSWORD, httponly=True, samesite="lax", max_age=30 * 86400) 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 return resp
@app.post("/api/logout") @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 = JSONResponse({"ok": True})
resp.delete_cookie(COOKIE) resp.delete_cookie(COOKIE)
return resp return resp
@ -116,7 +189,7 @@ async def event_detail(group: str):
@app.post("/api/import") @app.post("/api/import")
async def import_events(request: Request): async def import_events(request: Request):
body = await request.json() body = await request.json()
by = body.get("by", "匿名") by = request.state.user # 改动者=会话身份,不信前端自报
items = body.get("events", []) items = body.get("events", [])
if isinstance(items, dict): # 容错:单个 IR if isinstance(items, dict): # 容错:单个 IR
items = [items] items = [items]
@ -136,14 +209,14 @@ async def update_event(group: str, request: Request):
ir = body.get("ir") ir = body.get("ir")
if not ir or ir.get("id") != group: if not ir or ir.get("id") != group:
return JSONResponse({"error": "ir.id 与 group 不一致"}, status_code=400) 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()} return {"ok": True, "updated_at": _now()}
@app.post("/api/events/{group}/status") @app.post("/api/events/{group}/status")
async def change_status(group: str, request: Request): async def change_status(group: str, request: Request):
body = await request.json() 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: if not ok:
return JSONResponse({"error": "事件不存在"}, status_code=404) return JSONResponse({"error": "事件不存在"}, status_code=404)
return {"ok": True} return {"ok": True}

View File

@ -1,8 +1,9 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""M5 Web 编辑器的 SQLite 存储层。 """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 json
import os import os
@ -38,6 +39,43 @@ def init_db(path=None):
notes TEXT 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): def list_events(status=None, path=None):

View File

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

View File

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

View File

@ -25,10 +25,11 @@
function hideLogin() { $("login").style.display = "none"; } function hideLogin() { $("login").style.display = "none"; }
$("login-btn").onclick = async () => { $("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 }) }); 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 (!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(); hideLogin(); init();
}; };
$("login-pass").onkeydown = e => { if (e.key === "Enter") $("login-btn").click(); }; $("login-pass").onkeydown = e => { if (e.key === "Enter") $("login-btn").click(); };

View File

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

View File

@ -90,10 +90,10 @@
const k = n.kind; const k = n.kind;
if (k === "narration") { add("spk", "<b>" + esc(nameOf(n.speaker || "P1")) + "</b>" + esc(n.text)); loc = advance(loc); } 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 === "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 === "move") { add("sys", "〔走位〕" + esc(nameOf(n.actor)) + " → " + esc(n.to || "")); loc = advance(loc); }
else if (k === "anim") { add("sys", "〔动画〕" + nameOf(n.actor) + " " + (n.ani || "")); 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 === "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 === "choice" || k === "choice_once") { renderChoice(n); return; }
else if (k === "random") { renderRandom(n); return; } else if (k === "random") { renderRandom(n); return; }
else if (k === "fight") { renderFight(n); return; } else if (k === "fight") { renderFight(n); return; }