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 同步
这个提交包含在:
@@ -0,0 +1,103 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}个人中心 · {{ project_title }}{% endblock %}
|
||||
{% block body %}
|
||||
|
||||
<div class="pagehead">
|
||||
<div>
|
||||
<h1>个人中心</h1>
|
||||
<p class="lead">账号 <b>{{ me.username }}</b> · 注册于 {{ (me.created_at or '')[:16] }} ·
|
||||
最近登录 {{ (me.last_login_at or '未登录')[:19] }}</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<a class="btn" href="{{ url_for('views.config_page') }}">管理我的凭证</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="kpis">
|
||||
<div class="kpi" style="--c:var(--cyan)">
|
||||
<span>我的记录</span><b>{{ '{:,}'.format(my.n or 0) }}</b>
|
||||
<i>{{ my.d0 or '—' }} ~ {{ my.d1 or '—' }}</i>
|
||||
</div>
|
||||
<div class="kpi" style="--c:var(--violet)">
|
||||
<span>我的积分</span><b>{{ '%.2f'|format(my.c or 0) }}</b>
|
||||
<i>仅统计归属本账号的数据</i>
|
||||
</div>
|
||||
<div class="kpi" style="--c:var(--blue)">
|
||||
<span>采集次数</span><b>{{ '{:,}'.format(runs) }}</b>
|
||||
<i>{% if sch.last %}最近 {{ sch.last.started_at[5:16] if sch.last.started_at else '—' }}{% else %}尚无采集{% endif %}</i>
|
||||
</div>
|
||||
<div class="kpi" style="--c:{{ 'var(--green)' if cred.set and not cred.broken else 'var(--red)' }}">
|
||||
<span>我的 Cookie</span>
|
||||
<b>{% if cred.broken %}无法解密{% elif cred.set %}已配置{% else %}未配置{% endif %}</b>
|
||||
<i>{% if cred.set and not cred.broken %}{{ cred.chars }} 字符,结尾 …{{ cred.tail }}
|
||||
{%- elif cred.broken %}实例密钥被更换,请重新粘贴
|
||||
{%- else %}采集需要本人凭证{% endif %}</i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid2">
|
||||
<section class="card">
|
||||
<h2>修改资料</h2>
|
||||
<form id="formProfile">
|
||||
<label class="row"><span>用户名</span>
|
||||
<input value="{{ me.username }}" disabled spellcheck="false">
|
||||
<em class="unit">登录名不可改</em></label>
|
||||
<label class="row"><span>显示名</span>
|
||||
<input name="display_name" maxlength="64" value="{{ me.display_name or '' }}" spellcheck="false"></label>
|
||||
<label class="row"><span>邮箱</span>
|
||||
<input name="email" type="email" maxlength="128" value="{{ me.email or '' }}" spellcheck="false"></label>
|
||||
<button class="btn primary" type="submit">保存资料</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>修改登录密码</h2>
|
||||
<form id="formPwd">
|
||||
<label class="col">原密码<input name="old" type="password" autocomplete="current-password"></label>
|
||||
<label class="col">新密码<input name="new" type="password" autocomplete="new-password"></label>
|
||||
<label class="col">确认新密码<input name="new2" type="password" autocomplete="new-password"></label>
|
||||
<button class="btn primary" type="submit">修改密码</button>
|
||||
<p class="hint">至少 {{ pwd_min }} 位,且需包含大写字母、小写字母、数字、符号中的至少两类。
|
||||
修改成功后当前会话仍有效,不必重新登录。</p>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="card">
|
||||
<div class="cardhead">
|
||||
<h2>我的采集凭证</h2>
|
||||
<span class="tag {{ 'ok' if cred.set and not cred.broken else ('bad' if not cred.set else 'warn') }}">
|
||||
{{ '正常' if cred.set and not cred.broken else ('未配置' if not cred.set else '需要重配') }}</span>
|
||||
</div>
|
||||
<table class="kv">
|
||||
<tr><th>状态</th><td>
|
||||
{% if cred.broken %}<span class="tag bad">已保存但无法解密</span>,请到「配置管理」重新粘贴
|
||||
{% elif cred.set %}<span class="tag ok">已保存(密文入库)</span>
|
||||
{% else %}<span class="tag bad">未配置</span>{% endif %}
|
||||
</td></tr>
|
||||
<tr><th>字符数 / 尾部</th><td class="mono">{{ cred.chars or 0 }} / {{ ('…' + cred.tail) if cred.tail else '—' }}</td></tr>
|
||||
<tr><th>最后更新</th><td class="mono">{{ cred.at or '—' }}</td></tr>
|
||||
<tr><th>调度</th><td>
|
||||
{% if sch.enabled %}{{ sch.times | join(' · ') or '未设置时刻' }}{% else %}<span class="tag bad">已停用</span>{% endif %}
|
||||
{% if sch.next_run %}· 下次 <span class="mono">{{ sch.next_run }}</span>{% endif %}
|
||||
</td></tr>
|
||||
</table>
|
||||
<p class="hint">
|
||||
凭证以密文形式存在数据库里(主密钥在 <code>data/instance.json</code>),
|
||||
页面与接口**任何时候都不回传明文**,只显示长度与尾部 4 位。要更换请到
|
||||
<a href="{{ url_for('views.config_page') }}">配置管理</a>粘贴新的 Cookie 与 User-Agent
|
||||
(两者必须取自同一次浏览器请求)。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
|
||||
<script>
|
||||
WBU.bindForm('#formProfile', '/api/profile');
|
||||
WBU.bindForm('#formPwd', '/api/password', {
|
||||
validate: function (d) { return d.new === d.new2 ? null : '两次输入的新密码不一致'; }
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
在新工单中引用
屏蔽一个用户