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 同步
这个提交包含在:
@@ -5,10 +5,12 @@
|
||||
<div class="pagehead">
|
||||
<div>
|
||||
<h1>用户管理</h1>
|
||||
<p class="lead">门户在局域网可访问,因此必须靠账号隔离;这里维护账号、管理员身份与密码</p>
|
||||
<p class="lead">维护账号、状态与管理员身份。单价数据<b>按账号隔离</b>——管理员也看不到别人的用量与凭证</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<span class="tag accent">仅管理员可见</span>
|
||||
<span class="tag {{ 'ok' if allow_register else 'mute' }}">
|
||||
自助注册:{{ '已开放' if allow_register else '已关闭' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -17,21 +19,25 @@
|
||||
<h2>新建账号</h2>
|
||||
<form id="formNewUser">
|
||||
<label class="row"><span>用户名</span>
|
||||
<input name="username" maxlength="32" placeholder="登录名(≤32 字符)" autocomplete="off" spellcheck="false"></label>
|
||||
<input name="username" maxlength="32" placeholder="3~32 位,字母或数字开头"
|
||||
autocomplete="off" spellcheck="false"></label>
|
||||
<label class="row"><span>显示名</span>
|
||||
<input name="display_name" maxlength="64" placeholder="留空则与用户名相同"></label>
|
||||
<label class="row"><span>邮箱</span>
|
||||
<input name="email" type="email" maxlength="128" placeholder="选填"></label>
|
||||
<label class="row"><span>密码</span>
|
||||
<input name="password" type="password" autocomplete="new-password"></label>
|
||||
<label class="row"><span>确认密码</span>
|
||||
<input name="password2" type="password" autocomplete="new-password"></label>
|
||||
<label class="row"><span>权限</span>
|
||||
<select name="is_admin">
|
||||
<option value="1">管理员(可管理用户)</option>
|
||||
<option value="0">普通账号(只读数据与日志)</option>
|
||||
<option value="0">普通账号(只管自己的凭证与数据)</option>
|
||||
<option value="1">管理员(可管理用户与实例设置)</option>
|
||||
</select>
|
||||
</label>
|
||||
<button class="btn primary" type="submit">创建账号</button>
|
||||
<p class="hint">密码至少 6 位、最多 128 位。普通账号不能用本页,也调不动用户管理接口。</p>
|
||||
<p class="hint">密码至少 8 位且需含两类以上字符。新账号默认是<b>普通账号</b>;
|
||||
管理员身份请显式选择。无论哪种身份,都需要各自配置自己的 Cookie 才能采集。</p>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@@ -42,29 +48,39 @@
|
||||
</div>
|
||||
<div class="tablewrap">
|
||||
<table class="tbl" id="userTable">
|
||||
<thead><tr><th class="num">ID</th><th>用户名</th><th>显示名</th><th>权限</th>
|
||||
<th>最后登录</th><th class="num">次数</th><th>操作</th></tr></thead>
|
||||
<thead><tr><th class="num">ID</th><th>用户名</th><th>显示名</th><th>权限 / 状态</th>
|
||||
<th class="num">我的数据</th><th>最后登录</th><th>操作</th></tr></thead>
|
||||
<tbody>
|
||||
{% for u in users %}
|
||||
<tr data-uid="{{ u.id }}" data-name="{{ u.username }}">
|
||||
<tr data-uid="{{ u.id }}" data-name="{{ u.username }}"
|
||||
data-status="{{ u.status or 'active' }}">
|
||||
<td class="num muted">{{ u.id }}</td>
|
||||
<td class="nowrap"><b>{{ u.username }}</b>
|
||||
{% if u.id == me.id %}<span class="tag accent">当前</span>{% endif %}</td>
|
||||
{% if u.id == me.id %}<span class="tag accent">当前</span>{% endif %}
|
||||
{% if u.email %}<br><span class="muted sm">{{ u.email }}</span>{% endif %}</td>
|
||||
<td><input class="inp inp-sm" name="display_name" maxlength="64"
|
||||
value="{{ u.display_name or '' }}" spellcheck="false"></td>
|
||||
<td>
|
||||
{# 不能取消自己的管理员身份,所以本人的下拉直接禁用(服务端也会再拦一次) #}
|
||||
<td class="nowrap">
|
||||
{# 不能取消自己的管理员身份,也不能停用自己(服务端也会再拦一次) #}
|
||||
<select class="inp inp-sm" name="is_admin" {{ 'disabled' if u.id == me.id }}>
|
||||
<option value="1" {{ 'selected' if u.is_admin }}>管理员</option>
|
||||
<option value="0" {{ 'selected' if not u.is_admin }}>普通</option>
|
||||
</select>
|
||||
<select class="inp inp-sm" name="status" {{ 'disabled' if u.id == me.id }}>
|
||||
<option value="active" {{ 'selected' if (u.status or 'active') == 'active' }}>启用</option>
|
||||
<option value="disabled" {{ 'selected' if u.status == 'disabled' }}>停用</option>
|
||||
</select>
|
||||
</td>
|
||||
<td class="mono sm nowrap">{{ u.last_login_at or '—' }}</td>
|
||||
<td class="num">{{ u.login_count }}</td>
|
||||
<td class="num nowrap">{{ '{:,}'.format(u.recs or 0) }} 条
|
||||
<br><span class="muted sm">{{ '%.2f'|format(u.credits or 0) }} 分</span></td>
|
||||
<td class="mono sm nowrap">{{ (u.last_login_at or '—')[:16] }}
|
||||
{% if u.last_login_ip %}<br><span class="muted">{{ u.last_login_ip }}</span>{% endif %}</td>
|
||||
<td class="nowrap">
|
||||
<button class="btn sm" type="button" data-act="save">保存</button>
|
||||
<button class="btn sm ghost" type="button" data-act="pwd">改密</button>
|
||||
{% if u.id != me.id %}
|
||||
<button class="btn sm ghost" type="button" data-act="toggle">
|
||||
{{ '停用' if (u.status or 'active') == 'active' else '启用' }}</button>
|
||||
<button class="btn sm danger" type="button" data-act="del">删除</button>
|
||||
{% endif %}
|
||||
</td>
|
||||
@@ -75,14 +91,19 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="hint">「改密」会依次询问新密码与确认;管理员不能取消自己的管理员身份,任何人也不能删除自己。</p>
|
||||
<p class="hint">
|
||||
「停用」会让该账号<b>立刻</b>失效(每个请求都会校验状态,不必等会话过期),
|
||||
其数据与 Cookie 都保留;「删除」是<b>不可逆</b>的,会连同该账号的用量数据与 Cookie
|
||||
一起删除(接口层另有保留数据的开关,供脚本调用时指定)。
|
||||
管理员不能取消自己的管理员身份、不能停用或删除自己,也不能删掉最后一个启用的管理员。
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="card">
|
||||
<div class="cardhead">
|
||||
<h2>用户操作审计</h2>
|
||||
<span class="hint">最近 20 条</span>
|
||||
<h2>账号操作审计</h2>
|
||||
<span class="hint">最近 20 条(含注册与登录失败)</span>
|
||||
</div>
|
||||
<div class="tablewrap scroll-y">
|
||||
<table class="tbl">
|
||||
@@ -139,20 +160,31 @@
|
||||
if (act === 'save') {
|
||||
var fields = {};
|
||||
fields.display_name = row.querySelector('[name=display_name]').value;
|
||||
var sel = row.querySelector('[name=is_admin]');
|
||||
if (sel && !sel.disabled) fields.is_admin = sel.value;
|
||||
var adm = row.querySelector('[name=is_admin]');
|
||||
if (adm && !adm.disabled) fields.is_admin = adm.value;
|
||||
var st = row.querySelector('[name=status]');
|
||||
if (st && !st.disabled) fields.status = st.value;
|
||||
done(WBU.post('/api/users/' + uid, fields));
|
||||
} else if (act === 'pwd') {
|
||||
var p1 = window.prompt('为用户「' + name + '」设置新密码(至少 6 位)');
|
||||
var p1 = window.prompt('为用户「' + name + '」设置新密码(至少 8 位,需含两类字符)');
|
||||
if (p1 === null) { btn.disabled = false; btn.textContent = old; return; }
|
||||
var p2 = window.prompt('再输入一次新密码以确认');
|
||||
if (p2 === null) { btn.disabled = false; btn.textContent = old; return; }
|
||||
if (p1 !== p2) { WBU.say('两次输入的密码不一致', 'warn'); btn.disabled = false; btn.textContent = old; return; }
|
||||
done(WBU.post('/api/users/' + uid, { password: p1, password2: p2 }));
|
||||
} else if (act === 'del') {
|
||||
if (!window.confirm('确定删除用户「' + name + '」?该操作不可撤销(其历史审计记录会保留)。')) {
|
||||
} else if (act === 'toggle') {
|
||||
var cur = row.dataset.status === 'active';
|
||||
var next = cur ? 'disabled' : 'active';
|
||||
if (!window.confirm(cur ? ('停用「' + name + '」?其数据与 Cookie 会保留,但无法登录。')
|
||||
: ('启用「' + name + '」?'))) {
|
||||
btn.disabled = false; btn.textContent = old; return;
|
||||
}
|
||||
done(WBU.post('/api/users/' + uid, { status: next }));
|
||||
} else if (act === 'del') {
|
||||
var keep = window.confirm('确定删除用户「' + name + '」?\n\n'
|
||||
+ '点「确定」= 连同其用量数据与 Cookie 一起删除(推荐)\n'
|
||||
+ '点「取消」= 取消本次删除');
|
||||
if (!keep) { btn.disabled = false; btn.textContent = old; return; }
|
||||
done(WBU.post('/api/users/' + uid + '/delete', {}));
|
||||
} else {
|
||||
btn.disabled = false; btn.textContent = old;
|
||||
|
||||
在新工单中引用
屏蔽一个用户