文件
workbuddy-portal/workbuddy_portal/web/templates/users.html
T
wangchuanli df7db3582e 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 同步
2026-09-15 17:32:35 +08:00

195 行
9.1 KiB
HTML

{% extends "base.html" %}
{% block title %}用户管理 · {{ project_title }}{% endblock %}
{% block body %}
<div class="pagehead">
<div>
<h1>用户管理</h1>
<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>
<div class="grid2">
<section class="card">
<h2>新建账号</h2>
<form id="formNewUser">
<label class="row"><span>用户名</span>
<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="0">普通账号(只管自己的凭证与数据)</option>
<option value="1">管理员(可管理用户与实例设置)</option>
</select>
</label>
<button class="btn primary" type="submit">创建账号</button>
<p class="hint">密码至少 8 位且需含两类以上字符。新账号默认是<b>普通账号</b>;
管理员身份请显式选择。无论哪种身份,都需要各自配置自己的 Cookie 才能采集。</p>
</form>
</section>
<section class="card">
<div class="cardhead">
<h2>账号列表</h2>
<span class="hint">共 {{ users|length }} 个</span>
</div>
<div class="tablewrap">
<table class="tbl" id="userTable">
<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 }}"
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 %}
{% 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 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="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>
</tr>
{% else %}
<tr><td colspan="7" class="empty">还没有账号</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<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>
</div>
<div class="tablewrap scroll-y">
<table class="tbl">
<thead><tr><th>时间</th><th>操作者</th><th>动作</th><th>说明</th><th>IP</th></tr></thead>
<tbody>
{% for a in audits %}
<tr>
<td class="mono sm nowrap">{{ a.at[5:] if a.at else '' }}</td>
<td>{{ a.actor or '—' }}</td>
<td><span class="tag mute">{{ a.action }}</span></td>
<td>{{ a.detail or '—' }}</td>
<td class="mono sm">{{ a.ip or '' }}</td>
</tr>
{% else %}
<tr><td colspan="5" class="empty">暂无用户操作记录</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
<script>
WBU.bindForm('#formNewUser', '/api/users', {
validate: function (d) {
if (!d.username) return '用户名必填';
return d.password === d.password2 ? null : '两次输入的密码不一致';
}
});
// 行内操作走事件委托:表格动态渲染也不会丢绑定
document.getElementById('userTable').addEventListener('click', function (e) {
var btn = e.target.closest('button[data-act]');
if (!btn) return;
var row = btn.closest('tr'), uid = row.dataset.uid, name = row.dataset.name;
var act = btn.dataset.act;
var old = btn.textContent;
btn.disabled = true;
btn.textContent = '…';
function done(p) {
p.then(function (j) {
if (j.ok === false) { WBU.say(j.message || '操作失败', 'error'); return; }
WBU.say(j.message || '已保存', 'ok');
window.setTimeout(function () { location.reload(); }, 900);
}).catch(function (err) {
if (String(err.message) !== 'unauthorized') WBU.say('请求失败:' + err.message, 'error');
}).finally(function () { btn.disabled = false; btn.textContent = old; });
}
if (act === 'save') {
var fields = {};
fields.display_name = row.querySelector('[name=display_name]').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 + '」设置新密码(至少 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 === '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;
}
});
</script>
{% endblock %}